Files
SkinbaseNova/resources/js/components/Feed/ShareArtworkModal.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

285 lines
11 KiB
JavaScript

import React, { useState, useEffect, useRef } from 'react'
import axios from 'axios'
const VISIBILITY_OPTIONS = [
{ value: 'public', icon: 'fa-globe', label: 'Public' },
{ value: 'followers', icon: 'fa-user-friends', label: 'Followers' },
{ value: 'private', icon: 'fa-lock', label: 'Private' },
]
function ArtworkResult({ artwork, onSelect }) {
return (
<button
onClick={() => onSelect(artwork)}
className="w-full flex gap-3 p-3 rounded-xl hover:bg-white/5 transition-colors text-left group"
>
<div className="w-14 h-12 rounded-lg overflow-hidden shrink-0 bg-white/5">
{artwork.thumb_url ? (
<img
src={artwork.thumb_url}
alt={artwork.title}
className="w-full h-full object-cover"
loading="lazy"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-slate-600">
<i className="fa-solid fa-image" />
</div>
)}
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-white/90 truncate group-hover:text-sky-400 transition-colors">
{artwork.title}
</p>
<p className="text-xs text-slate-500 mt-0.5 truncate">
by {artwork.user?.name ?? artwork.author_name ?? 'Unknown'}
</p>
</div>
</button>
)
}
/**
* ShareArtworkModal
*
* Props:
* isOpen boolean
* onClose function
* onShared function(newPost)
* preselectedArtwork object|null (share from artwork page)
*/
export default function ShareArtworkModal({ isOpen, onClose, onShared, preselectedArtwork = null }) {
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const [searching, setSearching] = useState(false)
const [selected, setSelected] = useState(preselectedArtwork)
const [body, setBody] = useState('')
const [visibility, setVisibility] = useState('public')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState(null)
const searchTimer = useRef(null)
const inputRef = useRef(null)
// Focus search on open
useEffect(() => {
if (isOpen && !preselectedArtwork) {
setTimeout(() => inputRef.current?.focus(), 100)
}
}, [isOpen])
useEffect(() => {
setSelected(preselectedArtwork)
}, [preselectedArtwork])
const handleSearch = (q) => {
setQuery(q)
clearTimeout(searchTimer.current)
if (!q.trim()) { setResults([]); return }
searchTimer.current = setTimeout(async () => {
setSearching(true)
try {
const { data } = await axios.get('/api/search/artworks', {
params: { q, shareable: 1, per_page: 12 },
})
setResults(data.data ?? data.hits ?? [])
} catch {
setResults([])
} finally {
setSearching(false)
}
}, 300)
}
const handleSubmit = async () => {
if (!selected) return
setSubmitting(true)
setError(null)
try {
const { data } = await axios.post(`/api/posts/share/artwork/${selected.id}`, {
body: body.trim() || null,
visibility,
})
onShared?.(data.post)
handleClose()
} catch (err) {
setError(err.response?.data?.errors?.artwork_id?.[0] ?? err.response?.data?.message ?? 'Failed to share.')
} finally {
setSubmitting(false)
}
}
const handleClose = () => {
setQuery('')
setResults([])
setSelected(preselectedArtwork)
setBody('')
setVisibility('public')
setError(null)
onClose?.()
}
if (!isOpen) return null
return (
<div
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4"
role="dialog"
aria-modal="true"
aria-label="Share artwork"
>
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={handleClose}
aria-hidden="true"
/>
{/* Panel */}
<div className="relative w-full max-w-lg bg-[#0d1829] border border-white/10 rounded-2xl shadow-2xl max-h-[90vh] flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-white/[0.06]">
<h2 className="text-sm font-semibold text-white/90">
<i className="fa-solid fa-share-nodes mr-2 text-sky-400 opacity-80" />
Share Artwork to Profile
</h2>
<button
onClick={handleClose}
className="text-slate-500 hover:text-white transition-colors"
aria-label="Close"
>
<i className="fa-solid fa-xmark" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-5 space-y-4">
{/* Artwork search / selected */}
{!selected ? (
<div>
<label className="block text-xs font-medium text-slate-400 mb-1.5">
Search for an artwork
</label>
<div className="relative">
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => handleSearch(e.target.value)}
placeholder="Type artwork name…"
className="w-full bg-white/5 border border-white/10 rounded-xl pl-9 pr-4 py-2.5 text-sm text-white placeholder-slate-600 focus:outline-none focus:border-sky-500/50 transition-colors"
/>
<i className="fa-solid fa-magnifying-glass absolute left-3 top-1/2 -translate-y-1/2 text-slate-500 text-xs" />
{searching && (
<i className="fa-solid fa-spinner fa-spin absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 text-xs" />
)}
</div>
{results.length > 0 && (
<div className="mt-2 rounded-xl border border-white/[0.06] bg-black/20 max-h-56 overflow-y-auto">
{results.map((a) => (
<ArtworkResult key={a.id} artwork={a} onSelect={(art) => { setSelected(art); setQuery(''); setResults([]) }} />
))}
</div>
)}
{query && !searching && results.length === 0 && (
<p className="text-xs text-slate-500 mt-2 text-center py-4">No artworks found.</p>
)}
</div>
) : (
<div>
<label className="block text-xs font-medium text-slate-400 mb-1.5">Selected Artwork</label>
<div className="flex gap-3 rounded-xl border border-white/[0.08] bg-black/20 p-3">
<div className="w-16 h-14 rounded-lg overflow-hidden shrink-0 bg-white/5">
<img
src={selected.thumb_url ?? selected.thumb ?? ''}
alt={selected.title}
className="w-full h-full object-cover"
loading="lazy"
/>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white/90 truncate">{selected.title}</p>
<p className="text-xs text-slate-400 mt-0.5">
by {selected.user?.name ?? selected.author?.name ?? selected.author_name ?? 'Unknown'}
</p>
</div>
{!preselectedArtwork && (
<button
onClick={() => setSelected(null)}
className="text-slate-500 hover:text-white transition-colors self-start"
title="Change artwork"
>
<i className="fa-solid fa-xmark text-xs" />
</button>
)}
</div>
</div>
)}
{/* Commentary */}
<div>
<label className="block text-xs font-medium text-slate-400 mb-1.5">
Commentary <span className="text-slate-600">(optional)</span>
</label>
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
maxLength={2000}
rows={3}
placeholder="Say something about this artwork…"
className="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2.5 text-sm text-white resize-none placeholder-slate-600 focus:outline-none focus:border-sky-500/50 transition-colors"
/>
<p className="text-right text-[10px] text-slate-600 mt-0.5">{body.length}/2000</p>
</div>
{/* Visibility */}
<div>
<label className="block text-xs font-medium text-slate-400 mb-1.5">Visibility</label>
<div className="flex gap-2">
{VISIBILITY_OPTIONS.map((v) => (
<button
key={v.value}
onClick={() => setVisibility(v.value)}
className={`flex-1 flex items-center justify-center gap-1.5 px-3 py-2 rounded-xl text-xs font-medium transition-all border ${
visibility === v.value
? 'border-sky-500/50 bg-sky-500/10 text-sky-300'
: 'border-white/10 bg-white/[0.03] text-slate-400 hover:border-white/20 hover:text-white'
}`}
>
<i className={`fa-solid ${v.icon} fa-fw`} />
{v.label}
</button>
))}
</div>
</div>
{error && (
<p className="text-xs text-rose-400 bg-rose-500/10 border border-rose-500/20 rounded-xl px-3 py-2">
<i className="fa-solid fa-circle-exclamation mr-1.5" />
{error}
</p>
)}
</div>
{/* Footer */}
<div className="flex gap-2 px-5 py-4 border-t border-white/[0.06]">
<button
onClick={handleClose}
className="flex-1 px-4 py-2.5 rounded-xl text-sm text-slate-400 hover:text-white hover:bg-white/5 transition-colors"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={submitting || !selected}
className="flex-1 px-4 py-2.5 rounded-xl bg-sky-600 hover:bg-sky-500 disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm font-medium transition-colors flex items-center justify-center gap-2"
>
{submitting
? <><i className="fa-solid fa-spinner fa-spin" /> Sharing</>
: <><i className="fa-solid fa-share-nodes" /> Share</>
}
</button>
</div>
</div>
</div>
)
}