optimizations
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import axios from 'axios'
|
||||
import SuggestedUsersWidget from '../social/SuggestedUsersWidget'
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
@@ -44,7 +45,7 @@ function SideCard({ title, icon, children, className = '' }) {
|
||||
function StatsCard({ stats, followerCount, user, onTabChange }) {
|
||||
const items = [
|
||||
{
|
||||
label: 'Artworks',
|
||||
label: 'Uploads',
|
||||
value: fmt(stats?.uploads_count ?? 0),
|
||||
icon: 'fa-solid fa-image',
|
||||
color: 'text-sky-400',
|
||||
@@ -285,67 +286,6 @@ function TrendingHashtagsCard() {
|
||||
// Suggested to follow card
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function SuggestionsCard({ excludeUsername, isLoggedIn }) {
|
||||
const [users, setUsers] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn) { setLoading(false); return }
|
||||
axios.get('/api/search/users', { params: { q: '', per_page: 5 } })
|
||||
.then(({ data }) => {
|
||||
const list = (data.data ?? []).filter((u) => u.username !== excludeUsername).slice(0, 4)
|
||||
setUsers(list)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [excludeUsername, isLoggedIn])
|
||||
|
||||
if (!isLoggedIn) return null
|
||||
if (!loading && users.length === 0) return null
|
||||
|
||||
return (
|
||||
<SideCard title="Discover Creators" icon="fa-solid fa-compass">
|
||||
<div className="px-4 py-3 space-y-2.5">
|
||||
{loading ? (
|
||||
[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="flex items-center gap-2.5 animate-pulse">
|
||||
<div className="w-8 h-8 rounded-full bg-white/10 shrink-0" />
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="h-2.5 bg-white/10 rounded w-24" />
|
||||
<div className="h-2 bg-white/6 rounded w-16" />
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
users.map((u) => (
|
||||
<a
|
||||
key={u.id}
|
||||
href={u.profile_url ?? `/@${u.username}`}
|
||||
className="flex items-center gap-2.5 group"
|
||||
>
|
||||
<img
|
||||
src={u.avatar_url ?? '/images/avatar_default.webp'}
|
||||
alt={u.username}
|
||||
className="w-8 h-8 rounded-full object-cover ring-1 ring-white/10 group-hover:ring-sky-500/40 transition-all shrink-0"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[13px] font-medium text-slate-300 group-hover:text-white transition-colors truncate leading-tight">
|
||||
{u.name || u.username}
|
||||
</p>
|
||||
<p className="text-[11px] text-slate-600 truncate">@{u.username}</p>
|
||||
</div>
|
||||
<span className="shrink-0 text-[11px] text-sky-500/80 group-hover:text-sky-400 transition-colors font-medium">
|
||||
View
|
||||
</span>
|
||||
</a>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</SideCard>
|
||||
)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Main export
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -370,6 +310,7 @@ export default function FeedSidebar({
|
||||
stats,
|
||||
followerCount,
|
||||
recentFollowers,
|
||||
suggestedUsers,
|
||||
socialLinks,
|
||||
countryName,
|
||||
isLoggedIn,
|
||||
@@ -397,9 +338,12 @@ export default function FeedSidebar({
|
||||
onTabChange={onTabChange}
|
||||
/>
|
||||
|
||||
<SuggestionsCard
|
||||
<SuggestedUsersWidget
|
||||
title="Discover Creators"
|
||||
excludeUsername={user?.username}
|
||||
isLoggedIn={isLoggedIn}
|
||||
initialUsers={suggestedUsers}
|
||||
limit={4}
|
||||
/>
|
||||
|
||||
<TrendingHashtagsCard />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import LevelBadge from '../xp/LevelBadge'
|
||||
|
||||
const IMAGE_FALLBACK = 'https://files.skinbase.org/default/missing_md.webp'
|
||||
@@ -118,6 +119,87 @@ function getCsrfToken() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
||||
}
|
||||
|
||||
function sendDiscoveryEvent(endpoint, payload) {
|
||||
if (!endpoint) return
|
||||
|
||||
void fetch(endpoint, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
keepalive: true,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': getCsrfToken(),
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
async function sendFeedbackSignal(endpoint, payload) {
|
||||
if (!endpoint) {
|
||||
throw new Error('missing_feedback_endpoint')
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': getCsrfToken(),
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('feedback_request_failed')
|
||||
}
|
||||
|
||||
return response.json().catch(() => null)
|
||||
}
|
||||
|
||||
async function requestJson(endpoint, { method = 'GET', body } = {}) {
|
||||
const response = await fetch(endpoint, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': getCsrfToken(),
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
const error = new Error(payload?.message || 'Request failed.')
|
||||
error.payload = payload
|
||||
throw error
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
function trackRecommendationFeedback(item, eventType, extraMeta = {}) {
|
||||
const endpoint = item?.discovery_endpoint
|
||||
const artworkId = Number(item?.id ?? 0)
|
||||
if (!endpoint || artworkId <= 0) return
|
||||
|
||||
sendDiscoveryEvent(endpoint, {
|
||||
event_type: eventType,
|
||||
artwork_id: artworkId,
|
||||
algo_version: item?.recommendation_algo_version || item?.algo_version || undefined,
|
||||
meta: {
|
||||
gallery_type: item?.recommendation_surface || item?.gallery_type || 'recommendation-surface',
|
||||
source: item?.recommendation_source || null,
|
||||
reason: item?.recommendation_reason || null,
|
||||
score: item?.recommendation_score ?? null,
|
||||
...extraMeta,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function HeartIcon(props) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true" {...props}>
|
||||
@@ -145,6 +227,34 @@ function ViewIcon(props) {
|
||||
)
|
||||
}
|
||||
|
||||
function HideIcon(props) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 6l12 12" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M18 6 6 18" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function TagIcon(props) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 13.5 10.5 3.75H4.5v6l9.75 9.75a2.12 2.12 0 0 0 3 0l3-3a2.12 2.12 0 0 0 0-3Z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M7.875 7.875h.008v.008h-.008z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function CollectionIcon(props) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 6.75A2.25 2.25 0 0 1 6.75 4.5h10.5a2.25 2.25 0 0 1 2.25 2.25v10.5a2.25 2.25 0 0 1-2.25 2.25H6.75a2.25 2.25 0 0 1-2.25-2.25V6.75Z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 12h7.5" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8.25v7.5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ActionLink({ href, label, children, onClick }) {
|
||||
return (
|
||||
<a
|
||||
@@ -185,6 +295,113 @@ function BadgePill({ className = '', iconClass = '', children }) {
|
||||
)
|
||||
}
|
||||
|
||||
function CollectionPickerModal({
|
||||
open,
|
||||
artworkTitle,
|
||||
collections,
|
||||
loading,
|
||||
error,
|
||||
notice,
|
||||
createUrl,
|
||||
attachingCollectionId,
|
||||
onAttach,
|
||||
onClose,
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!open) return undefined
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [open, onClose])
|
||||
|
||||
if (!open) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[140] flex items-center justify-center p-4">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close add to collection dialog"
|
||||
onClick={onClose}
|
||||
className="absolute inset-0 bg-slate-950/80 backdrop-blur-sm"
|
||||
/>
|
||||
|
||||
<div className="relative z-10 w-full max-w-2xl overflow-hidden rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(8,15,27,0.98),rgba(6,11,20,0.98))] shadow-[0_40px_120px_rgba(2,6,23,0.55)]">
|
||||
<div className="border-b border-white/10 px-6 py-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-sky-200/80">Collections</p>
|
||||
<h3 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white">Add to collection</h3>
|
||||
<p className="mt-2 text-sm text-slate-300">Choose a showcase for <span className="font-semibold text-white">{artworkTitle}</span>.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="inline-flex h-11 w-11 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.04] text-slate-300 transition hover:bg-white/[0.08] hover:text-white"
|
||||
>
|
||||
<i className="fa-solid fa-xmark" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 px-6 py-6">
|
||||
{notice ? <div className="rounded-2xl border border-emerald-400/20 bg-emerald-400/10 px-4 py-3 text-sm text-emerald-100">{notice}</div> : null}
|
||||
{error ? <div className="rounded-2xl border border-rose-400/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{error}</div> : null}
|
||||
|
||||
{loading ? (
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] px-5 py-10 text-center text-sm text-slate-300">Loading collections...</div>
|
||||
) : collections.length ? (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{collections.map((collection) => (
|
||||
<div key={collection.id} className="rounded-[24px] border border-white/10 bg-white/[0.04] p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-white">{collection.title}</div>
|
||||
<div className="mt-1 text-xs text-slate-400">{collection.artworks_count} artworks • {collection.visibility}</div>
|
||||
</div>
|
||||
{collection.already_attached ? <span className="inline-flex items-center rounded-full border border-emerald-400/20 bg-emerald-400/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-emerald-100">Added</span> : null}
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAttach(collection)}
|
||||
disabled={collection.already_attached || attachingCollectionId === collection.id}
|
||||
className={`inline-flex items-center gap-2 rounded-xl border px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] ${collection.already_attached || attachingCollectionId === collection.id ? 'border-white/8 bg-white/[0.03] text-slate-500' : 'border-sky-300/20 bg-sky-400/10 text-sky-100 transition hover:bg-sky-400/15'}`}
|
||||
>
|
||||
<CollectionIcon className="h-3.5 w-3.5" />
|
||||
{attachingCollectionId === collection.id ? 'Adding...' : collection.already_attached ? 'Already added' : 'Add'}
|
||||
</button>
|
||||
<a href={collection.manage_url} className="inline-flex items-center gap-2 rounded-xl border border-white/12 bg-white/[0.05] px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.08]">Manage</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] px-5 py-10 text-center">
|
||||
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-[20px] border border-white/10 bg-white/[0.05] text-slate-400">
|
||||
<CollectionIcon className="h-7 w-7" />
|
||||
</div>
|
||||
<h4 className="mt-4 text-lg font-semibold text-white">Create your first collection</h4>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm leading-relaxed text-slate-300">Start a curated showcase, then add this artwork into the sequence.</p>
|
||||
<a href={createUrl} className="mt-5 inline-flex items-center gap-2 rounded-2xl border border-sky-300/25 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15">
|
||||
<i className="fa-solid fa-plus" />
|
||||
Create Collection
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ArtworkCard({
|
||||
artwork,
|
||||
variant = 'default',
|
||||
@@ -206,9 +423,18 @@ export default function ArtworkCard({
|
||||
decoding = 'async',
|
||||
fetchPriority,
|
||||
onLike,
|
||||
onDismissed,
|
||||
showActions = true,
|
||||
metricBadge = null,
|
||||
}) {
|
||||
let inertiaProps = {}
|
||||
|
||||
try {
|
||||
inertiaProps = usePage()?.props || {}
|
||||
} catch {
|
||||
inertiaProps = {}
|
||||
}
|
||||
|
||||
const item = artwork || {}
|
||||
const rawAuthor = item.author || item.creator
|
||||
const title = decodeHtml(item.title || item.name || 'Untitled artwork')
|
||||
@@ -255,10 +481,51 @@ export default function ArtworkCard({
|
||||
const [likeCount, setLikeCount] = useState(Number(likes ?? 0) || 0)
|
||||
const [likeBusy, setLikeBusy] = useState(false)
|
||||
const [downloadBusy, setDownloadBusy] = useState(false)
|
||||
const [hideBusy, setHideBusy] = useState(false)
|
||||
const [dislikeBusy, setDislikeBusy] = useState(false)
|
||||
const [dismissed, setDismissed] = useState(false)
|
||||
const [collectionPickerOpen, setCollectionPickerOpen] = useState(false)
|
||||
const [collectionOptionsLoading, setCollectionOptionsLoading] = useState(false)
|
||||
const [collectionOptionsLoaded, setCollectionOptionsLoaded] = useState(false)
|
||||
const [collectionOptions, setCollectionOptions] = useState([])
|
||||
const [collectionCreateUrl, setCollectionCreateUrl] = useState('/settings/collections/create')
|
||||
const [collectionPickerError, setCollectionPickerError] = useState('')
|
||||
const [collectionPickerNotice, setCollectionPickerNotice] = useState('')
|
||||
const [attachingCollectionId, setAttachingCollectionId] = useState(null)
|
||||
const openTrackedRef = useRef(false)
|
||||
const primaryTag = useMemo(() => {
|
||||
if (item?.primary_tag && typeof item.primary_tag === 'object') {
|
||||
return item.primary_tag
|
||||
}
|
||||
|
||||
if (Array.isArray(item?.tags) && item.tags.length > 0) {
|
||||
return item.tags[0]
|
||||
}
|
||||
|
||||
return null
|
||||
}, [item.primary_tag, item.tags])
|
||||
const hideArtworkEndpoint = item.hide_artwork_endpoint || item?.negative_feedback?.hide_artwork_endpoint || null
|
||||
const dislikeTagEndpoint = item.dislike_tag_endpoint || item?.negative_feedback?.dislike_tag_endpoint || null
|
||||
const canHideRecommendation = Boolean(item?.id && hideArtworkEndpoint && item?.recommendation_algo_version)
|
||||
const canDislikePrimaryTag = Boolean(dislikeTagEndpoint && item?.recommendation_algo_version && (primaryTag?.id || primaryTag?.slug))
|
||||
const authUserId = Number(inertiaProps?.auth?.user?.id ?? 0)
|
||||
const itemOwnerId = Number(item.author_id ?? rawAuthor?.id ?? item.user_id ?? item.creator?.id ?? 0)
|
||||
const canAddToCollection = Boolean(authUserId > 0 && Number(item.id ?? 0) > 0 && itemOwnerId > 0 && itemOwnerId === authUserId)
|
||||
const collectionOptionsEndpoint = canAddToCollection
|
||||
? (item.collection_options_endpoint || `/settings/collections/artworks/${item.id}/options`)
|
||||
: null
|
||||
|
||||
useEffect(() => {
|
||||
setLiked(Boolean(item.viewer?.is_liked))
|
||||
setLikeCount(Number(item.likes ?? item.favourites ?? 0) || 0)
|
||||
setDismissed(false)
|
||||
setCollectionPickerOpen(false)
|
||||
setCollectionOptionsLoading(false)
|
||||
setCollectionOptionsLoaded(false)
|
||||
setCollectionOptions([])
|
||||
setCollectionPickerError('')
|
||||
setCollectionPickerNotice('')
|
||||
setAttachingCollectionId(null)
|
||||
}, [item.id, item.likes, item.favourites, item.viewer?.is_liked])
|
||||
|
||||
const articleData = useMemo(() => ({
|
||||
@@ -268,6 +535,16 @@ export default function ArtworkCard({
|
||||
'data-art-img': image,
|
||||
}), [href, image, item.id, title])
|
||||
|
||||
const handleOpen = () => {
|
||||
if (openTrackedRef.current) return
|
||||
openTrackedRef.current = true
|
||||
|
||||
trackRecommendationFeedback(item, 'click', {
|
||||
interaction_origin: 'artwork-card-open',
|
||||
target_url: href,
|
||||
})
|
||||
}
|
||||
|
||||
const handleLike = async () => {
|
||||
if (!item.id || likeBusy) {
|
||||
onLike?.(item)
|
||||
@@ -294,6 +571,12 @@ export default function ArtworkCard({
|
||||
throw new Error('like_request_failed')
|
||||
}
|
||||
|
||||
if (nextState) {
|
||||
trackRecommendationFeedback(item, 'favorite', {
|
||||
interaction_origin: 'artwork-card-like',
|
||||
})
|
||||
}
|
||||
|
||||
onLike?.(item)
|
||||
} catch {
|
||||
setLiked(!nextState)
|
||||
@@ -309,6 +592,11 @@ export default function ArtworkCard({
|
||||
|
||||
setDownloadBusy(true)
|
||||
try {
|
||||
trackRecommendationFeedback(item, 'download', {
|
||||
interaction_origin: 'artwork-card-download',
|
||||
target_url: downloadHref,
|
||||
})
|
||||
|
||||
const link = document.createElement('a')
|
||||
link.href = downloadHref
|
||||
link.rel = 'noopener noreferrer'
|
||||
@@ -322,6 +610,125 @@ export default function ArtworkCard({
|
||||
}
|
||||
}
|
||||
|
||||
const dismissArtwork = (kind) => {
|
||||
setDismissed(true)
|
||||
onDismissed?.(item, kind)
|
||||
}
|
||||
|
||||
const handleHideArtwork = async (event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!canHideRecommendation || hideBusy) return
|
||||
|
||||
setHideBusy(true)
|
||||
try {
|
||||
await sendFeedbackSignal(hideArtworkEndpoint, {
|
||||
artwork_id: Number(item.id),
|
||||
algo_version: item.recommendation_algo_version || item.algo_version || undefined,
|
||||
source: item.recommendation_source || 'recommendation-card',
|
||||
meta: {
|
||||
gallery_type: item.recommendation_surface || item.gallery_type || 'recommendation-surface',
|
||||
reason: item.recommendation_reason || null,
|
||||
primary_tag_slug: primaryTag?.slug || null,
|
||||
interaction_origin: 'artwork-card-hide',
|
||||
},
|
||||
})
|
||||
|
||||
dismissArtwork('hide-artwork')
|
||||
} catch {
|
||||
// Keep the card visible if the feedback request fails.
|
||||
} finally {
|
||||
setHideBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDislikePrimaryTag = async (event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!canDislikePrimaryTag || dislikeBusy) return
|
||||
|
||||
setDislikeBusy(true)
|
||||
try {
|
||||
await sendFeedbackSignal(dislikeTagEndpoint, {
|
||||
tag_id: primaryTag?.id ? Number(primaryTag.id) : undefined,
|
||||
tag_slug: primaryTag?.slug || undefined,
|
||||
algo_version: item.recommendation_algo_version || item.algo_version || undefined,
|
||||
source: item.recommendation_source || 'recommendation-card',
|
||||
meta: {
|
||||
artwork_id: Number(item.id),
|
||||
gallery_type: item.recommendation_surface || item.gallery_type || 'recommendation-surface',
|
||||
reason: item.recommendation_reason || null,
|
||||
interaction_origin: 'artwork-card-dislike-tag',
|
||||
},
|
||||
})
|
||||
|
||||
dismissArtwork('dislike-tag')
|
||||
} catch {
|
||||
// Keep the card visible if the feedback request fails.
|
||||
} finally {
|
||||
setDislikeBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenCollectionPicker = async (event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!collectionOptionsEndpoint || collectionOptionsLoading) return
|
||||
|
||||
setCollectionPickerOpen(true)
|
||||
setCollectionPickerError('')
|
||||
setCollectionPickerNotice('')
|
||||
|
||||
if (collectionOptionsLoaded) {
|
||||
return
|
||||
}
|
||||
|
||||
setCollectionOptionsLoading(true)
|
||||
try {
|
||||
const payload = await requestJson(collectionOptionsEndpoint)
|
||||
setCollectionOptions(Array.isArray(payload?.data) ? payload.data : [])
|
||||
setCollectionCreateUrl(payload?.meta?.create_url || '/settings/collections/create')
|
||||
setCollectionOptionsLoaded(true)
|
||||
} catch (error) {
|
||||
setCollectionPickerError(error.message || 'Unable to load collections.')
|
||||
} finally {
|
||||
setCollectionOptionsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAttachToCollection = async (collection) => {
|
||||
if (!collection?.attach_url || attachingCollectionId === collection.id) return
|
||||
|
||||
setAttachingCollectionId(collection.id)
|
||||
setCollectionPickerError('')
|
||||
setCollectionPickerNotice('')
|
||||
|
||||
try {
|
||||
await requestJson(collection.attach_url, {
|
||||
method: 'POST',
|
||||
body: { artwork_ids: [Number(item.id)] },
|
||||
})
|
||||
|
||||
setCollectionOptions((current) => current.map((entry) => (
|
||||
entry.id === collection.id
|
||||
? { ...entry, already_attached: true, artworks_count: Number(entry.artworks_count || 0) + 1 }
|
||||
: entry
|
||||
)))
|
||||
setCollectionPickerNotice(`Added to ${collection.title}.`)
|
||||
} catch (error) {
|
||||
const firstError = error?.payload?.errors
|
||||
? Object.values(error.payload.errors).flat().find(Boolean)
|
||||
: null
|
||||
setCollectionPickerError(firstError || error.message || 'Unable to add artwork to collection.')
|
||||
} finally {
|
||||
setAttachingCollectionId(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (dismissed) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (variant === 'embed') {
|
||||
return (
|
||||
<article
|
||||
@@ -333,6 +740,7 @@ export default function ArtworkCard({
|
||||
href={href}
|
||||
className="flex gap-3 p-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/80"
|
||||
aria-label={`Open artwork: ${cardLabel}`}
|
||||
onClick={handleOpen}
|
||||
>
|
||||
<div className="h-16 w-20 shrink-0 overflow-hidden rounded-lg bg-white/5">
|
||||
<img
|
||||
@@ -371,15 +779,17 @@ export default function ArtworkCard({
|
||||
}
|
||||
|
||||
return (
|
||||
<article
|
||||
className={cx('group relative', articleClassName, className)}
|
||||
style={articleStyle}
|
||||
{...articleData}
|
||||
>
|
||||
<div className={cx('relative overflow-hidden rounded-[1.6rem] border border-white/8 bg-slate-950/80 shadow-[0_18px_60px_rgba(2,6,23,0.45)] transition duration-300 ease-out group-hover:-translate-y-1 group-hover:scale-[1.02] group-hover:border-white/14 group-hover:shadow-[0_24px_80px_rgba(8,47,73,0.5)] group-focus-within:-translate-y-1 group-focus-within:scale-[1.02] group-focus-within:border-sky-200/40', frameClassName)}>
|
||||
<>
|
||||
<article
|
||||
className={cx('group relative', articleClassName, className)}
|
||||
style={articleStyle}
|
||||
{...articleData}
|
||||
>
|
||||
<div className={cx('relative overflow-hidden rounded-[1.6rem] border border-white/8 bg-slate-950/80 shadow-[0_18px_60px_rgba(2,6,23,0.45)] transition duration-300 ease-out group-hover:-translate-y-1 group-hover:scale-[1.02] group-hover:border-white/14 group-hover:shadow-[0_24px_80px_rgba(8,47,73,0.5)] group-focus-within:-translate-y-1 group-focus-within:scale-[1.02] group-focus-within:border-sky-200/40', frameClassName)}>
|
||||
<a
|
||||
href={href}
|
||||
aria-label={`Open artwork: ${cardLabel}`}
|
||||
onClick={handleOpen}
|
||||
className="absolute inset-0 z-10 rounded-[inherit] cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/80"
|
||||
>
|
||||
<span className="sr-only">{cardLabel}</span>
|
||||
@@ -426,7 +836,7 @@ export default function ArtworkCard({
|
||||
|
||||
{showActions && (
|
||||
<div className={cx(
|
||||
'absolute right-3 z-20 flex translate-y-2 gap-2 opacity-100 transition duration-200 md:opacity-0 md:group-hover:translate-y-0 md:group-hover:opacity-100 md:group-focus-within:translate-y-0 md:group-focus-within:opacity-100',
|
||||
'absolute right-3 z-20 flex max-w-[14rem] flex-wrap justify-end translate-y-2 gap-2 opacity-100 transition duration-200 md:opacity-0 md:group-hover:translate-y-0 md:group-hover:opacity-100 md:group-focus-within:translate-y-0 md:group-focus-within:opacity-100',
|
||||
relativePublishedAt ? 'top-12' : 'top-3'
|
||||
)}>
|
||||
<ActionButton label={liked ? 'Unlike artwork' : 'Like artwork'} onClick={handleLike}>
|
||||
@@ -440,6 +850,18 @@ export default function ArtworkCard({
|
||||
<ActionLink href={href} label="View artwork">
|
||||
<ViewIcon className="h-4 w-4" />
|
||||
</ActionLink>
|
||||
|
||||
{canAddToCollection ? (
|
||||
<ActionButton label="Add artwork to collection" onClick={handleOpenCollectionPicker}>
|
||||
<CollectionIcon className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
) : null}
|
||||
|
||||
{canHideRecommendation ? (
|
||||
<ActionButton label={hideBusy ? 'Hiding artwork' : 'Hide artwork'} onClick={handleHideArtwork}>
|
||||
<HideIcon className={cx('h-4 w-4', hideBusy ? 'animate-pulse text-amber-200' : 'text-white/90')} />
|
||||
</ActionButton>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -482,9 +904,36 @@ export default function ArtworkCard({
|
||||
{metadataLine}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canDislikePrimaryTag ? (
|
||||
<div className="pointer-events-auto mt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDislikePrimaryTag}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-white/12 bg-black/35 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-white/80 transition hover:border-amber-200/40 hover:bg-black/55 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/80"
|
||||
>
|
||||
<TagIcon className={cx('h-3.5 w-3.5', dislikeBusy ? 'animate-pulse text-amber-200' : '')} />
|
||||
{dislikeBusy ? 'Updating' : `Less of #${primaryTag?.slug || primaryTag?.name}`}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<CollectionPickerModal
|
||||
open={collectionPickerOpen}
|
||||
artworkTitle={title}
|
||||
collections={collectionOptions}
|
||||
loading={collectionOptionsLoading}
|
||||
error={collectionPickerError}
|
||||
notice={collectionPickerNotice}
|
||||
createUrl={collectionCreateUrl}
|
||||
attachingCollectionId={attachingCollectionId}
|
||||
onAttach={handleAttachToCollection}
|
||||
onClose={() => setCollectionPickerOpen(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,67 @@
|
||||
import React from 'react'
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import ArtworkCard from './ArtworkCard'
|
||||
|
||||
function getCsrfToken() {
|
||||
if (typeof document === 'undefined') return ''
|
||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
||||
}
|
||||
|
||||
async function revokeDismissSignal(entry) {
|
||||
const item = entry?.item || null
|
||||
const kind = entry?.kind || null
|
||||
|
||||
if (!item || !kind) {
|
||||
throw new Error('missing_dismiss_entry')
|
||||
}
|
||||
|
||||
const endpoint = kind === 'dislike-tag'
|
||||
? item.dislike_tag_endpoint || item?.negative_feedback?.dislike_tag_endpoint
|
||||
: item.hide_artwork_endpoint || item?.negative_feedback?.hide_artwork_endpoint
|
||||
|
||||
if (!endpoint) {
|
||||
throw new Error('missing_revoke_endpoint')
|
||||
}
|
||||
|
||||
const payload = kind === 'dislike-tag'
|
||||
? {
|
||||
tag_id: item?.primary_tag?.id ? Number(item.primary_tag.id) : undefined,
|
||||
tag_slug: item?.primary_tag?.slug || item?.primary_tag?.name || undefined,
|
||||
artwork_id: Number(item.id),
|
||||
algo_version: item?.recommendation_algo_version || item?.algo_version || undefined,
|
||||
meta: {
|
||||
gallery_type: item?.recommendation_surface || item?.gallery_type || 'recommendation-surface',
|
||||
reason: item?.recommendation_reason || null,
|
||||
interaction_origin: 'artwork-gallery-undo-dislike-tag',
|
||||
},
|
||||
}
|
||||
: {
|
||||
artwork_id: Number(item.id),
|
||||
algo_version: item?.recommendation_algo_version || item?.algo_version || undefined,
|
||||
meta: {
|
||||
gallery_type: item?.recommendation_surface || item?.gallery_type || 'recommendation-surface',
|
||||
reason: item?.recommendation_reason || null,
|
||||
interaction_origin: 'artwork-gallery-undo-hide',
|
||||
},
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': getCsrfToken(),
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('revoke_request_failed')
|
||||
}
|
||||
|
||||
return response.json().catch(() => null)
|
||||
}
|
||||
|
||||
function cx(...parts) {
|
||||
return parts.filter(Boolean).join(' ')
|
||||
}
|
||||
@@ -14,6 +75,36 @@ function getArtworkKey(item, index) {
|
||||
return `artwork-${index}`
|
||||
}
|
||||
|
||||
function DismissNotice({ notice, onUndo, onClose }) {
|
||||
if (!notice) return null
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed bottom-5 right-5 z-50 max-w-sm" aria-live="polite" aria-atomic="true">
|
||||
<div className="pointer-events-auto rounded-2xl border border-amber-300/30 bg-slate-950/92 px-4 py-3 text-amber-50 shadow-2xl shadow-black/40 backdrop-blur">
|
||||
<p className="text-[11px] uppercase tracking-[0.2em] text-amber-100/70">Discovery Feedback</p>
|
||||
<p className="mt-1 text-sm font-medium">{notice.message}</p>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUndo}
|
||||
disabled={notice.busy}
|
||||
className="inline-flex items-center rounded-full border border-white/15 px-3 py-1 text-xs font-semibold uppercase tracking-[0.14em] text-white/85 transition hover:border-white/30 hover:text-white"
|
||||
>
|
||||
{notice.busy ? 'Undoing...' : 'Undo'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-[0.14em] text-white/55 transition hover:text-white/85"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ArtworkGallery({
|
||||
items,
|
||||
layout = 'grid',
|
||||
@@ -30,13 +121,81 @@ export default function ArtworkGallery({
|
||||
if (!Array.isArray(items) || items.length === 0) return null
|
||||
|
||||
const visibleItems = typeof limit === 'number' ? items.slice(0, limit) : items
|
||||
const [dismissedEntries, setDismissedEntries] = useState([])
|
||||
const [dismissNotice, setDismissNotice] = useState(null)
|
||||
const visibleArtworkItems = useMemo(
|
||||
() => visibleItems.filter((item) => !dismissedEntries.some((entry) => entry.item?.id === item?.id)),
|
||||
[dismissedEntries, visibleItems]
|
||||
)
|
||||
const baseClassName = layout === 'masonry'
|
||||
? 'grid gap-6'
|
||||
: 'grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'
|
||||
: 'grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5'
|
||||
|
||||
useEffect(() => {
|
||||
if (!dismissNotice) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setDismissNotice(null)
|
||||
}, 3200)
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId)
|
||||
}
|
||||
}, [dismissNotice])
|
||||
|
||||
function handleDismissed(item, kind) {
|
||||
if (!item?.id) return
|
||||
|
||||
setDismissedEntries((current) => {
|
||||
const next = current.filter((entry) => entry.item?.id !== item.id)
|
||||
next.push({ item, kind })
|
||||
return next
|
||||
})
|
||||
|
||||
setDismissNotice({
|
||||
itemId: item.id,
|
||||
busy: false,
|
||||
message: kind === 'dislike-tag'
|
||||
? `We will show less content like #${item?.primary_tag?.slug || item?.primary_tag?.name || 'this tag'}.`
|
||||
: 'Artwork hidden from this recommendation view.',
|
||||
})
|
||||
}
|
||||
|
||||
async function handleUndoDismiss() {
|
||||
if (!dismissNotice?.itemId) {
|
||||
setDismissNotice(null)
|
||||
return
|
||||
}
|
||||
|
||||
const entry = dismissedEntries.find((current) => current.item?.id === dismissNotice.itemId)
|
||||
if (!entry) {
|
||||
setDismissNotice(null)
|
||||
return
|
||||
}
|
||||
|
||||
setDismissNotice((current) => current ? { ...current, busy: true } : current)
|
||||
|
||||
try {
|
||||
await revokeDismissSignal(entry)
|
||||
} catch {
|
||||
setDismissNotice({
|
||||
itemId: entry.item.id,
|
||||
busy: false,
|
||||
message: 'Undo failed. The feedback signal is still active.',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setDismissedEntries((current) => current.filter((entry) => entry.item?.id !== dismissNotice.itemId))
|
||||
setDismissNotice(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cx(baseClassName, className)} {...containerProps}>
|
||||
{visibleItems.map((item, index) => {
|
||||
<>
|
||||
<div className={cx(baseClassName, className)} {...containerProps}>
|
||||
{visibleArtworkItems.map((item, index) => {
|
||||
const cardProps = resolveCardProps?.(item, index) || {}
|
||||
const { className: resolvedClassName = '', ...restCardProps } = cardProps
|
||||
|
||||
@@ -48,11 +207,14 @@ export default function ArtworkGallery({
|
||||
showStats={showStats}
|
||||
showAuthor={showAuthor}
|
||||
className={cx(cardClassName, resolvedClassName)}
|
||||
onDismissed={handleDismissed}
|
||||
{...restCardProps}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
<DismissNotice notice={dismissNotice} onUndo={handleUndoDismiss} onClose={() => setDismissNotice(null)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -350,7 +350,7 @@ export default function ArtworkRecommendationsRails({ artwork, related = [] }) {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/art/${artwork.id}/similar`, { credentials: 'same-origin' })
|
||||
const response = await fetch(`/api/art/${artwork.id}/similar-ai`, { credentials: 'same-origin' })
|
||||
if (!response.ok) throw new Error('similar fetch failed')
|
||||
const payload = await response.json()
|
||||
const items = dedupeByUrl((payload?.data || []).map(normalizeSimilar).filter(Boolean))
|
||||
|
||||
@@ -119,10 +119,32 @@ export default function ReactionBar({ entityType, entityId, initialTotals = {},
|
||||
hoverTimeout.current = setTimeout(() => setPickerOpen(false), 400)
|
||||
}
|
||||
|
||||
const isArtworkVariant = entityType === 'artwork'
|
||||
|
||||
const triggerClassName = isArtworkVariant
|
||||
? [
|
||||
'inline-flex items-center gap-2.5 rounded-full border px-4 py-2.5 text-sm font-semibold transition-all duration-200',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50',
|
||||
myReaction
|
||||
? 'border-accent/35 bg-accent/12 text-accent shadow-[0_12px_30px_rgba(245,158,11,0.14)] hover:bg-accent/18'
|
||||
: 'border-white/[0.12] bg-white/[0.06] text-white/75 hover:border-accent/30 hover:bg-white/[0.1] hover:text-white',
|
||||
].join(' ')
|
||||
: [
|
||||
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-all duration-200',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50',
|
||||
myReaction
|
||||
? 'text-accent'
|
||||
: 'text-white/40 hover:text-white/70',
|
||||
].join(' ')
|
||||
|
||||
const summaryClassName = isArtworkVariant
|
||||
? 'inline-flex items-center gap-2 rounded-full border border-white/[0.1] bg-white/[0.05] px-3 py-1.5 transition-colors hover:border-white/[0.16] hover:bg-white/[0.08] group/summary'
|
||||
: 'inline-flex items-center gap-1.5 rounded-full px-1.5 py-0.5 transition-colors hover:bg-white/[0.06] group/summary'
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex items-center gap-2"
|
||||
className={isArtworkVariant ? 'flex flex-wrap items-center gap-3' : 'flex items-center gap-2'}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
{/* ── Trigger button ──────────────────────────────────────────── */}
|
||||
@@ -138,21 +160,15 @@ export default function ReactionBar({ entityType, entityId, initialTotals = {},
|
||||
toggle('thumbs_up')
|
||||
}
|
||||
}}
|
||||
className={[
|
||||
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-all duration-200',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50',
|
||||
myReaction
|
||||
? 'text-accent'
|
||||
: 'text-white/40 hover:text-white/70',
|
||||
].join(' ')}
|
||||
className={triggerClassName}
|
||||
aria-label={myReaction ? `You reacted with ${myReactionData?.label}. Click to remove.` : 'React to this comment'}
|
||||
>
|
||||
{myReaction ? (
|
||||
<span className="text-base leading-none">{myReactionData?.emoji}</span>
|
||||
<span className={isArtworkVariant ? 'text-xl leading-none' : 'text-base leading-none'}>{myReactionData?.emoji}</span>
|
||||
) : (
|
||||
<HeartOutlineIcon className="h-4 w-4" />
|
||||
<HeartOutlineIcon className={isArtworkVariant ? 'h-5 w-5' : 'h-4 w-4'} />
|
||||
)}
|
||||
<span>{myReaction ? myReactionData?.label : 'React'}</span>
|
||||
<span>{myReaction ? myReactionData?.label : (isArtworkVariant ? 'React to this artwork' : 'React')}</span>
|
||||
</button>
|
||||
|
||||
{/* ── Floating picker ─────────────────────────────────────── */}
|
||||
@@ -202,7 +218,7 @@ export default function ReactionBar({ entityType, entityId, initialTotals = {},
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerOpen(v => !v)}
|
||||
className="inline-flex items-center gap-1.5 rounded-full px-1.5 py-0.5 transition-colors hover:bg-white/[0.06] group/summary"
|
||||
className={summaryClassName}
|
||||
aria-label={`${totalCount} reaction${totalCount !== 1 ? 's' : ''}`}
|
||||
>
|
||||
{/* Stacked emoji circles (Facebook-style, max 3) */}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react'
|
||||
import LevelBadge from '../xp/LevelBadge'
|
||||
|
||||
const ROLE_STYLES = {
|
||||
admin: 'bg-red-500/15 text-red-300',
|
||||
@@ -18,6 +19,7 @@ export default function AuthorBadge({ user, size = 'md' }) {
|
||||
const role = (user?.role ?? 'member').toLowerCase()
|
||||
const cls = ROLE_STYLES[role] ?? ROLE_STYLES.member
|
||||
const label = ROLE_LABELS[role] ?? 'Member'
|
||||
const level = Number(user?.level ?? 0)
|
||||
const rank = user?.rank ?? null
|
||||
|
||||
const imgSize = size === 'sm' ? 'h-8 w-8' : 'h-10 w-10'
|
||||
@@ -37,11 +39,7 @@ export default function AuthorBadge({ user, size = 'md' }) {
|
||||
<span className={`inline-flex rounded-full px-2 py-0.5 text-[11px] font-medium ${cls}`}>
|
||||
{label}
|
||||
</span>
|
||||
{rank && (
|
||||
<span className="inline-flex rounded-full bg-emerald-500/12 px-2 py-0.5 text-[11px] font-medium text-emerald-300">
|
||||
{rank}
|
||||
</span>
|
||||
)}
|
||||
{rank && level > 0 ? <LevelBadge level={level} rank={rank} compact /> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,30 @@ import React, {
|
||||
import ArtworkGallery from '../artwork/ArtworkGallery';
|
||||
import './MasonryGallery.css';
|
||||
|
||||
function getCsrfToken() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
|
||||
}
|
||||
|
||||
async function sendDiscoveryEvent(endpoint, payload) {
|
||||
if (!endpoint) return;
|
||||
|
||||
try {
|
||||
await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
keepalive: true,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': getCsrfToken(),
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
} catch {
|
||||
// Discovery telemetry should never block the gallery UX.
|
||||
}
|
||||
}
|
||||
|
||||
// ── Masonry helpers ────────────────────────────────────────────────────────
|
||||
const ROW_SIZE = 8;
|
||||
const ROW_GAP = 16;
|
||||
@@ -89,6 +113,7 @@ async function fetchPageData(url) {
|
||||
nextCursor,
|
||||
nextPageUrl,
|
||||
hasMore,
|
||||
meta: json.meta ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -106,6 +131,7 @@ async function fetchPageData(url) {
|
||||
nextCursor: el.dataset.nextCursor || null,
|
||||
nextPageUrl: el.dataset.nextPageUrl || null,
|
||||
hasMore: null,
|
||||
meta: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -193,6 +219,12 @@ function getMasonryCardProps(art, idx) {
|
||||
decoding: idx < 8 ? 'sync' : 'async',
|
||||
fetchPriority: idx === 0 ? 'high' : undefined,
|
||||
imageClassName: 'nova-card-main-image absolute inset-0 h-full w-full object-cover group-hover:scale-[1.03]',
|
||||
metricBadge: art.recommendation_reason
|
||||
? {
|
||||
label: art.recommendation_reason,
|
||||
className: 'bg-sky-500/14 text-sky-100 ring-sky-300/30 max-w-[15rem] truncate',
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -222,15 +254,20 @@ function MasonryGallery({
|
||||
rankApiEndpoint = null,
|
||||
rankType = null,
|
||||
gridClassName = null,
|
||||
discoveryEndpoint = null,
|
||||
algoVersion: initialAlgoVersion = null,
|
||||
}) {
|
||||
const [artworks, setArtworks] = useState(initialArtworks);
|
||||
const [nextCursor, setNextCursor] = useState(initialNextCursor);
|
||||
const [nextPageUrl, setNextPageUrl] = useState(initialNextPageUrl);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [done, setDone] = useState(!initialNextCursor && !initialNextPageUrl);
|
||||
const [algoVersion, setAlgoVersion] = useState(initialAlgoVersion);
|
||||
|
||||
const gridRef = useRef(null);
|
||||
const triggerRef = useRef(null);
|
||||
const viewedArtworkIdsRef = useRef(new Set());
|
||||
const clickedArtworkIdsRef = useRef(new Set());
|
||||
|
||||
// ── Ranking API fallback ───────────────────────────────────────────────
|
||||
// When the server-side render provides no initial artworks (e.g. cache miss
|
||||
@@ -298,9 +335,13 @@ function MasonryGallery({
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const { artworks: newItems, nextCursor: nc, nextPageUrl: np, hasMore } =
|
||||
const { artworks: newItems, nextCursor: nc, nextPageUrl: np, hasMore, meta } =
|
||||
await fetchPageData(fetchUrl);
|
||||
|
||||
if (meta?.algo_version) {
|
||||
setAlgoVersion(meta.algo_version);
|
||||
}
|
||||
|
||||
if (!newItems.length) {
|
||||
setDone(true);
|
||||
} else {
|
||||
@@ -334,6 +375,95 @@ function MasonryGallery({
|
||||
return () => io.disconnect();
|
||||
}, [done, fetchNext]);
|
||||
|
||||
useEffect(() => {
|
||||
if (galleryType !== 'for-you' || !discoveryEndpoint) return undefined;
|
||||
|
||||
const grid = gridRef.current;
|
||||
if (!grid || !(window.IntersectionObserver)) return undefined;
|
||||
|
||||
const artworkIndex = new Map(artworks.map((art, index) => [String(art.id), { art, index }]));
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.isIntersecting || entry.intersectionRatio < 0.65) {
|
||||
return;
|
||||
}
|
||||
|
||||
const card = entry.target.closest('[data-art-id]');
|
||||
const artworkId = card?.getAttribute('data-art-id');
|
||||
if (!artworkId || viewedArtworkIdsRef.current.has(artworkId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const candidate = artworkIndex.get(artworkId);
|
||||
if (!candidate?.art?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
viewedArtworkIdsRef.current.add(artworkId);
|
||||
observer.unobserve(entry.target);
|
||||
sendDiscoveryEvent(discoveryEndpoint, {
|
||||
event_type: 'view',
|
||||
artwork_id: Number(candidate.art.id),
|
||||
algo_version: candidate.art.recommendation_algo_version || algoVersion || undefined,
|
||||
meta: {
|
||||
gallery_type: galleryType,
|
||||
position: candidate.index + 1,
|
||||
source: candidate.art.recommendation_source || null,
|
||||
reason: candidate.art.recommendation_reason || null,
|
||||
score: candidate.art.recommendation_score ?? null,
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
{ threshold: [0.65] },
|
||||
);
|
||||
|
||||
const cards = grid.querySelectorAll('[data-art-id]');
|
||||
cards.forEach((card) => observer.observe(card));
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [algoVersion, artworks, discoveryEndpoint, galleryType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (galleryType !== 'for-you' || !discoveryEndpoint) return undefined;
|
||||
|
||||
const grid = gridRef.current;
|
||||
if (!grid) return undefined;
|
||||
|
||||
const handleClick = (event) => {
|
||||
const card = event.target.closest('[data-art-id]');
|
||||
if (!card) return;
|
||||
|
||||
const artworkId = card.getAttribute('data-art-id');
|
||||
if (!artworkId || clickedArtworkIdsRef.current.has(artworkId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const artwork = artworks.find((item) => String(item.id) === artworkId);
|
||||
if (!artwork?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
clickedArtworkIdsRef.current.add(artworkId);
|
||||
sendDiscoveryEvent(discoveryEndpoint, {
|
||||
event_type: 'click',
|
||||
artwork_id: Number(artwork.id),
|
||||
algo_version: artwork.recommendation_algo_version || algoVersion || undefined,
|
||||
meta: {
|
||||
gallery_type: galleryType,
|
||||
source: artwork.recommendation_source || null,
|
||||
reason: artwork.recommendation_reason || null,
|
||||
score: artwork.recommendation_score ?? null,
|
||||
target_url: artwork.url || null,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
grid.addEventListener('click', handleClick, true);
|
||||
return () => grid.removeEventListener('click', handleClick, true);
|
||||
}, [algoVersion, artworks, discoveryEndpoint, galleryType]);
|
||||
|
||||
// Gallery V2 spec §7: 5 col desktop / 3 tablet / 2 mobile for all gallery pages.
|
||||
// Discover feeds (home/discover page) retain the same 5-col layout.
|
||||
const gridClass = gridClassName || 'grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6';
|
||||
|
||||
@@ -104,7 +104,7 @@ function ConversationRow({ conv, isActive, currentUserId, onlineUserIds, typingU
|
||||
|
||||
<div className="mt-3 rounded-2xl border border-white/[0.04] bg-black/10 px-3 py-2.5">
|
||||
{typingUsers.length === 0 && senderLabel ? <p className="text-[11px] text-white/35">{senderLabel}</p> : null}
|
||||
<p className={`mt-1 flex items-center gap-2 truncate text-sm ${typingUsers.length > 0 ? 'text-emerald-200' : 'text-white/62'}`}>
|
||||
<p data-testid={`conversation-preview-${conv.id}`} className={`mt-1 flex items-center gap-2 truncate text-sm ${typingUsers.length > 0 ? 'text-emerald-200' : 'text-white/62'}`}>
|
||||
{typingUsers.length > 0 ? <SidebarTypingIcon /> : null}
|
||||
<span className="truncate">{preview}</span>
|
||||
</p>
|
||||
|
||||
@@ -14,6 +14,7 @@ export default function ConversationThread({
|
||||
onBack,
|
||||
onMarkRead,
|
||||
onConversationPatched,
|
||||
onUnreadTotalPatched,
|
||||
}) {
|
||||
const [messages, setMessages] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -47,6 +48,35 @@ export default function ConversationThread({
|
||||
const animatedMessageIdsRef = useRef(new Set())
|
||||
const [animatedMessageIds, setAnimatedMessageIds] = useState({})
|
||||
const prefersReducedMotion = usePrefersReducedMotion()
|
||||
const draftStorageKey = useMemo(() => (conversationId ? `nova_draft_${conversationId}` : null), [conversationId])
|
||||
|
||||
const readDraftFromStorage = useCallback(() => {
|
||||
if (!draftStorageKey || typeof window === 'undefined') {
|
||||
return ''
|
||||
}
|
||||
|
||||
try {
|
||||
return window.localStorage.getItem(draftStorageKey) ?? ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}, [draftStorageKey])
|
||||
|
||||
const persistDraftToStorage = useCallback((value) => {
|
||||
if (!draftStorageKey || typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (value.trim() === '') {
|
||||
window.localStorage.removeItem(draftStorageKey)
|
||||
} else {
|
||||
window.localStorage.setItem(draftStorageKey, value)
|
||||
}
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}, [draftStorageKey])
|
||||
|
||||
const myParticipant = useMemo(() => (
|
||||
conversation?.my_participant
|
||||
@@ -221,13 +251,13 @@ export default function ConversationThread({
|
||||
setPresenceUsers([])
|
||||
setTypingUsers([])
|
||||
setNextCursor(null)
|
||||
setBody('')
|
||||
setBody(readDraftFromStorage())
|
||||
setFiles([])
|
||||
loadMessages()
|
||||
if (!realtimeEnabled) {
|
||||
loadTyping()
|
||||
}
|
||||
}, [conversationId, loadMessages, loadTyping, realtimeEnabled])
|
||||
}, [conversationId, loadMessages, loadTyping, readDraftFromStorage, realtimeEnabled])
|
||||
|
||||
useEffect(() => {
|
||||
setParticipantState(conversation?.all_participants ?? [])
|
||||
@@ -319,8 +349,28 @@ export default function ConversationThread({
|
||||
try {
|
||||
const data = await apiFetch(`/api/messages/${conversationId}/delta?after_message_id=${encodeURIComponent(lastServerMessage.id)}`)
|
||||
const incoming = normalizeMessages(data.data ?? [], currentUserId)
|
||||
|
||||
if (data?.conversation?.id) {
|
||||
patchConversation(data.conversation)
|
||||
}
|
||||
|
||||
if (Number.isFinite(Number(data?.summary?.unread_total))) {
|
||||
onUnreadTotalPatched?.(data.summary.unread_total)
|
||||
}
|
||||
|
||||
if (incoming.length > 0) {
|
||||
setMessages((prev) => mergeMessageLists(prev, incoming))
|
||||
|
||||
const latestIncoming = incoming[incoming.length - 1] ?? null
|
||||
const latestRemoteIncoming = [...incoming].reverse().find((message) => message.sender_id !== currentUserId) ?? null
|
||||
|
||||
if (latestIncoming) {
|
||||
patchLastMessage(latestIncoming)
|
||||
}
|
||||
|
||||
if (latestRemoteIncoming && document.visibilityState === 'visible') {
|
||||
queueReadReceipt(latestRemoteIncoming.id)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// no-op
|
||||
@@ -423,7 +473,7 @@ export default function ConversationThread({
|
||||
typingExpiryTimersRef.current.clear()
|
||||
echo.leave(`conversation.${conversationId}`)
|
||||
}
|
||||
}, [apiFetch, conversationId, currentUserId, patchConversation, patchLastMessage, queueReadReceipt, realtimeEnabled])
|
||||
}, [apiFetch, conversationId, currentUserId, onUnreadTotalPatched, patchConversation, patchLastMessage, queueReadReceipt, realtimeEnabled])
|
||||
|
||||
useEffect(() => {
|
||||
const known = knownMessageIdsRef.current
|
||||
@@ -464,6 +514,7 @@ export default function ConversationThread({
|
||||
|
||||
const handleBodyChange = useCallback((value) => {
|
||||
setBody(value)
|
||||
persistDraftToStorage(value)
|
||||
|
||||
if (value.trim() === '') {
|
||||
if (stopTypingRef.current) window.clearTimeout(stopTypingRef.current)
|
||||
@@ -481,7 +532,7 @@ export default function ConversationThread({
|
||||
stopTypingRef.current = window.setTimeout(() => {
|
||||
apiFetch(`/api/messages/${conversationId}/typing/stop`, { method: 'POST' }).catch(() => {})
|
||||
}, 1200)
|
||||
}, [apiFetch, conversationId])
|
||||
}, [apiFetch, conversationId, persistDraftToStorage])
|
||||
|
||||
const handleFiles = useCallback((selectedFiles) => {
|
||||
const nextFiles = Array.from(selectedFiles || []).slice(0, 5)
|
||||
@@ -517,6 +568,7 @@ export default function ConversationThread({
|
||||
shouldStickToBottomRef.current = true
|
||||
setMessages((prev) => mergeMessageLists(prev, [optimisticMessage]))
|
||||
setBody('')
|
||||
persistDraftToStorage('')
|
||||
setFiles([])
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
setSending(true)
|
||||
@@ -533,19 +585,28 @@ export default function ConversationThread({
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (draftStorageKey && typeof window !== 'undefined') {
|
||||
try {
|
||||
window.localStorage.removeItem(draftStorageKey)
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
||||
const normalized = normalizeMessage(created, currentUserId)
|
||||
setMessages((prev) => mergeMessageLists(prev, [normalized]))
|
||||
patchLastMessage(normalized, { unread_count: 0 })
|
||||
} catch (err) {
|
||||
setMessages((prev) => prev.filter((message) => !messagesMatch(message, { id: optimisticId, client_temp_id: clientTempId })))
|
||||
setBody(trimmed)
|
||||
persistDraftToStorage(trimmed)
|
||||
setFiles(files)
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setSending(false)
|
||||
apiFetch(`/api/messages/${conversationId}/typing/stop`, { method: 'POST' }).catch(() => {})
|
||||
}
|
||||
}, [apiFetch, body, conversationId, currentUserId, currentUsername, files, patchLastMessage, sending])
|
||||
}, [apiFetch, body, conversationId, currentUserId, currentUsername, files, patchLastMessage, persistDraftToStorage, sending])
|
||||
|
||||
const updateReactions = useCallback((messageId, summary) => {
|
||||
setMessages((prev) => prev.map((message) => {
|
||||
@@ -1104,9 +1165,20 @@ function participantHasReadMessage(participant, message) {
|
||||
}
|
||||
|
||||
function formatSeenTime(iso) {
|
||||
return new Date(iso).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
const then = new Date(iso).getTime()
|
||||
if (!Number.isFinite(then)) {
|
||||
return 'moments ago'
|
||||
}
|
||||
|
||||
const diffSeconds = Math.max(0, Math.floor((Date.now() - then) / 1000))
|
||||
if (diffSeconds < 60) return 'moments ago'
|
||||
if (diffSeconds < 3600) return `${Math.floor(diffSeconds / 60)}m ago`
|
||||
if (diffSeconds < 86400) return `${Math.floor(diffSeconds / 3600)}h ago`
|
||||
if (diffSeconds < 604800) return `${Math.floor(diffSeconds / 86400)}d ago`
|
||||
|
||||
return new Date(iso).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ export default function MessageBubble({ message, isMine, showAvatar, endsSequenc
|
||||
|
||||
return (
|
||||
<div
|
||||
id={message?.id ? `message-${message.id}` : undefined}
|
||||
className={`group flex items-end gap-2.5 sm:gap-3 ${isMine ? 'flex-row-reverse' : 'flex-row'} ${showAvatar ? 'mt-4' : 'mt-1'}`}
|
||||
style={prefersReducedMotion ? undefined : {
|
||||
opacity: isArrivalVisible ? 1 : 0.55,
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import React from 'react'
|
||||
|
||||
const styles = {
|
||||
idle: 'border-white/10 bg-white/[0.04] text-slate-300',
|
||||
saving: 'border-sky-300/20 bg-sky-400/10 text-sky-100',
|
||||
saved: 'border-emerald-300/20 bg-emerald-400/10 text-emerald-100',
|
||||
error: 'border-rose-300/20 bg-rose-400/10 text-rose-100',
|
||||
}
|
||||
|
||||
export default function NovaCardAutosaveIndicator({ status = 'idle', message = '' }) {
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.16em] ${styles[status] || styles.idle}`}>
|
||||
<i className={`fa-solid ${status === 'saving' ? 'fa-rotate fa-spin' : status === 'saved' ? 'fa-check' : status === 'error' ? 'fa-triangle-exclamation' : 'fa-cloud'}`} />
|
||||
{message || (status === 'saving' ? 'Saving' : status === 'saved' ? 'Saved' : status === 'error' ? 'Failed' : 'Draft')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
187
resources/js/components/nova-cards/NovaCardCanvasPreview.jsx
Normal file
187
resources/js/components/nova-cards/NovaCardCanvasPreview.jsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import React from 'react'
|
||||
|
||||
const aspectRatios = {
|
||||
square: '1 / 1',
|
||||
portrait: '4 / 5',
|
||||
story: '9 / 16',
|
||||
landscape: '16 / 9',
|
||||
}
|
||||
|
||||
const placementStyles = {
|
||||
'top-left': { top: '12%', left: '12%' },
|
||||
'top-right': { top: '12%', right: '12%' },
|
||||
'bottom-left': { bottom: '12%', left: '12%' },
|
||||
'bottom-right': { bottom: '12%', right: '12%' },
|
||||
}
|
||||
|
||||
function overlayStyle(style) {
|
||||
if (style === 'dark-strong') return 'linear-gradient(180deg, rgba(2,6,23,0.38), rgba(2,6,23,0.68))'
|
||||
if (style === 'light-soft') return 'linear-gradient(180deg, rgba(255,255,255,0.08), rgba(255,255,255,0.22))'
|
||||
if (style === 'dark-soft') return 'linear-gradient(180deg, rgba(2,6,23,0.18), rgba(2,6,23,0.48))'
|
||||
return 'none'
|
||||
}
|
||||
|
||||
function positionStyle(position) {
|
||||
if (position === 'top') return { alignItems: 'flex-start', paddingTop: '10%' }
|
||||
if (position === 'upper-middle') return { alignItems: 'flex-start', paddingTop: '22%' }
|
||||
if (position === 'lower-middle') return { alignItems: 'flex-end', paddingBottom: '18%' }
|
||||
if (position === 'bottom') return { alignItems: 'flex-end', paddingBottom: '10%' }
|
||||
return { alignItems: 'center' }
|
||||
}
|
||||
|
||||
function alignmentClass(alignment) {
|
||||
if (alignment === 'left') return 'items-start text-left'
|
||||
if (alignment === 'right') return 'items-end text-right'
|
||||
return 'items-center text-center'
|
||||
}
|
||||
|
||||
function focalPositionStyle(position) {
|
||||
if (position === 'top') return 'center top'
|
||||
if (position === 'bottom') return 'center bottom'
|
||||
if (position === 'left') return 'left center'
|
||||
if (position === 'right') return 'right center'
|
||||
if (position === 'top-left') return 'left top'
|
||||
if (position === 'top-right') return 'right top'
|
||||
if (position === 'bottom-left') return 'left bottom'
|
||||
if (position === 'bottom-right') return 'right bottom'
|
||||
return 'center center'
|
||||
}
|
||||
|
||||
function shadowValue(preset) {
|
||||
if (preset === 'none') return 'none'
|
||||
if (preset === 'strong') return '0 12px 36px rgba(2, 6, 23, 0.72)'
|
||||
return '0 8px 24px rgba(2, 6, 23, 0.5)'
|
||||
}
|
||||
|
||||
function resolveTextBlocks(card, project) {
|
||||
const blocks = Array.isArray(project.text_blocks) ? project.text_blocks.filter((block) => block?.enabled !== false && String(block?.text || '').trim() !== '') : []
|
||||
if (blocks.length) return blocks
|
||||
|
||||
const content = project.content || {}
|
||||
|
||||
return [
|
||||
{ key: 'title', type: 'title', text: card?.title || content.title || 'Untitled card', enabled: true },
|
||||
{ key: 'quote', type: 'quote', text: card?.quote_text || content.quote_text || 'Your next quote starts here.', enabled: true },
|
||||
{ key: 'author', type: 'author', text: card?.quote_author || content.quote_author || '', enabled: Boolean(card?.quote_author || content.quote_author) },
|
||||
{ key: 'source', type: 'source', text: card?.quote_source || content.quote_source || '', enabled: Boolean(card?.quote_source || content.quote_source) },
|
||||
].filter((block) => String(block.text || '').trim() !== '')
|
||||
}
|
||||
|
||||
function blockClass(type) {
|
||||
if (type === 'title') return 'text-[11px] font-semibold uppercase tracking-[0.28em] text-white/55'
|
||||
if (type === 'author') return 'font-medium uppercase tracking-[0.18em] text-white/80 sm:text-sm lg:text-base'
|
||||
if (type === 'source') return 'text-[11px] uppercase tracking-[0.18em] text-white/50 sm:text-xs'
|
||||
if (type === 'caption') return 'text-[10px] uppercase tracking-[0.2em] text-white/45'
|
||||
if (type === 'body') return 'text-sm leading-6 text-white/90 sm:text-base'
|
||||
return 'font-semibold tracking-[-0.03em] sm:text-[1.65rem] lg:text-[2.1rem]'
|
||||
}
|
||||
|
||||
function blockStyle(type, typography, textColor, accentColor) {
|
||||
const quoteSize = Math.max(26, Math.min(typography.quote_size || 72, 120))
|
||||
const authorSize = Math.max(14, Math.min(typography.author_size || 28, 42))
|
||||
const letterSpacing = Math.max(-1, Math.min(typography.letter_spacing || 0, 10))
|
||||
const lineHeight = Math.max(0.9, Math.min(typography.line_height || 1.2, 1.8))
|
||||
const shadowPreset = typography.shadow_preset || 'soft'
|
||||
|
||||
if (type === 'title') {
|
||||
return { color: accentColor, letterSpacing: `${Math.max(letterSpacing, 0) / 10}em`, textShadow: shadowValue(shadowPreset) }
|
||||
}
|
||||
|
||||
if (type === 'author' || type === 'source') {
|
||||
return { color: accentColor, fontSize: `${authorSize / 4}px`, textShadow: shadowValue(shadowPreset) }
|
||||
}
|
||||
|
||||
if (type === 'body' || type === 'caption') {
|
||||
return { color: textColor, lineHeight, textShadow: shadowValue(shadowPreset) }
|
||||
}
|
||||
|
||||
return { color: textColor, fontSize: `${quoteSize / 4}px`, lineHeight, letterSpacing: `${letterSpacing / 12}px`, textShadow: shadowValue(shadowPreset) }
|
||||
}
|
||||
|
||||
export default function NovaCardCanvasPreview({ card, className = '' }) {
|
||||
const project = card?.project_json || {}
|
||||
const layout = project.layout || {}
|
||||
const typography = project.typography || {}
|
||||
const background = project.background || {}
|
||||
const backgroundImage = card?.background_image?.processed_url
|
||||
const colors = Array.isArray(background.gradient_colors) && background.gradient_colors.length >= 2
|
||||
? background.gradient_colors
|
||||
: ['#0f172a', '#1d4ed8']
|
||||
const backgroundStyle = background.type === 'upload' && backgroundImage
|
||||
? `url(${backgroundImage}) ${focalPositionStyle(background.focal_position)}/cover no-repeat`
|
||||
: background.type === 'solid'
|
||||
? background.solid_color || '#111827'
|
||||
: `linear-gradient(180deg, ${colors[0]}, ${colors[1]})`
|
||||
|
||||
const textBlocks = resolveTextBlocks(card, project)
|
||||
const decorations = Array.isArray(project.decorations) ? project.decorations : []
|
||||
const assetItems = Array.isArray(project.assets?.items) ? project.assets.items : []
|
||||
const textColor = typography.text_color || '#ffffff'
|
||||
const accentColor = typography.accent_color || textColor
|
||||
const maxWidth = layout.max_width === 'compact' ? '62%' : layout.max_width === 'wide' ? '88%' : '76%'
|
||||
const padding = layout.padding === 'tight' ? '8%' : layout.padding === 'airy' ? '14%' : '11%'
|
||||
|
||||
return (
|
||||
<div className={`relative overflow-hidden rounded-[28px] border border-white/10 bg-slate-950 shadow-[0_30px_80px_rgba(2,6,23,0.35)] ${className}`} style={{ aspectRatio: aspectRatios[card?.format || 'square'] || aspectRatios.square }}>
|
||||
<div className="absolute inset-0" style={{ background: backgroundStyle, filter: background.type === 'upload' && Number(background.blur_level || 0) > 0 ? `blur(${Math.max(Number(background.blur_level) / 8, 0)}px)` : undefined }} />
|
||||
<div className="absolute inset-0" style={{ background: overlayStyle(background.overlay_style), opacity: Math.max(0, Math.min(Number(background.opacity || 50), 100)) / 100 }} />
|
||||
<div className="absolute left-4 top-4 rounded-full border border-white/10 bg-black/25 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-white/80 backdrop-blur">
|
||||
{(card?.format || 'square').replace('-', ' ')}
|
||||
</div>
|
||||
|
||||
{decorations.slice(0, 6).map((decoration, index) => {
|
||||
const placement = placementStyles[decoration.placement] || placementStyles['top-right']
|
||||
return (
|
||||
<div
|
||||
key={`${decoration.key || decoration.glyph || 'dec'}-${index}`}
|
||||
className="absolute text-white/85 drop-shadow-[0_6px_14px_rgba(2,6,23,0.3)]"
|
||||
style={{
|
||||
...placement,
|
||||
color: accentColor,
|
||||
fontSize: `${Math.max(18, Math.min(decoration.size || 28, 64))}px`,
|
||||
}}
|
||||
>
|
||||
{decoration.glyph || '✦'}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{assetItems.slice(0, 6).map((item, index) => {
|
||||
if (item?.type === 'frame') {
|
||||
const top = index % 2 === 0 ? '10%' : '88%'
|
||||
return <div key={`${item.asset_key || item.label || 'frame'}-${index}`} className="absolute left-[12%] right-[12%] h-px bg-white/35" style={{ top }} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${item.asset_key || item.label || 'asset'}-${index}`}
|
||||
className="absolute text-white/85 drop-shadow-[0_6px_14px_rgba(2,6,23,0.3)]"
|
||||
style={{
|
||||
top: `${12 + ((index % 3) * 18)}%`,
|
||||
left: `${10 + (Math.floor(index / 3) * 72)}%`,
|
||||
color: accentColor,
|
||||
fontSize: `${Math.max(18, Math.min(item.size || 26, 56))}px`,
|
||||
}}
|
||||
>
|
||||
{item.glyph || item.label || '✦'}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<div className={`relative flex h-full w-full ${alignmentClass(layout.alignment)}`} style={{ padding, ...positionStyle(layout.position) }}>
|
||||
<div className="flex w-full flex-col gap-4" style={{ maxWidth }}>
|
||||
{textBlocks.map((block, index) => {
|
||||
const type = block?.type || 'body'
|
||||
const text = type === 'author' ? `— ${block.text}` : block.text
|
||||
|
||||
return (
|
||||
<div key={`${block.key || type}-${index}`} style={blockStyle(type, typography, textColor, accentColor)} className={blockClass(type)}>
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
22
resources/js/components/nova-cards/NovaCardFontPicker.jsx
Normal file
22
resources/js/components/nova-cards/NovaCardFontPicker.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function NovaCardFontPicker({ fonts = [], selectedKey = null, onSelect }) {
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{fonts.map((font) => {
|
||||
const active = selectedKey === font.key
|
||||
return (
|
||||
<button
|
||||
key={font.key}
|
||||
type="button"
|
||||
onClick={() => onSelect?.(font)}
|
||||
className={`rounded-[22px] border p-4 text-left transition ${active ? 'border-sky-300/35 bg-sky-400/10 text-white' : 'border-white/10 bg-white/[0.03] text-slate-200 hover:border-white/20 hover:bg-white/[0.05]'}`}
|
||||
>
|
||||
<div className="text-lg font-semibold tracking-[-0.03em]" style={{ fontFamily: font.family }}>{font.label}</div>
|
||||
<div className="mt-2 text-sm text-slate-400">{font.recommended_use}</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function NovaCardGradientPicker({ gradients = [], selectedKey = null, onSelect }) {
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{gradients.map((gradient) => {
|
||||
const active = selectedKey === gradient.key
|
||||
return (
|
||||
<button
|
||||
key={gradient.key}
|
||||
type="button"
|
||||
onClick={() => onSelect?.(gradient)}
|
||||
className={`overflow-hidden rounded-[22px] border text-left transition ${active ? 'border-sky-300/35 bg-sky-400/10' : 'border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.05]'}`}
|
||||
>
|
||||
<div className="h-20 w-full" style={{ background: `linear-gradient(135deg, ${gradient.colors?.[0] || '#0f172a'}, ${gradient.colors?.[1] || '#1d4ed8'})` }} />
|
||||
<div className="p-3">
|
||||
<div className="text-sm font-semibold text-white">{gradient.label}</div>
|
||||
<div className="mt-1 text-[11px] uppercase tracking-[0.18em] text-slate-400">{gradient.key}</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
255
resources/js/components/nova-cards/NovaCardPresetPicker.jsx
Normal file
255
resources/js/components/nova-cards/NovaCardPresetPicker.jsx
Normal file
@@ -0,0 +1,255 @@
|
||||
import React from 'react'
|
||||
|
||||
const TYPE_LABELS = {
|
||||
style: 'Style',
|
||||
layout: 'Layout',
|
||||
background: 'Background',
|
||||
typography: 'Typography',
|
||||
starter: 'Starter',
|
||||
}
|
||||
|
||||
const TYPE_ICONS = {
|
||||
style: 'fa-palette',
|
||||
layout: 'fa-table-columns',
|
||||
background: 'fa-image',
|
||||
typography: 'fa-font',
|
||||
starter: 'fa-star',
|
||||
}
|
||||
|
||||
function PresetCard({ preset, onApply, onDelete, applying }) {
|
||||
return (
|
||||
<div className="group relative flex items-center gap-3 rounded-[18px] border border-white/10 bg-white/[0.03] px-3.5 py-3 transition hover:border-white/20 hover:bg-white/[0.05]">
|
||||
<button
|
||||
type="button"
|
||||
disabled={applying}
|
||||
onClick={() => onApply(preset)}
|
||||
className="flex flex-1 items-center gap-3 text-left"
|
||||
>
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-white/10 bg-white/[0.06] text-sky-300 text-xs">
|
||||
<i className={`fa-solid ${TYPE_ICONS[preset.preset_type] || 'fa-sparkles'}`} />
|
||||
</span>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block truncate text-sm font-semibold text-white">{preset.name}</span>
|
||||
<span className="block text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">
|
||||
{TYPE_LABELS[preset.preset_type] || preset.preset_type}
|
||||
{preset.is_default ? ' · Default' : ''}
|
||||
</span>
|
||||
</span>
|
||||
{applying ? (
|
||||
<i className="fa-solid fa-rotate fa-spin text-sky-300 text-xs" />
|
||||
) : (
|
||||
<i className="fa-solid fa-chevron-right text-slate-500 text-xs opacity-0 transition group-hover:opacity-100" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(preset)}
|
||||
className="ml-1 rounded-full p-1.5 text-slate-500 opacity-0 transition hover:text-rose-400 group-hover:opacity-100"
|
||||
title="Delete preset"
|
||||
>
|
||||
<i className="fa-solid fa-trash-can text-xs" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function NovaCardPresetPicker({
|
||||
presets = {},
|
||||
endpoints = {},
|
||||
cardId = null,
|
||||
onApplyPatch,
|
||||
onPresetsChange,
|
||||
activeType = null,
|
||||
}) {
|
||||
const [selectedType, setSelectedType] = React.useState(activeType || 'style')
|
||||
const [applyingId, setApplyingId] = React.useState(null)
|
||||
const [capturing, setCapturing] = React.useState(false)
|
||||
const [captureName, setCaptureName] = React.useState('')
|
||||
const [captureType, setCaptureType] = React.useState('style')
|
||||
const [showCaptureForm, setShowCaptureForm] = React.useState(false)
|
||||
const [error, setError] = React.useState(null)
|
||||
|
||||
const typeKeys = Object.keys(TYPE_LABELS)
|
||||
const listedPresets = Array.isArray(presets[selectedType]) ? presets[selectedType] : []
|
||||
|
||||
async function handleApply(preset) {
|
||||
if (!cardId || !endpoints.presetApplyPattern) return
|
||||
const url = endpoints.presetApplyPattern
|
||||
.replace('__PRESET__', preset.id)
|
||||
.replace('__CARD__', cardId)
|
||||
|
||||
setApplyingId(preset.id)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest', Accept: 'application/json' },
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data?.message || 'Failed to apply preset')
|
||||
if (data?.project_patch && onApplyPatch) {
|
||||
onApplyPatch(data.project_patch)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to apply preset')
|
||||
} finally {
|
||||
setApplyingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(preset) {
|
||||
if (!endpoints.presetDestroyPattern) return
|
||||
const url = endpoints.presetDestroyPattern.replace('__PRESET__', preset.id)
|
||||
|
||||
if (!window.confirm(`Delete preset "${preset.name}"?`)) return
|
||||
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || ''
|
||||
const res = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
},
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to delete preset')
|
||||
onPresetsChange?.()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to delete preset')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCapture(e) {
|
||||
e.preventDefault()
|
||||
if (!cardId || !endpoints.capturePresetPattern || !captureName.trim()) return
|
||||
const url = endpoints.capturePresetPattern.replace('__CARD__', cardId)
|
||||
|
||||
setCapturing(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || ''
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
},
|
||||
body: JSON.stringify({ name: captureName.trim(), preset_type: captureType }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data?.message || 'Failed to capture preset')
|
||||
setCaptureName('')
|
||||
setShowCaptureForm(false)
|
||||
onPresetsChange?.()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to capture preset')
|
||||
} finally {
|
||||
setCapturing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Type tabs */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{typeKeys.map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => setSelectedType(type)}
|
||||
className={`rounded-full border px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.16em] transition ${
|
||||
selectedType === type
|
||||
? 'border-sky-300/30 bg-sky-400/15 text-sky-100'
|
||||
: 'border-white/10 bg-white/[0.03] text-slate-300 hover:bg-white/[0.05]'
|
||||
}`}
|
||||
>
|
||||
<i className={`fa-solid ${TYPE_ICONS[type]} mr-1.5`} />
|
||||
{TYPE_LABELS[type]}
|
||||
{Array.isArray(presets[type]) && presets[type].length > 0 && (
|
||||
<span className="ml-1.5 rounded-full bg-white/10 px-1.5 text-[10px]">{presets[type].length}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Preset list */}
|
||||
{listedPresets.length > 0 ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{listedPresets.map((preset) => (
|
||||
<PresetCard
|
||||
key={preset.id}
|
||||
preset={preset}
|
||||
applying={applyingId === preset.id}
|
||||
onApply={handleApply}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-slate-500">No {TYPE_LABELS[selectedType]?.toLowerCase()} presets saved yet.</p>
|
||||
)}
|
||||
|
||||
{/* Capture from current card */}
|
||||
{cardId && endpoints.capturePresetPattern && (
|
||||
<div className="mt-1 border-t border-white/[0.06] pt-3">
|
||||
{showCaptureForm ? (
|
||||
<form onSubmit={handleCapture} className="flex flex-col gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={captureName}
|
||||
onChange={(e) => setCaptureName(e.target.value)}
|
||||
placeholder="Preset name…"
|
||||
maxLength={64}
|
||||
required
|
||||
className="w-full rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2 text-sm text-white placeholder-slate-500 outline-none focus:border-sky-400/40"
|
||||
/>
|
||||
<select
|
||||
value={captureType}
|
||||
onChange={(e) => setCaptureType(e.target.value)}
|
||||
className="w-full rounded-xl border border-white/10 bg-slate-900 px-3 py-2 text-sm text-white outline-none focus:border-sky-400/40"
|
||||
>
|
||||
{typeKeys.map((type) => (
|
||||
<option key={type} value={type}>{TYPE_LABELS[type]}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={capturing || !captureName.trim()}
|
||||
className="flex-1 rounded-xl bg-sky-500/20 py-2 text-sm font-semibold text-sky-200 transition hover:bg-sky-500/30 disabled:opacity-50"
|
||||
>
|
||||
{capturing ? 'Saving…' : 'Save preset'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCaptureForm(false)}
|
||||
className="rounded-xl border border-white/10 px-4 py-2 text-sm text-slate-400 transition hover:bg-white/[0.05]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCaptureForm(true)}
|
||||
className="flex w-full items-center gap-2 rounded-xl border border-dashed border-white/15 px-3 py-2.5 text-sm text-slate-400 transition hover:border-white/25 hover:text-slate-200"
|
||||
>
|
||||
<i className="fa-solid fa-plus text-xs" />
|
||||
Capture current as preset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="rounded-xl border border-rose-400/20 bg-rose-400/10 px-3 py-2 text-xs text-rose-300">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function NovaCardTemplatePicker({ templates = [], selectedId = null, onSelect }) {
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{templates.map((template) => {
|
||||
const active = Number(selectedId) === Number(template.id)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={template.id}
|
||||
type="button"
|
||||
onClick={() => onSelect?.(template)}
|
||||
className={`rounded-[24px] border p-4 text-left transition ${active ? 'border-sky-300/35 bg-sky-400/10 text-white' : 'border-white/10 bg-white/[0.03] text-slate-200 hover:border-white/20 hover:bg-white/[0.05]'}`}
|
||||
>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.22em] text-slate-400">Template</div>
|
||||
<div className="mt-2 text-base font-semibold tracking-[-0.03em]">{template.name}</div>
|
||||
{template.description ? <div className="mt-2 text-sm text-slate-400">{template.description}</div> : null}
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">
|
||||
{(template.supported_formats || []).map((format) => (
|
||||
<span key={`${template.id}-${format}`} className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1">
|
||||
{format}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,13 +3,15 @@ import ProfileCoverEditor from './ProfileCoverEditor'
|
||||
import LevelBadge from '../xp/LevelBadge'
|
||||
import XPProgressBar from '../xp/XPProgressBar'
|
||||
import FollowButton from '../social/FollowButton'
|
||||
import FollowersPreview from '../social/FollowersPreview'
|
||||
import MutualFollowersBadge from '../social/MutualFollowersBadge'
|
||||
|
||||
function formatCompactNumber(value) {
|
||||
const numeric = Number(value ?? 0)
|
||||
return numeric.toLocaleString()
|
||||
}
|
||||
|
||||
export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing, followerCount, heroBgUrl, countryName, leaderboardRank, extraActions = null }) {
|
||||
export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing, followerCount, recentFollowers = [], followContext = null, heroBgUrl, countryName, leaderboardRank, extraActions = null }) {
|
||||
const [following, setFollowing] = useState(viewerIsFollowing)
|
||||
const [count, setCount] = useState(followerCount)
|
||||
const [editorOpen, setEditorOpen] = useState(false)
|
||||
@@ -22,11 +24,10 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
|
||||
? new Date(user.created_at).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
|
||||
: null
|
||||
const bio = profile?.bio || profile?.about || ''
|
||||
const heroFacts = [
|
||||
const progressPercent = Math.round(Number(user?.progress_percent ?? 0))
|
||||
const heroStats = [
|
||||
{ label: 'Followers', value: formatCompactNumber(count) },
|
||||
{ label: 'Level', value: `Lv ${formatCompactNumber(user?.level ?? 1)}` },
|
||||
{ label: 'Progress', value: `${Math.round(Number(user?.progress_percent ?? 0))}%` },
|
||||
{ label: 'Member since', value: joinDate ?? 'Recently joined' },
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -99,7 +100,7 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 text-center md:text-left">
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_280px] xl:items-start">
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_430px] xl:items-start">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center justify-center gap-2 md:justify-start">
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-300">
|
||||
@@ -115,6 +116,7 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center justify-center gap-2 md:justify-start">
|
||||
<LevelBadge level={user?.level} rank={user?.rank} />
|
||||
{!isOwner ? <MutualFollowersBadge context={followContext} /> : null}
|
||||
{countryName ? (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-slate-300">
|
||||
{profile?.country_code ? (
|
||||
@@ -171,7 +173,15 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 xl:pt-1">
|
||||
<div className="flex flex-wrap items-center justify-center gap-2 xl:justify-end">
|
||||
{!isOwner && recentFollowers?.length > 0 ? (
|
||||
<FollowersPreview
|
||||
users={followContext?.follower_overlap?.users?.length ? followContext.follower_overlap.users : recentFollowers}
|
||||
label={followContext?.follower_overlap?.label || `${formatCompactNumber(followerCount)} followers`}
|
||||
href={`/@${uname}/activity`}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-center gap-2 xl:flex-nowrap xl:justify-end">
|
||||
{extraActions}
|
||||
{isOwner ? (
|
||||
<>
|
||||
@@ -198,8 +208,10 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
|
||||
username={uname}
|
||||
initialFollowing={following}
|
||||
initialCount={count}
|
||||
className="shrink-0 whitespace-nowrap"
|
||||
followingClassName="border border-emerald-400/40 bg-emerald-500/12 text-emerald-300 hover:bg-emerald-500/18"
|
||||
idleClassName="border border-sky-400/40 bg-sky-500/12 text-sky-200 hover:bg-sky-500/20"
|
||||
sizeClassName="px-3.5 py-2 text-sm"
|
||||
onChange={({ following: nextFollowing, followersCount }) => {
|
||||
setFollowing(nextFollowing)
|
||||
setCount(followersCount)
|
||||
@@ -216,7 +228,7 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
|
||||
}
|
||||
}}
|
||||
aria-label="Share profile"
|
||||
className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-2.5 text-sm font-medium text-slate-300 transition-all hover:bg-white/[0.08] hover:text-white"
|
||||
className="inline-flex shrink-0 items-center gap-2 whitespace-nowrap rounded-2xl border border-white/10 bg-white/[0.04] px-3.5 py-2 text-sm font-medium text-slate-300 transition-all hover:bg-white/[0.08] hover:text-white"
|
||||
>
|
||||
<i className="fa-solid fa-share-nodes fa-fw" />
|
||||
Share
|
||||
@@ -225,16 +237,32 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-left">
|
||||
{heroFacts.map((fact) => (
|
||||
<div
|
||||
key={fact.label}
|
||||
className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]"
|
||||
>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">{fact.label}</div>
|
||||
<div className="mt-1.5 text-sm font-semibold tracking-tight text-white md:text-base">{fact.value}</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="rounded-[24px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.72),rgba(9,17,31,0.92))] p-3 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{heroStats.map((fact) => (
|
||||
<div
|
||||
key={fact.label}
|
||||
className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2.5"
|
||||
>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">{fact.label}</div>
|
||||
<div className="mt-1 text-sm font-semibold tracking-tight text-white md:text-[15px]">{fact.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-2.5 flex flex-wrap items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-[11px] font-medium text-slate-300">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-cyan-300" />
|
||||
Progress {progressPercent}%
|
||||
</span>
|
||||
|
||||
{joinDate ? (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-[11px] font-medium text-slate-300">
|
||||
<i className="fa-solid fa-calendar-days text-[10px] text-slate-500" />
|
||||
Since {joinDate}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -246,10 +274,8 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
|
||||
</div>
|
||||
|
||||
<ProfileCoverEditor
|
||||
open={editorOpen}
|
||||
isOpen={editorOpen}
|
||||
onClose={() => setEditorOpen(false)}
|
||||
currentCoverUrl={coverUrl}
|
||||
currentPosition={coverPosition}
|
||||
coverUrl={coverUrl}
|
||||
coverPosition={coverPosition}
|
||||
onCoverUpdated={(nextUrl, nextPosition) => {
|
||||
|
||||
197
resources/js/components/profile/activity/ActivityCard.jsx
Normal file
197
resources/js/components/profile/activity/ActivityCard.jsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import React from 'react'
|
||||
|
||||
function typeMeta(type) {
|
||||
switch (type) {
|
||||
case 'upload':
|
||||
return { icon: 'fa-solid fa-image', label: 'Upload', tone: 'text-sky-200 bg-sky-400/12 border-sky-300/20' }
|
||||
case 'comment':
|
||||
return { icon: 'fa-solid fa-comment-dots', label: 'Comment', tone: 'text-amber-100 bg-amber-400/12 border-amber-300/20' }
|
||||
case 'reply':
|
||||
return { icon: 'fa-solid fa-reply', label: 'Reply', tone: 'text-orange-100 bg-orange-400/12 border-orange-300/20' }
|
||||
case 'like':
|
||||
return { icon: 'fa-solid fa-heart', label: 'Like', tone: 'text-rose-100 bg-rose-400/12 border-rose-300/20' }
|
||||
case 'favourite':
|
||||
return { icon: 'fa-solid fa-bookmark', label: 'Favourite', tone: 'text-pink-100 bg-pink-400/12 border-pink-300/20' }
|
||||
case 'follow':
|
||||
return { icon: 'fa-solid fa-user-plus', label: 'Follow', tone: 'text-emerald-100 bg-emerald-400/12 border-emerald-300/20' }
|
||||
case 'achievement':
|
||||
return { icon: 'fa-solid fa-trophy', label: 'Achievement', tone: 'text-yellow-100 bg-yellow-400/12 border-yellow-300/20' }
|
||||
case 'forum_post':
|
||||
return { icon: 'fa-solid fa-signs-post', label: 'Forum thread', tone: 'text-violet-100 bg-violet-400/12 border-violet-300/20' }
|
||||
case 'forum_reply':
|
||||
return { icon: 'fa-solid fa-comments', label: 'Forum reply', tone: 'text-indigo-100 bg-indigo-400/12 border-indigo-300/20' }
|
||||
default:
|
||||
return { icon: 'fa-solid fa-bolt', label: 'Activity', tone: 'text-slate-100 bg-white/6 border-white/10' }
|
||||
}
|
||||
}
|
||||
|
||||
function profileName(actor) {
|
||||
if (!actor) return 'Creator'
|
||||
return actor.username ? `@${actor.username}` : actor.name || 'Creator'
|
||||
}
|
||||
|
||||
function headline(activity) {
|
||||
switch (activity?.type) {
|
||||
case 'upload':
|
||||
return activity?.artwork?.title ? `Uploaded ${activity.artwork.title}` : 'Uploaded new artwork'
|
||||
case 'comment':
|
||||
return activity?.artwork?.title ? `Commented on ${activity.artwork.title}` : 'Posted a new comment'
|
||||
case 'reply':
|
||||
return activity?.artwork?.title ? `Replied on ${activity.artwork.title}` : 'Posted a reply'
|
||||
case 'like':
|
||||
return activity?.artwork?.title ? `Liked ${activity.artwork.title}` : 'Liked an artwork'
|
||||
case 'favourite':
|
||||
return activity?.artwork?.title ? `Favourited ${activity.artwork.title}` : 'Saved an artwork'
|
||||
case 'follow':
|
||||
return activity?.target_user ? `Started following @${activity.target_user.username || activity.target_user.name}` : 'Started following a creator'
|
||||
case 'achievement':
|
||||
return activity?.achievement?.name ? `Unlocked ${activity.achievement.name}` : 'Unlocked a new achievement'
|
||||
case 'forum_post':
|
||||
return activity?.forum?.thread?.title ? `Started forum thread ${activity.forum.thread.title}` : 'Started a new forum thread'
|
||||
case 'forum_reply':
|
||||
return activity?.forum?.thread?.title ? `Replied in ${activity.forum.thread.title}` : 'Posted a forum reply'
|
||||
default:
|
||||
return 'Shared new activity'
|
||||
}
|
||||
}
|
||||
|
||||
function body(activity) {
|
||||
if (activity?.comment?.body) return activity.comment.body
|
||||
if (activity?.forum?.post?.excerpt) return activity.forum.post.excerpt
|
||||
if (activity?.achievement?.description) return activity.achievement.description
|
||||
return ''
|
||||
}
|
||||
|
||||
function cta(activity) {
|
||||
if (activity?.comment?.url) return { href: activity.comment.url, label: 'Open comment' }
|
||||
if (activity?.artwork?.url) return { href: activity.artwork.url, label: 'View artwork' }
|
||||
if (activity?.forum?.post?.url) return { href: activity.forum.post.url, label: 'Open reply' }
|
||||
if (activity?.forum?.thread?.url) return { href: activity.forum.thread.url, label: 'Open thread' }
|
||||
if (activity?.target_user?.profile_url) return { href: activity.target_user.profile_url, label: 'View profile' }
|
||||
return null
|
||||
}
|
||||
|
||||
function AchievementIcon({ achievement }) {
|
||||
const raw = String(achievement?.icon || '').trim()
|
||||
const className = raw.startsWith('fa-') ? raw : `fa-solid ${raw || 'fa-trophy'}`
|
||||
|
||||
return (
|
||||
<div className="inline-flex h-12 w-12 items-center justify-center rounded-2xl border border-yellow-300/20 bg-yellow-400/12 text-yellow-100">
|
||||
<i className={className} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ActivityCard({ activity }) {
|
||||
const meta = typeMeta(activity?.type)
|
||||
const nextAction = cta(activity)
|
||||
const copy = body(activity)
|
||||
|
||||
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-5 shadow-[0_18px_45px_rgba(0,0,0,0.28)] backdrop-blur-xl">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start">
|
||||
<div className="flex items-start gap-4 md:w-[17rem] md:shrink-0">
|
||||
<div className="relative h-14 w-14 shrink-0 overflow-hidden rounded-[20px] border border-white/10 bg-slate-950/70">
|
||||
{activity?.actor?.avatar_url ? (
|
||||
<img src={activity.actor.avatar_url} alt={profileName(activity.actor)} className="h-full w-full object-cover" loading="lazy" />
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-slate-500">
|
||||
<i className="fa-solid fa-user" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-white">{profileName(activity.actor)}</div>
|
||||
{activity?.actor?.badge?.label ? (
|
||||
<div className="mt-1 inline-flex items-center rounded-full border border-white/10 bg-white/[0.04] px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-300">
|
||||
{activity.actor.badge.label}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-2 text-xs uppercase tracking-[0.18em] text-slate-500">{activity?.time_ago || ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={`inline-flex items-center gap-2 rounded-full border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] ${meta.tone}`}>
|
||||
<i className={meta.icon} />
|
||||
{meta.label}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="mt-3 text-lg font-semibold tracking-[-0.02em] text-white">{headline(activity)}</h3>
|
||||
{copy ? <p className="mt-2 max-w-3xl text-sm leading-7 text-slate-400">{copy}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-slate-500 md:text-right">{activity?.created_at ? new Date(activity.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : ''}</div>
|
||||
</div>
|
||||
|
||||
{activity?.artwork ? (
|
||||
<div className="mt-4 flex items-center gap-3 rounded-2xl border border-white/[0.06] bg-white/[0.03] p-3">
|
||||
{activity.artwork.thumb ? (
|
||||
<img src={activity.artwork.thumb} alt={activity.artwork.title} className="h-16 w-16 rounded-2xl object-cover ring-1 ring-white/10" loading="lazy" />
|
||||
) : (
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.04] text-slate-500">
|
||||
<i className="fa-solid fa-image" />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-white">{activity.artwork.title}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-3 text-xs text-slate-400">
|
||||
<span>{Number(activity.artwork.stats?.likes || 0).toLocaleString()} likes</span>
|
||||
<span>{Number(activity.artwork.stats?.views || 0).toLocaleString()} views</span>
|
||||
<span>{Number(activity.artwork.stats?.comments || 0).toLocaleString()} comments</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activity?.target_user ? (
|
||||
<div className="mt-4 flex items-center gap-3 rounded-2xl border border-white/[0.06] bg-white/[0.03] p-3">
|
||||
<div className="h-12 w-12 overflow-hidden rounded-2xl border border-white/10 bg-slate-950/70">
|
||||
{activity.target_user.avatar_url ? (
|
||||
<img src={activity.target_user.avatar_url} alt={activity.target_user.username || activity.target_user.name} className="h-full w-full object-cover" loading="lazy" />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Target creator</div>
|
||||
<div className="mt-1 text-sm font-medium text-white">@{activity.target_user.username || activity.target_user.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activity?.achievement ? (
|
||||
<div className="mt-4 flex items-center gap-3 rounded-2xl border border-white/[0.06] bg-white/[0.03] p-3">
|
||||
<AchievementIcon achievement={activity.achievement} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Achievement unlocked</div>
|
||||
<div className="mt-1 text-sm font-medium text-white">{activity.achievement.name}</div>
|
||||
{activity.achievement.description ? <div className="mt-1 text-sm text-slate-400">{activity.achievement.description}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activity?.forum?.thread ? (
|
||||
<div className="mt-4 rounded-2xl border border-white/[0.06] bg-white/[0.03] p-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Forum activity</div>
|
||||
<div className="mt-1 text-sm font-medium text-white">{activity.forum.thread.title}</div>
|
||||
<div className="mt-2 text-xs text-slate-400">{activity.forum.thread.category_name}</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{nextAction ? (
|
||||
<a
|
||||
href={nextAction.href}
|
||||
className="mt-4 inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-3.5 py-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-200 transition hover:border-white/20 hover:bg-white/[0.1] hover:text-white"
|
||||
>
|
||||
{nextAction.label}
|
||||
<i className="fa-solid fa-arrow-right text-[10px]" />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
63
resources/js/components/profile/activity/ActivityFeed.jsx
Normal file
63
resources/js/components/profile/activity/ActivityFeed.jsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React from 'react'
|
||||
import ActivityCard from './ActivityCard'
|
||||
|
||||
export default function ActivityFeed({ activities, loading, loadingMore, error, sentinelRef }) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="rounded-[28px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(10,16,26,0.92),rgba(6,10,18,0.88))] p-6 text-sm text-slate-400 shadow-[0_20px_60px_rgba(0,0,0,0.25)]">
|
||||
Loading activity...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-[28px] border border-rose-300/20 bg-rose-500/10 p-6 text-sm text-rose-100 shadow-[0_20px_60px_rgba(0,0,0,0.25)]">
|
||||
{error}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!activities.length) {
|
||||
return (
|
||||
<div className="rounded-[28px] border border-dashed border-white/10 bg-[linear-gradient(180deg,rgba(10,16,26,0.92),rgba(6,10,18,0.88))] px-6 py-12 text-center shadow-[0_20px_60px_rgba(0,0,0,0.25)]">
|
||||
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-3xl border border-white/10 bg-white/[0.04] text-sky-300">
|
||||
<i className="fa-solid fa-wave-square text-xl" />
|
||||
</div>
|
||||
<h3 className="mt-5 text-lg font-semibold text-white">No activity yet</h3>
|
||||
<p className="mx-auto mt-2 max-w-lg text-sm leading-7 text-slate-400">
|
||||
Upload artwork, join a conversation, follow creators, or post in the forum to start building this profile timeline.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex flex-wrap justify-center gap-3">
|
||||
<a href="/upload" className="inline-flex items-center gap-2 rounded-full border border-sky-300/30 bg-sky-400/12 px-4 py-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100 transition hover:border-sky-200/50 hover:bg-sky-400/18">
|
||||
<i className="fa-solid fa-upload" />
|
||||
Upload artwork
|
||||
</a>
|
||||
<a href="/uploads/latest" className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-200 transition hover:border-white/20 hover:bg-white/[0.1] hover:text-white">
|
||||
<i className="fa-solid fa-comment-dots" />
|
||||
Comment on artwork
|
||||
</a>
|
||||
<a href="/discover/trending" className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-200 transition hover:border-white/20 hover:bg-white/[0.1] hover:text-white">
|
||||
<i className="fa-solid fa-user-plus" />
|
||||
Follow creators
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{activities.map((activity) => (
|
||||
<ActivityCard key={activity.id} activity={activity} />
|
||||
))}
|
||||
|
||||
<div ref={sentinelRef} className="h-12" aria-hidden="true" />
|
||||
|
||||
{loadingMore ? (
|
||||
<div className="text-center text-sm text-slate-400">Loading more activity...</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
36
resources/js/components/profile/activity/ActivityFilters.jsx
Normal file
36
resources/js/components/profile/activity/ActivityFilters.jsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from 'react'
|
||||
|
||||
const FILTERS = [
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'uploads', label: 'Uploads' },
|
||||
{ key: 'comments', label: 'Comments' },
|
||||
{ key: 'likes', label: 'Likes' },
|
||||
{ key: 'forum', label: 'Forum' },
|
||||
{ key: 'following', label: 'Following' },
|
||||
]
|
||||
|
||||
export default function ActivityFilters({ activeFilter, onChange }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{FILTERS.map((filter) => {
|
||||
const active = activeFilter === filter.key
|
||||
|
||||
return (
|
||||
<button
|
||||
key={filter.key}
|
||||
type="button"
|
||||
onClick={() => onChange(filter.key)}
|
||||
className={[
|
||||
'inline-flex items-center rounded-full border px-3.5 py-2 text-[11px] font-semibold uppercase tracking-[0.18em] transition-all',
|
||||
active
|
||||
? 'border-sky-300/35 bg-sky-400/14 text-sky-100 shadow-[0_0_0_1px_rgba(125,211,252,0.1)]'
|
||||
: 'border-white/10 bg-white/[0.04] text-slate-300 hover:border-white/20 hover:bg-white/[0.08] hover:text-white',
|
||||
].join(' ')}
|
||||
>
|
||||
{filter.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
138
resources/js/components/profile/activity/ActivityTab.jsx
Normal file
138
resources/js/components/profile/activity/ActivityTab.jsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import ActivityFeed from './ActivityFeed'
|
||||
import ActivityFilters from './ActivityFilters'
|
||||
|
||||
function endpointForUser(user) {
|
||||
return `/api/profile/${encodeURIComponent(user.username || user.name || '')}/activity`
|
||||
}
|
||||
|
||||
export default function ActivityTab({ user }) {
|
||||
const [activeFilter, setActiveFilter] = useState('all')
|
||||
const [activities, setActivities] = useState([])
|
||||
const [meta, setMeta] = useState({ current_page: 1, has_more: false, total: 0 })
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const requestIdRef = useRef(0)
|
||||
const sentinelRef = useRef(null)
|
||||
|
||||
const fetchFeed = useCallback(async ({ filter, page, append }) => {
|
||||
const requestId = requestIdRef.current + 1
|
||||
requestIdRef.current = requestId
|
||||
|
||||
if (append) {
|
||||
setLoadingMore(true)
|
||||
} else {
|
||||
setLoading(true)
|
||||
}
|
||||
|
||||
try {
|
||||
setError('')
|
||||
const params = new URLSearchParams({
|
||||
filter,
|
||||
page: String(page),
|
||||
per_page: '20',
|
||||
})
|
||||
|
||||
const response = await fetch(`${endpointForUser(user)}?${params.toString()}`, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load profile activity.')
|
||||
}
|
||||
|
||||
const payload = await response.json()
|
||||
if (requestId !== requestIdRef.current) return
|
||||
|
||||
setActivities((current) => append ? [...current, ...(payload.data || [])] : (payload.data || []))
|
||||
setMeta(payload.meta || { current_page: page, has_more: false, total: 0 })
|
||||
} catch {
|
||||
if (requestId === requestIdRef.current) {
|
||||
setError('Could not load this activity timeline right now.')
|
||||
}
|
||||
} finally {
|
||||
if (requestId === requestIdRef.current) {
|
||||
setLoading(false)
|
||||
setLoadingMore(false)
|
||||
}
|
||||
}
|
||||
}, [user])
|
||||
|
||||
useEffect(() => {
|
||||
fetchFeed({ filter: activeFilter, page: 1, append: false })
|
||||
}, [activeFilter, fetchFeed])
|
||||
|
||||
const hasMore = Boolean(meta?.has_more)
|
||||
const nextPage = Number(meta?.current_page || 1) + 1
|
||||
|
||||
useEffect(() => {
|
||||
const sentinel = sentinelRef.current
|
||||
if (!sentinel || loading || loadingMore || !hasMore || !('IntersectionObserver' in window)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
const [entry] = entries
|
||||
if (entry?.isIntersecting) {
|
||||
fetchFeed({ filter: activeFilter, page: nextPage, append: true })
|
||||
}
|
||||
}, { rootMargin: '240px 0px' })
|
||||
|
||||
observer.observe(sentinel)
|
||||
return () => observer.disconnect()
|
||||
}, [activeFilter, fetchFeed, hasMore, loading, loadingMore, nextPage])
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const total = Number(meta?.total || activities.length || 0)
|
||||
return total ? `${total.toLocaleString()} recent actions` : 'No recent actions'
|
||||
}, [activities.length, meta?.total])
|
||||
|
||||
return (
|
||||
<div
|
||||
id="tabpanel-activity"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-activity"
|
||||
className="mx-auto max-w-7xl px-4 pt-4 pb-10 md:px-6"
|
||||
>
|
||||
<div className="rounded-[32px] border border-white/[0.06] bg-[linear-gradient(135deg,rgba(56,189,248,0.14),rgba(10,16,26,0.94),rgba(249,115,22,0.08))] p-5 shadow-[0_22px_70px_rgba(0,0,0,0.26)] md:p-6">
|
||||
<div className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="max-w-3xl">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/75">Activity</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white md:text-3xl">Recent actions and contributions</h2>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300 md:text-[15px]">
|
||||
A living timeline of uploads, discussions, follows, achievements, and forum participation from {user.username || user.name}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="self-start rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-300">
|
||||
{summary}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<ActivityFilters activeFilter={activeFilter} onChange={setActiveFilter} />
|
||||
<div className="flex flex-wrap gap-2 text-xs text-slate-400">
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-2">
|
||||
<i className="fa-solid fa-bolt text-sky-300" />
|
||||
Timeline updates automatically as new actions are logged
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<ActivityFeed
|
||||
activities={activities}
|
||||
loading={loading}
|
||||
loadingMore={loadingMore}
|
||||
error={error}
|
||||
sentinelRef={sentinelRef}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
277
resources/js/components/profile/collections/CollectionCard.jsx
Normal file
277
resources/js/components/profile/collections/CollectionCard.jsx
Normal file
@@ -0,0 +1,277 @@
|
||||
import React from 'react'
|
||||
import CollectionVisibilityBadge from './CollectionVisibilityBadge'
|
||||
|
||||
async function requestJson(url, { method = 'GET', body } = {}) {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
const message = payload?.message || 'Request failed.'
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
function formatUpdated(value) {
|
||||
if (!value) return 'Updated recently'
|
||||
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return 'Updated recently'
|
||||
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
function StatPill({ icon, label, value }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/[0.05] px-2.5 py-1 text-[11px] font-medium text-slate-300">
|
||||
<i className={`fa-solid ${icon} text-[10px] text-slate-400`} />
|
||||
<span className="text-white">{value}</span>
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CollectionCard({ collection, isOwner, onDelete, onToggleFeature, onMoveUp, onMoveDown, canMoveUp, canMoveDown, busy = false, saveContext = null, saveContextMeta = null }) {
|
||||
const coverImage = collection?.cover_image
|
||||
const [saved, setSaved] = React.useState(Boolean(collection?.saved))
|
||||
const [saveBusy, setSaveBusy] = React.useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
setSaved(Boolean(collection?.saved))
|
||||
setSaveBusy(false)
|
||||
}, [collection?.id, collection?.saved])
|
||||
|
||||
function handleDelete(event) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onDelete?.(collection)
|
||||
}
|
||||
|
||||
function stop(event) {
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
function handleToggleFeature(event) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onToggleFeature?.(collection)
|
||||
}
|
||||
|
||||
async function handleSaveToggle(event) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
if (saveBusy) return
|
||||
|
||||
const targetUrl = saved ? collection?.unsave_url : collection?.save_url
|
||||
if (!targetUrl) {
|
||||
if (collection?.login_url) {
|
||||
window.location.assign(collection.login_url)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setSaveBusy(true)
|
||||
|
||||
try {
|
||||
const payload = await requestJson(targetUrl, {
|
||||
method: saved ? 'DELETE' : 'POST',
|
||||
body: saved ? undefined : {
|
||||
context: saveContext,
|
||||
context_meta: saveContextMeta || undefined,
|
||||
},
|
||||
})
|
||||
|
||||
setSaved(Boolean(payload?.saved))
|
||||
} catch (error) {
|
||||
window.console?.error?.(error)
|
||||
} finally {
|
||||
setSaveBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={collection?.url || '#'}
|
||||
className={`group relative overflow-hidden rounded-[28px] border border-white/10 bg-white/[0.04] shadow-[0_22px_60px_rgba(2,6,23,0.22)] transition-all duration-200 hover:-translate-y-1 hover:border-sky-300/30 hover:bg-white/[0.06] ${busy ? 'opacity-70' : ''} lg:max-w-[360px] lg:mx-auto`}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.12),transparent_34%),radial-gradient(circle_at_bottom_right,rgba(249,115,22,0.12),transparent_28%)] opacity-0 transition duration-300 group-hover:opacity-100" />
|
||||
<div className="relative">
|
||||
{coverImage ? (
|
||||
<div className="aspect-[16/10] overflow-hidden bg-slate-950">
|
||||
<img
|
||||
src={coverImage}
|
||||
alt={collection?.title || 'Collection cover'}
|
||||
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.04]"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex aspect-[16/10] items-center justify-center bg-[linear-gradient(135deg,rgba(8,17,31,1),rgba(15,23,42,1),rgba(8,17,31,1))] text-slate-500">
|
||||
<i className="fa-solid fa-layer-group text-4xl" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-5">
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
{collection?.is_featured ? (
|
||||
<span className="inline-flex items-center rounded-full border border-amber-300/25 bg-amber-300/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-100">
|
||||
Featured
|
||||
</span>
|
||||
) : null}
|
||||
{collection?.mode === 'smart' ? (
|
||||
<span className="inline-flex items-center rounded-full border border-sky-300/25 bg-sky-400/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-sky-100">
|
||||
Smart
|
||||
</span>
|
||||
) : null}
|
||||
{!isOwner && collection?.program_key ? (
|
||||
<span className="inline-flex items-center rounded-full border border-lime-300/25 bg-lime-400/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-lime-100">
|
||||
Program · {collection.program_key}
|
||||
</span>
|
||||
) : null}
|
||||
{!isOwner && collection?.partner_label ? (
|
||||
<span className="inline-flex items-center rounded-full border border-white/10 bg-white/[0.05] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-200">
|
||||
Partner · {collection.partner_label}
|
||||
</span>
|
||||
) : null}
|
||||
{!isOwner && collection?.sponsorship_label ? (
|
||||
<span className="inline-flex items-center rounded-full border border-amber-300/20 bg-amber-300/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-100">
|
||||
Sponsor · {collection.sponsorship_label}
|
||||
</span>
|
||||
) : null}
|
||||
{isOwner ? <CollectionVisibilityBadge visibility={collection?.visibility} /> : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-lg font-semibold tracking-[-0.03em] text-white">{collection?.title}</h3>
|
||||
{collection?.subtitle ? <p className="mt-1 truncate text-sm text-slate-300">{collection.subtitle}</p> : null}
|
||||
{collection?.owner?.name ? <p className="mt-1 truncate text-sm text-slate-400">Curated by {collection.owner.name}{collection?.owner?.username ? ` • @${collection.owner.username}` : ''}</p> : null}
|
||||
<p className="mt-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">
|
||||
{(collection?.artworks_count ?? 0).toLocaleString()} artworks
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{collection?.description_excerpt ? (
|
||||
<p className="mt-3 line-clamp-2 text-sm leading-relaxed text-slate-300">{collection.description_excerpt}</p>
|
||||
) : collection?.smart_summary ? (
|
||||
<p className="mt-3 line-clamp-2 text-sm leading-relaxed text-slate-300">{collection.smart_summary}</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<StatPill icon="fa-heart" label="likes" value={(collection?.likes_count ?? 0).toLocaleString()} />
|
||||
<StatPill icon="fa-bell" label="followers" value={(collection?.followers_count ?? 0).toLocaleString()} />
|
||||
{collection?.collaborators_count > 1 ? <StatPill icon="fa-user-group" label="curators" value={(collection?.collaborators_count ?? 0).toLocaleString()} /> : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between gap-3 text-xs text-slate-400">
|
||||
<span>{collection?.is_featured ? 'Featured' : 'Updated'} {formatUpdated(collection?.featured_at || collection?.updated_at)}</span>
|
||||
<div className="flex items-center gap-2" onClick={stop}>
|
||||
{!isOwner && (collection?.save_url || collection?.unsave_url || collection?.login_url) ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveToggle}
|
||||
disabled={saveBusy}
|
||||
className={`inline-flex items-center gap-2 rounded-full border px-3 py-2 text-[11px] font-semibold uppercase tracking-[0.14em] transition ${saved ? 'border-violet-300/20 bg-violet-400/10 text-violet-100 hover:bg-violet-400/15' : 'border-white/10 bg-white/[0.04] text-white hover:bg-white/[0.08]'} disabled:opacity-60`}
|
||||
>
|
||||
<i className={`fa-solid ${saveBusy ? 'fa-circle-notch fa-spin' : (saved ? 'fa-bookmark' : 'fa-bookmark')} text-[11px]`} />
|
||||
{saved ? 'Saved' : 'Save'}
|
||||
</button>
|
||||
) : null}
|
||||
<span className="inline-flex items-center gap-1 text-slate-200">
|
||||
<i className="fa-solid fa-arrow-right text-[11px]" />
|
||||
Open
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isOwner ? (
|
||||
<div className="mt-4 flex flex-wrap gap-2" onClick={stop}>
|
||||
{collection?.visibility === 'public' ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleFeature}
|
||||
className={`inline-flex items-center gap-2 rounded-xl border px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] transition ${collection?.is_featured ? 'border-amber-300/25 bg-amber-300/10 text-amber-100 hover:bg-amber-300/15' : 'border-sky-300/20 bg-sky-400/10 text-sky-100 hover:bg-sky-400/15'}`}
|
||||
>
|
||||
<i className={`fa-solid ${collection?.is_featured ? 'fa-star' : 'fa-sparkles'} fa-fw`} />
|
||||
{collection?.is_featured ? 'Featured' : 'Feature'}
|
||||
</button>
|
||||
) : null}
|
||||
<a
|
||||
href={collection?.edit_url || collection?.manage_url || '#'}
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-white/12 bg-white/[0.05] px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-slate-100 transition hover:bg-white/[0.09]"
|
||||
>
|
||||
<i className="fa-solid fa-pen-to-square fa-fw" />
|
||||
Edit
|
||||
</a>
|
||||
<a
|
||||
href={collection?.manage_url || collection?.edit_url || '#'}
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-sky-300/20 bg-sky-400/10 px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-sky-100 transition hover:bg-sky-400/15"
|
||||
>
|
||||
<i className="fa-solid fa-grip fa-fw" />
|
||||
Manage Artworks
|
||||
</a>
|
||||
{onMoveUp ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canMoveUp}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onMoveUp(collection)
|
||||
}}
|
||||
className={`inline-flex items-center gap-2 rounded-xl border px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] ${canMoveUp ? 'border-white/12 bg-white/[0.05] text-white hover:bg-white/[0.09]' : 'border-white/8 bg-white/[0.03] text-slate-500'}`}
|
||||
>
|
||||
<i className="fa-solid fa-arrow-up fa-fw" />
|
||||
Up
|
||||
</button>
|
||||
) : null}
|
||||
{onMoveDown ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canMoveDown}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onMoveDown(collection)
|
||||
}}
|
||||
className={`inline-flex items-center gap-2 rounded-xl border px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] ${canMoveDown ? 'border-white/12 bg-white/[0.05] text-white hover:bg-white/[0.09]' : 'border-white/8 bg-white/[0.03] text-slate-500'}`}
|
||||
>
|
||||
<i className="fa-solid fa-arrow-down fa-fw" />
|
||||
Down
|
||||
</button>
|
||||
) : null}
|
||||
{collection?.delete_url ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-rose-400/20 bg-rose-400/10 px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-rose-100 transition hover:bg-rose-400/15"
|
||||
>
|
||||
<i className="fa-solid fa-trash-can fa-fw" />
|
||||
Delete
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function CollectionEmptyState({ isOwner, createUrl }) {
|
||||
const smartUrl = createUrl ? `${createUrl}?mode=smart` : null
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-[28px] border border-white/10 bg-[linear-gradient(135deg,rgba(56,189,248,0.08),rgba(255,255,255,0.04),rgba(249,115,22,0.1))] px-6 py-14 text-center shadow-[0_26px_80px_rgba(2,6,23,0.28)]">
|
||||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.12),transparent_32%),radial-gradient(circle_at_bottom_right,rgba(249,115,22,0.12),transparent_30%)]" />
|
||||
<div className="relative mx-auto max-w-xl">
|
||||
<div className="mx-auto mb-5 flex h-20 w-20 items-center justify-center rounded-[24px] border border-white/12 bg-white/[0.06] text-sky-200 shadow-[0_18px_40px_rgba(2,6,23,0.28)]">
|
||||
<i className="fa-solid fa-layer-group text-3xl" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold tracking-[-0.03em] text-white">
|
||||
{isOwner ? 'Create your first collection' : 'No public collections yet'}
|
||||
</h3>
|
||||
<p className="mx-auto mt-3 max-w-md text-sm leading-relaxed text-slate-300">
|
||||
{isOwner
|
||||
? 'Collections turn your gallery into intentional showcases. Build them manually or let smart rules keep them fresh from your own artwork library.'
|
||||
: 'This creator has not published any collections.'}
|
||||
</p>
|
||||
{isOwner && createUrl ? (
|
||||
<div className="mt-6 flex flex-wrap items-center justify-center gap-3">
|
||||
<a
|
||||
href={createUrl}
|
||||
className="inline-flex items-center gap-2 rounded-2xl border border-sky-300/25 bg-sky-400/10 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15"
|
||||
>
|
||||
<i className="fa-solid fa-plus fa-fw" />
|
||||
Create Manual Collection
|
||||
</a>
|
||||
<a
|
||||
href={smartUrl || createUrl}
|
||||
className="inline-flex items-center gap-2 rounded-2xl border border-white/12 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]"
|
||||
>
|
||||
<i className="fa-solid fa-wand-magic-sparkles fa-fw" />
|
||||
Create Smart Collection
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
{isOwner ? <p className="mx-auto mt-6 max-w-lg text-xs uppercase tracking-[0.18em] text-slate-400">Examples: Featured wallpapers, best of 2026, cyberpunk studies, blue neon universe</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react'
|
||||
|
||||
const STYLES = {
|
||||
public: 'border-emerald-400/25 bg-emerald-400/10 text-emerald-100',
|
||||
unlisted: 'border-amber-300/25 bg-amber-300/10 text-amber-100',
|
||||
private: 'border-white/15 bg-white/6 text-slate-200',
|
||||
}
|
||||
|
||||
const LABELS = {
|
||||
public: 'Public',
|
||||
unlisted: 'Unlisted',
|
||||
private: 'Private',
|
||||
}
|
||||
|
||||
export default function CollectionVisibilityBadge({ visibility, className = '' }) {
|
||||
const value = String(visibility || 'public').toLowerCase()
|
||||
const label = LABELS[value] || 'Public'
|
||||
const style = STYLES[value] || STYLES.public
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] ${style} ${className}`.trim()}>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,155 +1,6 @@
|
||||
import React, { useRef, useState } from 'react'
|
||||
import LevelBadge from '../../xp/LevelBadge'
|
||||
import React from 'react'
|
||||
import ActivityTab from '../activity/ActivityTab'
|
||||
|
||||
const DEFAULT_AVATAR = 'https://files.skinbase.org/default/avatar_default.webp'
|
||||
|
||||
function CommentItem({ comment }) {
|
||||
return (
|
||||
<div className="flex gap-3 py-4 border-b border-white/5 last:border-0">
|
||||
<a href={comment.author_profile_url} className="shrink-0 mt-0.5">
|
||||
<img
|
||||
src={comment.author_avatar || DEFAULT_AVATAR}
|
||||
alt={comment.author_name}
|
||||
className="w-9 h-9 rounded-xl object-cover ring-1 ring-white/10"
|
||||
onError={(e) => { e.target.src = DEFAULT_AVATAR }}
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<a
|
||||
href={comment.author_profile_url}
|
||||
className="text-sm font-semibold text-slate-200 hover:text-white transition-colors"
|
||||
>
|
||||
{comment.author_name}
|
||||
</a>
|
||||
<LevelBadge level={comment.author_level} rank={comment.author_rank} compact />
|
||||
<span className="text-slate-600 text-xs ml-auto whitespace-nowrap">
|
||||
{(() => {
|
||||
try {
|
||||
const d = new Date(comment.created_at)
|
||||
const diff = Date.now() - d.getTime()
|
||||
const mins = Math.floor(diff / 60000)
|
||||
if (mins < 1) return 'just now'
|
||||
if (mins < 60) return `${mins}m ago`
|
||||
const hrs = Math.floor(mins / 60)
|
||||
if (hrs < 24) return `${hrs}h ago`
|
||||
const days = Math.floor(hrs / 24)
|
||||
if (days < 30) return `${days}d ago`
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
} catch { return '' }
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-400 leading-relaxed break-words whitespace-pre-line">
|
||||
{comment.body}
|
||||
</p>
|
||||
{comment.author_signature && (
|
||||
<p className="text-xs text-slate-600 mt-2 italic border-t border-white/5 pt-1 truncate">
|
||||
{comment.author_signature}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* TabActivity
|
||||
* Profile comments list + comment form for authenticated visitors.
|
||||
* Also acts as "Activity" tab.
|
||||
*/
|
||||
export default function TabActivity({ profileComments, user, isOwner, isLoggedIn }) {
|
||||
const uname = user.username || user.name
|
||||
const formRef = useRef(null)
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
|
||||
return (
|
||||
<div
|
||||
id="tabpanel-activity"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-activity"
|
||||
className="pt-6 max-w-2xl"
|
||||
>
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-500 mb-4 flex items-center gap-2">
|
||||
<i className="fa-solid fa-comments text-orange-400 fa-fw" />
|
||||
Comments
|
||||
{profileComments?.length > 0 && (
|
||||
<span className="ml-1 px-1.5 py-0.5 rounded bg-white/5 text-slate-400 font-normal text-[11px]">
|
||||
{profileComments.length}
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
|
||||
{/* Comments list */}
|
||||
<div className="bg-white/4 ring-1 ring-white/10 rounded-2xl p-5 shadow-xl shadow-black/20 mb-5">
|
||||
{!profileComments?.length ? (
|
||||
<p className="text-slate-500 text-sm text-center py-8">
|
||||
No comments yet. Be the first to leave one!
|
||||
</p>
|
||||
) : (
|
||||
<div>
|
||||
{profileComments.map((comment) => (
|
||||
<CommentItem key={comment.id} comment={comment} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Comment form */}
|
||||
{!isOwner && (
|
||||
<div className="bg-white/4 ring-1 ring-white/10 rounded-2xl p-5 shadow-xl shadow-black/20">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-widest text-slate-500 mb-4 flex items-center gap-2">
|
||||
<i className="fa-solid fa-pen text-sky-400 fa-fw" />
|
||||
Write a Comment
|
||||
</h3>
|
||||
|
||||
{isLoggedIn ? (
|
||||
submitted ? (
|
||||
<div className="flex items-center gap-2 text-green-400 text-sm p-3 rounded-xl bg-green-500/10 ring-1 ring-green-500/20">
|
||||
<i className="fa-solid fa-check fa-fw" />
|
||||
Your comment has been posted!
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
ref={formRef}
|
||||
method="POST"
|
||||
action={`/@${uname.toLowerCase()}/comment`}
|
||||
onSubmit={() => setSubmitted(false)}
|
||||
>
|
||||
<input type="hidden" name="_token" value={
|
||||
(() => document.querySelector('meta[name="csrf-token"]')?.content ?? '')()
|
||||
} />
|
||||
<textarea
|
||||
name="body"
|
||||
rows={4}
|
||||
required
|
||||
minLength={2}
|
||||
maxLength={2000}
|
||||
placeholder={`Write a comment for ${uname}…`}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2.5 text-sm text-slate-200 placeholder:text-slate-600 resize-none focus:outline-none focus:ring-2 focus:ring-sky-400/40 focus:border-sky-400/30 transition-all"
|
||||
/>
|
||||
<div className="mt-3 flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl bg-sky-600 hover:bg-sky-500 text-white text-sm font-semibold transition-all shadow-lg shadow-sky-900/30"
|
||||
>
|
||||
<i className="fa-solid fa-paper-plane fa-fw" />
|
||||
Post Comment
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
) : (
|
||||
<p className="text-sm text-slate-400 text-center py-4">
|
||||
<a href="/login" className="text-sky-400 hover:text-sky-300 hover:underline transition-colors">
|
||||
Log in
|
||||
</a>
|
||||
{' '}to leave a comment.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
export default function TabActivity({ user }) {
|
||||
return <ActivityTab user={user} />
|
||||
}
|
||||
|
||||
@@ -1,49 +1,138 @@
|
||||
import React from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import CollectionCard from '../collections/CollectionCard'
|
||||
import CollectionEmptyState from '../collections/CollectionEmptyState'
|
||||
|
||||
/**
|
||||
* TabCollections
|
||||
* Collections feature placeholder.
|
||||
*/
|
||||
export default function TabCollections({ collections }) {
|
||||
if (collections?.length > 0) {
|
||||
return (
|
||||
<div
|
||||
id="tabpanel-collections"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-collections"
|
||||
className="pt-6"
|
||||
>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{collections.map((col) => (
|
||||
<div
|
||||
key={col.id}
|
||||
className="bg-white/4 ring-1 ring-white/10 rounded-2xl overflow-hidden group hover:ring-sky-400/30 transition-all cursor-pointer shadow-xl shadow-black/20"
|
||||
>
|
||||
{col.cover_image ? (
|
||||
<div className="aspect-video overflow-hidden bg-black/30">
|
||||
<img
|
||||
src={col.cover_image}
|
||||
alt={col.title}
|
||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="aspect-video bg-white/5 flex items-center justify-center text-slate-600">
|
||||
<i className="fa-solid fa-layer-group text-3xl" />
|
||||
</div>
|
||||
)}
|
||||
<div className="p-4">
|
||||
<h3 className="font-semibold text-white truncate">{col.title}</h3>
|
||||
<p className="text-sm text-slate-500 mt-0.5">{col.items_count ?? 0} artworks</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
function getCsrfToken() {
|
||||
if (typeof document === 'undefined') return ''
|
||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
||||
}
|
||||
|
||||
async function deleteCollection(url) {
|
||||
const response = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': getCsrfToken(),
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.message || 'Unable to delete collection.')
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
async function requestJson(url, { method = 'POST', body } = {}) {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': getCsrfToken(),
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.message || 'Unable to update collection presentation.')
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
const FILTERS = ['all', 'featured', 'smart', 'manual']
|
||||
|
||||
export default function TabCollections({ collections, isOwner, createUrl, reorderUrl, featuredUrl, featureLimit = 3 }) {
|
||||
const [items, setItems] = useState(Array.isArray(collections) ? collections : [])
|
||||
const [busyId, setBusyId] = useState(null)
|
||||
const [filter, setFilter] = useState('all')
|
||||
|
||||
useEffect(() => {
|
||||
setItems(Array.isArray(collections) ? collections : [])
|
||||
}, [collections])
|
||||
|
||||
async function handleDelete(collection) {
|
||||
if (!collection?.delete_url) return
|
||||
if (!window.confirm(`Delete "${collection.title}"? Artworks will remain untouched.`)) return
|
||||
|
||||
setBusyId(collection.id)
|
||||
try {
|
||||
await deleteCollection(collection.delete_url)
|
||||
setItems((current) => current.filter((item) => item.id !== collection.id))
|
||||
} catch (error) {
|
||||
window.alert(error.message)
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleFeature(collection) {
|
||||
const url = collection?.is_featured ? collection?.unfeature_url : collection?.feature_url
|
||||
const method = collection?.is_featured ? 'DELETE' : 'POST'
|
||||
if (!url) return
|
||||
|
||||
setBusyId(collection.id)
|
||||
try {
|
||||
const payload = await requestJson(url, { method })
|
||||
setItems((current) => current.map((item) => (
|
||||
item.id === collection.id
|
||||
? {
|
||||
...item,
|
||||
is_featured: payload?.collection?.is_featured ?? !item.is_featured,
|
||||
featured_at: payload?.collection?.featured_at ?? item.featured_at,
|
||||
updated_at: payload?.collection?.updated_at ?? item.updated_at,
|
||||
}
|
||||
: item
|
||||
)))
|
||||
} catch (error) {
|
||||
window.alert(error.message)
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMove(collection, direction) {
|
||||
const index = items.findIndex((item) => item.id === collection.id)
|
||||
const nextIndex = index + direction
|
||||
if (index < 0 || nextIndex < 0 || nextIndex >= items.length || !reorderUrl) return
|
||||
|
||||
const next = [...items]
|
||||
const temp = next[index]
|
||||
next[index] = next[nextIndex]
|
||||
next[nextIndex] = temp
|
||||
setItems(next)
|
||||
|
||||
try {
|
||||
const payload = await requestJson(reorderUrl, {
|
||||
method: 'POST',
|
||||
body: { collection_ids: next.map((item) => item.id) },
|
||||
})
|
||||
if (Array.isArray(payload?.collections)) {
|
||||
setItems(payload.collections)
|
||||
}
|
||||
} catch (error) {
|
||||
window.alert(error.message)
|
||||
setItems(Array.isArray(collections) ? collections : [])
|
||||
}
|
||||
}
|
||||
|
||||
const featuredItems = items.filter((collection) => collection.is_featured)
|
||||
const smartItems = items.filter((collection) => collection.mode === 'smart')
|
||||
const filteredItems = items.filter((collection) => {
|
||||
if (filter === 'featured') return collection.is_featured
|
||||
if (filter === 'smart') return collection.mode === 'smart'
|
||||
if (filter === 'manual') return collection.mode !== 'smart'
|
||||
return true
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
id="tabpanel-collections"
|
||||
@@ -51,15 +140,84 @@ export default function TabCollections({ collections }) {
|
||||
aria-labelledby="tab-collections"
|
||||
className="pt-6"
|
||||
>
|
||||
<div className="bg-white/4 ring-1 ring-white/10 rounded-2xl px-8 py-16 text-center shadow-xl shadow-black/20 backdrop-blur">
|
||||
<div className="w-20 h-20 rounded-2xl bg-white/5 flex items-center justify-center mx-auto mb-5 text-slate-500">
|
||||
<i className="fa-solid fa-layer-group text-3xl" />
|
||||
<div className="mb-5 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Collections</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white">Curated showcases from the gallery</h2>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-relaxed text-slate-300">
|
||||
Collections now support featured presentation, smart rule-based curation, and richer profile storytelling.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{featuredUrl ? <a href={featuredUrl} className="inline-flex items-center gap-2 self-start rounded-2xl border border-white/12 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]"><i className="fa-solid fa-compass fa-fw" />Browse Featured</a> : null}
|
||||
{isOwner && createUrl ? <a href={createUrl} className="inline-flex items-center gap-2 self-start rounded-2xl border border-sky-300/25 bg-sky-400/10 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15"><i className="fa-solid fa-plus fa-fw" />Create Collection</a> : null}
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-white mb-2">Collections Coming Soon</h3>
|
||||
<p className="text-slate-500 text-sm max-w-sm mx-auto">
|
||||
Group your artworks into curated collections.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-5 flex flex-wrap items-center gap-2">
|
||||
{FILTERS.map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setFilter(value)}
|
||||
className={`inline-flex items-center gap-2 rounded-full border px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] transition ${filter === value ? 'border-sky-300/25 bg-sky-400/10 text-sky-100' : 'border-white/10 bg-white/[0.04] text-slate-300 hover:bg-white/[0.07]'}`}
|
||||
>
|
||||
{value}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{items.length > 0 && featuredItems.length > 0 && filter === 'all' ? (
|
||||
<section className="mb-6 overflow-hidden rounded-[28px] border border-amber-300/15 bg-[linear-gradient(135deg,rgba(251,191,36,0.08),rgba(255,255,255,0.04),rgba(56,189,248,0.08))] p-5 shadow-[0_26px_70px_rgba(2,6,23,0.22)]">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-100/80">Featured Collections</p>
|
||||
<h3 className="mt-2 text-xl font-semibold text-white">Premium profile showcases</h3>
|
||||
</div>
|
||||
{isOwner ? <p className="text-xs uppercase tracking-[0.18em] text-slate-300">{featuredItems.length}/{featureLimit} featured</p> : null}
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{featuredItems.map((collection, index) => (
|
||||
<CollectionCard
|
||||
key={`featured-${collection.id}`}
|
||||
collection={collection}
|
||||
isOwner={isOwner}
|
||||
onDelete={handleDelete}
|
||||
onToggleFeature={handleToggleFeature}
|
||||
onMoveUp={isOwner ? () => handleMove(collection, -1) : null}
|
||||
onMoveDown={isOwner ? () => handleMove(collection, 1) : null}
|
||||
canMoveUp={index > 0}
|
||||
canMoveDown={index < featuredItems.length - 1}
|
||||
busy={busyId === collection.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{isOwner && items.length > 0 && featuredItems.length === 0 ? <div className="mb-5 rounded-[24px] border border-white/10 bg-white/[0.04] px-5 py-4 text-sm text-slate-300">Feature your best collections to pin them at the top of your profile.</div> : null}
|
||||
{isOwner && items.length > 0 && smartItems.length === 0 ? <div className="mb-5 rounded-[24px] border border-white/10 bg-white/[0.04] px-5 py-4 text-sm text-slate-300">Create a smart collection from your tags or categories to keep a showcase updated automatically.</div> : null}
|
||||
|
||||
{items.length === 0 ? (
|
||||
<CollectionEmptyState isOwner={isOwner} createUrl={createUrl} />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{filteredItems.map((collection, index) => (
|
||||
<CollectionCard
|
||||
key={collection.id}
|
||||
collection={collection}
|
||||
isOwner={isOwner}
|
||||
onDelete={handleDelete}
|
||||
onToggleFeature={handleToggleFeature}
|
||||
onMoveUp={isOwner ? () => handleMove(collection, -1) : null}
|
||||
onMoveDown={isOwner ? () => handleMove(collection, 1) : null}
|
||||
canMoveUp={index > 0}
|
||||
canMoveDown={index < filteredItems.length - 1}
|
||||
busy={busyId === collection.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ export default function TabPosts({
|
||||
stats,
|
||||
followerCount,
|
||||
recentFollowers,
|
||||
suggestedUsers,
|
||||
socialLinks,
|
||||
countryName,
|
||||
profileUrl,
|
||||
@@ -117,7 +118,7 @@ export default function TabPosts({
|
||||
|
||||
const summaryCards = [
|
||||
{ label: 'Followers', value: formatCompactNumber(followerCount), icon: 'fa-user-group' },
|
||||
{ label: 'Artworks', value: formatCompactNumber(stats?.uploads_count ?? 0), icon: 'fa-image' },
|
||||
{ label: 'Uploads', value: formatCompactNumber(stats?.uploads_count ?? 0), icon: 'fa-image' },
|
||||
{ label: 'Awards', value: formatCompactNumber(stats?.awards_received_count ?? 0), icon: 'fa-trophy' },
|
||||
{ label: 'Location', value: countryName || 'Unknown', icon: 'fa-location-dot' },
|
||||
]
|
||||
@@ -239,6 +240,7 @@ export default function TabPosts({
|
||||
stats={stats}
|
||||
followerCount={followerCount}
|
||||
recentFollowers={recentFollowers}
|
||||
suggestedUsers={suggestedUsers}
|
||||
socialLinks={socialLinks}
|
||||
countryName={countryName}
|
||||
isLoggedIn={!!authUser}
|
||||
|
||||
@@ -18,7 +18,7 @@ function KpiCard({ icon, label, value, color = 'text-sky-400' }) {
|
||||
* TabStats
|
||||
* KPI overview cards. Charts can be added here once chart infrastructure exists.
|
||||
*/
|
||||
export default function TabStats({ stats, followerCount }) {
|
||||
export default function TabStats({ stats, followerCount, followAnalytics }) {
|
||||
const kpis = [
|
||||
{ icon: 'fa-eye', label: 'Profile Views', value: stats?.profile_views_count, color: 'text-sky-400' },
|
||||
{ icon: 'fa-images', label: 'Uploads', value: stats?.uploads_count, color: 'text-violet-400' },
|
||||
@@ -29,6 +29,12 @@ export default function TabStats({ stats, followerCount }) {
|
||||
{ icon: 'fa-trophy', label: 'Awards Received', value: stats?.awards_received_count, color: 'text-yellow-400' },
|
||||
{ icon: 'fa-comment', label: 'Comments Received', value: stats?.comments_received_count, color: 'text-orange-400' },
|
||||
]
|
||||
const trendCards = [
|
||||
{ icon: 'fa-arrow-trend-up', label: 'Followers Today', value: followAnalytics?.daily?.gained ?? 0, color: 'text-emerald-400' },
|
||||
{ icon: 'fa-user-minus', label: 'Unfollows Today', value: followAnalytics?.daily?.lost ?? 0, color: 'text-rose-400' },
|
||||
{ icon: 'fa-chart-line', label: 'Weekly Net', value: followAnalytics?.weekly?.net ?? 0, color: 'text-sky-400' },
|
||||
{ icon: 'fa-percent', label: 'Weekly Growth %', value: followAnalytics?.weekly?.growth_rate ?? 0, color: 'text-amber-400' },
|
||||
]
|
||||
|
||||
const hasStats = stats !== null && stats !== undefined
|
||||
|
||||
@@ -56,6 +62,15 @@ export default function TabStats({ stats, followerCount }) {
|
||||
<KpiCard key={kpi.label} {...kpi} />
|
||||
))}
|
||||
</div>
|
||||
<h3 className="mt-8 mb-4 text-xs font-semibold uppercase tracking-widest text-slate-500 flex items-center gap-2">
|
||||
<i className="fa-solid fa-user-group text-emerald-400 fa-fw" />
|
||||
Follow Growth
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{trendCards.map((card) => (
|
||||
<KpiCard key={card.label} {...card} />
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-slate-600 mt-6 text-center">
|
||||
More detailed analytics (charts, trends) coming soon.
|
||||
</p>
|
||||
|
||||
@@ -1,9 +1,195 @@
|
||||
import React, { useState } from 'react'
|
||||
import React, { useCallback, useRef, useState } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import EmojiPickerButton from '../comments/EmojiPickerButton'
|
||||
|
||||
function BoldIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} className="h-4 w-4">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 4h8a4 4 0 0 1 0 8H6zM6 12h9a4 4 0 0 1 0 8H6z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ItalicIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} className="h-4 w-4">
|
||||
<line x1="19" y1="4" x2="10" y2="4" />
|
||||
<line x1="14" y1="20" x2="5" y2="20" />
|
||||
<line x1="15" y1="4" x2="9" y2="20" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function CodeIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} className="h-4 w-4">
|
||||
<polyline points="16 18 22 12 16 6" />
|
||||
<polyline points="8 6 2 12 8 18" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function LinkIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} className="h-4 w-4">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ListIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} className="h-4 w-4">
|
||||
<line x1="8" y1="6" x2="21" y2="6" />
|
||||
<line x1="8" y1="12" x2="21" y2="12" />
|
||||
<line x1="8" y1="18" x2="21" y2="18" />
|
||||
<circle cx="3" cy="6" r="1" fill="currentColor" stroke="none" />
|
||||
<circle cx="3" cy="12" r="1" fill="currentColor" stroke="none" />
|
||||
<circle cx="3" cy="18" r="1" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function QuoteIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className="h-4 w-4">
|
||||
<path d="M4.583 17.321C3.553 16.227 3 15 3 13.011c0-3.5 2.457-6.637 6.03-8.188l.893 1.378c-3.335 1.804-3.987 4.145-4.247 5.621.537-.278 1.24-.375 1.929-.311C9.591 11.68 11 13.176 11 15c0 1.933-1.567 3.5-3.5 3.5-1.073 0-2.099-.456-2.917-1.179zM15.583 17.321C14.553 16.227 14 15 14 13.011c0-3.5 2.457-6.637 6.03-8.188l.893 1.378c-3.335 1.804-3.987 4.145-4.247 5.621.537-.278 1.24-.375 1.929-.311C20.591 11.68 22 13.176 22 15c0 1.933-1.567 3.5-3.5 3.5-1.073 0-2.099-.456-2.917-1.179z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolbarBtn({ title, onClick, children }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
onClick()
|
||||
}}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-white/40 transition-colors hover:bg-white/[0.08] hover:text-white/70"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CommentForm({ placeholder = 'Write a comment…', submitLabel = 'Post', onSubmit, onCancel, compact = false }) {
|
||||
const [content, setContent] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [tab, setTab] = useState('write')
|
||||
const textareaRef = useRef(null)
|
||||
|
||||
const wrapSelection = useCallback((before, after) => {
|
||||
const element = textareaRef.current
|
||||
if (!element) return
|
||||
|
||||
const start = element.selectionStart
|
||||
const end = element.selectionEnd
|
||||
const selected = content.slice(start, end)
|
||||
const replacement = before + (selected || 'text') + after
|
||||
const next = content.slice(0, start) + replacement + content.slice(end)
|
||||
setContent(next)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const cursorPos = selected ? start + replacement.length : start + before.length
|
||||
const cursorEnd = selected ? start + replacement.length : start + before.length + 4
|
||||
element.selectionStart = cursorPos
|
||||
element.selectionEnd = cursorEnd
|
||||
element.focus()
|
||||
})
|
||||
}, [content])
|
||||
|
||||
const prefixLines = useCallback((prefix) => {
|
||||
const element = textareaRef.current
|
||||
if (!element) return
|
||||
|
||||
const start = element.selectionStart
|
||||
const end = element.selectionEnd
|
||||
const selected = content.slice(start, end)
|
||||
const lines = selected ? selected.split('\n') : ['']
|
||||
const prefixed = lines.map((line) => prefix + line).join('\n')
|
||||
const next = content.slice(0, start) + prefixed + content.slice(end)
|
||||
setContent(next)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
element.selectionStart = start
|
||||
element.selectionEnd = start + prefixed.length
|
||||
element.focus()
|
||||
})
|
||||
}, [content])
|
||||
|
||||
const insertLink = useCallback(() => {
|
||||
const element = textareaRef.current
|
||||
if (!element) return
|
||||
|
||||
const start = element.selectionStart
|
||||
const end = element.selectionEnd
|
||||
const selected = content.slice(start, end)
|
||||
const isUrl = /^https?:\/\//.test(selected)
|
||||
const replacement = isUrl ? `[link](${selected})` : `[${selected || 'link'}](https://)`
|
||||
const next = content.slice(0, start) + replacement + content.slice(end)
|
||||
setContent(next)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (isUrl) {
|
||||
element.selectionStart = start + 1
|
||||
element.selectionEnd = start + 5
|
||||
} else {
|
||||
const urlStart = start + replacement.length - 1
|
||||
element.selectionStart = urlStart - 8
|
||||
element.selectionEnd = urlStart - 1
|
||||
}
|
||||
element.focus()
|
||||
})
|
||||
}, [content])
|
||||
|
||||
const insertAtCursor = useCallback((text) => {
|
||||
const element = textareaRef.current
|
||||
if (!element) {
|
||||
setContent((current) => current + text)
|
||||
return
|
||||
}
|
||||
|
||||
const start = element.selectionStart ?? content.length
|
||||
const end = element.selectionEnd ?? content.length
|
||||
const next = content.slice(0, start) + text + content.slice(end)
|
||||
setContent(next)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
element.selectionStart = start + text.length
|
||||
element.selectionEnd = start + text.length
|
||||
element.focus()
|
||||
})
|
||||
}, [content])
|
||||
|
||||
const handleKeyDown = useCallback((event) => {
|
||||
const mod = event.ctrlKey || event.metaKey
|
||||
if (!mod) return
|
||||
|
||||
switch (event.key.toLowerCase()) {
|
||||
case 'b':
|
||||
event.preventDefault()
|
||||
wrapSelection('**', '**')
|
||||
break
|
||||
case 'i':
|
||||
event.preventDefault()
|
||||
wrapSelection('*', '*')
|
||||
break
|
||||
case 'k':
|
||||
event.preventDefault()
|
||||
insertLink()
|
||||
break
|
||||
case 'e':
|
||||
event.preventDefault()
|
||||
wrapSelection('`', '`')
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}, [insertLink, wrapSelection])
|
||||
|
||||
const handleSubmit = async (event) => {
|
||||
event.preventDefault()
|
||||
@@ -15,6 +201,7 @@ export default function CommentForm({ placeholder = 'Write a comment…', submit
|
||||
try {
|
||||
await onSubmit?.(trimmed)
|
||||
setContent('')
|
||||
setTab('write')
|
||||
} catch (submitError) {
|
||||
setError(submitError?.message || 'Unable to post comment.')
|
||||
} finally {
|
||||
@@ -24,19 +211,119 @@ export default function CommentForm({ placeholder = 'Write a comment…', submit
|
||||
|
||||
return (
|
||||
<form className="space-y-3" onSubmit={handleSubmit}>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
rows={compact ? 3 : 4}
|
||||
maxLength={10000}
|
||||
placeholder={placeholder}
|
||||
className="w-full rounded-2xl border border-white/[0.08] bg-white/[0.04] px-4 py-3 text-sm text-white placeholder-white/35 outline-none transition focus:border-sky-400/40 focus:bg-white/[0.06]"
|
||||
/>
|
||||
<div className={`rounded-2xl border border-white/[0.08] bg-white/[0.04] transition-all duration-200 focus-within:border-white/[0.12] focus-within:shadow-lg focus-within:shadow-black/20 ${compact ? 'rounded-xl' : ''}`}>
|
||||
<div className="flex items-center justify-between border-b border-white/[0.06] px-3 py-1.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('write')}
|
||||
className={[
|
||||
'rounded-md px-3 py-1 text-xs font-medium transition-colors',
|
||||
tab === 'write' ? 'bg-white/[0.08] text-white' : 'text-white/40 hover:text-white/60',
|
||||
].join(' ')}
|
||||
>
|
||||
Write
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('preview')}
|
||||
className={[
|
||||
'rounded-md px-3 py-1 text-xs font-medium transition-colors',
|
||||
tab === 'preview' ? 'bg-white/[0.08] text-white' : 'text-white/40 hover:text-white/60',
|
||||
].join(' ')}
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={[
|
||||
'text-[11px] tabular-nums font-medium transition-colors',
|
||||
content.length > 9000 ? 'text-amber-400/80' : 'text-white/20',
|
||||
].join(' ')}
|
||||
>
|
||||
{content.length > 0 && `${content.length.toLocaleString()}/10,000`}
|
||||
</span>
|
||||
<EmojiPickerButton onEmojiSelect={insertAtCursor} disabled={busy} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tab === 'write' && (
|
||||
<div className="flex items-center gap-0.5 border-b border-white/[0.04] px-3 py-1">
|
||||
<ToolbarBtn title="Bold (Ctrl+B)" onClick={() => wrapSelection('**', '**')}>
|
||||
<BoldIcon />
|
||||
</ToolbarBtn>
|
||||
<ToolbarBtn title="Italic (Ctrl+I)" onClick={() => wrapSelection('*', '*')}>
|
||||
<ItalicIcon />
|
||||
</ToolbarBtn>
|
||||
<ToolbarBtn title="Inline code (Ctrl+E)" onClick={() => wrapSelection('`', '`')}>
|
||||
<CodeIcon />
|
||||
</ToolbarBtn>
|
||||
<ToolbarBtn title="Link (Ctrl+K)" onClick={insertLink}>
|
||||
<LinkIcon />
|
||||
</ToolbarBtn>
|
||||
|
||||
<div className="mx-1 h-4 w-px bg-white/[0.08]" />
|
||||
|
||||
<ToolbarBtn title="Bulleted list" onClick={() => prefixLines('- ')}>
|
||||
<ListIcon />
|
||||
</ToolbarBtn>
|
||||
<ToolbarBtn title="Quote" onClick={() => prefixLines('> ')}>
|
||||
<QuoteIcon />
|
||||
</ToolbarBtn>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'write' && (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={compact ? 3 : 4}
|
||||
maxLength={10000}
|
||||
placeholder={placeholder}
|
||||
disabled={busy}
|
||||
className="w-full resize-none bg-transparent px-4 py-3 text-sm leading-relaxed text-white/90 placeholder-white/35 outline-none transition disabled:opacity-50"
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === 'preview' && (
|
||||
<div className="min-h-[7rem] px-4 py-3">
|
||||
{content.trim() ? (
|
||||
<div className="prose prose-invert prose-sm max-w-none text-[13px] leading-relaxed text-white/80 [&_a]:text-accent [&_a]:no-underline hover:[&_a]:underline [&_code]:rounded [&_code]:bg-white/[0.08] [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:text-[12px] [&_code]:text-amber-300/80 [&_blockquote]:border-l-2 [&_blockquote]:border-accent/40 [&_blockquote]:pl-3 [&_blockquote]:text-white/50 [&_ul]:list-disc [&_ul]:pl-4 [&_ol]:list-decimal [&_ol]:pl-4 [&_li]:text-white/70 [&_strong]:text-white [&_em]:text-white/70 [&_p]:mb-2 [&_p:last-child]:mb-0">
|
||||
<ReactMarkdown
|
||||
allowedElements={['p', 'strong', 'em', 'a', 'code', 'pre', 'ul', 'ol', 'li', 'blockquote', 'br']}
|
||||
unwrapDisallowed
|
||||
components={{
|
||||
a: ({ href, children }) => (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer nofollow">{children}</a>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm italic text-white/25">Nothing to preview</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'write' && (
|
||||
<div className="px-4 pb-2">
|
||||
<p className="text-[11px] text-white/15">
|
||||
Markdown supported · <kbd className="rounded bg-white/[0.06] px-1 py-0.5 text-[10px] text-white/25">Ctrl+B</kbd> bold · <kbd className="rounded bg-white/[0.06] px-1 py-0.5 text-[10px] text-white/25">Ctrl+I</kbd> italic · <kbd className="rounded bg-white/[0.06] px-1 py-0.5 text-[10px] text-white/25">Ctrl+K</kbd> link
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error ? <p className="text-sm text-rose-300">{error}</p> : null}
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-xs text-white/35">{content.trim().length}/10000</span>
|
||||
<span className="text-xs text-white/35">Use emoji and markdown to match the rest of the site.</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{onCancel ? (
|
||||
<button
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState } from 'react'
|
||||
import LevelBadge from '../xp/LevelBadge'
|
||||
import CommentForm from './CommentForm'
|
||||
|
||||
function CommentItem({ comment, canReply, onReply, onDelete }) {
|
||||
function CommentItem({ comment, canReply, onReply, onDelete, onReport }) {
|
||||
const [replying, setReplying] = useState(false)
|
||||
|
||||
return (
|
||||
@@ -42,6 +42,11 @@ function CommentItem({ comment, canReply, onReply, onDelete }) {
|
||||
Delete
|
||||
</button>
|
||||
) : null}
|
||||
{comment.can_report ? (
|
||||
<button type="button" onClick={() => onReport?.(comment)} className="transition hover:text-amber-300">
|
||||
Report
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{replying ? (
|
||||
@@ -62,7 +67,7 @@ function CommentItem({ comment, canReply, onReply, onDelete }) {
|
||||
{Array.isArray(comment.replies) && comment.replies.length > 0 ? (
|
||||
<div className="mt-4 space-y-3 border-l border-white/[0.08] pl-4">
|
||||
{comment.replies.map((reply) => (
|
||||
<CommentItem key={reply.id} comment={reply} canReply={canReply} onReply={onReply} onDelete={onDelete} />
|
||||
<CommentItem key={reply.id} comment={reply} canReply={canReply} onReply={onReply} onDelete={onDelete} onReport={onReport} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -72,7 +77,7 @@ function CommentItem({ comment, canReply, onReply, onDelete }) {
|
||||
)
|
||||
}
|
||||
|
||||
export default function CommentList({ comments = [], canReply = false, onReply, onDelete, emptyMessage = 'No comments yet.' }) {
|
||||
export default function CommentList({ comments = [], canReply = false, onReply, onDelete, onReport, emptyMessage = 'No comments yet.' }) {
|
||||
if (!comments.length) {
|
||||
return <p className="text-sm text-white/45">{emptyMessage}</p>
|
||||
}
|
||||
@@ -80,8 +85,8 @@ export default function CommentList({ comments = [], canReply = false, onReply,
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{comments.map((comment) => (
|
||||
<CommentItem key={comment.id} comment={comment} canReply={canReply} onReply={onReply} onDelete={onDelete} />
|
||||
<CommentItem key={comment.id} comment={comment} canReply={canReply} onReply={onReply} onDelete={onDelete} onReport={onReport} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export default function FollowButton({
|
||||
const [count, setCount] = useState(Number(initialCount || 0))
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||
const [hovered, setHovered] = useState(false)
|
||||
|
||||
const csrfToken = typeof document !== 'undefined'
|
||||
? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
|
||||
@@ -65,6 +66,8 @@ export default function FollowButton({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
disabled={loading || !username}
|
||||
aria-label={following ? 'Unfollow creator' : 'Follow creator'}
|
||||
className={[
|
||||
@@ -74,8 +77,8 @@ export default function FollowButton({
|
||||
className,
|
||||
].join(' ')}
|
||||
>
|
||||
<i className={`fa-solid fa-fw ${loading ? 'fa-circle-notch fa-spin' : following ? 'fa-user-check' : 'fa-user-plus'}`} />
|
||||
<span>{following ? 'Following' : 'Follow'}</span>
|
||||
<i className={`fa-solid fa-fw ${loading ? 'fa-circle-notch fa-spin' : following ? (hovered ? 'fa-user-minus' : 'fa-user-check') : 'fa-user-plus'}`} />
|
||||
<span>{following ? (hovered ? 'Unfollow' : 'Following') : 'Follow'}</span>
|
||||
{showCount ? <span className="text-xs opacity-70">{count.toLocaleString()}</span> : null}
|
||||
</button>
|
||||
|
||||
@@ -94,4 +97,4 @@ export default function FollowButton({
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
40
resources/js/components/social/FollowersPreview.jsx
Normal file
40
resources/js/components/social/FollowersPreview.jsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function FollowersPreview({ users = [], label = '', href = null }) {
|
||||
if (!Array.isArray(users) || users.length === 0) return null
|
||||
|
||||
const preview = users.slice(0, 4)
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex -space-x-2">
|
||||
{preview.map((user) => (
|
||||
<a
|
||||
key={user.id}
|
||||
href={user.profile_url || `/@${user.username}`}
|
||||
className="inline-flex h-9 w-9 overflow-hidden rounded-full border border-[#09111f] bg-[#09111f] ring-1 ring-white/10"
|
||||
title={user.name || user.username}
|
||||
>
|
||||
<img
|
||||
src={user.avatar_url || '/images/avatar_default.webp'}
|
||||
alt={user.username || user.name}
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-white/90">{label}</p>
|
||||
{href ? (
|
||||
<a href={href} className="text-xs text-sky-300/80 transition-colors hover:text-sky-200">
|
||||
View network
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -72,14 +72,14 @@ export default function MessageInboxBadge({ initialUnreadCount = 0, userId = nul
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className="relative w-10 h-10 inline-flex items-center justify-center rounded-lg hover:bg-white/5"
|
||||
className="relative inline-flex h-9 w-9 lg:h-10 lg:w-10 items-center justify-center rounded-lg hover:bg-white/5"
|
||||
title="Messages"
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<svg className="h-[18px] w-[18px] lg:h-5 lg:w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="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>
|
||||
{unreadCount > 0 ? (
|
||||
<span className="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">
|
||||
<span className="absolute -bottom-1 right-0 rounded border border-sb-line bg-red-700/70 px-1 py-0 text-[10px] tabular-nums text-white lg:px-1.5 lg:py-0.5 lg:text-[11px]">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
14
resources/js/components/social/MutualFollowersBadge.jsx
Normal file
14
resources/js/components/social/MutualFollowersBadge.jsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function MutualFollowersBadge({ context }) {
|
||||
const label = context?.follower_overlap?.label || context?.shared_following?.label || null
|
||||
|
||||
if (!label) return null
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-emerald-400/20 bg-emerald-400/10 px-3 py-1.5 text-xs font-medium text-emerald-200">
|
||||
<i className="fa-solid fa-user-group fa-fw text-[11px]" />
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -96,15 +96,15 @@ export default function NotificationDropdown({ initialUnreadCount = 0, notificat
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className="relative inline-flex h-10 w-10 items-center justify-center rounded-lg text-white/75 transition hover:bg-white/5 hover:text-white"
|
||||
className="relative inline-flex h-9 w-9 lg:h-10 lg:w-10 items-center justify-center rounded-lg text-white/75 transition hover:bg-white/5 hover:text-white"
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<svg className="h-[18px] w-[18px] lg:h-5 lg:w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="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>
|
||||
{unreadCount > 0 ? (
|
||||
<span className="absolute -bottom-1 right-0 rounded bg-red-700/80 px-1.5 py-0.5 text-[11px] font-semibold text-white border border-sb-line">
|
||||
<span className="absolute -bottom-1 right-0 rounded border border-sb-line bg-red-700/80 px-1 py-0 text-[10px] font-semibold text-white lg:px-1.5 lg:py-0.5 lg:text-[11px]">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
101
resources/js/components/social/SuggestedUsersWidget.jsx
Normal file
101
resources/js/components/social/SuggestedUsersWidget.jsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import axios from 'axios'
|
||||
import FollowButton from './FollowButton'
|
||||
|
||||
export default function SuggestedUsersWidget({
|
||||
title = 'Suggested Users',
|
||||
limit = 4,
|
||||
isLoggedIn = false,
|
||||
excludeUsername = null,
|
||||
initialUsers = null,
|
||||
}) {
|
||||
const [users, setUsers] = useState(Array.isArray(initialUsers) ? initialUsers : [])
|
||||
const [loading, setLoading] = useState(!Array.isArray(initialUsers) && isLoggedIn)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn || Array.isArray(initialUsers)) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
axios.get('/api/users/suggestions', { params: { limit } })
|
||||
.then(({ data }) => {
|
||||
if (cancelled) return
|
||||
const nextUsers = Array.isArray(data?.data) ? data.data : []
|
||||
setUsers(nextUsers)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setUsers([])
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [initialUsers, isLoggedIn, limit])
|
||||
|
||||
const visibleUsers = users
|
||||
.filter((user) => user?.username && user.username !== excludeUsername)
|
||||
.slice(0, limit)
|
||||
|
||||
if (!isLoggedIn) return null
|
||||
if (!loading && visibleUsers.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/[0.07] bg-white/[0.03] overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-white/[0.05]">
|
||||
<i className="fa-solid fa-compass text-slate-500 fa-fw text-[13px]" />
|
||||
<span className="text-[11px] font-semibold uppercase tracking-widest text-slate-500">{title}</span>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 space-y-3">
|
||||
{loading ? [1, 2, 3].map((key) => (
|
||||
<div key={key} className="animate-pulse flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-full bg-white/10" />
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="h-2.5 w-24 rounded bg-white/10" />
|
||||
<div className="h-2 w-32 rounded bg-white/6" />
|
||||
</div>
|
||||
</div>
|
||||
)) : visibleUsers.map((user) => (
|
||||
<div key={user.id} className="rounded-2xl border border-white/[0.05] bg-white/[0.03] p-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<a href={user.profile_url || `/@${user.username}`} className="shrink-0">
|
||||
<img
|
||||
src={user.avatar_url || '/images/avatar_default.webp'}
|
||||
alt={user.username}
|
||||
className="h-10 w-10 rounded-full object-cover ring-1 ring-white/10"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<a href={user.profile_url || `/@${user.username}`} className="block truncate text-sm font-semibold text-white/90 hover:text-white">
|
||||
{user.name || user.username}
|
||||
</a>
|
||||
<p className="truncate text-xs text-slate-500">@{user.username}</p>
|
||||
<p className="mt-1 text-xs text-slate-400">{user.context?.follower_overlap?.label || user.context?.shared_following?.label || user.reason}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between gap-3">
|
||||
<span className="text-[11px] text-slate-500">{Number(user.followers_count || 0).toLocaleString()} followers</span>
|
||||
<FollowButton
|
||||
username={user.username}
|
||||
initialFollowing={false}
|
||||
initialCount={Number(user.followers_count || 0)}
|
||||
showCount={false}
|
||||
sizeClassName="px-3 py-1.5 text-xs"
|
||||
className="min-w-[110px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,9 +9,29 @@ import { createPortal } from 'react-dom'
|
||||
* visible – whether the toast is currently shown
|
||||
* onHide – callback when the toast finishes (auto-hidden after ~2 s)
|
||||
* duration – ms before auto-dismiss (default 2000)
|
||||
* variant – success or error tone (default success)
|
||||
*/
|
||||
export default function ShareToast({ message = 'Link copied!', visible = false, onHide, duration = 2000 }) {
|
||||
export default function ShareToast({ message = 'Link copied!', visible = false, onHide, duration = 2000, variant = 'success' }) {
|
||||
const [show, setShow] = useState(false)
|
||||
const config = variant === 'error'
|
||||
? {
|
||||
border: 'border-rose-300/25',
|
||||
background: 'bg-rose-950/90',
|
||||
text: 'text-rose-50',
|
||||
icon: 'text-rose-300',
|
||||
role: 'alert',
|
||||
live: 'assertive',
|
||||
iconPath: 'M12 9v3.75m0 3.75h.007v.008H12v-.008ZM10.29 3.86 1.82 18a1.875 1.875 0 0 0 1.606 2.813h16.148A1.875 1.875 0 0 0 21.18 18L12.71 3.86a1.875 1.875 0 0 0-3.42 0Z',
|
||||
}
|
||||
: {
|
||||
border: 'border-white/[0.10]',
|
||||
background: 'bg-nova-800/90',
|
||||
text: 'text-white',
|
||||
icon: 'text-emerald-400',
|
||||
role: 'status',
|
||||
live: 'polite',
|
||||
iconPath: 'M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z',
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
@@ -35,17 +55,19 @@ export default function ShareToast({ message = 'Link copied!', visible = false,
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
role={config.role}
|
||||
aria-live={config.live}
|
||||
className={[
|
||||
'fixed bottom-24 left-1/2 z-[10001] -translate-x-1/2 rounded-full border border-white/[0.10] bg-nova-800/90 px-5 py-2.5 text-sm font-medium text-white shadow-xl backdrop-blur-md transition-all duration-200',
|
||||
'fixed bottom-24 left-1/2 z-[10001] -translate-x-1/2 rounded-full border px-5 py-2.5 text-sm font-medium shadow-xl backdrop-blur-md transition-all duration-200',
|
||||
config.border,
|
||||
config.background,
|
||||
config.text,
|
||||
show ? 'translate-y-0 opacity-100' : 'translate-y-3 opacity-0',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{/* Check icon */}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-4 w-4 text-emerald-400">
|
||||
<path fillRule="evenodd" d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z" clipRule="evenodd" />
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className={`h-4 w-4 ${config.icon}`}>
|
||||
<path fillRule="evenodd" d={config.iconPath} clipRule="evenodd" />
|
||||
</svg>
|
||||
{message}
|
||||
</span>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { forwardRef } from 'react'
|
||||
import React, { forwardRef, useId } from 'react'
|
||||
|
||||
/**
|
||||
* Nova TextInput
|
||||
@@ -26,7 +26,11 @@ const TextInput = forwardRef(function TextInput(
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const inputId = id ?? (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined)
|
||||
const generatedId = useId()
|
||||
const labelSlug = typeof label === 'string'
|
||||
? label.toLowerCase().replace(/\s+/g, '-')
|
||||
: null
|
||||
const inputId = id ?? labelSlug ?? `text-input-${generatedId.replace(/[:]/g, '')}`
|
||||
|
||||
const sizeClass = {
|
||||
sm: 'py-1.5 text-xs',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { forwardRef } from 'react'
|
||||
import React, { forwardRef, useId } from 'react'
|
||||
|
||||
/**
|
||||
* Nova Textarea
|
||||
@@ -14,7 +14,11 @@ const Textarea = forwardRef(function Textarea(
|
||||
{ label, error, hint, required, rows = 4, resize = false, id, className = '', ...rest },
|
||||
ref,
|
||||
) {
|
||||
const inputId = id ?? (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined)
|
||||
const generatedId = useId()
|
||||
const labelSlug = typeof label === 'string'
|
||||
? label.toLowerCase().replace(/\s+/g, '-')
|
||||
: null
|
||||
const inputId = id ?? labelSlug ?? `textarea-${generatedId.replace(/[:]/g, '')}`
|
||||
|
||||
const inputClass = [
|
||||
'block w-full rounded-xl border bg-white/[0.06] text-white text-sm',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import TagPicker from '../tags/TagPicker'
|
||||
import Checkbox from '../../Components/ui/Checkbox'
|
||||
import MarkdownEditor from '../ui/MarkdownEditor'
|
||||
import RichTextEditor from '../forum/RichTextEditor'
|
||||
|
||||
export default function UploadSidebar({
|
||||
title = 'Artwork details',
|
||||
@@ -13,6 +13,7 @@ export default function UploadSidebar({
|
||||
onChangeTitle,
|
||||
onChangeTags,
|
||||
onChangeDescription,
|
||||
onToggleMature,
|
||||
onToggleRights,
|
||||
}) {
|
||||
return (
|
||||
@@ -46,14 +47,16 @@ export default function UploadSidebar({
|
||||
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium text-white/90">Description <span className="text-red-300">*</span></span>
|
||||
<MarkdownEditor
|
||||
id="upload-sidebar-description"
|
||||
value={metadata.description}
|
||||
<div className="mt-2">
|
||||
<RichTextEditor
|
||||
content={metadata.description}
|
||||
onChange={onChangeDescription}
|
||||
placeholder="Describe your artwork (Markdown supported)."
|
||||
error={errors.description}
|
||||
placeholder="Describe your artwork, tools, inspiration…"
|
||||
error={Array.isArray(errors.description) ? errors.description[0] : errors.description}
|
||||
minHeight={12}
|
||||
autofocus={false}
|
||||
/>
|
||||
{errors.description && <p className="mt-1 text-xs text-red-200">{errors.description}</p>}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
@@ -74,6 +77,18 @@ export default function UploadSidebar({
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-white/10 bg-white/[0.03] p-5">
|
||||
<Checkbox
|
||||
id="upload-sidebar-mature"
|
||||
checked={Boolean(metadata.isMature)}
|
||||
onChange={(event) => onToggleMature?.(event.target.checked)}
|
||||
variant="accent"
|
||||
size={20}
|
||||
label="Mark this artwork as mature content."
|
||||
hint="Use this for NSFW, explicit, or otherwise age-restricted artwork."
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-white/10 bg-white/[0.03] p-5">
|
||||
<Checkbox
|
||||
id="upload-sidebar-rights"
|
||||
|
||||
@@ -50,6 +50,7 @@ const initialMetadata = {
|
||||
subCategoryId: '',
|
||||
tags: [],
|
||||
description: '',
|
||||
isMature: false,
|
||||
rightsAccepted: false,
|
||||
contentType: '',
|
||||
}
|
||||
@@ -426,6 +427,7 @@ export default function UploadWizard({
|
||||
onChangeTitle={(value) => setMeta({ title: value })}
|
||||
onChangeTags={(value) => setMeta({ tags: value })}
|
||||
onChangeDescription={(value) => setMeta({ description: value })}
|
||||
onToggleMature={(value) => setMeta({ isMature: Boolean(value) })}
|
||||
onToggleRights={(value) => setMeta({ rightsAccepted: Boolean(value) })}
|
||||
/>
|
||||
)
|
||||
@@ -581,8 +583,8 @@ export default function UploadWizard({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right column: PublishPanel (sticky sidebar on lg+) */}
|
||||
{(primaryFile || resolvedArtworkId) && machine.state !== machineStates.complete && (
|
||||
{/* Right column: PublishPanel (sticky sidebar on lg+, Step 2+ only) */}
|
||||
{(primaryFile || resolvedArtworkId) && machine.state !== machineStates.complete && activeStep > 1 && (
|
||||
<div className="hidden shrink-0 lg:block lg:w-80 xl:w-[22rem] lg:sticky lg:top-20 lg:self-start">
|
||||
<PublishPanel
|
||||
primaryPreviewUrl={primaryPreviewUrl}
|
||||
@@ -612,7 +614,7 @@ export default function UploadWizard({
|
||||
</div>
|
||||
|
||||
{/* ── Mobile: floating "Publish" button that opens bottom sheet ────── */}
|
||||
{(primaryFile || resolvedArtworkId) && machine.state !== machineStates.complete && (
|
||||
{(primaryFile || resolvedArtworkId) && machine.state !== machineStates.complete && activeStep > 1 && (
|
||||
<div className="fixed bottom-4 right-4 z-30 lg:hidden">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -277,6 +277,42 @@ describe('UploadWizard step flow', () => {
|
||||
expect(window.axios.post).not.toHaveBeenCalledWith('/api/uploads/session-1/publish', expect.anything(), expect.anything())
|
||||
})
|
||||
|
||||
it('includes the mature flag in the final publish payload when selected', async () => {
|
||||
installAxiosStubs({ statusValue: 'ready' })
|
||||
await renderWizard({ initialDraftId: 311, contentTypes: [{ id: 1, name: 'Art', categories: [{ id: 10, name: 'Root', children: [{ id: 11, name: 'Sub' }] }] }] })
|
||||
|
||||
await completeStep1ToReady()
|
||||
|
||||
await screen.findByText(/artwork details/i)
|
||||
const titleInput = screen.getByPlaceholderText(/give your artwork a clear title/i)
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.type(titleInput, 'Mature Piece')
|
||||
await userEvent.selectOptions(screen.getByRole('combobox', { name: /root category/i }), '10')
|
||||
await userEvent.selectOptions(screen.getByRole('combobox', { name: /subcategory/i }), '11')
|
||||
await userEvent.click(screen.getByLabelText(/mark this artwork as mature content/i))
|
||||
await userEvent.click(screen.getByLabelText(/i confirm i own the rights to this content/i))
|
||||
await userEvent.click(screen.getByRole('button', { name: /continue to publish/i }))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
const publish = screen.getByRole('button', { name: /^publish$/i })
|
||||
expect(publish.disabled).toBe(false)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(screen.getByRole('button', { name: /^publish$/i }))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.axios.post).toHaveBeenCalledWith(
|
||||
'/api/uploads/311/publish',
|
||||
expect.objectContaining({ is_mature: true }),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps mobile sticky action bar visible class', async () => {
|
||||
installAxiosStubs()
|
||||
await renderWizard({ initialDraftId: 306 })
|
||||
|
||||
@@ -28,67 +28,50 @@ export default function Step1FileUpload({
|
||||
// Machine state (passed for potential future use)
|
||||
machine,
|
||||
}) {
|
||||
const fileSelected = Boolean(primaryFile)
|
||||
|
||||
return (
|
||||
<div className="space-y-5 rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))] p-6 shadow-[0_24px_90px_rgba(2,8,23,0.28)] backdrop-blur sm:p-7">
|
||||
{/* Step header */}
|
||||
<div className="rounded-2xl border border-white/8 bg-white/[0.04] px-5 py-4">
|
||||
<div className="space-y-6 rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))] p-6 shadow-[0_24px_90px_rgba(2,8,23,0.28)] backdrop-blur sm:p-8">
|
||||
|
||||
{/* ── Hero heading ─────────────────────────────────────────────────── */}
|
||||
<div className="text-center">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-sky-400/30 bg-sky-400/10 px-3 py-1 text-[11px] uppercase tracking-widest text-sky-300">
|
||||
Step 1 of 3
|
||||
</span>
|
||||
<h2
|
||||
ref={headingRef}
|
||||
tabIndex={-1}
|
||||
className="text-lg font-semibold text-white focus:outline-none"
|
||||
className="mt-4 text-2xl font-bold text-white focus:outline-none"
|
||||
>
|
||||
Upload your artwork
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-white/60">
|
||||
Drop or browse a file. Validation runs immediately. Upload starts when you click
|
||||
<span className="text-white/80">Start upload</span>.
|
||||
<p className="mx-auto mt-2 max-w-md text-sm text-white/55">
|
||||
Drop an image or an archive pack. We validate the file instantly so you can start uploading straight away.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{[
|
||||
{
|
||||
title: '1. Add the file',
|
||||
body: 'Drop an image or archive pack into the upload area.',
|
||||
},
|
||||
{
|
||||
title: '2. Check validation',
|
||||
body: 'We flag unsupported formats, missing screenshots, and basic file issues immediately.',
|
||||
},
|
||||
{
|
||||
title: '3. Start upload',
|
||||
body: 'Once the file is clean, the secure processing pipeline takes over.',
|
||||
},
|
||||
].map((item) => (
|
||||
<div key={item.title} className="rounded-2xl border border-white/8 bg-white/[0.03] p-4">
|
||||
<p className="text-sm font-semibold text-white">{item.title}</p>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-300">{item.body}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Locked notice */}
|
||||
{/* ── Locked notice ────────────────────────────────────────────────── */}
|
||||
{fileSelectionLocked && (
|
||||
<div className="flex items-center gap-2 rounded-2xl bg-amber-500/10 px-4 py-3 text-xs text-amber-100 ring-1 ring-amber-300/30">
|
||||
<svg className="h-3.5 w-3.5 shrink-0" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<div className="flex items-center gap-2.5 rounded-2xl bg-amber-500/10 px-4 py-3 text-sm text-amber-100 ring-1 ring-amber-300/30">
|
||||
<svg className="h-4 w-4 shrink-0" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M10 1a4.5 4.5 0 00-4.5 4.5V9H5a2 2 0 00-2 2v6a2 2 0 002 2h10a2 2 0 002-2v-6a2 2 0 00-2-2h-.5V5.5A4.5 4.5 0 0010 1zm3 8V5.5a3 3 0 10-6 0V9h6z" clipRule="evenodd" />
|
||||
</svg>
|
||||
File is locked after upload. Reset to change.
|
||||
File is locked after upload starts. Reset to change the file.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Primary dropzone */}
|
||||
{/* ── Primary dropzone ─────────────────────────────────────────────── */}
|
||||
<UploadDropzone
|
||||
title="Upload your artwork file"
|
||||
description="Drag & drop or click to browse. Accepted: JPG, PNG, WEBP, ZIP, RAR, 7Z."
|
||||
title="Drop your file here"
|
||||
description="JPG, PNG, WEBP · ZIP, RAR, 7Z · Images up to 50 MB · Archives up to 200 MB"
|
||||
fileName={primaryFile?.name || ''}
|
||||
previewUrl={primaryPreviewUrl}
|
||||
fileMeta={fileMetadata}
|
||||
fileHint="No file selected"
|
||||
invalid={primaryErrors.length > 0}
|
||||
errors={primaryErrors}
|
||||
showLooksGood={Boolean(primaryFile) && primaryErrors.length === 0}
|
||||
looksGoodText="Looks good"
|
||||
showLooksGood={fileSelected && primaryErrors.length === 0}
|
||||
looksGoodText="File looks good — ready to upload"
|
||||
locked={fileSelectionLocked}
|
||||
onPrimaryFileChange={(file) => {
|
||||
if (fileSelectionLocked) return
|
||||
@@ -96,10 +79,10 @@ export default function Step1FileUpload({
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Screenshots (archives only) */}
|
||||
{/* ── Screenshots (archives only) ──────────────────────────────────── */}
|
||||
<ScreenshotUploader
|
||||
title="Archive screenshots"
|
||||
description="We need at least 1 screenshot to generate thumbnails and analyze content."
|
||||
description="Add at least 1 screenshot so we can generate a thumbnail and analyze your content."
|
||||
visible={isArchive}
|
||||
files={screenshots}
|
||||
min={1}
|
||||
@@ -108,9 +91,54 @@ export default function Step1FileUpload({
|
||||
errors={screenshotErrors}
|
||||
invalid={isArchive && screenshotErrors.length > 0}
|
||||
showLooksGood={isArchive && screenshots.length > 0 && screenshotErrors.length === 0}
|
||||
looksGoodText="Looks good"
|
||||
looksGoodText="Screenshots look good"
|
||||
onFilesChange={onScreenshotsChange}
|
||||
/>
|
||||
|
||||
{/* ── Subtle what-happens-next hints (shown only before a file is picked) */}
|
||||
{!fileSelected && (
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{[
|
||||
{
|
||||
icon: (
|
||||
<svg className="h-5 w-5 text-sky-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M21 15v4a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-4" /><path d="M7 10l5-5 5 5" /><path d="M12 5v10" />
|
||||
</svg>
|
||||
),
|
||||
label: 'Add your file',
|
||||
hint: 'Image or archive — drop it in or click to browse.',
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<svg className="h-5 w-5 text-violet-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M9 12l2 2 4-4" /><path d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10z" />
|
||||
</svg>
|
||||
),
|
||||
label: 'Instant validation',
|
||||
hint: 'Format, size, and screenshot checks run immediately.',
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<svg className="h-5 w-5 text-emerald-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
),
|
||||
label: 'Start upload',
|
||||
hint: 'One click sends your file through the secure pipeline.',
|
||||
},
|
||||
].map((item) => (
|
||||
<div key={item.label} className="flex items-start gap-3 rounded-2xl border border-white/8 bg-white/[0.03] px-4 py-4">
|
||||
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl border border-white/10 bg-white/[0.05]">
|
||||
{item.icon}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white">{item.label}</p>
|
||||
<p className="mt-1 text-xs leading-5 text-slate-400">{item.hint}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react'
|
||||
import ContentTypeSelector from '../ContentTypeSelector'
|
||||
import CategorySelector from '../CategorySelector'
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import UploadSidebar from '../UploadSidebar'
|
||||
import { getContentTypeValue, getContentTypeVisualKey } from '../../../lib/uploadUtils'
|
||||
|
||||
/**
|
||||
* Step2Details
|
||||
@@ -33,8 +32,78 @@ export default function Step2Details({
|
||||
onChangeTitle,
|
||||
onChangeTags,
|
||||
onChangeDescription,
|
||||
onToggleMature,
|
||||
onToggleRights,
|
||||
}) {
|
||||
const [isContentTypeChooserOpen, setIsContentTypeChooserOpen] = useState(() => !metadata.contentType)
|
||||
const [isCategoryChooserOpen, setIsCategoryChooserOpen] = useState(() => !metadata.rootCategoryId)
|
||||
const [isSubCategoryChooserOpen, setIsSubCategoryChooserOpen] = useState(() => !metadata.subCategoryId)
|
||||
const [categorySearch, setCategorySearch] = useState('')
|
||||
const [subCategorySearch, setSubCategorySearch] = useState('')
|
||||
|
||||
const contentTypeOptions = useMemo(
|
||||
() => (Array.isArray(contentTypes) ? contentTypes : []).map((item) => {
|
||||
const normalizedName = String(item?.name || '').trim().toLowerCase()
|
||||
const normalizedSlug = String(item?.slug || '').trim().toLowerCase()
|
||||
|
||||
if (normalizedName === 'other' || normalizedSlug === 'other') {
|
||||
return {
|
||||
...item,
|
||||
name: 'Others',
|
||||
}
|
||||
}
|
||||
|
||||
return item
|
||||
}),
|
||||
[contentTypes]
|
||||
)
|
||||
|
||||
const selectedContentType = useMemo(
|
||||
() => contentTypeOptions.find((item) => String(getContentTypeValue(item)) === String(metadata.contentType || '')) ?? null,
|
||||
[contentTypeOptions, metadata.contentType]
|
||||
)
|
||||
|
||||
const selectedRoot = useMemo(
|
||||
() => filteredCategoryTree.find((item) => String(item.id) === String(metadata.rootCategoryId || '')) ?? null,
|
||||
[filteredCategoryTree, metadata.rootCategoryId]
|
||||
)
|
||||
|
||||
const subCategories = selectedRoot?.children || []
|
||||
const selectedSubCategory = useMemo(
|
||||
() => subCategories.find((item) => String(item.id) === String(metadata.subCategoryId || '')) ?? null,
|
||||
[subCategories, metadata.subCategoryId]
|
||||
)
|
||||
|
||||
const sortedFilteredCategories = useMemo(() => {
|
||||
const sorted = [...filteredCategoryTree].sort((a, b) => a.name.localeCompare(b.name))
|
||||
const q = categorySearch.trim().toLowerCase()
|
||||
return q ? sorted.filter((c) => c.name.toLowerCase().includes(q)) : sorted
|
||||
}, [filteredCategoryTree, categorySearch])
|
||||
|
||||
const sortedFilteredSubCategories = useMemo(() => {
|
||||
const sorted = [...subCategories].sort((a, b) => a.name.localeCompare(b.name))
|
||||
const q = subCategorySearch.trim().toLowerCase()
|
||||
return q ? sorted.filter((s) => s.name.toLowerCase().includes(q)) : sorted
|
||||
}, [subCategories, subCategorySearch])
|
||||
|
||||
useEffect(() => {
|
||||
if (!metadata.contentType) {
|
||||
setIsContentTypeChooserOpen(true)
|
||||
}
|
||||
}, [metadata.contentType])
|
||||
|
||||
useEffect(() => {
|
||||
if (!metadata.rootCategoryId) {
|
||||
setIsCategoryChooserOpen(true)
|
||||
}
|
||||
}, [metadata.rootCategoryId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!metadata.subCategoryId) {
|
||||
setIsSubCategoryChooserOpen(true)
|
||||
}
|
||||
}, [metadata.subCategoryId])
|
||||
|
||||
return (
|
||||
<div className="space-y-5 rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))] p-6 shadow-[0_24px_90px_rgba(2,8,23,0.28)] backdrop-blur sm:p-7">
|
||||
{/* Step header */}
|
||||
@@ -95,56 +164,306 @@ export default function Step2Details({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content type selector */}
|
||||
<section className="rounded-2xl ring-1 ring-white/10 bg-gradient-to-br from-white/[0.04] to-white/[0.01] p-5 sm:p-6">
|
||||
<section className="rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(14,165,233,0.08),_rgba(15,23,36,0.92)_52%)] p-5 sm:p-6">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white">Content type</h3>
|
||||
<p className="mt-0.5 text-xs text-white/55">Choose what kind of artwork this is.</p>
|
||||
<p className="mt-1 text-xs text-white/55">Choose the main content family first.</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-sky-400/35 bg-sky-400/10 px-2.5 py-0.5 text-[11px] uppercase tracking-widest text-sky-300">
|
||||
Step 2a
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ContentTypeSelector
|
||||
contentTypes={contentTypes}
|
||||
selected={metadata.contentType}
|
||||
error={metadataErrors.contentType}
|
||||
onChange={onContentTypeChange}
|
||||
/>
|
||||
{contentTypeOptions.length === 0 && (
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-4 text-sm text-slate-400">
|
||||
No content types are available right now.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedContentType && !isContentTypeChooserOpen && (
|
||||
<div className="rounded-2xl border border-emerald-400/25 bg-emerald-400/[0.08] p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-emerald-200/80">Selected content type</div>
|
||||
<div className="mt-1 text-lg font-semibold text-white">{selectedContentType.name}</div>
|
||||
<div className="mt-1 text-sm text-slate-400">
|
||||
{filteredCategoryTree.length > 0
|
||||
? `Continue by choosing one of the ${filteredCategoryTree.length} matching categories below.`
|
||||
: 'This content type does not have categories yet.'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsContentTypeChooserOpen(true)}
|
||||
className="inline-flex items-center justify-center rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm font-medium text-slate-200 transition hover:border-white/20 hover:bg-white/[0.08] hover:text-white"
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!selectedContentType || isContentTypeChooserOpen) && (
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{contentTypeOptions.map((ct) => {
|
||||
const typeValue = String(getContentTypeValue(ct))
|
||||
const isActive = typeValue === String(metadata.contentType || '')
|
||||
const visualKey = getContentTypeVisualKey(ct)
|
||||
const categoryCount = Array.isArray(ct.categories) ? ct.categories.length : 0
|
||||
|
||||
return (
|
||||
<button
|
||||
key={typeValue || ct.name}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsContentTypeChooserOpen(false)
|
||||
setIsCategoryChooserOpen(true)
|
||||
onContentTypeChange(typeValue)
|
||||
}}
|
||||
className={[
|
||||
'group flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left transition-all',
|
||||
isActive
|
||||
? 'border-emerald-400/40 bg-emerald-400/10 shadow-[0_0_0_1px_rgba(52,211,153,0.18)]'
|
||||
: 'border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.06]',
|
||||
].join(' ')}
|
||||
aria-pressed={isActive}
|
||||
>
|
||||
<div className={`flex h-12 w-12 items-center justify-center rounded-2xl border ${isActive ? 'border-emerald-400/30 bg-emerald-400/10' : 'border-white/10 bg-white/[0.04]'}`}>
|
||||
<img
|
||||
src={`/gfx/mascot_${visualKey}.webp`}
|
||||
alt=""
|
||||
className="h-8 w-8 object-contain"
|
||||
onError={(e) => { e.currentTarget.style.display = 'none' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={`text-sm font-semibold ${isActive ? 'text-emerald-200' : 'text-white'}`}>{ct.name}</div>
|
||||
<div className="mt-1 text-[11px] text-slate-500">{categoryCount} {categoryCount === 1 ? 'category' : 'categories'}</div>
|
||||
</div>
|
||||
<div className={`text-xs ${isActive ? 'text-emerald-300' : 'text-slate-500 group-hover:text-slate-300'}`}>
|
||||
{isActive ? 'Selected' : 'Open'}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{metadataErrors.contentType && <p className="mt-3 text-xs text-red-300">{metadataErrors.contentType}</p>}
|
||||
</section>
|
||||
|
||||
{/* Category selector */}
|
||||
<section className="rounded-2xl ring-1 ring-white/10 bg-gradient-to-br from-white/[0.04] to-white/[0.01] p-5 sm:p-6">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||
<section className="rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(168,85,247,0.08),_rgba(15,23,36,0.88)_55%)] p-5 sm:p-6">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white">Category</h3>
|
||||
<p className="mt-0.5 text-xs text-white/55">
|
||||
{requiresSubCategory ? 'Select a category, then a subcategory.' : 'Select a category.'}
|
||||
</p>
|
||||
<h4 className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Category path</h4>
|
||||
<p className="mt-1 text-sm text-slate-400">Choose the main branch first, then refine with a subcategory when needed.</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-violet-400/35 bg-violet-400/10 px-2.5 py-0.5 text-[11px] uppercase tracking-widest text-violet-300">
|
||||
Step 2b
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<CategorySelector
|
||||
categories={filteredCategoryTree}
|
||||
rootCategoryId={metadata.rootCategoryId}
|
||||
subCategoryId={metadata.subCategoryId}
|
||||
hasContentType={Boolean(metadata.contentType)}
|
||||
error={metadataErrors.category}
|
||||
onRootChange={onRootCategoryChange}
|
||||
onSubChange={onSubCategoryChange}
|
||||
allRoots={allRootCategoryOptions}
|
||||
onRootChangeAll={(rootId, contentTypeValue) => {
|
||||
if (contentTypeValue) {
|
||||
onContentTypeChange(contentTypeValue)
|
||||
}
|
||||
onRootCategoryChange(rootId)
|
||||
}}
|
||||
/>
|
||||
{!selectedContentType && (
|
||||
<div className="mt-5 rounded-2xl border border-dashed border-white/12 bg-white/[0.02] px-5 py-10 text-center">
|
||||
<div className="text-sm font-medium text-white">Select a content type first</div>
|
||||
<p className="mt-2 text-sm text-slate-500">Once you choose the content type, the matching category tree will appear here.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedContentType && (
|
||||
<div className="mt-5 space-y-5">
|
||||
<div className="flex items-center gap-2 text-sm text-slate-400">
|
||||
<span className="rounded-full border border-emerald-400/20 bg-emerald-400/10 px-2.5 py-1 text-emerald-200">{selectedContentType.name}</span>
|
||||
<span>contains {filteredCategoryTree.length} top-level {filteredCategoryTree.length === 1 ? 'category' : 'categories'}</span>
|
||||
</div>
|
||||
|
||||
{selectedRoot && !isCategoryChooserOpen && (
|
||||
<div className="rounded-2xl border border-purple-400/25 bg-purple-400/[0.08] p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-purple-200/80">Selected category</div>
|
||||
<div className="mt-1 text-lg font-semibold text-white">{selectedRoot.name}</div>
|
||||
<div className="mt-1 text-sm text-slate-400">
|
||||
{subCategories.length > 0
|
||||
? `Next step: choose one of the ${subCategories.length} subcategories below.`
|
||||
: 'This category is complete. No subcategory is required.'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setCategorySearch(''); setIsCategoryChooserOpen(true) }}
|
||||
className="inline-flex items-center justify-center rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm font-medium text-slate-200 transition hover:border-white/20 hover:bg-white/[0.08] hover:text-white"
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!selectedRoot || isCategoryChooserOpen) && (
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<svg className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fillRule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clipRule="evenodd" /></svg>
|
||||
<input
|
||||
type="search"
|
||||
value={categorySearch}
|
||||
onChange={(e) => setCategorySearch(e.target.value)}
|
||||
placeholder="Search categories…"
|
||||
className="w-full rounded-xl border border-white/10 bg-white/[0.04] py-2.5 pl-9 pr-4 text-sm text-white placeholder:text-slate-500 focus:border-purple-400/40 focus:outline-none focus:ring-1 focus:ring-purple-400/30"
|
||||
/>
|
||||
</div>
|
||||
{sortedFilteredCategories.length === 0 && (
|
||||
<p className="py-4 text-center text-sm text-slate-500">No categories match “{categorySearch}”</p>
|
||||
)}
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{sortedFilteredCategories.map((cat) => {
|
||||
const isActive = String(metadata.rootCategoryId || '') === String(cat.id)
|
||||
const childCount = cat.children?.length || 0
|
||||
|
||||
return (
|
||||
<button
|
||||
key={cat.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsCategoryChooserOpen(false)
|
||||
onRootCategoryChange(String(cat.id))
|
||||
}}
|
||||
className={[
|
||||
'rounded-2xl border px-4 py-4 text-left transition-all',
|
||||
isActive
|
||||
? 'border-purple-400/40 bg-purple-400/12 shadow-[0_0_0_1px_rgba(192,132,252,0.15)]'
|
||||
: 'border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.05]',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className={`text-sm font-semibold ${isActive ? 'text-purple-200' : 'text-white'}`}>{cat.name}</div>
|
||||
<div className="mt-1 text-[11px] text-slate-500">{childCount > 0 ? `${childCount} subcategories available` : 'Standalone category'}</div>
|
||||
</div>
|
||||
<span className={`rounded-full px-2 py-1 text-[11px] ${isActive ? 'bg-purple-300/15 text-purple-200' : 'bg-white/[0.05] text-slate-500'}`}>
|
||||
{isActive ? 'Selected' : 'Choose'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedRoot && subCategories.length > 0 && (
|
||||
<div className="rounded-2xl border border-cyan-400/15 bg-cyan-400/[0.05] p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h5 className="text-[11px] font-semibold uppercase tracking-[0.18em] text-cyan-200/80">Subcategories</h5>
|
||||
<p className="mt-1 text-sm text-slate-400">Refine <span className="text-white">{selectedRoot.name}</span> with one more level.</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-cyan-400/20 bg-cyan-400/10 px-2 py-1 text-[11px] text-cyan-200">{subCategories.length}</span>
|
||||
</div>
|
||||
|
||||
{!metadata.subCategoryId && requiresSubCategory && (
|
||||
<div className="mt-4 rounded-xl border border-amber-400/20 bg-amber-400/10 px-3 py-2 text-sm text-amber-100">
|
||||
Subcategory still needs to be selected.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedSubCategory && !isSubCategoryChooserOpen && (
|
||||
<div className="mt-4 rounded-2xl border border-cyan-400/25 bg-cyan-400/[0.09] p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-cyan-200/80">Selected subcategory</div>
|
||||
<div className="mt-1 text-lg font-semibold text-white">{selectedSubCategory.name}</div>
|
||||
<div className="mt-1 text-sm text-slate-300">
|
||||
Final category path: <span className="text-white">{selectedRoot.name}</span> / <span className="text-cyan-100">{selectedSubCategory.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSubCategorySearch(''); setIsSubCategoryChooserOpen(true) }}
|
||||
className="inline-flex items-center justify-center rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm font-medium text-slate-200 transition hover:border-white/20 hover:bg-white/[0.08] hover:text-white"
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!selectedSubCategory || isSubCategoryChooserOpen) && (
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="relative">
|
||||
<svg className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fillRule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clipRule="evenodd" /></svg>
|
||||
<input
|
||||
type="search"
|
||||
value={subCategorySearch}
|
||||
onChange={(e) => setSubCategorySearch(e.target.value)}
|
||||
placeholder="Search subcategories…"
|
||||
className="w-full rounded-xl border border-white/10 bg-white/[0.04] py-2.5 pl-9 pr-4 text-sm text-white placeholder:text-slate-500 focus:border-cyan-400/40 focus:outline-none focus:ring-1 focus:ring-cyan-400/30"
|
||||
/>
|
||||
</div>
|
||||
{sortedFilteredSubCategories.length === 0 && (
|
||||
<p className="py-4 text-center text-sm text-slate-500">No subcategories match “{subCategorySearch}”</p>
|
||||
)}
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{sortedFilteredSubCategories.map((sub) => {
|
||||
const isActive = String(metadata.subCategoryId || '') === String(sub.id)
|
||||
return (
|
||||
<button
|
||||
key={sub.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsSubCategoryChooserOpen(false)
|
||||
onSubCategoryChange(String(sub.id))
|
||||
}}
|
||||
className={[
|
||||
'group rounded-2xl border px-4 py-3 text-left transition-all',
|
||||
isActive
|
||||
? 'border-cyan-400/40 bg-cyan-400/[0.13] shadow-[0_0_0_1px_rgba(34,211,238,0.14)]'
|
||||
: 'border-white/10 bg-white/[0.04] hover:border-white/20 hover:bg-white/[0.06]',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className={[
|
||||
'text-sm font-semibold transition-colors',
|
||||
isActive ? 'text-cyan-100' : 'text-slate-100 group-hover:text-white',
|
||||
].join(' ')}>
|
||||
{sub.name}
|
||||
</div>
|
||||
<div className={[
|
||||
'mt-1 text-xs',
|
||||
isActive ? 'text-cyan-200/80' : 'text-slate-500 group-hover:text-slate-300',
|
||||
].join(' ')}>
|
||||
Subcategory option
|
||||
</div>
|
||||
</div>
|
||||
<span className={[
|
||||
'shrink-0 rounded-full px-2.5 py-1 text-[11px] font-medium',
|
||||
isActive
|
||||
? 'bg-cyan-300/15 text-cyan-100'
|
||||
: 'bg-white/[0.05] text-slate-500 group-hover:text-slate-300',
|
||||
].join(' ')}>
|
||||
{isActive ? 'Selected' : 'Choose'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedRoot && subCategories.length === 0 && (
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-400">
|
||||
<span className="font-medium text-white">{selectedRoot.name}</span> does not have subcategories. Selecting it is enough.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{metadataErrors.category && <p className="mt-4 text-xs text-red-300">{metadataErrors.category}</p>}
|
||||
</section>
|
||||
|
||||
{/* Title, tags, description, rights */}
|
||||
@@ -156,6 +475,7 @@ export default function Step2Details({
|
||||
onChangeTitle={onChangeTitle}
|
||||
onChangeTags={onChangeTags}
|
||||
onChangeDescription={onChangeDescription}
|
||||
onToggleMature={onToggleMature}
|
||||
onToggleRights={onToggleRights}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import React from 'react'
|
||||
import { motion, useReducedMotion } from 'framer-motion'
|
||||
|
||||
function stripHtml(value) {
|
||||
return String(value || '')
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* PublishCheckBadge – a single status item for the review section
|
||||
*/
|
||||
@@ -66,6 +74,7 @@ export default function Step3Publish({
|
||||
(c) => String(c.id) === String(metadata.subCategoryId)
|
||||
) ?? null
|
||||
const subLabel = subCategory?.name ?? null
|
||||
const descriptionPreview = stripHtml(metadata.description)
|
||||
|
||||
const checks = [
|
||||
{ label: 'File uploaded', ok: uploadReady },
|
||||
@@ -137,6 +146,7 @@ export default function Step3Publish({
|
||||
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-white/55">
|
||||
<span>Tags: <span className="text-white/75">{(metadata.tags || []).length}</span></span>
|
||||
<span>Audience: <span className="text-white/75">{metadata.isMature ? 'Mature' : 'General'}</span></span>
|
||||
{!isArchive && fileMetadata?.resolution && fileMetadata.resolution !== '—' && (
|
||||
<span>Resolution: <span className="text-white/75">{fileMetadata.resolution}</span></span>
|
||||
)}
|
||||
@@ -145,8 +155,8 @@ export default function Step3Publish({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{metadata.description && (
|
||||
<p className="line-clamp-2 text-xs text-white/50">{metadata.description}</p>
|
||||
{descriptionPreview && (
|
||||
<p className="line-clamp-2 text-xs text-white/50">{descriptionPreview}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user