feat: add community activity feed and mentions

This commit is contained in:
2026-03-17 18:26:57 +01:00
parent 2728644477
commit 2119741ba7
15 changed files with 1280 additions and 112 deletions

View File

@@ -0,0 +1,25 @@
import React from 'react'
export default function ActivityArtworkPreview({ artwork }) {
if (!artwork?.url || !artwork?.thumb) return null
return (
<a
href={artwork.url}
className="group block w-full shrink-0 overflow-hidden rounded-2xl border border-white/[0.08] bg-white/[0.03] sm:w-[120px]"
>
<div className="aspect-[6/5] overflow-hidden bg-black/20">
<img
src={artwork.thumb}
alt={artwork.title || 'Artwork'}
className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.04]"
loading="lazy"
decoding="async"
/>
</div>
<div className="border-t border-white/[0.06] px-3 py-2">
<p className="truncate text-[11px] font-medium text-white/65">{artwork.title || 'Artwork'}</p>
</div>
</a>
)
}

View File

@@ -0,0 +1,44 @@
import React from 'react'
const FALLBACK_AVATAR = 'https://files.skinbase.org/default/avatar_default.webp'
const BADGE_TONES = {
rose: 'border-rose-400/25 bg-rose-500/10 text-rose-200',
amber: 'border-amber-400/25 bg-amber-500/10 text-amber-200',
sky: 'border-sky-400/25 bg-sky-500/10 text-sky-200',
}
export default function ActivityAvatar({ user }) {
if (!user) return null
const badgeClassName = BADGE_TONES[user.badge?.tone] || BADGE_TONES.sky
return (
<div className="flex items-start gap-3">
<a href={user.profile_url || '#'} className="shrink-0">
<img
src={user.avatar_url || FALLBACK_AVATAR}
alt={user.name || user.username || 'User'}
className="h-11 w-11 rounded-2xl object-cover ring-1 ring-white/10"
loading="lazy"
decoding="async"
onError={(event) => {
event.currentTarget.src = FALLBACK_AVATAR
}}
/>
</a>
<div className="min-w-0">
<a href={user.profile_url || '#'} className="truncate text-sm font-semibold text-white hover:text-sky-200 transition-colors">
{user.name || user.username || 'User'}
</a>
{user.username && <p className="truncate text-xs text-white/35">@{user.username}</p>}
{user.badge && (
<span className={`mt-1 inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.16em] ${badgeClassName}`}>
{user.badge.label}
</span>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,88 @@
import React from 'react'
import ActivityAvatar from './ActivityAvatar'
import ActivityArtworkPreview from './ActivityArtworkPreview'
import ActivityReactions from './ActivityReactions'
function ActivityHeadline({ activity }) {
const artworkLink = activity?.artwork?.url
const artworkTitle = activity?.artwork?.title || 'an artwork'
const mentionedUser = activity?.mentioned_user
const reaction = activity?.reaction
const commentAuthor = activity?.comment?.author
switch (activity?.type) {
case 'comment':
return (
<p className="text-sm leading-6 text-white/70">
<span className="font-medium text-white">commented on </span>
{artworkLink ? <a href={artworkLink} className="text-sky-300 hover:text-sky-200">{artworkTitle}</a> : <span className="text-white">{artworkTitle}</span>}
</p>
)
case 'reply':
return (
<p className="text-sm leading-6 text-white/70">
<span className="font-medium text-white">replied on </span>
{artworkLink ? <a href={artworkLink} className="text-sky-300 hover:text-sky-200">{artworkTitle}</a> : <span className="text-white">{artworkTitle}</span>}
</p>
)
case 'reaction':
return (
<p className="text-sm leading-6 text-white/70">
<span className="font-medium text-white">reacted {reaction?.emoji || '👍'} {reaction?.label || 'Like'} </span>
<span>to </span>
{commentAuthor?.profile_url ? <a href={commentAuthor.profile_url} className="text-sky-300 hover:text-sky-200">{commentAuthor.name || commentAuthor.username || 'a creator'}</a> : <span className="text-white">a creator</span>}
<span> on </span>
{artworkLink ? <a href={artworkLink} className="text-sky-300 hover:text-sky-200">{artworkTitle}</a> : <span className="text-white">{artworkTitle}</span>}
</p>
)
case 'mention':
return (
<p className="text-sm leading-6 text-white/70">
<span className="font-medium text-white">mentioned </span>
{mentionedUser?.profile_url ? <a href={mentionedUser.profile_url} className="text-sky-300 hover:text-sky-200">@{mentionedUser.username || mentionedUser.name}</a> : <span className="text-white">someone</span>}
<span> on </span>
{artworkLink ? <a href={artworkLink} className="text-sky-300 hover:text-sky-200">{artworkTitle}</a> : <span className="text-white">{artworkTitle}</span>}
</p>
)
default:
return <p className="text-sm leading-6 text-white/70">Shared new activity.</p>
}
}
export default function ActivityCard({ activity, isLoggedIn = false }) {
return (
<article className="rounded-[28px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(11,16,26,0.96),rgba(7,11,19,0.92))] p-4 shadow-[0_18px_45px_rgba(0,0,0,0.28)] backdrop-blur-xl sm:p-5">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start">
<div className="sm:w-[220px] sm:shrink-0">
<ActivityAvatar user={activity.user} />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<ActivityHeadline activity={activity} />
<span className="text-[11px] uppercase tracking-[0.18em] text-white/25">{activity.time_ago || ''}</span>
</div>
{activity.comment?.body ? (
<div className="mt-3 rounded-2xl border border-white/[0.06] bg-white/[0.025] px-4 py-3">
<p className="whitespace-pre-line break-words text-sm leading-6 text-white/80">{activity.comment.body}</p>
</div>
) : null}
{activity.type === 'mention' && activity.mentioned_user ? (
<div className="mt-3 inline-flex items-center gap-2 rounded-full border border-sky-400/20 bg-sky-500/10 px-3 py-1 text-xs text-sky-200">
<i className="fa-solid fa-at" />
Mentioned @{activity.mentioned_user.username || activity.mentioned_user.name}
</div>
) : null}
<ActivityReactions activity={activity} isLoggedIn={isLoggedIn} />
</div>
<div className="sm:ml-auto">
<ActivityArtworkPreview artwork={activity.artwork} />
</div>
</div>
</article>
)
}

View File

@@ -0,0 +1,80 @@
import React from 'react'
import ActivityCard from './ActivityCard'
function ActivitySkeleton() {
return (
<div className="space-y-4 animate-pulse">
{Array.from({ length: 5 }).map((_, index) => (
<div key={index} className="rounded-[28px] border border-white/[0.06] bg-white/[0.025] p-5">
<div className="flex flex-col gap-4 sm:flex-row">
<div className="flex items-start gap-3 sm:w-[220px]">
<div className="h-11 w-11 rounded-2xl bg-white/[0.08]" />
<div className="flex-1 space-y-2">
<div className="h-3 w-24 rounded bg-white/[0.08]" />
<div className="h-2.5 w-16 rounded bg-white/[0.06]" />
</div>
</div>
<div className="flex-1 space-y-3">
<div className="h-3 w-4/5 rounded bg-white/[0.08]" />
<div className="rounded-2xl border border-white/[0.04] bg-white/[0.02] px-4 py-3">
<div className="h-3 w-full rounded bg-white/[0.06]" />
<div className="mt-2 h-3 w-3/4 rounded bg-white/[0.05]" />
</div>
<div className="h-8 w-48 rounded-full bg-white/[0.05]" />
</div>
<div className="h-[132px] w-full rounded-2xl bg-white/[0.05] sm:w-[120px]" />
</div>
</div>
))}
</div>
)
}
function EmptyState({ isFiltered }) {
return (
<div className="rounded-[28px] border border-white/[0.06] bg-white/[0.025] px-6 py-16 text-center">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border border-white/[0.06] bg-white/[0.03] text-white/35">
<i className="fa-solid fa-wave-square text-xl" />
</div>
<h3 className="text-lg font-semibold text-white/80">No activity yet</h3>
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-white/45">
{isFiltered ? 'This filter has no recent activity right now.' : 'When creators and members interact around artworks, their activity will appear here.'}
</p>
</div>
)
}
export default function ActivityFeed({
activities = [],
isLoggedIn = false,
loading = false,
loadingMore = false,
error = null,
sentinelRef,
}) {
if (loading && activities.length === 0) {
return <ActivitySkeleton />
}
if (!loading && activities.length === 0) {
return <EmptyState isFiltered={Boolean(error) === false} />
}
return (
<div className="space-y-4">
{error ? (
<div className="rounded-2xl border border-rose-500/20 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">
{error}
</div>
) : null}
{activities.map((activity) => (
<ActivityCard key={activity.id} activity={activity} isLoggedIn={isLoggedIn} />
))}
{loadingMore ? <ActivitySkeleton /> : null}
<div ref={sentinelRef} className="h-6" aria-hidden="true" />
</div>
)
}

View File

@@ -0,0 +1,39 @@
import React from 'react'
import ReactionBar from '../comments/ReactionBar'
export default function ActivityReactions({ activity, isLoggedIn = false }) {
const commentId = activity?.comment?.id || null
const commentUrl = activity?.comment?.url || activity?.artwork?.url || '#'
const artworkUrl = activity?.artwork?.url || null
return (
<div className="flex flex-wrap items-center gap-3 pt-2">
{commentId ? (
<ReactionBar
entityType="comment"
entityId={commentId}
initialTotals={activity?.comment?.reactions || {}}
isLoggedIn={isLoggedIn}
/>
) : null}
<a
href={commentUrl}
className="inline-flex items-center gap-1.5 rounded-full border border-white/[0.08] bg-white/[0.03] px-3 py-1.5 text-xs font-medium text-white/55 transition hover:border-sky-400/30 hover:bg-sky-500/10 hover:text-sky-200"
>
<i className="fa-regular fa-comment-dots" />
Reply
</a>
{artworkUrl ? (
<a
href={artworkUrl}
className="inline-flex items-center gap-1.5 rounded-full border border-white/[0.08] bg-white/[0.03] px-3 py-1.5 text-xs font-medium text-white/55 transition hover:border-white/15 hover:bg-white/[0.07] hover:text-white"
>
<i className="fa-regular fa-image" />
View artwork
</a>
) : null}
</div>
)
}