Files
SkinbaseNova/resources/js/components/artwork/ArtworkShareModal.jsx
Gregor Klevze dc51d65440 feat: forum rich-text editor, emoji picker, mentions, discover nav, feed, uploads, profile
Forum:
- TipTap WYSIWYG editor with full toolbar
- @emoji-mart/react emoji picker (consistent with tweets)
- @mention autocomplete with user search API
- Fix PHP 8.4 parse errors in Blade templates
- Fix thread data display (paginator items)
- Align forum page widths to max-w-5xl

Discover:
- Extract shared _nav.blade.php partial
- Add missing nav links to for-you page
- Add Following link for authenticated users

Feed/Posts:
- Post model, controllers, policies, migrations
- Feed page components (PostComposer, FeedCard, etc)
- Post reactions, comments, saves, reports, sharing
- Scheduled publishing support
- Link preview controller

Profile:
- Profile page components (ProfileHero, ProfileTabs)
- Profile API controller

Uploads:
- Upload wizard enhancements
- Scheduled publish picker
- Studio status bar and readiness checklist
2026-03-03 09:48:31 +01:00

367 lines
16 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, { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import ShareToast from '../ui/ShareToast'
// Lazy-load the Feed share modal so artwork pages don't bundle the feed layer unless needed
const FeedShareArtworkModal = lazy(() => import('../Feed/ShareArtworkModal'))
/* ── 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, isLoggedIn = false }) {
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 [profileShareOpen, setProfileShareOpen] = useState(false)
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',
},
...(isLoggedIn ? [{
label: 'My Profile',
icon: <i className="fa-solid fa-share-nodes h-5 w-5 text-[1.1rem]" />,
onClick: () => setProfileShareOpen(true),
className: 'border-sky-500/30 bg-sky-500/10 text-sky-400 hover:border-sky-400/50 hover:bg-sky-500/20',
}] : []),
]
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)}
/>
{/* Share to Profile (Feed) modal — lazy loaded */}
{profileShareOpen && (
<Suspense fallback={null}>
<FeedShareArtworkModal
isOpen={profileShareOpen}
onClose={() => setProfileShareOpen(false)}
preselectedArtwork={artwork?.id ? {
id: artwork.id,
title: artwork.title,
thumb_url: artwork.thumbs?.md?.url ?? artwork.thumbs?.lg?.url ?? null,
user: artwork.user ?? null,
} : null}
onShared={() => {
setProfileShareOpen(false)
showToast('Shared to your profile!')
}}
/>
</Suspense>
)}
</>,
document.body,
)
}