Repair: copy legacy joinDate into new user's created_at when creating users from legacy wallz

This commit is contained in:
2026-03-22 09:13:39 +01:00
parent e8b5edf5d2
commit 2608be7420
80 changed files with 3991 additions and 723 deletions

View File

@@ -61,10 +61,12 @@ function buildSearchPreview(item) {
function MessagesPage({ userId, username, activeConversationId: initialId }) {
const [conversations, setConversations] = useState([])
const [unreadTotal, setUnreadTotal] = useState(null)
const [loadingConvs, setLoadingConvs] = useState(true)
const [activeId, setActiveId] = useState(initialId ?? null)
const [realtimeEnabled, setRealtimeEnabled] = useState(false)
const [realtimeStatus, setRealtimeStatus] = useState('offline')
const [onlineUserIds, setOnlineUserIds] = useState([])
const [typingByConversation, setTypingByConversation] = useState({})
const [showNewModal, setShowNewModal] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
@@ -75,6 +77,7 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
try {
const data = await apiFetch('/api/messages/conversations')
setConversations(data.data ?? [])
setUnreadTotal(Number.isFinite(Number(data?.summary?.unread_total)) ? Number(data.summary.unread_total) : null)
} catch (e) {
console.error('Failed to load conversations', e)
} finally {
@@ -173,6 +176,11 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
}
setConversations((prev) => mergeConversationSummary(prev, nextConversation))
const nextUnreadTotal = Number(payload?.summary?.unread_total)
if (Number.isFinite(nextUnreadTotal)) {
setUnreadTotal(nextUnreadTotal)
}
}
channel.listen('.conversation.updated', handleConversationUpdated)
@@ -192,6 +200,79 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
}
}, [realtimeEnabled, userId])
useEffect(() => {
if (!realtimeEnabled || !userId) {
setOnlineUserIds([])
return undefined
}
const echo = getEcho()
if (!echo) {
setOnlineUserIds([])
return undefined
}
const setMembers = (users) => {
const nextIds = (users ?? [])
.map((user) => Number(user?.id))
.filter((id) => Number.isFinite(id) && id !== Number(userId))
setOnlineUserIds(Array.from(new Set(nextIds)))
}
const channel = echo.join('messaging')
channel
.here(setMembers)
.joining((user) => setOnlineUserIds((prev) => (
prev.includes(Number(user?.id)) || Number(user?.id) === Number(userId)
? prev
: [...prev, Number(user.id)]
)))
.leaving((user) => setOnlineUserIds((prev) => prev.filter((id) => id !== Number(user?.id))))
return () => {
echo.leave('messaging')
}
}, [realtimeEnabled, userId])
useEffect(() => {
if (!userId) {
return undefined
}
let intervalId = null
const sendHeartbeat = () => {
if (document.visibilityState === 'hidden') {
return
}
apiFetch('/api/messages/presence/heartbeat', {
method: 'POST',
body: JSON.stringify(activeId ? { conversation_id: activeId } : {}),
}).catch(() => {})
}
const handleVisibilitySync = () => {
if (document.visibilityState === 'visible') {
sendHeartbeat()
}
}
sendHeartbeat()
intervalId = window.setInterval(sendHeartbeat, 25000)
window.addEventListener('focus', sendHeartbeat)
document.addEventListener('visibilitychange', handleVisibilitySync)
return () => {
if (intervalId) {
window.clearInterval(intervalId)
}
window.removeEventListener('focus', sendHeartbeat)
document.removeEventListener('visibilitychange', handleVisibilitySync)
}
}, [activeId, userId])
useEffect(() => {
if (!realtimeEnabled) {
setTypingByConversation({})
@@ -310,12 +391,16 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
history.replaceState(null, '', `/messages/${conv.id}`)
}, [loadConversations])
const handleMarkRead = useCallback((conversationId) => {
const handleMarkRead = useCallback((conversationId, nextUnreadTotal = null) => {
setConversations((prev) => prev.map((conversation) => (
conversation.id === conversationId
? { ...conversation, unread_count: 0 }
: conversation
)))
if (Number.isFinite(Number(nextUnreadTotal))) {
setUnreadTotal(Number(nextUnreadTotal))
}
}, [])
const handleConversationPatched = useCallback((patch) => {
@@ -369,7 +454,9 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
}, [])
const activeConversation = conversations.find((conversation) => conversation.id === activeId) ?? null
const unreadCount = conversations.reduce((sum, conversation) => sum + Number(conversation.unread_count || 0), 0)
const unreadCount = Number.isFinite(Number(unreadTotal))
? Number(unreadTotal)
: conversations.reduce((sum, conversation) => sum + Number(conversation.unread_count || 0), 0)
const pinnedCount = conversations.reduce((sum, conversation) => {
const me = conversation.my_participant ?? conversation.all_participants?.find((participant) => participant.user_id === userId)
return sum + (me?.is_pinned ? 1 : 0)
@@ -475,6 +562,7 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
loading={loadingConvs}
activeId={activeId}
currentUserId={userId}
onlineUserIds={onlineUserIds}
typingByConversation={typingByConversation}
onSelect={handleSelectConversation}
/>
@@ -490,6 +578,7 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
realtimeStatus={realtimeStatus}
currentUserId={userId}
currentUsername={username}
onlineUserIds={onlineUserIds}
apiFetch={apiFetch}
onBack={() => {
setActiveId(null)

View File

@@ -66,10 +66,9 @@ export default function ProfileGallery() {
</div>
</div>
<div className="mx-auto w-full max-w-6xl px-4 pt-6 md:px-6">
<div className="w-full pt-6">
<ProfileGalleryPanel
artworks={artworks}
featuredArtworks={featuredArtworks}
username={username}
/>
</div>

View File

@@ -1,7 +1,6 @@
import React, { useState, useEffect, useCallback } from 'react'
import { usePage } from '@inertiajs/react'
import ProfileHero from '../../components/profile/ProfileHero'
import ProfileStatsRow from '../../components/profile/ProfileStatsRow'
import ProfileTabs from '../../components/profile/ProfileTabs'
import TabArtworks from '../../components/profile/tabs/TabArtworks'
import TabAchievements from '../../components/profile/tabs/TabAchievements'
@@ -13,16 +12,26 @@ import TabActivity from '../../components/profile/tabs/TabActivity'
import TabPosts from '../../components/profile/tabs/TabPosts'
import TabStories from '../../components/profile/tabs/TabStories'
const VALID_TABS = ['artworks', 'stories', 'achievements', 'posts', 'collections', 'about', 'stats', 'favourites', 'activity']
const VALID_TABS = ['posts', 'artworks', 'stories', 'achievements', 'collections', 'about', 'stats', 'favourites', 'activity']
function getInitialTab() {
try {
const sp = new URLSearchParams(window.location.search)
const t = sp.get('tab')
return VALID_TABS.includes(t) ? t : 'artworks'
} catch {
return 'artworks'
function getInitialTab(initialTab = 'posts') {
if (typeof window === 'undefined') {
return VALID_TABS.includes(initialTab) ? initialTab : 'posts'
}
try {
const pathname = window.location.pathname.replace(/\/+$/, '')
const segments = pathname.split('/').filter(Boolean)
const lastSegment = segments.at(-1)
if (VALID_TABS.includes(lastSegment)) {
return lastSegment
}
} catch {
return VALID_TABS.includes(initialTab) ? initialTab : 'posts'
}
return VALID_TABS.includes(initialTab) ? initialTab : 'posts'
}
/**
@@ -52,34 +61,37 @@ export default function ProfileShow() {
countryName,
isOwner,
auth,
initialTab,
profileUrl,
galleryUrl,
profileTabUrls,
} = props
const [activeTab, setActiveTab] = useState(getInitialTab)
const [activeTab, setActiveTab] = useState(() => getInitialTab(initialTab))
const handleTabChange = useCallback((tab) => {
if (!VALID_TABS.includes(tab)) return
setActiveTab(tab)
// Update URL query param without full navigation
try {
const url = new URL(window.location.href)
if (tab === 'artworks') {
url.searchParams.delete('tab')
} else {
url.searchParams.set('tab', tab)
}
window.history.pushState({}, '', url.toString())
} catch (_) {}
}, [])
const currentUrl = new URL(window.location.href)
const targetBase = profileTabUrls?.[tab] || `${profileUrl || `${window.location.origin}`}/${tab}`
const nextUrl = new URL(targetBase, window.location.origin)
const sharedPostId = currentUrl.searchParams.get('post')
if (sharedPostId) {
nextUrl.searchParams.set('post', sharedPostId)
}
window.history.pushState({}, '', nextUrl.toString())
} catch (_) {}
}, [profileTabUrls, profileUrl])
// Handle browser back/forward
useEffect(() => {
const onPop = () => setActiveTab(getInitialTab())
const onPop = () => setActiveTab(getInitialTab(initialTab))
window.addEventListener('popstate', onPop)
return () => window.removeEventListener('popstate', onPop)
}, [])
}, [initialTab])
const isLoggedIn = !!(auth?.user)
@@ -98,9 +110,27 @@ export default function ProfileShow() {
? socialLinks.reduce((acc, l) => { acc[l.platform] = l; return acc }, {})
: (socialLinks ?? {})
const contentShellClassName = activeTab === 'artworks'
? 'w-full px-4 md:px-6'
: activeTab === 'posts'
? 'mx-auto max-w-7xl px-4 md:px-6'
: 'max-w-6xl mx-auto px-4'
return (
<div className="min-h-screen pb-16">
{/* Hero section */}
<div className="relative min-h-screen overflow-hidden pb-16">
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[34rem] opacity-90"
style={{
background: 'radial-gradient(circle at top left, rgba(56,189,248,0.18), transparent 32%), radial-gradient(circle at 82% 10%, rgba(249,115,22,0.16), transparent 28%), linear-gradient(180deg, #07101d 0%, #0a1220 42%, #0a1220 100%)',
}}
/>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 -z-10 opacity-[0.06]"
style={{ backgroundImage: 'url(/gfx/noise.png)', backgroundSize: '180px' }}
/>
<ProfileHero
user={user}
profile={profile}
@@ -121,26 +151,20 @@ export default function ProfileShow() {
) : null}
/>
{/* Stats pills row */}
<ProfileStatsRow
stats={stats}
followerCount={followerCount}
onTabChange={handleTabChange}
/>
<div className="mt-6">
<ProfileTabs
activeTab={activeTab}
onTabChange={handleTabChange}
/>
</div>
{/* Sticky tabs */}
<ProfileTabs
activeTab={activeTab}
onTabChange={handleTabChange}
/>
{/* Tab content area */}
<div className={activeTab === 'artworks' ? 'w-full px-4 md:px-6' : 'max-w-6xl mx-auto px-4'}>
<div className={`${contentShellClassName} pt-6`}>
{activeTab === 'artworks' && (
<TabArtworks
artworks={{ data: artworkList, next_cursor: artworkNextCursor }}
featuredArtworks={featuredArtworks}
username={user.username || user.name}
galleryUrl={galleryUrl}
isActive
/>
)}
@@ -156,6 +180,7 @@ export default function ProfileShow() {
recentFollowers={recentFollowers}
socialLinks={socialLinksObj}
countryName={countryName}
profileUrl={profileUrl}
onTabChange={handleTabChange}
/>
)}
@@ -175,9 +200,16 @@ export default function ProfileShow() {
<TabAbout
user={user}
profile={profile}
stats={stats}
achievements={achievements}
artworks={artworkList}
creatorStories={creatorStories}
profileComments={profileComments}
socialLinks={socialLinksObj}
countryName={countryName}
followerCount={followerCount}
recentFollowers={recentFollowers}
leaderboardRank={leaderboardRank}
/>
)}
{activeTab === 'stats' && (

View File

@@ -43,7 +43,7 @@ export default function PostActions({
}
const handleCopyLink = () => {
const url = `${window.location.origin}/@${post.author.username}?tab=posts&post=${post.id}`
const url = `${window.location.origin}/@${post.author.username}/posts?post=${post.id}`
navigator.clipboard?.writeText(url)
setShareMsg('Link copied!')
setTimeout(() => setShareMsg(null), 2000)

View File

@@ -1,67 +1,28 @@
import React, { useMemo, useState } from 'react'
import React, { useState } from 'react'
const COLLAPSE_AT = 560
function renderMarkdownSafe(text) {
const lines = text.split(/\n{2,}/)
return lines.map((line, lineIndex) => {
const parts = []
let rest = line
let key = 0
const linkPattern = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g
let match = linkPattern.exec(rest)
let lastIndex = 0
while (match) {
if (match.index > lastIndex) {
parts.push(<span key={`txt-${lineIndex}-${key++}`}>{rest.slice(lastIndex, match.index)}</span>)
}
parts.push(
<a
key={`lnk-${lineIndex}-${key++}`}
href={match[2]}
target="_blank"
rel="noopener noreferrer nofollow"
className="text-accent hover:underline"
>
{match[1]}
</a>,
)
lastIndex = match.index + match[0].length
match = linkPattern.exec(rest)
}
if (lastIndex < rest.length) {
parts.push(<span key={`txt-${lineIndex}-${key++}`}>{rest.slice(lastIndex)}</span>)
}
return (
<p key={`p-${lineIndex}`} className="text-sm leading-7 text-white/50">
{parts}
</p>
)
})
}
export default function ArtworkDescription({ artwork }) {
const [expanded, setExpanded] = useState(false)
const content = (artwork?.description || '').trim()
const contentHtml = (artwork?.description_html || '').trim()
const collapsed = content.length > COLLAPSE_AT && !expanded
const visibleText = collapsed ? `${content.slice(0, COLLAPSE_AT)}` : content
// useMemo must always be called (Rules of Hooks) — guard inside the callback
const rendered = useMemo(
() => (content.length > 0 ? renderMarkdownSafe(visibleText) : null),
[content, visibleText],
)
if (content.length === 0) return null
return (
<div>
<div className="max-w-[720px] space-y-3 text-sm leading-7 text-white/50">{rendered}</div>
<div
className={[
'max-w-[720px] overflow-hidden transition-[max-height] duration-300',
collapsed ? 'max-h-[11.5rem]' : 'max-h-[100rem]',
].join(' ')}
>
<div
className="prose prose-invert max-w-none text-sm leading-7 prose-p:my-3 prose-p:text-white/50 prose-a:text-accent prose-a:no-underline hover:prose-a:underline prose-strong:text-white/80 prose-em:text-white/70 prose-code:text-white/80"
dangerouslySetInnerHTML={{ __html: contentHtml }}
/>
</div>
{content.length > COLLAPSE_AT && (
<button

View File

@@ -97,12 +97,32 @@ function slugify(text) {
}
function stripHtml(html) {
const decodeEntities = (value) => {
let decoded = String(value ?? '')
for (let index = 0; index < 4; index += 1) {
if (!decoded.includes('&')) break
if (typeof document !== 'undefined') {
const textarea = document.createElement('textarea')
textarea.innerHTML = decoded
const next = textarea.value
if (next === decoded) break
decoded = next
} else {
break
}
}
return decoded
}
if (typeof document !== 'undefined') {
const div = document.createElement('div')
div.innerHTML = html
div.innerHTML = decodeEntities(html)
return div.textContent || div.innerText || ''
}
return html.replace(/<[^>]*>/g, '')
return decodeEntities(html).replace(/<[^>]*>/g, '')
}
function formatDate(dateStr) {

View File

@@ -1,6 +1,6 @@
import React from 'react'
export default function ConversationList({ conversations, loading, activeId, currentUserId, typingByConversation = {}, onSelect }) {
export default function ConversationList({ conversations, loading, activeId, currentUserId, onlineUserIds = [], typingByConversation = {}, onSelect }) {
return (
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex items-center justify-between border-b border-white/[0.06] px-4 py-3">
@@ -28,6 +28,7 @@ export default function ConversationList({ conversations, loading, activeId, cur
conv={conversation}
isActive={conversation.id === activeId}
currentUserId={currentUserId}
onlineUserIds={onlineUserIds}
typingUsers={typingByConversation[conversation.id] ?? []}
onClick={() => onSelect(conversation.id)}
/>
@@ -37,7 +38,7 @@ export default function ConversationList({ conversations, loading, activeId, cur
)
}
function ConversationRow({ conv, isActive, currentUserId, typingUsers, onClick }) {
function ConversationRow({ conv, isActive, currentUserId, onlineUserIds, typingUsers, onClick }) {
const label = convLabel(conv, currentUserId)
const lastMsg = Array.isArray(conv.latest_message) ? conv.latest_message[0] : conv.latest_message
const preview = typingUsers.length > 0
@@ -45,10 +46,17 @@ function ConversationRow({ conv, isActive, currentUserId, typingUsers, onClick }
: lastMsg?.body ? truncate(lastMsg.body, 88) : 'No messages yet'
const unread = conv.unread_count ?? 0
const myParticipant = conv.all_participants?.find((participant) => participant.user_id === currentUserId)
const otherParticipant = conv.all_participants?.find((participant) => participant.user_id !== currentUserId)
const isArchived = myParticipant?.is_archived ?? false
const isPinned = myParticipant?.is_pinned ?? false
const activeMembers = conv.all_participants?.filter((participant) => !participant.left_at).length ?? 0
const typeLabel = conv.type === 'group' ? `${activeMembers} members` : 'Direct message'
const onlineMembers = conv.type === 'group'
? conv.all_participants?.filter((participant) => participant.user_id !== currentUserId && onlineUserIds.includes(Number(participant.user_id)) && !participant.left_at).length ?? 0
: 0
const isDirectOnline = conv.type === 'direct' && otherParticipant ? onlineUserIds.includes(Number(otherParticipant.user_id)) : false
const typeLabel = conv.type === 'group'
? (onlineMembers > 0 ? `${onlineMembers} online` : `${activeMembers} members`)
: (isDirectOnline ? 'Online now' : 'Direct message')
const senderLabel = lastMsg?.sender?.username ? `@${lastMsg.sender.username}` : null
const initials = label
.split(/\s+/)
@@ -64,8 +72,11 @@ function ConversationRow({ conv, isActive, currentUserId, typingUsers, onClick }
className={`w-full rounded-[24px] border px-4 py-4 text-left transition ${isActive ? 'border-sky-400/28 bg-sky-500/[0.12] shadow-[0_0_0_1px_rgba(56,189,248,0.08)]' : 'border-white/[0.06] bg-white/[0.03] hover:border-white/[0.1] hover:bg-white/[0.05]'} ${isArchived ? 'opacity-65' : ''}`}
>
<div className="flex gap-3">
<div className={`flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl text-sm font-semibold text-white shadow-[0_10px_25px_rgba(0,0,0,0.18)] ${conv.type === 'group' ? 'bg-gradient-to-br from-fuchsia-500 to-violet-600' : 'bg-gradient-to-br from-sky-500 to-cyan-500'}`}>
{initials}
<div className="relative shrink-0">
<div className={`flex h-12 w-12 items-center justify-center rounded-2xl text-sm font-semibold text-white shadow-[0_10px_25px_rgba(0,0,0,0.18)] ${conv.type === 'group' ? 'bg-gradient-to-br from-fuchsia-500 to-violet-600' : 'bg-gradient-to-br from-sky-500 to-cyan-500'}`}>
{initials}
</div>
{isDirectOnline ? <span className="absolute -bottom-0.5 -right-0.5 h-3.5 w-3.5 rounded-full border-2 border-[#0a101a] bg-emerald-300 shadow-[0_0_0_6px_rgba(16,185,129,0.08)]" /> : null}
</div>
<div className="min-w-0 flex-1">

View File

@@ -9,6 +9,7 @@ export default function ConversationThread({
realtimeStatus,
currentUserId,
currentUsername,
onlineUserIds,
apiFetch,
onBack,
onMarkRead,
@@ -65,6 +66,13 @@ export default function ConversationThread({
.map((participant) => participant.user?.username)
.filter(Boolean)
), [currentUserId, participants])
const directParticipant = useMemo(() => (
participants.find((participant) => participant.user_id !== currentUserId) ?? null
), [currentUserId, participants])
const remoteIsOnline = directParticipant ? onlineUserIds.includes(Number(directParticipant.user_id)) : false
const remoteIsViewingConversation = directParticipant
? presenceUsers.some((user) => Number(user?.id) === Number(directParticipant.user_id))
: false
const filteredMessages = useMemo(() => {
const query = threadSearch.trim().toLowerCase()
@@ -185,7 +193,7 @@ export default function ConversationThread({
}
: participant
)))
onMarkRead?.(conversationId)
onMarkRead?.(conversationId, response?.unread_total ?? null)
} catch {
// no-op
}
@@ -309,7 +317,7 @@ export default function ConversationThread({
}
try {
const data = await apiFetch(`/api/messages/${conversationId}?after_id=${encodeURIComponent(lastServerMessage.id)}`)
const data = await apiFetch(`/api/messages/${conversationId}/delta?after_message_id=${encodeURIComponent(lastServerMessage.id)}`)
const incoming = normalizeMessages(data.data ?? [], currentUserId)
if (incoming.length > 0) {
setMessages((prev) => mergeMessageLists(prev, incoming))
@@ -622,9 +630,11 @@ export default function ConversationThread({
}, [apiFetch, conversation?.title, conversationId, draftTitle, patchConversation])
const visibleMessages = filteredMessages
const messagesWithDecorators = useMemo(() => decorateMessages(visibleMessages, currentUserId, myParticipant?.last_read_at ?? null), [visibleMessages, currentUserId, myParticipant?.last_read_at])
const messagesWithDecorators = useMemo(() => decorateMessages(visibleMessages, currentUserId, myParticipant), [visibleMessages, currentUserId, myParticipant])
const typingLabel = buildTypingLabel(typingUsers)
const presenceLabel = presenceUsers.length > 0 ? `${presenceUsers.length} active now` : null
const presenceLabel = conversation?.type === 'group'
? (presenceUsers.length > 0 ? `${presenceUsers.length} active now` : null)
: (remoteIsViewingConversation ? 'Viewing this conversation' : (remoteIsOnline ? 'Online now' : null))
const typingSummary = typingUsers.length > 0
? `${typingLabel} ${conversation?.type === 'group' ? '' : 'Reply will appear here instantly.'}`.trim()
: null
@@ -796,7 +806,7 @@ export default function ConversationThread({
const showAvatar = !previous || previous.sender_id !== message.sender_id
const endsSequence = !next || next.sender_id !== message.sender_id
const seenText = isLastMineMessage(visibleMessages, index, currentUserId)
? buildSeenText(participants, currentUserId)
? buildSeenText(participants, currentUserId, message)
: null
return (
@@ -1037,29 +1047,38 @@ function isLastMineMessage(messages, index, currentUserId) {
return true
}
function buildSeenText(participants, currentUserId) {
const seenBy = participants
.filter((participant) => participant.user_id !== currentUserId && participant.last_read_at)
.map((participant) => participant.user?.username)
.filter(Boolean)
function buildSeenText(participants, currentUserId, message) {
const seenBy = participants.filter((participant) => participant.user_id !== currentUserId && participantHasReadMessage(participant, message))
if (seenBy.length === 0) return 'Sent'
if (seenBy.length === 1) return `Seen by @${seenBy[0]}`
if (seenBy.length === 1) {
const readAt = seenBy[0]?.last_read_at
return readAt ? `Seen ${formatSeenTime(readAt)}` : 'Seen'
}
return `Seen by ${seenBy.length} people`
}
function decorateMessages(messages, currentUserId, lastReadAt) {
function decorateMessages(messages, currentUserId, participant) {
let unreadMarked = false
const lastReadMessageId = Number(participant?.last_read_message_id ?? 0)
const lastReadAt = participant?.last_read_at ?? null
return messages.map((message, index) => {
const previous = messages[index - 1]
const currentDay = dayKey(message.created_at)
const previousDay = previous ? dayKey(previous.created_at) : null
const shouldMarkUnread = !unreadMarked
&& !!lastReadAt
&& message.sender_id !== currentUserId
&& !message.deleted_at
&& new Date(message.created_at).getTime() > new Date(lastReadAt).getTime()
&& (
lastReadMessageId > 0
? Number(message.id) > lastReadMessageId
: lastReadAt
? new Date(message.created_at).getTime() > new Date(lastReadAt).getTime()
: true
)
if (shouldMarkUnread) unreadMarked = true
@@ -1071,6 +1090,26 @@ function decorateMessages(messages, currentUserId, lastReadAt) {
})
}
function participantHasReadMessage(participant, message) {
const lastReadMessageId = Number(participant?.last_read_message_id ?? 0)
if (lastReadMessageId > 0) {
return Number(message?.id ?? 0) > 0 && lastReadMessageId >= Number(message.id)
}
if (participant?.last_read_at && message?.created_at) {
return new Date(participant.last_read_at).getTime() >= new Date(message.created_at).getTime()
}
return false
}
function formatSeenTime(iso) {
return new Date(iso).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})
}
function dayKey(iso) {
const date = new Date(iso)
return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`

View File

@@ -9,46 +9,31 @@ const SORT_OPTIONS = [
{ value: 'favs', label: 'Most Favourited' },
]
function slugify(str) {
return (str || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
}
function FeaturedStrip({ featuredArtworks }) {
if (!featuredArtworks?.length) return null
function GalleryToolbar({ sort, onSort }) {
return (
<div className="mb-7 rounded-2xl border border-white/10 bg-white/[0.02] p-4 md:p-5">
<h2 className="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-widest text-slate-400">
<i className="fa-solid fa-star fa-fw text-amber-400" />
Featured
</h2>
<div className="scrollbar-hide flex snap-x snap-mandatory gap-4 overflow-x-auto pb-2">
{featuredArtworks.slice(0, 5).map((art) => (
<a
key={art.id}
href={`/art/${art.id}/${slugify(art.name)}`}
className="group w-56 shrink-0 snap-start md:w-64"
<div className="mb-5 flex flex-wrap items-center gap-3">
<span className="text-xs font-semibold uppercase tracking-wider text-slate-500">Sort</span>
<div className="flex flex-wrap gap-1 rounded-2xl border border-white/10 bg-white/[0.03] p-1">
{SORT_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => onSort(opt.value)}
className={`rounded-xl px-3.5 py-2 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:bg-white/5 hover:text-white'
}`}
>
<div className="aspect-[5/3] overflow-hidden rounded-xl bg-black/30 ring-1 ring-white/10 transition-all hover:ring-sky-400/40">
<img
src={art.thumb}
alt={art.name}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.03]"
loading="lazy"
/>
</div>
<p className="mt-2 truncate text-sm text-slate-300 transition-colors group-hover:text-white">
{art.name}
</p>
{art.label ? <p className="truncate text-[11px] text-slate-600">{art.label}</p> : null}
</a>
{opt.label}
</button>
))}
</div>
</div>
)
}
export default function ProfileGalleryPanel({ artworks, featuredArtworks, username }) {
export default function ProfileGalleryPanel({ artworks, username }) {
const [sort, setSort] = useState('latest')
const [items, setItems] = useState(artworks?.data ?? artworks ?? [])
const [nextCursor, setNextCursor] = useState(artworks?.next_cursor ?? null)
@@ -74,36 +59,20 @@ export default function ProfileGalleryPanel({ artworks, featuredArtworks, userna
return (
<>
<FeaturedStrip featuredArtworks={featuredArtworks} />
<div className="mb-5 flex flex-wrap items-center gap-3">
<span className="text-xs font-semibold uppercase tracking-wider text-slate-500">Sort</span>
<div className="flex flex-wrap gap-1">
{SORT_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => handleSort(opt.value)}
className={`rounded-lg px-3 py-1.5 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:bg-white/5 hover:text-white'
}`}
>
{opt.label}
</button>
))}
</div>
<div className="mx-auto w-full max-w-6xl px-4 md:px-6">
<GalleryToolbar sort={sort} onSort={handleSort} />
</div>
<MasonryGallery
key={`profile-${username}-${sort}`}
artworks={items}
galleryType="profile"
cursorEndpoint={`/api/profile/${encodeURIComponent(username)}/artworks?sort=${encodeURIComponent(sort)}`}
initialNextCursor={nextCursor}
limit={24}
/>
<div className="w-full px-4 md:px-6 xl:px-8">
<MasonryGallery
key={`profile-${username}-${sort}`}
artworks={items}
galleryType="profile"
cursorEndpoint={`/api/profile/${encodeURIComponent(username)}/artworks?sort=${encodeURIComponent(sort)}`}
initialNextCursor={nextCursor}
limit={24}
/>
</div>
</>
)
}

View File

@@ -4,6 +4,11 @@ import LevelBadge from '../xp/LevelBadge'
import XPProgressBar from '../xp/XPProgressBar'
import FollowButton from '../social/FollowButton'
function formatCompactNumber(value) {
const numeric = Number(value ?? 0)
return numeric.toLocaleString()
}
export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing, followerCount, heroBgUrl, countryName, leaderboardRank, extraActions = null }) {
const [following, setFollowing] = useState(viewerIsFollowing)
const [count, setCount] = useState(followerCount)
@@ -17,26 +22,53 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
? new Date(user.created_at).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
: null
const bio = profile?.bio || profile?.about || ''
const heroFacts = [
{ label: 'Followers', value: formatCompactNumber(count) },
{ label: 'Level', value: `Lv ${formatCompactNumber(user?.level ?? 1)}` },
{ label: 'Progress', value: `${Math.round(Number(user?.progress_percent ?? 0))}%` },
{ label: 'Member since', value: joinDate ?? 'Recently joined' },
]
return (
<>
<div className="max-w-6xl mx-auto px-4 pt-4">
<div className="relative overflow-hidden rounded-2xl border border-white/10">
<div className="relative mx-auto max-w-7xl px-4 pt-4 md:pt-6">
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-10 top-8 -z-10 h-44 rounded-full blur-3xl"
style={{
background: 'linear-gradient(90deg, rgba(56,189,248,0.18), rgba(249,115,22,0.14), rgba(59,130,246,0.12))',
}}
/>
<div className="relative overflow-hidden rounded-[32px] border border-white/10 bg-[#09111f]/80 shadow-[0_24px_80px_rgba(2,6,23,0.55)]">
<div
className="w-full h-[180px] md:h-[220px] xl:h-[252px]"
className="w-full h-[208px] md:h-[248px] xl:h-[288px]"
style={{
background: coverUrl
? `url('${coverUrl}') center ${coverPosition}% / cover no-repeat`
: 'linear-gradient(140deg, #0f1724 0%, #101a2a 45%, #0a1220 100%)',
: 'linear-gradient(140deg, #07101d 0%, #0b1726 42%, #07111e 100%)',
position: 'relative',
}}
>
<div className="absolute left-4 top-4 z-20 flex flex-wrap items-center gap-2 md:left-6 md:top-6">
<span className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-black/30 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-200 backdrop-blur-md">
<span className="h-2 w-2 rounded-full bg-sky-400 shadow-[0_0_12px_rgba(56,189,248,0.9)]" />
Creator profile
</span>
{leaderboardRank?.rank ? (
<span className="inline-flex items-center gap-2 rounded-full border border-amber-300/20 bg-amber-300/10 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-100 backdrop-blur-md">
<i className="fa-solid fa-sparkles text-[10px]" />
Top #{leaderboardRank.rank} this week
</span>
) : null}
</div>
{isOwner ? (
<div className="absolute right-3 top-3 z-20">
<div className="absolute right-4 top-4 z-20 md:right-6 md:top-6">
<button
type="button"
onClick={() => setEditorOpen(true)}
className="inline-flex items-center gap-2 rounded-lg border border-white/20 bg-black/40 px-3 py-2 text-xs font-medium text-white hover:bg-black/60"
className="inline-flex items-center gap-2 rounded-full border border-white/20 bg-black/35 px-3.5 py-2 text-xs font-medium text-white backdrop-blur-md transition-colors hover:bg-black/55"
aria-label="Edit cover image"
>
<i className="fa-solid fa-image" />
@@ -49,148 +81,165 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
className="absolute inset-0"
style={{
background: coverUrl
? 'linear-gradient(to bottom, rgba(0,0,0,0.2), rgba(0,0,0,0.62))'
: 'radial-gradient(ellipse at 16% 40%, rgba(77,163,255,.18) 0%, transparent 60%), radial-gradient(ellipse at 84% 22%, rgba(224,122,33,.12) 0%, transparent 54%)',
? 'linear-gradient(180deg, rgba(2,6,23,0.16) 0%, rgba(2,6,23,0.28) 38%, rgba(2,6,23,0.9) 100%)'
: 'radial-gradient(ellipse at 16% 40%, rgba(77,163,255,.18) 0%, transparent 60%), radial-gradient(ellipse at 84% 22%, rgba(224,122,33,.14) 0%, transparent 54%)',
}}
/>
<div className="absolute inset-0 opacity-[0.06] pointer-events-none" style={{ backgroundImage: 'url(/gfx/noise.png)', backgroundSize: '32px' }} />
</div>
</div>
<div className="relative -mt-14 md:-mt-16 pb-4 px-1">
<div className="flex flex-col gap-4 md:flex-row md:items-end md:gap-5">
<div className="mx-auto z-10 shrink-0 md:mx-0">
<img
src={user.avatar_url || '/default/avatar_default.webp'}
alt={`${uname}'s avatar`}
className="h-[104px] w-[104px] rounded-full border-2 border-white/15 object-cover shadow-[0_0_0_6px_rgba(15,23,36,0.95),0_10px_32px_rgba(0,0,0,0.6)] md:h-[116px] md:w-[116px]"
/>
</div>
<div className="min-w-0 flex-1 text-center md:text-left">
<h1 className="text-[28px] font-bold leading-tight tracking-tight text-white md:text-[34px]">
{displayName}
</h1>
<p className="mt-0.5 font-mono text-sm text-slate-400">@{uname}</p>
<div className="mt-3 flex flex-wrap items-center justify-center gap-2 md:justify-start">
<LevelBadge level={user?.level} rank={user?.rank} />
{leaderboardRank?.rank ? (
<span className="inline-flex items-center gap-2 rounded-full border border-sky-400/25 bg-sky-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.12em] text-sky-100">
Rank #{leaderboardRank.rank} this week
</span>
) : null}
<div className="relative px-4 pb-6 md:px-7 md:pb-7">
<div className="relative -mt-16 flex flex-col gap-5 md:-mt-20 md:flex-row md:items-start md:gap-6">
<div className="mx-auto z-10 shrink-0 md:mx-0">
<img
src={user.avatar_url || '/default/avatar_default.webp'}
alt={`${uname}'s avatar`}
className="h-[112px] w-[112px] rounded-[28px] border border-white/15 bg-[#0b1320] object-cover shadow-[0_0_0_8px_rgba(9,17,31,0.92),0_22px_44px_rgba(2,6,23,0.5)] md:h-[132px] md:w-[132px]"
/>
</div>
<div className="mt-2 flex flex-wrap items-center justify-center gap-2.5 text-xs text-slate-400 md:justify-start">
{countryName ? (
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-2.5 py-1">
{profile?.country_code ? (
<img
src={`/gfx/flags/shiny/24/${encodeURIComponent(String(profile.country_code).toUpperCase())}.png`}
alt={countryName}
className="w-4 h-auto rounded-sm"
onError={(event) => { event.target.style.display = 'none' }}
/>
<div className="min-w-0 flex-1 text-center md:text-left">
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_280px] xl:items-start">
<div className="min-w-0">
<div className="flex flex-wrap items-center justify-center gap-2 md:justify-start">
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-300">
<i className="fa-solid fa-stars text-[10px] text-sky-300" />
Profile spotlight
</span>
</div>
<h1 className="mt-3 text-[30px] font-semibold leading-tight tracking-[-0.03em] text-white md:text-[42px]">
{displayName}
</h1>
<p className="mt-1 font-mono text-sm text-slate-400 md:text-[15px]">@{uname}</p>
<div className="mt-4 flex flex-wrap items-center justify-center gap-2 md:justify-start">
<LevelBadge level={user?.level} rank={user?.rank} />
{countryName ? (
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-slate-300">
{profile?.country_code ? (
<img
src={`/gfx/flags/shiny/24/${encodeURIComponent(String(profile.country_code).toUpperCase())}.png`}
alt={countryName}
className="h-auto w-4 rounded-sm"
onError={(event) => { event.target.style.display = 'none' }}
/>
) : null}
{countryName}
</span>
) : null}
{joinDate ? (
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-slate-300">
<i className="fa-solid fa-calendar-days fa-fw text-slate-500" />
Joined {joinDate}
</span>
) : null}
{profile?.website ? (
<a
href={profile.website.startsWith('http') ? profile.website : `https://${profile.website}`}
target="_blank"
rel="nofollow noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-full border border-sky-400/20 bg-sky-400/10 px-3 py-1.5 text-xs text-sky-200 transition-colors hover:border-sky-300/35 hover:bg-sky-400/15"
>
<i className="fa-solid fa-link fa-fw" />
{(() => {
try {
const url = profile.website.startsWith('http') ? profile.website : `https://${profile.website}`
return new URL(url).hostname
} catch {
return profile.website
}
})()}
</a>
) : null}
</div>
{bio ? (
<p className="mx-auto mt-4 max-w-2xl text-sm leading-relaxed text-slate-300/90 md:mx-0 md:text-[15px]">
{bio}
</p>
) : null}
{countryName}
</span>
) : null}
{joinDate ? (
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-2.5 py-1">
<i className="fa-solid fa-calendar-days fa-fw opacity-70" />
Joined {joinDate}
</span>
) : null}
<XPProgressBar
xp={user?.xp}
currentLevelXp={user?.current_level_xp}
nextLevelXp={user?.next_level_xp}
progressPercent={user?.progress_percent}
maxLevel={user?.max_level}
className="mt-4 max-w-3xl"
/>
</div>
<div className="space-y-3 xl:pt-1">
<div className="flex flex-wrap items-center justify-center gap-2 xl:justify-end">
{extraActions}
{isOwner ? (
<>
<a
href="/dashboard/profile"
className="inline-flex items-center gap-2 rounded-2xl border border-white/15 bg-white/[0.04] px-4 py-2.5 text-sm font-medium text-slate-200 transition-all hover:bg-white/[0.08] hover:text-white"
aria-label="Edit profile"
>
<i className="fa-solid fa-pen fa-fw" />
Edit Profile
</a>
<a
href="/studio"
className="inline-flex items-center gap-2 rounded-2xl bg-gradient-to-r from-sky-500 to-cyan-400 px-4 py-2.5 text-sm font-semibold text-slate-950 shadow-[0_18px_36px_rgba(14,165,233,0.28)] transition-transform hover:-translate-y-0.5"
aria-label="Open Studio"
>
<i className="fa-solid fa-wand-magic-sparkles fa-fw" />
Studio
</a>
</>
) : (
<>
<FollowButton
username={uname}
initialFollowing={following}
initialCount={count}
followingClassName="border border-emerald-400/40 bg-emerald-500/12 text-emerald-300 hover:bg-emerald-500/18"
idleClassName="border border-sky-400/40 bg-sky-500/12 text-sky-200 hover:bg-sky-500/20"
onChange={({ following: nextFollowing, followersCount }) => {
setFollowing(nextFollowing)
setCount(followersCount)
}}
/>
<button
type="button"
onClick={() => {
if (navigator.share) {
navigator.share({ title: `${displayName} on Skinbase`, url: window.location.href })
} else {
navigator.clipboard.writeText(window.location.href)
}
}}
aria-label="Share profile"
className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-2.5 text-sm font-medium text-slate-300 transition-all hover:bg-white/[0.08] hover:text-white"
>
<i className="fa-solid fa-share-nodes fa-fw" />
Share
</button>
</>
)}
</div>
<div className="grid grid-cols-2 gap-2 text-left">
{heroFacts.map((fact) => (
<div
key={fact.label}
className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]"
>
<div className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">{fact.label}</div>
<div className="mt-1.5 text-sm font-semibold tracking-tight text-white md:text-base">{fact.value}</div>
</div>
))}
</div>
</div>
</div>
{profile?.website ? (
<a
href={profile.website.startsWith('http') ? profile.website : `https://${profile.website}`}
target="_blank"
rel="nofollow noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-2.5 py-1 text-sky-300 transition-colors hover:bg-white/10 hover:text-sky-200"
>
<i className="fa-solid fa-link fa-fw" />
{(() => {
try {
const url = profile.website.startsWith('http') ? profile.website : `https://${profile.website}`
return new URL(url).hostname
} catch {
return profile.website
}
})()}
</a>
) : null}
</div>
{bio ? (
<p className="mx-auto mt-3 max-w-2xl line-clamp-2 text-sm leading-relaxed text-slate-300/90 md:mx-0 md:line-clamp-3">
{bio}
</p>
) : null}
<XPProgressBar
xp={user?.xp}
currentLevelXp={user?.current_level_xp}
nextLevelXp={user?.next_level_xp}
progressPercent={user?.progress_percent}
maxLevel={user?.max_level}
className="mt-4 max-w-xl"
/>
</div>
<div className="shrink-0 flex items-center justify-center gap-2 pb-0.5 md:justify-end">
{extraActions}
{isOwner ? (
<>
<a
href="/dashboard/profile"
className="inline-flex items-center gap-2 rounded-xl border border-white/15 px-4 py-2.5 text-sm font-medium text-slate-300 transition-all hover:bg-white/5 hover:text-white"
aria-label="Edit profile"
>
<i className="fa-solid fa-pen fa-fw" />
Edit Profile
</a>
<a
href="/studio"
className="inline-flex items-center gap-2 rounded-xl bg-sky-600 px-4 py-2.5 text-sm font-medium text-white shadow-lg shadow-sky-900/30 transition-all hover:bg-sky-500"
aria-label="Open Studio"
>
<i className="fa-solid fa-wand-magic-sparkles fa-fw" />
Studio
</a>
</>
) : (
<>
<FollowButton
username={uname}
initialFollowing={following}
initialCount={count}
followingClassName="bg-green-500/10 border border-green-400/40 text-green-400 hover:bg-green-500/15"
idleClassName="bg-sky-500/10 border border-sky-400/40 text-sky-400 hover:bg-sky-500/20"
onChange={({ following: nextFollowing, followersCount }) => {
setFollowing(nextFollowing)
setCount(followersCount)
}}
/>
<button
type="button"
onClick={() => {
if (navigator.share) {
navigator.share({ title: `${displayName} on Skinbase`, url: window.location.href })
} else {
navigator.clipboard.writeText(window.location.href)
}
}}
aria-label="Share profile"
className="rounded-xl border border-white/10 p-2.5 text-slate-400 transition-all hover:bg-white/5 hover:text-white"
>
<i className="fa-solid fa-share-nodes fa-fw" />
</button>
</>
)}
</div>
</div>
</div>

View File

@@ -1,57 +0,0 @@
import React from 'react'
const PILLS = [
{ key: 'uploads_count', label: 'Artworks', icon: 'fa-images', tab: 'artworks' },
{ key: 'downloads_received_count', label: 'Downloads', icon: 'fa-download', tab: null },
{ key: 'follower_count', label: 'Followers', icon: 'fa-users', tab: 'about' },
{ key: 'following_count', label: 'Following', icon: 'fa-user-check', tab: 'about' },
{ key: 'artwork_views_received_count', label: 'Views', icon: 'fa-eye', tab: 'stats' },
{ key: 'awards_received_count', label: 'Awards', icon: 'fa-trophy', tab: 'stats' },
]
/**
* ProfileStatsRow
* Horizontal scrollable pill row of stat counts.
* Clicking a pill navigates to the relevant tab.
*/
export default function ProfileStatsRow({ stats, followerCount, onTabChange }) {
const values = {
uploads_count: stats?.uploads_count ?? 0,
downloads_received_count: stats?.downloads_received_count ?? 0,
follower_count: followerCount ?? 0,
following_count: stats?.following_count ?? 0,
artwork_views_received_count: stats?.artwork_views_received_count ?? 0,
awards_received_count: stats?.awards_received_count ?? 0,
}
return (
<div className="border-b border-white/10" style={{ background: 'rgba(255,255,255,0.02)' }}>
<div className="max-w-6xl mx-auto px-4">
<div className="grid grid-cols-3 md:grid-cols-6 gap-2 py-3">
{PILLS.map((pill) => (
<button
key={pill.key}
onClick={() => pill.tab && onTabChange(pill.tab)}
title={pill.label}
disabled={!pill.tab}
className={`
flex flex-col items-center justify-center gap-1 px-2 py-3 rounded-xl text-sm transition-all text-center
border border-white/10 bg-white/[0.02]
${pill.tab
? 'cursor-pointer hover:bg-white/[0.06] hover:border-white/20 hover:text-white text-slate-300 group'
: 'cursor-default text-slate-400 opacity-90'
}
`}
>
<i className={`fa-solid ${pill.icon} fa-fw text-xs ${pill.tab ? 'opacity-70 group-hover:opacity-100' : 'opacity-60'}`} />
<span className="font-bold text-white tabular-nums text-base leading-none">
{Number(values[pill.key]).toLocaleString()}
</span>
<span className="text-slate-500 text-[11px] uppercase tracking-wide leading-none">{pill.label}</span>
</button>
))}
</div>
</div>
</div>
)
}

View File

@@ -1,10 +1,10 @@
import React, { useEffect, useRef } from 'react'
export const TABS = [
{ id: 'posts', label: 'Posts', icon: 'fa-newspaper' },
{ id: 'artworks', label: 'Artworks', icon: 'fa-images' },
{ id: 'stories', label: 'Stories', icon: 'fa-feather-pointed' },
{ id: 'achievements', label: 'Achievements', icon: 'fa-trophy' },
{ id: 'posts', label: 'Posts', icon: 'fa-newspaper' },
{ id: 'collections', label: 'Collections', icon: 'fa-layer-group' },
{ id: 'about', label: 'About', icon: 'fa-id-card' },
{ id: 'stats', label: 'Stats', icon: 'fa-chart-bar' },
@@ -23,7 +23,6 @@ export default function ProfileTabs({ activeTab, onTabChange }) {
const navRef = useRef(null)
const activeRef = useRef(null)
// Scroll active tab into view on mount/change
useEffect(() => {
if (activeRef.current && navRef.current) {
activeRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' })
@@ -31,13 +30,14 @@ export default function ProfileTabs({ activeTab, onTabChange }) {
}, [activeTab])
return (
<nav
ref={navRef}
className="profile-tabs-sticky sticky z-30 bg-[#0c1525]/95 backdrop-blur-xl border-b border-white/10 overflow-x-auto scrollbar-hide"
aria-label="Profile sections"
role="tablist"
>
<div className="max-w-6xl mx-auto px-3 flex gap-1 py-1 min-w-max sm:min-w-0">
<div className="sticky top-0 z-30 border-b border-white/10 bg-[#08111f]/80 backdrop-blur-2xl">
<nav
ref={navRef}
className="profile-tabs-sticky overflow-x-auto scrollbar-hide"
aria-label="Profile sections"
role="tablist"
>
<div className="mx-auto flex w-max min-w-full gap-2 px-3 py-3 justify-center xl:items-stretch">
{TABS.map((tab) => {
const isActive = activeTab === tab.id
return (
@@ -49,28 +49,29 @@ export default function ProfileTabs({ activeTab, onTabChange }) {
aria-selected={isActive}
aria-controls={`tabpanel-${tab.id}`}
className={`
relative flex items-center gap-2 px-4 py-3 text-sm font-medium whitespace-nowrap rounded-lg
transition-colors duration-150 outline-none
focus-visible:ring-2 focus-visible:ring-sky-400/70 rounded-t
group relative flex items-center gap-2.5 rounded-2xl border px-3.5 py-3 text-sm font-medium whitespace-nowrap
outline-none transition-all duration-150 focus-visible:ring-2 focus-visible:ring-sky-400/70
${isActive
? 'text-white bg-white/[0.05]'
: 'text-slate-400 hover:text-slate-200 hover:bg-white/[0.03]'
? 'border-sky-300/25 bg-gradient-to-br from-sky-400/18 via-white/[0.06] to-cyan-400/10 text-white shadow-[0_16px_32px_rgba(14,165,233,0.12)]'
: 'border-white/8 bg-white/[0.03] text-slate-400 hover:border-white/15 hover:bg-white/[0.05] hover:text-slate-100'
}
`}
>
<i className={`fa-solid ${tab.icon} fa-fw text-xs ${isActive ? 'text-sky-400' : 'opacity-75'}`} />
<span className={`inline-flex h-9 w-9 items-center justify-center rounded-xl border text-sm ${isActive ? 'border-sky-300/20 bg-sky-400/10 text-sky-200' : 'border-white/10 bg-white/[0.04] text-slate-500 group-hover:text-slate-300'}`}>
<i className={`fa-solid ${tab.icon} fa-fw`} />
</span>
{tab.label}
{/* Active indicator bar */}
{isActive && (
<span
className="absolute bottom-0 inset-x-0 h-0.5 rounded-full bg-sky-400 shadow-[0_0_8px_rgba(56,189,248,0.6)]"
className="absolute inset-x-4 bottom-0 h-0.5 rounded-full bg-sky-300 shadow-[0_0_10px_rgba(125,211,252,0.8)]"
aria-hidden="true"
/>
)}
</button>
)
})}
</div>
</nav>
</div>
</nav>
</div>
)
}

View File

@@ -10,6 +10,100 @@ const SOCIAL_ICONS = {
website: { icon: 'fa-solid fa-link', label: 'Website' },
}
function formatNumber(value) {
return Number(value ?? 0).toLocaleString()
}
function formatRelativeDate(value) {
if (!value) return null
try {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return null
const now = new Date()
const diffSeconds = Math.round((date.getTime() - now.getTime()) / 1000)
const absSeconds = Math.abs(diffSeconds)
const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
if (absSeconds < 3600) {
return formatter.format(Math.round(diffSeconds / 60), 'minute')
}
if (absSeconds < 86400) {
return formatter.format(Math.round(diffSeconds / 3600), 'hour')
}
if (absSeconds < 604800) {
return formatter.format(Math.round(diffSeconds / 86400), 'day')
}
if (absSeconds < 2629800) {
return formatter.format(Math.round(diffSeconds / 604800), 'week')
}
return formatter.format(Math.round(diffSeconds / 2629800), 'month')
} catch {
return null
}
}
function formatShortDate(value) {
if (!value) return null
try {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return null
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
} catch {
return null
}
}
function truncateText(value, maxLength = 140) {
const text = String(value ?? '').trim()
if (!text) return ''
if (text.length <= maxLength) return text
return `${text.slice(0, maxLength).trimEnd()}...`
}
function buildInterestGroups(artworks = []) {
const categoryMap = new Map()
const contentTypeMap = new Map()
artworks.forEach((artwork) => {
const categoryKey = String(artwork?.category_slug || artwork?.category || '').trim().toLowerCase()
const categoryLabel = String(artwork?.category || '').trim()
const contentTypeKey = String(artwork?.content_type_slug || artwork?.content_type || '').trim().toLowerCase()
const contentTypeLabel = String(artwork?.content_type || '').trim()
if (categoryKey && categoryLabel) {
categoryMap.set(categoryKey, {
label: categoryLabel,
count: (categoryMap.get(categoryKey)?.count ?? 0) + 1,
})
}
if (contentTypeKey && contentTypeLabel) {
contentTypeMap.set(contentTypeKey, {
label: contentTypeLabel,
count: (contentTypeMap.get(contentTypeKey)?.count ?? 0) + 1,
})
}
})
const toSortedList = (source) => Array.from(source.values())
.sort((left, right) => right.count - left.count || left.label.localeCompare(right.label))
.slice(0, 5)
return {
categories: toSortedList(categoryMap),
contentTypes: toSortedList(contentTypeMap),
}
}
function InfoRow({ icon, label, children }) {
return (
<div className="flex items-start gap-3 py-2.5 border-b border-white/5 last:border-0">
@@ -22,11 +116,47 @@ function InfoRow({ icon, label, children }) {
)
}
function StatCard({ icon, label, value, tone = 'sky' }) {
const tones = {
sky: 'text-sky-300 bg-sky-400/10 border-sky-300/15',
amber: 'text-amber-200 bg-amber-300/10 border-amber-300/15',
emerald: 'text-emerald-200 bg-emerald-400/10 border-emerald-300/15',
violet: 'text-violet-200 bg-violet-400/10 border-violet-300/15',
}
return (
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] p-4 shadow-[0_18px_44px_rgba(2,6,23,0.18)]">
<div className={`inline-flex h-11 w-11 items-center justify-center rounded-2xl border ${tones[tone] || tones.sky}`}>
<i className={`fa-solid ${icon}`} />
</div>
<div className="mt-4 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{label}</div>
<div className="mt-1 text-2xl font-semibold tracking-tight text-white">{value}</div>
</div>
)
}
function SectionCard({ icon, eyebrow, title, children, className = '' }) {
return (
<section className={`rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_20px_52px_rgba(2,6,23,0.18)] md:p-6 ${className}`.trim()}>
<div className="flex items-start gap-3">
<div className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.05] text-sky-300">
<i className={`${icon} text-base`} />
</div>
<div className="min-w-0">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">{eyebrow}</p>
<h2 className="mt-1 text-xl font-semibold tracking-[-0.02em] text-white md:text-2xl">{title}</h2>
</div>
</div>
<div className="mt-5">{children}</div>
</section>
)
}
/**
* TabAbout
* Bio, social links, metadata - replaces old sidebar profile card.
*/
export default function TabAbout({ user, profile, socialLinks, countryName, followerCount }) {
export default function TabAbout({ user, profile, stats, achievements, artworks, creatorStories, profileComments, socialLinks, countryName, followerCount, recentFollowers, leaderboardRank }) {
const uname = user.username || user.name
const displayName = user.name || uname
const about = profile?.about
@@ -47,119 +177,344 @@ export default function TabAbout({ user, profile, socialLinks, countryName, foll
const genderMap = { M: 'Male', F: 'Female', X: 'Non-binary / N/A' }
const genderLabel = genderMap[profile?.gender?.toUpperCase()] ?? null
const birthDate = profile?.birthdate
? (() => {
try {
return new Date(profile.birthdate).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })
} catch { return null }
})()
: null
const lastSeenRelative = formatRelativeDate(user.last_visit_at)
const socialEntries = socialLinks
? Object.entries(socialLinks).filter(([, link]) => link?.url)
: []
const followers = recentFollowers ?? []
const recentAchievements = Array.isArray(achievements?.recent) ? achievements.recent : []
const stories = Array.isArray(creatorStories) ? creatorStories : []
const comments = Array.isArray(profileComments) ? profileComments : []
const interestGroups = buildInterestGroups(Array.isArray(artworks) ? artworks : [])
const summaryCards = [
{ icon: 'fa-user-group', label: 'Followers', value: formatNumber(followerCount), tone: 'sky' },
{ icon: 'fa-images', label: 'Uploads', value: formatNumber(stats?.uploads_count ?? 0), tone: 'violet' },
{ icon: 'fa-eye', label: 'Profile views', value: formatNumber(stats?.profile_views_count ?? 0), tone: 'emerald' },
{ icon: 'fa-trophy', label: 'Weekly rank', value: leaderboardRank?.rank ? `#${formatNumber(leaderboardRank.rank)}` : 'Unranked', tone: 'amber' },
]
return (
<div
id="tabpanel-about"
role="tabpanel"
aria-labelledby="tab-about"
className="pt-6 max-w-2xl"
className="mx-auto max-w-7xl px-4 pt-4 pb-10 md:px-6"
>
{/* 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(String(profile.country_code).toUpperCase())}.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 className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{summaryCards.map((card) => (
<StatCard key={card.label} {...card} />
))}
</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 className="mt-6 grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_380px]">
<div className="space-y-6">
<SectionCard icon="fa-solid fa-circle-info" eyebrow="Profile story" title={`About ${displayName}`} className="bg-[linear-gradient(135deg,rgba(56,189,248,0.08),rgba(255,255,255,0.04),rgba(249,115,22,0.05))]">
{about ? (
<p className="whitespace-pre-line text-[15px] leading-8 text-slate-200/90">{about}</p>
) : (
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.03] px-5 py-8 text-center text-sm text-slate-400">
This creator has not written a public bio yet.
</div>
)}
</SectionCard>
<SectionCard icon="fa-solid fa-address-card" eyebrow="Details" title="Profile information">
<div className="grid gap-3 md:grid-cols-2">
{displayName && displayName !== uname ? (
<InfoRow icon="fa-user" label="Display name">{displayName}</InfoRow>
) : null}
<InfoRow icon="fa-at" label="Username"><span className="font-mono">@{uname}</span></InfoRow>
{genderLabel ? <InfoRow icon="fa-venus-mars" label="Gender">{genderLabel}</InfoRow> : null}
{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(String(profile.country_code).toUpperCase())}.png`}
alt={countryName}
className="h-auto w-4 rounded-sm"
onError={(e) => { e.target.style.display = 'none' }}
/>
) : null}
{countryName}
</span>
</InfoRow>
) : null}
{website ? (
<InfoRow icon="fa-link" label="Website">
<a
href={website.startsWith('http') ? website : `https://${website}`}
target="_blank"
rel="nofollow noopener noreferrer"
className="text-sky-300 transition-colors hover:text-sky-200 hover:underline"
>
{(() => {
try {
const url = website.startsWith('http') ? website : `https://${website}`
return new URL(url).hostname
} catch { return website }
})()}
</a>
</InfoRow>
) : null}
{birthDate ? <InfoRow icon="fa-cake-candles" label="Birth date">{birthDate}</InfoRow> : null}
{joinDate ? <InfoRow icon="fa-calendar-days" label="Member since">{joinDate}</InfoRow> : null}
{lastVisit ? <InfoRow icon="fa-clock" label="Last seen">{lastSeenRelative ? `${lastSeenRelative} · ${lastVisit}` : lastVisit}</InfoRow> : null}
</div>
</SectionCard>
{followers.length > 0 ? (
<SectionCard icon="fa-solid fa-user-group" eyebrow="Community" title="Recent followers">
<div className="grid gap-3 sm:grid-cols-2">
{followers.slice(0, 6).map((follower) => (
<a
key={follower.id}
href={follower.profile_url ?? `/@${follower.username}`}
className="group flex items-center gap-3 rounded-2xl border border-white/8 bg-white/[0.03] px-4 py-3 transition-colors hover:border-white/14 hover:bg-white/[0.06]"
>
<img
src={follower.avatar_url ?? '/images/avatar_default.webp'}
alt={follower.username}
className="h-11 w-11 rounded-2xl object-cover ring-1 ring-white/10 transition-all group-hover:ring-sky-400/30"
loading="lazy"
/>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-slate-200 group-hover:text-white">{follower.uname || follower.username}</div>
<div className="truncate text-xs text-slate-500">@{follower.username}</div>
</div>
</a>
))}
</div>
</SectionCard>
) : null}
{recentAchievements.length > 0 ? (
<SectionCard icon="fa-solid fa-trophy" eyebrow="Recent wins" title="Latest achievements">
<div className="grid gap-3 sm:grid-cols-2">
{recentAchievements.slice(0, 4).map((achievement) => (
<div
key={achievement.id}
className="rounded-2xl border border-white/8 bg-white/[0.03] px-4 py-4 transition-colors hover:bg-white/[0.05]"
>
<div className="flex items-start gap-3">
<div className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl border border-amber-300/15 bg-amber-300/10 text-amber-100">
<i className={`fa-solid ${achievement.icon || 'fa-trophy'}`} />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-white">{achievement.name}</div>
{achievement.description ? (
<div className="mt-1 line-clamp-2 text-sm leading-relaxed text-slate-400">{achievement.description}</div>
) : null}
<div className="mt-3 flex flex-wrap gap-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-300/75">
{achievement.unlocked_at ? (
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1">
{formatShortDate(achievement.unlocked_at) || 'Unlocked'}
</span>
) : null}
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1">
+{formatNumber(achievement.xp_reward ?? 0)} XP
</span>
</div>
</div>
</div>
</div>
))}
</div>
</SectionCard>
) : null}
{stories.length > 0 || comments.length > 0 ? (
<SectionCard icon="fa-solid fa-wave-square" eyebrow="Fresh from this creator" title="Recent activity">
<div className="grid gap-3 lg:grid-cols-2">
{stories.length > 0 ? (
<div className="rounded-[24px] border border-white/8 bg-white/[0.03] p-4">
<div className="flex items-center justify-between gap-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Latest story</div>
<span className="rounded-full border border-sky-300/15 bg-sky-400/10 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-sky-100/80">
{formatShortDate(stories[0]?.published_at) || 'Published'}
</span>
</div>
<a
href={`/stories/${stories[0].slug}`}
className="mt-3 block text-lg font-semibold tracking-tight text-white transition-colors hover:text-sky-200"
>
{stories[0].title}
</a>
{stories[0].excerpt ? (
<p className="mt-2 text-sm leading-7 text-slate-400">
{truncateText(stories[0].excerpt, 180)}
</p>
) : null}
<div className="mt-4 flex flex-wrap gap-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-300/75">
{stories[0].reading_time ? (
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1">
{stories[0].reading_time} min read
</span>
) : null}
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1">
{formatNumber(stories[0].views ?? 0)} views
</span>
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1">
{formatNumber(stories[0].comments_count ?? 0)} comments
</span>
</div>
</div>
) : null}
{comments.length > 0 ? (
<div className="rounded-[24px] border border-white/8 bg-white/[0.03] p-4">
<div className="flex items-center justify-between gap-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Latest guestbook comment</div>
<span className="rounded-full border border-amber-300/15 bg-amber-300/10 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-amber-100/80">
{formatRelativeDate(comments[0]?.created_at) || 'Recently'}
</span>
</div>
<div className="mt-3 flex items-start gap-3">
<img
src={comments[0].author_avatar || '/images/avatar_default.webp'}
alt={comments[0].author_name}
className="h-11 w-11 rounded-2xl object-cover ring-1 ring-white/10"
loading="lazy"
onError={(e) => { e.target.src = '/images/avatar_default.webp' }}
/>
<div className="min-w-0 flex-1">
<a
href={comments[0].author_profile_url}
className="text-sm font-semibold text-white transition-colors hover:text-sky-200"
>
{comments[0].author_name}
</a>
<p className="mt-2 text-sm leading-7 text-slate-400">
{truncateText(comments[0].body, 180)}
</p>
</div>
</div>
</div>
) : null}
</div>
</SectionCard>
) : null}
</div>
)}
<div className="space-y-6">
<SectionCard icon="fa-solid fa-sparkles" eyebrow="Creator snapshot" title="Profile snapshot" className="bg-[linear-gradient(180deg,rgba(15,23,42,0.72),rgba(2,6,23,0.5))]">
<div className="space-y-4">
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] p-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Creator level</div>
<div className="mt-2 flex items-end justify-between gap-4">
<div>
<div className="text-3xl font-semibold tracking-tight text-white">Lv {formatNumber(user?.level ?? 1)}</div>
<div className="mt-1 text-sm text-slate-400">{user?.rank || 'Creator'}</div>
</div>
<div className="rounded-2xl border border-sky-300/15 bg-sky-400/10 px-3 py-2 text-right">
<div className="text-[10px] font-semibold uppercase tracking-[0.16em] text-sky-100/70">XP</div>
<div className="mt-1 text-lg font-semibold text-sky-100">{formatNumber(user?.xp ?? 0)}</div>
</div>
</div>
<div className="mt-4 h-2 overflow-hidden rounded-full bg-white/8">
<div className="h-full rounded-full bg-[linear-gradient(90deg,#38bdf8,#60a5fa,#f59e0b)]" style={{ width: `${Math.max(0, Math.min(100, Number(user?.progress_percent ?? 0)))}%` }} />
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-2xl border border-white/10 bg-white/[0.04] p-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Weekly rank</div>
<div className="mt-2 text-2xl font-semibold tracking-tight text-white">{leaderboardRank?.rank ? `#${formatNumber(leaderboardRank.rank)}` : 'Not ranked'}</div>
{leaderboardRank?.score ? <div className="mt-1 text-sm text-slate-400">Score {formatNumber(leaderboardRank.score)}</div> : null}
</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.04] p-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Community size</div>
<div className="mt-2 text-2xl font-semibold tracking-tight text-white">{formatNumber(followerCount)}</div>
<div className="mt-1 text-sm text-slate-400">Followers</div>
</div>
</div>
</div>
</SectionCard>
<SectionCard icon="fa-solid fa-chart-simple" eyebrow="Highlights" title="Useful stats">
<div className="space-y-3">
<InfoRow icon="fa-images" label="Uploads">{formatNumber(stats?.uploads_count ?? 0)}</InfoRow>
<InfoRow icon="fa-eye" label="Artwork views received">{formatNumber(stats?.artwork_views_received_count ?? 0)}</InfoRow>
<InfoRow icon="fa-download" label="Downloads received">{formatNumber(stats?.downloads_received_count ?? 0)}</InfoRow>
<InfoRow icon="fa-heart" label="Favourites received">{formatNumber(stats?.favourites_received_count ?? 0)}</InfoRow>
<InfoRow icon="fa-comment" label="Comments received">{formatNumber(stats?.comments_received_count ?? 0)}</InfoRow>
</div>
</SectionCard>
{interestGroups.categories.length > 0 || interestGroups.contentTypes.length > 0 ? (
<SectionCard icon="fa-solid fa-layer-group" eyebrow="Creative focus" title="Favourite categories & formats">
<div className="space-y-5">
{interestGroups.categories.length > 0 ? (
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Top categories</div>
<div className="mt-3 flex flex-wrap gap-2.5">
{interestGroups.categories.map((category) => (
<span
key={category.label}
className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-3.5 py-2 text-sm text-slate-200"
>
<span>{category.label}</span>
<span className="rounded-full bg-black/20 px-2 py-0.5 text-[11px] font-semibold text-slate-400">{formatNumber(category.count)}</span>
</span>
))}
</div>
</div>
) : null}
{interestGroups.contentTypes.length > 0 ? (
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Preferred formats</div>
<div className="mt-3 flex flex-wrap gap-2.5">
{interestGroups.contentTypes.map((contentType) => (
<span
key={contentType.label}
className="inline-flex items-center gap-2 rounded-2xl border border-sky-300/15 bg-sky-400/10 px-3.5 py-2 text-sm text-sky-100"
>
<span>{contentType.label}</span>
<span className="rounded-full bg-black/20 px-2 py-0.5 text-[11px] font-semibold text-sky-100/70">{formatNumber(contentType.count)}</span>
</span>
))}
</div>
</div>
) : null}
</div>
</SectionCard>
) : null}
{socialEntries.length > 0 ? (
<SectionCard icon="fa-solid fa-share-nodes" eyebrow="Links" title="Social links">
<div className="flex flex-wrap gap-2.5">
{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 rounded-2xl border border-white/10 bg-white/[0.04] px-3.5 py-2.5 text-sm text-slate-300 transition-all hover:border-sky-400/30 hover:bg-white/[0.07] hover:text-white"
aria-label={si.label}
>
<i className={`${si.icon} fa-fw`} />
<span>{si.label}</span>
</a>
)
})}
</div>
</SectionCard>
) : null}
</div>
</div>
</div>
)
}

View File

@@ -1,20 +1,286 @@
import React from 'react'
import ProfileGalleryPanel from '../ProfileGalleryPanel'
import React, { useEffect, useMemo, useState } from 'react'
import ArtworkGallery from '../../artwork/ArtworkGallery'
export default function TabArtworks({ artworks, featuredArtworks, username, isActive }) {
function slugify(value) {
return String(value ?? '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
}
function formatNumber(value) {
return Number(value ?? 0).toLocaleString()
}
function sortByPublishedAt(items) {
return [...items].sort((left, right) => {
const leftTime = left?.published_at ? new Date(left.published_at).getTime() : 0
const rightTime = right?.published_at ? new Date(right.published_at).getTime() : 0
return rightTime - leftTime
})
}
function isWallpaperArtwork(item) {
const contentType = String(item?.content_type_slug || item?.content_type || '').toLowerCase()
const category = String(item?.category_slug || item?.category || '').toLowerCase()
return contentType.includes('wallpaper') || category.includes('wallpaper')
}
function useArtworkPreview(username, sort) {
const [items, setItems] = useState([])
useEffect(() => {
let active = true
async function load() {
try {
const response = await fetch(`/api/profile/${encodeURIComponent(username)}/artworks?sort=${encodeURIComponent(sort)}`, {
headers: { Accept: 'application/json' },
})
if (!response.ok) return
const data = await response.json()
if (active) {
setItems(Array.isArray(data?.data) ? data.data : [])
}
} catch (_) {}
}
load()
return () => {
active = false
}
}, [sort, username])
return items
}
function SectionHeader({ eyebrow, title, description, action }) {
return (
<div className="mb-5 flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-300/80">{eyebrow}</p>
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white md:text-3xl">{title}</h2>
{description ? <p className="mt-2 max-w-2xl text-sm leading-relaxed text-slate-400">{description}</p> : null}
</div>
{action}
</div>
)
}
function artworkMeta(art) {
return [art?.content_type, art?.category].filter(Boolean).join(' • ')
}
function artworkStats(art) {
return [
{ label: 'Views', value: formatNumber(art?.views ?? 0), icon: 'fa-regular fa-eye' },
{ label: 'Likes', value: formatNumber(art?.likes ?? 0), icon: 'fa-regular fa-heart' },
{ label: 'Downloads', value: formatNumber(art?.downloads ?? 0), icon: 'fa-solid fa-download' },
]
}
function FeaturedShowcase({ featuredArtworks }) {
if (!featuredArtworks?.length) return null
const leadArtwork = featuredArtworks[0]
const secondaryArtworks = featuredArtworks.slice(1, 4)
const leadMeta = artworkMeta(leadArtwork)
const leadStats = artworkStats(leadArtwork)
return (
<section className="relative mt-8 overflow-hidden rounded-[36px] border border-white/10 bg-[linear-gradient(135deg,rgba(56,189,248,0.14),rgba(255,255,255,0.04),rgba(249,115,22,0.12))] shadow-[0_30px_90px_rgba(2,6,23,0.3)]">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(250,204,21,0.12),transparent_30%),radial-gradient(circle_at_bottom_left,rgba(56,189,248,0.14),transparent_34%)]" />
<div className="relative grid gap-6 p-5 md:p-7 xl:grid-cols-[minmax(0,1.28fr)_380px]">
<a
href={`/art/${leadArtwork.id}/${slugify(leadArtwork.name)}`}
className="group relative overflow-hidden rounded-[30px] border border-white/10 bg-slate-950/60 shadow-[0_24px_60px_rgba(2,6,23,0.28)]"
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(125,211,252,0.24),transparent_46%),linear-gradient(to_top,rgba(2,6,23,0.9),rgba(2,6,23,0.08))]" />
<div className="aspect-[16/9] overflow-hidden">
<img
src={leadArtwork.thumb}
alt={leadArtwork.name}
className="h-full w-full object-cover transition-transform duration-700 group-hover:scale-[1.05]"
loading="lazy"
/>
</div>
<div className="absolute inset-x-0 top-0 flex items-start justify-between p-5 md:p-7">
<div className="inline-flex items-center gap-2 rounded-full border border-amber-300/20 bg-amber-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.2em] text-amber-100 backdrop-blur-sm">
<i className="fa-solid fa-star text-[10px]" />
Featured spotlight
</div>
<div className="hidden rounded-2xl border border-white/10 bg-slate-950/35 px-4 py-3 backdrop-blur-md md:block">
<div className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-400">Featured set</div>
<div className="mt-1 text-2xl font-semibold tracking-tight text-white">{formatNumber(featuredArtworks.length)}</div>
</div>
</div>
<div className="absolute inset-x-0 bottom-0 p-5 md:p-7">
{leadMeta ? (
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100/85">{leadMeta}</div>
) : null}
<h2 className="mt-3 max-w-2xl text-2xl font-semibold tracking-[-0.04em] text-white md:text-[2.7rem] md:leading-[1.02]">
{leadArtwork.name}
</h2>
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-slate-200/90 md:text-[15px]">
A standout first impression for the artwork landing page, built to pull attention before visitors move into trending picks and the full archive.
</p>
<div className="mt-5 flex flex-wrap gap-2.5">
<span className="rounded-full border border-white/15 bg-black/20 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-100/90">Top pick</span>
{leadArtwork.width && leadArtwork.height ? (
<span className="rounded-full border border-white/15 bg-black/20 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-100/90">
{leadArtwork.width}x{leadArtwork.height}
</span>
) : null}
</div>
<div className="mt-5 grid gap-3 sm:grid-cols-3">
{leadStats.map((stat) => (
<div key={stat.label} className="rounded-2xl border border-white/10 bg-slate-950/35 px-4 py-3 backdrop-blur-md">
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-300/75">
<i className={`${stat.icon} text-[10px]`} />
{stat.label}
</div>
<div className="mt-1 text-xl font-semibold tracking-tight text-white">{stat.value}</div>
</div>
))}
</div>
</div>
</a>
<div className="flex flex-col gap-4">
<div className="rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.66),rgba(2,6,23,0.5))] p-5 backdrop-blur-sm">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">Featured</p>
<h3 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white">Curated gallery highlights</h3>
<p className="mt-2 text-sm leading-relaxed text-slate-300">
These picks create a cleaner visual entry point and give the artwork page more personality than a simple list of thumbnails.
</p>
<div className="mt-4 flex flex-wrap gap-2">
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200/85">Editorial layout</span>
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200/85">Hero-led showcase</span>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-1">
{secondaryArtworks.map((art, index) => (
<a
key={art.id}
href={`/art/${art.id}/${slugify(art.name)}`}
className="group flex gap-4 rounded-[26px] border border-white/10 bg-white/[0.045] p-4 shadow-[0_14px_36px_rgba(2,6,23,0.18)] transition-all hover:-translate-y-0.5 hover:bg-white/[0.08]"
>
<div className="h-24 w-28 shrink-0 overflow-hidden rounded-[18px] bg-black/30 ring-1 ring-white/10">
<img
src={art.thumb}
alt={art.name}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.04]"
loading="lazy"
/>
</div>
<div className="min-w-0">
<div className="flex items-center justify-between gap-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Feature {index + 2}</div>
{artworkMeta(art) ? <div className="truncate text-[10px] font-semibold uppercase tracking-[0.16em] text-sky-100/70">{artworkMeta(art)}</div> : null}
</div>
<div className="mt-2 truncate text-lg font-semibold text-white">{art.name}</div>
{art.label ? <div className="mt-1 line-clamp-2 text-sm leading-relaxed text-slate-400">{art.label}</div> : null}
<div className="mt-3 flex flex-wrap gap-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-300/80">
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1">{formatNumber(art?.views ?? 0)} views</span>
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1">{formatNumber(art?.likes ?? 0)} likes</span>
</div>
</div>
</a>
))}
</div>
</div>
</div>
</section>
)
}
function PreviewRail({ eyebrow, title, description, items }) {
if (!items.length) return null
return (
<section className="mt-10">
<SectionHeader eyebrow={eyebrow} title={title} description={description} />
<ArtworkGallery
items={items}
compact
className="grid grid-cols-1 gap-5 sm:grid-cols-2 xl:grid-cols-4"
resolveCardProps={() => ({ showActions: false })}
/>
</section>
)
}
function FullGalleryCta({ galleryUrl, username }) {
return (
<section className="mt-10 rounded-[30px] border border-white/10 bg-white/[0.04] p-6 md:p-8">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-300/80">Full archive</p>
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white md:text-3xl">Want the complete gallery?</h2>
<p className="mt-2 max-w-2xl text-sm leading-relaxed text-slate-400">
The curated sections above are a friendlier starting point. The full gallery has the infinite-scroll archive with everything published by @{username}.
</p>
</div>
<a
href={galleryUrl || '#'}
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-colors hover:bg-sky-400/15"
>
<i className="fa-solid fa-arrow-right fa-fw" />
Browse full gallery
</a>
</div>
</section>
)
}
export default function TabArtworks({ artworks, featuredArtworks, username, galleryUrl }) {
const initialItems = artworks?.data ?? artworks ?? []
const trendingItems = useArtworkPreview(username, 'trending')
const popularItems = useArtworkPreview(username, 'views')
const wallpaperItems = useMemo(() => {
const wallpapers = popularItems.filter(isWallpaperArtwork)
return (wallpapers.length ? wallpapers : popularItems).slice(0, 4)
}, [popularItems])
const latestItems = useMemo(() => sortByPublishedAt(initialItems).slice(0, 4), [initialItems])
return (
<div
id="tabpanel-artworks"
role="tabpanel"
aria-labelledby="tab-artworks"
className="pt-6"
className="mx-auto max-w-7xl px-4 pt-2 pb-10 md:px-6"
>
<ProfileGalleryPanel
artworks={artworks}
featuredArtworks={featuredArtworks}
username={username}
<FeaturedShowcase featuredArtworks={featuredArtworks ?? []} />
<PreviewRail
eyebrow="Trending"
title="Trending artworks right now"
description="A quick scan of the work currently pulling the most momentum on the creator profile."
items={trendingItems.slice(0, 4)}
/>
<PreviewRail
eyebrow="Wallpaper picks"
title="Popular wallpapers"
description="Surface the strongest wallpaper-friendly pieces before sending people into the full archive."
items={wallpaperItems}
/>
<PreviewRail
eyebrow="Latest"
title="Recent additions"
description="Fresh uploads from the profile, presented as a preview instead of the full endless gallery."
items={latestItems}
/>
<FullGalleryCta galleryUrl={galleryUrl} username={username} />
</div>
)
}

View File

@@ -5,24 +5,50 @@ import PostComposer from '../../Feed/PostComposer'
import PostCardSkeleton from '../../Feed/PostCardSkeleton'
import FeedSidebar from '../../Feed/FeedSidebar'
function formatCompactNumber(value) {
return Number(value ?? 0).toLocaleString()
}
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">
<div className="flex flex-col items-center justify-center rounded-[28px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-20 text-center">
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-3xl border border-white/10 bg-white/[0.04] text-slate-500">
<i className="fa-regular fa-newspaper text-2xl" />
</div>
<p className="text-slate-400 font-medium mb-1">No posts yet</p>
<p className="mb-1 text-lg font-semibold text-white">No posts yet</p>
{isOwner ? (
<p className="text-slate-600 text-sm max-w-xs">
Share updates or showcase your artworks.
<p className="max-w-sm text-sm leading-relaxed text-slate-400">
Share works in progress, announce releases, or add a bit of personality beyond the gallery.
</p>
) : (
<p className="text-slate-600 text-sm">@{username} has not posted anything yet.</p>
<p className="max-w-sm text-sm leading-relaxed text-slate-400">@{username} has not published any profile posts yet.</p>
)}
</div>
)
}
function ErrorPostsState({ onRetry }) {
return (
<div className="rounded-[28px] border border-rose-400/20 bg-rose-400/10 px-6 py-12 text-center">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl border border-rose-300/20 bg-rose-500/10 text-rose-200">
<i className="fa-solid fa-triangle-exclamation text-lg" />
</div>
<h3 className="mt-4 text-lg font-semibold text-white">Posts could not be loaded</h3>
<p className="mx-auto mt-2 max-w-md text-sm leading-relaxed text-rose-100/80">
The profile shell loaded, but the posts feed request failed. Retry without leaving the page.
</p>
<button
type="button"
onClick={onRetry}
className="mt-5 inline-flex items-center gap-2 rounded-2xl border border-rose-300/20 bg-white/10 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-white/15"
>
<i className="fa-solid fa-rotate-right" />
Retry loading posts
</button>
</div>
)
}
/**
* TabPosts
* Profile Posts tab — shows the user's post feed with optional composer (for owner).
@@ -51,6 +77,7 @@ export default function TabPosts({
recentFollowers,
socialLinks,
countryName,
profileUrl,
onTabChange,
}) {
const [posts, setPosts] = useState([])
@@ -58,21 +85,22 @@ export default function TabPosts({
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(false)
const [loaded, setLoaded] = useState(false)
const [error, setError] = useState(false)
// Fetch on mount
React.useEffect(() => {
fetchFeed(1)
}, [username])
const fetchFeed = async (p = 1) => {
setLoading(true)
setError(false)
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 {
//
setError(true)
} finally {
setLoading(false)
setLoaded(true)
@@ -87,28 +115,94 @@ export default function TabPosts({
setPosts((prev) => prev.filter((p) => p.id !== postId))
}, [])
const summaryCards = [
{ label: 'Followers', value: formatCompactNumber(followerCount), icon: 'fa-user-group' },
{ label: 'Artworks', 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' },
]
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) */}
<div className="py-6">
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_20rem]">
<section className="rounded-[30px] border border-white/10 bg-[linear-gradient(135deg,rgba(56,189,248,0.08),rgba(255,255,255,0.04),rgba(249,115,22,0.06))] p-6 shadow-[0_20px_60px_rgba(2,6,23,0.24)]">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-3xl">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">Profile posts</p>
<h2 className="mt-2 text-3xl font-semibold tracking-[-0.03em] text-white">
Updates, thoughts, and shared work from @{username}
</h2>
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-slate-300">
This stream adds the human layer to the profile: quick notes, shared artwork posts, and announcements that do not belong inside the gallery grid.
</p>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => onTabChange?.('artworks')}
className="inline-flex items-center gap-2 rounded-2xl border border-white/15 bg-white/[0.06] px-4 py-2.5 text-sm font-medium text-slate-200 transition-colors hover:bg-white/[0.1]"
>
<i className="fa-solid fa-images fa-fw" />
View artworks
</button>
<button
type="button"
onClick={() => onTabChange?.('about')}
className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-2.5 text-sm font-medium text-slate-300 transition-colors hover:bg-white/[0.08] hover:text-white"
>
<i className="fa-solid fa-id-card fa-fw" />
About creator
</button>
{profileUrl ? (
<a
href={profileUrl}
className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-2.5 text-sm font-medium text-slate-300 transition-colors hover:bg-white/[0.08] hover:text-white"
>
<i className="fa-solid fa-user fa-fw" />
Canonical profile
</a>
) : null}
</div>
</div>
</section>
<section className="grid grid-cols-2 gap-3 sm:grid-cols-4 xl:grid-cols-2">
{summaryCards.map((card) => (
<div
key={card.label}
className="rounded-[24px] border border-white/10 bg-white/[0.04] px-4 py-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]"
>
<div className="flex items-center justify-between gap-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{card.label}</div>
<i className={`fa-solid ${card.icon} text-slate-500`} />
</div>
<div className="mt-3 text-xl font-semibold tracking-tight text-white">{card.value}</div>
</div>
))}
</section>
</div>
<div className="mt-6 grid items-start gap-6 xl:grid-cols-[minmax(0,1fr)_20rem]">
<div className="min-w-0 space-y-4">
{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 && (
{loaded && error && posts.length === 0 && (
<ErrorPostsState onRetry={() => fetchFeed(1)} />
)}
{loaded && !loading && !error && posts.length === 0 && (
<EmptyPostsState isOwner={isOwner} username={username} />
)}
{/* Post list */}
{posts.length > 0 && (
<div className="space-y-4">
{posts.map((post) => (
@@ -123,13 +217,12 @@ export default function TabPosts({
</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"
className="rounded-2xl border border-white/10 bg-white/[0.04] px-6 py-2.5 text-sm font-medium text-slate-200 transition-colors hover:bg-white/[0.08] disabled:opacity-50"
>
{loading ? (
<><i className="fa-solid fa-spinner fa-spin mr-2" />Loading</>
@@ -137,22 +230,22 @@ export default function TabPosts({
</button>
</div>
)}
</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>
<aside className="hidden xl:block xl:sticky xl:top-24">
<FeedSidebar
user={user}
profile={profile}
stats={stats}
followerCount={followerCount}
recentFollowers={recentFollowers}
socialLinks={socialLinks}
countryName={countryName}
isLoggedIn={!!authUser}
onTabChange={onTabChange}
/>
</aside>
</div>
</div>
)
}

View File

@@ -0,0 +1,88 @@
import React, { useEffect, useMemo, useState } from 'react'
import { getEcho } from '../../bootstrap'
export default function MessageInboxBadge({ initialUnreadCount = 0, userId = null, href = '/messages' }) {
const [unreadCount, setUnreadCount] = useState(Math.max(0, Number(initialUnreadCount || 0)))
useEffect(() => {
let cancelled = false
const loadUnreadState = async () => {
try {
const response = await fetch('/api/messages/conversations', {
headers: { Accept: 'application/json' },
credentials: 'same-origin',
})
if (!response.ok) {
throw new Error('Failed to load unread conversations')
}
const payload = await response.json()
if (cancelled) {
return
}
if (cancelled) {
return
}
const nextUnreadTotal = Number(payload?.summary?.unread_total)
if (Number.isFinite(nextUnreadTotal)) {
setUnreadCount(Math.max(0, nextUnreadTotal))
}
} catch {
// Keep server-rendered count if bootstrap fetch fails.
}
}
loadUnreadState()
return () => {
cancelled = true
}
}, [])
useEffect(() => {
if (!userId) {
return undefined
}
const echo = getEcho()
if (!echo) {
return undefined
}
const channel = echo.private(`user.${userId}`)
const handleConversationUpdated = (payload) => {
const nextUnreadTotal = Number(payload?.summary?.unread_total)
if (Number.isFinite(nextUnreadTotal)) {
setUnreadCount(Math.max(0, nextUnreadTotal))
}
}
channel.listen('.conversation.updated', handleConversationUpdated)
return () => {
channel.stopListening('.conversation.updated', handleConversationUpdated)
echo.leaveChannel(`private-user.${userId}`)
}
}, [userId])
return (
<a
href={href}
className="relative w-10 h-10 inline-flex items-center justify-center rounded-lg hover:bg-white/5"
title="Messages"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
{unreadCount > 0 ? (
<span className="absolute -bottom-1 right-0 text-[11px] tabular-nums px-1.5 py-0.5 rounded bg-red-700/70 text-white border border-sb-line">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
) : null}
</a>
)
}

View File

@@ -87,6 +87,23 @@ function mountToolbarNotifications() {
});
}
function mountToolbarMessages() {
var rootEl = document.getElementById('toolbar-messages-root');
if (!rootEl || rootEl.dataset.reactMounted === 'true') return;
var props = safeParseJson(rootEl.getAttribute('data-props'), {});
rootEl.dataset.reactMounted = 'true';
void import('./components/social/MessageInboxBadge.jsx')
.then(function (module) {
var Component = module.default;
createRoot(rootEl).render(React.createElement(Component, props));
})
.catch(function () {
rootEl.dataset.reactMounted = 'false';
});
}
function mountStorySocial() {
var socialRoot = document.getElementById('story-social-root');
if (socialRoot && socialRoot.dataset.reactMounted !== 'true') {
@@ -130,6 +147,7 @@ function mountStorySocial() {
});
}
mountToolbarMessages();
mountToolbarNotifications();
mountStorySocial();

View File

@@ -202,16 +202,14 @@
@endif
</a>
<a href="{{ Route::has('messages.index') ? route('messages.index') : '/messages' }}"
class="relative w-10 h-10 inline-flex items-center justify-center rounded-lg hover:bg-white/5"
title="Messages">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
@if(($msgCount ?? 0) > 0)
<span class="absolute -bottom-1 right-0 text-[11px] tabular-nums px-1.5 py-0.5 rounded bg-red-700/70 text-white border border-sb-line">{{ $msgCount }}</span>
@endif
</a>
@php
$toolbarMessagesProps = [
'initialUnreadCount' => (int) ($msgCount ?? 0),
'userId' => (int) ($userId ?? Auth::id() ?? 0),
'href' => Route::has('messages.index') ? route('messages.index') : '/messages',
];
@endphp
<div id="toolbar-messages-root" data-props='@json($toolbarMessagesProps)'></div>
<div id="toolbar-notification-root" data-props='@json(['initialUnreadCount' => (int) ($noticeCount ?? 0)])'></div>
</div>