diff --git a/app/Http/Controllers/Api/ArtworkInteractionController.php b/app/Http/Controllers/Api/ArtworkInteractionController.php index d063df88..1547bdf9 100644 --- a/app/Http/Controllers/Api/ArtworkInteractionController.php +++ b/app/Http/Controllers/Api/ArtworkInteractionController.php @@ -120,6 +120,27 @@ final class ArtworkInteractionController extends Controller ]); } + /** + * POST /api/artworks/{id}/share — record a share event (Phase 2 tracking). + */ + public function share(Request $request, int $artworkId): JsonResponse + { + $data = $request->validate([ + 'platform' => ['required', 'string', 'in:facebook,twitter,pinterest,email,copy,embed'], + ]); + + if (Schema::hasTable('artwork_shares')) { + DB::table('artwork_shares')->insert([ + 'artwork_id' => $artworkId, + 'user_id' => $request->user()?->id, + 'platform' => $data['platform'], + 'created_at' => now(), + ]); + } + + return response()->json(['ok' => true]); + } + private function toggleSimple( Request $request, string $table, diff --git a/database/migrations/2026_02_28_000002_create_artwork_shares_table.php b/database/migrations/2026_02_28_000002_create_artwork_shares_table.php new file mode 100644 index 00000000..84922109 --- /dev/null +++ b/database/migrations/2026_02_28_000002_create_artwork_shares_table.php @@ -0,0 +1,28 @@ +id(); + $table->unsignedBigInteger('artwork_id'); + $table->unsignedBigInteger('user_id')->nullable(); + $table->string('platform', 32); // facebook, twitter, pinterest, copy, email, embed + $table->timestamp('created_at')->useCurrent(); + + $table->index('artwork_id'); + $table->index('platform'); + $table->index(['artwork_id', 'platform']); + }); + } + + public function down(): void + { + Schema::dropIfExists('artwork_shares'); + } +}; diff --git a/resources/js/components/artwork/ArtworkActionBar.jsx b/resources/js/components/artwork/ArtworkActionBar.jsx index 98a9ad87..d5557b1d 100644 --- a/resources/js/components/artwork/ArtworkActionBar.jsx +++ b/resources/js/components/artwork/ArtworkActionBar.jsx @@ -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 ( - - - - ) -} +/* 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 {/* Share pill */} - + {/* Report pill */} + {/* Report */} + + {/* Lazy-loaded modal – only rendered when opened */} + {modalOpen && ( + + + + )} + + ) +} diff --git a/resources/js/components/artwork/ArtworkShareModal.jsx b/resources/js/components/artwork/ArtworkShareModal.jsx new file mode 100644 index 00000000..229e24da --- /dev/null +++ b/resources/js/components/artwork/ArtworkShareModal.jsx @@ -0,0 +1,336 @@ +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 ( + + + + ) +} + +function CheckIcon() { + return ( + + + + ) +} + +function FacebookIcon() { + return ( + + + + ) +} + +function XTwitterIcon() { + return ( + + + + ) +} + +function PinterestIcon() { + return ( + + + + ) +} + +function EmailIcon() { + return ( + + + + ) +} + +function EmbedIcon() { + return ( + + + + ) +} + +function CloseIcon() { + return ( + + + + ) +} + +/* ── 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 = `\n ${title.replace(/\n` + + // 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 ? : , + 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: , + 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: , + 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: , + 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: , + 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 */} +
{ 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 */} +
+ {/* Header */} +
+

Share this artwork

+ +
+ + {/* Artwork preview */} + {thumbMdUrl && ( +
+ {title} +
+

{title}

+ {artwork?.user?.username && ( +

by {artwork.user.username}

+ )} +
+
+ )} + + {/* Share buttons grid */} +
+ {SHARE_OPTIONS.map((opt) => ( + + ))} +
+ + {/* Embed section */} +
+ + + {showEmbed && ( +
+