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
285 lines
11 KiB
JavaScript
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>
|
|
)
|
|
}
|