feat: artwork share system with modal, native Web Share API, and tracking

- 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
This commit is contained in:
2026-02-28 15:29:45 +01:00
parent 568b3f3abb
commit 90f244f264
8 changed files with 569 additions and 38 deletions

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import ArtworkShareButton from './ArtworkShareButton'
function formatCount(value) {
const n = Number(value || 0)
@@ -49,13 +50,7 @@ function DownloadArrowIcon() {
)
}
function ShareIcon() {
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="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 1 1 0-2.684m0 2.684 6.632 3.316m-6.632-6 6.632-3.316m0 0a3 3 0 1 0 5.367-2.684 3 3 0 0 0-5.367 2.684Zm0 9.316a3 3 0 1 0 5.368 2.684 3 3 0 0 0-5.368-2.684Z" />
</svg>
)
}
/* ShareIcon removed — now provided by ArtworkShareButton */
function FlagIcon() {
return (
@@ -201,8 +196,6 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
const [reporting, setReporting] = useState(false)
const [reported, setReported] = useState(false)
const [reportOpen, setReportOpen] = useState(false)
const [copied, setCopied] = useState(false)
useEffect(() => {
setFavorited(Boolean(artwork?.viewer?.is_favorited))
}, [artwork?.id, artwork?.viewer?.is_favorited])
@@ -272,18 +265,6 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
} catch { setFavorited(!nextState) }
}
const onShare = async () => {
try {
if (navigator.share) {
await navigator.share({ title: artwork?.title || 'Artwork', url: shareUrl })
return
}
await navigator.clipboard.writeText(shareUrl)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch { /* noop */ }
}
const openReport = () => {
if (reported) return
setReportOpen(true)
@@ -330,15 +311,7 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
</div>
{/* Share pill */}
<button
type="button"
aria-label="Share artwork"
onClick={onShare}
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-white/[0.15] hover:bg-white/[0.07] hover:text-white"
>
<ShareIcon />
{copied ? 'Copied!' : 'Share'}
</button>
<ArtworkShareButton artwork={artwork} shareUrl={shareUrl} size="default" />
{/* Report pill */}
<button
@@ -389,14 +362,7 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
</button>
{/* Share */}
<button
type="button"
aria-label="Share"
onClick={onShare}
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"
>
<ShareIcon />
</button>
<ArtworkShareButton artwork={artwork} shareUrl={shareUrl} size="small" />
{/* Report */}
<button