feat: merge Like+Favourite into single heart button, add Report modal with required reason & proof, fix favourite 422 (user_favorites -> artwork_favourites)
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
function formatCount(value) {
|
||||
const n = Number(value || 0)
|
||||
@@ -64,17 +65,147 @@ function FlagIcon() {
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Report Modal ──────────────────────────────────────────────────────────── */
|
||||
const REPORT_REASONS = [
|
||||
'Inappropriate content',
|
||||
'Copyright violation',
|
||||
'Spam or misleading',
|
||||
'Offensive or abusive',
|
||||
]
|
||||
|
||||
function ReportModal({ open, onClose, onSubmit, submitting }) {
|
||||
const [selected, setSelected] = useState('')
|
||||
const [details, setDetails] = useState('')
|
||||
const backdropRef = useRef(null)
|
||||
const inputRef = useRef(null)
|
||||
|
||||
// Reset & focus when opening
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelected('')
|
||||
setDetails('')
|
||||
const t = setTimeout(() => inputRef.current?.focus(), 80)
|
||||
return () => clearTimeout(t)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handler = (e) => { if (e.key === 'Escape') onClose() }
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [open, onClose])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const trimmedDetails = details.trim()
|
||||
const canSubmit = selected.length > 0 && trimmedDetails.length >= 10 && !submitting
|
||||
const fullReason = `${selected}: ${trimmedDetails}`
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={backdropRef}
|
||||
onClick={(e) => { if (e.target === backdropRef.current) onClose() }}
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
|
||||
>
|
||||
<div className="w-full max-w-md rounded-2xl border border-white/[0.08] bg-nova-900 shadow-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-white/[0.06] px-6 py-4">
|
||||
<h3 className="text-base font-semibold text-white">Report Artwork</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-1.5 text-white/40 transition hover:bg-white/[0.06] hover:text-white/70"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-5 w-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="space-y-4 px-6 py-5">
|
||||
{/* Step 1 — pick a reason */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white/60">Reason <span className="text-red-400">*</span></label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{REPORT_REASONS.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
onClick={() => setSelected(r)}
|
||||
className={[
|
||||
'rounded-full border px-3.5 py-1.5 text-xs font-medium transition-all',
|
||||
selected === r
|
||||
? 'border-red-500/50 bg-red-500/15 text-red-400'
|
||||
: 'border-white/[0.08] bg-white/[0.04] text-white/60 hover:border-white/[0.15] hover:text-white/80',
|
||||
].join(' ')}
|
||||
>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 2 — describe & prove */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white/60">
|
||||
Details & proof <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={details}
|
||||
onChange={(e) => setDetails(e.target.value)}
|
||||
maxLength={1000}
|
||||
rows={4}
|
||||
placeholder="Please describe the issue and provide any links or evidence that support your report…"
|
||||
className="w-full resize-none rounded-xl border border-white/[0.08] bg-white/[0.03] px-4 py-3 text-sm text-white placeholder-white/30 outline-none transition focus:border-white/[0.15] focus:ring-1 focus:ring-white/[0.1]"
|
||||
/>
|
||||
<p className="text-xs text-white/30">
|
||||
{trimmedDetails.length < 10
|
||||
? `At least 10 characters required (${trimmedDetails.length}/10)`
|
||||
: `${details.length}/1000`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 border-t border-white/[0.06] px-6 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-full border border-white/[0.08] bg-white/[0.04] px-5 py-2 text-sm font-medium text-white/60 transition hover:bg-white/[0.07] hover:text-white/80"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canSubmit}
|
||||
onClick={() => onSubmit(fullReason)}
|
||||
className="rounded-full bg-red-600 px-5 py-2 text-sm font-semibold text-white shadow-lg shadow-red-600/20 transition hover:bg-red-500 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{submitting ? 'Sending…' : 'Submit Report'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStatsChange }) {
|
||||
const [liked, setLiked] = useState(Boolean(artwork?.viewer?.is_liked))
|
||||
const [favorited, setFavorited] = useState(Boolean(artwork?.viewer?.is_favorited))
|
||||
const [downloading, setDownloading] = useState(false)
|
||||
const [reporting, setReporting] = useState(false)
|
||||
const [reported, setReported] = useState(false)
|
||||
const [reportOpen, setReportOpen] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setLiked(Boolean(artwork?.viewer?.is_liked))
|
||||
setFavorited(Boolean(artwork?.viewer?.is_favorited))
|
||||
}, [artwork?.id, artwork?.viewer?.is_liked, artwork?.viewer?.is_favorited])
|
||||
}, [artwork?.id, artwork?.viewer?.is_favorited])
|
||||
|
||||
const fallbackUrl = artwork?.thumbs?.xl?.url || artwork?.thumbs?.lg?.url || artwork?.file?.url || '#'
|
||||
const shareUrl = canonicalUrl || artwork?.canonical_url || (typeof window !== 'undefined' ? window.location.href : '#')
|
||||
@@ -132,15 +263,6 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
|
||||
}
|
||||
}
|
||||
|
||||
const onToggleLike = async () => {
|
||||
const nextState = !liked
|
||||
setLiked(nextState)
|
||||
try {
|
||||
await postInteraction(`/api/artworks/${artwork.id}/like`, { state: nextState })
|
||||
onStatsChange?.({ likes: nextState ? 1 : -1 })
|
||||
} catch { setLiked(!nextState) }
|
||||
}
|
||||
|
||||
const onToggleFavorite = async () => {
|
||||
const nextState = !favorited
|
||||
setFavorited(nextState)
|
||||
@@ -162,16 +284,22 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
|
||||
} catch { /* noop */ }
|
||||
}
|
||||
|
||||
const onReport = async () => {
|
||||
const openReport = () => {
|
||||
if (reported) return
|
||||
setReportOpen(true)
|
||||
}
|
||||
|
||||
const submitReport = async (reason) => {
|
||||
if (reporting) return
|
||||
setReporting(true)
|
||||
try {
|
||||
await postInteraction(`/api/artworks/${artwork.id}/report`, { reason: 'Reported from artwork page' })
|
||||
await postInteraction(`/api/artworks/${artwork.id}/report`, { reason })
|
||||
setReported(true)
|
||||
setReportOpen(false)
|
||||
} catch { /* noop */ }
|
||||
finally { setReporting(false) }
|
||||
}
|
||||
|
||||
const likeCount = formatCount(stats?.likes ?? artwork?.stats?.likes ?? 0)
|
||||
const favCount = formatCount(stats?.favorites ?? artwork?.stats?.favorites ?? 0)
|
||||
const viewCount = formatCount(stats?.views ?? artwork?.stats?.views ?? 0)
|
||||
|
||||
@@ -179,35 +307,19 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
|
||||
<>
|
||||
{/* ── Desktop centered bar ────────────────────────────────────── */}
|
||||
<div className="hidden lg:flex lg:items-center lg:justify-center lg:gap-3">
|
||||
{/* Like stat pill */}
|
||||
{/* Favourite (heart) stat pill */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={liked ? 'Unlike artwork' : 'Like artwork'}
|
||||
onClick={onToggleLike}
|
||||
className={[
|
||||
'inline-flex items-center gap-2 rounded-full border px-5 py-2.5 text-sm font-medium transition-all duration-200',
|
||||
liked
|
||||
? 'border-rose-500/40 bg-rose-500/15 text-rose-400 shadow-lg shadow-rose-500/10 hover:bg-rose-500/20'
|
||||
: 'border-white/[0.08] bg-white/[0.04] text-white/70 hover:border-white/[0.15] hover:bg-white/[0.07] hover:text-white',
|
||||
].join(' ')}
|
||||
>
|
||||
<HeartIcon filled={liked} />
|
||||
<span className="tabular-nums">{likeCount}</span>
|
||||
</button>
|
||||
|
||||
{/* Favorite/bookmark stat pill */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={favorited ? 'Unsave artwork' : 'Save artwork'}
|
||||
aria-label={favorited ? 'Remove from favourites' : 'Add to favourites'}
|
||||
onClick={onToggleFavorite}
|
||||
className={[
|
||||
'inline-flex items-center gap-2 rounded-full border px-5 py-2.5 text-sm font-medium transition-all duration-200',
|
||||
favorited
|
||||
? 'border-amber-500/40 bg-amber-500/15 text-amber-400 shadow-lg shadow-amber-500/10 hover:bg-amber-500/20'
|
||||
? 'border-rose-500/40 bg-rose-500/15 text-rose-400 shadow-lg shadow-rose-500/10 hover:bg-rose-500/20'
|
||||
: 'border-white/[0.08] bg-white/[0.04] text-white/70 hover:border-white/[0.15] hover:bg-white/[0.07] hover:text-white',
|
||||
].join(' ')}
|
||||
>
|
||||
<BookmarkIcon filled={favorited} />
|
||||
<HeartIcon filled={favorited} />
|
||||
<span className="tabular-nums">{favCount}</span>
|
||||
</button>
|
||||
|
||||
@@ -232,12 +344,17 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Report artwork"
|
||||
onClick={onReport}
|
||||
disabled={reporting}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-white/[0.08] bg-white/[0.04] px-5 py-2.5 text-sm font-medium text-white/70 transition-all duration-200 hover:border-red-500/40 hover:bg-red-500/10 hover:text-red-400 disabled:cursor-wait disabled:opacity-50"
|
||||
onClick={openReport}
|
||||
disabled={reported}
|
||||
className={[
|
||||
'inline-flex items-center gap-2 rounded-full border px-5 py-2.5 text-sm font-medium transition-all duration-200',
|
||||
reported
|
||||
? 'border-red-500/30 bg-red-500/10 text-red-400/70 cursor-default'
|
||||
: 'border-white/[0.08] bg-white/[0.04] text-white/70 hover:border-red-500/40 hover:bg-red-500/10 hover:text-red-400',
|
||||
].join(' ')}
|
||||
>
|
||||
<FlagIcon />
|
||||
{reporting ? '…' : 'Report'}
|
||||
{reported ? 'Reported' : 'Report'}
|
||||
</button>
|
||||
|
||||
{/* Download button */}
|
||||
@@ -258,31 +375,16 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={liked ? 'Unlike' : 'Like'}
|
||||
onClick={onToggleLike}
|
||||
className={[
|
||||
'inline-flex items-center gap-1.5 rounded-full border px-3.5 py-2 text-xs font-medium transition-all',
|
||||
liked
|
||||
? 'border-rose-500/40 bg-rose-500/15 text-rose-400'
|
||||
: 'border-white/[0.08] bg-white/[0.04] text-white/70',
|
||||
].join(' ')}
|
||||
>
|
||||
<HeartIcon filled={liked} />
|
||||
<span className="tabular-nums">{likeCount}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label={favorited ? 'Unsave' : 'Save'}
|
||||
aria-label={favorited ? 'Remove from favourites' : 'Add to favourites'}
|
||||
onClick={onToggleFavorite}
|
||||
className={[
|
||||
'inline-flex items-center gap-1.5 rounded-full border px-3.5 py-2 text-xs font-medium transition-all',
|
||||
favorited
|
||||
? 'border-amber-500/40 bg-amber-500/15 text-amber-400'
|
||||
? 'border-rose-500/40 bg-rose-500/15 text-rose-400'
|
||||
: 'border-white/[0.08] bg-white/[0.04] text-white/70',
|
||||
].join(' ')}
|
||||
>
|
||||
<BookmarkIcon filled={favorited} />
|
||||
<HeartIcon filled={favorited} />
|
||||
<span className="tabular-nums">{favCount}</span>
|
||||
</button>
|
||||
|
||||
@@ -300,9 +402,14 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Report"
|
||||
onClick={onReport}
|
||||
disabled={reporting}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-white/[0.08] bg-white/[0.04] px-3.5 py-2 text-xs font-medium text-white/70 transition-all hover:border-red-500/40 hover:text-red-400 disabled:opacity-50"
|
||||
onClick={openReport}
|
||||
disabled={reported}
|
||||
className={[
|
||||
'inline-flex items-center gap-1.5 rounded-full border px-3.5 py-2 text-xs font-medium transition-all',
|
||||
reported
|
||||
? 'border-red-500/30 bg-red-500/10 text-red-400/70 cursor-default'
|
||||
: 'border-white/[0.08] bg-white/[0.04] text-white/70 hover:border-red-500/40 hover:text-red-400',
|
||||
].join(' ')}
|
||||
>
|
||||
<FlagIcon />
|
||||
</button>
|
||||
@@ -319,6 +426,14 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Report modal */}
|
||||
<ReportModal
|
||||
open={reportOpen}
|
||||
onClose={() => setReportOpen(false)}
|
||||
onSubmit={submitReport}
|
||||
submitting={reporting}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user