messages implemented
This commit is contained in:
@@ -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">
|
||||
|
||||
127
resources/js/Pages/Community/LatestCommentsPage.jsx
Normal file
127
resources/js/Pages/Community/LatestCommentsPage.jsx
Normal 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
|
||||
241
resources/js/Pages/Messages/Index.jsx
Normal file
241
resources/js/Pages/Messages/Index.jsx
Normal 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
|
||||
Reference in New Issue
Block a user