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:
@@ -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,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('artwork_shares', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
|
||||
77
resources/js/components/artwork/ArtworkShareButton.jsx
Normal file
77
resources/js/components/artwork/ArtworkShareButton.jsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
336
resources/js/components/artwork/ArtworkShareModal.jsx
Normal file
336
resources/js/components/artwork/ArtworkShareModal.jsx
Normal file
@@ -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 (
|
||||
<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, '"')}" />\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,
|
||||
)
|
||||
}
|
||||
55
resources/js/components/ui/ShareToast.jsx
Normal file
55
resources/js/components/ui/ShareToast.jsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
/**
|
||||
* ShareToast — a minimal, auto-dismissing toast notification.
|
||||
*
|
||||
* Props:
|
||||
* message – text to display
|
||||
* visible – whether the toast is currently shown
|
||||
* onHide – callback when the toast finishes (auto-hidden after ~2 s)
|
||||
* duration – ms before auto-dismiss (default 2000)
|
||||
*/
|
||||
export default function ShareToast({ message = 'Link copied!', visible = false, onHide, duration = 2000 }) {
|
||||
const [show, setShow] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
// Small delay so the enter transition plays
|
||||
const enterTimer = requestAnimationFrame(() => setShow(true))
|
||||
const hideTimer = setTimeout(() => {
|
||||
setShow(false)
|
||||
setTimeout(() => onHide?.(), 200) // let exit transition finish
|
||||
}, duration)
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(enterTimer)
|
||||
clearTimeout(hideTimer)
|
||||
}
|
||||
} else {
|
||||
setShow(false)
|
||||
}
|
||||
}, [visible, duration, onHide])
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={[
|
||||
'fixed bottom-24 left-1/2 z-[10001] -translate-x-1/2 rounded-full border border-white/[0.10] bg-nova-800/90 px-5 py-2.5 text-sm font-medium text-white shadow-xl backdrop-blur-md transition-all duration-200',
|
||||
show ? 'translate-y-0 opacity-100' : 'translate-y-3 opacity-0',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{/* Check icon */}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-4 w-4 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>
|
||||
{message}
|
||||
</span>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
41
resources/js/hooks/useWebShare.js
Normal file
41
resources/js/hooks/useWebShare.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
|
||||
/**
|
||||
* useWebShare – abstracts native Web Share API with a fallback callback.
|
||||
*
|
||||
* Usage:
|
||||
* const { canNativeShare, share } = useWebShare({ onFallback })
|
||||
* share({ title, text, url })
|
||||
*
|
||||
* If `navigator.share` is available the browser-native share sheet opens.
|
||||
* Otherwise `onFallback({ title, text, url })` is called (e.g. open a modal).
|
||||
*/
|
||||
export default function useWebShare({ onFallback } = {}) {
|
||||
const canNativeShare = useMemo(
|
||||
() => typeof navigator !== 'undefined' && typeof navigator.share === 'function',
|
||||
[],
|
||||
)
|
||||
|
||||
const share = useCallback(
|
||||
async ({ title, text, url }) => {
|
||||
if (canNativeShare) {
|
||||
try {
|
||||
await navigator.share({ title, text, url })
|
||||
return { shared: true, native: true }
|
||||
} catch (err) {
|
||||
// User cancelled the native share — don't fall through to modal
|
||||
if (err?.name === 'AbortError') {
|
||||
return { shared: false, native: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback — open modal
|
||||
onFallback?.({ title, text, url })
|
||||
return { shared: false, native: false }
|
||||
},
|
||||
[canNativeShare, onFallback],
|
||||
)
|
||||
|
||||
return { canNativeShare, share }
|
||||
}
|
||||
@@ -258,6 +258,13 @@ Route::middleware(['web', 'auth', 'normalize.username'])->group(function () {
|
||||
->name('api.users.follow');
|
||||
});
|
||||
|
||||
// ── Share tracking (public, throttled) ────────────────────────────────────────
|
||||
// POST /api/artworks/{id}/share → record a share event
|
||||
Route::middleware(['web', 'throttle:30,1'])
|
||||
->post('artworks/{id}/share', [\App\Http\Controllers\Api\ArtworkInteractionController::class, 'share'])
|
||||
->whereNumber('id')
|
||||
->name('api.artworks.share');
|
||||
|
||||
// ── Comment CRUD ──────────────────────────────────────────────────────────────
|
||||
// GET /api/artworks/{id}/comments list comments (public)
|
||||
// POST /api/artworks/{id}/comments post a comment (auth)
|
||||
|
||||
Reference in New Issue
Block a user