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
|
||||
@@ -1,7 +1,9 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
||||
|
||||
const SEARCH_API = '/api/search/artworks'
|
||||
const DEBOUNCE_MS = 280
|
||||
const ARTWORKS_API = '/api/search/artworks'
|
||||
const TAGS_API = '/api/tags/search'
|
||||
const USERS_API = '/api/search/users'
|
||||
const DEBOUNCE_MS = 280
|
||||
|
||||
function useDebounce(value, delay) {
|
||||
const [debounced, setDebounced] = useState(value)
|
||||
@@ -12,36 +14,108 @@ function useDebounce(value, delay) {
|
||||
return debounced
|
||||
}
|
||||
|
||||
export default function SearchBar({ placeholder = 'Search artworks, artists, tags…' }) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [suggestions, setSuggestions] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
const inputRef = useRef(null)
|
||||
const wrapperRef = useRef(null)
|
||||
const abortRef = useRef(null)
|
||||
const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPod|iPad/.test(navigator.platform)
|
||||
|
||||
export default function SearchBar({ placeholder = 'Search artworks, artists, tags\u2026' }) {
|
||||
const [phase, setPhase] = useState('idle') // idle | opening | open | closing
|
||||
const [query, setQuery] = useState('')
|
||||
const [artworks, setArtworks] = useState([])
|
||||
const [tags, setTags] = useState([])
|
||||
const [users, setUsers] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [activeIdx, setActiveIdx] = useState(-1)
|
||||
|
||||
const inputRef = useRef(null)
|
||||
const wrapperRef = useRef(null)
|
||||
const abortRef = useRef(null)
|
||||
const openTimerRef = useRef(null)
|
||||
const closeTimerRef = useRef(null)
|
||||
|
||||
const debouncedQuery = useDebounce(query, DEBOUNCE_MS)
|
||||
const isExpanded = phase === 'opening' || phase === 'open'
|
||||
|
||||
const fetchSuggestions = useCallback(async (q) => {
|
||||
if (!q || q.length < 2) {
|
||||
setSuggestions([])
|
||||
setOpen(false)
|
||||
return
|
||||
// flat list of navigable items: artworks → users → tags
|
||||
const allItems = [
|
||||
...artworks.map(a => ({ type: 'artwork', ...a })),
|
||||
...users.map(u => ({ type: 'user', ...u })),
|
||||
...tags.map(t => ({ type: 'tag', ...t })),
|
||||
]
|
||||
|
||||
// ── expand / collapse ────────────────────────────────────────────────────
|
||||
function expand() {
|
||||
clearTimeout(closeTimerRef.current)
|
||||
setPhase('opening')
|
||||
openTimerRef.current = setTimeout(() => {
|
||||
setPhase('open')
|
||||
inputRef.current?.focus()
|
||||
}, 80)
|
||||
}
|
||||
|
||||
function collapse() {
|
||||
clearTimeout(openTimerRef.current)
|
||||
setPhase('closing')
|
||||
setOpen(false)
|
||||
setActiveIdx(-1)
|
||||
closeTimerRef.current = setTimeout(() => {
|
||||
setPhase('idle')
|
||||
setQuery('')
|
||||
setArtworks([])
|
||||
setTags([])
|
||||
setUsers([])
|
||||
}, 160)
|
||||
}
|
||||
|
||||
// ── Ctrl/Cmd+K ───────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
function onKey(e) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); expand() }
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => document.removeEventListener('keydown', onKey)
|
||||
}, [])
|
||||
|
||||
// ── outside click ────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
function onMouse(e) {
|
||||
if (wrapperRef.current && !wrapperRef.current.contains(e.target)) collapse()
|
||||
}
|
||||
document.addEventListener('mousedown', onMouse)
|
||||
return () => document.removeEventListener('mousedown', onMouse)
|
||||
}, [])
|
||||
|
||||
// ── fetch (parallel artworks + tags) ────────────────────────────────────
|
||||
const fetchSuggestions = useCallback(async (q) => {
|
||||
const bare = q?.replace(/^@+/, '') ?? ''
|
||||
if (!bare || bare.length < 2) {
|
||||
setArtworks([]); setTags([]); setUsers([]); setOpen(false); return
|
||||
}
|
||||
if (abortRef.current) abortRef.current.abort()
|
||||
abortRef.current = new AbortController()
|
||||
|
||||
const sig = abortRef.current.signal
|
||||
setLoading(true)
|
||||
try {
|
||||
const url = `${SEARCH_API}?q=${encodeURIComponent(q)}&per_page=6`
|
||||
const res = await fetch(url, { signal: abortRef.current.signal })
|
||||
if (!res.ok) return
|
||||
const json = await res.json()
|
||||
const items = json.data ?? json ?? []
|
||||
setSuggestions(Array.isArray(items) ? items.slice(0, 6) : [])
|
||||
setOpen(true)
|
||||
const isAtMention = q.startsWith('@')
|
||||
// if user typed @foo: emphasise users (4 slots) and skip artworks
|
||||
const fetchArt = isAtMention
|
||||
? Promise.resolve(null)
|
||||
: fetch(`${ARTWORKS_API}?q=${encodeURIComponent(bare)}&per_page=4`, { signal: sig })
|
||||
const fetchUsers = fetch(`${USERS_API}?q=${encodeURIComponent(q)}&per_page=4`, { signal: sig })
|
||||
const fetchTags = isAtMention
|
||||
? Promise.resolve(null)
|
||||
: fetch(`${TAGS_API}?q=${encodeURIComponent(bare)}&per_page=3`, { signal: sig })
|
||||
const [artRes, userRes, tagRes] = await Promise.all([fetchArt, fetchUsers, fetchTags])
|
||||
const artJson = artRes && artRes.ok ? await artRes.json() : {}
|
||||
const userJson = userRes && userRes.ok ? await userRes.json() : {}
|
||||
const tagJson = tagRes && tagRes.ok ? await tagRes.json() : {}
|
||||
const artItems = Array.isArray(artJson.data ?? artJson) ? (artJson.data ?? artJson).slice(0, 4) : []
|
||||
const userItems = Array.isArray(userJson.data ?? userJson) ? (userJson.data ?? userJson).slice(0, 4) : []
|
||||
const tagItems = Array.isArray(tagJson.data ?? tagJson) ? (tagJson.data ?? tagJson).slice(0, 3) : []
|
||||
setArtworks(artItems)
|
||||
setUsers(userItems)
|
||||
setTags(tagItems)
|
||||
setActiveIdx(-1)
|
||||
setOpen(artItems.length > 0 || userItems.length > 0 || tagItems.length > 0)
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') console.error('SearchBar fetch error', e)
|
||||
} finally {
|
||||
@@ -49,104 +123,207 @@ export default function SearchBar({ placeholder = 'Search artworks, artists, tag
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchSuggestions(debouncedQuery)
|
||||
}, [debouncedQuery, fetchSuggestions])
|
||||
useEffect(() => { fetchSuggestions(debouncedQuery) }, [debouncedQuery, fetchSuggestions])
|
||||
|
||||
// Close suggestions on outside click
|
||||
useEffect(() => {
|
||||
function handler(e) {
|
||||
if (wrapperRef.current && !wrapperRef.current.contains(e.target)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [])
|
||||
// ── navigation helpers ───────────────────────────────────────────────────
|
||||
function navigate(item) {
|
||||
if (item.type === 'artwork') window.location.href = item.urls?.web ?? `/${item.slug ?? ''}`
|
||||
else if (item.type === 'user') window.location.href = item.profile_url ?? `/@${item.username}`
|
||||
else window.location.href = `/tags/${item.slug ?? item.name}`
|
||||
}
|
||||
|
||||
function handleSubmit(e) {
|
||||
e.preventDefault()
|
||||
if (query.trim()) {
|
||||
window.location.href = `/search?q=${encodeURIComponent(query.trim())}`
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelect(item) {
|
||||
window.location.href = item.urls?.web ?? `/${item.slug ?? ''}`
|
||||
if (activeIdx >= 0 && allItems[activeIdx]) { navigate(allItems[activeIdx]); return }
|
||||
if (query.trim()) window.location.href = `/search?q=${encodeURIComponent(query.trim())}`
|
||||
}
|
||||
|
||||
function handleKeyDown(e) {
|
||||
if (e.key === 'Escape') {
|
||||
setOpen(false)
|
||||
inputRef.current?.blur()
|
||||
if (e.key === 'Escape') { collapse(); return }
|
||||
if (!open || allItems.length === 0) return
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setActiveIdx(i => (i + 1) % allItems.length)
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setActiveIdx(i => (i <= 0 ? allItems.length - 1 : i - 1))
|
||||
} else if (e.key === 'Enter' && activeIdx >= 0) {
|
||||
e.preventDefault()
|
||||
navigate(allItems[activeIdx])
|
||||
}
|
||||
}
|
||||
|
||||
// ── widths / opacities ───────────────────────────────────────────────────
|
||||
const pillOpacity = phase === 'idle' ? 1 : 0
|
||||
const formOpacity = phase === 'open' ? 1 : 0
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="relative w-full">
|
||||
<form onSubmit={handleSubmit} role="search" className="relative">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
onFocus={() => suggestions.length > 0 && setOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
aria-label="Search"
|
||||
autoComplete="off"
|
||||
className="w-full bg-nova-900 border border-nova-800 rounded-lg py-2.5 pl-3.5 pr-10 text-white placeholder-soft outline-none focus:border-accent transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
aria-label="Submit search"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-soft hover:text-accent transition-colors"
|
||||
>
|
||||
{loading
|
||||
? <svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"/></svg>
|
||||
: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2" aria-hidden="true"><path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z"/></svg>
|
||||
}
|
||||
</button>
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
style={{
|
||||
position: 'relative',
|
||||
height: '40px',
|
||||
width: isExpanded ? '100%' : '168px',
|
||||
maxWidth: isExpanded ? '560px' : '168px',
|
||||
transition: 'width 340ms cubic-bezier(0.16,1,0.3,1), max-width 340ms cubic-bezier(0.16,1,0.3,1)',
|
||||
}}
|
||||
>
|
||||
{/* ── COLLAPSED PILL ── */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={expand}
|
||||
aria-label="Open search"
|
||||
style={{ position: 'absolute', inset: 0, opacity: pillOpacity, pointerEvents: phase === 'idle' ? 'auto' : 'none', transition: 'opacity 120ms ease' }}
|
||||
className="w-full h-full flex items-center gap-2.5 px-3.5 rounded-lg
|
||||
bg-white/[0.05] border border-white/[0.09]
|
||||
text-soft hover:bg-white/[0.1] hover:border-white/20 hover:text-white transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z"/>
|
||||
</svg>
|
||||
<span className="text-sm flex-1 text-left truncate">Search\u2026</span>
|
||||
<kbd className="shrink-0 inline-flex items-center gap-0.5 text-[10px] font-medium px-1.5 py-0.5 rounded-md bg-white/[0.06] border border-white/[0.10] text-white/30">
|
||||
{isMac ? '\u2318' : 'Ctrl'}K
|
||||
</kbd>
|
||||
</button>
|
||||
|
||||
{/* ── EXPANDED FORM ── */}
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
role="search"
|
||||
style={{ position: 'absolute', inset: 0, opacity: formOpacity, pointerEvents: phase === 'open' ? 'auto' : 'none', transition: 'opacity 180ms ease 60ms' }}
|
||||
>
|
||||
<div className="relative h-full">
|
||||
<svg className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-soft pointer-events-none"
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z"/>
|
||||
</svg>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={e => { setQuery(e.target.value); setActiveIdx(-1) }}
|
||||
onFocus={() => (artworks.length > 0 || tags.length > 0) && setOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
aria-label="Search"
|
||||
aria-autocomplete="list"
|
||||
aria-controls="sb-suggestions"
|
||||
aria-activedescendant={activeIdx >= 0 ? `sb-item-${activeIdx}` : undefined}
|
||||
autoComplete="off"
|
||||
className="w-full h-full bg-white/[0.06] border border-white/[0.12] rounded-lg
|
||||
py-0 pl-10 pr-16 text-sm text-white placeholder-soft outline-none
|
||||
focus:border-accent focus:ring-1 focus:ring-accent/30 transition-colors"
|
||||
/>
|
||||
<div className="absolute right-2.5 top-1/2 -translate-y-1/2 flex items-center gap-1.5">
|
||||
{loading && (
|
||||
<svg className="w-3.5 h-3.5 animate-spin text-soft" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"/>
|
||||
</svg>
|
||||
)}
|
||||
<kbd className="text-[10px] font-medium px-1.5 py-0.5 rounded-md bg-white/[0.06] border border-white/[0.10] text-white/30 pointer-events-none select-none">Esc</kbd>
|
||||
<button type="button" onClick={collapse} aria-label="Close search"
|
||||
className="w-5 h-5 flex items-center justify-center rounded-md text-white/30 hover:text-white hover:bg-white/10 transition-colors">
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{open && suggestions.length > 0 && (
|
||||
{/* ── SUGGESTIONS DROPDOWN ── */}
|
||||
{open && (artworks.length > 0 || tags.length > 0) && (
|
||||
<ul
|
||||
id="sb-suggestions"
|
||||
role="listbox"
|
||||
aria-label="Search suggestions"
|
||||
className="absolute top-full left-0 right-0 mt-1 bg-nova-900 border border-nova-800 rounded-xl shadow-2xl overflow-hidden z-50"
|
||||
className="absolute top-full left-0 right-0 mt-1.5 bg-[#151820] border border-white/[0.10] rounded-xl shadow-2xl overflow-hidden z-50"
|
||||
style={{ opacity: formOpacity, transition: 'opacity 180ms ease 60ms' }}
|
||||
>
|
||||
{suggestions.map((item) => (
|
||||
<li key={item.slug} role="option">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelect(item)}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 hover:bg-white/[0.06] text-left transition-colors"
|
||||
>
|
||||
{item.thumbnail_url && (
|
||||
<img
|
||||
src={item.thumbnail_url}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="w-10 h-10 rounded object-cover shrink-0 bg-nova-900"
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-white truncate">{item.title}</div>
|
||||
{item.author?.name && (
|
||||
<div className="text-xs text-neutral-400 truncate">{item.author.name}</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
<li className="border-t border-nova-900">
|
||||
<a
|
||||
href={`/search?q=${encodeURIComponent(query)}`}
|
||||
className="flex items-center justify-center gap-1.5 px-3 py-2 text-xs text-accent hover:text-accent/80 transition-colors"
|
||||
>
|
||||
{/* Artworks section */}
|
||||
{artworks.length > 0 && (
|
||||
<>
|
||||
<li role="presentation" className="px-3 pt-2.5 pb-1 text-[10px] font-semibold uppercase tracking-wider text-white/30 select-none">Artworks</li>
|
||||
{artworks.map((item, i) => (
|
||||
<li key={item.slug ?? i} role="option" id={`sb-item-${i}`} aria-selected={activeIdx === i}>
|
||||
<button type="button" onClick={() => navigate({ type: 'artwork', ...item })}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors ${activeIdx === i ? 'bg-white/[0.09]' : 'hover:bg-white/[0.06]'}`}>
|
||||
{item.thumbnail_url && (
|
||||
<img src={item.thumbnail_url} alt="" aria-hidden="true"
|
||||
className="w-9 h-9 rounded-lg object-cover shrink-0" loading="lazy" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-white truncate">{item.title}</div>
|
||||
{item.author?.name && <div className="text-xs text-neutral-400 truncate">{item.author.name}</div>}
|
||||
</div>
|
||||
<svg className="w-3.5 h-3.5 text-white/20 ml-auto shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Users / Creators section */}
|
||||
{users.length > 0 && (
|
||||
<>
|
||||
<li role="presentation" className={`px-3 pb-1 text-[10px] font-semibold uppercase tracking-wider text-white/30 select-none ${artworks.length > 0 ? 'pt-2 border-t border-white/[0.06]' : 'pt-2.5'}`}>Creators</li>
|
||||
{users.map((user, j) => {
|
||||
const flatIdx = artworks.length + j
|
||||
return (
|
||||
<li key={user.username} role="option" id={`sb-item-${flatIdx}`} aria-selected={activeIdx === flatIdx}>
|
||||
<button type="button" onClick={() => navigate({ type: 'user', ...user })}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 text-left transition-colors ${activeIdx === flatIdx ? 'bg-white/[0.09]' : 'hover:bg-white/[0.06]'}`}>
|
||||
<img src={user.avatar_url} alt="" aria-hidden="true"
|
||||
className="w-9 h-9 rounded-full object-cover shrink-0 bg-white/[0.04] border border-white/[0.08]" loading="lazy" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-white truncate">@{user.username}</div>
|
||||
{user.uploads > 0 && <div className="text-xs text-neutral-400">{user.uploads.toLocaleString()} upload{user.uploads !== 1 ? 's' : ''}</div>}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Tags section */}
|
||||
{tags.length > 0 && (
|
||||
<>
|
||||
<li role="presentation" className={`px-3 pb-1 text-[10px] font-semibold uppercase tracking-wider text-white/30 select-none ${artworks.length > 0 || users.length > 0 ? 'pt-2 border-t border-white/[0.06]' : 'pt-2.5'}`}>Tags</li>
|
||||
{tags.map((tag, j) => {
|
||||
const flatIdx = artworks.length + users.length + j
|
||||
return (
|
||||
<li key={tag.slug ?? tag.name ?? j} role="option" id={`sb-item-${flatIdx}`} aria-selected={activeIdx === flatIdx}>
|
||||
<button type="button" onClick={() => navigate({ type: 'tag', ...tag })}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 text-left transition-colors ${activeIdx === flatIdx ? 'bg-white/[0.09]' : 'hover:bg-white/[0.06]'}`}>
|
||||
<span className="w-9 h-9 rounded-lg bg-white/[0.04] border border-white/[0.07] inline-flex items-center justify-center shrink-0">
|
||||
<svg className="w-3.5 h-3.5 text-white/40" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"/>
|
||||
</svg>
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-white truncate">#{tag.name ?? tag.slug}</div>
|
||||
{tag.artworks_count != null && <div className="text-xs text-neutral-400">{tag.artworks_count.toLocaleString()} artworks</div>}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* See all footer */}
|
||||
<li role="presentation" className="border-t border-white/[0.06]">
|
||||
<a href={`/search?q=${encodeURIComponent(query)}`}
|
||||
className="flex items-center justify-center gap-1.5 px-3 py-2 text-xs text-accent hover:text-accent/80 transition-colors">
|
||||
See all results for <span className="font-semibold">"{query}"</span>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7"/></svg>
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import React from 'react'
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import axios from 'axios'
|
||||
import CommentForm from '../comments/CommentForm'
|
||||
import ReactionBar from '../comments/ReactionBar'
|
||||
import { isFlood } from '../../utils/emojiFlood'
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function timeAgo(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
const date = new Date(dateStr)
|
||||
const seconds = Math.floor((Date.now() - date.getTime()) / 1000)
|
||||
if (seconds < 60) return 'just now'
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
@@ -25,6 +31,10 @@ function Avatar({ user, size = 36 }) {
|
||||
className="rounded-full object-cover shrink-0"
|
||||
style={{ width: size, height: size }}
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
e.currentTarget.onerror = null
|
||||
e.currentTarget.src = 'https://files.skinbase.org/avatars/default.webp'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -39,59 +49,259 @@ function Avatar({ user, size = 36 }) {
|
||||
)
|
||||
}
|
||||
|
||||
export default function ArtworkComments({ comments = [] }) {
|
||||
if (!comments || comments.length === 0) return null
|
||||
// ── Single comment ────────────────────────────────────────────────────────────
|
||||
|
||||
function CommentItem({ comment, isLoggedIn }) {
|
||||
const user = comment.user
|
||||
const html = comment.rendered_content ?? null
|
||||
const plain = comment.content ?? ''
|
||||
|
||||
// Emoji-flood collapse: long runs of repeated emoji get a show-more toggle.
|
||||
const flood = isFlood(plain)
|
||||
const [expanded, setExpanded] = useState(!flood)
|
||||
|
||||
// Build initial reaction totals (empty if not provided by server)
|
||||
const [reactionTotals, setReactionTotals] = useState(comment.reactions ?? {})
|
||||
|
||||
// Load reactions lazily if not provided
|
||||
useEffect(() => {
|
||||
if (comment.reactions || !comment.id) return
|
||||
axios
|
||||
.get(`/api/comments/${comment.id}/reactions`)
|
||||
.then(({ data }) => setReactionTotals(data.totals ?? {}))
|
||||
.catch(() => {})
|
||||
}, [comment.id, comment.reactions])
|
||||
|
||||
return (
|
||||
<section aria-label="Comments">
|
||||
<h2 className="text-base font-semibold text-white mb-4">
|
||||
<li className="flex gap-3" id={`comment-${comment.id}`}>
|
||||
{/* Avatar */}
|
||||
{user?.profile_url ? (
|
||||
<a href={user.profile_url} className="shrink-0 mt-0.5" tabIndex={-1} aria-hidden="true">
|
||||
<Avatar user={user} size={36} />
|
||||
</a>
|
||||
) : (
|
||||
<span className="shrink-0 mt-0.5">
|
||||
<Avatar user={user} size={36} />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
{/* Header */}
|
||||
<div className="flex items-baseline gap-2 flex-wrap">
|
||||
{user?.profile_url ? (
|
||||
<a href={user.profile_url} className="text-sm font-medium text-white hover:underline">
|
||||
{user.display || user.username || user.name || 'Member'}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-sm font-medium text-white">
|
||||
{user?.display || user?.username || user?.name || 'Member'}
|
||||
</span>
|
||||
)}
|
||||
<time
|
||||
dateTime={comment.created_at}
|
||||
title={comment.created_at ? new Date(comment.created_at).toLocaleString() : ''}
|
||||
className="text-xs text-neutral-500"
|
||||
>
|
||||
{comment.time_ago || timeAgo(comment.created_at)}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
{/* Body — use rendered_content (safe HTML) when available, else plain text */}
|
||||
{/* Flood-collapse wrapper: clips height when content is a repeated-emoji flood */}
|
||||
<div
|
||||
className={!expanded ? 'overflow-hidden relative' : undefined}
|
||||
style={!expanded ? { maxHeight: '5em' } : undefined}
|
||||
>
|
||||
{html ? (
|
||||
<div
|
||||
className="text-sm text-neutral-300 leading-relaxed prose prose-invert prose-sm max-w-none
|
||||
prose-a:text-sky-400 prose-a:no-underline hover:prose-a:underline
|
||||
prose-code:bg-white/[0.07] prose-code:px-1 prose-code:rounded prose-code:text-xs"
|
||||
// rendered_content is server-sanitized HTML — safe to inject
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-neutral-300 whitespace-pre-line break-words leading-relaxed">
|
||||
{plain}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Gradient fade at the bottom while collapsed */}
|
||||
{flood && !expanded && (
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0 h-6 bg-gradient-to-t from-neutral-900 to-transparent pointer-events-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Flood expand / collapse toggle */}
|
||||
{flood && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
className="text-xs text-sky-400 hover:text-sky-300 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-sky-500 rounded"
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{expanded ? '▲\u2009Collapse' : '▼\u2009Show full comment'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Reactions */}
|
||||
{Object.keys(reactionTotals).length > 0 && (
|
||||
<ReactionBar
|
||||
entityType="comment"
|
||||
entityId={comment.id}
|
||||
initialTotals={reactionTotals}
|
||||
isLoggedIn={isLoggedIn}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Skeleton ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function Skeleton() {
|
||||
return (
|
||||
<div className="space-y-4 animate-pulse">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex gap-3">
|
||||
<div className="w-9 h-9 rounded-full bg-white/[0.07] shrink-0" />
|
||||
<div className="flex-1 space-y-2 pt-1">
|
||||
<div className="h-3 bg-white/[0.07] rounded w-28" />
|
||||
<div className="h-3 bg-white/[0.05] rounded w-full" />
|
||||
<div className="h-3 bg-white/[0.04] rounded w-2/3" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main export ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* ArtworkComments
|
||||
*
|
||||
* Can operate in two modes:
|
||||
* 1. Static: pass `comments` array from Inertia page props (legacy / SSR)
|
||||
* 2. Dynamic: pass `artworkId` to load + post comments via the API
|
||||
*
|
||||
* Props:
|
||||
* artworkId number Used for API calls
|
||||
* comments array SSR initial comments (optional)
|
||||
* isLoggedIn boolean
|
||||
* loginUrl string
|
||||
*/
|
||||
export default function ArtworkComments({
|
||||
artworkId,
|
||||
comments: initialComments = [],
|
||||
isLoggedIn = false,
|
||||
loginUrl = '/login',
|
||||
}) {
|
||||
const [comments, setComments] = useState(initialComments)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [page, setPage] = useState(1)
|
||||
const [lastPage, setLastPage] = useState(1)
|
||||
const [total, setTotal] = useState(initialComments.length)
|
||||
const initialized = useRef(false)
|
||||
|
||||
// Load comments from API
|
||||
const loadComments = useCallback(
|
||||
async (p = 1) => {
|
||||
if (!artworkId) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const { data } = await axios.get(`/api/artworks/${artworkId}/comments?page=${p}`)
|
||||
if (p === 1) {
|
||||
setComments(data.data ?? [])
|
||||
} else {
|
||||
setComments((prev) => [...prev, ...(data.data ?? [])])
|
||||
}
|
||||
setPage(data.meta?.current_page ?? p)
|
||||
setLastPage(data.meta?.last_page ?? 1)
|
||||
setTotal(data.meta?.total ?? 0)
|
||||
} catch {
|
||||
// keep existing data on error
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
},
|
||||
[artworkId],
|
||||
)
|
||||
|
||||
// On mount, load if artworkId provided and no SSR comments given
|
||||
useEffect(() => {
|
||||
if (initialized.current) return
|
||||
initialized.current = true
|
||||
|
||||
if (artworkId && initialComments.length === 0) {
|
||||
loadComments(1)
|
||||
} else {
|
||||
setTotal(initialComments.length)
|
||||
}
|
||||
}, [artworkId, initialComments.length, loadComments])
|
||||
|
||||
const handlePosted = useCallback((newComment) => {
|
||||
setComments((prev) => [newComment, ...prev])
|
||||
setTotal((t) => t + 1)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<section aria-label="Comments" className="space-y-6">
|
||||
<h2 className="text-base font-semibold text-white">
|
||||
Comments{' '}
|
||||
<span className="text-neutral-500 font-normal">({comments.length})</span>
|
||||
{total > 0 && (
|
||||
<span className="text-neutral-500 font-normal">({total})</span>
|
||||
)}
|
||||
</h2>
|
||||
|
||||
<ul className="space-y-5">
|
||||
{comments.map((comment) => (
|
||||
<li key={comment.id} className="flex gap-3">
|
||||
{comment.user?.profile_url ? (
|
||||
<a href={comment.user.profile_url} className="shrink-0 mt-0.5">
|
||||
<Avatar user={comment.user} size={36} />
|
||||
</a>
|
||||
) : (
|
||||
<span className="shrink-0 mt-0.5">
|
||||
<Avatar user={comment.user} size={36} />
|
||||
</span>
|
||||
)}
|
||||
{/* Comment form */}
|
||||
{artworkId && (
|
||||
<CommentForm
|
||||
artworkId={artworkId}
|
||||
onPosted={handlePosted}
|
||||
isLoggedIn={isLoggedIn}
|
||||
loginUrl={loginUrl}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-2 flex-wrap">
|
||||
{comment.user?.profile_url ? (
|
||||
<a
|
||||
href={comment.user.profile_url}
|
||||
className="text-sm font-medium text-white hover:underline"
|
||||
>
|
||||
{comment.user.name || comment.user.username || 'Member'}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-sm font-medium text-white">
|
||||
{comment.user?.name || comment.user?.username || 'Member'}
|
||||
</span>
|
||||
)}
|
||||
<time
|
||||
dateTime={comment.created_at}
|
||||
title={comment.created_at ? new Date(comment.created_at).toLocaleString() : ''}
|
||||
className="text-xs text-neutral-500"
|
||||
>
|
||||
{timeAgo(comment.created_at)}
|
||||
</time>
|
||||
</div>
|
||||
{/* Comment list */}
|
||||
{loading && comments.length === 0 ? (
|
||||
<Skeleton />
|
||||
) : comments.length === 0 ? (
|
||||
<p className="text-sm text-neutral-500">No comments yet. Be the first!</p>
|
||||
) : (
|
||||
<>
|
||||
<ul className="space-y-5">
|
||||
{comments.map((comment) => (
|
||||
<CommentItem
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
isLoggedIn={isLoggedIn}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<p className="mt-1 text-sm text-neutral-300 whitespace-pre-line break-words leading-relaxed">
|
||||
{comment.content}
|
||||
</p>
|
||||
{/* Load more */}
|
||||
{page < lastPage && (
|
||||
<div className="flex justify-center pt-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onClick={() => loadComments(page + 1)}
|
||||
className="px-5 py-2 rounded-lg text-sm text-white/60 border border-white/[0.08] hover:text-white hover:border-white/20 transition-colors disabled:opacity-40"
|
||||
>
|
||||
{loading ? 'Loading…' : 'Load more comments'}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
41
resources/js/components/artwork/ArtworkReactions.jsx
Normal file
41
resources/js/components/artwork/ArtworkReactions.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import axios from 'axios'
|
||||
import ReactionBar from '../comments/ReactionBar'
|
||||
|
||||
/**
|
||||
* Loads and displays reactions for a single artwork.
|
||||
*
|
||||
* Props:
|
||||
* artworkId number
|
||||
* isLoggedIn boolean
|
||||
*/
|
||||
export default function ArtworkReactions({ artworkId, isLoggedIn = false }) {
|
||||
const [totals, setTotals] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!artworkId) return
|
||||
axios
|
||||
.get(`/api/artworks/${artworkId}/reactions`)
|
||||
.then(({ data }) => setTotals(data.totals ?? {}))
|
||||
.catch(() => setTotals({}))
|
||||
.finally(() => setLoading(false))
|
||||
}, [artworkId])
|
||||
|
||||
if (loading) return null
|
||||
|
||||
if (!totals || Object.values(totals).every((r) => r.count === 0) && !isLoggedIn) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<ReactionBar
|
||||
entityType="artwork"
|
||||
entityId={artworkId}
|
||||
initialTotals={totals}
|
||||
isLoggedIn={isLoggedIn}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
158
resources/js/components/comments/CommentForm.jsx
Normal file
158
resources/js/components/comments/CommentForm.jsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import React, { useCallback, useRef, useState } from 'react'
|
||||
import axios from 'axios'
|
||||
import EmojiPickerButton from './EmojiPickerButton'
|
||||
|
||||
/**
|
||||
* Comment form with emoji picker and Markdown-lite support.
|
||||
*
|
||||
* Props:
|
||||
* artworkId number Target artwork
|
||||
* onPosted (comment) => void Called when comment is successfully posted
|
||||
* isLoggedIn boolean
|
||||
* loginUrl string Where to redirect non-authenticated users
|
||||
*/
|
||||
export default function CommentForm({
|
||||
artworkId,
|
||||
onPosted,
|
||||
isLoggedIn = false,
|
||||
loginUrl = '/login',
|
||||
}) {
|
||||
const [content, setContent] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [errors, setErrors] = useState([])
|
||||
const textareaRef = useRef(null)
|
||||
|
||||
// Insert text at current cursor position
|
||||
const insertAtCursor = useCallback((text) => {
|
||||
const el = textareaRef.current
|
||||
if (!el) {
|
||||
setContent((v) => v + text)
|
||||
return
|
||||
}
|
||||
|
||||
const start = el.selectionStart ?? content.length
|
||||
const end = el.selectionEnd ?? content.length
|
||||
|
||||
const next = content.slice(0, start) + text + content.slice(end)
|
||||
setContent(next)
|
||||
|
||||
// Restore cursor after the inserted text
|
||||
requestAnimationFrame(() => {
|
||||
el.selectionStart = start + text.length
|
||||
el.selectionEnd = start + text.length
|
||||
el.focus()
|
||||
})
|
||||
}, [content])
|
||||
|
||||
const handleEmojiSelect = useCallback((emoji) => {
|
||||
insertAtCursor(emoji)
|
||||
}, [insertAtCursor])
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!isLoggedIn) {
|
||||
window.location.href = loginUrl
|
||||
return
|
||||
}
|
||||
|
||||
const trimmed = content.trim()
|
||||
if (!trimmed) return
|
||||
|
||||
setSubmitting(true)
|
||||
setErrors([])
|
||||
|
||||
try {
|
||||
const { data } = await axios.post(`/api/artworks/${artworkId}/comments`, {
|
||||
content: trimmed,
|
||||
})
|
||||
|
||||
setContent('')
|
||||
onPosted?.(data.data)
|
||||
} catch (err) {
|
||||
if (err.response?.status === 422) {
|
||||
const apiErrors = err.response.data?.errors?.content ?? ['Invalid content.']
|
||||
setErrors(Array.isArray(apiErrors) ? apiErrors : [apiErrors])
|
||||
} else {
|
||||
setErrors(['Something went wrong. Please try again.'])
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
},
|
||||
[artworkId, content, isLoggedIn, loginUrl, onPosted],
|
||||
)
|
||||
|
||||
if (!isLoggedIn) {
|
||||
return (
|
||||
<div className="rounded-xl border border-white/[0.08] bg-white/[0.02] px-5 py-4 text-sm text-white/50">
|
||||
<a href={loginUrl} className="text-sky-400 hover:text-sky-300 font-medium transition-colors">
|
||||
Sign in
|
||||
</a>{' '}
|
||||
to leave a comment.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-2">
|
||||
{/* Textarea */}
|
||||
<div className="relative rounded-xl border border-white/[0.1] bg-white/[0.03] focus-within:border-white/[0.2] focus-within:bg-white/[0.05] transition-colors">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="Write a comment… Markdown supported: **bold**, *italic*, `code`"
|
||||
rows={3}
|
||||
maxLength={10000}
|
||||
disabled={submitting}
|
||||
aria-label="Comment text"
|
||||
className="w-full resize-none bg-transparent px-4 pt-3 pb-10 text-sm text-white placeholder-white/25 focus:outline-none disabled:opacity-50"
|
||||
/>
|
||||
|
||||
{/* Toolbar at bottom-right of textarea */}
|
||||
<div className="absolute bottom-2 right-3 flex items-center gap-2">
|
||||
<span
|
||||
className={[
|
||||
'text-xs tabular-nums transition-colors',
|
||||
content.length > 9000 ? 'text-amber-400' : 'text-white/20',
|
||||
].join(' ')}
|
||||
aria-live="polite"
|
||||
>
|
||||
{content.length}/10 000
|
||||
</span>
|
||||
|
||||
<EmojiPickerButton onEmojiSelect={handleEmojiSelect} disabled={submitting} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Markdown hint */}
|
||||
<p className="text-xs text-white/25 px-1">
|
||||
**bold** · *italic* · `code` · https://links.auto-linked · @mentions
|
||||
</p>
|
||||
|
||||
{/* Errors */}
|
||||
{errors.length > 0 && (
|
||||
<ul className="space-y-1" role="alert">
|
||||
{errors.map((e, i) => (
|
||||
<li key={i} className="text-xs text-red-400 px-1">
|
||||
{e}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !content.trim()}
|
||||
className="px-5 py-2 rounded-lg text-sm font-medium bg-sky-600 hover:bg-sky-500 text-white transition-colors disabled:opacity-40 disabled:pointer-events-none focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-400"
|
||||
>
|
||||
{submitting ? 'Posting…' : 'Post comment'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
287
resources/js/components/comments/CommentsFeed.jsx
Normal file
287
resources/js/components/comments/CommentsFeed.jsx
Normal file
@@ -0,0 +1,287 @@
|
||||
import React from 'react'
|
||||
|
||||
// ── Pagination ────────────────────────────────────────────────────────────────
|
||||
function Pagination({ meta, onPageChange }) {
|
||||
if (!meta || meta.last_page <= 1) return null
|
||||
|
||||
const { current_page, last_page } = meta
|
||||
const pages = []
|
||||
|
||||
if (last_page <= 7) {
|
||||
for (let i = 1; i <= last_page; i++) pages.push(i)
|
||||
} else {
|
||||
const around = new Set(
|
||||
[1, last_page, current_page, current_page - 1, current_page + 1].filter(
|
||||
(p) => p >= 1 && p <= last_page
|
||||
)
|
||||
)
|
||||
const sorted = [...around].sort((a, b) => a - b)
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
if (i > 0 && sorted[i] - sorted[i - 1] > 1) pages.push('…')
|
||||
pages.push(sorted[i])
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Pagination"
|
||||
className="mt-10 flex items-center justify-center gap-1 flex-wrap"
|
||||
>
|
||||
<button
|
||||
disabled={current_page <= 1}
|
||||
onClick={() => onPageChange(current_page - 1)}
|
||||
className="px-3 py-1.5 rounded-md text-sm text-white/50 hover:text-white hover:bg-white/[0.06] disabled:opacity-25 disabled:pointer-events-none transition-colors"
|
||||
aria-label="Previous page"
|
||||
>
|
||||
‹ Prev
|
||||
</button>
|
||||
|
||||
{pages.map((p, i) =>
|
||||
p === '…' ? (
|
||||
<span key={`sep-${i}`} className="px-2 text-white/25 text-sm select-none">
|
||||
…
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => p !== current_page && onPageChange(p)}
|
||||
aria-current={p === current_page ? 'page' : undefined}
|
||||
className={[
|
||||
'min-w-[2rem] px-3 py-1.5 rounded-md text-sm font-medium transition-colors',
|
||||
p === current_page
|
||||
? 'bg-sky-600/30 text-sky-300 ring-1 ring-sky-500/40'
|
||||
: 'text-white/50 hover:text-white hover:bg-white/[0.06]',
|
||||
].join(' ')}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
|
||||
<button
|
||||
disabled={current_page >= last_page}
|
||||
onClick={() => onPageChange(current_page + 1)}
|
||||
className="px-3 py-1.5 rounded-md text-sm text-white/50 hover:text-white hover:bg-white/[0.06] disabled:opacity-25 disabled:pointer-events-none transition-colors"
|
||||
aria-label="Next page"
|
||||
>
|
||||
Next ›
|
||||
</button>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Pin icon (for artwork reference) ─────────────────────────────────────────
|
||||
function PinIcon() {
|
||||
return (
|
||||
<svg
|
||||
className="w-3.5 h-3.5 shrink-0 text-white/30"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||
/>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Artwork image icon (for right panel label) ────────────────────────────────
|
||||
function ImageIcon() {
|
||||
return (
|
||||
<svg
|
||||
className="w-3 h-3 shrink-0 text-white/25"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Single comment row ────────────────────────────────────────────────────────
|
||||
function CommentItem({ comment }) {
|
||||
const { commenter, artwork, comment_text, time_ago, created_at } = comment
|
||||
|
||||
return (
|
||||
<article className="flex gap-4 p-4 sm:p-5 rounded-xl border border-white/[0.065] bg-white/[0.025] hover:bg-white/[0.04] transition-colors">
|
||||
|
||||
{/* ── Avatar ── */}
|
||||
<a
|
||||
href={commenter.profile_url}
|
||||
className="shrink-0 mt-0.5"
|
||||
aria-label={`View ${commenter.display}'s profile`}
|
||||
>
|
||||
<img
|
||||
src={commenter.avatar_url}
|
||||
alt={commenter.display}
|
||||
width={48}
|
||||
height={48}
|
||||
className="w-12 h-12 rounded-full object-cover ring-1 ring-white/[0.1]"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
e.currentTarget.onerror = null
|
||||
e.currentTarget.src = commenter.avatar_fallback || 'https://files.skinbase.org/avatars/default.webp'
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
|
||||
{/* ── Main content ── */}
|
||||
<div className="min-w-0 flex-1">
|
||||
|
||||
{/* Author + time */}
|
||||
<div className="flex items-baseline gap-2 flex-wrap mb-1">
|
||||
<a
|
||||
href={commenter.profile_url}
|
||||
className="text-sm font-bold text-white hover:text-white/80 transition-colors"
|
||||
>
|
||||
{commenter.display}
|
||||
</a>
|
||||
<time
|
||||
dateTime={created_at}
|
||||
title={created_at ? new Date(created_at).toLocaleString() : ''}
|
||||
className="text-xs text-white/40"
|
||||
>
|
||||
{time_ago}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
{/* Comment text — primary visual element */}
|
||||
<p className="text-base text-white leading-relaxed whitespace-pre-line break-words mb-3">
|
||||
{comment_text}
|
||||
</p>
|
||||
|
||||
{/* Artwork reference link */}
|
||||
{artwork && (
|
||||
<a
|
||||
href={artwork.url}
|
||||
className="inline-flex items-center gap-1.5 text-xs text-sky-400 hover:text-sky-300 transition-colors group"
|
||||
>
|
||||
<PinIcon />
|
||||
<span className="font-medium">{artwork.title}</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Right: artwork thumbnail ── */}
|
||||
{artwork?.thumb && (
|
||||
<a
|
||||
href={artwork.url}
|
||||
className="shrink-0 self-start group"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="w-[220px] overflow-hidden rounded-lg ring-1 ring-white/[0.07] group-hover:ring-white/20 transition-all">
|
||||
<img
|
||||
src={artwork.thumb}
|
||||
alt={artwork.title ?? 'Artwork'}
|
||||
width={220}
|
||||
height={96}
|
||||
className="w-full h-24 object-cover"
|
||||
loading="lazy"
|
||||
onError={(e) => { e.currentTarget.closest('a').style.display = 'none' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1.5 px-0.5">
|
||||
<div className="flex items-center gap-1 mb-0.5">
|
||||
<ImageIcon />
|
||||
<span className="text-[11px] text-white/45 truncate max-w-[200px]">{artwork.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Loading skeleton ──────────────────────────────────────────────────────────
|
||||
function FeedSkeleton() {
|
||||
return (
|
||||
<div className="space-y-3 animate-pulse">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex gap-4 p-5 rounded-xl border border-white/[0.06] bg-white/[0.02]"
|
||||
>
|
||||
{/* avatar */}
|
||||
<div className="w-12 h-12 rounded-full bg-white/[0.07] shrink-0" />
|
||||
{/* content */}
|
||||
<div className="flex-1 space-y-2 pt-1">
|
||||
<div className="h-3 bg-white/[0.07] rounded w-32" />
|
||||
<div className="h-4 bg-white/[0.06] rounded w-full" />
|
||||
<div className="h-4 bg-white/[0.05] rounded w-3/4" />
|
||||
<div className="h-3 bg-white/[0.04] rounded w-24 mt-2" />
|
||||
</div>
|
||||
{/* thumbnail */}
|
||||
<div className="w-[220px] h-24 rounded-lg bg-white/[0.05] shrink-0" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Empty state ───────────────────────────────────────────────────────────────
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="py-16 text-center">
|
||||
<svg
|
||||
className="mx-auto w-10 h-10 text-white/15 mb-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M8.625 9.75a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375m-13.5 3.01c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.184-4.183a1.14 1.14 0 01.778-.332 48.294 48.294 0 005.83-.498c1.585-.233 2.708-1.626 2.708-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-white/30 text-sm">No comments found.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main export ───────────────────────────────────────────────────────────────
|
||||
export default function CommentsFeed({
|
||||
comments = [],
|
||||
meta = {},
|
||||
loading = false,
|
||||
error = null,
|
||||
onPageChange,
|
||||
}) {
|
||||
if (loading) return <FeedSkeleton />
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-xl border border-red-500/20 bg-red-900/10 px-6 py-5 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!comments || comments.length === 0) return <EmptyState />
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div role="feed" aria-live="polite" aria-busy={loading} className="space-y-3">
|
||||
{comments.map((comment) => (
|
||||
<CommentItem key={comment.comment_id} comment={comment} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Pagination meta={meta} onPageChange={onPageChange} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
92
resources/js/components/comments/EmojiPickerButton.jsx
Normal file
92
resources/js/components/comments/EmojiPickerButton.jsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import data from '@emoji-mart/data'
|
||||
import Picker from '@emoji-mart/react'
|
||||
|
||||
/**
|
||||
* A button that opens a floating emoji picker.
|
||||
* When the user selects an emoji, `onEmojiSelect(emojiNative)` is called
|
||||
* with the native Unicode character.
|
||||
*
|
||||
* Props:
|
||||
* onEmojiSelect (string) → void Called with the emoji character
|
||||
* disabled boolean Disables the button
|
||||
* className string Additional classes for the trigger button
|
||||
*/
|
||||
export default function EmojiPickerButton({ onEmojiSelect, disabled = false, className = '' }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const wrapRef = useRef(null)
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
function handleClick(e) {
|
||||
if (wrapRef.current && !wrapRef.current.contains(e.target)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [open])
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
function handleKey(e) {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKey)
|
||||
return () => document.removeEventListener('keydown', handleKey)
|
||||
}, [open])
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(emoji) => {
|
||||
onEmojiSelect?.(emoji.native)
|
||||
setOpen(false)
|
||||
},
|
||||
[onEmojiSelect],
|
||||
)
|
||||
|
||||
return (
|
||||
<div ref={wrapRef} className="relative inline-block">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-label="Open emoji picker"
|
||||
aria-expanded={open}
|
||||
className={[
|
||||
'flex items-center justify-center w-8 h-8 rounded-md',
|
||||
'text-white/40 hover:text-white/70 hover:bg-white/[0.07]',
|
||||
'transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-500',
|
||||
'disabled:opacity-30 disabled:pointer-events-none',
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
😊
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="absolute bottom-full mb-2 right-0 z-50 shadow-2xl rounded-xl overflow-hidden"
|
||||
style={{ filter: 'drop-shadow(0 8px 32px rgba(0,0,0,0.6))' }}
|
||||
>
|
||||
<Picker
|
||||
data={data}
|
||||
onEmojiSelect={handleSelect}
|
||||
theme="dark"
|
||||
previewPosition="none"
|
||||
skinTonePosition="none"
|
||||
maxFrequentRows={2}
|
||||
perLine={8}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
110
resources/js/components/comments/ReactionBar.jsx
Normal file
110
resources/js/components/comments/ReactionBar.jsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import React, { useCallback, useOptimistic, useState } from 'react'
|
||||
import axios from 'axios'
|
||||
|
||||
/**
|
||||
* Reaction bar for an artwork or comment.
|
||||
*
|
||||
* Props:
|
||||
* entityType 'artwork' | 'comment'
|
||||
* entityId number
|
||||
* initialTotals Record<slug, { emoji, label, count, mine }>
|
||||
* isLoggedIn boolean — if false, clicking shows a prompt
|
||||
*/
|
||||
export default function ReactionBar({ entityType, entityId, initialTotals = {}, isLoggedIn = false }) {
|
||||
const [totals, setTotals] = useState(initialTotals)
|
||||
const [loading, setLoading] = useState(null) // slug being toggled
|
||||
|
||||
const endpoint =
|
||||
entityType === 'artwork'
|
||||
? `/api/artworks/${entityId}/reactions`
|
||||
: `/api/comments/${entityId}/reactions`
|
||||
|
||||
const toggle = useCallback(
|
||||
async (slug) => {
|
||||
if (!isLoggedIn) {
|
||||
window.location.href = '/login'
|
||||
return
|
||||
}
|
||||
|
||||
if (loading) return // prevent double-click
|
||||
setLoading(slug)
|
||||
|
||||
// Optimistic update
|
||||
setTotals((prev) => {
|
||||
const entry = prev[slug] ?? { count: 0, mine: false }
|
||||
return {
|
||||
...prev,
|
||||
[slug]: {
|
||||
...entry,
|
||||
count: entry.mine ? entry.count - 1 : entry.count + 1,
|
||||
mine: !entry.mine,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
const { data } = await axios.post(endpoint, { reaction: slug })
|
||||
setTotals(data.totals)
|
||||
} catch {
|
||||
// Rollback
|
||||
setTotals((prev) => {
|
||||
const entry = prev[slug] ?? { count: 0, mine: false }
|
||||
return {
|
||||
...prev,
|
||||
[slug]: {
|
||||
...entry,
|
||||
count: entry.mine ? entry.count - 1 : entry.count + 1,
|
||||
mine: !entry.mine,
|
||||
},
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
setLoading(null)
|
||||
}
|
||||
},
|
||||
[endpoint, isLoggedIn, loading],
|
||||
)
|
||||
|
||||
const entries = Object.entries(totals)
|
||||
|
||||
if (entries.length === 0) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Reactions"
|
||||
className="flex flex-wrap items-center gap-1.5"
|
||||
>
|
||||
{entries.map(([slug, info]) => {
|
||||
const { emoji, label, count, mine } = info
|
||||
const isProcessing = loading === slug
|
||||
|
||||
return (
|
||||
<button
|
||||
key={slug}
|
||||
type="button"
|
||||
disabled={isProcessing}
|
||||
onClick={() => toggle(slug)}
|
||||
aria-label={`${label} — ${count} reaction${count !== 1 ? 's' : ''}${mine ? ' (your reaction)' : ''}`}
|
||||
aria-pressed={mine}
|
||||
className={[
|
||||
'inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-sm',
|
||||
'border transition-all duration-150',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-500',
|
||||
'disabled:opacity-50 disabled:pointer-events-none',
|
||||
mine
|
||||
? 'border-sky-500/60 bg-sky-500/15 text-sky-300 hover:bg-sky-500/25'
|
||||
: 'border-white/[0.1] bg-white/[0.03] text-white/60 hover:border-white/20 hover:text-white/80',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<span aria-hidden="true">{emoji}</span>
|
||||
<span className="tabular-nums font-medium">{count > 0 ? count : ''}</span>
|
||||
<span className="sr-only">{label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
123
resources/js/components/messaging/ConversationList.jsx
Normal file
123
resources/js/components/messaging/ConversationList.jsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Left panel: searchable, paginated list of conversations.
|
||||
*/
|
||||
export default function ConversationList({ conversations, loading, activeId, currentUserId, onSelect }) {
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.toLowerCase().trim()
|
||||
if (!q) return conversations
|
||||
return conversations.filter(conv => {
|
||||
const label = convLabel(conv, currentUserId).toLowerCase()
|
||||
const last = (conv.latest_message?.[0]?.body ?? '').toLowerCase()
|
||||
return label.includes(q) || last.includes(q)
|
||||
})
|
||||
}, [conversations, search, currentUserId])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 overflow-hidden">
|
||||
{/* Search */}
|
||||
<div className="px-3 py-2 border-b border-gray-100 dark:border-gray-800">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search conversations…"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<ul className="flex-1 overflow-y-auto divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{loading && (
|
||||
<li className="px-4 py-8 text-center text-sm text-gray-400">Loading…</li>
|
||||
)}
|
||||
{!loading && filtered.length === 0 && (
|
||||
<li className="px-4 py-8 text-center text-sm text-gray-400">
|
||||
{search ? 'No matches found.' : 'No conversations yet.'}
|
||||
</li>
|
||||
)}
|
||||
{filtered.map(conv => (
|
||||
<ConversationRow
|
||||
key={conv.id}
|
||||
conv={conv}
|
||||
isActive={conv.id === activeId}
|
||||
currentUserId={currentUserId}
|
||||
onClick={() => onSelect(conv.id)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConversationRow({ conv, isActive, currentUserId, onClick }) {
|
||||
const label = convLabel(conv, currentUserId)
|
||||
const lastMsg = Array.isArray(conv.latest_message) ? conv.latest_message[0] : conv.latest_message
|
||||
const preview = lastMsg ? truncate(lastMsg.body, 60) : 'No messages yet'
|
||||
const unread = conv.unread_count ?? 0
|
||||
const myParticipant = conv.all_participants?.find(p => p.user_id === currentUserId)
|
||||
const isArchived = myParticipant?.is_archived ?? false
|
||||
const isPinned = myParticipant?.is_pinned ?? false
|
||||
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`w-full text-left px-4 py-3 flex gap-3 hover:bg-gray-50 dark:hover:bg-gray-800/60 transition-colors ${isActive ? 'bg-blue-50 dark:bg-blue-900/30' : ''} ${isArchived ? 'opacity-60' : ''}`}
|
||||
>
|
||||
{/* Avatar placeholder */}
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-gradient-to-br from-blue-400 to-purple-500 flex items-center justify-center text-white text-sm font-medium">
|
||||
{label.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
{isPinned && <span className="text-xs" title="Pinned">📌</span>}
|
||||
<span className={`text-sm font-medium truncate ${isActive ? 'text-blue-700 dark:text-blue-300' : 'text-gray-900 dark:text-gray-100'}`}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
{conv.last_message_at && (
|
||||
<span className="text-xs text-gray-400 flex-shrink-0">
|
||||
{relativeTime(conv.last_message_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-1 mt-0.5">
|
||||
<span className="text-xs text-gray-400 truncate">{preview}</span>
|
||||
{unread > 0 && (
|
||||
<span className="flex-shrink-0 min-w-[1.25rem] h-5 rounded-full bg-blue-500 text-white text-xs font-medium flex items-center justify-center px-1">
|
||||
{unread > 99 ? '99+' : unread}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function convLabel(conv, currentUserId) {
|
||||
if (conv.type === 'group') return conv.title ?? 'Group'
|
||||
const other = conv.all_participants?.find(p => p.user_id !== currentUserId)
|
||||
return other?.user?.username ?? 'Direct message'
|
||||
}
|
||||
|
||||
function truncate(str, max) {
|
||||
if (!str) return ''
|
||||
return str.length > max ? str.slice(0, max) + '…' : str
|
||||
}
|
||||
|
||||
function relativeTime(iso) {
|
||||
const diff = (Date.now() - new Date(iso).getTime()) / 1000
|
||||
if (diff < 60) return 'now'
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m`
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h`
|
||||
return `${Math.floor(diff / 86400)}d`
|
||||
}
|
||||
587
resources/js/components/messaging/ConversationThread.jsx
Normal file
587
resources/js/components/messaging/ConversationThread.jsx
Normal file
@@ -0,0 +1,587 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import MessageBubble from './MessageBubble'
|
||||
|
||||
/**
|
||||
* Right panel: scrollable thread of messages with send form.
|
||||
*/
|
||||
export default function ConversationThread({
|
||||
conversationId,
|
||||
conversation,
|
||||
currentUserId,
|
||||
currentUsername,
|
||||
apiFetch,
|
||||
onBack,
|
||||
onMarkRead,
|
||||
onConversationUpdated,
|
||||
}) {
|
||||
const [messages, setMessages] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [sending, setSending] = useState(false)
|
||||
const [body, setBody] = useState('')
|
||||
const [nextCursor, setNextCursor] = useState(null)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [attachments, setAttachments] = useState([])
|
||||
const [typingUsers, setTypingUsers] = useState([])
|
||||
const [threadSearch, setThreadSearch] = useState('')
|
||||
const [threadSearchResults, setThreadSearchResults] = useState([])
|
||||
const fileInputRef = useRef(null)
|
||||
const bottomRef = useRef(null)
|
||||
const threadRef = useRef(null)
|
||||
const pollRef = useRef(null)
|
||||
const typingPollRef = useRef(null)
|
||||
const typingStopTimerRef = useRef(null)
|
||||
const latestIdRef = useRef(null)
|
||||
const shouldAutoScrollRef = useRef(true)
|
||||
const draftKey = `nova_draft_${conversationId}`
|
||||
|
||||
// ── Initial load ─────────────────────────────────────────────────────────
|
||||
const loadMessages = useCallback(async () => {
|
||||
try {
|
||||
const data = await apiFetch(`/api/messages/${conversationId}`)
|
||||
const msgs = [...(data.data ?? [])].map(m => tagReactions(m, currentUserId))
|
||||
setMessages(msgs)
|
||||
setNextCursor(data.next_cursor ?? null)
|
||||
setLoading(false)
|
||||
if (msgs.length) latestIdRef.current = msgs[msgs.length - 1].id
|
||||
shouldAutoScrollRef.current = true
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
setLoading(false)
|
||||
}
|
||||
}, [conversationId, currentUserId, apiFetch])
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
setMessages([])
|
||||
const storedDraft = window.localStorage.getItem(draftKey)
|
||||
setBody(storedDraft ?? '')
|
||||
loadMessages()
|
||||
|
||||
// Phase 1 polling: check new messages every 10 seconds
|
||||
pollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const data = await apiFetch(`/api/messages/${conversationId}`)
|
||||
const latestChunk = [...(data.data ?? [])].map(m => tagReactions(m, currentUserId))
|
||||
if (latestChunk.length && latestChunk[latestChunk.length - 1].id !== latestIdRef.current) {
|
||||
shouldAutoScrollRef.current = true
|
||||
setMessages(prev => mergeMessageLists(prev, latestChunk))
|
||||
latestIdRef.current = latestChunk[latestChunk.length - 1].id
|
||||
onConversationUpdated()
|
||||
}
|
||||
} catch (_) {}
|
||||
}, 10_000)
|
||||
|
||||
return () => clearInterval(pollRef.current)
|
||||
}, [conversationId, draftKey])
|
||||
|
||||
useEffect(() => {
|
||||
typingPollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const data = await apiFetch(`/api/messages/${conversationId}/typing`)
|
||||
setTypingUsers(data.typing ?? [])
|
||||
} catch (_) {}
|
||||
}, 2_000)
|
||||
|
||||
return () => {
|
||||
clearInterval(typingPollRef.current)
|
||||
clearTimeout(typingStopTimerRef.current)
|
||||
apiFetch(`/api/messages/${conversationId}/typing/stop`, { method: 'POST' }).catch(() => {})
|
||||
}
|
||||
}, [conversationId, apiFetch])
|
||||
|
||||
useEffect(() => {
|
||||
const content = body.trim()
|
||||
if (!content) {
|
||||
clearTimeout(typingStopTimerRef.current)
|
||||
apiFetch(`/api/messages/${conversationId}/typing/stop`, { method: 'POST' }).catch(() => {})
|
||||
return
|
||||
}
|
||||
|
||||
apiFetch(`/api/messages/${conversationId}/typing`, { method: 'POST' }).catch(() => {})
|
||||
clearTimeout(typingStopTimerRef.current)
|
||||
typingStopTimerRef.current = setTimeout(() => {
|
||||
apiFetch(`/api/messages/${conversationId}/typing/stop`, { method: 'POST' }).catch(() => {})
|
||||
}, 2500)
|
||||
}, [body, conversationId, apiFetch])
|
||||
|
||||
useEffect(() => {
|
||||
if (body.trim()) {
|
||||
window.localStorage.setItem(draftKey, body)
|
||||
return
|
||||
}
|
||||
|
||||
window.localStorage.removeItem(draftKey)
|
||||
}, [body, draftKey])
|
||||
|
||||
// ── Scroll to bottom on first load and new messages ───────────────────────
|
||||
useEffect(() => {
|
||||
if (!loading && shouldAutoScrollRef.current) {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
shouldAutoScrollRef.current = false
|
||||
}
|
||||
}, [loading, messages.length])
|
||||
|
||||
// ── Mark as read when thread is viewed ────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
apiFetch(`/api/messages/${conversationId}/read`, { method: 'POST' })
|
||||
.then(() => onMarkRead(conversationId))
|
||||
.catch(() => {})
|
||||
}
|
||||
}, [loading, conversationId])
|
||||
|
||||
// ── Load older messages ───────────────────────────────────────────────────
|
||||
const loadMore = useCallback(async () => {
|
||||
if (!nextCursor || loadingMore) return
|
||||
setLoadingMore(true)
|
||||
const container = threadRef.current
|
||||
const prevHeight = container?.scrollHeight ?? 0
|
||||
|
||||
try {
|
||||
const data = await apiFetch(`/api/messages/${conversationId}?cursor=${nextCursor}`)
|
||||
const older = [...(data.data ?? [])].map(m => tagReactions(m, currentUserId))
|
||||
shouldAutoScrollRef.current = false
|
||||
setMessages(prev => [...older, ...prev])
|
||||
setNextCursor(data.next_cursor ?? null)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!container) return
|
||||
const newHeight = container.scrollHeight
|
||||
container.scrollTop = Math.max(0, newHeight - prevHeight + container.scrollTop)
|
||||
})
|
||||
} catch (_) {}
|
||||
setLoadingMore(false)
|
||||
}, [nextCursor, loadingMore, apiFetch, conversationId, currentUserId])
|
||||
|
||||
const handleThreadScroll = useCallback((e) => {
|
||||
if (e.currentTarget.scrollTop < 120) {
|
||||
loadMore()
|
||||
}
|
||||
}, [loadMore])
|
||||
|
||||
// ── Send message ──────────────────────────────────────────────────────────
|
||||
const handleSend = useCallback(async (e) => {
|
||||
e.preventDefault()
|
||||
const text = body.trim()
|
||||
if ((!text && attachments.length === 0) || sending) return
|
||||
|
||||
setSending(true)
|
||||
const optimistic = {
|
||||
id: `opt-${Date.now()}`,
|
||||
sender_id: currentUserId,
|
||||
sender: { id: currentUserId, username: currentUsername },
|
||||
body: text,
|
||||
created_at: new Date().toISOString(),
|
||||
_optimistic: true,
|
||||
attachments: attachments.map((file, index) => ({
|
||||
id: `tmp-${Date.now()}-${index}`,
|
||||
type: file.type.startsWith('image/') ? 'image' : 'file',
|
||||
original_name: file.name,
|
||||
})),
|
||||
}
|
||||
setMessages(prev => [...prev, optimistic])
|
||||
setBody('')
|
||||
window.localStorage.removeItem(draftKey)
|
||||
shouldAutoScrollRef.current = true
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('body', text)
|
||||
attachments.forEach(file => formData.append('attachments[]', file))
|
||||
|
||||
const msg = await apiFetch(`/api/messages/${conversationId}`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
setMessages(prev => prev.map(m => m.id === optimistic.id ? msg : m))
|
||||
latestIdRef.current = msg.id
|
||||
onConversationUpdated()
|
||||
setAttachments([])
|
||||
} catch (e) {
|
||||
setMessages(prev => prev.filter(m => m.id !== optimistic.id))
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}, [body, attachments, sending, conversationId, currentUserId, currentUsername, apiFetch, onConversationUpdated, draftKey])
|
||||
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend(e)
|
||||
}
|
||||
}, [handleSend])
|
||||
|
||||
// ── Reaction ──────────────────────────────────────────────────────────────
|
||||
const handleReact = useCallback(async (messageId, emoji) => {
|
||||
try {
|
||||
await apiFetch(`/api/messages/${messageId}/reactions`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reaction: emoji }),
|
||||
})
|
||||
// Optimistically add reaction with _iMine flag
|
||||
setMessages(prev => prev.map(m => {
|
||||
if (m.id !== messageId) return m
|
||||
const existing = (m.reactions ?? []).some(r => r._iMine && r.reaction === emoji)
|
||||
if (existing) return m
|
||||
return { ...m, reactions: [...(m.reactions ?? []), { reaction: emoji, user_id: currentUserId, _iMine: true }] }
|
||||
}))
|
||||
} catch (_) {}
|
||||
}, [currentUserId, apiFetch])
|
||||
|
||||
const handleUnreact = useCallback(async (messageId, emoji) => {
|
||||
try {
|
||||
await apiFetch(`/api/messages/${messageId}/reactions`, {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ reaction: emoji }),
|
||||
})
|
||||
// Optimistically remove reaction
|
||||
setMessages(prev => prev.map(m => {
|
||||
if (m.id !== messageId) return m
|
||||
return { ...m, reactions: (m.reactions ?? []).filter(r => !(r._iMine && r.reaction === emoji)) }
|
||||
}))
|
||||
} catch (_) {}
|
||||
}, [apiFetch])
|
||||
|
||||
const handleEdit = useCallback(async (messageId, newBody) => {
|
||||
const updated = await apiFetch(`/api/messages/message/${messageId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ body: newBody }),
|
||||
})
|
||||
setMessages(prev => prev.map(m => m.id === messageId ? { ...m, body: updated.body, edited_at: updated.edited_at } : m))
|
||||
}, [apiFetch])
|
||||
|
||||
const handleReportMessage = useCallback(async (messageId) => {
|
||||
try {
|
||||
await apiFetch('/api/reports', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
target_type: 'message',
|
||||
target_id: messageId,
|
||||
reason: 'inappropriate',
|
||||
details: '',
|
||||
}),
|
||||
})
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
}
|
||||
}, [apiFetch])
|
||||
|
||||
const handlePickAttachments = useCallback((e) => {
|
||||
const next = Array.from(e.target.files ?? [])
|
||||
if (!next.length) return
|
||||
setAttachments(prev => [...prev, ...next].slice(0, 5))
|
||||
e.target.value = ''
|
||||
}, [])
|
||||
|
||||
const removeAttachment = useCallback((index) => {
|
||||
setAttachments(prev => prev.filter((_, i) => i !== index))
|
||||
}, [])
|
||||
|
||||
const togglePin = useCallback(async () => {
|
||||
const me = conversation?.all_participants?.find(p => p.user_id === currentUserId)
|
||||
const isPinned = !!me?.is_pinned
|
||||
const endpoint = isPinned ? 'unpin' : 'pin'
|
||||
try {
|
||||
await apiFetch(`/api/messages/${conversationId}/${endpoint}`, { method: 'POST' })
|
||||
onConversationUpdated()
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
}
|
||||
}, [conversation, currentUserId, apiFetch, conversationId, onConversationUpdated])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const q = threadSearch.trim()
|
||||
if (q.length < 2) {
|
||||
setThreadSearchResults([])
|
||||
return
|
||||
}
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const data = await apiFetch(`/api/messages/search?q=${encodeURIComponent(q)}&conversation_id=${conversationId}`)
|
||||
if (!cancelled) {
|
||||
setThreadSearchResults(data.data ?? [])
|
||||
}
|
||||
} catch (_) {
|
||||
if (!cancelled) {
|
||||
setThreadSearchResults([])
|
||||
}
|
||||
}
|
||||
}, 250)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [threadSearch, conversationId, apiFetch])
|
||||
|
||||
const jumpToMessage = useCallback((messageId) => {
|
||||
const target = document.getElementById(`message-${messageId}`)
|
||||
if (target) {
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
}, [])
|
||||
|
||||
// ── Thread header label ───────────────────────────────────────────────────
|
||||
const threadLabel = conversation?.type === 'group'
|
||||
? (conversation?.title ?? 'Group conversation')
|
||||
: (conversation?.all_participants?.find(p => p.user_id !== currentUserId)?.user?.username ?? 'Direct message')
|
||||
const otherParticipant = conversation?.all_participants?.find(p => p.user_id !== currentUserId)
|
||||
const otherLastReadAt = otherParticipant?.last_read_at ?? null
|
||||
const lastMessageId = messages[messages.length - 1]?.id ?? null
|
||||
|
||||
// ── Group date separators from messages ──────────────────────────────────
|
||||
const grouped = groupByDate(messages)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="sm:hidden p-1 text-gray-500 hover:text-gray-900 dark:hover:text-gray-100"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
<div>
|
||||
<p className="font-medium text-sm text-gray-900 dark:text-gray-100">{threadLabel}</p>
|
||||
{conversation?.type === 'group' && (
|
||||
<p className="text-xs text-gray-400">
|
||||
{conversation.all_participants?.filter(p => !p.left_at).length ?? 0} members
|
||||
</p>
|
||||
)}
|
||||
{typingUsers.length > 0 && (
|
||||
<p className="text-xs text-blue-400">{typingUsers.map(u => u.username).join(', ')} typing…</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePin}
|
||||
className="ml-auto text-xs px-2 py-1 rounded border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
>
|
||||
{conversation?.my_participant?.is_pinned ? 'Unpin' : 'Pin'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<input
|
||||
type="search"
|
||||
value={threadSearch}
|
||||
onChange={e => setThreadSearch(e.target.value)}
|
||||
placeholder="Search in this conversation…"
|
||||
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"
|
||||
/>
|
||||
{threadSearch.trim().length >= 2 && (
|
||||
<div className="mt-2 max-h-28 overflow-y-auto rounded border border-gray-200 dark:border-gray-700">
|
||||
{threadSearchResults.length === 0 && (
|
||||
<p className="px-2 py-1 text-xs text-gray-400">No matches</p>
|
||||
)}
|
||||
{threadSearchResults.map(item => (
|
||||
<button
|
||||
key={`thread-search-${item.id}`}
|
||||
onClick={() => jumpToMessage(item.id)}
|
||||
className="w-full text-left px-2 py-1 text-xs hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
>
|
||||
<span className="text-gray-500">@{item.sender?.username ?? 'unknown'}: </span>
|
||||
<span className="text-gray-800 dark:text-gray-200">{item.body || '(attachment)'}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div ref={threadRef} onScroll={handleThreadScroll} className="flex-1 overflow-y-auto px-4 py-4 space-y-1">
|
||||
{loadingMore && (
|
||||
<div className="text-center py-2 text-xs text-gray-400">Loading older messages…</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="text-center text-sm text-gray-400 py-12">Loading messages…</div>
|
||||
)}
|
||||
|
||||
{!loading && messages.length === 0 && (
|
||||
<div className="text-center text-sm text-gray-400 py-12">
|
||||
No messages yet. Say hello!
|
||||
</div>
|
||||
)}
|
||||
|
||||
{grouped.map(({ date, messages: dayMessages }) => (
|
||||
<React.Fragment key={date}>
|
||||
<div className="flex items-center gap-3 my-4">
|
||||
<hr className="flex-1 border-gray-200 dark:border-gray-700" />
|
||||
<span className="text-xs text-gray-400 flex-shrink-0">{date}</span>
|
||||
<hr className="flex-1 border-gray-200 dark:border-gray-700" />
|
||||
</div>
|
||||
{dayMessages.map((msg, idx) => (
|
||||
<div key={msg.id} id={`message-${msg.id}`}>
|
||||
<MessageBubble
|
||||
message={msg}
|
||||
isMine={msg.sender_id === currentUserId}
|
||||
showAvatar={idx === 0 || dayMessages[idx - 1]?.sender_id !== msg.sender_id}
|
||||
onReact={handleReact}
|
||||
onUnreact={handleUnreact}
|
||||
onEdit={handleEdit}
|
||||
onReport={handleReportMessage}
|
||||
seenText={buildSeenText({
|
||||
message: msg,
|
||||
isMine: msg.sender_id === currentUserId,
|
||||
isDirect: conversation?.type === 'direct',
|
||||
isLastMessage: msg.id === lastMessageId,
|
||||
otherLastReadAt,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="mx-4 mb-2 px-3 py-2 text-xs text-red-600 bg-red-50 dark:bg-red-900/30 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Compose */}
|
||||
<form onSubmit={handleSend} className="flex gap-2 px-4 py-3 border-t border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handlePickAttachments}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex-shrink-0 rounded-xl border border-gray-200 dark:border-gray-700 px-3 py-2 text-sm text-gray-600 dark:text-gray-300"
|
||||
title="Attach files"
|
||||
>
|
||||
📎
|
||||
</button>
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={e => setBody(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Write a message… (Enter to send, Shift+Enter for new line)"
|
||||
rows={1}
|
||||
maxLength={5000}
|
||||
className="flex-1 resize-none rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 placeholder-gray-400 outline-none focus:ring-2 focus:ring-blue-500 max-h-32 overflow-y-auto"
|
||||
style={{ minHeight: '2.5rem' }}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={(!body.trim() && attachments.length === 0) || sending}
|
||||
className="flex-shrink-0 rounded-xl bg-blue-500 hover:bg-blue-600 disabled:opacity-40 text-white px-4 py-2 text-sm font-medium transition-colors"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{attachments.length > 0 && (
|
||||
<div className="px-4 pb-3 flex flex-wrap gap-2">
|
||||
{attachments.map((file, idx) => (
|
||||
<div key={`${file.name}-${idx}`} className="inline-flex items-center gap-2 rounded-lg bg-gray-100 dark:bg-gray-800 px-2 py-1 text-xs text-gray-700 dark:text-gray-300">
|
||||
<span className="truncate max-w-[220px]">{file.name}</span>
|
||||
<button type="button" onClick={() => removeAttachment(idx)} className="text-red-500">✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function tagReactions(msg, currentUserId) {
|
||||
if (!msg.reactions?.length) return msg
|
||||
return {
|
||||
...msg,
|
||||
reactions: msg.reactions.map(r => ({ ...r, _iMine: r.user_id === currentUserId })),
|
||||
}
|
||||
}
|
||||
|
||||
function groupByDate(messages) {
|
||||
const map = new Map()
|
||||
for (const msg of messages) {
|
||||
const date = formatDate(msg.created_at)
|
||||
if (!map.has(date)) map.set(date, [])
|
||||
map.get(date).push(msg)
|
||||
}
|
||||
return Array.from(map.entries()).map(([date, messages]) => ({ date, messages }))
|
||||
}
|
||||
|
||||
function mergeMessageLists(existing, incoming) {
|
||||
const byId = new Map()
|
||||
|
||||
for (const msg of existing) {
|
||||
byId.set(String(msg.id), msg)
|
||||
}
|
||||
|
||||
for (const msg of incoming) {
|
||||
byId.set(String(msg.id), msg)
|
||||
}
|
||||
|
||||
return Array.from(byId.values()).sort((a, b) => {
|
||||
const at = new Date(a.created_at).getTime()
|
||||
const bt = new Date(b.created_at).getTime()
|
||||
if (at !== bt) return at - bt
|
||||
const aid = Number(a.id)
|
||||
const bid = Number(b.id)
|
||||
if (!Number.isNaN(aid) && !Number.isNaN(bid)) {
|
||||
return aid - bid
|
||||
}
|
||||
return String(a.id).localeCompare(String(b.id))
|
||||
})
|
||||
}
|
||||
|
||||
function buildSeenText({ message, isMine, isDirect, isLastMessage, otherLastReadAt }) {
|
||||
if (!isDirect || !isMine || !isLastMessage || !otherLastReadAt || !message?.created_at) {
|
||||
return null
|
||||
}
|
||||
|
||||
const seenAt = new Date(otherLastReadAt)
|
||||
const sentAt = new Date(message.created_at)
|
||||
|
||||
if (Number.isNaN(seenAt.getTime()) || Number.isNaN(sentAt.getTime()) || seenAt < sentAt) {
|
||||
return null
|
||||
}
|
||||
|
||||
return `Seen ${relativeTimeFromNow(otherLastReadAt)} ago`
|
||||
}
|
||||
|
||||
function relativeTimeFromNow(iso) {
|
||||
const seconds = Math.max(0, Math.floor((Date.now() - new Date(iso).getTime()) / 1000))
|
||||
if (seconds < 60) return `${seconds}s`
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`
|
||||
return `${Math.floor(seconds / 86400)}d`
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
const d = new Date(iso)
|
||||
const today = new Date()
|
||||
const yesterday = new Date(today)
|
||||
yesterday.setDate(today.getDate() - 1)
|
||||
|
||||
if (isSameDay(d, today)) return 'Today'
|
||||
if (isSameDay(d, yesterday)) return 'Yesterday'
|
||||
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function isSameDay(a, b) {
|
||||
return a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
}
|
||||
252
resources/js/components/messaging/MessageBubble.jsx
Normal file
252
resources/js/components/messaging/MessageBubble.jsx
Normal file
@@ -0,0 +1,252 @@
|
||||
import React, { useState, useRef, useEffect } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
|
||||
const QUICK_REACTIONS = ['👍', '❤️', '🔥', '😂', '👏', '😮']
|
||||
|
||||
/**
|
||||
* Individual message bubble with:
|
||||
* - Markdown rendering (no raw HTML allowed)
|
||||
* - Hover reaction picker + unreact on click
|
||||
* - Inline edit for own messages
|
||||
* - Soft-delete display
|
||||
*/
|
||||
export default function MessageBubble({ message, isMine, showAvatar, onReact, onUnreact, onEdit, onReport = null, seenText = null }) {
|
||||
const [showPicker, setShowPicker] = useState(false)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [editBody, setEditBody] = useState(message.body ?? '')
|
||||
const [savingEdit, setSavingEdit] = useState(false)
|
||||
const editRef = useRef(null)
|
||||
|
||||
const isDeleted = !!message.deleted_at
|
||||
const isEdited = !!message.edited_at
|
||||
const username = message.sender?.username ?? 'Unknown'
|
||||
const time = formatTime(message.created_at)
|
||||
|
||||
// Focus textarea when entering edit mode
|
||||
useEffect(() => {
|
||||
if (editing) {
|
||||
editRef.current?.focus()
|
||||
editRef.current?.setSelectionRange(editBody.length, editBody.length)
|
||||
}
|
||||
}, [editing])
|
||||
|
||||
const reactionGroups = groupReactions(message.reactions ?? [])
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
const trimmed = editBody.trim()
|
||||
if (!trimmed || trimmed === message.body || savingEdit) return
|
||||
setSavingEdit(true)
|
||||
try {
|
||||
await onEdit(message.id, trimmed)
|
||||
setEditing(false)
|
||||
} finally {
|
||||
setSavingEdit(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditKeyDown = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSaveEdit() }
|
||||
if (e.key === 'Escape') { setEditing(false); setEditBody(message.body) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group flex gap-2 items-end ${isMine ? 'flex-row-reverse' : 'flex-row'} ${showAvatar ? 'mt-3' : 'mt-0.5'}`}
|
||||
onMouseEnter={() => !isDeleted && !editing && setShowPicker(true)}
|
||||
onMouseLeave={() => setShowPicker(false)}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div className={`flex-shrink-0 w-7 h-7 ${showAvatar ? 'visible' : 'invisible'}`}>
|
||||
<div className="w-7 h-7 rounded-full bg-gradient-to-br from-indigo-400 to-pink-400 flex items-center justify-center text-white text-xs font-medium select-none">
|
||||
{username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`max-w-[75%] flex flex-col ${isMine ? 'items-end' : 'items-start'}`}>
|
||||
{/* Sender name & time */}
|
||||
{showAvatar && (
|
||||
<div className={`flex items-center gap-1.5 mb-1 ${isMine ? 'flex-row-reverse' : ''}`}>
|
||||
<span className="text-xs font-medium text-gray-700 dark:text-gray-300">{username}</span>
|
||||
<span className="text-xs text-gray-400">{time}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bubble */}
|
||||
<div className="relative">
|
||||
{editing ? (
|
||||
<div className="w-72">
|
||||
<textarea
|
||||
ref={editRef}
|
||||
value={editBody}
|
||||
onChange={e => setEditBody(e.target.value)}
|
||||
onKeyDown={handleEditKeyDown}
|
||||
rows={3}
|
||||
maxLength={5000}
|
||||
className="w-full resize-none rounded-xl border border-blue-400 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex gap-2 mt-1 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setEditing(false); setEditBody(message.body) }}
|
||||
className="text-xs text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveEdit}
|
||||
disabled={savingEdit || !editBody.trim() || editBody.trim() === message.body}
|
||||
className="text-xs text-blue-500 hover:text-blue-700 font-medium disabled:opacity-40"
|
||||
>
|
||||
{savingEdit ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`px-3 py-2 rounded-2xl text-sm leading-relaxed break-words ${
|
||||
isMine
|
||||
? 'bg-blue-500 text-white rounded-br-sm'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-bl-sm'
|
||||
} ${isDeleted ? 'italic opacity-60' : ''} ${message._optimistic ? 'opacity-70' : ''}`}
|
||||
>
|
||||
{isDeleted ? (
|
||||
<span>This message was deleted.</span>
|
||||
) : (
|
||||
<>
|
||||
{message.attachments?.length > 0 && (
|
||||
<div className="mb-2 space-y-1">
|
||||
{message.attachments.map(att => (
|
||||
<div key={att.id}>
|
||||
{att.type === 'image' ? (
|
||||
<a href={`/messages/attachments/${att.id}`} target="_blank" rel="noopener noreferrer">
|
||||
<img
|
||||
src={`/messages/attachments/${att.id}`}
|
||||
alt={att.original_name}
|
||||
className="max-h-44 rounded-lg border border-white/20"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
) : (
|
||||
<a
|
||||
href={`/messages/attachments/${att.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs ${isMine ? 'bg-blue-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-100'}`}
|
||||
>
|
||||
📎 {att.original_name}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className={`prose prose-sm max-w-none prose-p:my-0 prose-pre:my-1 ${isMine ? 'prose-invert' : 'dark:prose-invert'}`}>
|
||||
<ReactMarkdown
|
||||
allowedElements={['p', 'strong', 'em', 'code', 'pre', 'ul', 'ol', 'li', 'blockquote', 'a', 'br']}
|
||||
unwrapDisallowed
|
||||
components={{
|
||||
a: ({ href, children }) => (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer"
|
||||
className={isMine ? 'text-blue-200 underline' : 'text-blue-600 dark:text-blue-400 underline'}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
code: ({ children, className }) => className
|
||||
? <code className={`${className} text-xs`}>{children}</code>
|
||||
: <code className={`px-1 py-0.5 rounded text-xs ${isMine ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700'}`}>{children}</code>,
|
||||
}}
|
||||
>
|
||||
{message.body}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
{isEdited && (
|
||||
<span className={`text-xs ml-1 ${isMine ? 'text-blue-200' : 'text-gray-400'}`}>(edited)</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hover action strip: reactions + edit pencil */}
|
||||
{showPicker && !editing && !isDeleted && (
|
||||
<div
|
||||
className={`absolute bottom-full mb-1 ${isMine ? 'right-0' : 'left-0'} flex items-center gap-1 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-full px-2 py-1 shadow-md z-10 whitespace-nowrap`}
|
||||
onMouseEnter={() => setShowPicker(true)}
|
||||
onMouseLeave={() => setShowPicker(false)}
|
||||
>
|
||||
{QUICK_REACTIONS.map(emoji => (
|
||||
<button key={emoji} onClick={() => onReact(message.id, emoji)}
|
||||
className="text-base hover:scale-125 transition-transform" title={`React ${emoji}`}>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
{isMine && (
|
||||
<button
|
||||
onClick={() => { setEditing(true); setShowPicker(false) }}
|
||||
className="ml-1 text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
|
||||
title="Edit message"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{!isMine && onReport && (
|
||||
<button
|
||||
onClick={() => { onReport(message.id); setShowPicker(false) }}
|
||||
className="ml-1 text-gray-400 hover:text-red-500"
|
||||
title="Report message"
|
||||
>
|
||||
⚑
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reactions bar */}
|
||||
{reactionGroups.length > 0 && !isDeleted && (
|
||||
<div className={`flex flex-wrap gap-1 mt-1 ${isMine ? 'justify-end' : 'justify-start'}`}>
|
||||
{reactionGroups.map(({ emoji, count, iReacted }) => (
|
||||
<button
|
||||
key={emoji}
|
||||
onClick={() => iReacted ? onUnreact(message.id, emoji) : onReact(message.id, emoji)}
|
||||
title={iReacted ? 'Remove reaction' : `React ${emoji}`}
|
||||
className={`flex items-center gap-0.5 px-1.5 py-0.5 rounded-full text-xs transition-colors ${
|
||||
iReacted
|
||||
? 'bg-blue-100 dark:bg-blue-900/40 border border-blue-300 dark:border-blue-700 text-blue-700 dark:text-blue-300'
|
||||
: 'bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span>{emoji}</span>
|
||||
<span>{count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isMine && seenText && (
|
||||
<div className="mt-1 text-[11px] text-gray-400 dark:text-gray-500">
|
||||
{seenText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatTime(iso) {
|
||||
return new Date(iso).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function groupReactions(reactions) {
|
||||
const map = new Map()
|
||||
for (const r of reactions) {
|
||||
if (!map.has(r.reaction)) map.set(r.reaction, { count: 0, iReacted: false })
|
||||
const entry = map.get(r.reaction)
|
||||
entry.count++
|
||||
if (r._iMine) entry.iReacted = true
|
||||
}
|
||||
return Array.from(map.entries()).map(([emoji, { count, iReacted }]) => ({ emoji, count, iReacted }))
|
||||
}
|
||||
177
resources/js/components/messaging/NewConversationModal.jsx
Normal file
177
resources/js/components/messaging/NewConversationModal.jsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
|
||||
/**
|
||||
* Modal for creating a new direct or group conversation.
|
||||
*/
|
||||
export default function NewConversationModal({ currentUserId, apiFetch, onCreated, onClose }) {
|
||||
const [type, setType] = useState('direct')
|
||||
const [recipientInput, setRecipient] = useState('')
|
||||
const [groupTitle, setGroupTitle] = useState('')
|
||||
const [participantInputs, setParticipantInputs] = useState(['', ''])
|
||||
const [body, setBody] = useState('')
|
||||
const [sending, setSending] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const addParticipant = () => setParticipantInputs(p => [...p, ''])
|
||||
const updateParticipant = (i, val) =>
|
||||
setParticipantInputs(p => p.map((v, idx) => idx === i ? val : v))
|
||||
const removeParticipant = (i) =>
|
||||
setParticipantInputs(p => p.filter((_, idx) => idx !== i))
|
||||
|
||||
const handleSubmit = useCallback(async (e) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setSending(true)
|
||||
|
||||
try {
|
||||
// Resolve usernames to IDs via the search API
|
||||
let payload = { type, body }
|
||||
|
||||
if (type === 'direct') {
|
||||
const user = await resolveUsername(recipientInput.trim(), apiFetch)
|
||||
payload.recipient_id = user.id
|
||||
} else {
|
||||
const resolved = await Promise.all(
|
||||
participantInputs
|
||||
.map(p => p.trim())
|
||||
.filter(Boolean)
|
||||
.map(u => resolveUsername(u, apiFetch))
|
||||
)
|
||||
payload.participant_ids = resolved.map(u => u.id)
|
||||
payload.title = groupTitle.trim()
|
||||
}
|
||||
|
||||
const conv = await apiFetch('/api/messages/conversation', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
onCreated(conv)
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}, [type, body, recipientInput, groupTitle, participantInputs, apiFetch, onCreated])
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-xl w-full max-w-md p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">New Message</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Type toggle */}
|
||||
<div className="flex rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden mb-4">
|
||||
{['direct', 'group'].map(t => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setType(t)}
|
||||
className={`flex-1 py-1.5 text-sm font-medium transition-colors ${
|
||||
type === t
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
{t === 'direct' ? '1:1 Message' : 'Group'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
{type === 'direct' ? (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">Recipient username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={recipientInput}
|
||||
onChange={e => setRecipient(e.target.value)}
|
||||
placeholder="username"
|
||||
required
|
||||
className="w-full rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">Group name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={groupTitle}
|
||||
onChange={e => setGroupTitle(e.target.value)}
|
||||
placeholder="Group name"
|
||||
required
|
||||
maxLength={120}
|
||||
className="w-full rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">Participants (usernames)</label>
|
||||
{participantInputs.map((val, i) => (
|
||||
<div key={i} className="flex gap-2 mb-1">
|
||||
<input
|
||||
type="text"
|
||||
value={val}
|
||||
onChange={e => updateParticipant(i, e.target.value)}
|
||||
placeholder={`Username ${i + 1}`}
|
||||
required
|
||||
className="flex-1 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 px-3 py-1.5 text-sm text-gray-900 dark:text-gray-100 outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
{participantInputs.length > 2 && (
|
||||
<button type="button" onClick={() => removeParticipant(i)} className="text-gray-400 hover:text-red-500">×</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={addParticipant} className="text-xs text-blue-500 hover:text-blue-700 mt-1">
|
||||
+ Add participant
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">Message</label>
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={e => setBody(e.target.value)}
|
||||
placeholder="Write your message…"
|
||||
required
|
||||
rows={3}
|
||||
maxLength={5000}
|
||||
className="w-full resize-none rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-red-500 bg-red-50 dark:bg-red-900/30 rounded px-2 py-1">{error}</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 justify-end pt-1">
|
||||
<button type="button" onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-900">Cancel</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={sending}
|
||||
className="px-4 py-2 rounded-xl bg-blue-500 hover:bg-blue-600 disabled:opacity-50 text-white text-sm font-medium transition-colors"
|
||||
>
|
||||
{sending ? 'Sending…' : 'Send'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Resolve username to user object via search API ───────────────────────────
|
||||
async function resolveUsername(username, apiFetch) {
|
||||
const data = await apiFetch(`/api/search/users?q=${encodeURIComponent(username)}&limit=1`)
|
||||
const user = data?.data?.[0] ?? data?.[0]
|
||||
if (!user) throw new Error(`User "${username}" not found.`)
|
||||
return user
|
||||
}
|
||||
@@ -57,7 +57,7 @@ import './lib/nav-context.js';
|
||||
if (except && dropdown === except) return;
|
||||
var menu = dropdown.querySelector('[data-dropdown-menu]');
|
||||
var toggle = dropdown.querySelector('[data-dropdown-toggle]');
|
||||
if (menu) menu.classList.add('hidden');
|
||||
if (menu) menu.classList.remove('is-open');
|
||||
setExpanded(toggle, false);
|
||||
|
||||
// Close any submenus
|
||||
@@ -75,14 +75,14 @@ import './lib/nav-context.js';
|
||||
var toggle = dropdown.querySelector('[data-dropdown-toggle]');
|
||||
if (!menu || !toggle) return;
|
||||
closeAllDropdowns(dropdown);
|
||||
menu.classList.remove('hidden');
|
||||
menu.classList.add('is-open');
|
||||
setExpanded(toggle, true);
|
||||
}
|
||||
|
||||
function closeDropdown(dropdown) {
|
||||
var menu = dropdown.querySelector('[data-dropdown-menu]');
|
||||
var toggle = dropdown.querySelector('[data-dropdown-toggle]');
|
||||
if (menu) menu.classList.add('hidden');
|
||||
if (menu) menu.classList.remove('is-open');
|
||||
setExpanded(toggle, false);
|
||||
}
|
||||
|
||||
@@ -91,14 +91,14 @@ import './lib/nav-context.js';
|
||||
var toggle = dropdown.querySelector('[data-dropdown-toggle]');
|
||||
if (!menu || !toggle) return;
|
||||
|
||||
var isOpen = !menu.classList.contains('hidden');
|
||||
var isOpen = menu.classList.contains('is-open');
|
||||
closeAllDropdowns(isOpen ? null : dropdown);
|
||||
|
||||
if (isOpen) {
|
||||
menu.classList.add('hidden');
|
||||
menu.classList.remove('is-open');
|
||||
setExpanded(toggle, false);
|
||||
} else {
|
||||
menu.classList.remove('hidden');
|
||||
menu.classList.add('is-open');
|
||||
setExpanded(toggle, true);
|
||||
}
|
||||
}
|
||||
@@ -158,15 +158,23 @@ import './lib/nav-context.js';
|
||||
if (!menu) return;
|
||||
|
||||
// treat this pair (toggle + menu) similarly to our dropdown API
|
||||
var isOpen = !menu.classList.contains('hidden');
|
||||
var isOpen = menu.classList.contains('is-open');
|
||||
// close other dropdowns
|
||||
closeAllDropdowns();
|
||||
// also close other legacy (data-dd) menus
|
||||
document.querySelectorAll('[data-dd]').forEach(function (other) {
|
||||
if (other === legacyToggle) return;
|
||||
var otherId = other.getAttribute('data-dd');
|
||||
var otherMenu = otherId ? document.getElementById('dd-' + otherId) : null;
|
||||
if (otherMenu) otherMenu.classList.remove('is-open');
|
||||
setExpanded(other, false);
|
||||
});
|
||||
|
||||
if (isOpen) {
|
||||
menu.classList.add('hidden');
|
||||
menu.classList.remove('is-open');
|
||||
setExpanded(legacyToggle, false);
|
||||
} else {
|
||||
menu.classList.remove('hidden');
|
||||
menu.classList.add('is-open');
|
||||
setExpanded(legacyToggle, true);
|
||||
}
|
||||
|
||||
@@ -249,7 +257,7 @@ import './lib/nav-context.js';
|
||||
if (el.hasAttribute && el.hasAttribute('data-dropdown')) {
|
||||
var menu = el.querySelector('[data-dropdown-menu]');
|
||||
var toggle = el.querySelector('[data-dropdown-toggle]');
|
||||
if (menu) menu.classList.add('hidden');
|
||||
if (menu) menu.classList.remove('is-open');
|
||||
setExpanded(toggle, false);
|
||||
// also close submenus inside
|
||||
el.querySelectorAll('[data-submenu-menu]').forEach(function (sm) {
|
||||
@@ -268,7 +276,7 @@ import './lib/nav-context.js';
|
||||
}
|
||||
|
||||
// hide the element if possible
|
||||
try { menuEl.classList.add('hidden'); } catch (e) {}
|
||||
try { menuEl.classList.remove('is-open'); } catch (e) {}
|
||||
|
||||
// Try to map back to a toggle: id like dd-name -> data-dd="name"
|
||||
if (menuEl.id && menuEl.id.indexOf('dd-') === 0) {
|
||||
@@ -302,7 +310,15 @@ import './lib/nav-context.js';
|
||||
// when pointer enters either toggle or menu, open
|
||||
function enter() {
|
||||
clearHoverTimer(menu);
|
||||
menu.classList.remove('hidden');
|
||||
// Instantly close any other open legacy dropdown to prevent overlap
|
||||
document.querySelectorAll('[data-dd]').forEach(function (other) {
|
||||
if (other === el) return;
|
||||
var otherId = other.getAttribute('data-dd');
|
||||
var otherMenu = otherId ? document.getElementById('dd-' + otherId) : null;
|
||||
if (otherMenu) otherMenu.classList.remove('is-open');
|
||||
setExpanded(other, false);
|
||||
});
|
||||
menu.classList.add('is-open');
|
||||
setExpanded(el, true);
|
||||
}
|
||||
|
||||
|
||||
138
resources/js/utils/emojiFlood.js
Normal file
138
resources/js/utils/emojiFlood.js
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* emojiFlood.js
|
||||
*
|
||||
* Utilities for detecting and collapsing emoji flood content on the client.
|
||||
*
|
||||
* These mirror the PHP-side logic in LegacySmileyMapper::collapseFlood() and
|
||||
* ContentSanitizer::validate() so that the UI can apply the same rules without
|
||||
* a round-trip to the server.
|
||||
*/
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Absolute emoji count above which text is considered a flood for display. */
|
||||
export const FLOOD_COUNT_THRESHOLD = 20
|
||||
|
||||
/** Ratio of emoji / total chars above which text is a density flood. */
|
||||
export const FLOOD_DENSITY_THRESHOLD = 0.40
|
||||
|
||||
/** Maximum consecutive identical emoji kept before the rest are collapsed. */
|
||||
export const COLLAPSE_MAX_RUN = 5
|
||||
|
||||
// ── Emoji detection ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Regex that matches a single emoji "unit":
|
||||
* • Codepoints U+1F000–U+1FFFF (most modern emoji)
|
||||
* • Codepoints U+2600–U+27BF (misc symbols, dingbats)
|
||||
* • Optionally followed by U+FE0F variation selector
|
||||
*
|
||||
* Note: ZWJ sequences and flags are not fully modelled here, but those
|
||||
* multi-codepoint emoji are extremely unlikely in legacy flood spam.
|
||||
*/
|
||||
const EMOJI_UNIT_RE = /[\u{1F000}-\u{1FFFF}\u{2600}-\u{27BF}]\uFE0F?/gu
|
||||
|
||||
/**
|
||||
* Count emoji in a string.
|
||||
* @param {string} text
|
||||
* @returns {number}
|
||||
*/
|
||||
export function countEmoji(text) {
|
||||
if (!text) return 0
|
||||
return (text.match(EMOJI_UNIT_RE) || []).length
|
||||
}
|
||||
|
||||
// ── Flood detection ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns true when the text qualifies as an emoji flood.
|
||||
*
|
||||
* Two independent flood signals:
|
||||
* 1. Absolute count > FLOOD_COUNT_THRESHOLD emoji (e.g. 21 beer mugs)
|
||||
* 2. Density > FLOOD_DENSITY_THRESHOLD (e.g. "🍺🍺🍺🍺" — 80% emoji, 0 text)
|
||||
* Only applied when count > 5 to avoid false-positives on short strings.
|
||||
*
|
||||
* @param {string} text
|
||||
* @param {object} [opts]
|
||||
* @param {number} [opts.maxCount=FLOOD_COUNT_THRESHOLD]
|
||||
* @param {number} [opts.maxDensity=FLOOD_DENSITY_THRESHOLD]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isFlood(text, { maxCount = FLOOD_COUNT_THRESHOLD, maxDensity = FLOOD_DENSITY_THRESHOLD } = {}) {
|
||||
if (!text) return false
|
||||
const count = countEmoji(text)
|
||||
if (count > maxCount) return true
|
||||
if (count > 5 && text.length > 0 && count / text.length > maxDensity) return true
|
||||
return false
|
||||
}
|
||||
|
||||
// ── Flood collapsing ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Collapse consecutive runs of the same emoji that exceed `maxRun` repetitions.
|
||||
*
|
||||
* Behaviour mirrors LegacySmileyMapper::collapseFlood() on the PHP side:
|
||||
* "🍺 🍺 🍺 🍺 🍺 🍺 🍺 🍺" (8×) → "🍺 🍺 🍺 🍺 🍺 ×8"
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Scan the string for all emoji "units" (codepoint + optional variation selector).
|
||||
* 2. Group consecutive matches of the SAME emoji where the gap between each
|
||||
* pair is pure horizontal whitespace (space / tab / no gap at all).
|
||||
* 3. Runs longer than `maxRun` are replaced with `maxRun` copies + " ×N".
|
||||
*
|
||||
* @param {string} text
|
||||
* @param {number} [maxRun=COLLAPSE_MAX_RUN]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function collapseEmojiRuns(text, maxRun = COLLAPSE_MAX_RUN) {
|
||||
if (!text) return text
|
||||
|
||||
// Step 1 – locate every emoji unit in the string.
|
||||
const unitRe = /[\u{1F000}-\u{1FFFF}\u{2600}-\u{27BF}]\uFE0F?/gu
|
||||
const hits = []
|
||||
let m
|
||||
while ((m = unitRe.exec(text)) !== null) {
|
||||
hits.push({ index: m.index, val: m[0], end: m.index + m[0].length })
|
||||
}
|
||||
if (hits.length === 0) return text
|
||||
|
||||
// Step 2 – group hits into "same-emoji runs separated only by whitespace".
|
||||
const runs = []
|
||||
let i = 0
|
||||
while (i < hits.length) {
|
||||
let j = i
|
||||
while (
|
||||
j + 1 < hits.length &&
|
||||
hits[j + 1].val === hits[i].val &&
|
||||
/^[ \t]*$/.test(text.slice(hits[j].end, hits[j + 1].index))
|
||||
) {
|
||||
j++
|
||||
}
|
||||
runs.push({ from: i, to: j, emoji: hits[i].val })
|
||||
i = j + 1
|
||||
}
|
||||
|
||||
// Step 3 – rebuild the string, collapsing long runs.
|
||||
if (!runs.some((r) => r.to - r.from + 1 > maxRun)) return text
|
||||
|
||||
let result = ''
|
||||
let pos = 0
|
||||
for (const run of runs) {
|
||||
const count = run.to - run.from + 1
|
||||
const spanStart = hits[run.from].index
|
||||
const spanEnd = hits[run.to].end
|
||||
|
||||
result += text.slice(pos, spanStart)
|
||||
|
||||
if (count > maxRun) {
|
||||
result += Array(maxRun).fill(run.emoji).join(' ') + ' ×' + count
|
||||
} else {
|
||||
result += text.slice(spanStart, spanEnd)
|
||||
}
|
||||
|
||||
pos = spanEnd
|
||||
}
|
||||
result += text.slice(pos)
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -91,5 +91,63 @@ header {
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
/* ─── Dropdown animations ─────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* .dd-menu — base (closed) state.
|
||||
* Invisible, shifted up slightly, non-interactive.
|
||||
* On close the element fades out; visibility is hidden *after* the fade
|
||||
* so the element is still rendered during the transition.
|
||||
*/
|
||||
.dd-menu {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px) scale(0.97);
|
||||
pointer-events: none;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
opacity 140ms ease,
|
||||
transform 140ms ease,
|
||||
visibility 0s linear 140ms;
|
||||
}
|
||||
|
||||
/**
|
||||
* .dd-menu.is-open — open state.
|
||||
* Fully visible, interactive, with a snappy entrance spring.
|
||||
* visibility is made visible immediately (0s delay), then
|
||||
* opacity + transform animate in.
|
||||
*/
|
||||
.dd-menu.is-open {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
pointer-events: auto;
|
||||
visibility: visible;
|
||||
transition:
|
||||
opacity 170ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
transform 170ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
visibility 0s linear 0s;
|
||||
}
|
||||
|
||||
/* Chevron rotation indicator for open dropdowns */
|
||||
[data-dd] svg:last-child,
|
||||
[data-dropdown-toggle] svg:last-child {
|
||||
transition: transform 180ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
[data-dd][aria-expanded="true"] svg:last-child,
|
||||
[data-dropdown-toggle][aria-expanded="true"] svg:last-child {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Item hover: subtle left-border accent when hovering menu links */
|
||||
.dd-menu a:hover,
|
||||
.dd-menu button:hover {
|
||||
border-left: 2px solid rgba(77,163,255,0.4);
|
||||
padding-left: calc(1rem - 2px);
|
||||
}
|
||||
.dd-menu a,
|
||||
.dd-menu button {
|
||||
border-left: 2px solid transparent;
|
||||
transition: border-color 120ms ease, background-color 120ms ease, padding-left 120ms ease;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
$uploadCount = 0;
|
||||
}
|
||||
try {
|
||||
$favCount = \Illuminate\Support\Facades\DB::table('favourites')->where('user_id', $userId)->count();
|
||||
$favCount = \Illuminate\Support\Facades\DB::table('artwork_favourites')->where('user_id', $userId)->count();
|
||||
} catch (\Throwable $e) {
|
||||
$favCount = 0;
|
||||
}
|
||||
|
||||
@@ -216,6 +216,7 @@
|
||||
following: {{ $viewerIsFollowing ? 'true' : 'false' }},
|
||||
count: {{ (int) $followerCount }},
|
||||
loading: false,
|
||||
hovering: false,
|
||||
async toggle() {
|
||||
this.loading = true;
|
||||
try {
|
||||
@@ -232,13 +233,13 @@
|
||||
this.loading = false;
|
||||
}
|
||||
}">
|
||||
<button @click="toggle" :disabled="loading" class="follow-btn inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium border transition-all"
|
||||
<button @click="toggle" @mouseenter="hovering=true" @mouseleave="hovering=false" :disabled="loading" class="follow-btn inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium border transition-all"
|
||||
:class="following
|
||||
? 'bg-green-500/10 border-green-500/40 text-green-400 hover:bg-red-500/10 hover:border-red-500/40 hover:text-red-400'
|
||||
: 'bg-[--sb-blue]/10 border-[--sb-blue]/40 text-[--sb-blue] hover:bg-[--sb-blue]/20'">
|
||||
<i class="fa-solid fa-fw"
|
||||
:class="loading ? 'fa-circle-notch fa-spin' : (following ? 'fa-user-check' : 'fa-user-plus')"></i>
|
||||
<span x-text="following ? 'Following' : 'Follow'"></span>
|
||||
:class="loading ? 'fa-circle-notch fa-spin' : (following ? (hovering ? 'fa-user-minus' : 'fa-user-check') : 'fa-user-plus')"></i>
|
||||
<span x-text="following ? (hovering ? 'Unfollow' : 'Following') : 'Follow'"></span>
|
||||
<span class="text-xs opacity-60" x-text="'(' + count + ')'"></span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -268,11 +269,12 @@
|
||||
<div class="max-w-screen-xl mx-auto px-4">
|
||||
<div class="flex divide-x divide-[--sb-line] overflow-x-auto">
|
||||
@foreach([
|
||||
['value' => number_format($stats->uploads ?? 0), 'label' => 'Uploads', 'icon' => 'fa-cloud-arrow-up'],
|
||||
['value' => number_format($stats->downloads ?? 0), 'label' => 'Downloads', 'icon' => 'fa-download'],
|
||||
['value' => number_format($stats->profile_views ?? 0), 'label' => 'Profile Views', 'icon' => 'fa-eye'],
|
||||
['value' => number_format($followerCount), 'label' => 'Followers', 'icon' => 'fa-users'],
|
||||
['value' => number_format($stats->awards ?? 0), 'label' => 'Awards', 'icon' => 'fa-trophy'],
|
||||
['value' => number_format($stats->uploads_count ?? 0), 'label' => 'Uploads', 'icon' => 'fa-cloud-arrow-up'],
|
||||
['value' => number_format($stats->downloads_received_count ?? 0),'label' => 'Downloads', 'icon' => 'fa-download'],
|
||||
['value' => number_format($stats->profile_views_count ?? 0), 'label' => 'Profile Views', 'icon' => 'fa-eye'],
|
||||
['value' => number_format($followerCount), 'label' => 'Followers', 'icon' => 'fa-users'],
|
||||
['value' => number_format($stats->following_count ?? 0), 'label' => 'Following', 'icon' => 'fa-user-check'],
|
||||
['value' => number_format($stats->awards_received_count ?? 0), 'label' => 'Awards', 'icon' => 'fa-trophy'],
|
||||
] as $si)
|
||||
<div class="stat-item flex-1 py-3">
|
||||
<div class="stat-value">{{ $si['value'] }}</div>
|
||||
@@ -503,11 +505,11 @@
|
||||
<div class="nova-panel-body p-0">
|
||||
<table class="profile-table w-full">
|
||||
@foreach([
|
||||
['Profile Views', number_format($stats->profile_views ?? 0), null],
|
||||
['Uploads', number_format($stats->uploads ?? 0), null],
|
||||
['Downloads', number_format($stats->downloads ?? 0), null],
|
||||
['Page Views', number_format($stats->pageviews ?? 0), null],
|
||||
['Featured Works',number_format($stats->awards ?? 0), 'fa-star text-yellow-400'],
|
||||
['Profile Views', number_format($stats->profile_views_count ?? 0), null],
|
||||
['Uploads', number_format($stats->uploads_count ?? 0), null],
|
||||
['Downloads', number_format($stats->downloads_received_count ?? 0), null],
|
||||
['Page Views', number_format($stats->artwork_views_received_count ?? 0), null],
|
||||
['Featured Works',number_format($stats->awards_received_count ?? 0), 'fa-star text-yellow-400'],
|
||||
] as [$label, $value, $iconClass])
|
||||
<tr>
|
||||
<td class="pl-4">{{ $label }}</td>
|
||||
|
||||
@@ -1,34 +1,55 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
<div class="container-fluid legacy-page">
|
||||
<div class="effect2 page-header-wrap">
|
||||
<header class="page-heading">
|
||||
<h1 class="page-header">{{ $page_title ?? 'Today in History' }}</h1>
|
||||
<p>List of featured Artworks on this day in history of Skinbase.</p>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<div class="panel panel-default uploads-panel effect2">
|
||||
<div class="panel-body">
|
||||
<div class="container_photo gallery_box">
|
||||
@if($artworks && $artworks->count())
|
||||
@foreach($artworks as $ar)
|
||||
<div class="photo_frame">
|
||||
<a href="/art/{{ $ar->id }}/{{ \Illuminate\Support\Str::slug($ar->name ?? '') }}">
|
||||
<img src="{{ $ar->thumb_url ?? '/gfx/sb_join.jpg' }}" @if(!empty($ar->thumb_srcset)) srcset="{{ $ar->thumb_srcset }}" @endif loading="lazy" decoding="async" alt="{{ $ar->name }}">
|
||||
</a>
|
||||
</div>
|
||||
@endforeach
|
||||
@else
|
||||
<p class="text-muted">No featured artworks found for today in history.</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="paginationMenu text-center">
|
||||
@if($artworks){{ $artworks->withQueryString()->links('pagination::bootstrap-4') }}@endif
|
||||
{{-- ── Hero header ── --}}
|
||||
<div class="px-6 pt-10 pb-6 md:px-10">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">History</p>
|
||||
<h1 class="text-3xl font-bold text-white leading-tight">{{ $page_title ?? 'Today in History' }}</h1>
|
||||
<p class="mt-1 text-sm text-white/50">
|
||||
Featured artworks uploaded on
|
||||
<span class="text-white/80 font-medium">{{ $todayLabel ?? now()->format('F j') }}</span>
|
||||
in past years.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Gallery ── --}}
|
||||
<div class="px-6 pb-16 md:px-10">
|
||||
@if($artworks && $artworks->count())
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
|
||||
@foreach($artworks as $ar)
|
||||
<a href="{{ $ar->art_url ?? ('/art/' . $ar->id) }}"
|
||||
class="group relative block overflow-hidden rounded-xl ring-1 ring-white/5 bg-black/20 shadow-md transition-all duration-200 hover:-translate-y-0.5">
|
||||
<div class="relative aspect-square overflow-hidden bg-neutral-900">
|
||||
<img src="{{ $ar->thumb_url ?? '/gfx/sb_join.jpg' }}"
|
||||
alt="{{ $ar->name ?? '' }}"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.06]"
|
||||
onerror="this.src='/gfx/sb_join.jpg'">
|
||||
{{-- Title overlay on hover --}}
|
||||
<div class="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent px-2 py-2
|
||||
opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||
<p class="truncate text-xs font-medium text-white">{{ $ar->name ?? 'Untitled' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- Pagination --}}
|
||||
<div class="mt-10 flex justify-center">
|
||||
{{ $artworks->withQueryString()->links('pagination::bootstrap-4') }}
|
||||
</div>
|
||||
@else
|
||||
<div class="rounded-xl border border-white/[0.06] bg-white/[0.02] px-8 py-16 text-center">
|
||||
<p class="text-4xl mb-4">📅</p>
|
||||
<p class="text-white/60 text-sm">No featured artworks found for this day in history.</p>
|
||||
<p class="text-white/30 text-xs mt-1">Check back tomorrow!</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@endsection
|
||||
|
||||
10
resources/views/admin/reports/queue.blade.php
Normal file
10
resources/views/admin/reports/queue.blade.php
Normal file
@@ -0,0 +1,10 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
<div class="mx-auto max-w-5xl px-4 sm:px-6 lg:px-8 py-10">
|
||||
<div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-6">
|
||||
<h1 class="text-xl font-semibold text-gray-900 dark:text-gray-100">Reports Queue</h1>
|
||||
<p class="mt-2 text-sm text-gray-500">Use the API endpoint <code>/api/reports</code> to submit reports and review records in the <code>reports</code> table.</p>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
40
resources/views/dashboard/awards.blade.php
Normal file
40
resources/views/dashboard/awards.blade.php
Normal file
@@ -0,0 +1,40 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
<div class="container mx-auto py-8">
|
||||
<h1 class="text-2xl font-semibold mb-1">My Awards</h1>
|
||||
<p class="text-sm text-soft mb-6">Artworks of yours that have received awards from the community.</p>
|
||||
|
||||
@if($artworks->isEmpty())
|
||||
<div class="flex flex-col items-center justify-center py-20 text-center">
|
||||
<i class="fa-solid fa-trophy text-4xl text-sb-muted mb-4"></i>
|
||||
<p class="text-soft">None of your artworks have received awards yet.</p>
|
||||
<a href="/browse" class="mt-4 text-sm text-accent hover:underline">Browse artworks for inspiration</a>
|
||||
</div>
|
||||
@else
|
||||
<section data-nova-gallery data-gallery-type="dashboard-awards">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6" data-gallery-grid>
|
||||
@foreach($artworks as $art)
|
||||
<div class="relative gallery-item">
|
||||
<x-artwork-card :art="$art" />
|
||||
@php $stat = $art->awardStat @endphp
|
||||
@if($stat && ($stat->gold_count + $stat->silver_count + $stat->bronze_count) > 0)
|
||||
<div class="absolute left-2 top-2 z-40 flex gap-1 text-xs font-bold">
|
||||
@if($stat->gold_count) <span class="rounded px-1.5 py-0.5 bg-yellow-500/80 text-black">🥇 {{ $stat->gold_count }}</span> @endif
|
||||
@if($stat->silver_count) <span class="rounded px-1.5 py-0.5 bg-neutral-400/80 text-black">🥈 {{ $stat->silver_count }}</span> @endif
|
||||
@if($stat->bronze_count) <span class="rounded px-1.5 py-0.5 bg-amber-700/80 text-white">🥉 {{ $stat->bronze_count }}</span> @endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="mt-6" data-gallery-pagination>{{ $artworks->links() }}</div>
|
||||
<div class="hidden" data-gallery-skeleton-template aria-hidden="true">
|
||||
<x-skeleton.artwork-card />
|
||||
</div>
|
||||
<div class="hidden mt-8" data-gallery-skeleton></div>
|
||||
</section>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
@@ -1,17 +1,27 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
<div class="container mx-auto py-8">
|
||||
<h1 class="text-2xl font-semibold mb-4">Followers</h1>
|
||||
<div class="container mx-auto py-8 max-w-3xl">
|
||||
<h1 class="text-2xl font-semibold mb-6">My Followers</h1>
|
||||
|
||||
@if(empty($followers))
|
||||
@if($followers->isEmpty())
|
||||
<p class="text-sm text-gray-500">You have no followers yet.</p>
|
||||
@else
|
||||
<ul class="space-y-2">
|
||||
<div class="space-y-3">
|
||||
@foreach($followers as $f)
|
||||
<li>{{ $f }}</li>
|
||||
<a href="{{ $f->profile_url }}" class="flex items-center gap-4 p-3 rounded-lg hover:bg-white/5 transition">
|
||||
<img src="{{ $f->avatar_url }}" alt="{{ $f->uname }}" class="w-10 h-10 rounded-full object-cover">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium truncate">{{ $f->uname }}</div>
|
||||
<div class="text-xs text-gray-500">{{ $f->uploads }} uploads · followed {{ \Carbon\Carbon::parse($f->followed_at)->diffForHumans() }}</div>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
{{ $followers->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
<div class="container mx-auto py-8">
|
||||
<h1 class="text-2xl font-semibold mb-4">Following</h1>
|
||||
<div class="container mx-auto py-8 max-w-3xl">
|
||||
<h1 class="text-2xl font-semibold mb-6">People I Follow</h1>
|
||||
|
||||
@if(empty($following))
|
||||
<p class="text-sm text-gray-500">You are not following anyone yet.</p>
|
||||
@if($following->isEmpty())
|
||||
<p class="text-sm text-gray-500">You are not following anyone yet. <a href="{{ route('discover.trending') }}" class="underline">Discover creators</a></p>
|
||||
@else
|
||||
<ul class="space-y-2">
|
||||
<div class="space-y-3">
|
||||
@foreach($following as $f)
|
||||
<li>{{ $f }}</li>
|
||||
<a href="{{ $f->profile_url }}" class="flex items-center gap-4 p-3 rounded-lg hover:bg-white/5 transition">
|
||||
<img src="{{ $f->avatar_url }}" alt="{{ $f->uname }}" class="w-10 h-10 rounded-full object-cover">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium truncate">{{ $f->uname }}</div>
|
||||
<div class="text-xs text-gray-500">{{ $f->uploads }} uploads · {{ $f->followers_count }} followers · followed {{ \Carbon\Carbon::parse($f->followed_at)->diffForHumans() }}</div>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
{{ $following->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@@ -1,271 +1,375 @@
|
||||
<header class="fixed inset-x-0 top-0 z-50 h-16 bg-nova border-b border-panel">
|
||||
<div class="mx-auto w-full h-full px-4 flex items-center gap-3">
|
||||
|
||||
<!-- Mobile hamburger -->
|
||||
<button id="btnSidebar"
|
||||
class="md:hidden inline-flex items-center justify-center w-10 h-10 rounded-lg hover:bg-white/5">
|
||||
<!-- bars -->
|
||||
class="md:hidden inline-flex items-center justify-center w-10 h-10 rounded-lg hover:bg-white/5"
|
||||
aria-label="Open menu">
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Logo -->
|
||||
<a href="/" class="flex items-center gap-2 pr-2">
|
||||
<a href="/" class="flex items-center gap-2 pr-2 shrink-0">
|
||||
<img src="/gfx/sb_logo.png" alt="Skinbase.org" class="h-8 w-auto rounded-sm shadow-sm object-contain">
|
||||
<span class="sr-only">Skinbase.org</span>
|
||||
</a>
|
||||
|
||||
<!-- Left nav -->
|
||||
<nav class="hidden lg:flex items-center gap-4 text-sm text-soft">
|
||||
<!-- Desktop left nav: Discover · Browse · Creators · Community -->
|
||||
@php
|
||||
$navSection = match(true) {
|
||||
request()->is('discover', 'discover/*') => 'discover',
|
||||
request()->is('browse', 'photography', 'wallpapers', 'skins', 'other', 'tags', 'tags/*') => 'browse',
|
||||
request()->is('creators', 'creators/*', 'stories', 'stories/*', 'following') => 'creators',
|
||||
request()->is('forum', 'forum/*', 'news', 'news/*') => 'community',
|
||||
default => null,
|
||||
};
|
||||
@endphp
|
||||
<nav class="hidden lg:flex items-center gap-1 text-sm text-soft" aria-label="Main navigation">
|
||||
|
||||
{{-- DISCOVER --}}
|
||||
<div class="relative">
|
||||
<button class="hover:text-white inline-flex items-center gap-1" data-dd="browse">
|
||||
<button class="inline-flex items-center gap-1 px-3 py-2 rounded-lg transition-colors {{ $navSection === 'discover' ? 'text-white bg-white/10' : 'hover:text-white hover:bg-white/5' }}"
|
||||
data-dd="discover"
|
||||
{{ $navSection === 'discover' ? 'aria-current=page' : '' }}>
|
||||
Discover
|
||||
<svg class="w-4 h-4 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6" /></svg>
|
||||
</button>
|
||||
<div id="dd-discover" class="dd-menu absolute left-0 mt-1 w-56 rounded-xl bg-panel border border-panel shadow-sb overflow-hidden">
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/discover/trending">
|
||||
<i class="fa-solid fa-fire w-4 text-center text-sb-muted"></i>Trending
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/discover/fresh">
|
||||
<i class="fa-solid fa-bolt w-4 text-center text-sb-muted"></i>Fresh
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/discover/top-rated">
|
||||
<i class="fa-solid fa-medal w-4 text-center text-sb-muted"></i>Top Rated
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/discover/most-downloaded">
|
||||
<i class="fa-solid fa-download w-4 text-center text-sb-muted"></i>Most Downloaded
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/discover/on-this-day">
|
||||
<i class="fa-solid fa-calendar-day w-4 text-center text-sb-muted"></i>On This Day
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- BROWSE --}}
|
||||
<div class="relative">
|
||||
<button class="inline-flex items-center gap-1 px-3 py-2 rounded-lg transition-colors {{ $navSection === 'browse' ? 'text-white bg-white/10' : 'hover:text-white hover:bg-white/5' }}"
|
||||
data-dd="browse"
|
||||
{{ $navSection === 'browse' ? 'aria-current=page' : '' }}>
|
||||
Browse
|
||||
<svg class="w-4 h-4 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6" /></svg>
|
||||
</button>
|
||||
<div id="dd-browse" class="hidden absolute left-0 mt-2 w-64 rounded-lg bg-panel border border-panel shadow-sb overflow-visible">
|
||||
<div class="rounded-lg overflow-hidden">
|
||||
<div class="px-4 dd-section">Views</div>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/forum"><i class="fa-solid fa-comments mr-3 text-sb-muted"></i>Forum</a>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/chat"><i class="fa-solid fa-message mr-3 text-sb-muted"></i>Chat</a>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/sections"><i class="fa-solid fa-folder-open mr-3 text-sb-muted"></i>Browse Sections</a>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/uploads/latest"><i class="fa-solid fa-cloud-arrow-up mr-3 text-sb-muted"></i>Latest Uploads</a>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/uploads/daily"><i class="fa-solid fa-calendar-day mr-3 text-sb-muted"></i>Daily Uploads</a>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/today-in-history"><i class="fa-solid fa-calendar mr-3 text-sb-muted"></i>Today In History</a>
|
||||
|
||||
<div class="px-4 dd-section">Authors</div>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/interviews"><i class="fa-solid fa-microphone mr-3 text-sb-muted"></i>Interviews</a>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/members/photos"><i class="fa-solid fa-camera mr-3 text-sb-muted"></i>Members Photos</a>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/authors/top"><i class="fa-solid fa-star mr-3 text-sb-muted"></i>Top Authors</a>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/comments/latest"><i class="fa-solid fa-comments mr-3 text-sb-muted"></i>Latest Comments</a>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/comments/monthly"><i class="fa-solid fa-chart-line mr-3 text-sb-muted"></i>Monthly Commented</a>
|
||||
|
||||
<div class="px-4 dd-section">Statistics</div>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/downloads/today"><i class="fa-solid fa-download mr-3 text-sb-muted"></i>Todays Downloads</a>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/favourites/top"><i class="fa-solid fa-heart mr-3 text-sb-muted"></i>Top Favourites</a>
|
||||
</div> <!-- end .rounded-lg -->
|
||||
</div> <!-- end .dd-browse -->
|
||||
</div> <!-- end .relative -->
|
||||
|
||||
<div class="relative">
|
||||
<button class="hover:text-white inline-flex items-center gap-1" data-dd="cats">
|
||||
Explore
|
||||
<svg class="w-4 h-4 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2">
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
<div id="dd-cats"
|
||||
class="hidden absolute left-0 mt-2 w-64 rounded-lg bg-panel border border-panel shadow-sb overflow-hidden">
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/browse"><i class="fa-solid fa-border-all mr-3 text-sb-muted"></i>All Artworks</a>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/photography"><i class="fa-solid fa-camera mr-3 text-sb-muted"></i>Photography</a>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/wallpapers"><i class="fa-solid fa-desktop mr-3 text-sb-muted"></i>Wallpapers</a>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/skins"><i class="fa-solid fa-layer-group mr-3 text-sb-muted"></i>Skins</a>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/other"><i class="fa-solid fa-folder-open mr-3 text-sb-muted"></i>Other</a>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/featured-artworks"><i class="fa-solid fa-star mr-3 text-sb-muted"></i>Featured Artwork</a>
|
||||
|
||||
<div id="dd-browse" class="dd-menu absolute left-0 mt-1 w-56 rounded-xl bg-panel border border-panel shadow-sb overflow-hidden">
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/browse">
|
||||
<i class="fa-solid fa-border-all w-4 text-center text-sb-muted"></i>All Artworks
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/photography">
|
||||
<i class="fa-solid fa-camera w-4 text-center text-sb-muted"></i>Photography
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/wallpapers">
|
||||
<i class="fa-solid fa-desktop w-4 text-center text-sb-muted"></i>Wallpapers
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/skins">
|
||||
<i class="fa-solid fa-layer-group w-4 text-center text-sb-muted"></i>Skins
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/other">
|
||||
<i class="fa-solid fa-folder-open w-4 text-center text-sb-muted"></i>Other
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/tags">
|
||||
<i class="fa-solid fa-tags w-4 text-center text-sb-muted"></i>Tags
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- CREATORS --}}
|
||||
<div class="relative">
|
||||
<button class="inline-flex items-center gap-1 px-3 py-2 rounded-lg transition-colors {{ $navSection === 'creators' ? 'text-white bg-white/10' : 'hover:text-white hover:bg-white/5' }}"
|
||||
data-dd="creators"
|
||||
{{ $navSection === 'creators' ? 'aria-current=page' : '' }}>
|
||||
Creators
|
||||
<svg class="w-4 h-4 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6" /></svg>
|
||||
</button>
|
||||
<div id="dd-creators" class="dd-menu absolute left-0 mt-1 w-56 rounded-xl bg-panel border border-panel shadow-sb overflow-hidden">
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/creators/top">
|
||||
<i class="fa-solid fa-star w-4 text-center text-sb-muted"></i>Top Creators
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/creators/rising">
|
||||
<i class="fa-solid fa-arrow-trend-up w-4 text-center text-sb-muted"></i>Rising Creators
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/stories">
|
||||
<i class="fa-solid fa-microphone w-4 text-center text-sb-muted"></i>Creator Stories
|
||||
</a>
|
||||
@auth
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('dashboard.following') }}">
|
||||
<i class="fa-solid fa-user-plus w-4 text-center text-sb-muted"></i>Following
|
||||
</a>
|
||||
@endauth
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- COMMUNITY --}}
|
||||
<div class="relative">
|
||||
<button class="inline-flex items-center gap-1 px-3 py-2 rounded-lg transition-colors {{ $navSection === 'community' ? 'text-white bg-white/10' : 'hover:text-white hover:bg-white/5' }}"
|
||||
data-dd="community"
|
||||
{{ $navSection === 'community' ? 'aria-current=page' : '' }}>
|
||||
Community
|
||||
<svg class="w-4 h-4 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6" /></svg>
|
||||
</button>
|
||||
<div id="dd-community" class="dd-menu absolute left-0 mt-1 w-56 rounded-xl bg-panel border border-panel shadow-sb overflow-hidden">
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/forum">
|
||||
<i class="fa-solid fa-comments w-4 text-center text-sb-muted"></i>Forum
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/news">
|
||||
<i class="fa-solid fa-newspaper w-4 text-center text-sb-muted"></i>Announcements
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</nav>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<div class="w-full max-w-lg">
|
||||
<div id="topbar-search-root"></div>
|
||||
</div>
|
||||
<!-- Search: collapsed pill → expands on click -->
|
||||
<div class="flex-1 flex items-center justify-center px-2 min-w-0">
|
||||
<div id="topbar-search-root" class="w-full flex justify-center"></div>
|
||||
</div>
|
||||
|
||||
@auth
|
||||
<!-- Right icon counters (authenticated users) -->
|
||||
<div class="hidden md:flex items-center gap-3 text-soft">
|
||||
<button class="relative w-10 h-10 rounded-lg hover:bg-white/5">
|
||||
<svg class="w-5 h-5 mx-auto" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Upload CTA -->
|
||||
<a href="{{ route('upload') }}"
|
||||
class="hidden md:inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium transition-colors shrink-0">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
Upload
|
||||
</a>
|
||||
|
||||
<button class="relative w-10 h-10 rounded-lg hover:bg-white/5">
|
||||
<svg class="w-5 h-5 mx-auto" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2">
|
||||
<!-- Notification icons -->
|
||||
<div class="hidden md:flex items-center gap-1 text-soft">
|
||||
<a href="{{ route('dashboard.favorites') }}"
|
||||
class="relative w-10 h-10 inline-flex items-center justify-center rounded-lg hover:bg-white/5"
|
||||
title="Favourites">
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 21s-7-4.4-9-9a5.5 5.5 0 0 1 9-6 5.5 5.5 0 0 1 9 6c-2 4.6-9 9-9 9z" />
|
||||
</svg>
|
||||
<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">{{ $uploadCount ?? 0 }}</span>
|
||||
</button>
|
||||
@if(($favCount ?? 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">{{ $favCount }}</span>
|
||||
@endif
|
||||
</a>
|
||||
|
||||
<button class="relative w-10 h-10 rounded-lg hover:bg-white/5">
|
||||
<svg class="w-5 h-5 mx-auto" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2">
|
||||
<path d="M4 4h16v14H5.2L4 19.2V4z" />
|
||||
<path d="M4 6l8 6 8-6" />
|
||||
<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>
|
||||
<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">{{ $favCount ?? 0 }}</span>
|
||||
</button>
|
||||
@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>
|
||||
|
||||
<button class="relative w-10 h-10 rounded-lg hover:bg-white/5">
|
||||
<svg class="w-5 h-5 mx-auto" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2">
|
||||
<a href="{{ route('dashboard.comments') }}"
|
||||
class="relative w-10 h-10 inline-flex items-center justify-center rounded-lg hover:bg-white/5"
|
||||
title="Notifications">
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 8a6 6 0 10-12 0c0 7-3 7-3 7h18s-3 0-3-7" />
|
||||
<path d="M13.7 21a2 2 0 01-3.4 0" />
|
||||
</svg>
|
||||
<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 ?? 0 }}</span>
|
||||
@if(($noticeCount ?? 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">{{ $noticeCount }}</span>
|
||||
@endif
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Profile dropdown -->
|
||||
<div class="relative">
|
||||
<button class="flex items-center gap-2 pl-2 pr-3 h-10 rounded-lg hover:bg-white/5 transition-colors" data-dd="user">
|
||||
@php
|
||||
$toolbarUserId = (int) ($userId ?? Auth::id() ?? 0);
|
||||
$toolbarAvatarHash = $avatarHash ?? optional(Auth::user())->profile->avatar_hash ?? null;
|
||||
@endphp
|
||||
<img class="w-7 h-7 rounded-full object-cover ring-1 ring-white/10"
|
||||
src="{{ \App\Support\AvatarUrl::forUser($toolbarUserId, $toolbarAvatarHash, 64) }}"
|
||||
alt="{{ $displayName ?? 'User' }}" />
|
||||
<span class="hidden xl:inline text-sm text-white/90">{{ $displayName ?? 'User' }}</span>
|
||||
<svg class="w-4 h-4 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- User dropdown -->
|
||||
<div class="relative">
|
||||
<button class="flex items-center gap-2 pl-2 pr-3 h-10 rounded-lg hover:bg-white/5" data-dd="user">
|
||||
@php
|
||||
$toolbarUserId = (int) ($userId ?? Auth::id() ?? 0);
|
||||
$toolbarAvatarHash = $avatarHash ?? optional(Auth::user())->profile->avatar_hash ?? null;
|
||||
@endphp
|
||||
<img class="w-7 h-7 rounded-full object-cover ring-1 ring-white/10"
|
||||
src="{{ \App\Support\AvatarUrl::forUser($toolbarUserId, $toolbarAvatarHash, 64) }}"
|
||||
alt="{{ $displayName ?? 'User' }}" />
|
||||
<span class="text-sm text-white/90">{{ $displayName ?? 'User' }}</span>
|
||||
<svg class="w-4 h-4 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2">
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
<div id="dd-user"
|
||||
class="dd-menu absolute right-0 mt-1 w-64 rounded-xl bg-panel border border-panel shadow-sb overflow-hidden">
|
||||
|
||||
<div id="dd-user"
|
||||
class="hidden absolute right-0 mt-2 w-64 rounded-lg bg-panel border border-panel shadow-sb overflow-hidden">
|
||||
@php
|
||||
$toolbarUsername = strtolower((string) (Auth::user()->username ?? ''));
|
||||
$routeUpload = Route::has('upload') ? route('upload') : '/upload';
|
||||
$routeDashboardGallery = Route::has('dashboard.gallery') ? route('dashboard.gallery') : '/dashboard/gallery';
|
||||
$routeDashboardArtworks = Route::has('dashboard.artworks.index') ? route('dashboard.artworks.index') : (Route::has('dashboard.artworks') ? route('dashboard.artworks') : '/dashboard/artworks');
|
||||
$routeDashboardStats = Route::has('legacy.statistics') ? route('legacy.statistics') : '/statistics';
|
||||
$routeDashboardFavorites = Route::has('dashboard.favorites') ? route('dashboard.favorites') : '/dashboard/favorites';
|
||||
$routeDashboardAwards = Route::has('dashboard.awards') ? route('dashboard.awards') : '/dashboard/awards';
|
||||
$routeDashboardFollowers = Route::has('dashboard.followers') ? route('dashboard.followers') : '/dashboard/followers';
|
||||
$routeDashboardFollowing = Route::has('dashboard.following') ? route('dashboard.following') : '/dashboard/following';
|
||||
$routeDashboardComments = Route::has('dashboard.comments') ? route('dashboard.comments') : '/dashboard/comments';
|
||||
$routeDashboardProfile = Route::has('dashboard.profile') ? route('dashboard.profile') : '/dashboard/profile';
|
||||
$routeEditProfile = Route::has('settings') ? route('settings') : '/settings';
|
||||
$routePublicProfile = Route::has('profile.show') ? route('profile.show', ['username' => $toolbarUsername]) : '/@'.$toolbarUsername;
|
||||
@endphp
|
||||
|
||||
@php
|
||||
$toolbarUsername = strtolower((string) (Auth::user()->username ?? ''));
|
||||
$routeDashboardUpload = Route::has('dashboard.upload') ? route('dashboard.upload') : route('upload');
|
||||
$routeDashboardGallery = Route::has('dashboard.gallery') ? route('dashboard.gallery') : '/dashboard/gallery';
|
||||
$routeDashboardArtworks = Route::has('dashboard.artworks') ? route('dashboard.artworks') : (Route::has('dashboard.artworks.index') ? route('dashboard.artworks.index') : '/dashboard/artworks');
|
||||
$routeDashboardStats = Route::has('dashboard.stats') ? route('dashboard.stats') : (Route::has('legacy.statistics') ? route('legacy.statistics') : '/dashboard/stats');
|
||||
$routeDashboardFollowers = Route::has('dashboard.followers') ? route('dashboard.followers') : '/dashboard/followers';
|
||||
$routeDashboardFollowing = Route::has('dashboard.following') ? route('dashboard.following') : '/dashboard/following';
|
||||
$routeDashboardComments = Route::has('dashboard.comments') ? route('dashboard.comments') : '/dashboard/comments';
|
||||
$routeDashboardFavorites = Route::has('dashboard.favorites') ? route('dashboard.favorites') : '/dashboard/favorites';
|
||||
$routeDashboardProfile = Route::has('dashboard.profile') ? route('dashboard.profile') : (Route::has('profile.edit') ? route('profile.edit') : '/dashboard/profile');
|
||||
$routePublicProfile = Route::has('profile.show') ? route('profile.show', ['username' => $toolbarUsername]) : '/@'.$toolbarUsername;
|
||||
@endphp
|
||||
{{-- My Content --}}
|
||||
<div class="px-4 pt-3 pb-1 text-[11px] font-semibold uppercase tracking-widest text-sb-muted">My Content</div>
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeUpload }}">
|
||||
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-upload text-xs text-sb-muted"></i></span>
|
||||
Upload
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardGallery }}">
|
||||
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-image text-xs text-sb-muted"></i></span>
|
||||
My Gallery
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardFavorites }}">
|
||||
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-heart text-xs text-sb-muted"></i></span>
|
||||
My Favorites
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardAwards }}">
|
||||
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-trophy text-xs text-sb-muted"></i></span>
|
||||
My Awards
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardStats }}">
|
||||
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-chart-line text-xs text-sb-muted"></i></span>
|
||||
Statistics
|
||||
</a>
|
||||
|
||||
<div class="px-4 dd-section">My Account</div>
|
||||
{{-- Community --}}
|
||||
<div class="px-4 pt-3 pb-1 text-[11px] font-semibold uppercase tracking-widest text-sb-muted">Community</div>
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardFollowers }}">
|
||||
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-user-group text-xs text-sb-muted"></i></span>
|
||||
Followers
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardFollowing }}">
|
||||
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-user-plus text-xs text-sb-muted"></i></span>
|
||||
Following
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardComments }}">
|
||||
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-comments text-xs text-sb-muted"></i></span>
|
||||
My Activity
|
||||
</a>
|
||||
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardUpload }}">
|
||||
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
|
||||
class="fa-solid fa-upload text-sb-muted"></i></span>
|
||||
Upload
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardGallery }}">
|
||||
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
|
||||
class="fa-solid fa-image text-sb-muted"></i></span>
|
||||
My Gallery
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardArtworks }}">
|
||||
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
|
||||
class="fa-solid fa-pencil text-sb-muted"></i></span>
|
||||
Edit Artworks
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardStats }}">
|
||||
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
|
||||
class="fa-solid fa-chart-line text-sb-muted"></i></span>
|
||||
Statistics
|
||||
</a>
|
||||
{{-- Account --}}
|
||||
<div class="px-4 pt-3 pb-1 text-[11px] font-semibold uppercase tracking-widest text-sb-muted">Account</div>
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routePublicProfile }}">
|
||||
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-eye text-xs text-sb-muted"></i></span>
|
||||
View Profile
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeEditProfile }}">
|
||||
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-pen text-xs text-sb-muted"></i></span>
|
||||
Edit Profile
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardProfile }}">
|
||||
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-cog text-xs text-sb-muted"></i></span>
|
||||
Settings
|
||||
</a>
|
||||
|
||||
@if(in_array(strtolower((string) (Auth::user()->role ?? '')), ['admin', 'moderator'], true))
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ route('admin.usernames.moderation') }}">
|
||||
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-user-shield text-xs text-sb-muted"></i></span>
|
||||
Moderation
|
||||
</a>
|
||||
@endif
|
||||
|
||||
<div class="px-4 dd-section">Community</div>
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardFollowers }}">
|
||||
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
|
||||
class="fa-solid fa-user-group text-sb-muted"></i></span>
|
||||
Followers
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardFollowing }}">
|
||||
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
|
||||
class="fa-solid fa-user-plus text-sb-muted"></i></span>
|
||||
Following
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardComments }}">
|
||||
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
|
||||
class="fa-solid fa-comments text-sb-muted"></i></span>
|
||||
Comments
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardFavorites }}">
|
||||
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
|
||||
class="fa-solid fa-heart text-sb-muted"></i></span>
|
||||
Favourites
|
||||
</a>
|
||||
|
||||
|
||||
<div class="px-4 dd-section">Community</div>
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routePublicProfile }}">
|
||||
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
|
||||
class="fa-solid fa-eye text-sb-muted"></i></span>
|
||||
View My Profile
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardProfile }}">
|
||||
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
|
||||
class="fa-solid fa-cog text-sb-muted"></i></span>
|
||||
Edit Profile
|
||||
</a>
|
||||
|
||||
<div class="px-4 dd-section">System</div>
|
||||
@if(in_array(strtolower((string) (Auth::user()->role ?? '')), ['admin', 'moderator'], true))
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ route('admin.usernames.moderation') }}">
|
||||
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
|
||||
class="fa-solid fa-user-shield text-sb-muted"></i></span>
|
||||
Username Moderation
|
||||
</a>
|
||||
@endif
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
<button type="submit" class="w-full text-left flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5">
|
||||
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
|
||||
class="fa-solid fa-sign-out text-sb-muted"></i></span>
|
||||
Logout
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="border-t border-panel mt-1 mb-1"></div>
|
||||
<form method="POST" action="{{ route('logout') }}" class="mb-1">
|
||||
@csrf
|
||||
<button type="submit" class="w-full text-left flex items-center gap-3 px-4 py-2 text-sm text-red-400 hover:bg-white/5">
|
||||
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-sign-out text-xs"></i></span>
|
||||
Logout
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<!-- Guest: show simple Join / Sign in links -->
|
||||
<div class="hidden md:flex items-center gap-3">
|
||||
<!-- Guest: Upload CTA + Join / Sign in -->
|
||||
<div class="hidden md:flex items-center gap-2">
|
||||
<a href="/register"
|
||||
class="px-3 py-2 rounded-lg text-sm text-sb-muted hover:text-white hover:bg-white/5">Join</a>
|
||||
class="px-3 py-2 rounded-lg text-sm text-sb-muted hover:text-white hover:bg-white/5 transition-colors">Join</a>
|
||||
<a href="/login"
|
||||
class="px-3 py-2 rounded-lg text-sm text-sb-muted hover:text-white hover:bg-white/5">Sign in</a>
|
||||
class="px-4 py-2 rounded-lg bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium transition-colors">Sign in</a>
|
||||
</div>
|
||||
@endauth
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- MOBILE MENU -->
|
||||
<div class="hidden fixed top-16 left-0 right-0 bg-neutral-950 border-b border-neutral-800 p-4" id="mobileMenu">
|
||||
<div class="space-y-2">
|
||||
<div class="hidden fixed top-16 left-0 right-0 z-40 bg-nova border-b border-panel p-4 shadow-sb" id="mobileMenu">
|
||||
<div class="space-y-0.5 text-sm text-soft">
|
||||
|
||||
@guest
|
||||
<a class="block py-2 border-b border-neutral-900" href="/signup">Join</a>
|
||||
<a class="block py-2 border-b border-neutral-900" href="/login">Sign in</a>
|
||||
<a class="block py-2.5 px-3 rounded-lg font-medium text-sky-400" href="/register">Join Skinbase</a>
|
||||
<a class="block py-2.5 px-3 rounded-lg" href="/login">Sign in</a>
|
||||
<div class="my-2 border-t border-panel"></div>
|
||||
@endguest
|
||||
<a class="block py-2 border-b border-neutral-900" href="/browse">All Artworks</a>
|
||||
<a class="block py-2 border-b border-neutral-900" href="/photography">Photography</a>
|
||||
<a class="block py-2 border-b border-neutral-900" href="/wallpapers">Wallpapers</a>
|
||||
<a class="block py-2 border-b border-neutral-900" href="/skins">Skins</a>
|
||||
<a class="block py-2 border-b border-neutral-900" href="/other">Other</a>
|
||||
<a class="block py-2 border-b border-neutral-900" href="/featured-artworks">Featured</a>
|
||||
<a class="block py-2 border-b border-neutral-900" href="/forum">Forum</a>
|
||||
|
||||
<div class="pt-1 pb-1 px-3 text-[11px] font-semibold uppercase tracking-widest text-sb-muted">Discover</div>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/discover/trending"><i class="fa-solid fa-fire w-4 text-center text-sb-muted"></i>Trending</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/discover/fresh"><i class="fa-solid fa-bolt w-4 text-center text-sb-muted"></i>Fresh</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/discover/top-rated"><i class="fa-solid fa-medal w-4 text-center text-sb-muted"></i>Top Rated</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/discover/most-downloaded"><i class="fa-solid fa-download w-4 text-center text-sb-muted"></i>Most Downloaded</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/discover/on-this-day"><i class="fa-solid fa-calendar-day w-4 text-center text-sb-muted"></i>On This Day</a>
|
||||
|
||||
<div class="pt-3 pb-1 px-3 text-[11px] font-semibold uppercase tracking-widest text-sb-muted">Browse</div>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/browse"><i class="fa-solid fa-border-all w-4 text-center text-sb-muted"></i>All Artworks</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/photography"><i class="fa-solid fa-camera w-4 text-center text-sb-muted"></i>Photography</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/wallpapers"><i class="fa-solid fa-desktop w-4 text-center text-sb-muted"></i>Wallpapers</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/skins"><i class="fa-solid fa-layer-group w-4 text-center text-sb-muted"></i>Skins</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/other"><i class="fa-solid fa-folder-open w-4 text-center text-sb-muted"></i>Other</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/tags"><i class="fa-solid fa-tags w-4 text-center text-sb-muted"></i>Tags</a>
|
||||
|
||||
<div class="pt-3 pb-1 px-3 text-[11px] font-semibold uppercase tracking-widest text-sb-muted">Creators</div>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/creators/top"><i class="fa-solid fa-star w-4 text-center text-sb-muted"></i>Top Creators</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/creators/rising"><i class="fa-solid fa-arrow-trend-up w-4 text-center text-sb-muted"></i>Rising Creators</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/stories"><i class="fa-solid fa-microphone w-4 text-center text-sb-muted"></i>Creator Stories</a>
|
||||
@auth
|
||||
@php
|
||||
$toolbarMobileUsername = strtolower((string) (Auth::user()->username ?? ''));
|
||||
$toolbarMobileProfile = Route::has('profile.show') ? route('profile.show', ['username' => $toolbarMobileUsername]) : '/@'.$toolbarMobileUsername;
|
||||
@endphp
|
||||
<a class="block py-2 border-b border-neutral-900" href="{{ $toolbarMobileProfile }}">Profile</a>
|
||||
@else
|
||||
<a class="block py-2 border-b border-neutral-900" href="/profile">Profile</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ route('dashboard.following') }}"><i class="fa-solid fa-user-plus w-4 text-center text-sb-muted"></i>Following</a>
|
||||
@endauth
|
||||
|
||||
<div class="pt-3 pb-1 px-3 text-[11px] font-semibold uppercase tracking-widest text-sb-muted">Community</div>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/forum"><i class="fa-solid fa-comments w-4 text-center text-sb-muted"></i>Forum</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/news"><i class="fa-solid fa-newspaper w-4 text-center text-sb-muted"></i>Announcements</a>
|
||||
|
||||
@auth
|
||||
@if(in_array(strtolower((string) (Auth::user()->role ?? '')), ['admin', 'moderator'], true))
|
||||
<a class="block py-2 border-b border-neutral-900" href="{{ route('admin.usernames.moderation') }}">Username Moderation</a>
|
||||
@endif
|
||||
<div class="pt-4 pb-2">
|
||||
<a href="{{ route('upload') }}"
|
||||
class="flex items-center justify-center gap-2 w-full py-2.5 px-4 rounded-lg bg-sky-600 hover:bg-sky-500 text-white font-medium transition-colors">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
Upload Artwork
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@php
|
||||
$mobileUsername = strtolower((string) (Auth::user()->username ?? ''));
|
||||
$mobileProfile = Route::has('profile.show') ? route('profile.show', ['username' => $mobileUsername]) : '/@'.$mobileUsername;
|
||||
@endphp
|
||||
<div class="pt-1 pb-1 px-3 text-[11px] font-semibold uppercase tracking-widest text-sb-muted">My Account</div>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ $mobileProfile }}">
|
||||
<i class="fa-solid fa-circle-user w-4 text-center text-sb-muted"></i>View Profile
|
||||
</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ route('dashboard.awards') }}">
|
||||
<i class="fa-solid fa-trophy w-4 text-center text-sb-muted"></i>My Awards
|
||||
</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ Route::has('settings') ? route('settings') : '/settings' }}">
|
||||
<i class="fa-solid fa-pen w-4 text-center text-sb-muted"></i>Edit Profile
|
||||
</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ route('dashboard.profile') }}">
|
||||
<i class="fa-solid fa-cog w-4 text-center text-sb-muted"></i>Settings
|
||||
</a>
|
||||
@if(in_array(strtolower((string) (Auth::user()->role ?? '')), ['admin', 'moderator'], true))
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ route('admin.usernames.moderation') }}">
|
||||
<i class="fa-solid fa-user-shield w-4 text-center text-sb-muted"></i>Moderation
|
||||
</a>
|
||||
@endif
|
||||
@endauth
|
||||
<a class="block py-2" href="/settings">Settings</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
271
resources/views/layouts/nova/toolbar.blade.php.bak
Normal file
271
resources/views/layouts/nova/toolbar.blade.php.bak
Normal file
@@ -0,0 +1,271 @@
|
||||
<header class="fixed inset-x-0 top-0 z-50 h-16 bg-nova border-b border-panel">
|
||||
<div class="mx-auto w-full h-full px-4 flex items-center gap-3">
|
||||
<!-- Mobile hamburger -->
|
||||
<button id="btnSidebar"
|
||||
class="md:hidden inline-flex items-center justify-center w-10 h-10 rounded-lg hover:bg-white/5">
|
||||
<!-- bars -->
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Logo -->
|
||||
<a href="/" class="flex items-center gap-2 pr-2">
|
||||
<img src="/gfx/sb_logo.png" alt="Skinbase.org" class="h-8 w-auto rounded-sm shadow-sm object-contain">
|
||||
<span class="sr-only">Skinbase.org</span>
|
||||
</a>
|
||||
|
||||
<!-- Left nav -->
|
||||
<nav class="hidden lg:flex items-center gap-4 text-sm text-soft">
|
||||
|
||||
<div class="relative">
|
||||
<button class="hover:text-white inline-flex items-center gap-1" data-dd="browse">
|
||||
Browse
|
||||
<svg class="w-4 h-4 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6" /></svg>
|
||||
</button>
|
||||
<div id="dd-browse" class="hidden absolute left-0 mt-2 w-64 rounded-lg bg-panel border border-panel shadow-sb overflow-visible">
|
||||
<div class="rounded-lg overflow-hidden">
|
||||
<div class="px-4 dd-section">Views</div>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/forum"><i class="fa-solid fa-comments mr-3 text-sb-muted"></i>Forum</a>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/chat"><i class="fa-solid fa-message mr-3 text-sb-muted"></i>Chat</a>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/sections"><i class="fa-solid fa-folder-open mr-3 text-sb-muted"></i>Browse Sections</a>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/uploads/latest"><i class="fa-solid fa-cloud-arrow-up mr-3 text-sb-muted"></i>Latest Uploads</a>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/uploads/daily"><i class="fa-solid fa-calendar-day mr-3 text-sb-muted"></i>Daily Uploads</a>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/today-in-history"><i class="fa-solid fa-calendar mr-3 text-sb-muted"></i>Today In History</a>
|
||||
|
||||
<div class="px-4 dd-section">Authors</div>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/interviews"><i class="fa-solid fa-microphone mr-3 text-sb-muted"></i>Interviews</a>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/members/photos"><i class="fa-solid fa-camera mr-3 text-sb-muted"></i>Members Photos</a>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/authors/top"><i class="fa-solid fa-star mr-3 text-sb-muted"></i>Top Authors</a>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/comments/latest"><i class="fa-solid fa-comments mr-3 text-sb-muted"></i>Latest Comments</a>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/comments/monthly"><i class="fa-solid fa-chart-line mr-3 text-sb-muted"></i>Monthly Commented</a>
|
||||
|
||||
<div class="px-4 dd-section">Statistics</div>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/downloads/today"><i class="fa-solid fa-download mr-3 text-sb-muted"></i>Todays Downloads</a>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/favourites/top"><i class="fa-solid fa-heart mr-3 text-sb-muted"></i>Top Favourites</a>
|
||||
</div> <!-- end .rounded-lg -->
|
||||
</div> <!-- end .dd-browse -->
|
||||
</div> <!-- end .relative -->
|
||||
|
||||
<div class="relative">
|
||||
<button class="hover:text-white inline-flex items-center gap-1" data-dd="cats">
|
||||
Explore
|
||||
<svg class="w-4 h-4 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2">
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
<div id="dd-cats"
|
||||
class="hidden absolute left-0 mt-2 w-64 rounded-lg bg-panel border border-panel shadow-sb overflow-hidden">
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/browse"><i class="fa-solid fa-border-all mr-3 text-sb-muted"></i>All Artworks</a>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/photography"><i class="fa-solid fa-camera mr-3 text-sb-muted"></i>Photography</a>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/wallpapers"><i class="fa-solid fa-desktop mr-3 text-sb-muted"></i>Wallpapers</a>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/skins"><i class="fa-solid fa-layer-group mr-3 text-sb-muted"></i>Skins</a>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/other"><i class="fa-solid fa-folder-open mr-3 text-sb-muted"></i>Other</a>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/featured-artworks"><i class="fa-solid fa-star mr-3 text-sb-muted"></i>Featured Artwork</a>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<div class="w-full max-w-lg">
|
||||
<div id="topbar-search-root"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@auth
|
||||
<!-- Right icon counters (authenticated users) -->
|
||||
<div class="hidden md:flex items-center gap-3 text-soft">
|
||||
<button class="relative w-10 h-10 rounded-lg hover:bg-white/5">
|
||||
<svg class="w-5 h-5 mx-auto" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button class="relative w-10 h-10 rounded-lg hover:bg-white/5">
|
||||
<svg class="w-5 h-5 mx-auto" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2">
|
||||
<path d="M12 21s-7-4.4-9-9a5.5 5.5 0 0 1 9-6 5.5 5.5 0 0 1 9 6c-2 4.6-9 9-9 9z" />
|
||||
</svg>
|
||||
<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">{{ $uploadCount ?? 0 }}</span>
|
||||
</button>
|
||||
|
||||
<button class="relative w-10 h-10 rounded-lg hover:bg-white/5">
|
||||
<svg class="w-5 h-5 mx-auto" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2">
|
||||
<path d="M4 4h16v14H5.2L4 19.2V4z" />
|
||||
<path d="M4 6l8 6 8-6" />
|
||||
</svg>
|
||||
<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">{{ $favCount ?? 0 }}</span>
|
||||
</button>
|
||||
|
||||
<button class="relative w-10 h-10 rounded-lg hover:bg-white/5">
|
||||
<svg class="w-5 h-5 mx-auto" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2">
|
||||
<path d="M18 8a6 6 0 10-12 0c0 7-3 7-3 7h18s-3 0-3-7" />
|
||||
<path d="M13.7 21a2 2 0 01-3.4 0" />
|
||||
</svg>
|
||||
<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 ?? 0 }}</span>
|
||||
</button>
|
||||
|
||||
<!-- User dropdown -->
|
||||
<div class="relative">
|
||||
<button class="flex items-center gap-2 pl-2 pr-3 h-10 rounded-lg hover:bg-white/5" data-dd="user">
|
||||
@php
|
||||
$toolbarUserId = (int) ($userId ?? Auth::id() ?? 0);
|
||||
$toolbarAvatarHash = $avatarHash ?? optional(Auth::user())->profile->avatar_hash ?? null;
|
||||
@endphp
|
||||
<img class="w-7 h-7 rounded-full object-cover ring-1 ring-white/10"
|
||||
src="{{ \App\Support\AvatarUrl::forUser($toolbarUserId, $toolbarAvatarHash, 64) }}"
|
||||
alt="{{ $displayName ?? 'User' }}" />
|
||||
<span class="text-sm text-white/90">{{ $displayName ?? 'User' }}</span>
|
||||
<svg class="w-4 h-4 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2">
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div id="dd-user"
|
||||
class="hidden absolute right-0 mt-2 w-64 rounded-lg bg-panel border border-panel shadow-sb overflow-hidden">
|
||||
|
||||
@php
|
||||
$toolbarUsername = strtolower((string) (Auth::user()->username ?? ''));
|
||||
$routeDashboardUpload = Route::has('dashboard.upload') ? route('dashboard.upload') : route('upload');
|
||||
$routeDashboardGallery = Route::has('dashboard.gallery') ? route('dashboard.gallery') : '/dashboard/gallery';
|
||||
$routeDashboardArtworks = Route::has('dashboard.artworks') ? route('dashboard.artworks') : (Route::has('dashboard.artworks.index') ? route('dashboard.artworks.index') : '/dashboard/artworks');
|
||||
$routeDashboardStats = Route::has('dashboard.stats') ? route('dashboard.stats') : (Route::has('legacy.statistics') ? route('legacy.statistics') : '/dashboard/stats');
|
||||
$routeDashboardFollowers = Route::has('dashboard.followers') ? route('dashboard.followers') : '/dashboard/followers';
|
||||
$routeDashboardFollowing = Route::has('dashboard.following') ? route('dashboard.following') : '/dashboard/following';
|
||||
$routeDashboardComments = Route::has('dashboard.comments') ? route('dashboard.comments') : '/dashboard/comments';
|
||||
$routeDashboardFavorites = Route::has('dashboard.favorites') ? route('dashboard.favorites') : '/dashboard/favorites';
|
||||
$routeDashboardProfile = Route::has('dashboard.profile') ? route('dashboard.profile') : (Route::has('profile.edit') ? route('profile.edit') : '/dashboard/profile');
|
||||
$routePublicProfile = Route::has('profile.show') ? route('profile.show', ['username' => $toolbarUsername]) : '/@'.$toolbarUsername;
|
||||
@endphp
|
||||
|
||||
<div class="px-4 dd-section">My Account</div>
|
||||
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardUpload }}">
|
||||
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
|
||||
class="fa-solid fa-upload text-sb-muted"></i></span>
|
||||
Upload
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardGallery }}">
|
||||
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
|
||||
class="fa-solid fa-image text-sb-muted"></i></span>
|
||||
My Gallery
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardArtworks }}">
|
||||
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
|
||||
class="fa-solid fa-pencil text-sb-muted"></i></span>
|
||||
Edit Artworks
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardStats }}">
|
||||
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
|
||||
class="fa-solid fa-chart-line text-sb-muted"></i></span>
|
||||
Statistics
|
||||
</a>
|
||||
|
||||
|
||||
<div class="px-4 dd-section">Community</div>
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardFollowers }}">
|
||||
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
|
||||
class="fa-solid fa-user-group text-sb-muted"></i></span>
|
||||
Followers
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardFollowing }}">
|
||||
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
|
||||
class="fa-solid fa-user-plus text-sb-muted"></i></span>
|
||||
Following
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardComments }}">
|
||||
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
|
||||
class="fa-solid fa-comments text-sb-muted"></i></span>
|
||||
Comments
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardFavorites }}">
|
||||
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
|
||||
class="fa-solid fa-heart text-sb-muted"></i></span>
|
||||
Favourites
|
||||
</a>
|
||||
|
||||
|
||||
<div class="px-4 dd-section">Community</div>
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routePublicProfile }}">
|
||||
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
|
||||
class="fa-solid fa-eye text-sb-muted"></i></span>
|
||||
View My Profile
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardProfile }}">
|
||||
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
|
||||
class="fa-solid fa-cog text-sb-muted"></i></span>
|
||||
Edit Profile
|
||||
</a>
|
||||
|
||||
<div class="px-4 dd-section">System</div>
|
||||
@if(in_array(strtolower((string) (Auth::user()->role ?? '')), ['admin', 'moderator'], true))
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ route('admin.usernames.moderation') }}">
|
||||
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
|
||||
class="fa-solid fa-user-shield text-sb-muted"></i></span>
|
||||
Username Moderation
|
||||
</a>
|
||||
@endif
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
<button type="submit" class="w-full text-left flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5">
|
||||
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
|
||||
class="fa-solid fa-sign-out text-sb-muted"></i></span>
|
||||
Logout
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<!-- Guest: show simple Join / Sign in links -->
|
||||
<div class="hidden md:flex items-center gap-3">
|
||||
<a href="/register"
|
||||
class="px-3 py-2 rounded-lg text-sm text-sb-muted hover:text-white hover:bg-white/5">Join</a>
|
||||
<a href="/login"
|
||||
class="px-3 py-2 rounded-lg text-sm text-sb-muted hover:text-white hover:bg-white/5">Sign in</a>
|
||||
</div>
|
||||
@endauth
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- MOBILE MENU -->
|
||||
<div class="hidden fixed top-16 left-0 right-0 bg-neutral-950 border-b border-neutral-800 p-4" id="mobileMenu">
|
||||
<div class="space-y-2">
|
||||
@guest
|
||||
<a class="block py-2 border-b border-neutral-900" href="/signup">Join</a>
|
||||
<a class="block py-2 border-b border-neutral-900" href="/login">Sign in</a>
|
||||
@endguest
|
||||
<a class="block py-2 border-b border-neutral-900" href="/browse">All Artworks</a>
|
||||
<a class="block py-2 border-b border-neutral-900" href="/photography">Photography</a>
|
||||
<a class="block py-2 border-b border-neutral-900" href="/wallpapers">Wallpapers</a>
|
||||
<a class="block py-2 border-b border-neutral-900" href="/skins">Skins</a>
|
||||
<a class="block py-2 border-b border-neutral-900" href="/other">Other</a>
|
||||
<a class="block py-2 border-b border-neutral-900" href="/featured-artworks">Featured</a>
|
||||
<a class="block py-2 border-b border-neutral-900" href="/forum">Forum</a>
|
||||
@auth
|
||||
@php
|
||||
$toolbarMobileUsername = strtolower((string) (Auth::user()->username ?? ''));
|
||||
$toolbarMobileProfile = Route::has('profile.show') ? route('profile.show', ['username' => $toolbarMobileUsername]) : '/@'.$toolbarMobileUsername;
|
||||
@endphp
|
||||
<a class="block py-2 border-b border-neutral-900" href="{{ $toolbarMobileProfile }}">Profile</a>
|
||||
@else
|
||||
<a class="block py-2 border-b border-neutral-900" href="/profile">Profile</a>
|
||||
@endauth
|
||||
@auth
|
||||
@if(in_array(strtolower((string) (Auth::user()->role ?? '')), ['admin', 'moderator'], true))
|
||||
<a class="block py-2 border-b border-neutral-900" href="{{ route('admin.usernames.moderation') }}">Username Moderation</a>
|
||||
@endif
|
||||
@endauth
|
||||
<a class="block py-2" href="/settings">Settings</a>
|
||||
</div>
|
||||
</div>
|
||||
17
resources/views/messages.blade.php
Normal file
17
resources/views/messages.blade.php
Normal file
@@ -0,0 +1,17 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('title', 'Messages')
|
||||
|
||||
@push('head')
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}" />
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
<div id="messages-root"
|
||||
data-user-id='@json(auth()->id())'
|
||||
data-username='@json(auth()->user()?->username)'
|
||||
data-active-conversation-id='@json($activeConversationId ?? null)'>
|
||||
</div>
|
||||
|
||||
@vite(['resources/js/Pages/Messages/Index.jsx'])
|
||||
@endsection
|
||||
@@ -1,6 +1,16 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
@php
|
||||
$contentTypes = $contentTypes ?? collect([
|
||||
(object) [
|
||||
'name' => 'Categories',
|
||||
'description' => null,
|
||||
'roots' => $categories ?? collect(),
|
||||
],
|
||||
]);
|
||||
$subgroups = $subgroups ?? collect();
|
||||
@endphp
|
||||
<div class="container-fluid legacy-page">
|
||||
<div class="effect2 page-header-wrap">
|
||||
<header class="page-heading">
|
||||
|
||||
@@ -2,81 +2,23 @@
|
||||
|
||||
@section('content')
|
||||
|
||||
{{-- ── Hero header ── --}}
|
||||
<div class="px-6 pt-10 pb-6 md:px-10">
|
||||
<div>
|
||||
<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">Latest Comments</h1>
|
||||
<p class="mt-1 text-sm text-white/50">Most recent artwork comments from the community.</p>
|
||||
{{-- Inline props for the React component --}}
|
||||
<script id="latest-comments-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>
|
||||
<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>
|
||||
|
||||
{{-- ── Comment cards grid ── --}}
|
||||
<div class="px-6 pb-16 md:px-10">
|
||||
@if ($comments->isNotEmpty())
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
@foreach ($comments as $comment)
|
||||
@php
|
||||
$artUrl = '/art/' . (int)($comment->id ?? 0) . '/' . ($comment->artwork_slug ?? 'artwork');
|
||||
$userUrl = ($comment->commenter_username ?? null) ? '/@' . $comment->commenter_username : '/profile/' . (int)($comment->commenter_id ?? 0);
|
||||
$avatarUrl = \App\Support\AvatarUrl::forUser((int)($comment->commenter_id ?? 0), $comment->icon ?? null, 40);
|
||||
$ago = \Carbon\Carbon::parse($comment->datetime ?? now())->diffForHumans();
|
||||
$snippet = \Illuminate\Support\Str::limit(strip_tags($comment->comment_description ?? ''), 160);
|
||||
@endphp
|
||||
|
||||
<article class="flex flex-col rounded-xl border border-white/[0.06] bg-white/[0.03] hover:border-white/[0.1] hover:bg-white/[0.05] transition-all duration-200 overflow-hidden">
|
||||
|
||||
{{-- Artwork thumbnail --}}
|
||||
@if (!empty($comment->thumb))
|
||||
<a href="{{ $artUrl }}" class="block overflow-hidden bg-neutral-900 flex-shrink-0">
|
||||
<img src="{{ $comment->thumb }}" alt="{{ $comment->name ?? 'Artwork' }}"
|
||||
class="w-full h-36 object-cover transition-transform duration-300 hover:scale-[1.03]"
|
||||
loading="lazy">
|
||||
</a>
|
||||
@endif
|
||||
|
||||
<div class="flex flex-col flex-1 p-4 gap-3">
|
||||
|
||||
{{-- Commenter row --}}
|
||||
<div class="flex items-center gap-2.5">
|
||||
<a href="{{ $userUrl }}" class="flex-shrink-0">
|
||||
<img src="{{ $avatarUrl }}" alt="{{ $comment->uname ?? 'User' }}"
|
||||
class="w-8 h-8 rounded-full object-cover ring-1 ring-white/[0.08]">
|
||||
</a>
|
||||
<div class="min-w-0 flex-1">
|
||||
<a href="{{ $userUrl }}" class="block truncate text-xs font-semibold text-white/85 hover:text-white transition-colors">
|
||||
{{ $comment->uname ?? 'Unknown' }}
|
||||
</a>
|
||||
<span class="text-[10px] text-white/35">{{ $ago }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Comment text --}}
|
||||
<p class="flex-1 text-sm text-white/60 leading-relaxed line-clamp-4">{{ $snippet }}</p>
|
||||
|
||||
{{-- Artwork link footer --}}
|
||||
@if (!empty($comment->name))
|
||||
<a href="{{ $artUrl }}"
|
||||
class="mt-auto inline-flex items-center gap-1.5 text-xs text-sky-400/70 hover:text-sky-300 transition-colors truncate">
|
||||
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
<span class="truncate">{{ $comment->name }}</span>
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
</article>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="mt-10 flex justify-center">
|
||||
{{ $comments->withQueryString()->links() }}
|
||||
</div>
|
||||
@else
|
||||
<div class="rounded-xl border border-white/[0.06] bg-white/[0.02] px-8 py-12 text-center">
|
||||
<p class="text-white/40 text-sm">No comments found.</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@vite(['resources/js/Pages/Community/LatestCommentsPage.jsx'])
|
||||
|
||||
@endsection
|
||||
|
||||
88
resources/views/web/creators/rising.blade.php
Normal file
88
resources/views/web/creators/rising.blade.php
Normal file
@@ -0,0 +1,88 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
|
||||
<div class="px-6 pt-10 pb-6 md:px-10">
|
||||
<div class="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Creators</p>
|
||||
<h1 class="text-3xl font-bold text-white leading-tight flex items-center gap-3">
|
||||
<i class="fa-solid fa-arrow-trend-up text-sky-400 text-2xl"></i>
|
||||
Rising Creators
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-white/50">Creators gaining momentum with the most views over the last 90 days.</p>
|
||||
</div>
|
||||
<a href="{{ route('creators.top') }}"
|
||||
class="inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium border border-white/[0.08] bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white transition-colors">
|
||||
<i class="fa-solid fa-star text-xs"></i>
|
||||
Top Creators
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-6 pb-16 md:px-10">
|
||||
@php $offset = ($creators->currentPage() - 1) * $creators->perPage(); @endphp
|
||||
|
||||
@if ($creators->isNotEmpty())
|
||||
<div class="rounded-xl border border-white/[0.06] overflow-hidden">
|
||||
<div class="grid grid-cols-[3rem_1fr_auto] items-center gap-4 px-5 py-3 bg-white/[0.03] border-b border-white/[0.06]">
|
||||
<span class="text-xs font-semibold uppercase tracking-widest text-white/30 text-center">#</span>
|
||||
<span class="text-xs font-semibold uppercase tracking-widest text-white/30">Creator</span>
|
||||
<span class="text-xs font-semibold uppercase tracking-widest text-white/30 text-right">Recent Views</span>
|
||||
</div>
|
||||
|
||||
<div class="divide-y divide-white/[0.04]">
|
||||
@foreach ($creators as $i => $creator)
|
||||
@php
|
||||
$rank = $offset + $i + 1;
|
||||
$profileUrl = ($creator->username ?? null)
|
||||
? '/@' . $creator->username
|
||||
: '/profile/' . (int) $creator->user_id;
|
||||
$avatarUrl = \App\Support\AvatarUrl::forUser((int) $creator->user_id, null, 40);
|
||||
@endphp
|
||||
<div class="grid grid-cols-[3rem_1fr_auto] items-center gap-4 px-5 py-4
|
||||
{{ $rank <= 3 ? 'bg-white/[0.015]' : '' }} hover:bg-white/[0.03] transition-colors">
|
||||
|
||||
<div class="text-center">
|
||||
@if ($rank === 1)
|
||||
<span class="inline-flex items-center justify-center w-7 h-7 rounded-full bg-amber-400/15 text-amber-300 text-xs font-bold ring-1 ring-amber-400/30">1</span>
|
||||
@elseif ($rank === 2)
|
||||
<span class="inline-flex items-center justify-center w-7 h-7 rounded-full bg-slate-400/15 text-slate-300 text-xs font-bold ring-1 ring-slate-400/30">2</span>
|
||||
@elseif ($rank === 3)
|
||||
<span class="inline-flex items-center justify-center w-7 h-7 rounded-full bg-orange-700/20 text-orange-400 text-xs font-bold ring-1 ring-orange-600/30">3</span>
|
||||
@else
|
||||
<span class="text-sm text-white/30 font-medium">{{ $rank }}</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<a href="{{ $profileUrl }}" class="flex items-center gap-3 min-w-0 hover:opacity-90 transition-opacity">
|
||||
<img src="{{ $avatarUrl }}" alt="{{ $creator->uname }}"
|
||||
class="w-9 h-9 rounded-full object-cover ring-1 ring-white/10 shrink-0"
|
||||
onerror="this.src='https://files.skinbase.org/avatars/default.webp'" />
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-white truncate">{{ $creator->uname }}</p>
|
||||
@if($creator->username ?? null)
|
||||
<p class="text-xs text-white/40 truncate">@{{ $creator->username }}</p>
|
||||
@endif
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div class="text-right">
|
||||
<span class="text-sm font-semibold text-white/80">{{ number_format($creator->total) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-10 flex justify-center">
|
||||
{{ $creators->withQueryString()->links() }}
|
||||
</div>
|
||||
@else
|
||||
<div class="rounded-xl border border-white/[0.06] bg-white/[0.02] px-8 py-12 text-center">
|
||||
<p class="text-white/40 text-sm">No rising creators found yet. Check back soon!</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@endsection
|
||||
73
resources/views/web/discover/index.blade.php
Normal file
73
resources/views/web/discover/index.blade.php
Normal file
@@ -0,0 +1,73 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
|
||||
{{-- ── Hero header ── --}}
|
||||
<div class="px-6 pt-10 pb-6 md:px-10">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Discover</p>
|
||||
<h1 class="text-3xl font-bold text-white leading-tight flex items-center gap-3">
|
||||
<i class="fa-solid {{ $icon ?? 'fa-compass' }} text-sky-400 text-2xl"></i>
|
||||
{{ $page_title ?? 'Discover' }}
|
||||
</h1>
|
||||
@isset($description)
|
||||
<p class="mt-1 text-sm text-white/50">{{ $description }}</p>
|
||||
@endisset
|
||||
</div>
|
||||
|
||||
{{-- Section switcher pills --}}
|
||||
<div class="flex flex-wrap items-center gap-2 text-sm">
|
||||
@php
|
||||
$sections = [
|
||||
'trending' => ['label' => 'Trending', 'icon' => 'fa-fire'],
|
||||
'fresh' => ['label' => 'Fresh', 'icon' => 'fa-bolt'],
|
||||
'top-rated' => ['label' => 'Top Rated', 'icon' => 'fa-medal'],
|
||||
'most-downloaded' => ['label' => 'Most Downloaded', 'icon' => 'fa-download'],
|
||||
'on-this-day' => ['label' => 'On This Day', 'icon' => 'fa-calendar-day'],
|
||||
];
|
||||
$active = $section ?? '';
|
||||
@endphp
|
||||
@foreach($sections as $slug => $meta)
|
||||
<a href="{{ route('discover.' . $slug) }}"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors
|
||||
{{ $active === $slug ? 'bg-sky-600 text-white' : 'bg-white/[0.05] text-white/60 hover:bg-white/[0.1] hover:text-white' }}">
|
||||
<i class="fa-solid {{ $meta['icon'] }} text-xs"></i>
|
||||
{{ $meta['label'] }}
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Artwork grid ── --}}
|
||||
<div class="px-6 pb-16 md:px-10">
|
||||
@if ($artworks && $artworks->isNotEmpty())
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 gap-4 md:gap-5">
|
||||
@foreach ($artworks as $art)
|
||||
@php
|
||||
$card = (object)[
|
||||
'id' => $art->id,
|
||||
'name' => $art->name,
|
||||
'thumb' => $art->thumb_url ?? $art->thumb ?? null,
|
||||
'thumb_srcset' => $art->thumb_srcset ?? null,
|
||||
'uname' => $art->uname ?? '',
|
||||
'category_name' => $art->category_name ?? '',
|
||||
];
|
||||
@endphp
|
||||
<x-artwork-card :art="$card" />
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- Pagination --}}
|
||||
<div class="mt-10 flex justify-center">
|
||||
{{ $artworks->withQueryString()->links() }}
|
||||
</div>
|
||||
@else
|
||||
<div class="rounded-xl border border-white/[0.06] bg-white/[0.02] px-8 py-12 text-center">
|
||||
<p class="text-white/40 text-sm">No artworks found for this section yet.</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@endsection
|
||||
40
resources/views/web/tags/index.blade.php
Normal file
40
resources/views/web/tags/index.blade.php
Normal file
@@ -0,0 +1,40 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
|
||||
<div class="px-6 pt-10 pb-6 md:px-10">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Browse</p>
|
||||
<h1 class="text-3xl font-bold text-white leading-tight flex items-center gap-3">
|
||||
<i class="fa-solid fa-tags text-sky-400 text-2xl"></i>
|
||||
Tags
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-white/50">Browse all artwork tags on Skinbase.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-6 pb-16 md:px-10">
|
||||
@if($tags->isNotEmpty())
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@foreach($tags as $tag)
|
||||
<a href="{{ route('tags.show', $tag->slug) }}"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-white/[0.05] border border-white/[0.07]
|
||||
text-sm text-white/70 hover:bg-white/[0.1] hover:text-white transition-colors">
|
||||
<i class="fa-solid fa-hashtag text-xs text-sky-400/70"></i>
|
||||
{{ $tag->name }}
|
||||
<span class="text-xs text-white/30 ml-1">{{ number_format($tag->artworks_count) }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="mt-10 flex justify-center">
|
||||
{{ $tags->withQueryString()->links() }}
|
||||
</div>
|
||||
@else
|
||||
<div class="rounded-xl border border-white/[0.06] bg-white/[0.02] px-8 py-12 text-center">
|
||||
<p class="text-white/40 text-sm">No tags found.</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@endsection
|
||||
Reference in New Issue
Block a user