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
This commit is contained in:
284
resources/js/components/Feed/ShareArtworkModal.jsx
Normal file
284
resources/js/components/Feed/ShareArtworkModal.jsx
Normal file
@@ -0,0 +1,284 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user