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

@@ -0,0 +1,77 @@
import React, { lazy, Suspense, useCallback, useState } from 'react'
import useWebShare from '../../hooks/useWebShare'
const ArtworkShareModal = lazy(() => import('./ArtworkShareModal'))
/* ── Share icon (lucide-style) ───────────────────────────────────────────── */
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>
)
}
/**
* ArtworkShareButton renders the Share pill and manages modal / native share.
*
* Props:
* artwork artwork object
* shareUrl canonical URL to share
* size 'default' | 'small' (for mobile bar)
*/
export default function ArtworkShareButton({ artwork, shareUrl, size = 'default' }) {
const [modalOpen, setModalOpen] = useState(false)
const openModal = useCallback(
() => setModalOpen(true),
[],
)
const closeModal = useCallback(
() => setModalOpen(false),
[],
)
const { share } = useWebShare({ onFallback: openModal })
const handleClick = () => {
share({
title: artwork?.title || 'Artwork',
text: artwork?.description?.substring(0, 120) || '',
url: shareUrl || artwork?.canonical_url || window.location.href,
})
}
const isSmall = size === 'small'
return (
<>
<button
type="button"
aria-label="Share artwork"
onClick={handleClick}
className={
isSmall
? '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-white/[0.15] hover:bg-white/[0.07] hover:text-white'
: 'group 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 hover:shadow-lg hover:shadow-white/[0.03]'
}
title="Share"
>
<ShareIcon />
{!isSmall && <span>Share</span>}
</button>
{/* Lazy-loaded modal only rendered when opened */}
{modalOpen && (
<Suspense fallback={null}>
<ArtworkShareModal
open={modalOpen}
onClose={closeModal}
artwork={artwork}
shareUrl={shareUrl}
/>
</Suspense>
)}
</>
)
}