Save workspace changes

This commit is contained in:
2026-04-18 17:02:56 +02:00
parent f02ea9a711
commit 87d60af5a9
4220 changed files with 1388603 additions and 1554 deletions

View 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/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

View File

@@ -0,0 +1,127 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { createRoot } from 'react-dom/client'
import CommentsFeed from '../../components/comments/CommentsFeed'
const FILTER_TABS = [
{ key: 'all', label: 'All' },
{ key: 'following', label: 'Following', authRequired: true },
{ key: 'mine', label: 'My Comments', authRequired: true },
]
function LatestCommentsPage({ initialComments = [], initialMeta = {}, isAuthenticated = false }) {
const [activeFilter, setActiveFilter] = useState('all')
const [comments, setComments] = useState(initialComments)
const [meta, setMeta] = useState(initialMeta)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
// Track if we've moved off the initial server-rendered data
const initialized = useRef(false)
const fetchComments = useCallback(async (filter, page = 1) => {
setLoading(true)
setError(null)
try {
const url = `/api/comments/latest?type=${encodeURIComponent(filter)}&page=${page}`
const res = await fetch(url, {
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
credentials: 'same-origin',
})
if (res.status === 401) {
setError('Please log in to view this feed.')
setComments([])
setMeta({})
return
}
if (! res.ok) {
setError('Failed to load comments. Please try again.')
return
}
const json = await res.json()
setComments(json.data ?? [])
setMeta(json.meta ?? {})
} catch {
setError('Network error. Please try again.')
} finally {
setLoading(false)
}
}, [])
const handleFilterChange = (key) => {
if (key === activeFilter) return
setActiveFilter(key)
initialized.current = true
fetchComments(key, 1)
}
const handlePageChange = (page) => {
initialized.current = true
fetchComments(activeFilter, page)
window.scrollTo({ top: 0, behavior: 'smooth' })
}
return (
<div className="px-4 pt-10 pb-20 sm:px-6 lg:px-8 max-w-5xl mx-auto">
{/* Page header */}
<div className="mb-7">
<p className="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Community</p>
<h1 className="text-3xl font-bold text-white leading-tight">Latest Comments</h1>
<p className="mt-1 text-sm text-white/50">Most recent artwork comments from the community.</p>
</div>
{/* Filter tabs — pill style */}
<div className="flex items-center gap-2 mb-6">
{FILTER_TABS.map((tab) => {
const disabled = tab.authRequired && !isAuthenticated
const active = activeFilter === tab.key
return (
<button
key={tab.key}
onClick={() => !disabled && handleFilterChange(tab.key)}
disabled={disabled}
aria-current={active ? 'page' : undefined}
title={disabled ? 'Log in to use this filter' : undefined}
className={[
'px-4 py-1.5 rounded-full text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500',
active
? 'bg-sky-600/25 text-sky-300 ring-1 ring-sky-500/40'
: 'text-white/50 hover:text-white/80 hover:bg-white/[0.06]',
disabled && 'opacity-30 cursor-not-allowed',
].filter(Boolean).join(' ')}
>
{tab.label}
</button>
)
})}
</div>
{/* Feed content */}
<CommentsFeed
comments={comments}
meta={meta}
loading={loading}
error={error}
onPageChange={handlePageChange}
/>
</div>
)
}
// Auto-mount when the Blade view provides #latest-comments-root
const mountEl = document.getElementById('latest-comments-root')
if (mountEl) {
let props = {}
try {
const propsEl = document.getElementById('latest-comments-props')
props = propsEl ? JSON.parse(propsEl.textContent || '{}') : {}
} catch {
props = {}
}
createRoot(mountEl).render(<LatestCommentsPage {...props} />)
}
export default LatestCommentsPage