Files
SkinbaseNova/resources/js/components/artwork/ArtworkShareModal.jsx
Gregor Klevze 90f244f264 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
2026-02-28 15:29:45 +01:00

337 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import ShareToast from '../ui/ShareToast'
/* ── Platform share URLs ─────────────────────────────────────────────────── */
function facebookUrl(url) {
return `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`
}
function twitterUrl(url, title) {
return `https://twitter.com/intent/tweet?url=${encodeURIComponent(url)}&text=${encodeURIComponent(title)}`
}
function pinterestUrl(url, imageUrl, title) {
return `https://pinterest.com/pin/create/button/?url=${encodeURIComponent(url)}&media=${encodeURIComponent(imageUrl)}&description=${encodeURIComponent(title)}`
}
function emailUrl(url, title) {
return `mailto:?subject=${encodeURIComponent(title)}&body=${encodeURIComponent(url)}`
}
/* ── Icons ────────────────────────────────────────────────────────────────── */
function CopyIcon() {
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="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 0 1 1.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 0 0-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 0 1-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 0 0-3.375-3.375h-1.5a1.125 1.125 0 0 1-1.125-1.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H9.75" />
</svg>
)
}
function CheckIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-5 w-5 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>
)
}
function FacebookIcon() {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className="h-5 w-5">
<path d="M22 12c0-5.523-4.477-10-10-10S2 6.477 2 12c0 4.991 3.657 9.128 8.438 9.878v-6.987h-2.54V12h2.54V9.797c0-2.506 1.492-3.89 3.777-3.89 1.094 0 2.238.195 2.238.195v2.46h-1.26c-1.243 0-1.63.771-1.63 1.562V12h2.773l-.443 2.89h-2.33v6.988C18.343 21.128 22 16.991 22 12Z" />
</svg>
)
}
function XTwitterIcon() {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className="h-5 w-5">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231 5.45-6.231Zm-1.161 17.52h1.833L7.084 4.126H5.117L17.083 19.77Z" />
</svg>
)
}
function PinterestIcon() {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className="h-5 w-5">
<path d="M12 2C6.477 2 2 6.477 2 12c0 4.236 2.636 7.855 6.356 9.312-.088-.791-.167-2.005.035-2.868.181-.78 1.172-4.97 1.172-4.97s-.299-.598-.299-1.482c0-1.388.806-2.425 1.808-2.425.853 0 1.265.64 1.265 1.408 0 .858-.546 2.14-.828 3.33-.236.995.5 1.807 1.482 1.807 1.778 0 3.144-1.874 3.144-4.58 0-2.393-1.72-4.068-4.177-4.068-2.845 0-4.515 2.135-4.515 4.34 0 .859.331 1.781.745 2.282a.3.3 0 0 1 .069.288l-.278 1.133c-.044.183-.145.222-.335.134-1.249-.581-2.03-2.407-2.03-3.874 0-3.154 2.292-6.052 6.608-6.052 3.469 0 6.165 2.472 6.165 5.776 0 3.447-2.173 6.22-5.19 6.22-1.013 0-1.965-.527-2.291-1.148l-.623 2.378c-.226.869-.835 1.958-1.244 2.621.937.29 1.931.446 2.962.446 5.523 0 10-4.477 10-10S17.523 2 12 2Z" />
</svg>
)
}
function EmailIcon() {
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="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75" />
</svg>
)
}
function EmbedIcon() {
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="M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5" />
</svg>
)
}
function CloseIcon() {
return (
<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>
)
}
/* ── Helpers ──────────────────────────────────────────────────────────────── */
function openShareWindow(url) {
window.open(url, '_blank', 'noopener,noreferrer,width=600,height=500')
}
function trackShare(artworkId, platform) {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
fetch(`/api/artworks/${artworkId}/share`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken || '' },
credentials: 'same-origin',
body: JSON.stringify({ platform }),
}).catch(() => {})
}
/* ── Main component ──────────────────────────────────────────────────────── */
/**
* ArtworkShareModal
*
* Props:
* open boolean, whether modal is visible
* onClose callback to close modal
* artwork artwork object (id, title, description, thumbs, canonical_url, …)
* shareUrl canonical share URL
*/
export default function ArtworkShareModal({ open, onClose, artwork, shareUrl }) {
const backdropRef = useRef(null)
const [linkCopied, setLinkCopied] = useState(false)
const [embedCopied, setEmbedCopied] = useState(false)
const [showEmbed, setShowEmbed] = useState(false)
const [toastVisible, setToastVisible] = useState(false)
const [toastMessage, setToastMessage] = useState('')
const url = shareUrl || artwork?.canonical_url || (typeof window !== 'undefined' ? window.location.href : '#')
const title = artwork?.title || 'Artwork'
const imageUrl = artwork?.thumbs?.xl?.url || artwork?.thumbs?.lg?.url || artwork?.thumbs?.md?.url || ''
const thumbMdUrl = artwork?.thumbs?.md?.url || imageUrl
const embedCode = `<a href="${url}">\n <img src="${thumbMdUrl}" alt="${title.replace(/"/g, '&quot;')}" />\n</a>`
// Lock body scroll when open
useEffect(() => {
if (open) {
document.body.style.overflow = 'hidden'
return () => { document.body.style.overflow = '' }
}
}, [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])
// Reset state when re-opening
useEffect(() => {
if (open) {
setLinkCopied(false)
setEmbedCopied(false)
setShowEmbed(false)
}
}, [open])
const showToast = useCallback((msg) => {
setToastMessage(msg)
setToastVisible(true)
}, [])
const handleCopyLink = async () => {
try {
await navigator.clipboard.writeText(url)
setLinkCopied(true)
showToast('Link copied!')
trackShare(artwork?.id, 'copy')
setTimeout(() => setLinkCopied(false), 2500)
} catch { /* noop */ }
}
const handleCopyEmbed = async () => {
try {
await navigator.clipboard.writeText(embedCode)
setEmbedCopied(true)
showToast('Embed code copied!')
trackShare(artwork?.id, 'embed')
setTimeout(() => setEmbedCopied(false), 2500)
} catch { /* noop */ }
}
const handlePlatformShare = (platform, shareLink) => {
openShareWindow(shareLink)
trackShare(artwork?.id, platform)
onClose()
}
if (!open) return null
const SHARE_OPTIONS = [
{
label: linkCopied ? 'Copied!' : 'Copy Link',
icon: linkCopied ? <CheckIcon /> : <CopyIcon />,
onClick: handleCopyLink,
className: linkCopied
? 'border-emerald-500/40 bg-emerald-500/15 text-emerald-400'
: 'border-white/[0.08] bg-white/[0.04] text-white/70 hover:border-white/[0.15] hover:bg-white/[0.07] hover:text-white',
},
{
label: 'Facebook',
icon: <FacebookIcon />,
onClick: () => handlePlatformShare('facebook', facebookUrl(url)),
className: 'border-white/[0.08] bg-white/[0.04] text-white/70 hover:border-[#1877F2]/40 hover:bg-[#1877F2]/15 hover:text-[#1877F2]',
},
{
label: 'X (Twitter)',
icon: <XTwitterIcon />,
onClick: () => handlePlatformShare('twitter', twitterUrl(url, title)),
className: 'border-white/[0.08] bg-white/[0.04] text-white/70 hover:border-white/30 hover:bg-white/[0.10] hover:text-white',
},
{
label: 'Pinterest',
icon: <PinterestIcon />,
onClick: () => handlePlatformShare('pinterest', pinterestUrl(url, imageUrl, title)),
className: 'border-white/[0.08] bg-white/[0.04] text-white/70 hover:border-[#E60023]/40 hover:bg-[#E60023]/15 hover:text-[#E60023]',
},
{
label: 'Email',
icon: <EmailIcon />,
onClick: () => { window.location.href = emailUrl(url, title); trackShare(artwork?.id, 'email') },
className: 'border-white/[0.08] bg-white/[0.04] text-white/70 hover:border-white/[0.15] hover:bg-white/[0.07] hover:text-white',
},
]
return createPortal(
<>
{/* Backdrop */}
<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"
role="dialog"
aria-modal="true"
aria-label="Share this artwork"
>
{/* Modal container — glassmorphism */}
<div className="w-full max-w-md rounded-2xl border border-nova-700/50 bg-nova-900/80 shadow-2xl backdrop-blur-xl">
{/* 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">Share this 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 share dialog"
>
<CloseIcon />
</button>
</div>
{/* Artwork preview */}
{thumbMdUrl && (
<div className="flex items-center gap-3 border-b border-white/[0.06] px-6 py-3">
<img
src={thumbMdUrl}
alt={title}
className="h-14 w-14 rounded-lg object-cover"
loading="lazy"
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-white">{title}</p>
{artwork?.user?.username && (
<p className="truncate text-xs text-white/50">by {artwork.user.username}</p>
)}
</div>
</div>
)}
{/* Share buttons grid */}
<div className="grid grid-cols-3 gap-2.5 px-6 py-5 sm:grid-cols-5">
{SHARE_OPTIONS.map((opt) => (
<button
key={opt.label}
type="button"
onClick={opt.onClick}
className={[
'flex flex-col items-center gap-1.5 rounded-xl border px-2 py-3 text-xs font-medium transition-all duration-200',
opt.className,
].join(' ')}
>
{opt.icon}
<span className="truncate">{opt.label}</span>
</button>
))}
</div>
{/* Embed section */}
<div className="border-t border-white/[0.06] px-6 py-4">
<button
type="button"
onClick={() => setShowEmbed(!showEmbed)}
className="flex items-center gap-2 text-sm font-medium text-white/60 transition hover:text-white/80"
>
<EmbedIcon />
{showEmbed ? 'Hide Embed Code' : 'Embed Code'}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
className={`h-3.5 w-3.5 transition-transform duration-200 ${showEmbed ? 'rotate-180' : ''}`}
>
<path fillRule="evenodd" d="M4.22 6.22a.75.75 0 0 1 1.06 0L8 8.94l2.72-2.72a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0L4.22 7.28a.75.75 0 0 1 0-1.06Z" clipRule="evenodd" />
</svg>
</button>
{showEmbed && (
<div className="mt-3 space-y-2">
<textarea
readOnly
value={embedCode}
rows={3}
className="w-full resize-none rounded-xl border border-white/[0.08] bg-white/[0.03] px-4 py-3 font-mono text-xs text-white/70 outline-none focus:border-white/[0.15]"
onClick={(e) => e.target.select()}
/>
<button
type="button"
onClick={handleCopyEmbed}
className={[
'inline-flex items-center gap-1.5 rounded-full border px-4 py-1.5 text-xs font-medium transition-all duration-200',
embedCopied
? 'border-emerald-500/40 bg-emerald-500/15 text-emerald-400'
: 'border-white/[0.08] bg-white/[0.04] text-white/60 hover:border-white/[0.15] hover:text-white/80',
].join(' ')}
>
{embedCopied ? <CheckIcon /> : <CopyIcon />}
{embedCopied ? 'Copied!' : 'Copy Embed'}
</button>
</div>
)}
</div>
</div>
</div>
{/* Toast */}
<ShareToast
message={toastMessage}
visible={toastVisible}
onHide={() => setToastVisible(false)}
/>
</>,
document.body,
)
}