storing analytics data
This commit is contained in:
@@ -39,6 +39,7 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
|
||||
const [conversations, setConversations] = useState([])
|
||||
const [loadingConvs, setLoadingConvs] = useState(true)
|
||||
const [activeId, setActiveId] = useState(initialId ?? null)
|
||||
const [realtimeEnabled, setRealtimeEnabled] = useState(false)
|
||||
const [showNewModal, setShowNewModal] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [searchResults, setSearchResults] = useState([])
|
||||
@@ -60,11 +61,32 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
|
||||
useEffect(() => {
|
||||
loadConversations()
|
||||
|
||||
// Phase 1 polling: refresh conversation list every 15 seconds
|
||||
pollRef.current = setInterval(loadConversations, 15_000)
|
||||
return () => clearInterval(pollRef.current)
|
||||
apiFetch('/api/messages/settings')
|
||||
.then(data => setRealtimeEnabled(!!data?.realtime_enabled))
|
||||
.catch(() => setRealtimeEnabled(false))
|
||||
|
||||
return () => {
|
||||
if (pollRef.current) clearInterval(pollRef.current)
|
||||
}
|
||||
}, [loadConversations])
|
||||
|
||||
useEffect(() => {
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current)
|
||||
pollRef.current = null
|
||||
}
|
||||
|
||||
if (realtimeEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
pollRef.current = setInterval(loadConversations, 15_000)
|
||||
|
||||
return () => {
|
||||
if (pollRef.current) clearInterval(pollRef.current)
|
||||
}
|
||||
}, [loadConversations, realtimeEnabled])
|
||||
|
||||
const handleSelectConversation = useCallback((id) => {
|
||||
setActiveId(id)
|
||||
history.replaceState(null, '', `/messages/${id}`)
|
||||
@@ -190,6 +212,7 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
|
||||
key={activeId}
|
||||
conversationId={activeId}
|
||||
conversation={activeConversation}
|
||||
realtimeEnabled={realtimeEnabled}
|
||||
currentUserId={userId}
|
||||
currentUsername={username}
|
||||
apiFetch={apiFetch}
|
||||
|
||||
@@ -181,7 +181,7 @@ export default function SearchBar({ placeholder = 'Search artworks, artists, tag
|
||||
<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>
|
||||
<span className="text-sm flex-1 text-left truncate">Search</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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
|
||||
export default function ArtworkActions({ artwork, canonicalUrl, mobilePriority = false }) {
|
||||
const [liked, setLiked] = useState(Boolean(artwork?.viewer?.is_liked))
|
||||
@@ -10,6 +10,29 @@ export default function ArtworkActions({ artwork, canonicalUrl, mobilePriority =
|
||||
? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
|
||||
: null
|
||||
|
||||
// Track the view once per browser session (sessionStorage prevents re-firing).
|
||||
useEffect(() => {
|
||||
if (!artwork?.id) return
|
||||
const key = `sb_viewed_${artwork.id}`
|
||||
if (typeof sessionStorage !== 'undefined' && sessionStorage.getItem(key)) return
|
||||
if (typeof sessionStorage !== 'undefined') sessionStorage.setItem(key, '1')
|
||||
fetch(`/api/art/${artwork.id}/view`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-TOKEN': csrfToken || '', 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
}).catch(() => {})
|
||||
}, [artwork?.id]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Fire-and-forget download tracking — does not interrupt the native download.
|
||||
const trackDownload = () => {
|
||||
if (!artwork?.id) return
|
||||
fetch(`/api/art/${artwork.id}/download`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-TOKEN': csrfToken || '', 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const postInteraction = async (url, body) => {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
@@ -82,6 +105,7 @@ export default function ArtworkActions({ artwork, canonicalUrl, mobilePriority =
|
||||
<a
|
||||
href={downloadUrl}
|
||||
className="inline-flex min-h-11 w-full items-center justify-center rounded-lg bg-accent px-4 py-3 text-sm font-semibold text-deep hover:brightness-110"
|
||||
onClick={trackDownload}
|
||||
download
|
||||
>
|
||||
Download
|
||||
@@ -125,6 +149,7 @@ export default function ArtworkActions({ artwork, canonicalUrl, mobilePriority =
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download
|
||||
onClick={trackDownload}
|
||||
className="pointer-events-auto inline-flex min-h-12 w-full items-center justify-center rounded-lg bg-accent px-4 py-3 text-sm font-semibold text-deep hover:brightness-110"
|
||||
>
|
||||
Download
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||
import MessageBubble from './MessageBubble'
|
||||
|
||||
/**
|
||||
@@ -7,6 +7,7 @@ import MessageBubble from './MessageBubble'
|
||||
export default function ConversationThread({
|
||||
conversationId,
|
||||
conversation,
|
||||
realtimeEnabled = false,
|
||||
currentUserId,
|
||||
currentUsername,
|
||||
apiFetch,
|
||||
@@ -22,9 +23,11 @@ export default function ConversationThread({
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [attachments, setAttachments] = useState([])
|
||||
const [uploadProgress, setUploadProgress] = useState(null)
|
||||
const [typingUsers, setTypingUsers] = useState([])
|
||||
const [threadSearch, setThreadSearch] = useState('')
|
||||
const [threadSearchResults, setThreadSearchResults] = useState([])
|
||||
const [lightboxImage, setLightboxImage] = useState(null)
|
||||
const fileInputRef = useRef(null)
|
||||
const bottomRef = useRef(null)
|
||||
const threadRef = useRef(null)
|
||||
@@ -34,6 +37,22 @@ export default function ConversationThread({
|
||||
const latestIdRef = useRef(null)
|
||||
const shouldAutoScrollRef = useRef(true)
|
||||
const draftKey = `nova_draft_${conversationId}`
|
||||
const previewAttachments = useMemo(() => {
|
||||
return attachments.map(file => ({
|
||||
file,
|
||||
previewUrl: isImageLike(file) ? URL.createObjectURL(file) : null,
|
||||
}))
|
||||
}, [attachments])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
for (const item of previewAttachments) {
|
||||
if (item.previewUrl) {
|
||||
URL.revokeObjectURL(item.previewUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [previewAttachments])
|
||||
|
||||
// ── Initial load ─────────────────────────────────────────────────────────
|
||||
const loadMessages = useCallback(async () => {
|
||||
@@ -58,37 +77,42 @@ export default function ConversationThread({
|
||||
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)
|
||||
if (!realtimeEnabled) {
|
||||
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(typingPollRef.current)
|
||||
if (pollRef.current) clearInterval(pollRef.current)
|
||||
}
|
||||
}, [conversationId, draftKey, realtimeEnabled, currentUserId, apiFetch, loadMessages, onConversationUpdated])
|
||||
|
||||
useEffect(() => {
|
||||
if (!realtimeEnabled) {
|
||||
typingPollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const data = await apiFetch(`/api/messages/${conversationId}/typing`)
|
||||
setTypingUsers(data.typing ?? [])
|
||||
} catch (_) {}
|
||||
}, 2_000)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (typingPollRef.current) clearInterval(typingPollRef.current)
|
||||
clearTimeout(typingStopTimerRef.current)
|
||||
apiFetch(`/api/messages/${conversationId}/typing/stop`, { method: 'POST' }).catch(() => {})
|
||||
}
|
||||
}, [conversationId, apiFetch])
|
||||
}, [conversationId, apiFetch, realtimeEnabled])
|
||||
|
||||
useEffect(() => {
|
||||
const content = body.trim()
|
||||
@@ -190,10 +214,10 @@ export default function ConversationThread({
|
||||
const formData = new FormData()
|
||||
formData.append('body', text)
|
||||
attachments.forEach(file => formData.append('attachments[]', file))
|
||||
setUploadProgress(0)
|
||||
|
||||
const msg = await apiFetch(`/api/messages/${conversationId}`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
const msg = await sendMessageWithProgress(`/api/messages/${conversationId}`, formData, (progress) => {
|
||||
setUploadProgress(progress)
|
||||
})
|
||||
setMessages(prev => prev.map(m => m.id === optimistic.id ? msg : m))
|
||||
latestIdRef.current = msg.id
|
||||
@@ -203,6 +227,7 @@ export default function ConversationThread({
|
||||
setMessages(prev => prev.filter(m => m.id !== optimistic.id))
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setUploadProgress(null)
|
||||
setSending(false)
|
||||
}
|
||||
}, [body, attachments, sending, conversationId, currentUserId, currentUsername, apiFetch, onConversationUpdated, draftKey])
|
||||
@@ -292,6 +317,24 @@ export default function ConversationThread({
|
||||
}
|
||||
}, [conversation, currentUserId, apiFetch, conversationId, onConversationUpdated])
|
||||
|
||||
const toggleMute = useCallback(async () => {
|
||||
try {
|
||||
await apiFetch(`/api/messages/${conversationId}/mute`, { method: 'POST' })
|
||||
onConversationUpdated()
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
}
|
||||
}, [apiFetch, conversationId, onConversationUpdated])
|
||||
|
||||
const toggleArchive = useCallback(async () => {
|
||||
try {
|
||||
await apiFetch(`/api/messages/${conversationId}/archive`, { method: 'POST' })
|
||||
onConversationUpdated()
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
}
|
||||
}, [apiFetch, conversationId, onConversationUpdated])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const q = threadSearch.trim()
|
||||
@@ -330,6 +373,7 @@ export default function ConversationThread({
|
||||
const threadLabel = conversation?.type === 'group'
|
||||
? (conversation?.title ?? 'Group conversation')
|
||||
: (conversation?.all_participants?.find(p => p.user_id !== currentUserId)?.user?.username ?? 'Direct message')
|
||||
const myParticipant = conversation?.my_participant ?? conversation?.all_participants?.find(p => p.user_id === currentUserId)
|
||||
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
|
||||
@@ -365,7 +409,21 @@ export default function ConversationThread({
|
||||
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'}
|
||||
{myParticipant?.is_pinned ? 'Unpin' : 'Pin'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleMute}
|
||||
className="text-xs px-2 py-1 rounded border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
>
|
||||
{myParticipant?.is_muted ? 'Unmute' : 'Mute'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleArchive}
|
||||
className="text-xs px-2 py-1 rounded border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
>
|
||||
{myParticipant?.is_archived ? 'Unarchive' : 'Archive'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -429,6 +487,7 @@ export default function ConversationThread({
|
||||
onUnreact={handleUnreact}
|
||||
onEdit={handleEdit}
|
||||
onReport={handleReportMessage}
|
||||
onOpenImage={setLightboxImage}
|
||||
seenText={buildSeenText({
|
||||
message: msg,
|
||||
isMine: msg.sender_id === currentUserId,
|
||||
@@ -490,14 +549,41 @@ export default function ConversationThread({
|
||||
|
||||
{attachments.length > 0 && (
|
||||
<div className="px-4 pb-3 flex flex-wrap gap-2">
|
||||
{attachments.map((file, idx) => (
|
||||
{previewAttachments.map(({ file, previewUrl }, 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">
|
||||
{previewUrl && (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt={file.name}
|
||||
className="h-10 w-10 object-cover rounded"
|
||||
/>
|
||||
)}
|
||||
<span className="truncate max-w-[220px]">{file.name}</span>
|
||||
<button type="button" onClick={() => removeAttachment(idx)} className="text-red-500">✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sending && uploadProgress !== null && (
|
||||
<div className="px-4 pb-3">
|
||||
<div className="h-2 rounded bg-gray-200 dark:bg-gray-700 overflow-hidden">
|
||||
<div className="h-full bg-blue-500" style={{ width: `${uploadProgress}%` }} />
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-gray-500">Uploading {uploadProgress}%</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lightboxImage && (
|
||||
<div className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-6" onClick={() => setLightboxImage(null)}>
|
||||
<img
|
||||
src={lightboxImage.url}
|
||||
alt={lightboxImage.original_name || 'Attachment'}
|
||||
className="max-h-full max-w-full rounded-lg"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -585,3 +671,42 @@ function isSameDay(a, b) {
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
}
|
||||
|
||||
function isImageLike(file) {
|
||||
return file?.type?.startsWith('image/')
|
||||
}
|
||||
|
||||
function getCsrf() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.content ?? ''
|
||||
}
|
||||
|
||||
function sendMessageWithProgress(url, formData, onProgress) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.open('POST', url)
|
||||
xhr.setRequestHeader('X-CSRF-TOKEN', getCsrf())
|
||||
xhr.setRequestHeader('Accept', 'application/json')
|
||||
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (!event.lengthComputable) return
|
||||
const progress = Math.max(0, Math.min(100, Math.round((event.loaded / event.total) * 100)))
|
||||
onProgress(progress)
|
||||
}
|
||||
|
||||
xhr.onload = () => {
|
||||
try {
|
||||
const json = JSON.parse(xhr.responseText || '{}')
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(json)
|
||||
return
|
||||
}
|
||||
reject(new Error(json.message || `HTTP ${xhr.status}`))
|
||||
} catch (_) {
|
||||
reject(new Error(`HTTP ${xhr.status}`))
|
||||
}
|
||||
}
|
||||
|
||||
xhr.onerror = () => reject(new Error('Network error'))
|
||||
xhr.send(formData)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ const QUICK_REACTIONS = ['👍', '❤️', '🔥', '😂', '👏', '😮']
|
||||
* - Inline edit for own messages
|
||||
* - Soft-delete display
|
||||
*/
|
||||
export default function MessageBubble({ message, isMine, showAvatar, onReact, onUnreact, onEdit, onReport = null, seenText = null }) {
|
||||
export default function MessageBubble({ message, isMine, showAvatar, onReact, onUnreact, onEdit, onReport = null, onOpenImage = null, seenText = null }) {
|
||||
const [showPicker, setShowPicker] = useState(false)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [editBody, setEditBody] = useState(message.body ?? '')
|
||||
@@ -119,14 +119,18 @@ export default function MessageBubble({ message, isMine, showAvatar, onReact, on
|
||||
{message.attachments.map(att => (
|
||||
<div key={att.id}>
|
||||
{att.type === 'image' ? (
|
||||
<a href={`/messages/attachments/${att.id}`} target="_blank" rel="noopener noreferrer">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenImage?.({ id: att.id, original_name: att.original_name, url: `/messages/attachments/${att.id}` })}
|
||||
className="block"
|
||||
>
|
||||
<img
|
||||
src={`/messages/attachments/${att.id}`}
|
||||
alt={att.original_name}
|
||||
className="max-h-44 rounded-lg border border-white/20"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
</button>
|
||||
) : (
|
||||
<a
|
||||
href={`/messages/attachments/${att.id}`}
|
||||
|
||||
87
resources/views/community/activity.blade.php
Normal file
87
resources/views/community/activity.blade.php
Normal file
@@ -0,0 +1,87 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('title', $page_title . ' — Skinbase')
|
||||
|
||||
@section('content')
|
||||
<div class="container-fluid legacy-page">
|
||||
<div class="page-heading">
|
||||
<h1 class="page-header"><i class="fa fa-stream"></i> {{ $page_title }}</h1>
|
||||
</div>
|
||||
|
||||
{{-- Tab bar --}}
|
||||
<ul class="nav nav-tabs mb-3">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ $active_tab === 'global' ? 'active' : '' }}"
|
||||
href="{{ route('community.activity', ['type' => 'global']) }}">
|
||||
<i class="fa fa-globe"></i> Global
|
||||
</a>
|
||||
</li>
|
||||
@auth
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ $active_tab === 'following' ? 'active' : '' }}"
|
||||
href="{{ route('community.activity', ['type' => 'following']) }}">
|
||||
<i class="fa fa-user-group"></i> Following
|
||||
</a>
|
||||
</li>
|
||||
@endauth
|
||||
</ul>
|
||||
|
||||
<div class="activity-feed">
|
||||
@forelse($enriched as $event)
|
||||
<div class="activity-event media mb-3 p-2 rounded bg-dark-subtle">
|
||||
<div class="media-body">
|
||||
<span class="fw-semibold">
|
||||
<a href="{{ $event['actor']['url'] ?? '#' }}">{{ $event['actor']['name'] ?? 'Someone' }}</a>
|
||||
</span>
|
||||
|
||||
@switch($event['type'])
|
||||
@case('upload')
|
||||
uploaded
|
||||
@break
|
||||
@case('comment')
|
||||
commented on
|
||||
@break
|
||||
@case('favorite')
|
||||
favourited
|
||||
@break
|
||||
@case('award')
|
||||
awarded
|
||||
@break
|
||||
@case('follow')
|
||||
started following
|
||||
@break
|
||||
@default
|
||||
interacted with
|
||||
@endswitch
|
||||
|
||||
@if($event['target'])
|
||||
@if($event['target_type'] === 'artwork')
|
||||
<a href="{{ $event['target']['url'] }}">{{ $event['target']['title'] }}</a>
|
||||
@if(!empty($event['target']['thumb']))
|
||||
<img src="{{ $event['target']['thumb'] }}" alt="" class="ms-2 rounded" style="height:36px;width:auto;vertical-align:middle;">
|
||||
@endif
|
||||
@elseif($event['target_type'] === 'user')
|
||||
<a href="{{ $event['target']['url'] ?? '#' }}">{{ $event['target']['name'] ?? $event['target']['username'] ?? '' }}</a>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
<small class="text-muted ms-2">{{ \Carbon\Carbon::parse($event['created_at'])->diffForHumans() }}</small>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="alert alert-info">
|
||||
@if($active_tab === 'following')
|
||||
Follow some creators to see their activity here.
|
||||
@else
|
||||
No activity yet. Be the first!
|
||||
@endif
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
{{-- Pagination --}}
|
||||
<div class="mt-3">
|
||||
{{ $events->links() }}
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
87
resources/views/web/community/activity.blade.php
Normal file
87
resources/views/web/community/activity.blade.php
Normal file
@@ -0,0 +1,87 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('title', $page_title . ' — Skinbase')
|
||||
|
||||
@section('content')
|
||||
<div class="container-fluid legacy-page">
|
||||
<div class="page-heading">
|
||||
<h1 class="page-header"><i class="fa fa-stream"></i> {{ $page_title }}</h1>
|
||||
</div>
|
||||
|
||||
{{-- Tab bar --}}
|
||||
<ul class="nav nav-tabs mb-3">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ $active_tab === 'global' ? 'active' : '' }}"
|
||||
href="{{ route('community.activity', ['type' => 'global']) }}">
|
||||
<i class="fa fa-globe"></i> Global
|
||||
</a>
|
||||
</li>
|
||||
@auth
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ $active_tab === 'following' ? 'active' : '' }}"
|
||||
href="{{ route('community.activity', ['type' => 'following']) }}">
|
||||
<i class="fa fa-user-group"></i> Following
|
||||
</a>
|
||||
</li>
|
||||
@endauth
|
||||
</ul>
|
||||
|
||||
<div class="activity-feed">
|
||||
@forelse($enriched as $event)
|
||||
<div class="activity-event media mb-3 p-2 rounded bg-dark-subtle">
|
||||
<div class="media-body">
|
||||
<span class="fw-semibold">
|
||||
<a href="{{ $event['actor']['url'] ?? '#' }}">{{ $event['actor']['name'] ?? 'Someone' }}</a>
|
||||
</span>
|
||||
|
||||
@switch($event['type'])
|
||||
@case('upload')
|
||||
uploaded
|
||||
@break
|
||||
@case('comment')
|
||||
commented on
|
||||
@break
|
||||
@case('favorite')
|
||||
favourited
|
||||
@break
|
||||
@case('award')
|
||||
awarded
|
||||
@break
|
||||
@case('follow')
|
||||
started following
|
||||
@break
|
||||
@default
|
||||
interacted with
|
||||
@endswitch
|
||||
|
||||
@if($event['target'])
|
||||
@if($event['target_type'] === 'artwork')
|
||||
<a href="{{ $event['target']['url'] }}">{{ $event['target']['title'] }}</a>
|
||||
@if(!empty($event['target']['thumb']))
|
||||
<img src="{{ $event['target']['thumb'] }}" alt="" class="ms-2 rounded" style="height:36px;width:auto;vertical-align:middle;">
|
||||
@endif
|
||||
@elseif($event['target_type'] === 'user')
|
||||
<a href="{{ $event['target']['url'] ?? '#' }}">{{ $event['target']['name'] ?? $event['target']['username'] ?? '' }}</a>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
<small class="text-muted ms-2">{{ \Carbon\Carbon::parse($event['created_at'])->diffForHumans() }}</small>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="alert alert-info">
|
||||
@if($active_tab === 'following')
|
||||
Follow some creators to see their activity here.
|
||||
@else
|
||||
No activity yet. Be the first!
|
||||
@endif
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
{{-- Pagination --}}
|
||||
<div class="mt-3">
|
||||
{{ $events->links() }}
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
Reference in New Issue
Block a user