- Add ArtworkShareModal with glassmorphism UI (Facebook, X, Pinterest, Email, Copy Link, Embed Code)
- Add ArtworkShareButton with lazy-loaded modal and native share fallback
- Add useWebShare hook abstracting navigator.share with AbortError handling
- Add ShareToast auto-dismissing notification component
- Add share() endpoint to ArtworkInteractionController (POST /api/artworks/{id}/share)
- Add artwork_shares migration for Phase 2 share tracking
- Refactor ArtworkActionBar to use new ArtworkShareButton component
406 lines
17 KiB
JavaScript
406 lines
17 KiB
JavaScript
import React, { useEffect, useRef, useState } from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
import ArtworkShareButton from './ArtworkShareButton'
|
|
|
|
function formatCount(value) {
|
|
const n = Number(value || 0)
|
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
|
|
if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, '')}k`
|
|
return `${n}`
|
|
}
|
|
|
|
/* ── SVG Icons ─────────────────────────────────────────────────────────────── */
|
|
function HeartIcon({ filled }) {
|
|
return filled ? (
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="h-5 w-5">
|
|
<path d="m11.645 20.91-.007-.003-.022-.012a15.247 15.247 0 0 1-.383-.218 25.18 25.18 0 0 1-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0 1 12 5.052 5.5 5.5 0 0 1 16.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 0 1-4.244 3.17 15.247 15.247 0 0 1-.383.219l-.022.012-.007.004-.003.001a.752.752 0 0 1-.704 0l-.003-.001Z" />
|
|
</svg>
|
|
) : (
|
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-5 w-5">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
function BookmarkIcon({ filled }) {
|
|
return filled ? (
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="h-5 w-5">
|
|
<path fillRule="evenodd" d="M6.32 2.577a49.255 49.255 0 0 1 11.36 0c1.497.174 2.57 1.46 2.57 2.93V21a.75.75 0 0 1-1.085.67L12 18.089l-7.165 3.583A.75.75 0 0 1 3.75 21V5.507c0-1.47 1.073-2.756 2.57-2.93Z" clipRule="evenodd" />
|
|
</svg>
|
|
) : (
|
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-5 w-5">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0 1 11.186 0Z" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
function CloudDownIcon() {
|
|
return (
|
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-5 w-5">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9.75v6.75m0 0-3-3m3 3 3-3m-8.25 6a4.5 4.5 0 0 1-1.41-8.775 5.25 5.25 0 0 1 10.233-2.33 3 3 0 0 1 3.758 3.848A3.752 3.752 0 0 1 18 19.5H6.75Z" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
function DownloadArrowIcon() {
|
|
return (
|
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-4 w-4">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
/* ShareIcon removed — now provided by ArtworkShareButton */
|
|
|
|
function FlagIcon() {
|
|
return (
|
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-5 w-5">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3 3v1.5M3 21v-6m0 0 2.77-.693a9 9 0 0 1 6.208.682l.108.054a9 9 0 0 0 6.086.71l3.114-.732a48.524 48.524 0 0 1-.005-10.499l-3.11.732a9 9 0 0 1-6.085-.711l-.108-.054a9 9 0 0 0-6.208-.682L3 4.5M3 15V4.5" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
/* ── 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 [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)
|
|
useEffect(() => {
|
|
setFavorited(Boolean(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 : '#')
|
|
const csrfToken = typeof document !== 'undefined'
|
|
? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
|
|
: null
|
|
|
|
// Track view
|
|
useEffect(() => {
|
|
if (!artwork?.id) return
|
|
const key = `sb_viewed_${artwork.id}`
|
|
if (typeof sessionStorage !== 'undefined' && sessionStorage.getItem(key)) return
|
|
fetch(`/api/art/${artwork.id}/view`, {
|
|
method: 'POST',
|
|
headers: { 'X-CSRF-TOKEN': csrfToken || '', 'Content-Type': 'application/json' },
|
|
credentials: 'same-origin',
|
|
}).then(res => {
|
|
if (res.ok && typeof sessionStorage !== 'undefined') sessionStorage.setItem(key, '1')
|
|
}).catch(() => {})
|
|
}, [artwork?.id]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
const postInteraction = async (url, body) => {
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken || '' },
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify(body),
|
|
})
|
|
if (!response.ok) throw new Error('Request failed')
|
|
return response.json()
|
|
}
|
|
|
|
const handleDownload = async () => {
|
|
if (downloading || !artwork?.id) return
|
|
setDownloading(true)
|
|
try {
|
|
const res = await fetch(`/api/art/${artwork.id}/download`, {
|
|
method: 'POST',
|
|
headers: { 'X-CSRF-TOKEN': csrfToken || '', 'Content-Type': 'application/json' },
|
|
credentials: 'same-origin',
|
|
})
|
|
const data = res.ok ? await res.json() : null
|
|
const url = data?.url || fallbackUrl
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = data?.filename || ''
|
|
a.rel = 'noopener noreferrer'
|
|
document.body.appendChild(a)
|
|
a.click()
|
|
document.body.removeChild(a)
|
|
} catch {
|
|
window.open(fallbackUrl, '_blank', 'noopener,noreferrer')
|
|
} finally {
|
|
setDownloading(false)
|
|
}
|
|
}
|
|
|
|
const onToggleFavorite = async () => {
|
|
const nextState = !favorited
|
|
setFavorited(nextState)
|
|
try {
|
|
await postInteraction(`/api/artworks/${artwork.id}/favorite`, { state: nextState })
|
|
onStatsChange?.({ favorites: nextState ? 1 : -1 })
|
|
} catch { setFavorited(!nextState) }
|
|
}
|
|
|
|
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 })
|
|
setReported(true)
|
|
setReportOpen(false)
|
|
} catch { /* noop */ }
|
|
finally { setReporting(false) }
|
|
}
|
|
|
|
const favCount = formatCount(stats?.favorites ?? artwork?.stats?.favorites ?? 0)
|
|
const viewCount = formatCount(stats?.views ?? artwork?.stats?.views ?? 0)
|
|
|
|
return (
|
|
<>
|
|
{/* ── Desktop centered bar ────────────────────────────────────── */}
|
|
<div className="hidden lg:flex lg:items-center lg:justify-center lg:gap-3">
|
|
{/* Favourite (heart) stat pill */}
|
|
<button
|
|
type="button"
|
|
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-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={favorited} />
|
|
<span className="tabular-nums">{favCount}</span>
|
|
</button>
|
|
|
|
{/* Views stat pill */}
|
|
<div 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">
|
|
<CloudDownIcon />
|
|
<span className="tabular-nums">{viewCount}</span>
|
|
</div>
|
|
|
|
{/* Share pill */}
|
|
<ArtworkShareButton artwork={artwork} shareUrl={shareUrl} size="default" />
|
|
|
|
{/* Report pill */}
|
|
<button
|
|
type="button"
|
|
aria-label="Report artwork"
|
|
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 />
|
|
{reported ? 'Reported' : 'Report'}
|
|
</button>
|
|
|
|
{/* Download button */}
|
|
<button
|
|
type="button"
|
|
aria-label="Download artwork"
|
|
onClick={handleDownload}
|
|
disabled={downloading}
|
|
className="inline-flex items-center gap-2 rounded-full bg-accent px-6 py-2.5 text-sm font-bold text-deep shadow-lg shadow-accent/25 transition-all duration-200 hover:brightness-110 hover:shadow-xl hover:shadow-accent/30 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 disabled:cursor-wait disabled:opacity-60"
|
|
>
|
|
<DownloadArrowIcon />
|
|
{downloading ? 'Downloading…' : 'Download'}
|
|
</button>
|
|
</div>
|
|
|
|
{/* ── Mobile fixed bottom bar ─────────────────────────────────── */}
|
|
<div className="fixed inset-x-0 bottom-0 z-50 border-t border-white/[0.08] bg-nova-900/95 px-3 py-2.5 backdrop-blur-md lg:hidden">
|
|
<div className="flex items-center justify-center gap-2">
|
|
<button
|
|
type="button"
|
|
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-rose-500/40 bg-rose-500/15 text-rose-400'
|
|
: 'border-white/[0.08] bg-white/[0.04] text-white/70',
|
|
].join(' ')}
|
|
>
|
|
<HeartIcon filled={favorited} />
|
|
<span className="tabular-nums">{favCount}</span>
|
|
</button>
|
|
|
|
{/* Share */}
|
|
<ArtworkShareButton artwork={artwork} shareUrl={shareUrl} size="small" />
|
|
|
|
{/* Report */}
|
|
<button
|
|
type="button"
|
|
aria-label="Report"
|
|
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>
|
|
|
|
<button
|
|
type="button"
|
|
aria-label="Download artwork"
|
|
onClick={handleDownload}
|
|
disabled={downloading}
|
|
className="inline-flex items-center gap-1.5 rounded-full bg-accent px-5 py-2 text-xs font-bold text-deep transition hover:brightness-110 disabled:cursor-wait disabled:opacity-60"
|
|
>
|
|
<DownloadArrowIcon />
|
|
{downloading ? '…' : 'Download'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Report modal */}
|
|
<ReportModal
|
|
open={reportOpen}
|
|
onClose={() => setReportOpen(false)}
|
|
onSubmit={submitReport}
|
|
submitting={reporting}
|
|
/>
|
|
</>
|
|
)
|
|
}
|