feat: add community activity feed and mentions
This commit is contained in:
220
resources/js/Pages/Community/CommunityActivityPage.jsx
Normal file
220
resources/js/Pages/Community/CommunityActivityPage.jsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import ActivityFeed from '../../components/community/ActivityFeed'
|
||||
|
||||
const FILTER_TABS = [
|
||||
{ key: 'all', label: 'All Activity' },
|
||||
{ key: 'comments', label: 'Comments' },
|
||||
{ key: 'replies', label: 'Replies' },
|
||||
{ key: 'following', label: 'Following', authRequired: true },
|
||||
{ key: 'my', label: 'My Activity', authRequired: true },
|
||||
]
|
||||
|
||||
function FilterPills({ activeFilter, isAuthenticated, onChange }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{FILTER_TABS.map((tab) => {
|
||||
const disabled = tab.authRequired && !isAuthenticated
|
||||
const active = activeFilter === tab.key
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => !disabled && onChange(tab.key)}
|
||||
className={[
|
||||
'rounded-full border px-4 py-2 text-sm font-medium transition-all',
|
||||
active
|
||||
? 'border-sky-400/30 bg-sky-500/14 text-sky-200 shadow-[0_0_0_1px_rgba(56,189,248,0.08)]'
|
||||
: 'border-white/[0.06] bg-white/[0.03] text-white/55 hover:border-white/15 hover:bg-white/[0.05] hover:text-white/85',
|
||||
disabled ? 'cursor-not-allowed opacity-35' : '',
|
||||
].join(' ')}
|
||||
title={disabled ? 'Log in to use this filter' : undefined}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function updateUrl(filter, userId) {
|
||||
const url = new URL(window.location.href)
|
||||
|
||||
if (filter && filter !== 'all') url.searchParams.set('filter', filter)
|
||||
else url.searchParams.delete('filter')
|
||||
|
||||
if (userId) url.searchParams.set('user_id', String(userId))
|
||||
else url.searchParams.delete('user_id')
|
||||
|
||||
window.history.replaceState({}, '', url.toString())
|
||||
}
|
||||
|
||||
function updateHeaderSummary(filter, userId) {
|
||||
const filterLabels = {
|
||||
all: 'All Activity',
|
||||
comments: 'Comments',
|
||||
replies: 'Replies',
|
||||
following: 'Following',
|
||||
my: 'My Activity',
|
||||
}
|
||||
|
||||
const filterNode = document.getElementById('community-activity-filter-summary')
|
||||
const scopeNode = document.getElementById('community-activity-scope-summary')
|
||||
|
||||
if (filterNode) {
|
||||
filterNode.innerHTML = `<i class="fa-solid fa-filter"></i> ${filterLabels[filter] || filterLabels.all}`
|
||||
}
|
||||
|
||||
if (scopeNode) {
|
||||
if (userId) {
|
||||
scopeNode.className = 'inline-flex items-center gap-1.5 rounded-full border border-white/[0.08] bg-white/[0.04] px-3 py-1 text-white/65'
|
||||
scopeNode.innerHTML = `<i class="fa-solid fa-user"></i> User #${userId}`
|
||||
} else {
|
||||
scopeNode.className = 'hidden'
|
||||
scopeNode.innerHTML = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function CommunityActivityPage({
|
||||
initialActivities = [],
|
||||
initialMeta = {},
|
||||
initialFilter = 'all',
|
||||
initialUserId = null,
|
||||
isAuthenticated = false,
|
||||
}) {
|
||||
const [activeFilter, setActiveFilter] = useState(initialFilter)
|
||||
const [activities, setActivities] = useState(initialActivities)
|
||||
const [meta, setMeta] = useState(initialMeta)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const sentinelRef = useRef(null)
|
||||
const requestIdRef = useRef(0)
|
||||
|
||||
const hasMore = Boolean(meta?.has_more)
|
||||
const nextPage = Number(meta?.current_page || 1) + 1
|
||||
|
||||
const fetchFeed = useCallback(async ({ filter, page, append }) => {
|
||||
const requestId = ++requestIdRef.current
|
||||
setError(null)
|
||||
if (append) setLoadingMore(true)
|
||||
else setLoading(true)
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({ filter, page: String(page) })
|
||||
if (initialUserId) params.set('user_id', String(initialUserId))
|
||||
|
||||
const response = await fetch(`/api/community/activity?${params.toString()}`, {
|
||||
headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
|
||||
if (requestId !== requestIdRef.current) return
|
||||
|
||||
if (response.status === 401) {
|
||||
setError('Please log in to view this activity filter.')
|
||||
if (!append) {
|
||||
setActivities([])
|
||||
setMeta({ current_page: 1, last_page: 1, has_more: false, total: 0 })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load community activity.')
|
||||
}
|
||||
|
||||
const payload = await response.json()
|
||||
setActivities((prev) => append ? [...prev, ...(payload.data || [])] : (payload.data || []))
|
||||
setMeta(payload.meta || {})
|
||||
} catch {
|
||||
if (requestId === requestIdRef.current) {
|
||||
setError('Failed to load community activity. Please try again.')
|
||||
}
|
||||
} finally {
|
||||
if (requestId === requestIdRef.current) {
|
||||
setLoading(false)
|
||||
setLoadingMore(false)
|
||||
}
|
||||
}
|
||||
}, [initialUserId])
|
||||
|
||||
const handleFilterChange = useCallback((nextFilter) => {
|
||||
if (nextFilter === activeFilter) return
|
||||
setActiveFilter(nextFilter)
|
||||
updateUrl(nextFilter, initialUserId)
|
||||
fetchFeed({ filter: nextFilter, page: 1, append: false })
|
||||
}, [activeFilter, fetchFeed, initialUserId])
|
||||
|
||||
useEffect(() => {
|
||||
updateHeaderSummary(activeFilter, initialUserId)
|
||||
}, [activeFilter, initialUserId])
|
||||
|
||||
useEffect(() => {
|
||||
const sentinel = sentinelRef.current
|
||||
if (!sentinel || loading || loadingMore || !hasMore) return undefined
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
const [entry] = entries
|
||||
if (entry?.isIntersecting) {
|
||||
fetchFeed({ filter: activeFilter, page: nextPage, append: true })
|
||||
}
|
||||
}, { rootMargin: '220px 0px' })
|
||||
|
||||
observer.observe(sentinel)
|
||||
return () => observer.disconnect()
|
||||
}, [activeFilter, fetchFeed, hasMore, loading, loadingMore, nextPage])
|
||||
|
||||
const resultsLabel = useMemo(() => {
|
||||
const total = Number(meta?.total || activities.length || 0)
|
||||
if (!total) return 'No recent activity'
|
||||
return `${total.toLocaleString()} events`
|
||||
}, [activities.length, meta?.total])
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-6 pt-8 pb-20 md:px-10">
|
||||
<div className="mb-6 flex flex-col gap-4 rounded-[28px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(10,16,26,0.92),rgba(6,10,18,0.88))] p-5 shadow-[0_20px_60px_rgba(0,0,0,0.25)]">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-white/35">Live community pulse</p>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-white/55">
|
||||
Comments, replies, reactions, and mentions from across Skinbase in one scrolling Nova feed.
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-white/45">{resultsLabel}</div>
|
||||
</div>
|
||||
|
||||
<FilterPills activeFilter={activeFilter} isAuthenticated={isAuthenticated} onChange={handleFilterChange} />
|
||||
</div>
|
||||
|
||||
<ActivityFeed
|
||||
activities={activities}
|
||||
isLoggedIn={isAuthenticated}
|
||||
loading={loading}
|
||||
loadingMore={loadingMore}
|
||||
error={error}
|
||||
sentinelRef={sentinelRef}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const mountEl = document.getElementById('community-activity-root')
|
||||
|
||||
if (mountEl) {
|
||||
let props = {}
|
||||
try {
|
||||
const propsEl = document.getElementById('community-activity-props')
|
||||
props = propsEl ? JSON.parse(propsEl.textContent || '{}') : {}
|
||||
} catch {
|
||||
props = {}
|
||||
}
|
||||
|
||||
createRoot(mountEl).render(<CommunityActivityPage {...props} />)
|
||||
}
|
||||
|
||||
export default CommunityActivityPage
|
||||
25
resources/js/components/community/ActivityArtworkPreview.jsx
Normal file
25
resources/js/components/community/ActivityArtworkPreview.jsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function ActivityArtworkPreview({ artwork }) {
|
||||
if (!artwork?.url || !artwork?.thumb) return null
|
||||
|
||||
return (
|
||||
<a
|
||||
href={artwork.url}
|
||||
className="group block w-full shrink-0 overflow-hidden rounded-2xl border border-white/[0.08] bg-white/[0.03] sm:w-[120px]"
|
||||
>
|
||||
<div className="aspect-[6/5] overflow-hidden bg-black/20">
|
||||
<img
|
||||
src={artwork.thumb}
|
||||
alt={artwork.title || 'Artwork'}
|
||||
className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.04]"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-white/[0.06] px-3 py-2">
|
||||
<p className="truncate text-[11px] font-medium text-white/65">{artwork.title || 'Artwork'}</p>
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
44
resources/js/components/community/ActivityAvatar.jsx
Normal file
44
resources/js/components/community/ActivityAvatar.jsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React from 'react'
|
||||
|
||||
const FALLBACK_AVATAR = 'https://files.skinbase.org/default/avatar_default.webp'
|
||||
|
||||
const BADGE_TONES = {
|
||||
rose: 'border-rose-400/25 bg-rose-500/10 text-rose-200',
|
||||
amber: 'border-amber-400/25 bg-amber-500/10 text-amber-200',
|
||||
sky: 'border-sky-400/25 bg-sky-500/10 text-sky-200',
|
||||
}
|
||||
|
||||
export default function ActivityAvatar({ user }) {
|
||||
if (!user) return null
|
||||
|
||||
const badgeClassName = BADGE_TONES[user.badge?.tone] || BADGE_TONES.sky
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<a href={user.profile_url || '#'} className="shrink-0">
|
||||
<img
|
||||
src={user.avatar_url || FALLBACK_AVATAR}
|
||||
alt={user.name || user.username || 'User'}
|
||||
className="h-11 w-11 rounded-2xl object-cover ring-1 ring-white/10"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(event) => {
|
||||
event.currentTarget.src = FALLBACK_AVATAR
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
|
||||
<div className="min-w-0">
|
||||
<a href={user.profile_url || '#'} className="truncate text-sm font-semibold text-white hover:text-sky-200 transition-colors">
|
||||
{user.name || user.username || 'User'}
|
||||
</a>
|
||||
{user.username && <p className="truncate text-xs text-white/35">@{user.username}</p>}
|
||||
{user.badge && (
|
||||
<span className={`mt-1 inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.16em] ${badgeClassName}`}>
|
||||
{user.badge.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
88
resources/js/components/community/ActivityCard.jsx
Normal file
88
resources/js/components/community/ActivityCard.jsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React from 'react'
|
||||
import ActivityAvatar from './ActivityAvatar'
|
||||
import ActivityArtworkPreview from './ActivityArtworkPreview'
|
||||
import ActivityReactions from './ActivityReactions'
|
||||
|
||||
function ActivityHeadline({ activity }) {
|
||||
const artworkLink = activity?.artwork?.url
|
||||
const artworkTitle = activity?.artwork?.title || 'an artwork'
|
||||
const mentionedUser = activity?.mentioned_user
|
||||
const reaction = activity?.reaction
|
||||
const commentAuthor = activity?.comment?.author
|
||||
|
||||
switch (activity?.type) {
|
||||
case 'comment':
|
||||
return (
|
||||
<p className="text-sm leading-6 text-white/70">
|
||||
<span className="font-medium text-white">commented on </span>
|
||||
{artworkLink ? <a href={artworkLink} className="text-sky-300 hover:text-sky-200">{artworkTitle}</a> : <span className="text-white">{artworkTitle}</span>}
|
||||
</p>
|
||||
)
|
||||
case 'reply':
|
||||
return (
|
||||
<p className="text-sm leading-6 text-white/70">
|
||||
<span className="font-medium text-white">replied on </span>
|
||||
{artworkLink ? <a href={artworkLink} className="text-sky-300 hover:text-sky-200">{artworkTitle}</a> : <span className="text-white">{artworkTitle}</span>}
|
||||
</p>
|
||||
)
|
||||
case 'reaction':
|
||||
return (
|
||||
<p className="text-sm leading-6 text-white/70">
|
||||
<span className="font-medium text-white">reacted {reaction?.emoji || '👍'} {reaction?.label || 'Like'} </span>
|
||||
<span>to </span>
|
||||
{commentAuthor?.profile_url ? <a href={commentAuthor.profile_url} className="text-sky-300 hover:text-sky-200">{commentAuthor.name || commentAuthor.username || 'a creator'}</a> : <span className="text-white">a creator</span>}
|
||||
<span> on </span>
|
||||
{artworkLink ? <a href={artworkLink} className="text-sky-300 hover:text-sky-200">{artworkTitle}</a> : <span className="text-white">{artworkTitle}</span>}
|
||||
</p>
|
||||
)
|
||||
case 'mention':
|
||||
return (
|
||||
<p className="text-sm leading-6 text-white/70">
|
||||
<span className="font-medium text-white">mentioned </span>
|
||||
{mentionedUser?.profile_url ? <a href={mentionedUser.profile_url} className="text-sky-300 hover:text-sky-200">@{mentionedUser.username || mentionedUser.name}</a> : <span className="text-white">someone</span>}
|
||||
<span> on </span>
|
||||
{artworkLink ? <a href={artworkLink} className="text-sky-300 hover:text-sky-200">{artworkTitle}</a> : <span className="text-white">{artworkTitle}</span>}
|
||||
</p>
|
||||
)
|
||||
default:
|
||||
return <p className="text-sm leading-6 text-white/70">Shared new activity.</p>
|
||||
}
|
||||
}
|
||||
|
||||
export default function ActivityCard({ activity, isLoggedIn = false }) {
|
||||
return (
|
||||
<article className="rounded-[28px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(11,16,26,0.96),rgba(7,11,19,0.92))] p-4 shadow-[0_18px_45px_rgba(0,0,0,0.28)] backdrop-blur-xl sm:p-5">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start">
|
||||
<div className="sm:w-[220px] sm:shrink-0">
|
||||
<ActivityAvatar user={activity.user} />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<ActivityHeadline activity={activity} />
|
||||
<span className="text-[11px] uppercase tracking-[0.18em] text-white/25">{activity.time_ago || ''}</span>
|
||||
</div>
|
||||
|
||||
{activity.comment?.body ? (
|
||||
<div className="mt-3 rounded-2xl border border-white/[0.06] bg-white/[0.025] px-4 py-3">
|
||||
<p className="whitespace-pre-line break-words text-sm leading-6 text-white/80">{activity.comment.body}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activity.type === 'mention' && activity.mentioned_user ? (
|
||||
<div className="mt-3 inline-flex items-center gap-2 rounded-full border border-sky-400/20 bg-sky-500/10 px-3 py-1 text-xs text-sky-200">
|
||||
<i className="fa-solid fa-at" />
|
||||
Mentioned @{activity.mentioned_user.username || activity.mentioned_user.name}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ActivityReactions activity={activity} isLoggedIn={isLoggedIn} />
|
||||
</div>
|
||||
|
||||
<div className="sm:ml-auto">
|
||||
<ActivityArtworkPreview artwork={activity.artwork} />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
80
resources/js/components/community/ActivityFeed.jsx
Normal file
80
resources/js/components/community/ActivityFeed.jsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import React from 'react'
|
||||
import ActivityCard from './ActivityCard'
|
||||
|
||||
function ActivitySkeleton() {
|
||||
return (
|
||||
<div className="space-y-4 animate-pulse">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div key={index} className="rounded-[28px] border border-white/[0.06] bg-white/[0.025] p-5">
|
||||
<div className="flex flex-col gap-4 sm:flex-row">
|
||||
<div className="flex items-start gap-3 sm:w-[220px]">
|
||||
<div className="h-11 w-11 rounded-2xl bg-white/[0.08]" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-3 w-24 rounded bg-white/[0.08]" />
|
||||
<div className="h-2.5 w-16 rounded bg-white/[0.06]" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="h-3 w-4/5 rounded bg-white/[0.08]" />
|
||||
<div className="rounded-2xl border border-white/[0.04] bg-white/[0.02] px-4 py-3">
|
||||
<div className="h-3 w-full rounded bg-white/[0.06]" />
|
||||
<div className="mt-2 h-3 w-3/4 rounded bg-white/[0.05]" />
|
||||
</div>
|
||||
<div className="h-8 w-48 rounded-full bg-white/[0.05]" />
|
||||
</div>
|
||||
<div className="h-[132px] w-full rounded-2xl bg-white/[0.05] sm:w-[120px]" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState({ isFiltered }) {
|
||||
return (
|
||||
<div className="rounded-[28px] border border-white/[0.06] bg-white/[0.025] px-6 py-16 text-center">
|
||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border border-white/[0.06] bg-white/[0.03] text-white/35">
|
||||
<i className="fa-solid fa-wave-square text-xl" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white/80">No activity yet</h3>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-white/45">
|
||||
{isFiltered ? 'This filter has no recent activity right now.' : 'When creators and members interact around artworks, their activity will appear here.'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ActivityFeed({
|
||||
activities = [],
|
||||
isLoggedIn = false,
|
||||
loading = false,
|
||||
loadingMore = false,
|
||||
error = null,
|
||||
sentinelRef,
|
||||
}) {
|
||||
if (loading && activities.length === 0) {
|
||||
return <ActivitySkeleton />
|
||||
}
|
||||
|
||||
if (!loading && activities.length === 0) {
|
||||
return <EmptyState isFiltered={Boolean(error) === false} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{error ? (
|
||||
<div className="rounded-2xl border border-rose-500/20 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activities.map((activity) => (
|
||||
<ActivityCard key={activity.id} activity={activity} isLoggedIn={isLoggedIn} />
|
||||
))}
|
||||
|
||||
{loadingMore ? <ActivitySkeleton /> : null}
|
||||
|
||||
<div ref={sentinelRef} className="h-6" aria-hidden="true" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
39
resources/js/components/community/ActivityReactions.jsx
Normal file
39
resources/js/components/community/ActivityReactions.jsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React from 'react'
|
||||
import ReactionBar from '../comments/ReactionBar'
|
||||
|
||||
export default function ActivityReactions({ activity, isLoggedIn = false }) {
|
||||
const commentId = activity?.comment?.id || null
|
||||
const commentUrl = activity?.comment?.url || activity?.artwork?.url || '#'
|
||||
const artworkUrl = activity?.artwork?.url || null
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3 pt-2">
|
||||
{commentId ? (
|
||||
<ReactionBar
|
||||
entityType="comment"
|
||||
entityId={commentId}
|
||||
initialTotals={activity?.comment?.reactions || {}}
|
||||
isLoggedIn={isLoggedIn}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<a
|
||||
href={commentUrl}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-white/[0.08] bg-white/[0.03] px-3 py-1.5 text-xs font-medium text-white/55 transition hover:border-sky-400/30 hover:bg-sky-500/10 hover:text-sky-200"
|
||||
>
|
||||
<i className="fa-regular fa-comment-dots" />
|
||||
Reply
|
||||
</a>
|
||||
|
||||
{artworkUrl ? (
|
||||
<a
|
||||
href={artworkUrl}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-white/[0.08] bg-white/[0.03] px-3 py-1.5 text-xs font-medium text-white/55 transition hover:border-white/15 hover:bg-white/[0.07] hover:text-white"
|
||||
>
|
||||
<i className="fa-regular fa-image" />
|
||||
View artwork
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,24 +1,64 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
@php
|
||||
$headerBreadcrumbs = collect([
|
||||
(object) ['name' => $page_title ?? 'Community Activity', 'url' => route('community.activity')],
|
||||
]);
|
||||
|
||||
{{-- Inline props for the React component --}}
|
||||
<script id="latest-comments-props" type="application/json">
|
||||
$initialFilterLabel = match (($initialFilter ?? 'all')) {
|
||||
'comments' => 'Comments',
|
||||
'replies' => 'Replies',
|
||||
'following' => 'Following',
|
||||
'my' => 'My Activity',
|
||||
default => 'All Activity',
|
||||
};
|
||||
@endphp
|
||||
|
||||
@section('content')
|
||||
<x-nova-page-header
|
||||
section="Community"
|
||||
:title="$page_title ?? 'Community Activity'"
|
||||
icon="fa-wave-square"
|
||||
:breadcrumbs="$headerBreadcrumbs"
|
||||
description="Track comments, replies, reactions, and mentions from across the Skinbase community in one live feed."
|
||||
headerClass="pb-6"
|
||||
>
|
||||
<x-slot name="actions">
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs font-medium">
|
||||
<span id="community-activity-filter-summary" class="inline-flex items-center gap-1.5 rounded-full border border-sky-400/20 bg-sky-500/10 px-3 py-1 text-sky-200">
|
||||
<i class="fa-solid fa-filter"></i>
|
||||
{{ $initialFilterLabel }}
|
||||
</span>
|
||||
@if (!empty($initialUserId))
|
||||
<span id="community-activity-scope-summary" class="inline-flex items-center gap-1.5 rounded-full border border-white/[0.08] bg-white/[0.04] px-3 py-1 text-white/65">
|
||||
<i class="fa-solid fa-user"></i>
|
||||
User #{{ $initialUserId }}
|
||||
</span>
|
||||
@else
|
||||
<span id="community-activity-scope-summary" class="hidden"></span>
|
||||
@endif
|
||||
</div>
|
||||
</x-slot>
|
||||
</x-nova-page-header>
|
||||
|
||||
<script id="community-activity-props" type="application/json">
|
||||
{!! json_encode($props, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP) !!}
|
||||
</script>
|
||||
|
||||
<div id="latest-comments-root" class="min-h-screen">
|
||||
{{-- SSR skeleton replaced on React hydration --}}
|
||||
<div class="px-4 pt-10 pb-20 sm:px-6 lg:px-8 max-w-5xl mx-auto">
|
||||
<div class="mb-8">
|
||||
<p class="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Community</p>
|
||||
<h1 class="text-3xl font-bold text-white leading-tight">{{ $page_title }}</h1>
|
||||
<p class="mt-1 text-sm text-white/50">Most recent artwork comments from the community.</p>
|
||||
<div id="community-activity-root" class="min-h-[480px]">
|
||||
<div class="mx-auto max-w-6xl px-6 pt-8 pb-20 md:px-10">
|
||||
<div class="mb-6 rounded-[28px] border border-white/[0.06] bg-white/[0.025] p-5 shadow-[0_18px_45px_rgba(0,0,0,0.22)]">
|
||||
<div class="h-3 w-40 animate-pulse rounded bg-white/[0.08]"></div>
|
||||
<div class="mt-3 h-3 w-2/3 animate-pulse rounded bg-white/[0.06]"></div>
|
||||
<div class="mt-5 flex gap-2">
|
||||
<div class="h-10 w-28 animate-pulse rounded-full bg-white/[0.06]"></div>
|
||||
<div class="h-10 w-24 animate-pulse rounded-full bg-white/[0.05]"></div>
|
||||
<div class="h-10 w-24 animate-pulse rounded-full bg-white/[0.05]"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-4 border-nova-500 border-t-transparent mx-auto mt-20"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@vite(['resources/js/Pages/Community/LatestCommentsPage.jsx'])
|
||||
@vite(['resources/js/Pages/Community/CommunityActivityPage.jsx'])
|
||||
|
||||
@endsection
|
||||
|
||||
Reference in New Issue
Block a user