messages implemented

This commit is contained in:
2026-02-26 21:12:32 +01:00
parent d0aefc5ddc
commit 15b7b77d20
168 changed files with 14728 additions and 6786 deletions

View File

@@ -10,6 +10,7 @@ import ArtworkAuthor from '../components/artwork/ArtworkAuthor'
import ArtworkRelated from '../components/artwork/ArtworkRelated'
import ArtworkDescription from '../components/artwork/ArtworkDescription'
import ArtworkComments from '../components/artwork/ArtworkComments'
import ArtworkReactions from '../components/artwork/ArtworkReactions'
import ArtworkNavigator from '../components/viewer/ArtworkNavigator'
import ArtworkViewer from '../components/viewer/ArtworkViewer'
@@ -80,7 +81,13 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present
<ArtworkStats artwork={artwork} />
<ArtworkTags artwork={artwork} />
<ArtworkDescription artwork={artwork} />
<ArtworkComments comments={comments} />
<ArtworkReactions artworkId={artwork.id} isLoggedIn={isAuthenticated} />
<ArtworkComments
artworkId={artwork.id}
comments={comments}
isLoggedIn={isAuthenticated}
loginUrl="/login"
/>
</div>
<aside className="hidden space-y-6 lg:block">

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

View File

@@ -0,0 +1,241 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { createRoot } from 'react-dom/client'
import ConversationList from '../../components/messaging/ConversationList'
import ConversationThread from '../../components/messaging/ConversationThread'
import NewConversationModal from '../../components/messaging/NewConversationModal'
// ── helpers ──────────────────────────────────────────────────────────────────
function getCsrf() {
return document.querySelector('meta[name="csrf-token"]')?.content ?? ''
}
async function apiFetch(url, options = {}) {
const isFormData = options.body instanceof FormData
const headers = {
'X-CSRF-TOKEN': getCsrf(),
Accept: 'application/json',
...options.headers,
}
if (!isFormData) {
headers['Content-Type'] = 'application/json'
}
const res = await fetch(url, {
headers,
...options,
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err.message ?? `HTTP ${res.status}`)
}
return res.json()
}
// ── MessagesPage ─────────────────────────────────────────────────────────────
function MessagesPage({ userId, username, activeConversationId: initialId }) {
const [conversations, setConversations] = useState([])
const [loadingConvs, setLoadingConvs] = useState(true)
const [activeId, setActiveId] = useState(initialId ?? null)
const [showNewModal, setShowNewModal] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [searchResults, setSearchResults] = useState([])
const [searching, setSearching] = useState(false)
const pollRef = useRef(null)
// ── Load conversations list ────────────────────────────────────────────────
const loadConversations = useCallback(async () => {
try {
const data = await apiFetch('/api/messages/conversations')
setConversations(data.data ?? [])
} catch (e) {
console.error('Failed to load conversations', e)
} finally {
setLoadingConvs(false)
}
}, [])
useEffect(() => {
loadConversations()
// Phase 1 polling: refresh conversation list every 15 seconds
pollRef.current = setInterval(loadConversations, 15_000)
return () => clearInterval(pollRef.current)
}, [loadConversations])
const handleSelectConversation = useCallback((id) => {
setActiveId(id)
history.replaceState(null, '', `/messages/${id}`)
}, [])
const handleConversationCreated = useCallback((conv) => {
setShowNewModal(false)
loadConversations()
setActiveId(conv.id)
history.replaceState(null, '', `/messages/${conv.id}`)
}, [loadConversations])
const handleMarkRead = useCallback((conversationId) => {
setConversations(prev =>
prev.map(c => c.id === conversationId ? { ...c, unread_count: 0 } : c)
)
}, [])
useEffect(() => {
let cancelled = false
const run = async () => {
const q = searchQuery.trim()
if (q.length < 2) {
setSearchResults([])
setSearching(false)
return
}
setSearching(true)
try {
const data = await apiFetch(`/api/messages/search?q=${encodeURIComponent(q)}`)
if (!cancelled) {
setSearchResults(data.data ?? [])
}
} catch (_) {
if (!cancelled) {
setSearchResults([])
}
} finally {
if (!cancelled) {
setSearching(false)
}
}
}
const timer = setTimeout(run, 250)
return () => {
cancelled = true
clearTimeout(timer)
}
}, [searchQuery])
const openSearchResult = useCallback((item) => {
if (!item?.conversation_id) return
setActiveId(item.conversation_id)
history.replaceState(null, '', `/messages/${item.conversation_id}?focus=${item.id}`)
}, [])
const activeConversation = conversations.find(c => c.id === activeId) ?? null
return (
<div className="mx-auto max-w-screen-xl px-4 sm:px-6 lg:px-8 py-6">
<div className="flex h-[calc(100vh-10rem)] overflow-hidden rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 shadow-sm">
{/* ── Left panel: conversation list ─────────────────────────────── */}
<aside className={`w-full sm:w-80 flex-shrink-0 border-r border-gray-200 dark:border-gray-700 flex flex-col ${activeId ? 'hidden sm:flex' : 'flex'}`}>
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h1 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Messages</h1>
<button
onClick={() => setShowNewModal(true)}
className="rounded-full p-1.5 text-gray-500 hover:text-gray-900 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
title="New message"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clipRule="evenodd" />
</svg>
</button>
</div>
<div className="px-3 py-2 border-b border-gray-100 dark:border-gray-800">
<input
type="search"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="Search all messages…"
className="w-full rounded-lg bg-gray-100 dark:bg-gray-800 px-3 py-1.5 text-sm text-gray-900 dark:text-gray-100 placeholder-gray-400 outline-none focus:ring-2 focus:ring-blue-500"
/>
{searching && <p className="mt-1 text-[11px] text-gray-400">Searching</p>}
</div>
{searchQuery.trim().length >= 2 && (
<div className="border-b border-gray-100 dark:border-gray-800 max-h-44 overflow-y-auto">
{searchResults.length === 0 && !searching && (
<p className="px-3 py-2 text-xs text-gray-400">No results.</p>
)}
{searchResults.map(item => (
<button
key={`search-${item.id}`}
onClick={() => openSearchResult(item)}
className="w-full text-left px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-800/60 border-b border-gray-100 dark:border-gray-800"
>
<p className="text-xs text-gray-500">@{item.sender?.username ?? 'unknown'} · {new Date(item.created_at).toLocaleString()}</p>
<p className="text-sm text-gray-800 dark:text-gray-200 truncate">{item.body || '(attachment)'}</p>
</button>
))}
</div>
)}
<ConversationList
conversations={conversations}
loading={loadingConvs}
activeId={activeId}
currentUserId={userId}
onSelect={handleSelectConversation}
/>
</aside>
{/* ── Right panel: thread ───────────────────────────────────────── */}
<main className={`flex-1 flex flex-col min-w-0 ${activeId ? 'flex' : 'hidden sm:flex'}`}>
{activeId ? (
<ConversationThread
key={activeId}
conversationId={activeId}
conversation={activeConversation}
currentUserId={userId}
currentUsername={username}
apiFetch={apiFetch}
onBack={() => { setActiveId(null); history.replaceState(null, '', '/messages') }}
onMarkRead={handleMarkRead}
onConversationUpdated={loadConversations}
/>
) : (
<div className="flex flex-1 items-center justify-center text-gray-400 dark:text-gray-600">
<div className="text-center">
<svg xmlns="http://www.w3.org/2000/svg" className="mx-auto h-12 w-12 mb-3 opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
<p className="text-sm">Select a conversation or start a new one</p>
</div>
</div>
)}
</main>
</div>
{showNewModal && (
<NewConversationModal
currentUserId={userId}
apiFetch={apiFetch}
onCreated={handleConversationCreated}
onClose={() => setShowNewModal(false)}
/>
)}
</div>
)
}
// ── Mount ────────────────────────────────────────────────────────────────────
const el = document.getElementById('messages-root')
if (el) {
function parse(key, fallback = null) {
try { return JSON.parse(el.dataset[key] ?? 'null') ?? fallback } catch { return fallback }
}
createRoot(el).render(
<MessagesPage
userId={parse('userId')}
username={parse('username', '')}
activeConversationId={parse('activeConversationId')}
/>
)
}
export default MessagesPage