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:
@@ -87,3 +87,107 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── TipTap rich text editor ─── */
|
||||
.tiptap {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.tiptap p.is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
color: theme('colors.zinc.600');
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.tiptap img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.tiptap pre {
|
||||
background: theme('colors.white / 4%');
|
||||
border: 1px solid theme('colors.white / 6%');
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tiptap pre code {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: theme('colors.zinc.300');
|
||||
}
|
||||
|
||||
.tiptap blockquote {
|
||||
border-left: 3px solid theme('colors.sky.500 / 40%');
|
||||
padding-left: 1rem;
|
||||
margin-left: 0;
|
||||
color: theme('colors.zinc.400');
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.tiptap hr {
|
||||
border: none;
|
||||
border-top: 1px solid theme('colors.white / 10%');
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.tiptap ul {
|
||||
list-style-type: disc;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.tiptap ol {
|
||||
list-style-type: decimal;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.tiptap li {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.tiptap a {
|
||||
color: theme('colors.sky.300');
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.tiptap a:hover {
|
||||
color: theme('colors.sky.200');
|
||||
}
|
||||
|
||||
/* ─── @mention pills ─── */
|
||||
.tiptap .mention,
|
||||
.mention {
|
||||
background: theme('colors.sky.500 / 15%');
|
||||
color: theme('colors.sky.300');
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tiptap .mention:hover {
|
||||
background: theme('colors.sky.500 / 25%');
|
||||
color: theme('colors.sky.200');
|
||||
}
|
||||
|
||||
/* ─── Tippy.js mention dropdown theme ─── */
|
||||
.tippy-box[data-theme~='mention'] {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.tippy-box[data-theme~='mention'] .tippy-content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
|
||||
154
resources/js/Pages/Feed/FollowingFeed.jsx
Normal file
154
resources/js/Pages/Feed/FollowingFeed.jsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import axios from 'axios'
|
||||
import PostCard from '../../Components/Feed/PostCard'
|
||||
import PostCardSkeleton from '../../Components/Feed/PostCardSkeleton'
|
||||
|
||||
const FILTER_OPTIONS = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'shares', label: 'Artwork Shares' },
|
||||
{ value: 'uploads', label: 'New Uploads' },
|
||||
{ value: 'text', label: 'Text Posts' },
|
||||
]
|
||||
|
||||
function EmptyFollowingState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||
<div className="w-20 h-20 rounded-2xl bg-white/5 flex items-center justify-center mb-5 text-slate-600">
|
||||
<i className="fa-solid fa-users text-3xl" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-white/80 mb-2">Nothing here yet</h2>
|
||||
<p className="text-slate-500 text-sm max-w-sm leading-relaxed">
|
||||
Follow some creators to see their posts here. Discover amazing artwork on{' '}
|
||||
<a href="/discover/trending" className="text-sky-400 hover:underline">Trending</a>.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function FollowingFeed() {
|
||||
const { props } = usePage()
|
||||
const { auth } = props
|
||||
const authUser = auth?.user ?? null
|
||||
|
||||
const [posts, setPosts] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [filter, setFilter] = useState('all')
|
||||
|
||||
const fetchFeed = useCallback(async (p = 1, f = filter) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const { data } = await axios.get('/api/posts/following', {
|
||||
params: { page: p, filter: f },
|
||||
})
|
||||
setPosts((prev) => p === 1 ? data.data : [...prev, ...data.data])
|
||||
setHasMore(data.meta.current_page < data.meta.last_page)
|
||||
setPage(p)
|
||||
} catch {
|
||||
//
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoaded(true)
|
||||
}
|
||||
}, [filter])
|
||||
|
||||
useEffect(() => {
|
||||
fetchFeed(1, filter)
|
||||
}, [filter])
|
||||
|
||||
const handleFilterChange = (f) => {
|
||||
if (f === filter) return
|
||||
setFilter(f)
|
||||
setPosts([])
|
||||
setLoaded(false)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const handleDeleted = useCallback((postId) => {
|
||||
setPosts((prev) => prev.filter((p) => p.id !== postId))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#080f1e]">
|
||||
{/* ── Page header ────────────────────────────────────────────────────── */}
|
||||
<div className="max-w-2xl mx-auto px-4 pt-8 pb-4">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white">
|
||||
<i className="fa-solid fa-users-rays mr-2 text-sky-400 opacity-80" />
|
||||
Following Feed
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500 mt-0.5">Posts from creators you follow</p>
|
||||
</div>
|
||||
<a
|
||||
href="/discover/trending"
|
||||
className="text-xs text-sky-400 hover:text-sky-300 transition-colors"
|
||||
>
|
||||
Discover creators →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Filter chips */}
|
||||
<div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{FILTER_OPTIONS.map((f) => (
|
||||
<button
|
||||
key={f.value}
|
||||
onClick={() => handleFilterChange(f.value)}
|
||||
className={`px-3.5 py-1.5 rounded-full text-xs font-medium whitespace-nowrap transition-all border ${
|
||||
filter === f.value
|
||||
? 'bg-sky-600/20 border-sky-500/40 text-sky-300'
|
||||
: 'bg-white/[0.03] border-white/[0.06] text-slate-400 hover:text-white hover:border-white/10'
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Feed ────────────────────────────────────────────────────────────── */}
|
||||
<div className="max-w-2xl mx-auto px-4 pb-16 space-y-4">
|
||||
{/* Loading skeletons */}
|
||||
{!loaded && loading && (
|
||||
<>
|
||||
<PostCardSkeleton />
|
||||
<PostCardSkeleton />
|
||||
<PostCardSkeleton />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Empty */}
|
||||
{loaded && !loading && posts.length === 0 && <EmptyFollowingState />}
|
||||
|
||||
{/* Posts */}
|
||||
{posts.map((post) => (
|
||||
<PostCard
|
||||
key={post.id}
|
||||
post={post}
|
||||
isLoggedIn={!!authUser}
|
||||
viewerUsername={authUser?.username ?? null}
|
||||
onDelete={handleDeleted}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Load more */}
|
||||
{loaded && hasMore && (
|
||||
<div className="flex justify-center py-4">
|
||||
<button
|
||||
onClick={() => fetchFeed(page + 1)}
|
||||
disabled={loading}
|
||||
className="px-6 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 text-sm transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading
|
||||
? <><i className="fa-solid fa-spinner fa-spin mr-2" />Loading…</>
|
||||
: 'Load more'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
114
resources/js/Pages/Feed/HashtagFeed.jsx
Normal file
114
resources/js/Pages/Feed/HashtagFeed.jsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import axios from 'axios'
|
||||
import PostCard from '../../Components/Feed/PostCard'
|
||||
import PostCardSkeleton from '../../Components/Feed/PostCardSkeleton'
|
||||
|
||||
export default function HashtagFeed() {
|
||||
const { props } = usePage()
|
||||
const { auth, tag } = props
|
||||
const authUser = auth?.user ?? null
|
||||
|
||||
const [posts, setPosts] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [totalPosts, setTotalPosts] = useState(null)
|
||||
|
||||
const fetchFeed = useCallback(async (p = 1) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const { data } = await axios.get(`/api/feed/hashtag/${encodeURIComponent(tag)}`, {
|
||||
params: { page: p },
|
||||
})
|
||||
setPosts((prev) => p === 1 ? data.data : [...prev, ...data.data])
|
||||
setHasMore(data.meta.current_page < data.meta.last_page)
|
||||
setTotalPosts(data.meta.total ?? null)
|
||||
setPage(p)
|
||||
} catch {
|
||||
//
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoaded(true)
|
||||
}
|
||||
}, [tag])
|
||||
|
||||
useEffect(() => { fetchFeed(1) }, [tag])
|
||||
|
||||
const handleDeleted = useCallback((id) => setPosts((prev) => prev.filter((p) => p.id !== id)), [])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#080f1e]">
|
||||
<div className="max-w-2xl mx-auto px-4 pt-8 pb-16">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<span className="inline-flex items-center justify-center w-10 h-10 rounded-xl bg-sky-500/15 text-sky-400 text-lg font-bold">
|
||||
#
|
||||
</span>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white">#{tag}</h1>
|
||||
{totalPosts !== null && (
|
||||
<p className="text-sm text-slate-500 mt-0.5">
|
||||
{totalPosts.toLocaleString()} post{totalPosts !== 1 ? 's' : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<a
|
||||
href="/feed/trending"
|
||||
className="text-xs text-slate-500 hover:text-sky-400 transition-colors"
|
||||
>
|
||||
← Trending
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feed */}
|
||||
<div className="space-y-4">
|
||||
{!loaded && loading && (
|
||||
<>{Array.from({ length: 3 }).map((_, i) => <PostCardSkeleton key={i} />)}</>
|
||||
)}
|
||||
|
||||
{loaded && !loading && posts.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||
<div className="w-16 h-16 rounded-2xl bg-white/5 flex items-center justify-center mb-4 text-slate-600">
|
||||
<i className="fa-solid fa-hashtag text-2xl" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-white/80 mb-2">No posts yet</h2>
|
||||
<p className="text-slate-500 text-sm max-w-xs">
|
||||
No posts tagged <span className="text-sky-400">#{tag}</span> yet. Be the first!
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{posts.map((post) => (
|
||||
<PostCard
|
||||
key={post.id}
|
||||
post={post}
|
||||
isLoggedIn={!!authUser}
|
||||
viewerUsername={authUser?.username ?? null}
|
||||
onDelete={handleDeleted}
|
||||
/>
|
||||
))}
|
||||
|
||||
{loaded && hasMore && (
|
||||
<div className="flex justify-center py-4">
|
||||
<button
|
||||
onClick={() => fetchFeed(page + 1)}
|
||||
disabled={loading}
|
||||
className="px-6 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 text-sm transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading
|
||||
? <><i className="fa-solid fa-spinner fa-spin mr-2" />Loading…</>
|
||||
: 'Load more'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
105
resources/js/Pages/Feed/SavedFeed.jsx
Normal file
105
resources/js/Pages/Feed/SavedFeed.jsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import axios from 'axios'
|
||||
import PostCard from '../../Components/Feed/PostCard'
|
||||
import PostCardSkeleton from '../../Components/Feed/PostCardSkeleton'
|
||||
|
||||
export default function SavedFeed() {
|
||||
const { props } = usePage()
|
||||
const { auth } = props
|
||||
const authUser = auth?.user ?? null
|
||||
|
||||
const [posts, setPosts] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
|
||||
const fetchFeed = useCallback(async (p = 1) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const { data } = await axios.get('/api/posts/saved', { params: { page: p } })
|
||||
setPosts((prev) => p === 1 ? data.data : [...prev, ...data.data])
|
||||
setHasMore(data.meta.current_page < data.meta.last_page)
|
||||
setPage(p)
|
||||
} catch {
|
||||
//
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoaded(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchFeed(1) }, [])
|
||||
|
||||
const handleDeleted = useCallback((id) => setPosts((prev) => prev.filter((p) => p.id !== id)), [])
|
||||
|
||||
// When a post is unsaved, remove it from the list too
|
||||
const handleUnsaved = useCallback((id) => setPosts((prev) => prev.filter((p) => p.id !== id)), [])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#080f1e]">
|
||||
<div className="max-w-2xl mx-auto px-4 pt-8 pb-16">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-xl font-bold text-white">
|
||||
<i className="fa-solid fa-bookmark mr-2 text-amber-400 opacity-80" />
|
||||
Saved Posts
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500 mt-0.5">Posts you've bookmarked</p>
|
||||
</div>
|
||||
|
||||
{/* Feed */}
|
||||
<div className="space-y-4">
|
||||
{!loaded && loading && (
|
||||
<>{Array.from({ length: 3 }).map((_, i) => <PostCardSkeleton key={i} />)}</>
|
||||
)}
|
||||
|
||||
{loaded && !loading && posts.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||
<div className="w-16 h-16 rounded-2xl bg-white/5 flex items-center justify-center mb-4 text-slate-600">
|
||||
<i className="fa-solid fa-bookmark text-2xl" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-white/80 mb-2">Nothing saved yet</h2>
|
||||
<p className="text-slate-500 text-sm max-w-xs leading-relaxed">
|
||||
Bookmark posts to read later. Look for the{' '}
|
||||
<i className="fa-regular fa-bookmark text-amber-400" /> icon on any post.
|
||||
</p>
|
||||
<a
|
||||
href="/feed/trending"
|
||||
className="mt-4 text-sm text-sky-400 hover:text-sky-300 transition-colors"
|
||||
>
|
||||
Browse trending posts →
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{posts.map((post) => (
|
||||
<PostCard
|
||||
key={post.id}
|
||||
post={post}
|
||||
isLoggedIn={!!authUser}
|
||||
viewerUsername={authUser?.username ?? null}
|
||||
onDelete={handleDeleted}
|
||||
onUnsaved={handleUnsaved}
|
||||
/>
|
||||
))}
|
||||
|
||||
{loaded && hasMore && (
|
||||
<div className="flex justify-center py-4">
|
||||
<button
|
||||
onClick={() => fetchFeed(page + 1)}
|
||||
disabled={loading}
|
||||
className="px-6 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 text-sm transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading
|
||||
? <><i className="fa-solid fa-spinner fa-spin mr-2" />Loading…</>
|
||||
: 'Load more'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
255
resources/js/Pages/Feed/SearchFeed.jsx
Normal file
255
resources/js/Pages/Feed/SearchFeed.jsx
Normal file
@@ -0,0 +1,255 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import axios from 'axios'
|
||||
import PostCard from '../../Components/Feed/PostCard'
|
||||
import PostCardSkeleton from '../../Components/Feed/PostCardSkeleton'
|
||||
|
||||
/* ── Trending hashtags sidebar ─────────────────────────────────────────────── */
|
||||
function TrendingHashtagsSidebar({ hashtags }) {
|
||||
if (!hashtags || hashtags.length === 0) return null
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/[0.07] bg-white/[0.03] overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-white/[0.05]">
|
||||
<i className="fa-solid fa-hashtag text-slate-500 fa-fw text-[13px]" />
|
||||
<span className="text-[11px] font-semibold uppercase tracking-widest text-slate-500">
|
||||
Trending Tags
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-4 py-3 space-y-1">
|
||||
{hashtags.map((h) => (
|
||||
<a
|
||||
key={h.tag}
|
||||
href={`/feed/search?q=%23${h.tag}`}
|
||||
className="flex items-center justify-between group px-2 py-1.5 rounded-lg transition-colors hover:bg-white/5 text-slate-400 hover:text-white"
|
||||
>
|
||||
<span className="text-sm font-medium">#{h.tag}</span>
|
||||
<span className="text-[11px] text-slate-600 group-hover:text-slate-500 tabular-nums">
|
||||
{h.post_count}
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Main page ─────────────────────────────────────────────────────────────── */
|
||||
export default function SearchFeed() {
|
||||
const { props } = usePage()
|
||||
const { auth, initialQuery, trendingHashtags } = props
|
||||
const authUser = auth?.user ?? null
|
||||
|
||||
const [query, setQuery] = useState(initialQuery ?? '')
|
||||
const [results, setResults] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [searched, setSearched] = useState(false)
|
||||
const [meta, setMeta] = useState(null)
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const debounceRef = useRef(null)
|
||||
const inputRef = useRef(null)
|
||||
|
||||
/* ── Push query into URL without reload ──────────────────────────────────── */
|
||||
const pushUrl = useCallback((q) => {
|
||||
const url = q.trim()
|
||||
? `/feed/search?q=${encodeURIComponent(q.trim())}`
|
||||
: '/feed/search'
|
||||
window.history.replaceState({}, '', url)
|
||||
}, [])
|
||||
|
||||
/* ── Fetch results ───────────────────────────────────────────────────────── */
|
||||
const fetchResults = useCallback(async (q, p = 1) => {
|
||||
if (!q.trim() || q.trim().length < 2) {
|
||||
setResults([])
|
||||
setMeta(null)
|
||||
setSearched(false)
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
try {
|
||||
const { data } = await axios.get('/api/feed/search', {
|
||||
params: { q: q.trim(), page: p },
|
||||
})
|
||||
setResults((prev) => p === 1 ? data.data : [...prev, ...data.data])
|
||||
setMeta(data.meta)
|
||||
setPage(p)
|
||||
setSearched(true)
|
||||
} catch {
|
||||
//
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
/* ── Debounce typing ─────────────────────────────────────────────────────── */
|
||||
const handleChange = useCallback((e) => {
|
||||
const q = e.target.value
|
||||
setQuery(q)
|
||||
pushUrl(q)
|
||||
clearTimeout(debounceRef.current)
|
||||
debounceRef.current = setTimeout(() => {
|
||||
fetchResults(q, 1)
|
||||
}, 350)
|
||||
}, [fetchResults, pushUrl])
|
||||
|
||||
const handleSubmit = useCallback((e) => {
|
||||
e.preventDefault()
|
||||
clearTimeout(debounceRef.current)
|
||||
fetchResults(query, 1)
|
||||
}, [fetchResults, query])
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setQuery('')
|
||||
setResults([])
|
||||
setMeta(null)
|
||||
setSearched(false)
|
||||
pushUrl('')
|
||||
inputRef.current?.focus()
|
||||
}, [pushUrl])
|
||||
|
||||
/* ── Run initial query if pre-filled from URL ────────────────────────────── */
|
||||
useEffect(() => {
|
||||
if (initialQuery?.trim().length >= 2) {
|
||||
fetchResults(initialQuery.trim(), 1)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const handleDeleted = useCallback((id) => {
|
||||
setResults((prev) => prev.filter((p) => p.id !== id))
|
||||
}, [])
|
||||
|
||||
const hasMore = meta ? meta.current_page < meta.last_page : false
|
||||
const noResults = searched && !loading && results.length === 0
|
||||
const hasResults = results.length > 0
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#080f1e]">
|
||||
<div className="max-w-5xl mx-auto px-4 pt-8 pb-16">
|
||||
<div className="flex gap-8">
|
||||
|
||||
{/* ── Main ─────────────────────────────────────────────────────── */}
|
||||
<div className="flex-1 min-w-0 space-y-4">
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-5">
|
||||
<h1 className="text-xl font-bold text-white">
|
||||
<i className="fa-solid fa-magnifying-glass mr-2 text-slate-400/80" />
|
||||
Search Posts
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500 mt-0.5">
|
||||
Search by keywords, hashtags, or phrases
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search box */}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="relative">
|
||||
<i className="fa-solid fa-magnifying-glass absolute left-4 top-1/2 -translate-y-1/2 text-slate-500 text-sm pointer-events-none" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={handleChange}
|
||||
placeholder="Search posts…"
|
||||
className="w-full bg-white/[0.05] border border-white/[0.08] rounded-xl pl-10 pr-10 py-3 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-sky-500/40 focus:ring-1 focus:ring-sky-500/30 transition-colors"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-slate-500 hover:text-white transition-colors"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<i className="fa-solid fa-xmark text-sm" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Skeletons while first load */}
|
||||
{loading && !hasResults && (
|
||||
<>{Array.from({ length: 3 }).map((_, i) => <PostCardSkeleton key={i} />)}</>
|
||||
)}
|
||||
|
||||
{/* Idle / too short */}
|
||||
{!searched && !loading && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div className="w-16 h-16 rounded-2xl bg-white/5 flex items-center justify-center mb-4 text-slate-600">
|
||||
<i className="fa-solid fa-magnifying-glass text-2xl" />
|
||||
</div>
|
||||
<p className="text-slate-500 text-sm">
|
||||
Type at least 2 characters to search posts
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No results */}
|
||||
{noResults && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div className="w-16 h-16 rounded-2xl bg-white/5 flex items-center justify-center mb-4 text-slate-600">
|
||||
<i className="fa-solid fa-face-rolling-eyes text-2xl" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-white/80 mb-1">No results</h2>
|
||||
<p className="text-slate-500 text-sm">
|
||||
Nothing matched <span className="text-slate-300">“{query}”</span>.
|
||||
Try different keywords or a hashtag.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results meta */}
|
||||
{hasResults && meta && (
|
||||
<p className="text-[11px] text-slate-600 px-1">
|
||||
{meta.total.toLocaleString()} result{meta.total !== 1 ? 's' : ''} for{' '}
|
||||
<span className="text-slate-400">“{query}”</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Post cards */}
|
||||
{results.map((post) => (
|
||||
<PostCard
|
||||
key={post.id}
|
||||
post={post}
|
||||
isLoggedIn={!!authUser}
|
||||
viewerUsername={authUser?.username ?? null}
|
||||
onDelete={handleDeleted}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Loading more indicator */}
|
||||
{loading && hasResults && (
|
||||
<div className="flex justify-center py-4">
|
||||
<i className="fa-solid fa-spinner fa-spin text-slate-500" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Load more */}
|
||||
{!loading && hasMore && (
|
||||
<div className="flex justify-center py-4">
|
||||
<button
|
||||
onClick={() => fetchResults(query, page + 1)}
|
||||
className="px-6 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 text-sm transition-colors"
|
||||
>
|
||||
Load more
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Sidebar ──────────────────────────────────────────────────── */}
|
||||
<aside className="hidden lg:block w-64 shrink-0 space-y-4 pt-14">
|
||||
<TrendingHashtagsSidebar hashtags={trendingHashtags} />
|
||||
<div className="rounded-2xl border border-white/[0.07] bg-white/[0.03] px-4 py-4 text-center">
|
||||
<p className="text-xs text-slate-500 leading-relaxed">
|
||||
Tip: search <span className="text-sky-400/80">#hashtag</span> to find
|
||||
posts by topic.
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
133
resources/js/Pages/Feed/TrendingFeed.jsx
Normal file
133
resources/js/Pages/Feed/TrendingFeed.jsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import axios from 'axios'
|
||||
import PostCard from '../../Components/Feed/PostCard'
|
||||
import PostCardSkeleton from '../../Components/Feed/PostCardSkeleton'
|
||||
|
||||
function TrendingHashtagsSidebar({ hashtags, activeTag = null }) {
|
||||
if (!hashtags || hashtags.length === 0) return null
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/[0.07] bg-white/[0.03] overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-white/[0.05]">
|
||||
<i className="fa-solid fa-hashtag text-slate-500 fa-fw text-[13px]" />
|
||||
<span className="text-[11px] font-semibold uppercase tracking-widest text-slate-500">Trending Tags</span>
|
||||
</div>
|
||||
<div className="px-4 py-3 space-y-1.5">
|
||||
{hashtags.map((h) => (
|
||||
<a
|
||||
key={h.tag}
|
||||
href={`/tags/${h.tag}`}
|
||||
className={`flex items-center justify-between group px-2 py-1.5 rounded-lg transition-colors ${
|
||||
activeTag === h.tag
|
||||
? 'bg-sky-500/15 text-sky-400'
|
||||
: 'hover:bg-white/5 text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm font-medium">#{h.tag}</span>
|
||||
<span className="text-[11px] text-slate-600 group-hover:text-slate-500 tabular-nums">
|
||||
{h.post_count} posts
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TrendingFeed() {
|
||||
const { props } = usePage()
|
||||
const { auth, trendingHashtags } = props
|
||||
const authUser = auth?.user ?? null
|
||||
|
||||
const [posts, setPosts] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
|
||||
const fetchFeed = useCallback(async (p = 1) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const { data } = await axios.get('/api/feed/trending', { params: { page: p } })
|
||||
setPosts((prev) => p === 1 ? data.data : [...prev, ...data.data])
|
||||
setHasMore(data.meta.current_page < data.meta.last_page)
|
||||
setPage(p)
|
||||
} catch {
|
||||
//
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoaded(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchFeed(1) }, [])
|
||||
|
||||
const handleDeleted = useCallback((id) => setPosts((prev) => prev.filter((p) => p.id !== id)), [])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#080f1e]">
|
||||
<div className="max-w-5xl mx-auto px-4 pt-8 pb-16">
|
||||
<div className="flex gap-8">
|
||||
{/* ── Main feed ──────────────────────────────────────────────── */}
|
||||
<div className="flex-1 min-w-0 space-y-4">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-xl font-bold text-white">
|
||||
<i className="fa-solid fa-fire mr-2 text-orange-400 opacity-80" />
|
||||
Trending
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500 mt-0.5">Most engaging posts right now</p>
|
||||
</div>
|
||||
|
||||
{!loaded && loading && (
|
||||
<>{Array.from({ length: 3 }).map((_, i) => <PostCardSkeleton key={i} />)}</>
|
||||
)}
|
||||
|
||||
{loaded && !loading && posts.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||
<div className="w-16 h-16 rounded-2xl bg-white/5 flex items-center justify-center mb-4 text-slate-600">
|
||||
<i className="fa-solid fa-fire text-2xl" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-white/80 mb-2">Nothing trending yet</h2>
|
||||
<p className="text-slate-500 text-sm">Check back soon — posts are ranked by engagement.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{posts.map((post) => (
|
||||
<PostCard
|
||||
key={post.id}
|
||||
post={post}
|
||||
isLoggedIn={!!authUser}
|
||||
viewerUsername={authUser?.username ?? null}
|
||||
onDelete={handleDeleted}
|
||||
/>
|
||||
))}
|
||||
|
||||
{loaded && hasMore && (
|
||||
<div className="flex justify-center py-4">
|
||||
<button
|
||||
onClick={() => fetchFeed(page + 1)}
|
||||
disabled={loading}
|
||||
className="px-6 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 text-sm transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading
|
||||
? <><i className="fa-solid fa-spinner fa-spin mr-2" />Loading…</>
|
||||
: 'Load more'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Sidebar ────────────────────────────────────────────────── */}
|
||||
<aside className="hidden lg:block w-64 shrink-0 space-y-4 pt-14">
|
||||
<TrendingHashtagsSidebar hashtags={trendingHashtags} />
|
||||
<div className="rounded-2xl border border-white/[0.07] bg-white/[0.03] px-4 py-4 text-center">
|
||||
<p className="text-xs text-slate-500 leading-relaxed">
|
||||
Posts are ranked by likes, comments & engagement over the last 7 days.
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
81
resources/js/Pages/Forum/ForumCategory.jsx
Normal file
81
resources/js/Pages/Forum/ForumCategory.jsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React from 'react'
|
||||
import Breadcrumbs from '../../components/forum/Breadcrumbs'
|
||||
import ThreadRow from '../../components/forum/ThreadRow'
|
||||
import Pagination from '../../components/forum/Pagination'
|
||||
import Button from '../../components/ui/Button'
|
||||
|
||||
export default function ForumCategory({ category, threads = [], pagination = {}, isAuthenticated = false }) {
|
||||
const name = category?.name ?? 'Category'
|
||||
const slug = category?.slug
|
||||
|
||||
const breadcrumbs = [
|
||||
{ label: 'Home', href: '/' },
|
||||
{ label: 'Forum', href: '/forum' },
|
||||
{ label: name },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="px-4 pt-10 pb-20 sm:px-6 lg:px-8 max-w-5xl mx-auto">
|
||||
{/* Breadcrumbs */}
|
||||
<Breadcrumbs items={breadcrumbs} />
|
||||
|
||||
{/* Header */}
|
||||
<div className="mt-5 mb-6 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Forum</p>
|
||||
<h1 className="text-3xl font-bold text-white leading-tight">{name}</h1>
|
||||
</div>
|
||||
{isAuthenticated && slug && (
|
||||
<a href={`/forum/${slug}/new`}>
|
||||
<Button variant="primary" size="sm"
|
||||
leftIcon={
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
New thread
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Thread list */}
|
||||
<section className="overflow-hidden rounded-2xl border border-white/[0.06] bg-nova-800/50 backdrop-blur">
|
||||
{/* Column header */}
|
||||
<div className="flex items-center gap-4 border-b border-white/[0.06] px-5 py-3">
|
||||
<span className="flex-1 text-xs font-semibold uppercase tracking-widest text-white/30">Threads</span>
|
||||
<span className="w-12 text-center text-xs font-semibold uppercase tracking-widest text-white/30">Posts</span>
|
||||
</div>
|
||||
|
||||
{threads.length === 0 ? (
|
||||
<div className="px-5 py-12 text-center">
|
||||
<svg className="mx-auto mb-4 text-zinc-600" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
|
||||
</svg>
|
||||
<p className="text-sm text-zinc-500">No threads in this section yet.</p>
|
||||
{isAuthenticated && slug && (
|
||||
<a href={`/forum/${slug}/new`} className="mt-3 inline-block text-sm text-sky-300 hover:text-sky-200">
|
||||
Be the first to start a discussion →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{threads.map((thread, i) => (
|
||||
<ThreadRow key={thread.topic_id ?? thread.id ?? i} thread={thread} isFirst={i === 0} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination?.last_page > 1 && (
|
||||
<div className="mt-6">
|
||||
<Pagination meta={pagination} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
74
resources/js/Pages/Forum/ForumEditPost.jsx
Normal file
74
resources/js/Pages/Forum/ForumEditPost.jsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import Breadcrumbs from '../../components/forum/Breadcrumbs'
|
||||
import Button from '../../components/ui/Button'
|
||||
import RichTextEditor from '../../components/forum/RichTextEditor'
|
||||
|
||||
export default function ForumEditPost({ post, thread, csrfToken, errors = {} }) {
|
||||
const [content, setContent] = useState(post?.content ?? '')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const breadcrumbs = [
|
||||
{ label: 'Home', href: '/' },
|
||||
{ label: 'Forum', href: '/forum' },
|
||||
{ label: thread?.title ?? 'Thread', href: thread?.id ? `/forum/thread/${thread.id}-${thread.slug ?? ''}` : '/forum' },
|
||||
{ label: 'Edit post' },
|
||||
]
|
||||
|
||||
const handleSubmit = useCallback((e) => {
|
||||
if (submitting) return
|
||||
setSubmitting(true)
|
||||
// Let the form submit normally for PRG
|
||||
}, [submitting])
|
||||
|
||||
return (
|
||||
<div className="px-4 pt-10 pb-20 sm:px-6 lg:px-8 max-w-3xl mx-auto">
|
||||
<Breadcrumbs items={breadcrumbs} />
|
||||
|
||||
{/* Header */}
|
||||
<div className="mt-5 mb-6">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Edit</p>
|
||||
<h1 className="text-2xl font-bold text-white leading-tight">Edit post</h1>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form
|
||||
method="POST"
|
||||
action={`/forum/post/${post?.id}`}
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-5 rounded-2xl border border-white/[0.06] bg-nova-800/50 p-6 backdrop-blur"
|
||||
>
|
||||
<input type="hidden" name="_token" value={csrfToken} />
|
||||
<input type="hidden" name="_method" value="PUT" />
|
||||
|
||||
{/* Rich text editor */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-white/85">
|
||||
Content
|
||||
</label>
|
||||
<RichTextEditor
|
||||
content={content}
|
||||
onChange={setContent}
|
||||
placeholder="Edit your post…"
|
||||
error={errors.content}
|
||||
minHeight={14}
|
||||
autofocus={false}
|
||||
/>
|
||||
<input type="hidden" name="content" value={content} />
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<a
|
||||
href={thread?.id ? `/forum/thread/${thread.id}-${thread.slug ?? ''}` : '/forum'}
|
||||
className="text-sm text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
← Cancel
|
||||
</a>
|
||||
<Button type="submit" variant="primary" size="md" loading={submitting}>
|
||||
Save changes
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
31
resources/js/Pages/Forum/ForumIndex.jsx
Normal file
31
resources/js/Pages/Forum/ForumIndex.jsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from 'react'
|
||||
import CategoryCard from '../../components/forum/CategoryCard'
|
||||
|
||||
export default function ForumIndex({ categories = [] }) {
|
||||
return (
|
||||
<div className="px-4 pt-10 pb-20 sm:px-6 lg:px-8 max-w-5xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Community</p>
|
||||
<h1 className="text-3xl font-bold text-white leading-tight">Forum</h1>
|
||||
<p className="mt-1.5 text-sm text-white/50">Browse forum sections and join the conversation.</p>
|
||||
</div>
|
||||
|
||||
{/* Category grid */}
|
||||
{categories.length === 0 ? (
|
||||
<div className="rounded-2xl border border-white/[0.06] bg-nova-800/50 p-12 text-center">
|
||||
<svg className="mx-auto mb-4 text-zinc-600" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
|
||||
</svg>
|
||||
<p className="text-sm text-zinc-500">No forum categories available yet.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||||
{categories.map((cat) => (
|
||||
<CategoryCard key={cat.id ?? cat.slug} category={cat} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
91
resources/js/Pages/Forum/ForumNewThread.jsx
Normal file
91
resources/js/Pages/Forum/ForumNewThread.jsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import Breadcrumbs from '../../components/forum/Breadcrumbs'
|
||||
import Button from '../../components/ui/Button'
|
||||
import TextInput from '../../components/ui/TextInput'
|
||||
import RichTextEditor from '../../components/forum/RichTextEditor'
|
||||
|
||||
export default function ForumNewThread({ category, csrfToken, errors = {}, oldValues = {} }) {
|
||||
const [title, setTitle] = useState(oldValues.title ?? '')
|
||||
const [content, setContent] = useState(oldValues.content ?? '')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const slug = category?.slug
|
||||
const categoryName = category?.name ?? 'Category'
|
||||
|
||||
const breadcrumbs = [
|
||||
{ label: 'Home', href: '/' },
|
||||
{ label: 'Forum', href: '/forum' },
|
||||
{ label: categoryName, href: slug ? `/forum/${slug}` : '/forum' },
|
||||
{ label: 'New thread' },
|
||||
]
|
||||
|
||||
const handleSubmit = useCallback(async (e) => {
|
||||
e.preventDefault()
|
||||
if (submitting) return
|
||||
setSubmitting(true)
|
||||
|
||||
// Standard form submission to keep server-side validation + redirect
|
||||
e.target.submit()
|
||||
}, [submitting])
|
||||
|
||||
return (
|
||||
<div className="px-4 pt-10 pb-20 sm:px-6 lg:px-8 max-w-3xl mx-auto">
|
||||
<Breadcrumbs items={breadcrumbs} />
|
||||
|
||||
{/* Header */}
|
||||
<div className="mt-5 mb-6">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">New thread</p>
|
||||
<h1 className="text-2xl font-bold text-white leading-tight">
|
||||
Create thread in {categoryName}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form
|
||||
method="POST"
|
||||
action={`/forum/${slug}/new`}
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-5 rounded-2xl border border-white/[0.06] bg-nova-800/50 p-6 backdrop-blur"
|
||||
>
|
||||
<input type="hidden" name="_token" value={csrfToken} />
|
||||
|
||||
<TextInput
|
||||
label="Title"
|
||||
name="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
maxLength={255}
|
||||
placeholder="Thread title…"
|
||||
error={errors.title}
|
||||
/>
|
||||
|
||||
{/* Rich text editor */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-white/85">
|
||||
Content <span className="text-red-400 ml-1">*</span>
|
||||
</label>
|
||||
<RichTextEditor
|
||||
content={content}
|
||||
onChange={setContent}
|
||||
placeholder="Write your post…"
|
||||
error={errors.content}
|
||||
minHeight={14}
|
||||
autofocus={false}
|
||||
/>
|
||||
<input type="hidden" name="content" value={content} />
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<a href={`/forum/${slug}`} className="text-sm text-zinc-500 hover:text-zinc-300 transition-colors">
|
||||
← Cancel
|
||||
</a>
|
||||
<Button type="submit" variant="primary" size="md" loading={submitting}>
|
||||
Publish thread
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
200
resources/js/Pages/Forum/ForumThread.jsx
Normal file
200
resources/js/Pages/Forum/ForumThread.jsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import Breadcrumbs from '../../components/forum/Breadcrumbs'
|
||||
import PostCard from '../../components/forum/PostCard'
|
||||
import ReplyForm from '../../components/forum/ReplyForm'
|
||||
import Pagination from '../../components/forum/Pagination'
|
||||
|
||||
export default function ForumThread({
|
||||
thread,
|
||||
category,
|
||||
author,
|
||||
opPost,
|
||||
posts = [],
|
||||
pagination = {},
|
||||
replyCount = 0,
|
||||
sort = 'asc',
|
||||
quotedPost = null,
|
||||
replyPrefill = '',
|
||||
isAuthenticated = false,
|
||||
canModerate = false,
|
||||
csrfToken = '',
|
||||
status = null,
|
||||
}) {
|
||||
const [currentSort, setCurrentSort] = useState(sort)
|
||||
|
||||
const breadcrumbs = [
|
||||
{ label: 'Home', href: '/' },
|
||||
{ label: 'Forum', href: '/forum' },
|
||||
{ label: category?.name ?? 'Category', href: category?.slug ? `/forum/${category.slug}` : '/forum' },
|
||||
{ label: thread?.title ?? 'Thread' },
|
||||
]
|
||||
|
||||
const handleSortToggle = useCallback(() => {
|
||||
const newSort = currentSort === 'asc' ? 'desc' : 'asc'
|
||||
setCurrentSort(newSort)
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set('sort', newSort)
|
||||
window.location.href = url.toString()
|
||||
}, [currentSort])
|
||||
|
||||
return (
|
||||
<div className="px-4 pt-10 pb-20 sm:px-6 lg:px-8 max-w-5xl mx-auto space-y-5">
|
||||
<Breadcrumbs items={breadcrumbs} />
|
||||
|
||||
{/* Status flash */}
|
||||
{status && (
|
||||
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-300">
|
||||
{status}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Thread header card */}
|
||||
<section className="rounded-2xl border border-white/[0.06] bg-nova-800/50 p-5 backdrop-blur">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-bold text-white leading-snug">{thread?.title}</h1>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-zinc-500">
|
||||
<span>By {author?.name ?? 'Unknown'}</span>
|
||||
<span className="text-zinc-700">•</span>
|
||||
{thread?.created_at && (
|
||||
<time dateTime={thread.created_at}>{formatDate(thread.created_at)}</time>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className="rounded-full bg-sky-500/15 px-2.5 py-1 text-sky-300">
|
||||
{number(thread?.views ?? 0)} views
|
||||
</span>
|
||||
<span className="rounded-full bg-cyan-500/15 px-2.5 py-1 text-cyan-300">
|
||||
{number(replyCount)} replies
|
||||
</span>
|
||||
{thread?.is_pinned && (
|
||||
<span className="rounded-full bg-amber-500/15 px-2.5 py-1 text-amber-300">Pinned</span>
|
||||
)}
|
||||
{thread?.is_locked && (
|
||||
<span className="rounded-full bg-red-500/15 px-2.5 py-1 text-red-300">Locked</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Moderation tools */}
|
||||
{canModerate && (
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2 border-t border-white/[0.06] pt-3">
|
||||
{thread?.is_locked ? (
|
||||
<ModForm action={`/forum/thread/${thread.id}/unlock`} csrf={csrfToken} label="Unlock" variant="danger" />
|
||||
) : (
|
||||
<ModForm action={`/forum/thread/${thread.id}/lock`} csrf={csrfToken} label="Lock" variant="danger" />
|
||||
)}
|
||||
{thread?.is_pinned ? (
|
||||
<ModForm action={`/forum/thread/${thread.id}/unpin`} csrf={csrfToken} label="Unpin" variant="warning" />
|
||||
) : (
|
||||
<ModForm action={`/forum/thread/${thread.id}/pin`} csrf={csrfToken} label="Pin" variant="warning" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Sort toggle + reply count */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-zinc-500">{number(replyCount)} {replyCount === 1 ? 'reply' : 'replies'}</p>
|
||||
<button
|
||||
onClick={handleSortToggle}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-white/10 px-3 py-1.5 text-xs text-zinc-400 transition-colors hover:border-white/20 hover:text-zinc-200"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points={currentSort === 'asc' ? '18 15 12 21 6 15' : '18 9 12 3 6 9'} />
|
||||
<line x1="12" y1="3" x2="12" y2="21" />
|
||||
</svg>
|
||||
{currentSort === 'asc' ? 'Oldest first' : 'Newest first'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* OP Post */}
|
||||
{opPost && (
|
||||
<PostCard
|
||||
post={opPost}
|
||||
thread={thread}
|
||||
isOp
|
||||
isAuthenticated={isAuthenticated}
|
||||
canModerate={canModerate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Reply list */}
|
||||
<section className="space-y-4" aria-label="Replies">
|
||||
{posts.length === 0 ? (
|
||||
<div className="rounded-2xl border border-white/[0.06] bg-nova-800/40 px-5 py-8 text-center text-zinc-500 text-sm">
|
||||
No replies yet. Be the first to respond!
|
||||
</div>
|
||||
) : (
|
||||
posts.map((post) => (
|
||||
<PostCard
|
||||
key={post.id}
|
||||
post={post}
|
||||
thread={thread}
|
||||
isAuthenticated={isAuthenticated}
|
||||
canModerate={canModerate}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination?.last_page > 1 && (
|
||||
<div className="sticky bottom-3 z-10 rounded-xl border border-white/[0.06] bg-nova-800/80 p-2 backdrop-blur">
|
||||
<Pagination meta={pagination} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reply form or locked / auth prompt */}
|
||||
{isAuthenticated ? (
|
||||
thread?.is_locked ? (
|
||||
<div className="rounded-2xl border border-red-500/20 bg-red-500/5 px-5 py-4 text-sm text-red-300">
|
||||
This thread is locked. Replies are disabled.
|
||||
</div>
|
||||
) : (
|
||||
<ReplyForm
|
||||
threadId={thread?.id}
|
||||
prefill={replyPrefill}
|
||||
quotedAuthor={quotedPost?.user?.name}
|
||||
csrfToken={csrfToken}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="rounded-2xl border border-white/[0.06] bg-nova-800/40 px-5 py-5 text-sm text-zinc-400">
|
||||
<a href="/login" className="text-sky-300 hover:text-sky-200 font-medium">Sign in</a> to post a reply.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ModForm({ action, csrf, label, variant }) {
|
||||
const colors = variant === 'danger'
|
||||
? 'bg-red-500/15 text-red-300 hover:bg-red-500/25 border-red-500/20'
|
||||
: 'bg-amber-500/15 text-amber-300 hover:bg-amber-500/25 border-amber-500/20'
|
||||
|
||||
return (
|
||||
<form method="POST" action={action}>
|
||||
<input type="hidden" name="_token" value={csrf} />
|
||||
<button type="submit" className={`rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors ${colors}`}>
|
||||
{label}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function number(n) {
|
||||
return (n ?? 0).toLocaleString()
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
try {
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
+ ' ' + d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
177
resources/js/Pages/Profile/ProfileShow.jsx
Normal file
177
resources/js/Pages/Profile/ProfileShow.jsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import ProfileHero from '../../Components/Profile/ProfileHero'
|
||||
import ProfileStatsRow from '../../Components/Profile/ProfileStatsRow'
|
||||
import ProfileTabs from '../../Components/Profile/ProfileTabs'
|
||||
import TabArtworks from '../../Components/Profile/tabs/TabArtworks'
|
||||
import TabAbout from '../../Components/Profile/tabs/TabAbout'
|
||||
import TabStats from '../../Components/Profile/tabs/TabStats'
|
||||
import TabFavourites from '../../Components/Profile/tabs/TabFavourites'
|
||||
import TabCollections from '../../Components/Profile/tabs/TabCollections'
|
||||
import TabActivity from '../../Components/Profile/tabs/TabActivity'
|
||||
import TabPosts from '../../Components/Profile/tabs/TabPosts'
|
||||
|
||||
const VALID_TABS = ['artworks', 'posts', 'collections', 'about', 'stats', 'favourites', 'activity']
|
||||
|
||||
function getInitialTab() {
|
||||
try {
|
||||
const sp = new URLSearchParams(window.location.search)
|
||||
const t = sp.get('tab')
|
||||
return VALID_TABS.includes(t) ? t : 'artworks'
|
||||
} catch {
|
||||
return 'artworks'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ProfileShow – Inertia page for /@username
|
||||
*
|
||||
* Props injected by ProfileController::renderUserProfile()
|
||||
*/
|
||||
export default function ProfileShow() {
|
||||
const { props } = usePage()
|
||||
|
||||
const {
|
||||
user,
|
||||
profile,
|
||||
artworks,
|
||||
featuredArtworks,
|
||||
favourites,
|
||||
stats,
|
||||
socialLinks,
|
||||
followerCount,
|
||||
recentFollowers,
|
||||
viewerIsFollowing,
|
||||
heroBgUrl,
|
||||
profileComments,
|
||||
countryName,
|
||||
isOwner,
|
||||
auth,
|
||||
} = props
|
||||
|
||||
const [activeTab, setActiveTab] = useState(getInitialTab)
|
||||
|
||||
const handleTabChange = useCallback((tab) => {
|
||||
if (!VALID_TABS.includes(tab)) return
|
||||
setActiveTab(tab)
|
||||
|
||||
// Update URL query param without full navigation
|
||||
try {
|
||||
const url = new URL(window.location.href)
|
||||
if (tab === 'artworks') {
|
||||
url.searchParams.delete('tab')
|
||||
} else {
|
||||
url.searchParams.set('tab', tab)
|
||||
}
|
||||
window.history.pushState({}, '', url.toString())
|
||||
} catch (_) {}
|
||||
}, [])
|
||||
|
||||
// Handle browser back/forward
|
||||
useEffect(() => {
|
||||
const onPop = () => setActiveTab(getInitialTab())
|
||||
window.addEventListener('popstate', onPop)
|
||||
return () => window.removeEventListener('popstate', onPop)
|
||||
}, [])
|
||||
|
||||
const isLoggedIn = !!(auth?.user)
|
||||
|
||||
// Normalise artwork list (SSR may send cursor-paginated object)
|
||||
const artworkList = Array.isArray(artworks)
|
||||
? artworks
|
||||
: (artworks?.data ?? [])
|
||||
const artworkNextCursor = artworks?.next_cursor ?? null
|
||||
|
||||
// Normalise social links (may be object keyed by platform, or array)
|
||||
const socialLinksObj = Array.isArray(socialLinks)
|
||||
? socialLinks.reduce((acc, l) => { acc[l.platform] = l; return acc }, {})
|
||||
: (socialLinks ?? {})
|
||||
|
||||
return (
|
||||
<div className="min-h-screen pb-16">
|
||||
{/* Hero section */}
|
||||
<ProfileHero
|
||||
user={user}
|
||||
profile={profile}
|
||||
isOwner={isOwner}
|
||||
viewerIsFollowing={viewerIsFollowing}
|
||||
followerCount={followerCount}
|
||||
heroBgUrl={heroBgUrl}
|
||||
countryName={countryName}
|
||||
/>
|
||||
|
||||
{/* Stats pills row */}
|
||||
<ProfileStatsRow
|
||||
stats={stats}
|
||||
followerCount={followerCount}
|
||||
onTabChange={handleTabChange}
|
||||
/>
|
||||
|
||||
{/* Sticky tabs */}
|
||||
<ProfileTabs
|
||||
activeTab={activeTab}
|
||||
onTabChange={handleTabChange}
|
||||
/>
|
||||
|
||||
{/* Tab content area */}
|
||||
<div className="max-w-6xl mx-auto px-4">
|
||||
{activeTab === 'artworks' && (
|
||||
<TabArtworks
|
||||
artworks={{ data: artworkList, next_cursor: artworkNextCursor }}
|
||||
featuredArtworks={featuredArtworks}
|
||||
username={user.username || user.name}
|
||||
isActive
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'posts' && (
|
||||
<TabPosts
|
||||
username={user.username || user.name}
|
||||
isOwner={isOwner}
|
||||
authUser={auth?.user ?? null}
|
||||
user={user}
|
||||
profile={profile}
|
||||
stats={stats}
|
||||
followerCount={followerCount}
|
||||
recentFollowers={recentFollowers}
|
||||
socialLinks={socialLinksObj}
|
||||
countryName={countryName}
|
||||
onTabChange={handleTabChange}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'collections' && (
|
||||
<TabCollections collections={[]} />
|
||||
)}
|
||||
{activeTab === 'about' && (
|
||||
<TabAbout
|
||||
user={user}
|
||||
profile={profile}
|
||||
socialLinks={socialLinksObj}
|
||||
countryName={countryName}
|
||||
followerCount={followerCount}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'stats' && (
|
||||
<TabStats
|
||||
stats={stats}
|
||||
followerCount={followerCount}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'favourites' && (
|
||||
<TabFavourites
|
||||
favourites={favourites}
|
||||
isOwner={isOwner}
|
||||
username={user.username || user.name}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'activity' && (
|
||||
<TabActivity
|
||||
profileComments={profileComments}
|
||||
user={user}
|
||||
isOwner={isOwner}
|
||||
isLoggedIn={isLoggedIn}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { usePage } from '@inertiajs/react'
|
||||
import TagInput from '../../components/tags/TagInput'
|
||||
import UploadWizard from '../../components/upload/UploadWizard'
|
||||
import Checkbox from '../../Components/ui/Checkbox'
|
||||
import { mapUploadErrorNotice, mapUploadResultNotice } from '../../lib/uploadNotices'
|
||||
|
||||
const phases = {
|
||||
idle: 'idle',
|
||||
@@ -179,13 +180,21 @@ function useUploadMachine({ draftId, filesCdnUrl, chunkSize, userId }) {
|
||||
}, [])
|
||||
|
||||
const pushNotice = useCallback((type, message) => {
|
||||
const normalizedType = ['success', 'warning', 'error'].includes(String(type || '').toLowerCase())
|
||||
? String(type).toLowerCase()
|
||||
: 'error'
|
||||
const id = `${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
dispatch({ type: 'PUSH_NOTICE', notice: { id, type, message } })
|
||||
dispatch({ type: 'PUSH_NOTICE', notice: { id, type: normalizedType, message } })
|
||||
window.setTimeout(() => {
|
||||
dispatch({ type: 'REMOVE_NOTICE', id })
|
||||
}, 4500)
|
||||
}, [])
|
||||
|
||||
const pushMappedNotice = useCallback((notice) => {
|
||||
if (!notice?.message) return
|
||||
pushNotice(notice.type || 'error', notice.message)
|
||||
}, [pushNotice])
|
||||
|
||||
const previewUrl = useMemo(() => {
|
||||
if (state.previewUrl) return state.previewUrl
|
||||
if (!state.filePreviewUrl) return null
|
||||
@@ -276,12 +285,12 @@ function useUploadMachine({ draftId, filesCdnUrl, chunkSize, userId }) {
|
||||
}
|
||||
return { sessionId: data.session_id, uploadToken: data.upload_token }
|
||||
} catch (error) {
|
||||
const message = extractErrorMessage(error, 'Failed to initialize upload session.')
|
||||
dispatch({ type: 'INIT_ERROR', error: message })
|
||||
pushNotice('error', message)
|
||||
const notice = mapUploadErrorNotice(error, 'Failed to initialize upload session.')
|
||||
dispatch({ type: 'INIT_ERROR', error: notice.message })
|
||||
pushMappedNotice(notice)
|
||||
return null
|
||||
}
|
||||
}, [state.file, userId, extractErrorMessage, pushNotice])
|
||||
}, [state.file, userId, pushMappedNotice])
|
||||
|
||||
const createDraft = useCallback(async () => {
|
||||
if (state.artworkId) return state.artworkId
|
||||
@@ -302,12 +311,12 @@ function useUploadMachine({ draftId, filesCdnUrl, chunkSize, userId }) {
|
||||
}
|
||||
throw new Error('missing_artwork_id')
|
||||
} catch (error) {
|
||||
const message = extractErrorMessage(error, 'Unable to create draft metadata.')
|
||||
dispatch({ type: 'FINISH_ERROR', error: message })
|
||||
pushNotice('error', message)
|
||||
const notice = mapUploadErrorNotice(error, 'Unable to create draft metadata.')
|
||||
dispatch({ type: 'FINISH_ERROR', error: notice.message })
|
||||
pushMappedNotice(notice)
|
||||
return null
|
||||
}
|
||||
}, [state.artworkId, state.metadata, state.draftId, extractErrorMessage, pushNotice])
|
||||
}, [state.artworkId, state.metadata, state.draftId, pushMappedNotice])
|
||||
|
||||
const syncArtworkTags = useCallback(async (artworkId) => {
|
||||
const tags = Array.from(new Set(parseUiTags(state.metadata.tags)))
|
||||
@@ -319,11 +328,11 @@ function useUploadMachine({ draftId, filesCdnUrl, chunkSize, userId }) {
|
||||
await window.axios.put(`/api/artworks/${artworkId}/tags`, { tags })
|
||||
return true
|
||||
} catch (error) {
|
||||
const message = extractErrorMessage(error, 'Tag sync failed. Upload will continue.')
|
||||
pushNotice('error', message)
|
||||
const notice = mapUploadErrorNotice(error, 'Tag sync failed. Upload will continue.')
|
||||
pushMappedNotice({ ...notice, type: 'warning' })
|
||||
return false
|
||||
}
|
||||
}, [state.metadata.tags, extractErrorMessage, pushNotice])
|
||||
}, [state.metadata.tags, pushMappedNotice])
|
||||
|
||||
const fetchStatus = useCallback(async (sessionId, uploadToken) => {
|
||||
const res = await window.axios.get(`/api/uploads/status/${sessionId}`, {
|
||||
@@ -393,9 +402,9 @@ function useUploadMachine({ draftId, filesCdnUrl, chunkSize, userId }) {
|
||||
try {
|
||||
status = await fetchStatus(sessionId, uploadToken)
|
||||
} catch (error) {
|
||||
const message = extractErrorMessage(error, 'Unable to resume upload.')
|
||||
dispatch({ type: 'UPLOAD_ERROR', error: message })
|
||||
pushNotice('error', message)
|
||||
const notice = mapUploadErrorNotice(error, 'Unable to resume upload.')
|
||||
dispatch({ type: 'UPLOAD_ERROR', error: notice.message })
|
||||
pushMappedNotice(notice)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -414,39 +423,53 @@ function useUploadMachine({ draftId, filesCdnUrl, chunkSize, userId }) {
|
||||
offset = nextOffset
|
||||
}
|
||||
} catch (error) {
|
||||
const message = extractErrorMessage(error, 'File upload failed. Please retry.')
|
||||
dispatch({ type: 'UPLOAD_ERROR', error: message })
|
||||
pushNotice('error', message)
|
||||
const notice = mapUploadErrorNotice(error, 'File upload failed. Please retry.')
|
||||
dispatch({ type: 'UPLOAD_ERROR', error: notice.message })
|
||||
pushMappedNotice(notice)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}, [chunkSize, fetchStatus, uploadChunk])
|
||||
}, [chunkSize, fetchStatus, uploadChunk, pushMappedNotice])
|
||||
|
||||
const finishUpload = useCallback(async (sessionId, uploadToken, artworkId) => {
|
||||
dispatch({ type: 'FINISH_START' })
|
||||
try {
|
||||
const res = await window.axios.post(
|
||||
'/api/uploads/finish',
|
||||
{ session_id: sessionId, artwork_id: artworkId, upload_token: uploadToken },
|
||||
{
|
||||
session_id: sessionId,
|
||||
artwork_id: artworkId,
|
||||
upload_token: uploadToken,
|
||||
file_name: String(state.file?.name || ''),
|
||||
},
|
||||
{ headers: { 'X-Upload-Token': uploadToken } }
|
||||
)
|
||||
const data = res.data || {}
|
||||
const previewPath = data.preview_path
|
||||
const previewUrl = previewPath ? `${filesCdnUrl}/${previewPath}` : null
|
||||
dispatch({ type: 'FINISH_SUCCESS', status: data.status, previewUrl })
|
||||
|
||||
const finishNotice = mapUploadResultNotice(data, {
|
||||
fallbackType: String(data.status || '').toLowerCase() === 'queued' ? 'warning' : 'success',
|
||||
fallbackMessage: String(data.status || '').toLowerCase() === 'queued'
|
||||
? 'Upload received. Processing is queued.'
|
||||
: 'Upload finalized successfully.',
|
||||
})
|
||||
pushMappedNotice(finishNotice)
|
||||
|
||||
if (userId) {
|
||||
clearStoredSession(userId)
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
const message = extractErrorMessage(error, 'Upload finalization failed.')
|
||||
dispatch({ type: 'FINISH_ERROR', error: message })
|
||||
pushNotice('error', message)
|
||||
const notice = mapUploadErrorNotice(error, 'Upload finalization failed.')
|
||||
dispatch({ type: 'FINISH_ERROR', error: notice.message })
|
||||
pushMappedNotice(notice)
|
||||
return false
|
||||
}
|
||||
}, [filesCdnUrl, userId])
|
||||
}, [filesCdnUrl, userId, pushMappedNotice])
|
||||
|
||||
const startUpload = useCallback(async () => {
|
||||
if (!state.file) {
|
||||
@@ -529,6 +552,7 @@ function useUploadMachine({ draftId, filesCdnUrl, chunkSize, userId }) {
|
||||
}
|
||||
dispatch({ type: 'CANCEL_SUCCESS' })
|
||||
dispatch({ type: 'RESET' })
|
||||
pushNotice('warning', 'Upload cancelled.')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -547,7 +571,8 @@ function useUploadMachine({ draftId, filesCdnUrl, chunkSize, userId }) {
|
||||
}
|
||||
dispatch({ type: 'CANCEL_SUCCESS' })
|
||||
dispatch({ type: 'RESET' })
|
||||
}, [state.sessionId, state.uploadToken, userId])
|
||||
pushNotice('warning', 'Upload cancelled.')
|
||||
}, [state.sessionId, state.uploadToken, userId, pushNotice])
|
||||
|
||||
return {
|
||||
state,
|
||||
@@ -683,7 +708,9 @@ export default function UploadPage({ draftId, filesCdnUrl, chunkSize }) {
|
||||
aria-live="polite"
|
||||
className={`rounded-xl border px-4 py-3 text-sm ${notice.type === 'error'
|
||||
? 'border-red-500/40 bg-red-500/10 text-red-100'
|
||||
: 'border-emerald-400/40 bg-emerald-400/10 text-emerald-100'}`}
|
||||
: notice.type === 'warning'
|
||||
? 'border-amber-400/40 bg-amber-400/10 text-amber-100'
|
||||
: 'border-emerald-400/40 bg-emerald-400/10 text-emerald-100'}`}
|
||||
>
|
||||
{notice.message}
|
||||
</div>
|
||||
|
||||
54
resources/js/components/Feed/EmbeddedArtworkCard.jsx
Normal file
54
resources/js/components/Feed/EmbeddedArtworkCard.jsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React from 'react'
|
||||
|
||||
/**
|
||||
* Compact artwork card for embedding inside a PostCard.
|
||||
* Shows thumbnail, title and original author with attribution.
|
||||
*/
|
||||
export default function EmbeddedArtworkCard({ artwork }) {
|
||||
if (!artwork) return null
|
||||
|
||||
const artUrl = `/art/${artwork.id}/${slugify(artwork.title)}`
|
||||
const authorUrl = `/@${artwork.author.username}`
|
||||
|
||||
return (
|
||||
<a
|
||||
href={artUrl}
|
||||
className="group flex gap-3 rounded-xl border border-white/[0.08] bg-black/30 p-3 hover:border-sky-500/30 transition-colors"
|
||||
title={artwork.title}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<div className="w-20 h-16 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 transition-transform duration-300 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-slate-600">
|
||||
<i className="fa-solid fa-image" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Meta */}
|
||||
<div className="flex flex-col justify-center min-w-0">
|
||||
<p className="text-sm font-medium text-white/90 truncate">{artwork.title}</p>
|
||||
<a
|
||||
href={authorUrl}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-xs text-slate-400 hover:text-sky-400 transition-colors mt-0.5 truncate"
|
||||
>
|
||||
<i className="fa-solid fa-user-circle fa-fw mr-1 opacity-60" />
|
||||
by {artwork.author.name || `@${artwork.author.username}`}
|
||||
</a>
|
||||
<span className="text-[10px] text-slate-600 mt-1 uppercase tracking-wider">Artwork</span>
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
function slugify(str) {
|
||||
return (str || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||
}
|
||||
396
resources/js/components/Feed/FeedSidebar.jsx
Normal file
396
resources/js/components/Feed/FeedSidebar.jsx
Normal file
@@ -0,0 +1,396 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import axios from 'axios'
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function fmt(n) {
|
||||
if (n === null || n === undefined) return '0'
|
||||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1).replace(/\.0$/, '') + 'M'
|
||||
if (n >= 1_000) return (n / 1_000).toFixed(1).replace(/\.0$/, '') + 'k'
|
||||
return String(n)
|
||||
}
|
||||
|
||||
const SOCIAL_META = {
|
||||
twitter: { icon: 'fa-brands fa-x-twitter', label: 'Twitter / X', prefix: 'https://x.com/' },
|
||||
instagram: { icon: 'fa-brands fa-instagram', label: 'Instagram', prefix: 'https://instagram.com/' },
|
||||
deviantart: { icon: 'fa-brands fa-deviantart', label: 'DeviantArt', prefix: 'https://deviantart.com/' },
|
||||
artstation: { icon: 'fa-brands fa-artstation', label: 'ArtStation', prefix: 'https://artstation.com/' },
|
||||
behance: { icon: 'fa-brands fa-behance', label: 'Behance', prefix: 'https://behance.net/' },
|
||||
website: { icon: 'fa-solid fa-globe', label: 'Website', prefix: '' },
|
||||
youtube: { icon: 'fa-brands fa-youtube', label: 'YouTube', prefix: '' },
|
||||
twitch: { icon: 'fa-brands fa-twitch', label: 'Twitch', prefix: '' },
|
||||
}
|
||||
|
||||
function SideCard({ title, icon, children, className = '' }) {
|
||||
return (
|
||||
<div className={`rounded-2xl border border-white/[0.07] bg-white/[0.03] overflow-hidden ${className}`}>
|
||||
{title && (
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-white/[0.05]">
|
||||
{icon && <i className={`${icon} text-slate-500 fa-fw text-[13px]`} />}
|
||||
<span className="text-[11px] font-semibold uppercase tracking-widest text-slate-500">{title}</span>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Stats card
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function StatsCard({ stats, followerCount, user, onTabChange }) {
|
||||
const items = [
|
||||
{
|
||||
label: 'Artworks',
|
||||
value: fmt(stats?.uploads_count ?? 0),
|
||||
icon: 'fa-solid fa-image',
|
||||
color: 'text-sky-400',
|
||||
tab: 'artworks',
|
||||
},
|
||||
{
|
||||
label: 'Followers',
|
||||
value: fmt(followerCount ?? stats?.followers_count ?? 0),
|
||||
icon: 'fa-solid fa-user-group',
|
||||
color: 'text-violet-400',
|
||||
tab: null,
|
||||
},
|
||||
{
|
||||
label: 'Following',
|
||||
value: fmt(stats?.following_count ?? 0),
|
||||
icon: 'fa-solid fa-user-plus',
|
||||
color: 'text-emerald-400',
|
||||
tab: null,
|
||||
},
|
||||
{
|
||||
label: 'Awards',
|
||||
value: fmt(stats?.awards_received_count ?? 0),
|
||||
icon: 'fa-solid fa-trophy',
|
||||
color: 'text-amber-400',
|
||||
tab: 'stats',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<SideCard title="Stats" icon="fa-solid fa-chart-simple">
|
||||
<div className="grid grid-cols-2 divide-x divide-y divide-white/[0.05]">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.label}
|
||||
type="button"
|
||||
onClick={() => item.tab && onTabChange?.(item.tab)}
|
||||
className={`flex flex-col items-center gap-1 py-4 px-3 transition-colors group ${
|
||||
item.tab ? 'hover:bg-white/[0.04] cursor-pointer' : 'cursor-default'
|
||||
}`}
|
||||
>
|
||||
<i className={`${item.icon} ${item.color} text-sm fa-fw mb-0.5 group-hover:scale-110 transition-transform`} />
|
||||
<span className="text-xl font-bold text-white/90 tabular-nums leading-none">{item.value}</span>
|
||||
<span className="text-[10px] text-slate-500 uppercase tracking-wide">{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</SideCard>
|
||||
)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// About card
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function AboutCard({ user, profile, socialLinks, countryName }) {
|
||||
const bio = profile?.bio || profile?.about || profile?.description
|
||||
const website = profile?.website || user?.website
|
||||
|
||||
const hasSocials = socialLinks && Object.keys(socialLinks).length > 0
|
||||
const hasContent = bio || countryName || website || hasSocials
|
||||
|
||||
if (!hasContent) return null
|
||||
|
||||
return (
|
||||
<SideCard title="About" icon="fa-solid fa-circle-info">
|
||||
<div className="px-4 py-3 space-y-3">
|
||||
{bio && (
|
||||
<p className="text-sm text-slate-300 leading-relaxed line-clamp-4">{bio}</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{countryName && (
|
||||
<div className="flex items-center gap-2 text-[13px] text-slate-400">
|
||||
<i className="fa-solid fa-location-dot fa-fw text-slate-600 text-xs" />
|
||||
<span>{countryName}</span>
|
||||
</div>
|
||||
)}
|
||||
{website && (
|
||||
<div className="flex items-center gap-2 text-[13px]">
|
||||
<i className="fa-solid fa-link fa-fw text-slate-600 text-xs" />
|
||||
<a
|
||||
href={website.startsWith('http') ? website : `https://${website}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
className="text-sky-400/80 hover:text-sky-400 transition-colors truncate max-w-[200px]"
|
||||
>
|
||||
{website.replace(/^https?:\/\//, '')}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasSocials && (
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{Object.entries(socialLinks).map(([platform, link]) => {
|
||||
const meta = SOCIAL_META[platform] ?? SOCIAL_META.website
|
||||
const url = link.url || (meta.prefix ? meta.prefix + link.handle : null)
|
||||
if (!url) return null
|
||||
return (
|
||||
<a
|
||||
key={platform}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
title={meta.label}
|
||||
className="flex items-center justify-center w-8 h-8 rounded-lg bg-white/5 hover:bg-sky-500/15 text-slate-400 hover:text-sky-400 transition-all border border-white/[0.06] hover:border-sky-500/30"
|
||||
>
|
||||
<i className={`${meta.icon} text-sm`} />
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SideCard>
|
||||
)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Recent followers card
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function RecentFollowersCard({ recentFollowers, followerCount, onTabChange }) {
|
||||
const followers = recentFollowers ?? []
|
||||
if (followers.length === 0) return null
|
||||
|
||||
return (
|
||||
<SideCard title="Recent Followers" icon="fa-solid fa-user-group">
|
||||
<div className="px-4 py-3 space-y-2.5">
|
||||
{followers.slice(0, 6).map((f) => (
|
||||
<a
|
||||
key={f.id}
|
||||
href={f.profile_url ?? `/@${f.username}`}
|
||||
className="flex items-center gap-2.5 group"
|
||||
>
|
||||
<img
|
||||
src={f.avatar_url ?? '/images/avatar_default.webp'}
|
||||
alt={f.username}
|
||||
className="w-8 h-8 rounded-full object-cover ring-1 ring-white/10 group-hover:ring-sky-500/40 transition-all shrink-0"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[13px] font-medium text-slate-300 group-hover:text-white transition-colors truncate leading-tight">
|
||||
{f.name || f.uname || f.username}
|
||||
</p>
|
||||
<p className="text-[11px] text-slate-600 truncate">@{f.username}</p>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
|
||||
{followerCount > 6 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTabChange?.('artworks')}
|
||||
className="w-full text-center text-[12px] text-slate-500 hover:text-sky-400 transition-colors pt-1"
|
||||
>
|
||||
View all {fmt(followerCount)} followers →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</SideCard>
|
||||
)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Trending hashtags card
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function TrendingHashtagsCard() {
|
||||
const [tags, setTags] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
axios.get('/api/feed/hashtags/trending', { params: { limit: 8 } })
|
||||
.then(({ data }) => setTags(Array.isArray(data.hashtags) ? data.hashtags : []))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
if (!loading && tags.length === 0) return null
|
||||
|
||||
return (
|
||||
<SideCard title="Trending Tags" icon="fa-solid fa-hashtag">
|
||||
<div className="px-4 py-3 space-y-1">
|
||||
{loading
|
||||
? [1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="animate-pulse flex items-center justify-between py-1.5">
|
||||
<div className="h-2.5 bg-white/10 rounded w-20" />
|
||||
<div className="h-2 bg-white/6 rounded w-10" />
|
||||
</div>
|
||||
))
|
||||
: tags.map((h) => (
|
||||
<a
|
||||
key={h.tag}
|
||||
href={`/tags/${h.tag}`}
|
||||
className="flex items-center justify-between group py-1.5 px-1 rounded-lg hover:bg-white/[0.04] transition-colors"
|
||||
>
|
||||
<span className="text-sm text-slate-300 group-hover:text-sky-400 transition-colors font-medium">
|
||||
#{h.tag}
|
||||
</span>
|
||||
<span className="text-[11px] text-slate-600 tabular-nums">{h.post_count} posts</span>
|
||||
</a>
|
||||
))
|
||||
}
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<a
|
||||
href="/feed/trending"
|
||||
className="text-[12px] text-sky-500/70 hover:text-sky-400 transition-colors"
|
||||
>
|
||||
See trending →
|
||||
</a>
|
||||
<a
|
||||
href="/feed/search"
|
||||
className="text-[12px] text-slate-500 hover:text-slate-300 transition-colors"
|
||||
>
|
||||
<i className="fa-solid fa-magnifying-glass mr-1 text-[10px]" />
|
||||
Search
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</SideCard>
|
||||
)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Suggested to follow card
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function SuggestionsCard({ excludeUsername, isLoggedIn }) {
|
||||
const [users, setUsers] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn) { setLoading(false); return }
|
||||
axios.get('/api/search/users', { params: { q: '', per_page: 5 } })
|
||||
.then(({ data }) => {
|
||||
const list = (data.data ?? []).filter((u) => u.username !== excludeUsername).slice(0, 4)
|
||||
setUsers(list)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [excludeUsername, isLoggedIn])
|
||||
|
||||
if (!isLoggedIn) return null
|
||||
if (!loading && users.length === 0) return null
|
||||
|
||||
return (
|
||||
<SideCard title="Discover Creators" icon="fa-solid fa-compass">
|
||||
<div className="px-4 py-3 space-y-2.5">
|
||||
{loading ? (
|
||||
[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="flex items-center gap-2.5 animate-pulse">
|
||||
<div className="w-8 h-8 rounded-full bg-white/10 shrink-0" />
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="h-2.5 bg-white/10 rounded w-24" />
|
||||
<div className="h-2 bg-white/6 rounded w-16" />
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
users.map((u) => (
|
||||
<a
|
||||
key={u.id}
|
||||
href={u.profile_url ?? `/@${u.username}`}
|
||||
className="flex items-center gap-2.5 group"
|
||||
>
|
||||
<img
|
||||
src={u.avatar_url ?? '/images/avatar_default.webp'}
|
||||
alt={u.username}
|
||||
className="w-8 h-8 rounded-full object-cover ring-1 ring-white/10 group-hover:ring-sky-500/40 transition-all shrink-0"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[13px] font-medium text-slate-300 group-hover:text-white transition-colors truncate leading-tight">
|
||||
{u.name || u.username}
|
||||
</p>
|
||||
<p className="text-[11px] text-slate-600 truncate">@{u.username}</p>
|
||||
</div>
|
||||
<span className="shrink-0 text-[11px] text-sky-500/80 group-hover:text-sky-400 transition-colors font-medium">
|
||||
View
|
||||
</span>
|
||||
</a>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</SideCard>
|
||||
)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Main export
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* FeedSidebar
|
||||
*
|
||||
* Props:
|
||||
* user object { id, username, name, uploads_count, ...}
|
||||
* profile object { bio, about, country, website, ... }
|
||||
* stats object from user_statistics
|
||||
* followerCount number
|
||||
* recentFollowers array [{ id, username, name, avatar_url, profile_url }]
|
||||
* socialLinks object keyed by platform
|
||||
* countryName string|null
|
||||
* isLoggedIn boolean
|
||||
* onTabChange function(tab)
|
||||
*/
|
||||
export default function FeedSidebar({
|
||||
user,
|
||||
profile,
|
||||
stats,
|
||||
followerCount,
|
||||
recentFollowers,
|
||||
socialLinks,
|
||||
countryName,
|
||||
isLoggedIn,
|
||||
onTabChange,
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<StatsCard
|
||||
stats={stats}
|
||||
followerCount={followerCount}
|
||||
user={user}
|
||||
onTabChange={onTabChange}
|
||||
/>
|
||||
|
||||
<AboutCard
|
||||
user={user}
|
||||
profile={profile}
|
||||
socialLinks={socialLinks}
|
||||
countryName={countryName}
|
||||
/>
|
||||
|
||||
<RecentFollowersCard
|
||||
recentFollowers={recentFollowers}
|
||||
followerCount={followerCount}
|
||||
onTabChange={onTabChange}
|
||||
/>
|
||||
|
||||
<SuggestionsCard
|
||||
excludeUsername={user?.username}
|
||||
isLoggedIn={isLoggedIn}
|
||||
/>
|
||||
|
||||
<TrendingHashtagsCard />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
96
resources/js/components/Feed/LinkPreviewCard.jsx
Normal file
96
resources/js/components/Feed/LinkPreviewCard.jsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import React from 'react'
|
||||
|
||||
/**
|
||||
* LinkPreviewCard
|
||||
* Renders an OG/OpenGraph link preview card.
|
||||
*
|
||||
* Props:
|
||||
* preview { url, title, description, image, site_name }
|
||||
* onDismiss function|null — if provided, shows a dismiss ✕ button
|
||||
* loading boolean — shows skeleton while fetching
|
||||
*/
|
||||
export default function LinkPreviewCard({ preview, onDismiss, loading = false }) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="rounded-xl border border-white/[0.08] bg-white/[0.03] overflow-hidden flex gap-3 p-3 animate-pulse">
|
||||
<div className="w-20 h-20 rounded-lg bg-white/10 shrink-0" />
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-2 justify-center">
|
||||
<div className="h-3 bg-white/10 rounded w-2/3" />
|
||||
<div className="h-2.5 bg-white/10 rounded w-full" />
|
||||
<div className="h-2.5 bg-white/10 rounded w-4/5" />
|
||||
<div className="h-2 bg-white/[0.06] rounded w-1/3 mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!preview?.url) return null
|
||||
|
||||
const domain = (() => {
|
||||
try { return new URL(preview.url).hostname.replace(/^www\./, '') }
|
||||
catch { return preview.site_name ?? '' }
|
||||
})()
|
||||
|
||||
return (
|
||||
<div className="relative rounded-xl border border-white/[0.08] bg-white/[0.03] overflow-hidden hover:border-white/[0.14] transition-colors group">
|
||||
<a
|
||||
href={preview.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
className="flex gap-0 items-stretch"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Image */}
|
||||
{preview.image ? (
|
||||
<div className="w-24 shrink-0 bg-white/5">
|
||||
<img
|
||||
src={preview.image}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
onError={(e) => { e.currentTarget.parentElement.style.display = 'none' }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-24 shrink-0 bg-white/[0.04] flex items-center justify-center text-slate-600">
|
||||
<i className="fa-solid fa-link text-xl" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text */}
|
||||
<div className="flex-1 min-w-0 px-3 py-2.5 flex flex-col justify-center gap-0.5">
|
||||
{preview.site_name && (
|
||||
<p className="text-[10px] uppercase tracking-wide text-sky-500/80 font-medium truncate">
|
||||
{preview.site_name}
|
||||
</p>
|
||||
)}
|
||||
{preview.title && (
|
||||
<p className="text-sm font-semibold text-white/90 line-clamp-2 leading-snug">
|
||||
{preview.title}
|
||||
</p>
|
||||
)}
|
||||
{preview.description && (
|
||||
<p className="text-xs text-slate-500 line-clamp-2 leading-relaxed mt-0.5">
|
||||
{preview.description}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[10px] text-slate-600 mt-1 truncate">
|
||||
{domain}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{/* Dismiss button */}
|
||||
{onDismiss && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onDismiss() }}
|
||||
className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-black/60 hover:bg-black/80 text-slate-400 hover:text-white flex items-center justify-center transition-colors text-[10px]"
|
||||
aria-label="Remove link preview"
|
||||
>
|
||||
<i className="fa-solid fa-xmark" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
139
resources/js/components/Feed/PostActions.jsx
Normal file
139
resources/js/components/Feed/PostActions.jsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import React, { useState } from 'react'
|
||||
import axios from 'axios'
|
||||
|
||||
/**
|
||||
* PostActions: Like toggle, Comment toggle, Share menu, Report
|
||||
*/
|
||||
export default function PostActions({
|
||||
post,
|
||||
isLoggedIn,
|
||||
onCommentToggle,
|
||||
onReactionChange,
|
||||
}) {
|
||||
const [liked, setLiked] = useState(post.viewer_liked ?? false)
|
||||
const [likeCount, setLikeCount] = useState(post.reactions_count ?? 0)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [shareMsg, setShareMsg] = useState(null)
|
||||
|
||||
const handleLike = async () => {
|
||||
if (!isLoggedIn) {
|
||||
window.location.href = '/login'
|
||||
return
|
||||
}
|
||||
if (busy) return
|
||||
setBusy(true)
|
||||
try {
|
||||
if (liked) {
|
||||
await axios.delete(`/api/posts/${post.id}/reactions/like`)
|
||||
setLiked(false)
|
||||
setLikeCount((c) => Math.max(0, c - 1))
|
||||
onReactionChange?.({ liked: false, count: Math.max(0, likeCount - 1) })
|
||||
} else {
|
||||
await axios.post(`/api/posts/${post.id}/reactions`, { reaction: 'like' })
|
||||
setLiked(true)
|
||||
setLikeCount((c) => c + 1)
|
||||
onReactionChange?.({ liked: true, count: likeCount + 1 })
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyLink = () => {
|
||||
const url = `${window.location.origin}/@${post.author.username}?tab=posts&post=${post.id}`
|
||||
navigator.clipboard?.writeText(url)
|
||||
setShareMsg('Link copied!')
|
||||
setTimeout(() => setShareMsg(null), 2000)
|
||||
setMenuOpen(false)
|
||||
}
|
||||
|
||||
const handleReport = async () => {
|
||||
setMenuOpen(false)
|
||||
const reason = window.prompt('Why are you reporting this post? (required)')
|
||||
if (!reason?.trim()) return
|
||||
try {
|
||||
await axios.post(`/api/posts/${post.id}/report`, { reason: reason.trim() })
|
||||
alert('Report submitted. Thank you!')
|
||||
} catch (err) {
|
||||
if (err.response?.data?.message) {
|
||||
alert(err.response.data.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-slate-400 relative">
|
||||
{/* Like */}
|
||||
<button
|
||||
onClick={handleLike}
|
||||
disabled={busy}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-colors ${
|
||||
liked
|
||||
? 'text-sky-400 bg-sky-500/10 hover:bg-sky-500/20'
|
||||
: 'hover:bg-white/5 hover:text-slate-200'
|
||||
}`}
|
||||
title={liked ? 'Unlike' : 'Like'}
|
||||
aria-label={liked ? 'Unlike this post' : 'Like this post'}
|
||||
>
|
||||
<i className={`fa-${liked ? 'solid' : 'regular'} fa-heart fa-fw text-xs`} />
|
||||
<span className="tabular-nums">{likeCount > 0 && likeCount}</span>
|
||||
</button>
|
||||
|
||||
{/* Comment toggle */}
|
||||
<button
|
||||
onClick={onCommentToggle}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm hover:bg-white/5 hover:text-slate-200 transition-colors"
|
||||
title="Comment"
|
||||
aria-label="Show comments"
|
||||
>
|
||||
<i className="fa-regular fa-comment fa-fw text-xs" />
|
||||
<span className="tabular-nums">{post.comments_count > 0 && post.comments_count}</span>
|
||||
</button>
|
||||
|
||||
{/* Share / More menu */}
|
||||
<div className="relative ml-auto">
|
||||
<button
|
||||
onClick={() => setMenuOpen((v) => !v)}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-sm hover:bg-white/5 hover:text-slate-200 transition-colors"
|
||||
aria-label="More options"
|
||||
>
|
||||
<i className="fa-solid fa-ellipsis-h fa-fw text-xs" />
|
||||
</button>
|
||||
|
||||
{menuOpen && (
|
||||
<div
|
||||
className="absolute right-0 bottom-full mb-1 w-44 rounded-xl border border-white/10 bg-[#10192e] shadow-2xl z-50 overflow-hidden"
|
||||
onBlur={() => setMenuOpen(false)}
|
||||
>
|
||||
<button
|
||||
onClick={handleCopyLink}
|
||||
className="w-full text-left px-4 py-2.5 text-sm text-slate-300 hover:bg-white/5 flex items-center gap-2"
|
||||
>
|
||||
<i className="fa-solid fa-link fa-fw opacity-60" />
|
||||
Copy link
|
||||
</button>
|
||||
{isLoggedIn && (
|
||||
<button
|
||||
onClick={handleReport}
|
||||
className="w-full text-left px-4 py-2.5 text-sm text-rose-400 hover:bg-white/5 flex items-center gap-2"
|
||||
>
|
||||
<i className="fa-solid fa-flag fa-fw opacity-60" />
|
||||
Report post
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Share feedback toast */}
|
||||
{shareMsg && (
|
||||
<span className="absolute -top-8 right-0 text-xs bg-slate-800 text-white px-2 py-1 rounded shadow">
|
||||
{shareMsg}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
404
resources/js/components/Feed/PostCard.jsx
Normal file
404
resources/js/components/Feed/PostCard.jsx
Normal file
@@ -0,0 +1,404 @@
|
||||
import React, { useState } from 'react'
|
||||
import PostActions from './PostActions'
|
||||
import PostComments from './PostComments'
|
||||
import EmbeddedArtworkCard from './EmbeddedArtworkCard'
|
||||
import VisibilityPill from './VisibilityPill'
|
||||
import LinkPreviewCard from './LinkPreviewCard'
|
||||
|
||||
function formatRelative(isoString) {
|
||||
const diff = Date.now() - new Date(isoString).getTime()
|
||||
const s = Math.floor(diff / 1000)
|
||||
if (s < 60) return 'just now'
|
||||
const m = Math.floor(s / 60)
|
||||
if (m < 60) return `${m}m ago`
|
||||
const h = Math.floor(m / 60)
|
||||
if (h < 24) return `${h}h ago`
|
||||
const d = Math.floor(h / 24)
|
||||
return `${d}d ago`
|
||||
}
|
||||
|
||||
function formatScheduledDate(isoString) {
|
||||
const d = new Date(isoString)
|
||||
return d.toLocaleString(undefined, {
|
||||
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
/** Render plain text body with #hashtag links */
|
||||
function BodyWithHashtags({ html }) {
|
||||
// The body may already be sanitised HTML from the server. We replace
|
||||
// #tag patterns in text nodes (not inside existing anchor elements) with
|
||||
// anchor links pointing to /tags/{tag}.
|
||||
const processed = html.replace(
|
||||
/(?<!["\w])#([A-Za-z][A-Za-z0-9_]{1,63})/g,
|
||||
(_, tag) => `<a href="/tags/${tag.toLowerCase()}" class="text-sky-400 hover:underline">#${tag}</a>`,
|
||||
)
|
||||
return (
|
||||
<div
|
||||
className="text-sm text-slate-300 leading-relaxed [&_a]:text-sky-400 [&_a]:hover:underline [&_strong]:text-white/90 [&_em]:text-slate-200"
|
||||
dangerouslySetInnerHTML={{ __html: processed }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* PostCard
|
||||
* Renders a single post in the feed. Supports text + artwork_share types.
|
||||
*
|
||||
* Props:
|
||||
* post object (formatted by PostFeedService::formatPost)
|
||||
* isLoggedIn boolean
|
||||
* viewerUsername string|null
|
||||
* onDelete function(postId)
|
||||
* onUnsaved function(postId) — called when viewer unsaves this post
|
||||
*/
|
||||
export default function PostCard({ post, isLoggedIn = false, viewerUsername = null, onDelete, onUnsaved }) {
|
||||
const [showComments, setShowComments] = useState(false)
|
||||
const [postData, setPostData] = useState(post)
|
||||
const [editMode, setEditMode] = useState(false)
|
||||
const [editBody, setEditBody] = useState(post.body ?? '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [saveLoading, setSaveLoading] = useState(false)
|
||||
const [analyticsOpen, setAnalyticsOpen] = useState(false)
|
||||
const [analytics, setAnalytics] = useState(null)
|
||||
|
||||
const isOwn = viewerUsername && post.author.username === viewerUsername
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const { default: axios } = await import('axios')
|
||||
const { data } = await axios.patch(`/api/posts/${post.id}`, { body: editBody })
|
||||
setPostData(data.post)
|
||||
setEditMode(false)
|
||||
} catch {
|
||||
//
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!window.confirm('Delete this post?')) return
|
||||
try {
|
||||
const { default: axios } = await import('axios')
|
||||
await axios.delete(`/api/posts/${post.id}`)
|
||||
onDelete?.(post.id)
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
const handlePin = async () => {
|
||||
const { default: axios } = await import('axios')
|
||||
try {
|
||||
if (postData.is_pinned) {
|
||||
await axios.delete(`/api/posts/${post.id}/pin`)
|
||||
setPostData((p) => ({ ...p, is_pinned: false, pinned_order: null }))
|
||||
} else {
|
||||
const { data } = await axios.post(`/api/posts/${post.id}/pin`)
|
||||
setPostData((p) => ({ ...p, is_pinned: true, pinned_order: data.pinned_order ?? 1 }))
|
||||
}
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
setMenuOpen(false)
|
||||
}
|
||||
|
||||
const handleSaveToggle = async () => {
|
||||
if (!isLoggedIn || saveLoading) return
|
||||
setSaveLoading(true)
|
||||
const { default: axios } = await import('axios')
|
||||
try {
|
||||
if (postData.viewer_saved) {
|
||||
await axios.delete(`/api/posts/${post.id}/save`)
|
||||
setPostData((p) => ({ ...p, viewer_saved: false, saves_count: Math.max(0, (p.saves_count ?? 1) - 1) }))
|
||||
onUnsaved?.(post.id)
|
||||
} else {
|
||||
await axios.post(`/api/posts/${post.id}/save`)
|
||||
setPostData((p) => ({ ...p, viewer_saved: true, saves_count: (p.saves_count ?? 0) + 1 }))
|
||||
}
|
||||
} catch {
|
||||
//
|
||||
} finally {
|
||||
setSaveLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenAnalytics = async () => {
|
||||
if (!isOwn) return
|
||||
setAnalyticsOpen(true)
|
||||
if (!analytics) {
|
||||
const { default: axios } = await import('axios')
|
||||
try {
|
||||
const { data } = await axios.get(`/api/posts/${post.id}/analytics`)
|
||||
setAnalytics(data)
|
||||
} catch {
|
||||
setAnalytics(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="rounded-2xl border border-white/[0.06] bg-white/[0.025] hover:border-white/10 transition-colors">
|
||||
{/* ── Pinned banner ──────────────────────────────────────────────── */}
|
||||
{postData.is_pinned && (
|
||||
<div className="flex items-center gap-1.5 px-5 pt-3 pb-0 text-[11px] text-slate-500">
|
||||
<i className="fa-solid fa-thumbtack fa-fw text-sky-500/60" />
|
||||
<span>Pinned post</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Scheduled banner (owner only) ─────────────────────────────── */}
|
||||
{isOwn && postData.status === 'scheduled' && postData.publish_at && (
|
||||
<div className="flex items-center gap-1.5 px-5 pt-3 pb-0 text-[11px] text-amber-500/80">
|
||||
<i className="fa-regular fa-clock fa-fw" />
|
||||
<span>Scheduled for {formatScheduledDate(postData.publish_at)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Achievement badge ──────────────────────────────────────────── */}
|
||||
{postData.type === 'achievement' && (
|
||||
<div className="flex items-center gap-1.5 px-5 pt-3 pb-0 text-[11px] text-amber-400/90">
|
||||
<i className="fa-solid fa-trophy fa-fw text-amber-400" />
|
||||
<span className="font-medium tracking-wide uppercase">Achievement unlocked</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Header ─────────────────────────────────────────────────────── */}
|
||||
<div className="flex items-center gap-3 px-5 pt-4 pb-3">
|
||||
<a href={`/@${post.author.username}`} className="shrink-0">
|
||||
<img
|
||||
src={post.author.avatar ?? '/images/avatar_default.webp'}
|
||||
alt={post.author.name}
|
||||
className="w-9 h-9 rounded-full object-cover ring-1 ring-white/10 hover:ring-sky-500/40 transition-all"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<a
|
||||
href={`/@${post.author.username}`}
|
||||
className="text-sm font-semibold text-white/90 hover:text-sky-400 transition-colors"
|
||||
>
|
||||
{post.author.name || `@${post.author.username}`}
|
||||
</a>
|
||||
<span className="text-slate-600 text-xs">@{post.author.username}</span>
|
||||
{post.meta?.tagged_users?.length > 0 && (
|
||||
<span className="text-slate-600 text-xs flex items-center gap-1 flex-wrap">
|
||||
<span className="text-slate-700">with</span>
|
||||
{post.meta.tagged_users.map((u, i) => (
|
||||
<React.Fragment key={u.id}>
|
||||
{i > 0 && <span className="text-slate-700">,</span>}
|
||||
<a href={`/@${u.username}`} className="text-sky-500/80 hover:text-sky-400 transition-colors">
|
||||
@{u.username}
|
||||
</a>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-slate-500 mt-0.5">
|
||||
<span>{formatRelative(post.created_at)}</span>
|
||||
<span aria-hidden>·</span>
|
||||
<VisibilityPill visibility={post.visibility} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right-side actions: save + owner menu */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Save / bookmark button */}
|
||||
{isLoggedIn && !isOwn && (
|
||||
<button
|
||||
onClick={handleSaveToggle}
|
||||
disabled={saveLoading}
|
||||
title={postData.viewer_saved ? 'Remove bookmark' : 'Save post'}
|
||||
className={`flex items-center justify-center w-8 h-8 rounded-lg transition-colors ${
|
||||
postData.viewer_saved
|
||||
? 'text-amber-400 hover:bg-amber-500/10'
|
||||
: 'text-slate-600 hover:text-slate-300 hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<i className={`${postData.viewer_saved ? 'fa-solid' : 'fa-regular'} fa-bookmark fa-fw text-sm`} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Analytics for owner */}
|
||||
{isOwn && (
|
||||
<button
|
||||
onClick={handleOpenAnalytics}
|
||||
title="Post analytics"
|
||||
className="flex items-center justify-center w-8 h-8 rounded-lg text-slate-600 hover:text-sky-400 hover:bg-sky-500/10 transition-colors"
|
||||
>
|
||||
<i className="fa-solid fa-chart-simple fa-fw text-sm" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Owner menu */}
|
||||
{isOwn && (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setMenuOpen((v) => !v)}
|
||||
className="text-slate-500 hover:text-slate-300 px-2 py-1 rounded-lg hover:bg-white/5 transition-colors"
|
||||
aria-label="Post options"
|
||||
>
|
||||
<i className="fa-solid fa-ellipsis-v fa-fw text-xs" />
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div className="absolute right-0 top-full mt-1 w-40 rounded-xl border border-white/10 bg-[#10192e] shadow-2xl z-50 overflow-hidden">
|
||||
<button
|
||||
onClick={() => { setEditMode(true); setMenuOpen(false) }}
|
||||
className="w-full text-left px-4 py-2.5 text-sm text-slate-300 hover:bg-white/5 flex items-center gap-2"
|
||||
>
|
||||
<i className="fa-solid fa-pen fa-fw opacity-60" />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePin}
|
||||
className="w-full text-left px-4 py-2.5 text-sm text-slate-300 hover:bg-white/5 flex items-center gap-2"
|
||||
>
|
||||
<i className={`fa-solid fa-thumbtack fa-fw opacity-60 ${postData.is_pinned ? 'text-sky-400' : ''}`} />
|
||||
{postData.is_pinned ? 'Unpin post' : 'Pin post'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { handleDelete(); setMenuOpen(false) }}
|
||||
className="w-full text-left px-4 py-2.5 text-sm text-rose-400 hover:bg-white/5 flex items-center gap-2"
|
||||
>
|
||||
<i className="fa-solid fa-trash-can fa-fw opacity-60" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Body ─────────────────────────────────────────────────────────── */}
|
||||
<div className="px-5 pb-3 space-y-3">
|
||||
{editMode ? (
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
value={editBody}
|
||||
onChange={(e) => setEditBody(e.target.value)}
|
||||
maxLength={2000}
|
||||
rows={4}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-sm text-white resize-none focus:outline-none focus:border-sky-500/50 transition-colors"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleSaveEdit}
|
||||
disabled={saving}
|
||||
className="px-4 py-1.5 rounded-lg bg-sky-600 hover:bg-sky-500 text-white text-xs transition-colors disabled:opacity-40"
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditMode(false)}
|
||||
className="px-4 py-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-white/5 text-xs transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
postData.body && <BodyWithHashtags html={postData.body} />
|
||||
)}
|
||||
|
||||
{/* Hashtag pills */}
|
||||
{!editMode && postData.hashtags && postData.hashtags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 pt-0.5">
|
||||
{postData.hashtags.map((tag) => (
|
||||
<a
|
||||
key={tag}
|
||||
href={`/tags/${tag}`}
|
||||
className="text-[11px] text-sky-500/80 hover:text-sky-400 hover:bg-sky-500/10 px-2 py-0.5 rounded-full border border-sky-500/20 hover:border-sky-500/40 transition-all"
|
||||
>
|
||||
#{tag}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Link preview (stored OG data) */}
|
||||
{!editMode && postData.meta?.link_preview?.url && (
|
||||
<LinkPreviewCard preview={postData.meta.link_preview} />
|
||||
)}
|
||||
|
||||
{/* Artwork share embed */}
|
||||
{postData.type === 'artwork_share' && postData.artwork && (
|
||||
<EmbeddedArtworkCard artwork={postData.artwork} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Actions ─────────────────────────────────────────────────────── */}
|
||||
<div className="border-t border-white/[0.04] px-5 py-2">
|
||||
<PostActions
|
||||
post={postData}
|
||||
isLoggedIn={isLoggedIn}
|
||||
onCommentToggle={() => setShowComments((v) => !v)}
|
||||
onReactionChange={({ liked, count }) =>
|
||||
setPostData((p) => ({ ...p, viewer_liked: liked, reactions_count: count }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Comments ────────────────────────────────────────────────────── */}
|
||||
{showComments && (
|
||||
<div className="border-t border-white/[0.04] px-5 py-4">
|
||||
<PostComments
|
||||
postId={post.id}
|
||||
isLoggedIn={isLoggedIn}
|
||||
isOwn={isOwn}
|
||||
initialCount={postData.comments_count}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Analytics modal ─────────────────────────────────────────────── */}
|
||||
{analyticsOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) setAnalyticsOpen(false) }}
|
||||
>
|
||||
<div className="w-full max-w-sm rounded-2xl border border-white/10 bg-[#0d1628] p-6 shadow-2xl">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-white">Post Analytics</h3>
|
||||
<button
|
||||
onClick={() => setAnalyticsOpen(false)}
|
||||
className="text-slate-500 hover:text-white w-7 h-7 flex items-center justify-center rounded-lg hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<i className="fa-solid fa-xmark" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!analytics ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<i className="fa-solid fa-spinner fa-spin text-slate-500 text-xl" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{[
|
||||
{ label: 'Impressions', value: analytics.impressions?.toLocaleString() ?? '—', icon: 'fa-eye', color: 'text-sky-400' },
|
||||
{ label: 'Saves', value: analytics.saves?.toLocaleString() ?? '—', icon: 'fa-bookmark', color: 'text-amber-400' },
|
||||
{ label: 'Reactions', value: analytics.reactions?.toLocaleString() ?? '—', icon: 'fa-heart', color: 'text-rose-400' },
|
||||
{ label: 'Engagement', value: analytics.engagement_rate ? `${analytics.engagement_rate}%` : '—', icon: 'fa-chart-simple', color: 'text-emerald-400' },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="rounded-xl bg-white/[0.04] border border-white/[0.06] px-4 py-3">
|
||||
<div className={`${item.color} text-sm mb-1`}>
|
||||
<i className={`fa-solid ${item.icon} fa-fw`} />
|
||||
</div>
|
||||
<p className="text-xl font-bold text-white/90 tabular-nums leading-tight">{item.value}</p>
|
||||
<p className="text-[11px] text-slate-500 mt-0.5">{item.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
30
resources/js/components/Feed/PostCardSkeleton.jsx
Normal file
30
resources/js/components/Feed/PostCardSkeleton.jsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function PostCardSkeleton() {
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.03] p-5 animate-pulse space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-full bg-white/10 shrink-0" />
|
||||
<div className="space-y-1.5 flex-1">
|
||||
<div className="h-3 bg-white/10 rounded w-28" />
|
||||
<div className="h-2 bg-white/6 rounded w-20" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Body */}
|
||||
<div className="space-y-2">
|
||||
<div className="h-3 bg-white/10 rounded w-full" />
|
||||
<div className="h-3 bg-white/8 rounded w-4/5" />
|
||||
<div className="h-3 bg-white/6 rounded w-2/3" />
|
||||
</div>
|
||||
{/* Artwork embed placeholder */}
|
||||
<div className="rounded-xl bg-white/5 aspect-[16/9]" />
|
||||
{/* Actions */}
|
||||
<div className="flex gap-4 pt-1">
|
||||
<div className="h-3 bg-white/8 rounded w-12" />
|
||||
<div className="h-3 bg-white/6 rounded w-16" />
|
||||
<div className="h-3 bg-white/6 rounded w-10" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
222
resources/js/components/Feed/PostComments.jsx
Normal file
222
resources/js/components/Feed/PostComments.jsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import axios from 'axios'
|
||||
|
||||
function formatRelative(isoString) {
|
||||
const diff = Date.now() - new Date(isoString).getTime()
|
||||
const s = Math.floor(diff / 1000)
|
||||
if (s < 60) return 'just now'
|
||||
const m = Math.floor(s / 60)
|
||||
if (m < 60) return `${m}m ago`
|
||||
const h = Math.floor(m / 60)
|
||||
if (h < 24) return `${h}h ago`
|
||||
const d = Math.floor(h / 24)
|
||||
return `${d}d ago`
|
||||
}
|
||||
|
||||
export default function PostComments({ postId, isLoggedIn, isOwn = false, initialCount = 0 }) {
|
||||
const [comments, setComments] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [body, setBody] = useState('')
|
||||
const [error, setError] = useState(null)
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const textareaRef = useRef(null)
|
||||
|
||||
const fetchComments = async (p = 1) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const { data } = await axios.get(`/api/posts/${postId}/comments`, { params: { page: p } })
|
||||
setComments((prev) => p === 1 ? data.data : [...prev, ...data.data])
|
||||
setHasMore(data.meta.current_page < data.meta.last_page)
|
||||
setPage(p)
|
||||
} catch {
|
||||
//
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoaded(true)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchComments(1)
|
||||
}, [postId])
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!body.trim()) return
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const { data } = await axios.post(`/api/posts/${postId}/comments`, { body })
|
||||
setComments((prev) => [...prev, data.comment])
|
||||
setBody('')
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.message ?? 'Failed to post comment.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (commentId) => {
|
||||
if (!window.confirm('Delete this comment?')) return
|
||||
try {
|
||||
await axios.delete(`/api/posts/${postId}/comments/${commentId}`)
|
||||
setComments((prev) => prev.filter((c) => c.id !== commentId))
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
const handleHighlight = async (comment) => {
|
||||
try {
|
||||
if (comment.is_highlighted) {
|
||||
await axios.delete(`/api/posts/${postId}/comments/${comment.id}/highlight`)
|
||||
setComments((prev) =>
|
||||
prev.map((c) => c.id === comment.id ? { ...c, is_highlighted: false } : c),
|
||||
)
|
||||
} else {
|
||||
await axios.post(`/api/posts/${postId}/comments/${comment.id}/highlight`)
|
||||
// Only one can be highlighted — clear others and set this one
|
||||
setComments((prev) =>
|
||||
prev.map((c) => ({ ...c, is_highlighted: c.id === comment.id })),
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
// Highlighted comment always first (server also orders this way, but keep client in sync)
|
||||
const sorted = [...comments].sort((a, b) =>
|
||||
(b.is_highlighted ? 1 : 0) - (a.is_highlighted ? 1 : 0),
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Comment list */}
|
||||
{!loaded && loading && (
|
||||
<div className="space-y-2">
|
||||
{[1, 2].map((i) => (
|
||||
<div key={i} className="flex gap-2 animate-pulse">
|
||||
<div className="w-7 h-7 rounded-full bg-white/10 shrink-0" />
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="h-2.5 bg-white/10 rounded w-24" />
|
||||
<div className="h-2 bg-white/6 rounded w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loaded && sorted.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className={`flex gap-2 group ${c.is_highlighted ? 'rounded-xl bg-sky-500/5 border border-sky-500/15 px-3 py-2 -mx-3' : ''}`}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<a href={`/@${c.author.username}`} className="shrink-0">
|
||||
<img
|
||||
src={c.author.avatar ?? '/images/avatar_default.webp'}
|
||||
alt={c.author.name}
|
||||
className="w-7 h-7 rounded-full object-cover ring-1 ring-white/10"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
{/* Body */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline gap-2 flex-wrap">
|
||||
<a
|
||||
href={`/@${c.author.username}`}
|
||||
className="text-xs font-semibold text-white/80 hover:text-sky-400 transition-colors"
|
||||
>
|
||||
{c.author.name || `@${c.author.username}`}
|
||||
</a>
|
||||
<span className="text-[10px] text-slate-600">{formatRelative(c.created_at)}</span>
|
||||
{c.is_highlighted && (
|
||||
<span className="text-[10px] text-sky-400 font-medium flex items-center gap-1">
|
||||
<i className="fa-solid fa-star fa-xs" />
|
||||
Highlighted by author
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="text-sm text-slate-300 mt-0.5 [&_a]:text-sky-400 [&_a]:hover:underline"
|
||||
dangerouslySetInnerHTML={{ __html: c.body }}
|
||||
/>
|
||||
</div>
|
||||
{/* Actions: highlight (owner) + delete */}
|
||||
<div className="flex items-start gap-1 opacity-0 group-hover:opacity-100 transition-all ml-1">
|
||||
{isOwn && (
|
||||
<button
|
||||
onClick={() => handleHighlight(c)}
|
||||
title={c.is_highlighted ? 'Remove highlight' : 'Highlight comment'}
|
||||
className={`text-xs transition-colors px-1 py-0.5 rounded ${
|
||||
c.is_highlighted
|
||||
? 'text-sky-400 hover:text-slate-400'
|
||||
: 'text-slate-600 hover:text-sky-400'
|
||||
}`}
|
||||
>
|
||||
<i className={`${c.is_highlighted ? 'fa-solid' : 'fa-regular'} fa-star`} />
|
||||
</button>
|
||||
)}
|
||||
{isLoggedIn && (
|
||||
<button
|
||||
onClick={() => handleDelete(c.id)}
|
||||
className="text-slate-600 hover:text-rose-400 transition-all text-xs"
|
||||
title="Delete comment"
|
||||
>
|
||||
<i className="fa-solid fa-trash-can" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{loaded && hasMore && (
|
||||
<button
|
||||
onClick={() => fetchComments(page + 1)}
|
||||
disabled={loading}
|
||||
className="text-xs text-sky-400 hover:text-sky-300 transition-colors"
|
||||
>
|
||||
{loading ? 'Loading…' : 'Load more comments'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Composer */}
|
||||
{isLoggedIn ? (
|
||||
<form onSubmit={handleSubmit} className="flex gap-2 mt-2">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
placeholder="Write a comment…"
|
||||
maxLength={1000}
|
||||
rows={1}
|
||||
className="flex-1 bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-sm text-white resize-none placeholder-slate-600 focus:outline-none focus:border-sky-500/50 transition-colors"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSubmit(e)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !body.trim()}
|
||||
className="px-3 py-2 rounded-xl bg-sky-600 hover:bg-sky-500 disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm transition-colors"
|
||||
>
|
||||
{submitting ? <i className="fa-solid fa-spinner fa-spin" /> : <i className="fa-solid fa-paper-plane" />}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<p className="text-xs text-slate-500 mt-2">
|
||||
<a href="/login" className="text-sky-400 hover:underline">Sign in</a> to comment.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-rose-400">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
456
resources/js/components/Feed/PostComposer.jsx
Normal file
456
resources/js/components/Feed/PostComposer.jsx
Normal file
@@ -0,0 +1,456 @@
|
||||
import React, { useState, useRef, useCallback, useEffect, lazy, Suspense } from 'react'
|
||||
import axios from 'axios'
|
||||
import ShareArtworkModal from './ShareArtworkModal'
|
||||
import LinkPreviewCard from './LinkPreviewCard'
|
||||
import TagPeopleModal from './TagPeopleModal'
|
||||
|
||||
// Lazy-load the heavy emoji picker only when first opened
|
||||
// @emoji-mart/react only has a default export (the Picker); m.Picker is undefined
|
||||
const EmojiPicker = lazy(() => import('@emoji-mart/react'))
|
||||
|
||||
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' },
|
||||
]
|
||||
|
||||
const URL_RE = /https?:\/\/[^\s\])"'>]{4,}/gi
|
||||
|
||||
function extractFirstUrl(text) {
|
||||
const m = text.match(URL_RE)
|
||||
return m ? m[0].replace(/[.,;:!?)]+$/, '') : null
|
||||
}
|
||||
|
||||
/**
|
||||
* PostComposer
|
||||
*
|
||||
* Props:
|
||||
* user object { id, username, name, avatar }
|
||||
* onPosted function(newPost)
|
||||
*/
|
||||
export default function PostComposer({ user, onPosted }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [body, setBody] = useState('')
|
||||
const [visibility, setVisibility] = useState('public')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [shareModal, setShareModal] = useState(false)
|
||||
const [linkPreview, setLinkPreview] = useState(null)
|
||||
const [previewLoading, setPreviewLoading] = useState(false)
|
||||
const [previewDismissed, setPreviewDismissed] = useState(false)
|
||||
const [lastPreviewUrl, setLastPreviewUrl] = useState(null)
|
||||
const [emojiOpen, setEmojiOpen] = useState(false)
|
||||
const [emojiData, setEmojiData] = useState(null) // loaded lazily
|
||||
const [tagModal, setTagModal] = useState(false)
|
||||
const [taggedUsers, setTaggedUsers] = useState([]) // [{ id, username, name, avatar_url }]
|
||||
const [scheduleOpen, setScheduleOpen] = useState(false)
|
||||
const [scheduledAt, setScheduledAt] = useState('') // ISO datetime-local string
|
||||
|
||||
const textareaRef = useRef(null)
|
||||
const debounceTimer = useRef(null)
|
||||
const emojiWrapRef = useRef(null) // wraps button + popover for outside-click
|
||||
|
||||
// Load emoji-mart data lazily the first time the picker opens
|
||||
const openEmojiPicker = useCallback(async () => {
|
||||
if (!emojiData) {
|
||||
const { default: data } = await import('@emoji-mart/data')
|
||||
setEmojiData(data)
|
||||
}
|
||||
setEmojiOpen((v) => !v)
|
||||
}, [emojiData])
|
||||
|
||||
// Close picker on outside click
|
||||
useEffect(() => {
|
||||
if (!emojiOpen) return
|
||||
const handler = (e) => {
|
||||
if (emojiWrapRef.current && !emojiWrapRef.current.contains(e.target)) {
|
||||
setEmojiOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [emojiOpen])
|
||||
|
||||
// Insert emoji at current cursor position
|
||||
const insertEmoji = useCallback((emoji) => {
|
||||
const native = emoji.native ?? emoji.shortcodes ?? ''
|
||||
const ta = textareaRef.current
|
||||
if (!ta) {
|
||||
setBody((b) => b + native)
|
||||
return
|
||||
}
|
||||
const start = ta.selectionStart ?? body.length
|
||||
const end = ta.selectionEnd ?? body.length
|
||||
const next = body.slice(0, start) + native + body.slice(end)
|
||||
setBody(next)
|
||||
// Restore cursor after the inserted emoji
|
||||
requestAnimationFrame(() => {
|
||||
ta.focus()
|
||||
const pos = start + native.length
|
||||
ta.setSelectionRange(pos, pos)
|
||||
})
|
||||
setEmojiOpen(false)
|
||||
}, [body])
|
||||
|
||||
const handleFocus = () => {
|
||||
setExpanded(true)
|
||||
setTimeout(() => textareaRef.current?.focus(), 50)
|
||||
}
|
||||
|
||||
const fetchLinkPreview = useCallback(async (url) => {
|
||||
setPreviewLoading(true)
|
||||
try {
|
||||
const { data } = await axios.get('/api/link-preview', { params: { url } })
|
||||
if (data?.url) {
|
||||
setLinkPreview(data)
|
||||
}
|
||||
} catch {
|
||||
// silently ignore – preview is optional
|
||||
} finally {
|
||||
setPreviewLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleBodyChange = (e) => {
|
||||
const val = e.target.value
|
||||
setBody(val)
|
||||
|
||||
// Detect URLs and auto-fetch preview (debounced)
|
||||
clearTimeout(debounceTimer.current)
|
||||
debounceTimer.current = setTimeout(() => {
|
||||
const url = extractFirstUrl(val)
|
||||
if (!url || previewDismissed) return
|
||||
if (url === lastPreviewUrl) return
|
||||
setLastPreviewUrl(url)
|
||||
setLinkPreview(null)
|
||||
fetchLinkPreview(url)
|
||||
}, 700)
|
||||
}
|
||||
|
||||
const handleDismissPreview = () => {
|
||||
setLinkPreview(null)
|
||||
setPreviewDismissed(true)
|
||||
}
|
||||
|
||||
const resetComposer = () => {
|
||||
setBody('')
|
||||
setExpanded(false)
|
||||
setLinkPreview(null)
|
||||
setPreviewLoading(false)
|
||||
setPreviewDismissed(false)
|
||||
setLastPreviewUrl(null)
|
||||
setEmojiOpen(false)
|
||||
setTaggedUsers([])
|
||||
setTagModal(false)
|
||||
setScheduleOpen(false)
|
||||
setScheduledAt('')
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e?.preventDefault()
|
||||
if (!body.trim()) return
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const { data } = await axios.post('/api/posts', {
|
||||
type: 'text',
|
||||
visibility,
|
||||
body,
|
||||
link_preview: linkPreview ?? undefined,
|
||||
tagged_users: taggedUsers.length > 0 ? taggedUsers.map(({ id, username, name }) => ({ id, username, name })) : undefined,
|
||||
publish_at: scheduledAt || undefined,
|
||||
})
|
||||
onPosted?.(data.post)
|
||||
resetComposer()
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.message ?? 'Failed to post.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleShared = (newPost) => {
|
||||
onPosted?.(newPost)
|
||||
setShareModal(false)
|
||||
}
|
||||
|
||||
const showPreview = (linkPreview || previewLoading) && !previewDismissed
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-white/[0.025] px-5 py-4">
|
||||
{/* Collapsed: click-to-expand placeholder */}
|
||||
{!expanded ? (
|
||||
<div
|
||||
onClick={handleFocus}
|
||||
className="flex items-center gap-3 cursor-text"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleFocus()}
|
||||
aria-label="Create a post"
|
||||
>
|
||||
<img
|
||||
src={user.avatar ?? '/images/avatar_default.webp'}
|
||||
alt={user.name}
|
||||
className="w-9 h-9 rounded-full object-cover ring-1 ring-white/10 shrink-0"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span className="text-sm text-slate-500 flex-1 bg-white/[0.04] rounded-xl px-4 py-2.5 hover:bg-white/[0.07] transition-colors">
|
||||
What's on your mind, {user.name?.split(' ')[0] ?? user.username}?
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
{/* Textarea */}
|
||||
<div className="flex gap-3">
|
||||
<a href={`/@${user.username}`} className="shrink-0" tabIndex={-1}>
|
||||
<img
|
||||
src={user.avatar ?? '/images/avatar_default.webp'}
|
||||
alt={user.name}
|
||||
className="w-9 h-9 rounded-full object-cover ring-1 ring-white/10 hover:ring-sky-500/40 transition-all mt-0.5"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-1.5">
|
||||
{/* User identity byline */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<a
|
||||
href={`/@${user.username}`}
|
||||
className="text-sm font-semibold text-white/90 hover:text-sky-400 transition-colors leading-tight"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{user.name || `@${user.username}`}
|
||||
</a>
|
||||
<span className="text-xs text-slate-500 leading-tight">@{user.username}</span>
|
||||
</div>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={body}
|
||||
onChange={handleBodyChange}
|
||||
maxLength={2000}
|
||||
rows={3}
|
||||
placeholder="What's on your mind?"
|
||||
autoFocus
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tagged people pills */}
|
||||
{taggedUsers.length > 0 && (
|
||||
<div className="pl-12 flex flex-wrap gap-1.5 items-center">
|
||||
<span className="text-xs text-slate-500">With:</span>
|
||||
{taggedUsers.map((u) => (
|
||||
<span key={u.id} className="flex items-center gap-1 px-2 py-0.5 bg-sky-500/10 border border-sky-500/20 rounded-full text-xs text-sky-400">
|
||||
<img src={u.avatar_url ?? '/images/avatar_default.webp'} alt="" className="w-3.5 h-3.5 rounded-full object-cover" />
|
||||
@{u.username}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTaggedUsers((prev) => prev.filter((x) => x.id !== u.id))}
|
||||
className="opacity-60 hover:opacity-100 ml-0.5"
|
||||
>
|
||||
<i className="fa-solid fa-xmark fa-xs" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Link preview */}
|
||||
{showPreview && (
|
||||
<div className="pl-12">
|
||||
<LinkPreviewCard
|
||||
preview={linkPreview}
|
||||
loading={previewLoading && !linkPreview}
|
||||
onDismiss={handleDismissPreview}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Schedule date picker */}
|
||||
{scheduleOpen && (
|
||||
<div className="pl-12">
|
||||
<div className="flex items-center gap-2.5 p-3 rounded-xl bg-violet-500/10 border border-violet-500/20">
|
||||
<i className="fa-regular fa-calendar-plus text-violet-400 text-sm fa-fw shrink-0" />
|
||||
<div className="flex-1">
|
||||
<label className="block text-[11px] text-slate-400 mb-1">Publish on</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={scheduledAt}
|
||||
onChange={(e) => setScheduledAt(e.target.value)}
|
||||
min={new Date(Date.now() + 60_000).toISOString().slice(0, 16)}
|
||||
className="bg-transparent text-sm text-white border-none outline-none w-full [color-scheme:dark]"
|
||||
/>
|
||||
<p className="text-[10px] text-slate-500 mt-1">
|
||||
{Intl.DateTimeFormat().resolvedOptions().timeZone}
|
||||
</p>
|
||||
</div>
|
||||
{scheduledAt && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setScheduledAt('')}
|
||||
className="text-slate-500 hover:text-slate-300 transition-colors"
|
||||
title="Clear"
|
||||
>
|
||||
<i className="fa-solid fa-xmark fa-sm" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer row */}
|
||||
<div className="flex items-center gap-2 pl-12">
|
||||
{/* Visibility selector */}
|
||||
<div className="flex gap-1">
|
||||
{VISIBILITY_OPTIONS.map((v) => (
|
||||
<button
|
||||
key={v.value}
|
||||
type="button"
|
||||
onClick={() => setVisibility(v.value)}
|
||||
title={v.label}
|
||||
className={`flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs transition-all ${
|
||||
visibility === v.value
|
||||
? 'bg-sky-500/15 text-sky-400 border border-sky-500/30'
|
||||
: 'text-slate-500 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<i className={`fa-solid ${v.icon} fa-fw`} />
|
||||
{visibility === v.value && <span>{v.label}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Emoji picker trigger */}
|
||||
<div ref={emojiWrapRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openEmojiPicker}
|
||||
title="Add emoji"
|
||||
className={`flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs transition-all ${
|
||||
emojiOpen
|
||||
? 'bg-amber-500/15 text-amber-400 border border-amber-500/30'
|
||||
: 'text-slate-500 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<i className="fa-regular fa-face-smile fa-fw" />
|
||||
</button>
|
||||
|
||||
{emojiOpen && (
|
||||
<div className="absolute bottom-full mb-2 left-0 z-50 shadow-2xl">
|
||||
<Suspense fallback={
|
||||
<div className="w-[352px] h-[400px] rounded-2xl bg-[#10192e] border border-white/10 flex items-center justify-center text-slate-600">
|
||||
<i className="fa-solid fa-spinner fa-spin text-xl" />
|
||||
</div>
|
||||
}>
|
||||
{emojiData && (
|
||||
<EmojiPicker
|
||||
data={emojiData}
|
||||
onEmojiSelect={insertEmoji}
|
||||
theme="dark"
|
||||
set="native"
|
||||
previewPosition="none"
|
||||
skinTonePosition="search"
|
||||
navPosition="bottom"
|
||||
perLine={9}
|
||||
maxFrequentRows={2}
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tag people button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTagModal(true)}
|
||||
title="Tag people"
|
||||
className={`flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs transition-all ${
|
||||
taggedUsers.length > 0
|
||||
? 'bg-sky-500/15 text-sky-400 border border-sky-500/30'
|
||||
: 'text-slate-500 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<i className="fa-solid fa-user-tag fa-fw" />
|
||||
{taggedUsers.length > 0 && <span>{taggedUsers.length}</span>}
|
||||
</button>
|
||||
|
||||
{/* Schedule button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setScheduleOpen((v) => !v)}
|
||||
title="Schedule post"
|
||||
className={`flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs transition-all ${
|
||||
scheduleOpen || scheduledAt
|
||||
? 'bg-violet-500/15 text-violet-400 border border-violet-500/30'
|
||||
: 'text-slate-500 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<i className="fa-regular fa-clock fa-fw" />
|
||||
{scheduledAt && <span className="max-w-[80px] truncate">{new Date(scheduledAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}</span>}
|
||||
</button>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{/* Share artwork button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShareModal(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs text-slate-400 hover:text-sky-400 hover:bg-sky-500/10 transition-colors"
|
||||
title="Share an artwork"
|
||||
>
|
||||
<i className="fa-solid fa-share-nodes fa-fw" />
|
||||
Share artwork
|
||||
</button>
|
||||
|
||||
{/* Cancel */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetComposer}
|
||||
className="px-3 py-1.5 rounded-lg text-xs text-slate-400 hover:text-white hover:bg-white/5 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
{/* Post */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !body.trim()}
|
||||
className={`px-4 py-1.5 rounded-xl disabled:opacity-40 disabled:cursor-not-allowed text-white text-xs font-medium transition-colors ${
|
||||
scheduledAt ? 'bg-violet-600 hover:bg-violet-500' : 'bg-sky-600 hover:bg-sky-500'
|
||||
}`}
|
||||
>
|
||||
{submitting ? 'Posting…' : scheduledAt ? 'Schedule' : 'Post'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Char count */}
|
||||
{body.length > 1800 && (
|
||||
<p className="text-right text-[10px] text-amber-400/70 pr-1">{body.length}/2000</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-rose-400">{error}</p>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Share artwork modal */}
|
||||
<ShareArtworkModal
|
||||
isOpen={shareModal}
|
||||
onClose={() => setShareModal(false)}
|
||||
onShared={handleShared}
|
||||
/>
|
||||
|
||||
{/* Tag people modal */}
|
||||
<TagPeopleModal
|
||||
isOpen={tagModal}
|
||||
onClose={() => setTagModal(false)}
|
||||
selected={taggedUsers}
|
||||
onConfirm={(users) => { setTaggedUsers(users); setTagModal(false) }}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
204
resources/js/components/Feed/TagPeopleModal.jsx
Normal file
204
resources/js/components/Feed/TagPeopleModal.jsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import axios from 'axios'
|
||||
|
||||
/**
|
||||
* TagPeopleModal
|
||||
*
|
||||
* Props:
|
||||
* isOpen boolean
|
||||
* onClose function()
|
||||
* selected array [{ id, username, name, avatar_url }]
|
||||
* onConfirm function(selectedArray)
|
||||
*/
|
||||
export default function TagPeopleModal({ isOpen, onClose, selected = [], onConfirm }) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [staged, setStaged] = useState(selected)
|
||||
const inputRef = useRef(null)
|
||||
const debounce = useRef(null)
|
||||
|
||||
// Re-sync staged list when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setStaged(selected)
|
||||
setQuery('')
|
||||
setResults([])
|
||||
setTimeout(() => inputRef.current?.focus(), 80)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
const search = useCallback(async (q) => {
|
||||
if (q.length < 2) { setResults([]); return }
|
||||
setLoading(true)
|
||||
try {
|
||||
const { data } = await axios.get('/api/search/users', { params: { q, per_page: 8 } })
|
||||
setResults(data.data ?? [])
|
||||
} catch {
|
||||
setResults([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleQueryChange = (e) => {
|
||||
const val = e.target.value
|
||||
setQuery(val)
|
||||
clearTimeout(debounce.current)
|
||||
debounce.current = setTimeout(() => search(val.replace(/^@/, '')), 300)
|
||||
}
|
||||
|
||||
const isSelected = (user) => staged.some((u) => u.id === user.id)
|
||||
|
||||
const toggle = (user) => {
|
||||
setStaged((prev) =>
|
||||
isSelected(user)
|
||||
? prev.filter((u) => u.id !== user.id)
|
||||
: prev.length < 10 ? [...prev, user] : prev
|
||||
)
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm(staged)
|
||||
onClose()
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative w-full max-w-md rounded-2xl border border-white/10 bg-[#0c1525] shadow-2xl overflow-hidden flex flex-col max-h-[80vh]">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-5 py-4 border-b border-white/[0.07]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="w-8 h-8 rounded-full hover:bg-white/10 flex items-center justify-center text-slate-400 hover:text-white transition-colors"
|
||||
aria-label="Back"
|
||||
>
|
||||
<i className="fa-solid fa-arrow-left fa-sm" />
|
||||
</button>
|
||||
<h2 className="flex-1 text-center text-base font-semibold text-white/90 -ml-8">
|
||||
Tag people
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirm}
|
||||
className="text-sm font-medium text-sky-400 hover:text-sky-300 transition-colors"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search bar */}
|
||||
<div className="px-4 py-3 border-b border-white/[0.05]">
|
||||
<div className="flex items-center gap-2 bg-white/[0.06] border border-white/[0.08] rounded-xl px-3 py-2">
|
||||
<i className="fa-solid fa-magnifying-glass text-slate-500 text-xs fa-fw" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={handleQueryChange}
|
||||
placeholder="Search users…"
|
||||
className="flex-1 bg-transparent text-sm text-white placeholder-slate-600 focus:outline-none"
|
||||
/>
|
||||
{loading && <i className="fa-solid fa-spinner fa-spin text-slate-500 text-xs" />}
|
||||
{query && !loading && (
|
||||
<button type="button" onClick={() => { setQuery(''); setResults([]) }} className="text-slate-500 hover:text-white">
|
||||
<i className="fa-solid fa-xmark text-xs" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected chips */}
|
||||
{staged.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 px-4 py-3 border-b border-white/[0.05]">
|
||||
{staged.map((u) => (
|
||||
<span
|
||||
key={u.id}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 bg-sky-500/15 border border-sky-500/30 rounded-full text-xs text-sky-400"
|
||||
>
|
||||
<img
|
||||
src={u.avatar_url ?? '/images/avatar_default.webp'}
|
||||
alt=""
|
||||
className="w-4 h-4 rounded-full object-cover"
|
||||
/>
|
||||
@{u.username}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(u)}
|
||||
className="ml-0.5 opacity-60 hover:opacity-100 transition-opacity"
|
||||
aria-label={`Remove @${u.username}`}
|
||||
>
|
||||
<i className="fa-solid fa-xmark fa-xs" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results list */}
|
||||
<div className="overflow-y-auto flex-1">
|
||||
{results.length === 0 && query.length >= 2 && !loading && (
|
||||
<p className="px-5 py-8 text-center text-sm text-slate-600">No users found for "{query}"</p>
|
||||
)}
|
||||
{results.length === 0 && query.length < 2 && (
|
||||
<p className="px-5 py-8 text-center text-sm text-slate-600">Type a name or @username to search</p>
|
||||
)}
|
||||
{results.map((u) => {
|
||||
const checked = isSelected(u)
|
||||
return (
|
||||
<button
|
||||
key={u.id}
|
||||
type="button"
|
||||
onClick={() => toggle(u)}
|
||||
className={`w-full flex items-center gap-3 px-5 py-3 text-left transition-colors ${
|
||||
checked ? 'bg-sky-500/10' : 'hover:bg-white/[0.04]'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={u.avatar_url ?? '/images/avatar_default.webp'}
|
||||
alt={u.username}
|
||||
className="w-10 h-10 rounded-full object-cover ring-1 ring-white/10 shrink-0"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-white/90 truncate">
|
||||
{u.name || `@${u.username}`}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 truncate">@{u.username}</p>
|
||||
</div>
|
||||
<div className={`w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors ${
|
||||
checked
|
||||
? 'bg-sky-500 border-sky-500 text-white'
|
||||
: 'border-white/20'
|
||||
}`}>
|
||||
{checked && <i className="fa-solid fa-check fa-xs" />}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer confirm */}
|
||||
<div className="px-5 py-3 border-t border-white/[0.07]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirm}
|
||||
className="w-full py-2.5 rounded-xl bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium transition-colors disabled:opacity-40"
|
||||
>
|
||||
{staged.length === 0
|
||||
? 'Continue without tagging'
|
||||
: `Tag ${staged.length} ${staged.length === 1 ? 'person' : 'people'}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
22
resources/js/components/Feed/VisibilityPill.jsx
Normal file
22
resources/js/components/Feed/VisibilityPill.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react'
|
||||
|
||||
const ICONS = {
|
||||
public: { icon: 'fa-globe', label: 'Public', cls: 'text-slate-500' },
|
||||
followers: { icon: 'fa-user-friends', label: 'Followers', cls: 'text-sky-500/70' },
|
||||
private: { icon: 'fa-lock', label: 'Private', cls: 'text-amber-500/70' },
|
||||
}
|
||||
|
||||
export default function VisibilityPill({ visibility, showLabel = false }) {
|
||||
const v = ICONS[visibility] ?? ICONS.public
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 text-xs ${v.cls}`}
|
||||
title={v.label}
|
||||
aria-label={`Visibility: ${v.label}`}
|
||||
>
|
||||
<i className={`fa-solid ${v.icon} fa-fw`} />
|
||||
{showLabel && <span>{v.label}</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -196,6 +196,7 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
|
||||
const [reporting, setReporting] = useState(false)
|
||||
const [reported, setReported] = useState(false)
|
||||
const [reportOpen, setReportOpen] = useState(false)
|
||||
const isLoggedIn = artwork?.viewer != null
|
||||
useEffect(() => {
|
||||
setFavorited(Boolean(artwork?.viewer?.is_favorited))
|
||||
}, [artwork?.id, artwork?.viewer?.is_favorited])
|
||||
@@ -311,7 +312,7 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
|
||||
</div>
|
||||
|
||||
{/* Share pill */}
|
||||
<ArtworkShareButton artwork={artwork} shareUrl={shareUrl} size="default" />
|
||||
<ArtworkShareButton artwork={artwork} shareUrl={shareUrl} size="default" isLoggedIn={isLoggedIn} />
|
||||
|
||||
{/* Report pill */}
|
||||
<button
|
||||
@@ -362,7 +363,7 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
|
||||
</button>
|
||||
|
||||
{/* Share */}
|
||||
<ArtworkShareButton artwork={artwork} shareUrl={shareUrl} size="small" />
|
||||
<ArtworkShareButton artwork={artwork} shareUrl={shareUrl} size="small" isLoggedIn={isLoggedIn} />
|
||||
|
||||
{/* Report */}
|
||||
<button
|
||||
|
||||
@@ -20,7 +20,7 @@ function ShareIcon() {
|
||||
* shareUrl – canonical URL to share
|
||||
* size – 'default' | 'small' (for mobile bar)
|
||||
*/
|
||||
export default function ArtworkShareButton({ artwork, shareUrl, size = 'default' }) {
|
||||
export default function ArtworkShareButton({ artwork, shareUrl, size = 'default', isLoggedIn = false }) {
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
|
||||
const openModal = useCallback(
|
||||
@@ -69,6 +69,7 @@ export default function ArtworkShareButton({ artwork, shareUrl, size = 'default'
|
||||
onClose={closeModal}
|
||||
artwork={artwork}
|
||||
shareUrl={shareUrl}
|
||||
isLoggedIn={isLoggedIn}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
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)}`
|
||||
@@ -107,13 +110,14 @@ function trackShare(artworkId, platform) {
|
||||
* artwork – artwork object (id, title, description, thumbs, canonical_url, …)
|
||||
* shareUrl – canonical share URL
|
||||
*/
|
||||
export default function ArtworkShareModal({ open, onClose, artwork, shareUrl }) {
|
||||
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'
|
||||
@@ -213,6 +217,12 @@ export default function ArtworkShareModal({ open, onClose, artwork, shareUrl })
|
||||
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(
|
||||
@@ -330,6 +340,26 @@ export default function ArtworkShareModal({ open, onClose, artwork, shareUrl })
|
||||
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,
|
||||
)
|
||||
|
||||
41
resources/js/components/forum/AuthorBadge.jsx
Normal file
41
resources/js/components/forum/AuthorBadge.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from 'react'
|
||||
|
||||
const ROLE_STYLES = {
|
||||
admin: 'bg-red-500/15 text-red-300',
|
||||
moderator: 'bg-amber-500/15 text-amber-300',
|
||||
member: 'bg-sky-500/15 text-sky-300',
|
||||
}
|
||||
|
||||
const ROLE_LABELS = {
|
||||
admin: 'Admin',
|
||||
moderator: 'Moderator',
|
||||
member: 'Member',
|
||||
}
|
||||
|
||||
export default function AuthorBadge({ user, size = 'md' }) {
|
||||
const name = user?.name ?? 'Anonymous'
|
||||
const avatar = user?.avatar_url ?? '/default/avatar_default.webp'
|
||||
const role = (user?.role ?? 'member').toLowerCase()
|
||||
const cls = ROLE_STYLES[role] ?? ROLE_STYLES.member
|
||||
const label = ROLE_LABELS[role] ?? 'Member'
|
||||
|
||||
const imgSize = size === 'sm' ? 'h-8 w-8' : 'h-10 w-10'
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
src={avatar}
|
||||
alt={`${name} avatar`}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className={`${imgSize} rounded-full border border-white/10 object-cover`}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-zinc-100">{name}</div>
|
||||
<span className={`inline-flex rounded-full px-2 py-0.5 text-[11px] font-medium ${cls}`}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
30
resources/js/components/forum/Breadcrumbs.jsx
Normal file
30
resources/js/components/forum/Breadcrumbs.jsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function Breadcrumbs({ items = [] }) {
|
||||
return (
|
||||
<nav className="text-sm text-zinc-400" aria-label="Breadcrumb">
|
||||
<ol className="flex flex-wrap items-center gap-1.5">
|
||||
{items.map((item, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{i > 0 && (
|
||||
<li aria-hidden="true" className="text-zinc-600">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="inline-block">
|
||||
<path d="M6 4l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</li>
|
||||
)}
|
||||
<li>
|
||||
{item.href ? (
|
||||
<a href={item.href} className="hover:text-zinc-200 transition-colors">
|
||||
{item.label}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-zinc-200">{item.label}</span>
|
||||
)}
|
||||
</li>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
85
resources/js/components/forum/CategoryCard.jsx
Normal file
85
resources/js/components/forum/CategoryCard.jsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function CategoryCard({ category }) {
|
||||
const name = category?.name ?? 'Untitled'
|
||||
const slug = category?.slug
|
||||
const threads = category?.thread_count ?? 0
|
||||
const posts = category?.post_count ?? 0
|
||||
const lastActivity = category?.last_activity_at
|
||||
const preview = category?.preview_image ?? '/images/forum-default.jpg'
|
||||
const href = slug ? `/forum/${slug}` : '#'
|
||||
|
||||
const timeAgo = lastActivity ? formatTimeAgo(lastActivity) : null
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className="group relative block overflow-hidden rounded-2xl border border-white/[0.06] bg-nova-800/50 shadow-xl backdrop-blur transition-all duration-300 hover:border-cyan-400/20 hover:shadow-cyan-500/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400"
|
||||
>
|
||||
{/* Image */}
|
||||
<div className="relative aspect-[16/9]">
|
||||
<img
|
||||
src={preview}
|
||||
alt={`${name} preview`}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="h-full w-full object-cover object-center transition-transform duration-500 group-hover:scale-[1.03]"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent" />
|
||||
|
||||
{/* Overlay content */}
|
||||
<div className="absolute inset-x-0 bottom-0 p-5">
|
||||
<div className="mb-2 inline-flex h-8 w-8 items-center justify-center rounded-lg bg-cyan-400/15 text-cyan-300">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-bold text-white leading-snug">{name}</h3>
|
||||
|
||||
{timeAgo && (
|
||||
<p className="mt-1 text-xs text-white/50">
|
||||
Last activity: <span className="text-white/70">{timeAgo}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex items-center gap-4 text-sm">
|
||||
<span className="flex items-center gap-1.5 text-cyan-300">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
|
||||
</svg>
|
||||
{number(posts)} posts
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 text-cyan-300/70">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
{number(threads)} topics
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
function number(n) {
|
||||
return (n ?? 0).toLocaleString()
|
||||
}
|
||||
|
||||
function formatTimeAgo(dateStr) {
|
||||
try {
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diff = Math.floor((now - date) / 1000)
|
||||
|
||||
if (diff < 60) return 'just now'
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
|
||||
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`
|
||||
return date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
111
resources/js/components/forum/EmojiPicker.jsx
Normal file
111
resources/js/components/forum/EmojiPicker.jsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import data from '@emoji-mart/data'
|
||||
import Picker from '@emoji-mart/react'
|
||||
|
||||
/**
|
||||
* Emoji picker button for the forum rich-text editor.
|
||||
* Uses the same @emoji-mart/react picker as profile tweets / comments
|
||||
* so the UI is consistent across the whole site.
|
||||
*
|
||||
* The panel is rendered through a React portal so it escapes any
|
||||
* overflow-hidden containers (like the editor wrapper).
|
||||
*/
|
||||
export default function EmojiPicker({ onSelect, editor }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [panelStyle, setPanelStyle] = useState({})
|
||||
const panelRef = useRef(null)
|
||||
const buttonRef = useRef(null)
|
||||
|
||||
// Position the portal panel relative to the trigger button
|
||||
useEffect(() => {
|
||||
if (!open || !buttonRef.current) return
|
||||
const rect = buttonRef.current.getBoundingClientRect()
|
||||
const panelWidth = 352 // emoji-mart default width
|
||||
const panelHeight = 435 // approximate picker height
|
||||
|
||||
const spaceAbove = rect.top
|
||||
const openAbove = spaceAbove > panelHeight + 8
|
||||
|
||||
setPanelStyle({
|
||||
position: 'fixed',
|
||||
zIndex: 9999,
|
||||
left: Math.max(8, Math.min(rect.right - panelWidth, window.innerWidth - panelWidth - 8)),
|
||||
...(openAbove
|
||||
? { bottom: window.innerHeight - rect.top + 6 }
|
||||
: { top: rect.bottom + 6 }),
|
||||
})
|
||||
}, [open])
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handler = (e) => {
|
||||
if (panelRef.current && !panelRef.current.contains(e.target) &&
|
||||
buttonRef.current && !buttonRef.current.contains(e.target)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [open])
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handler = (e) => { if (e.key === 'Escape') setOpen(false) }
|
||||
document.addEventListener('keydown', handler)
|
||||
return () => document.removeEventListener('keydown', handler)
|
||||
}, [open])
|
||||
|
||||
const handleSelect = useCallback((emoji) => {
|
||||
const native = emoji.native ?? ''
|
||||
onSelect?.(native)
|
||||
if (editor) {
|
||||
editor.chain().focus().insertContent(native).run()
|
||||
}
|
||||
setOpen(false)
|
||||
}, [onSelect, editor])
|
||||
|
||||
const panel = open ? createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
style={panelStyle}
|
||||
className="rounded-xl shadow-2xl overflow-hidden"
|
||||
>
|
||||
<Picker
|
||||
data={data}
|
||||
onEmojiSelect={handleSelect}
|
||||
theme="dark"
|
||||
previewPosition="none"
|
||||
skinTonePosition="search"
|
||||
maxFrequentRows={2}
|
||||
perLine={9}
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
) : null
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
title="Insert emoji"
|
||||
aria-label="Open emoji picker"
|
||||
aria-expanded={open}
|
||||
className={[
|
||||
'inline-flex h-8 w-8 items-center justify-center rounded-lg text-sm transition-colors',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-400',
|
||||
open
|
||||
? 'bg-sky-600/25 text-sky-300'
|
||||
: 'text-zinc-400 hover:bg-white/[0.06] hover:text-zinc-200',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-[15px]">😊</span>
|
||||
</button>
|
||||
{panel}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
76
resources/js/components/forum/MentionList.jsx
Normal file
76
resources/js/components/forum/MentionList.jsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React, { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Dropdown list rendered by TipTap's mention suggestion.
|
||||
* Receives `items` (user objects) and keyboard nav commands via ref.
|
||||
*/
|
||||
const MentionList = forwardRef(function MentionList({ items, command }, ref) {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
|
||||
// Reset selection when items change
|
||||
useEffect(() => setSelectedIndex(0), [items])
|
||||
|
||||
// Expose keyboard handler to TipTap suggestion plugin
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeyDown: ({ event }) => {
|
||||
if (event.key === 'ArrowUp') {
|
||||
setSelectedIndex((i) => (i + items.length - 1) % items.length)
|
||||
return true
|
||||
}
|
||||
if (event.key === 'ArrowDown') {
|
||||
setSelectedIndex((i) => (i + 1) % items.length)
|
||||
return true
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
selectItem(selectedIndex)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
}))
|
||||
|
||||
const selectItem = (index) => {
|
||||
const item = items[index]
|
||||
if (item) {
|
||||
command({ id: item.username, label: item.username })
|
||||
}
|
||||
}
|
||||
|
||||
if (!items.length) {
|
||||
return (
|
||||
<div className="rounded-xl border border-white/[0.08] bg-nova-800 p-3 shadow-xl backdrop-blur">
|
||||
<p className="text-xs text-zinc-500">No users found</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-w-[200px] overflow-hidden rounded-xl border border-white/[0.08] bg-nova-800 shadow-xl backdrop-blur">
|
||||
{items.map((item, index) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => selectItem(index)}
|
||||
className={[
|
||||
'flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm transition-colors',
|
||||
index === selectedIndex
|
||||
? 'bg-sky-600/20 text-white'
|
||||
: 'text-zinc-300 hover:bg-white/[0.04]',
|
||||
].join(' ')}
|
||||
>
|
||||
<img
|
||||
src={item.avatar_url}
|
||||
alt=""
|
||||
className="h-6 w-6 rounded-full border border-white/10 object-cover"
|
||||
/>
|
||||
<span className="truncate font-medium">@{item.username}</span>
|
||||
{item.name && item.name !== item.username && (
|
||||
<span className="truncate text-xs text-zinc-500">{item.name}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default MentionList
|
||||
93
resources/js/components/forum/Pagination.jsx
Normal file
93
resources/js/components/forum/Pagination.jsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import React from 'react'
|
||||
|
||||
/**
|
||||
* Pagination control that mirrors Laravel's paginator shape.
|
||||
* Expects: { current_page, last_page, per_page, total, path } or links array.
|
||||
*/
|
||||
export default function Pagination({ meta, onPageChange }) {
|
||||
const current = meta?.current_page ?? 1
|
||||
const lastPage = meta?.last_page ?? 1
|
||||
|
||||
if (lastPage <= 1) return null
|
||||
|
||||
const pages = buildPages(current, lastPage)
|
||||
|
||||
const go = (page) => {
|
||||
if (page < 1 || page > lastPage || page === current) return
|
||||
if (onPageChange) {
|
||||
onPageChange(page)
|
||||
} else {
|
||||
// Fallback: navigate via URL
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set('page', page)
|
||||
window.location.href = url.toString()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<nav aria-label="Pagination" className="flex items-center justify-center gap-1.5">
|
||||
{/* Prev */}
|
||||
<button
|
||||
onClick={() => go(current - 1)}
|
||||
disabled={current <= 1}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-white/10 text-zinc-400 transition-colors hover:border-white/20 hover:text-white disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M10 4L6 8l4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{pages.map((p, i) =>
|
||||
p === '...' ? (
|
||||
<span key={`dots-${i}`} className="px-1 text-xs text-zinc-600">…</span>
|
||||
) : (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => go(p)}
|
||||
aria-current={p === current ? 'page' : undefined}
|
||||
className={[
|
||||
'inline-flex h-9 min-w-[2.25rem] items-center justify-center rounded-xl text-sm font-medium transition-colors',
|
||||
p === current
|
||||
? 'bg-sky-600/20 text-sky-300 border border-sky-500/30'
|
||||
: 'border border-white/10 text-zinc-400 hover:border-white/20 hover:text-white',
|
||||
].join(' ')}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Next */}
|
||||
<button
|
||||
onClick={() => go(current + 1)}
|
||||
disabled={current >= lastPage}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-white/10 text-zinc-400 transition-colors hover:border-white/20 hover:text-white disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
aria-label="Next page"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M6 4l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
/** Build a compact page-number array with ellipsis. */
|
||||
function buildPages(current, last) {
|
||||
if (last <= 7) {
|
||||
return Array.from({ length: last }, (_, i) => i + 1)
|
||||
}
|
||||
|
||||
const pages = new Set([1, 2, current - 1, current, current + 1, last - 1, last])
|
||||
const sorted = [...pages].filter(p => p >= 1 && p <= last).sort((a, b) => a - b)
|
||||
|
||||
const result = []
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
if (i > 0 && sorted[i] - sorted[i - 1] > 1) {
|
||||
result.push('...')
|
||||
}
|
||||
result.push(sorted[i])
|
||||
}
|
||||
return result
|
||||
}
|
||||
192
resources/js/components/forum/PostCard.jsx
Normal file
192
resources/js/components/forum/PostCard.jsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import React, { useState } from 'react'
|
||||
import AuthorBadge from './AuthorBadge'
|
||||
|
||||
export default function PostCard({ post, thread, isOp = false, isAuthenticated = false, canModerate = false }) {
|
||||
const [reported, setReported] = useState(false)
|
||||
const [reporting, setReporting] = useState(false)
|
||||
|
||||
const author = post?.user
|
||||
const content = post?.rendered_content ?? post?.content ?? ''
|
||||
const postedAt = post?.created_at
|
||||
const editedAt = post?.edited_at
|
||||
const isEdited = post?.is_edited
|
||||
const postId = post?.id
|
||||
const threadId = thread?.id
|
||||
const threadSlug = thread?.slug
|
||||
|
||||
const handleReport = async () => {
|
||||
if (reporting || reported) return
|
||||
setReporting(true)
|
||||
try {
|
||||
const res = await fetch(`/forum/post/${postId}/report`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': getCsrf(),
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
if (res.ok) setReported(true)
|
||||
} catch { /* silent */ }
|
||||
setReporting(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<article
|
||||
id={`post-${postId}`}
|
||||
className="overflow-hidden rounded-2xl border border-white/[0.06] bg-nova-800/50 backdrop-blur transition-all hover:border-white/10"
|
||||
>
|
||||
{/* Header */}
|
||||
<header className="flex flex-col gap-3 border-b border-white/[0.06] px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<AuthorBadge user={author} />
|
||||
<div className="flex items-center gap-2 text-xs text-zinc-500">
|
||||
{postedAt && (
|
||||
<time dateTime={postedAt}>
|
||||
{formatDate(postedAt)}
|
||||
</time>
|
||||
)}
|
||||
{isOp && (
|
||||
<span className="rounded-full bg-cyan-500/15 px-2.5 py-0.5 text-[11px] font-medium text-cyan-300">
|
||||
OP
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-5 py-5">
|
||||
<div
|
||||
className="prose prose-invert max-w-none text-sm leading-relaxed prose-pre:overflow-x-auto prose-a:text-sky-300 prose-a:hover:text-sky-200"
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
/>
|
||||
|
||||
{isEdited && editedAt && (
|
||||
<p className="mt-3 text-xs text-zinc-600">
|
||||
Edited {formatTimeAgo(editedAt)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Attachments */}
|
||||
{post?.attachments?.length > 0 && (
|
||||
<div className="mt-5 space-y-3 border-t border-white/[0.06] pt-4">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-widest text-white/30">Attachments</h4>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{post.attachments.map((att) => (
|
||||
<AttachmentItem key={att.id} attachment={att} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="flex flex-wrap items-center gap-3 border-t border-white/[0.06] px-5 py-3 text-xs">
|
||||
{/* Quote */}
|
||||
{threadId && (
|
||||
<a
|
||||
href={`/forum/thread/${threadId}-${threadSlug ?? ''}?quote=${postId}#reply-content`}
|
||||
className="rounded-lg border border-white/10 px-2.5 py-1 text-zinc-400 transition-colors hover:border-white/20 hover:text-zinc-200"
|
||||
>
|
||||
Quote
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Report */}
|
||||
{isAuthenticated && (post?.user_id !== post?.current_user_id) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReport}
|
||||
disabled={reported || reporting}
|
||||
className={[
|
||||
'rounded-lg border border-white/10 px-2.5 py-1 transition-colors',
|
||||
reported
|
||||
? 'text-emerald-400 border-emerald-500/20 cursor-default'
|
||||
: 'text-zinc-400 hover:border-white/20 hover:text-zinc-200',
|
||||
].join(' ')}
|
||||
>
|
||||
{reported ? 'Reported ✓' : reporting ? 'Reporting…' : 'Report'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Edit */}
|
||||
{(post?.can_edit) && (
|
||||
<a
|
||||
href={`/forum/post/${postId}/edit`}
|
||||
className="rounded-lg border border-white/10 px-2.5 py-1 text-zinc-400 transition-colors hover:border-white/20 hover:text-zinc-200"
|
||||
>
|
||||
Edit
|
||||
</a>
|
||||
)}
|
||||
|
||||
{canModerate && (
|
||||
<span className="ml-auto text-[11px] text-amber-400/60">Mod</span>
|
||||
)}
|
||||
</footer>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function AttachmentItem({ attachment }) {
|
||||
const mime = attachment?.mime_type ?? ''
|
||||
const isImage = mime.startsWith('image/')
|
||||
const url = attachment?.url ?? '#'
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-white/[0.06] bg-slate-900/60">
|
||||
{isImage ? (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" className="block">
|
||||
<img
|
||||
src={url}
|
||||
alt="Attachment"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="h-40 w-full object-cover transition-transform hover:scale-[1.02]"
|
||||
/>
|
||||
</a>
|
||||
) : (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 p-3 text-sm text-sky-300 hover:text-sky-200"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
Download attachment
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getCsrf() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') ?? ''
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
try {
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
+ ' ' + d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimeAgo(dateStr) {
|
||||
try {
|
||||
const now = new Date()
|
||||
const date = new Date(dateStr)
|
||||
const diff = Math.floor((now - date) / 1000)
|
||||
if (diff < 60) return 'just now'
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
|
||||
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`
|
||||
return formatDate(dateStr)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
82
resources/js/components/forum/ReplyForm.jsx
Normal file
82
resources/js/components/forum/ReplyForm.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React, { useState, useRef, useCallback } from 'react'
|
||||
import Button from '../ui/Button'
|
||||
import RichTextEditor from './RichTextEditor'
|
||||
|
||||
export default function ReplyForm({ threadId, prefill = '', quotedAuthor = null, csrfToken }) {
|
||||
const [content, setContent] = useState(prefill)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const formRef = useRef(null)
|
||||
|
||||
const handleSubmit = useCallback(async (e) => {
|
||||
e.preventDefault()
|
||||
if (submitting || content.trim().length < 2) return
|
||||
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const res = await fetch(`/forum/thread/${threadId}/reply`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ content: content.trim() }),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
// Reload page to show new reply
|
||||
window.location.reload()
|
||||
} else if (res.status === 422) {
|
||||
const json = await res.json()
|
||||
setError(json.errors?.content?.[0] ?? 'Validation error.')
|
||||
} else {
|
||||
setError('Failed to post reply. Please try again.')
|
||||
}
|
||||
} catch {
|
||||
setError('Network error. Please try again.')
|
||||
}
|
||||
|
||||
setSubmitting(false)
|
||||
}, [content, threadId, csrfToken, submitting])
|
||||
|
||||
return (
|
||||
<form
|
||||
ref={formRef}
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-4 rounded-2xl border border-white/[0.06] bg-nova-800/50 p-5 backdrop-blur"
|
||||
>
|
||||
{quotedAuthor && (
|
||||
<p className="text-xs text-cyan-400/70">
|
||||
Replying with quote from <strong className="text-cyan-300">{quotedAuthor}</strong>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Rich text editor */}
|
||||
<RichTextEditor
|
||||
content={content}
|
||||
onChange={setContent}
|
||||
placeholder="Write your reply…"
|
||||
error={error}
|
||||
minHeight={10}
|
||||
/>
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex items-center justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="md"
|
||||
loading={submitting}
|
||||
disabled={content.trim().length < 2}
|
||||
>
|
||||
Post reply
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
316
resources/js/components/forum/RichTextEditor.jsx
Normal file
316
resources/js/components/forum/RichTextEditor.jsx
Normal file
@@ -0,0 +1,316 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { useEditor, EditorContent } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import Link from '@tiptap/extension-link'
|
||||
import Image from '@tiptap/extension-image'
|
||||
import Placeholder from '@tiptap/extension-placeholder'
|
||||
import Underline from '@tiptap/extension-underline'
|
||||
import Mention from '@tiptap/extension-mention'
|
||||
import mentionSuggestion from './mentionSuggestion'
|
||||
import EmojiPicker from './EmojiPicker'
|
||||
|
||||
/* ─── Toolbar button ─── */
|
||||
function ToolbarBtn({ onClick, active, disabled, title, children }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
className={[
|
||||
'inline-flex h-8 w-8 items-center justify-center rounded-lg text-sm transition-colors',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-400',
|
||||
active
|
||||
? 'bg-sky-600/25 text-sky-300'
|
||||
: 'text-zinc-400 hover:bg-white/[0.06] hover:text-zinc-200',
|
||||
disabled && 'opacity-30 pointer-events-none',
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function Divider() {
|
||||
return <div className="mx-1 h-5 w-px bg-white/10" />
|
||||
}
|
||||
|
||||
/* ─── Toolbar ─── */
|
||||
function Toolbar({ editor }) {
|
||||
if (!editor) return null
|
||||
|
||||
const addLink = useCallback(() => {
|
||||
const prev = editor.getAttributes('link').href
|
||||
const url = window.prompt('URL', prev ?? 'https://')
|
||||
if (url === null) return
|
||||
if (url === '') {
|
||||
editor.chain().focus().extendMarkRange('link').unsetLink().run()
|
||||
} else {
|
||||
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
const addImage = useCallback(() => {
|
||||
const url = window.prompt('Image URL', 'https://')
|
||||
if (url) {
|
||||
editor.chain().focus().setImage({ src: url }).run()
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-0.5 border-b border-white/[0.06] px-2.5 py-2">
|
||||
{/* Text formatting */}
|
||||
<ToolbarBtn
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
active={editor.isActive('bold')}
|
||||
title="Bold (Ctrl+B)"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M6 4h8a4 4 0 014 4 4 4 0 01-4 4H6zm0 8h9a4 4 0 014 4 4 4 0 01-4 4H6z"/></svg>
|
||||
</ToolbarBtn>
|
||||
|
||||
<ToolbarBtn
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
active={editor.isActive('italic')}
|
||||
title="Italic (Ctrl+I)"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="19" y1="4" x2="10" y2="4"/><line x1="14" y1="20" x2="5" y2="20"/><line x1="15" y1="4" x2="9" y2="20"/></svg>
|
||||
</ToolbarBtn>
|
||||
|
||||
<ToolbarBtn
|
||||
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
||||
active={editor.isActive('underline')}
|
||||
title="Underline (Ctrl+U)"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 3v7a6 6 0 006 6 6 6 0 006-6V3"/><line x1="4" y1="21" x2="20" y2="21"/></svg>
|
||||
</ToolbarBtn>
|
||||
|
||||
<ToolbarBtn
|
||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||
active={editor.isActive('strike')}
|
||||
title="Strikethrough"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="4" y1="12" x2="20" y2="12"/><path d="M17.5 7.5c0-2-1.5-3.5-5.5-3.5S6.5 5.5 6.5 7.5c0 4 11 4 11 8 0 2-1.5 3.5-5.5 3.5s-5.5-1.5-5.5-3.5"/></svg>
|
||||
</ToolbarBtn>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Headings */}
|
||||
<ToolbarBtn
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
active={editor.isActive('heading', { level: 2 })}
|
||||
title="Heading 2"
|
||||
>
|
||||
<span className="text-xs font-bold">H2</span>
|
||||
</ToolbarBtn>
|
||||
|
||||
<ToolbarBtn
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||
active={editor.isActive('heading', { level: 3 })}
|
||||
title="Heading 3"
|
||||
>
|
||||
<span className="text-xs font-bold">H3</span>
|
||||
</ToolbarBtn>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Lists */}
|
||||
<ToolbarBtn
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
active={editor.isActive('bulletList')}
|
||||
title="Bullet list"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="9" y1="6" x2="20" y2="6"/><line x1="9" y1="12" x2="20" y2="12"/><line x1="9" y1="18" x2="20" y2="18"/><circle cx="4.5" cy="6" r="1" fill="currentColor"/><circle cx="4.5" cy="12" r="1" fill="currentColor"/><circle cx="4.5" cy="18" r="1" fill="currentColor"/></svg>
|
||||
</ToolbarBtn>
|
||||
|
||||
<ToolbarBtn
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
active={editor.isActive('orderedList')}
|
||||
title="Numbered list"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><text x="3" y="8" fontSize="7" fill="currentColor" stroke="none" fontFamily="sans-serif">1</text><text x="3" y="14" fontSize="7" fill="currentColor" stroke="none" fontFamily="sans-serif">2</text><text x="3" y="20" fontSize="7" fill="currentColor" stroke="none" fontFamily="sans-serif">3</text></svg>
|
||||
</ToolbarBtn>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Block elements */}
|
||||
<ToolbarBtn
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
active={editor.isActive('blockquote')}
|
||||
title="Quote"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M4.583 17.321C3.553 16.227 3 15 3 13.011c0-3.5 2.457-6.637 6.03-8.188l.893 1.378c-3.335 1.804-3.987 4.145-4.247 5.621.537-.278 1.24-.375 1.929-.311C9.591 11.68 11 13.24 11 15.14c0 .94-.36 1.84-1.001 2.503A3.34 3.34 0 017.559 18.6a3.77 3.77 0 01-2.976-.879zm10.4 0C13.953 16.227 13.4 15 13.4 13.011c0-3.5 2.457-6.637 6.03-8.188l.893 1.378c-3.335 1.804-3.987 4.145-4.247 5.621.537-.278 1.24-.375 1.929-.311 1.986.169 3.395 1.729 3.395 3.629 0 .94-.36 1.84-1.001 2.503a3.34 3.34 0 01-2.44.957 3.77 3.77 0 01-2.976-.879z"/></svg>
|
||||
</ToolbarBtn>
|
||||
|
||||
<ToolbarBtn
|
||||
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
||||
active={editor.isActive('codeBlock')}
|
||||
title="Code block"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
|
||||
</ToolbarBtn>
|
||||
|
||||
<ToolbarBtn
|
||||
onClick={() => editor.chain().focus().toggleCode().run()}
|
||||
active={editor.isActive('code')}
|
||||
title="Inline code"
|
||||
>
|
||||
<span className="font-mono text-[11px] font-bold">{'{}'}</span>
|
||||
</ToolbarBtn>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Link & Image */}
|
||||
<ToolbarBtn
|
||||
onClick={addLink}
|
||||
active={editor.isActive('link')}
|
||||
title="Link"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
|
||||
</ToolbarBtn>
|
||||
|
||||
<ToolbarBtn onClick={addImage} title="Insert image">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
||||
</ToolbarBtn>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Horizontal rule */}
|
||||
<ToolbarBtn
|
||||
onClick={() => editor.chain().focus().setHorizontalRule().run()}
|
||||
title="Horizontal rule"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="3" y1="12" x2="21" y2="12"/></svg>
|
||||
</ToolbarBtn>
|
||||
|
||||
{/* Emoji picker */}
|
||||
<EmojiPicker editor={editor} />
|
||||
|
||||
{/* Mention hint */}
|
||||
<ToolbarBtn
|
||||
onClick={() => editor.chain().focus().insertContent('@').run()}
|
||||
title="Mention a user (type @username)"
|
||||
>
|
||||
<span className="text-xs font-bold">@</span>
|
||||
</ToolbarBtn>
|
||||
|
||||
{/* Undo / Redo */}
|
||||
<div className="ml-auto flex items-center gap-0.5">
|
||||
<ToolbarBtn
|
||||
onClick={() => editor.chain().focus().undo().run()}
|
||||
disabled={!editor.can().undo()}
|
||||
title="Undo (Ctrl+Z)"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 102.13-9.36L1 10"/></svg>
|
||||
</ToolbarBtn>
|
||||
|
||||
<ToolbarBtn
|
||||
onClick={() => editor.chain().focus().redo().run()}
|
||||
disabled={!editor.can().redo()}
|
||||
title="Redo (Ctrl+Shift+Z)"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.13-9.36L23 10"/></svg>
|
||||
</ToolbarBtn>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Main editor component ─── */
|
||||
|
||||
/**
|
||||
* Rich text editor for forum posts & replies.
|
||||
*
|
||||
* @prop {string} content – initial HTML content
|
||||
* @prop {function} onChange – called with HTML string on every change
|
||||
* @prop {string} placeholder – placeholder text
|
||||
* @prop {string} error – validation error message
|
||||
* @prop {number} minHeight – min height in rem (default 12)
|
||||
* @prop {boolean} autofocus – focus on mount
|
||||
*/
|
||||
export default function RichTextEditor({
|
||||
content = '',
|
||||
onChange,
|
||||
placeholder = 'Write something…',
|
||||
error,
|
||||
minHeight = 12,
|
||||
autofocus = false,
|
||||
}) {
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [2, 3] },
|
||||
codeBlock: {
|
||||
HTMLAttributes: { class: 'forum-code-block' },
|
||||
},
|
||||
}),
|
||||
Underline,
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
HTMLAttributes: {
|
||||
class: 'text-sky-300 underline hover:text-sky-200',
|
||||
rel: 'noopener noreferrer nofollow',
|
||||
},
|
||||
}),
|
||||
Image.configure({
|
||||
HTMLAttributes: { class: 'rounded-lg max-w-full' },
|
||||
}),
|
||||
Placeholder.configure({ placeholder }),
|
||||
Mention.configure({
|
||||
HTMLAttributes: {
|
||||
class: 'mention',
|
||||
},
|
||||
suggestion: mentionSuggestion,
|
||||
}),
|
||||
],
|
||||
content,
|
||||
autofocus,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: [
|
||||
'prose prose-invert prose-sm max-w-none',
|
||||
'focus:outline-none',
|
||||
'px-4 py-3',
|
||||
'prose-headings:text-white prose-headings:font-bold',
|
||||
'prose-p:text-zinc-200 prose-p:leading-relaxed',
|
||||
'prose-a:text-sky-300 prose-a:no-underline hover:prose-a:text-sky-200',
|
||||
'prose-blockquote:border-l-sky-500/50 prose-blockquote:text-zinc-400',
|
||||
'prose-code:text-amber-300 prose-code:bg-white/[0.06] prose-code:rounded prose-code:px-1.5 prose-code:py-0.5 prose-code:text-xs',
|
||||
'prose-pre:bg-white/[0.04] prose-pre:border prose-pre:border-white/[0.06] prose-pre:rounded-xl',
|
||||
'prose-img:rounded-xl',
|
||||
'prose-hr:border-white/10',
|
||||
].join(' '),
|
||||
style: `min-height: ${minHeight}rem`,
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor: e }) => {
|
||||
onChange?.(e.getHTML())
|
||||
},
|
||||
})
|
||||
|
||||
// Sync content from outside (e.g. prefill / quote)
|
||||
useEffect(() => {
|
||||
if (editor && content && !editor.getHTML().includes(content.slice(0, 30))) {
|
||||
editor.commands.setContent(content, false)
|
||||
}
|
||||
}, [content]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div
|
||||
className={[
|
||||
'overflow-hidden rounded-xl border bg-white/[0.04] transition-colors',
|
||||
error
|
||||
? 'border-red-500/60 focus-within:border-red-500/70 focus-within:ring-2 focus-within:ring-red-500/30'
|
||||
: 'border-white/12 hover:border-white/20 focus-within:border-sky-500/50 focus-within:ring-2 focus-within:ring-sky-500/20',
|
||||
].join(' ')}
|
||||
>
|
||||
<Toolbar editor={editor} />
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-red-400">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
116
resources/js/components/forum/ThreadRow.jsx
Normal file
116
resources/js/components/forum/ThreadRow.jsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function ThreadRow({ thread, isFirst = false }) {
|
||||
const id = thread?.topic_id ?? thread?.id ?? 0
|
||||
const title = thread?.topic ?? thread?.title ?? 'Untitled'
|
||||
const slug = thread?.slug ?? slugify(title)
|
||||
const excerpt = thread?.discuss ?? ''
|
||||
const posts = thread?.num_posts ?? 0
|
||||
const author = thread?.uname ?? 'Unknown'
|
||||
const lastUpdate = thread?.last_update ?? thread?.post_date
|
||||
const isPinned = thread?.is_pinned ?? false
|
||||
|
||||
const href = `/forum/thread/${id}-${slug}`
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className={[
|
||||
'group flex items-start gap-4 px-5 py-4 transition-colors hover:bg-white/[0.03]',
|
||||
!isFirst && 'border-t border-white/[0.06]',
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-sky-500/10 text-sky-400 group-hover:bg-sky-500/15 transition-colors">
|
||||
{isPinned ? (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M16 12V4h1V2H7v2h1v8l-2 2v2h5.2v6h1.6v-6H18v-2z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="truncate text-sm font-semibold text-white group-hover:text-sky-300 transition-colors">
|
||||
{title}
|
||||
</h3>
|
||||
{isPinned && (
|
||||
<span className="shrink-0 rounded-full bg-amber-500/15 px-2 py-0.5 text-[10px] font-medium text-amber-300">
|
||||
Pinned
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{excerpt && (
|
||||
<p className="mt-0.5 truncate text-xs text-white/40">
|
||||
{stripHtml(excerpt).slice(0, 180)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-3 text-xs text-white/35">
|
||||
<span className="flex items-center gap-1">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
{author}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
|
||||
</svg>
|
||||
{posts} {posts === 1 ? 'reply' : 'replies'}
|
||||
</span>
|
||||
{lastUpdate && (
|
||||
<span className="flex items-center gap-1">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
{formatDate(lastUpdate)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reply count badge */}
|
||||
<div className="mt-1 shrink-0">
|
||||
<span className="inline-flex min-w-[2rem] items-center justify-center rounded-full bg-white/[0.06] px-2.5 py-1 text-xs font-medium text-white/60">
|
||||
{posts}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
function slugify(text) {
|
||||
return (text ?? '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)/g, '')
|
||||
.slice(0, 80)
|
||||
}
|
||||
|
||||
function stripHtml(html) {
|
||||
if (typeof document !== 'undefined') {
|
||||
const div = document.createElement('div')
|
||||
div.innerHTML = html
|
||||
return div.textContent || div.innerText || ''
|
||||
}
|
||||
return html.replace(/<[^>]*>/g, '')
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
try {
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
+ ' ' + d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
|
||||
} catch {
|
||||
return '-'
|
||||
}
|
||||
}
|
||||
74
resources/js/components/forum/mentionSuggestion.js
Normal file
74
resources/js/components/forum/mentionSuggestion.js
Normal file
@@ -0,0 +1,74 @@
|
||||
import { ReactRenderer } from '@tiptap/react'
|
||||
import tippy from 'tippy.js'
|
||||
import MentionList from './MentionList'
|
||||
|
||||
/**
|
||||
* TipTap suggestion configuration for @mentions.
|
||||
* Fetches users from /api/search/users?q=... as the user types.
|
||||
*/
|
||||
export default {
|
||||
items: async ({ query }) => {
|
||||
if (!query || query.length < 2) return []
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/search/users?q=${encodeURIComponent(query)}&per_page=6`)
|
||||
if (!res.ok) return []
|
||||
const json = await res.json()
|
||||
return json.data ?? []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
},
|
||||
|
||||
render: () => {
|
||||
let component
|
||||
let popup
|
||||
|
||||
return {
|
||||
onStart: (props) => {
|
||||
component = new ReactRenderer(MentionList, {
|
||||
props,
|
||||
editor: props.editor,
|
||||
})
|
||||
|
||||
if (!props.clientRect) return
|
||||
|
||||
popup = tippy('body', {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: 'manual',
|
||||
placement: 'bottom-start',
|
||||
theme: 'mention',
|
||||
arrow: false,
|
||||
offset: [0, 8],
|
||||
})
|
||||
},
|
||||
|
||||
onUpdate: (props) => {
|
||||
component?.updateProps(props)
|
||||
|
||||
if (!props.clientRect) return
|
||||
|
||||
popup?.[0]?.setProps({
|
||||
getReferenceClientRect: props.clientRect,
|
||||
})
|
||||
},
|
||||
|
||||
onKeyDown: (props) => {
|
||||
if (props.event.key === 'Escape') {
|
||||
popup?.[0]?.hide()
|
||||
return true
|
||||
}
|
||||
return component?.ref?.onKeyDown(props) ?? false
|
||||
},
|
||||
|
||||
onExit: () => {
|
||||
popup?.[0]?.destroy()
|
||||
component?.destroy()
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -151,7 +151,7 @@ export default function MessageBubble({ message, isMine, showAvatar, onReact, on
|
||||
unwrapDisallowed
|
||||
components={{
|
||||
a: ({ href, children }) => (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer"
|
||||
<a href={href} target="_blank" rel="noopener noreferrer nofollow"
|
||||
className={isMine ? 'text-blue-200 underline' : 'text-blue-600 dark:text-blue-400 underline'}>
|
||||
{children}
|
||||
</a>
|
||||
|
||||
197
resources/js/components/profile/ProfileHero.jsx
Normal file
197
resources/js/components/profile/ProfileHero.jsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import React, { useState } from 'react'
|
||||
import { router } from '@inertiajs/react'
|
||||
|
||||
/**
|
||||
* ProfileHero
|
||||
* Cover banner + avatar + identity block + action buttons
|
||||
*/
|
||||
export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing, followerCount, heroBgUrl, countryName }) {
|
||||
const [following, setFollowing] = useState(viewerIsFollowing)
|
||||
const [count, setCount] = useState(followerCount)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [hovering, setHovering] = useState(false)
|
||||
|
||||
const uname = user.username || user.name || 'Unknown'
|
||||
const displayName = user.name || uname
|
||||
|
||||
const joinDate = user.created_at
|
||||
? new Date(user.created_at).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
|
||||
: null
|
||||
|
||||
const toggleFollow = async () => {
|
||||
if (loading) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/@${uname.toLowerCase()}/follow`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setFollowing(data.following)
|
||||
setCount(data.follower_count)
|
||||
}
|
||||
} catch (_) {}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden border-b border-white/10">
|
||||
{/* Cover / hero background */}
|
||||
<div
|
||||
className="w-full"
|
||||
style={{
|
||||
height: 'clamp(160px, 22vw, 260px)',
|
||||
background: heroBgUrl
|
||||
? `url('${heroBgUrl}') center/cover no-repeat`
|
||||
: 'linear-gradient(135deg, #0f1724 0%, #151e2e 50%, #090f1a 100%)',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Overlay */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background: heroBgUrl
|
||||
? 'linear-gradient(to right, rgba(15,23,36,0.95) 0%, rgba(15,23,36,0.75) 50%, rgba(15,23,36,0.45) 100%)'
|
||||
: 'radial-gradient(ellipse at 20% 50%, rgba(77,163,255,.12) 0%, transparent 60%), radial-gradient(ellipse at 80% 20%, rgba(224,122,33,.08) 0%, transparent 50%)',
|
||||
}}
|
||||
/>
|
||||
{/* Nebula grain decoration */}
|
||||
<div className="absolute inset-0 opacity-[0.06] pointer-events-none" style={{ backgroundImage: 'url(/gfx/noise.png)', backgroundSize: '32px' }} />
|
||||
</div>
|
||||
|
||||
{/* Identity block – overlaps cover at bottom */}
|
||||
<div className="max-w-6xl mx-auto px-4">
|
||||
<div className="relative -mt-16 pb-5 flex flex-col sm:flex-row sm:items-end gap-4">
|
||||
|
||||
{/* Avatar */}
|
||||
<div className="shrink-0 z-10">
|
||||
<img
|
||||
src={user.avatar_url || '/default/avatar_default.webp'}
|
||||
alt={`${uname}'s avatar`}
|
||||
className="w-24 h-24 sm:w-28 sm:h-28 rounded-2xl object-cover ring-4 ring-[#0f1724] shadow-xl shadow-black/60"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Name + meta */}
|
||||
<div className="flex-1 min-w-0 pb-1">
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-white leading-tight">
|
||||
{displayName}
|
||||
</h1>
|
||||
<p className="text-slate-400 text-sm mt-0.5 font-mono">@{uname}</p>
|
||||
<div className="flex flex-wrap items-center gap-3 mt-2 text-xs text-slate-500">
|
||||
{countryName && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
{profile?.country_code && (
|
||||
<img
|
||||
src={`/gfx/flags/shiny/24/${encodeURIComponent(profile.country_code)}.png`}
|
||||
alt={countryName}
|
||||
className="w-4 h-auto rounded-sm"
|
||||
onError={(e) => { e.target.style.display = 'none' }}
|
||||
/>
|
||||
)}
|
||||
{countryName}
|
||||
</span>
|
||||
)}
|
||||
{joinDate && (
|
||||
<span className="flex items-center gap-1">
|
||||
<i className="fa-solid fa-calendar-days fa-fw opacity-60" />
|
||||
Joined {joinDate}
|
||||
</span>
|
||||
)}
|
||||
{profile?.website && (
|
||||
<a
|
||||
href={profile.website.startsWith('http') ? profile.website : `https://${profile.website}`}
|
||||
target="_blank"
|
||||
rel="nofollow noopener noreferrer"
|
||||
className="flex items-center gap-1 text-sky-400 hover:text-sky-300 transition-colors"
|
||||
>
|
||||
<i className="fa-solid fa-link fa-fw" />
|
||||
{(() => {
|
||||
try {
|
||||
const url = profile.website.startsWith('http') ? profile.website : `https://${profile.website}`
|
||||
return new URL(url).hostname
|
||||
} catch {
|
||||
return profile.website
|
||||
}
|
||||
})()}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="shrink-0 flex items-center gap-2 pb-1">
|
||||
{isOwner ? (
|
||||
<>
|
||||
<a
|
||||
href="/dashboard/profile"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium border border-white/15 text-slate-300 hover:text-white hover:bg-white/5 transition-all"
|
||||
aria-label="Edit profile"
|
||||
>
|
||||
<i className="fa-solid fa-pen fa-fw" />
|
||||
<span className="hidden sm:inline">Edit Profile</span>
|
||||
</a>
|
||||
<a
|
||||
href="/studio"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium bg-sky-600 hover:bg-sky-500 text-white transition-all shadow-lg shadow-sky-900/30"
|
||||
aria-label="Open Studio"
|
||||
>
|
||||
<i className="fa-solid fa-wand-magic-sparkles fa-fw" />
|
||||
<span className="hidden sm:inline">Studio</span>
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Follow button */}
|
||||
<button
|
||||
onClick={toggleFollow}
|
||||
onMouseEnter={() => setHovering(true)}
|
||||
onMouseLeave={() => setHovering(false)}
|
||||
disabled={loading}
|
||||
aria-label={following ? 'Unfollow' : 'Follow'}
|
||||
className={`inline-flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium border transition-all ${
|
||||
following
|
||||
? hovering
|
||||
? 'bg-red-500/10 border-red-400/40 text-red-400'
|
||||
: 'bg-green-500/10 border-green-400/40 text-green-400'
|
||||
: 'bg-sky-500/10 border-sky-400/40 text-sky-400 hover:bg-sky-500/20'
|
||||
}`}
|
||||
>
|
||||
<i className={`fa-solid fa-fw ${
|
||||
loading
|
||||
? 'fa-circle-notch fa-spin'
|
||||
: following
|
||||
? hovering ? 'fa-user-minus' : 'fa-user-check'
|
||||
: 'fa-user-plus'
|
||||
}`} />
|
||||
<span>{following ? (hovering ? 'Unfollow' : 'Following') : 'Follow'}</span>
|
||||
<span className="text-xs opacity-60">({count.toLocaleString()})</span>
|
||||
</button>
|
||||
{/* Share */}
|
||||
<button
|
||||
onClick={() => {
|
||||
if (navigator.share) {
|
||||
navigator.share({ title: `${displayName} on Skinbase`, url: window.location.href })
|
||||
} else {
|
||||
navigator.clipboard.writeText(window.location.href)
|
||||
}
|
||||
}}
|
||||
aria-label="Share profile"
|
||||
className="p-2.5 rounded-xl border border-white/10 text-slate-400 hover:text-white hover:bg-white/5 transition-all"
|
||||
>
|
||||
<i className="fa-solid fa-share-nodes fa-fw" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
57
resources/js/components/profile/ProfileStatsRow.jsx
Normal file
57
resources/js/components/profile/ProfileStatsRow.jsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import React from 'react'
|
||||
|
||||
const PILLS = [
|
||||
{ key: 'uploads_count', label: 'Artworks', icon: 'fa-images', tab: 'artworks' },
|
||||
{ key: 'downloads_received_count', label: 'Downloads', icon: 'fa-download', tab: null },
|
||||
{ key: 'follower_count', label: 'Followers', icon: 'fa-users', tab: 'about' },
|
||||
{ key: 'following_count', label: 'Following', icon: 'fa-user-check', tab: 'about' },
|
||||
{ key: 'artwork_views_received_count', label: 'Views', icon: 'fa-eye', tab: 'stats' },
|
||||
{ key: 'awards_received_count', label: 'Awards', icon: 'fa-trophy', tab: 'stats' },
|
||||
]
|
||||
|
||||
/**
|
||||
* ProfileStatsRow
|
||||
* Horizontal scrollable pill row of stat counts.
|
||||
* Clicking a pill navigates to the relevant tab.
|
||||
*/
|
||||
export default function ProfileStatsRow({ stats, followerCount, onTabChange }) {
|
||||
const values = {
|
||||
uploads_count: stats?.uploads_count ?? 0,
|
||||
downloads_received_count: stats?.downloads_received_count ?? 0,
|
||||
follower_count: followerCount ?? 0,
|
||||
following_count: stats?.following_count ?? 0,
|
||||
artwork_views_received_count: stats?.artwork_views_received_count ?? 0,
|
||||
awards_received_count: stats?.awards_received_count ?? 0,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white/3 border-b border-white/10 overflow-x-auto" style={{ background: 'rgba(255,255,255,0.025)' }}>
|
||||
<div className="max-w-6xl mx-auto px-4">
|
||||
<div className="flex gap-1 py-2 min-w-max sm:min-w-0 sm:flex-wrap">
|
||||
{PILLS.map((pill) => (
|
||||
<button
|
||||
key={pill.key}
|
||||
onClick={() => pill.tab && onTabChange(pill.tab)}
|
||||
title={pill.label}
|
||||
disabled={!pill.tab}
|
||||
className={`
|
||||
flex items-center gap-2 px-4 py-2 rounded-lg text-sm transition-all
|
||||
${pill.tab
|
||||
? 'cursor-pointer hover:bg-white/8 hover:text-white text-slate-300 group'
|
||||
: 'cursor-default text-slate-400'
|
||||
}
|
||||
`}
|
||||
style={{ background: 'transparent' }}
|
||||
>
|
||||
<i className={`fa-solid ${pill.icon} fa-fw text-xs opacity-60 group-hover:opacity-80`} />
|
||||
<span className="font-bold text-white tabular-nums">
|
||||
{Number(values[pill.key]).toLocaleString()}
|
||||
</span>
|
||||
<span className="text-slate-500 text-xs hidden sm:inline">{pill.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
74
resources/js/components/profile/ProfileTabs.jsx
Normal file
74
resources/js/components/profile/ProfileTabs.jsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
|
||||
export const TABS = [
|
||||
{ id: 'artworks', label: 'Artworks', icon: 'fa-images' },
|
||||
{ id: 'posts', label: 'Posts', icon: 'fa-newspaper' },
|
||||
{ id: 'collections', label: 'Collections', icon: 'fa-layer-group' },
|
||||
{ id: 'about', label: 'About', icon: 'fa-id-card' },
|
||||
{ id: 'stats', label: 'Stats', icon: 'fa-chart-bar' },
|
||||
{ id: 'favourites', label: 'Favourites', icon: 'fa-heart' },
|
||||
{ id: 'activity', label: 'Activity', icon: 'fa-bolt' },
|
||||
]
|
||||
|
||||
/**
|
||||
* ProfileTabs
|
||||
* Sticky tab navigation that:
|
||||
* - Scrolls horizontally on mobile
|
||||
* - Shows active underline / glow
|
||||
* - Updates URL query param on tab change
|
||||
*/
|
||||
export default function ProfileTabs({ activeTab, onTabChange }) {
|
||||
const navRef = useRef(null)
|
||||
const activeRef = useRef(null)
|
||||
|
||||
// Scroll active tab into view on mount/change
|
||||
useEffect(() => {
|
||||
if (activeRef.current && navRef.current) {
|
||||
activeRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' })
|
||||
}
|
||||
}, [activeTab])
|
||||
|
||||
return (
|
||||
<nav
|
||||
ref={navRef}
|
||||
className="profile-tabs-sticky sticky z-30 bg-[#0c1525]/95 backdrop-blur-xl border-b border-white/10 overflow-x-auto scrollbar-hide"
|
||||
aria-label="Profile sections"
|
||||
role="tablist"
|
||||
>
|
||||
<div className="max-w-6xl mx-auto px-3 flex gap-0 min-w-max sm:min-w-0">
|
||||
{TABS.map((tab) => {
|
||||
const isActive = activeTab === tab.id
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
ref={isActive ? activeRef : null}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
aria-controls={`tabpanel-${tab.id}`}
|
||||
className={`
|
||||
relative flex items-center gap-2 px-4 py-3.5 text-sm font-medium whitespace-nowrap
|
||||
transition-colors duration-150 outline-none
|
||||
focus-visible:ring-2 focus-visible:ring-sky-400/70 rounded-t
|
||||
${isActive
|
||||
? 'text-white'
|
||||
: 'text-slate-400 hover:text-slate-200'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<i className={`fa-solid ${tab.icon} fa-fw text-xs ${isActive ? 'text-sky-400' : ''}`} />
|
||||
{tab.label}
|
||||
{/* Active indicator bar */}
|
||||
{isActive && (
|
||||
<span
|
||||
className="absolute bottom-0 inset-x-0 h-0.5 rounded-full bg-sky-400 shadow-[0_0_8px_rgba(56,189,248,0.6)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
165
resources/js/components/profile/tabs/TabAbout.jsx
Normal file
165
resources/js/components/profile/tabs/TabAbout.jsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import React from 'react'
|
||||
|
||||
const SOCIAL_ICONS = {
|
||||
twitter: { icon: 'fa-brands fa-x-twitter', label: 'X / Twitter' },
|
||||
deviantart: { icon: 'fa-brands fa-deviantart', label: 'DeviantArt' },
|
||||
instagram: { icon: 'fa-brands fa-instagram', label: 'Instagram' },
|
||||
behance: { icon: 'fa-brands fa-behance', label: 'Behance' },
|
||||
artstation: { icon: 'fa-solid fa-palette', label: 'ArtStation' },
|
||||
youtube: { icon: 'fa-brands fa-youtube', label: 'YouTube' },
|
||||
website: { icon: 'fa-solid fa-link', label: 'Website' },
|
||||
}
|
||||
|
||||
function InfoRow({ icon, label, children }) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 py-2.5 border-b border-white/5 last:border-0">
|
||||
<i className={`fa-solid ${icon} fa-fw text-slate-500 mt-0.5 w-4 text-center`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-xs text-slate-500 block mb-0.5">{label}</span>
|
||||
<div className="text-sm text-slate-200">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* TabAbout
|
||||
* Bio, social links, metadata - replaces old sidebar profile card.
|
||||
*/
|
||||
export default function TabAbout({ user, profile, socialLinks, countryName, followerCount }) {
|
||||
const uname = user.username || user.name
|
||||
const displayName = user.name || uname
|
||||
const about = profile?.about
|
||||
const website = profile?.website
|
||||
|
||||
const joinDate = user.created_at
|
||||
? new Date(user.created_at).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })
|
||||
: null
|
||||
|
||||
const lastVisit = user.last_visit_at
|
||||
? (() => {
|
||||
try {
|
||||
const d = new Date(user.last_visit_at)
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
} catch { return null }
|
||||
})()
|
||||
: null
|
||||
|
||||
const genderMap = { M: 'Male', F: 'Female', X: 'Non-binary / N/A' }
|
||||
const genderLabel = genderMap[profile?.gender?.toUpperCase()] ?? null
|
||||
|
||||
const socialEntries = socialLinks
|
||||
? Object.entries(socialLinks).filter(([, link]) => link?.url)
|
||||
: []
|
||||
|
||||
return (
|
||||
<div
|
||||
id="tabpanel-about"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-about"
|
||||
className="pt-6 max-w-2xl"
|
||||
>
|
||||
{/* Bio */}
|
||||
{about ? (
|
||||
<div className="bg-white/4 ring-1 ring-white/10 rounded-2xl p-5 mb-5 shadow-xl shadow-black/20 backdrop-blur">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-500 mb-3 flex items-center gap-2">
|
||||
<i className="fa-solid fa-quote-left text-purple-400 fa-fw" />
|
||||
About
|
||||
</h2>
|
||||
<p className="text-sm text-slate-300 leading-relaxed whitespace-pre-line">{about}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white/4 ring-1 ring-white/10 rounded-2xl p-5 mb-5 text-center text-slate-500 text-sm">
|
||||
No bio yet.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info card */}
|
||||
<div className="bg-white/4 ring-1 ring-white/10 rounded-2xl p-5 mb-5 shadow-xl shadow-black/20">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-500 mb-3 flex items-center gap-2">
|
||||
<i className="fa-solid fa-id-card text-sky-400 fa-fw" />
|
||||
Profile Info
|
||||
</h2>
|
||||
<div className="divide-y divide-white/5">
|
||||
{displayName && displayName !== uname && (
|
||||
<InfoRow icon="fa-user" label="Display name">{displayName}</InfoRow>
|
||||
)}
|
||||
<InfoRow icon="fa-at" label="Username">
|
||||
<span className="font-mono">@{uname}</span>
|
||||
</InfoRow>
|
||||
{genderLabel && (
|
||||
<InfoRow icon="fa-venus-mars" label="Gender">{genderLabel}</InfoRow>
|
||||
)}
|
||||
{countryName && (
|
||||
<InfoRow icon="fa-earth-americas" label="Country">
|
||||
<span className="flex items-center gap-2">
|
||||
{profile?.country_code && (
|
||||
<img
|
||||
src={`/gfx/flags/shiny/24/${encodeURIComponent(profile.country_code)}.png`}
|
||||
alt={countryName}
|
||||
className="w-4 h-auto rounded-sm"
|
||||
onError={(e) => { e.target.style.display = 'none' }}
|
||||
/>
|
||||
)}
|
||||
{countryName}
|
||||
</span>
|
||||
</InfoRow>
|
||||
)}
|
||||
{website && (
|
||||
<InfoRow icon="fa-link" label="Website">
|
||||
<a
|
||||
href={website.startsWith('http') ? website : `https://${website}`}
|
||||
target="_blank"
|
||||
rel="nofollow noopener noreferrer"
|
||||
className="text-sky-400 hover:text-sky-300 hover:underline transition-colors"
|
||||
>
|
||||
{(() => {
|
||||
try {
|
||||
const url = website.startsWith('http') ? website : `https://${website}`
|
||||
return new URL(url).hostname
|
||||
} catch { return website }
|
||||
})()}
|
||||
</a>
|
||||
</InfoRow>
|
||||
)}
|
||||
{joinDate && (
|
||||
<InfoRow icon="fa-calendar-days" label="Member since">{joinDate}</InfoRow>
|
||||
)}
|
||||
{lastVisit && (
|
||||
<InfoRow icon="fa-clock" label="Last seen">{lastVisit}</InfoRow>
|
||||
)}
|
||||
<InfoRow icon="fa-users" label="Followers">{Number(followerCount).toLocaleString()}</InfoRow>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Social links */}
|
||||
{socialEntries.length > 0 && (
|
||||
<div className="bg-white/4 ring-1 ring-white/10 rounded-2xl p-5 shadow-xl shadow-black/20">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-500 mb-3 flex items-center gap-2">
|
||||
<i className="fa-solid fa-share-nodes text-sky-400 fa-fw" />
|
||||
Social Links
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{socialEntries.map(([platform, link]) => {
|
||||
const si = SOCIAL_ICONS[platform] ?? { icon: 'fa-solid fa-link', label: platform }
|
||||
const href = link.url.startsWith('http') ? link.url : `https://${link.url}`
|
||||
return (
|
||||
<a
|
||||
key={platform}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="nofollow noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-3 py-2 rounded-xl text-sm border border-white/10 text-slate-300 hover:text-white hover:bg-white/8 hover:border-sky-400/30 transition-all"
|
||||
aria-label={si.label}
|
||||
>
|
||||
<i className={`${si.icon} fa-fw`} />
|
||||
<span>{si.label}</span>
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
153
resources/js/components/profile/tabs/TabActivity.jsx
Normal file
153
resources/js/components/profile/tabs/TabActivity.jsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import React, { useRef, useState } from 'react'
|
||||
|
||||
const DEFAULT_AVATAR = 'https://files.skinbase.org/default/avatar_default.webp'
|
||||
|
||||
function CommentItem({ comment }) {
|
||||
return (
|
||||
<div className="flex gap-3 py-4 border-b border-white/5 last:border-0">
|
||||
<a href={comment.author_profile_url} className="shrink-0 mt-0.5">
|
||||
<img
|
||||
src={comment.author_avatar || DEFAULT_AVATAR}
|
||||
alt={comment.author_name}
|
||||
className="w-9 h-9 rounded-xl object-cover ring-1 ring-white/10"
|
||||
onError={(e) => { e.target.src = DEFAULT_AVATAR }}
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<a
|
||||
href={comment.author_profile_url}
|
||||
className="text-sm font-semibold text-slate-200 hover:text-white transition-colors"
|
||||
>
|
||||
{comment.author_name}
|
||||
</a>
|
||||
<span className="text-slate-600 text-xs ml-auto whitespace-nowrap">
|
||||
{(() => {
|
||||
try {
|
||||
const d = new Date(comment.created_at)
|
||||
const diff = Date.now() - d.getTime()
|
||||
const mins = Math.floor(diff / 60000)
|
||||
if (mins < 1) return 'just now'
|
||||
if (mins < 60) return `${mins}m ago`
|
||||
const hrs = Math.floor(mins / 60)
|
||||
if (hrs < 24) return `${hrs}h ago`
|
||||
const days = Math.floor(hrs / 24)
|
||||
if (days < 30) return `${days}d ago`
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
} catch { return '' }
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-400 leading-relaxed break-words whitespace-pre-line">
|
||||
{comment.body}
|
||||
</p>
|
||||
{comment.author_signature && (
|
||||
<p className="text-xs text-slate-600 mt-2 italic border-t border-white/5 pt-1 truncate">
|
||||
{comment.author_signature}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* TabActivity
|
||||
* Profile comments list + comment form for authenticated visitors.
|
||||
* Also acts as "Activity" tab.
|
||||
*/
|
||||
export default function TabActivity({ profileComments, user, isOwner, isLoggedIn }) {
|
||||
const uname = user.username || user.name
|
||||
const formRef = useRef(null)
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
|
||||
return (
|
||||
<div
|
||||
id="tabpanel-activity"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-activity"
|
||||
className="pt-6 max-w-2xl"
|
||||
>
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-500 mb-4 flex items-center gap-2">
|
||||
<i className="fa-solid fa-comments text-orange-400 fa-fw" />
|
||||
Comments
|
||||
{profileComments?.length > 0 && (
|
||||
<span className="ml-1 px-1.5 py-0.5 rounded bg-white/5 text-slate-400 font-normal text-[11px]">
|
||||
{profileComments.length}
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
|
||||
{/* Comments list */}
|
||||
<div className="bg-white/4 ring-1 ring-white/10 rounded-2xl p-5 shadow-xl shadow-black/20 mb-5">
|
||||
{!profileComments?.length ? (
|
||||
<p className="text-slate-500 text-sm text-center py-8">
|
||||
No comments yet. Be the first to leave one!
|
||||
</p>
|
||||
) : (
|
||||
<div>
|
||||
{profileComments.map((comment) => (
|
||||
<CommentItem key={comment.id} comment={comment} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Comment form */}
|
||||
{!isOwner && (
|
||||
<div className="bg-white/4 ring-1 ring-white/10 rounded-2xl p-5 shadow-xl shadow-black/20">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-widest text-slate-500 mb-4 flex items-center gap-2">
|
||||
<i className="fa-solid fa-pen text-sky-400 fa-fw" />
|
||||
Write a Comment
|
||||
</h3>
|
||||
|
||||
{isLoggedIn ? (
|
||||
submitted ? (
|
||||
<div className="flex items-center gap-2 text-green-400 text-sm p-3 rounded-xl bg-green-500/10 ring-1 ring-green-500/20">
|
||||
<i className="fa-solid fa-check fa-fw" />
|
||||
Your comment has been posted!
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
ref={formRef}
|
||||
method="POST"
|
||||
action={`/@${uname.toLowerCase()}/comment`}
|
||||
onSubmit={() => setSubmitted(false)}
|
||||
>
|
||||
<input type="hidden" name="_token" value={
|
||||
(() => document.querySelector('meta[name="csrf-token"]')?.content ?? '')()
|
||||
} />
|
||||
<textarea
|
||||
name="body"
|
||||
rows={4}
|
||||
required
|
||||
minLength={2}
|
||||
maxLength={2000}
|
||||
placeholder={`Write a comment for ${uname}…`}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2.5 text-sm text-slate-200 placeholder:text-slate-600 resize-none focus:outline-none focus:ring-2 focus:ring-sky-400/40 focus:border-sky-400/30 transition-all"
|
||||
/>
|
||||
<div className="mt-3 flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl bg-sky-600 hover:bg-sky-500 text-white text-sm font-semibold transition-all shadow-lg shadow-sky-900/30"
|
||||
>
|
||||
<i className="fa-solid fa-paper-plane fa-fw" />
|
||||
Post Comment
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
) : (
|
||||
<p className="text-sm text-slate-400 text-center py-4">
|
||||
<a href="/login" className="text-sky-400 hover:text-sky-300 hover:underline transition-colors">
|
||||
Log in
|
||||
</a>
|
||||
{' '}to leave a comment.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
195
resources/js/components/profile/tabs/TabArtworks.jsx
Normal file
195
resources/js/components/profile/tabs/TabArtworks.jsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import ArtworkCard from '../../gallery/ArtworkCard'
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
{ value: 'latest', label: 'Latest' },
|
||||
{ value: 'trending', label: 'Trending' },
|
||||
{ value: 'rising', label: 'Rising' },
|
||||
{ value: 'views', label: 'Most Viewed' },
|
||||
{ value: 'favs', label: 'Most Favourited' },
|
||||
]
|
||||
|
||||
function ArtworkSkeleton() {
|
||||
return (
|
||||
<div className="rounded-2xl overflow-hidden bg-white/5 animate-pulse">
|
||||
<div className="aspect-[4/3] bg-white/8" />
|
||||
<div className="p-2 space-y-1.5">
|
||||
<div className="h-3 bg-white/8 rounded w-3/4" />
|
||||
<div className="h-2 bg-white/5 rounded w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState({ username }) {
|
||||
return (
|
||||
<div className="col-span-full flex flex-col items-center justify-center py-20 text-center">
|
||||
<div className="w-20 h-20 rounded-2xl bg-white/5 flex items-center justify-center mb-5 text-slate-500">
|
||||
<i className="fa-solid fa-image text-3xl" />
|
||||
</div>
|
||||
<p className="text-slate-400 font-medium">No artworks yet</p>
|
||||
<p className="text-slate-600 text-sm mt-1">@{username} hasn't uploaded anything yet.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Featured artworks horizontal scroll strip.
|
||||
*/
|
||||
function FeaturedStrip({ featuredArtworks }) {
|
||||
if (!featuredArtworks?.length) return null
|
||||
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-500 mb-3 flex items-center gap-2">
|
||||
<i className="fa-solid fa-star text-yellow-400 fa-fw" />
|
||||
Featured
|
||||
</h2>
|
||||
<div className="flex gap-3 overflow-x-auto pb-2 scrollbar-hide snap-x snap-mandatory">
|
||||
{featuredArtworks.map((art) => (
|
||||
<a
|
||||
key={art.id}
|
||||
href={`/art/${art.id}/${slugify(art.name)}`}
|
||||
className="group shrink-0 snap-start w-40 sm:w-48"
|
||||
>
|
||||
<div className="overflow-hidden rounded-xl ring-1 ring-white/10 bg-black/30 aspect-[4/3] hover:ring-sky-400/40 transition-all">
|
||||
<img
|
||||
src={art.thumb}
|
||||
alt={art.name}
|
||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-1.5 truncate group-hover:text-white transition-colors">
|
||||
{art.name}
|
||||
</p>
|
||||
{art.label && (
|
||||
<p className="text-[10px] text-slate-600 truncate">{art.label}</p>
|
||||
)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function slugify(str) {
|
||||
return (str || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* TabArtworks
|
||||
* Features: sort selector, featured strip, masonry-style artwork grid,
|
||||
* skeleton loading, empty state, load-more pagination.
|
||||
*/
|
||||
export default function TabArtworks({ artworks, featuredArtworks, username, isActive }) {
|
||||
const [sort, setSort] = useState('latest')
|
||||
const [items, setItems] = useState(artworks?.data ?? artworks ?? [])
|
||||
const [nextCursor, setNextCursor] = useState(artworks?.next_cursor ?? null)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [isInitialLoad] = useState(false) // data SSR-loaded
|
||||
|
||||
const handleSort = async (newSort) => {
|
||||
setSort(newSort)
|
||||
setItems([])
|
||||
try {
|
||||
const res = await fetch(`/api/profile/${encodeURIComponent(username)}/artworks?sort=${newSort}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setItems(data.data ?? data)
|
||||
setNextCursor(data.next_cursor ?? null)
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
const loadMore = async () => {
|
||||
if (!nextCursor || loadingMore) return
|
||||
setLoadingMore(true)
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/profile/${encodeURIComponent(username)}/artworks?sort=${sort}&cursor=${encodeURIComponent(nextCursor)}`,
|
||||
{ headers: { Accept: 'application/json' } }
|
||||
)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setItems((prev) => [...prev, ...(data.data ?? data)])
|
||||
setNextCursor(data.next_cursor ?? null)
|
||||
}
|
||||
} catch (_) {}
|
||||
setLoadingMore(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
id="tabpanel-artworks"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-artworks"
|
||||
className="pt-6"
|
||||
>
|
||||
{/* Featured strip */}
|
||||
<FeaturedStrip featuredArtworks={featuredArtworks} />
|
||||
|
||||
{/* Sort bar */}
|
||||
<div className="flex items-center gap-3 mb-5 flex-wrap">
|
||||
<span className="text-xs text-slate-500 uppercase tracking-wider font-semibold">Sort</span>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{SORT_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => handleSort(opt.value)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||
sort === opt.value
|
||||
? 'bg-sky-500/20 text-sky-300 ring-1 ring-sky-400/40'
|
||||
: 'text-slate-400 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
{isInitialLoad ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{Array.from({ length: 8 }).map((_, i) => <ArtworkSkeleton key={i} />)}
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="grid grid-cols-1">
|
||||
<EmptyState username={username} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{items.map((art, i) => (
|
||||
<ArtworkCard
|
||||
key={art.id ?? i}
|
||||
art={art}
|
||||
loading={i < 8 ? 'eager' : 'lazy'}
|
||||
/>
|
||||
))}
|
||||
{loadingMore && Array.from({ length: 4 }).map((_, i) => <ArtworkSkeleton key={`sk-${i}`} />)}
|
||||
</div>
|
||||
|
||||
{/* Load more */}
|
||||
{nextCursor && (
|
||||
<div className="mt-8 text-center">
|
||||
<button
|
||||
onClick={loadMore}
|
||||
disabled={loadingMore}
|
||||
className="inline-flex items-center gap-2 px-6 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 text-sm font-medium border border-white/10 transition-all"
|
||||
>
|
||||
{loadingMore
|
||||
? <><i className="fa-solid fa-circle-notch fa-spin fa-fw" /> Loading…</>
|
||||
: <><i className="fa-solid fa-chevron-down fa-fw" /> Load more</>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
65
resources/js/components/profile/tabs/TabCollections.jsx
Normal file
65
resources/js/components/profile/tabs/TabCollections.jsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import React from 'react'
|
||||
|
||||
/**
|
||||
* TabCollections
|
||||
* Collections feature placeholder.
|
||||
*/
|
||||
export default function TabCollections({ collections }) {
|
||||
if (collections?.length > 0) {
|
||||
return (
|
||||
<div
|
||||
id="tabpanel-collections"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-collections"
|
||||
className="pt-6"
|
||||
>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{collections.map((col) => (
|
||||
<div
|
||||
key={col.id}
|
||||
className="bg-white/4 ring-1 ring-white/10 rounded-2xl overflow-hidden group hover:ring-sky-400/30 transition-all cursor-pointer shadow-xl shadow-black/20"
|
||||
>
|
||||
{col.cover_image ? (
|
||||
<div className="aspect-video overflow-hidden bg-black/30">
|
||||
<img
|
||||
src={col.cover_image}
|
||||
alt={col.title}
|
||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="aspect-video bg-white/5 flex items-center justify-center text-slate-600">
|
||||
<i className="fa-solid fa-layer-group text-3xl" />
|
||||
</div>
|
||||
)}
|
||||
<div className="p-4">
|
||||
<h3 className="font-semibold text-white truncate">{col.title}</h3>
|
||||
<p className="text-sm text-slate-500 mt-0.5">{col.items_count ?? 0} artworks</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
id="tabpanel-collections"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-collections"
|
||||
className="pt-6"
|
||||
>
|
||||
<div className="bg-white/4 ring-1 ring-white/10 rounded-2xl px-8 py-16 text-center shadow-xl shadow-black/20 backdrop-blur">
|
||||
<div className="w-20 h-20 rounded-2xl bg-white/5 flex items-center justify-center mx-auto mb-5 text-slate-500">
|
||||
<i className="fa-solid fa-layer-group text-3xl" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-white mb-2">Collections Coming Soon</h3>
|
||||
<p className="text-slate-500 text-sm max-w-sm mx-auto">
|
||||
Group artworks into curated collections. This feature is currently in development.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
89
resources/js/components/profile/tabs/TabFavourites.jsx
Normal file
89
resources/js/components/profile/tabs/TabFavourites.jsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import React, { useState } from 'react'
|
||||
import ArtworkCard from '../../gallery/ArtworkCard'
|
||||
|
||||
function FavSkeleton() {
|
||||
return (
|
||||
<div className="rounded-2xl overflow-hidden bg-white/5 animate-pulse">
|
||||
<div className="aspect-square bg-white/8" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* TabFavourites
|
||||
* Shows artworks the user has favourited.
|
||||
*/
|
||||
export default function TabFavourites({ favourites, isOwner, username }) {
|
||||
const [items, setItems] = useState(favourites ?? [])
|
||||
const [nextCursor, setNextCursor] = useState(null)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
|
||||
const loadMore = async () => {
|
||||
if (!nextCursor || loadingMore) return
|
||||
setLoadingMore(true)
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/profile/${encodeURIComponent(username)}/favourites?cursor=${encodeURIComponent(nextCursor)}`,
|
||||
{ headers: { Accept: 'application/json' } }
|
||||
)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setItems((prev) => [...prev, ...(data.data ?? data)])
|
||||
setNextCursor(data.next_cursor ?? null)
|
||||
}
|
||||
} catch (_) {}
|
||||
setLoadingMore(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
id="tabpanel-favourites"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-favourites"
|
||||
className="pt-6"
|
||||
>
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-500 mb-4 flex items-center gap-2">
|
||||
<i className="fa-solid fa-heart text-pink-400 fa-fw" />
|
||||
{isOwner ? 'Your Favourites' : 'Favourites'}
|
||||
</h2>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div className="w-20 h-20 rounded-2xl bg-white/5 flex items-center justify-center mb-5 text-slate-500">
|
||||
<i className="fa-solid fa-heart text-3xl" />
|
||||
</div>
|
||||
<p className="text-slate-400 font-medium">No favourites yet</p>
|
||||
<p className="text-slate-600 text-sm mt-1">Artworks added to favourites will appear here.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{items.map((art, i) => (
|
||||
<ArtworkCard
|
||||
key={art.id ?? i}
|
||||
art={art}
|
||||
loading={i < 8 ? 'eager' : 'lazy'}
|
||||
/>
|
||||
))}
|
||||
{loadingMore && Array.from({ length: 4 }).map((_, i) => <FavSkeleton key={`sk-${i}`} />)}
|
||||
</div>
|
||||
|
||||
{nextCursor && (
|
||||
<div className="mt-8 text-center">
|
||||
<button
|
||||
onClick={loadMore}
|
||||
disabled={loadingMore}
|
||||
className="inline-flex items-center gap-2 px-6 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 text-sm font-medium border border-white/10 transition-all"
|
||||
>
|
||||
{loadingMore
|
||||
? <><i className="fa-solid fa-circle-notch fa-spin fa-fw" /> Loading…</>
|
||||
: <><i className="fa-solid fa-chevron-down fa-fw" /> Load more</>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
158
resources/js/components/profile/tabs/TabPosts.jsx
Normal file
158
resources/js/components/profile/tabs/TabPosts.jsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import axios from 'axios'
|
||||
import PostCard from '../../Feed/PostCard'
|
||||
import PostComposer from '../../Feed/PostComposer'
|
||||
import PostCardSkeleton from '../../Feed/PostCardSkeleton'
|
||||
import FeedSidebar from '../../Feed/FeedSidebar'
|
||||
|
||||
function EmptyPostsState({ isOwner, username }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div className="w-16 h-16 rounded-2xl bg-white/5 flex items-center justify-center mb-4 text-slate-600">
|
||||
<i className="fa-regular fa-newspaper text-2xl" />
|
||||
</div>
|
||||
<p className="text-slate-400 font-medium mb-1">No posts yet</p>
|
||||
{isOwner ? (
|
||||
<p className="text-slate-600 text-sm max-w-xs">
|
||||
Share your thoughts or showcase your artworks. Your first post is a tap away.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-slate-600 text-sm">@{username} hasn't posted anything yet.</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* TabPosts
|
||||
* Profile Posts tab — shows the user's post feed with optional composer (for owner).
|
||||
*
|
||||
* Props:
|
||||
* username string
|
||||
* isOwner boolean
|
||||
* authUser object|null { id, username, name, avatar }
|
||||
* user object full user from ProfileController
|
||||
* profile object
|
||||
* stats object|null
|
||||
* followerCount number
|
||||
* recentFollowers array
|
||||
* socialLinks object
|
||||
* countryName string|null
|
||||
* onTabChange function(tab)
|
||||
*/
|
||||
export default function TabPosts({
|
||||
username,
|
||||
isOwner,
|
||||
authUser,
|
||||
user,
|
||||
profile,
|
||||
stats,
|
||||
followerCount,
|
||||
recentFollowers,
|
||||
socialLinks,
|
||||
countryName,
|
||||
onTabChange,
|
||||
}) {
|
||||
const [posts, setPosts] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
|
||||
// Fetch on mount
|
||||
React.useEffect(() => {
|
||||
fetchFeed(1)
|
||||
}, [username])
|
||||
|
||||
const fetchFeed = async (p = 1) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const { data } = await axios.get(`/api/posts/profile/${username}`, { params: { page: p } })
|
||||
setPosts((prev) => p === 1 ? data.data : [...prev, ...data.data])
|
||||
setHasMore(data.meta.current_page < data.meta.last_page)
|
||||
setPage(p)
|
||||
} catch {
|
||||
//
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoaded(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePosted = useCallback((newPost) => {
|
||||
setPosts((prev) => [newPost, ...prev])
|
||||
}, [])
|
||||
|
||||
const handleDeleted = useCallback((postId) => {
|
||||
setPosts((prev) => prev.filter((p) => p.id !== postId))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex gap-6 py-4 items-start">
|
||||
{/* ── Main feed column ──────────────────────────────────────────────── */}
|
||||
<div className="flex-1 min-w-0 space-y-4">
|
||||
{/* Composer (owner only) */}
|
||||
{isOwner && authUser && (
|
||||
<PostComposer user={authUser} onPosted={handlePosted} />
|
||||
)}
|
||||
|
||||
{/* Skeletons while loading */}
|
||||
{!loaded && loading && (
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => <PostCardSkeleton key={i} />)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{loaded && !loading && posts.length === 0 && (
|
||||
<EmptyPostsState isOwner={isOwner} username={username} />
|
||||
)}
|
||||
|
||||
{/* Post list */}
|
||||
{posts.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{posts.map((post) => (
|
||||
<PostCard
|
||||
key={post.id}
|
||||
post={post}
|
||||
isLoggedIn={!!authUser}
|
||||
viewerUsername={authUser?.username ?? null}
|
||||
onDelete={handleDeleted}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Load more */}
|
||||
{loaded && hasMore && (
|
||||
<div className="flex justify-center py-4">
|
||||
<button
|
||||
onClick={() => fetchFeed(page + 1)}
|
||||
disabled={loading}
|
||||
className="px-6 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 text-sm transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? (
|
||||
<><i className="fa-solid fa-spinner fa-spin mr-2" />Loading…</>
|
||||
) : 'Load more posts'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Sidebar ───────────────────────────────────────────────────────── */}
|
||||
<aside className="w-72 xl:w-80 shrink-0 hidden lg:block sticky top-20 self-start">
|
||||
<FeedSidebar
|
||||
user={user}
|
||||
profile={profile}
|
||||
stats={stats}
|
||||
followerCount={followerCount}
|
||||
recentFollowers={recentFollowers}
|
||||
socialLinks={socialLinks}
|
||||
countryName={countryName}
|
||||
isLoggedIn={!!authUser}
|
||||
onTabChange={onTabChange}
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
66
resources/js/components/profile/tabs/TabStats.jsx
Normal file
66
resources/js/components/profile/tabs/TabStats.jsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React from 'react'
|
||||
|
||||
function KpiCard({ icon, label, value, color = 'text-sky-400' }) {
|
||||
return (
|
||||
<div className="bg-white/4 ring-1 ring-white/10 rounded-2xl p-5 shadow-xl shadow-black/20 backdrop-blur flex items-center gap-4">
|
||||
<div className={`w-12 h-12 rounded-xl bg-white/5 flex items-center justify-center shrink-0 ${color}`}>
|
||||
<i className={`fa-solid ${icon} text-xl`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-white tabular-nums">{Number(value ?? 0).toLocaleString()}</p>
|
||||
<p className="text-xs text-slate-500 mt-0.5 uppercase tracking-wider">{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* TabStats
|
||||
* KPI overview cards. Charts can be added here once chart infrastructure exists.
|
||||
*/
|
||||
export default function TabStats({ stats, followerCount }) {
|
||||
const kpis = [
|
||||
{ icon: 'fa-eye', label: 'Profile Views', value: stats?.profile_views_count, color: 'text-sky-400' },
|
||||
{ icon: 'fa-images', label: 'Uploads', value: stats?.uploads_count, color: 'text-violet-400' },
|
||||
{ icon: 'fa-download', label: 'Downloads', value: stats?.downloads_received_count, color: 'text-green-400' },
|
||||
{ icon: 'fa-eye', label: 'Artwork Views', value: stats?.artwork_views_received_count, color: 'text-blue-400' },
|
||||
{ icon: 'fa-heart', label: 'Favourites Received', value: stats?.favourites_received_count, color: 'text-pink-400' },
|
||||
{ icon: 'fa-users', label: 'Followers', value: followerCount, color: 'text-amber-400' },
|
||||
{ icon: 'fa-trophy', label: 'Awards Received', value: stats?.awards_received_count, color: 'text-yellow-400' },
|
||||
{ icon: 'fa-comment', label: 'Comments Received', value: stats?.comments_received_count, color: 'text-orange-400' },
|
||||
]
|
||||
|
||||
const hasStats = stats !== null && stats !== undefined
|
||||
|
||||
return (
|
||||
<div
|
||||
id="tabpanel-stats"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-stats"
|
||||
className="pt-6"
|
||||
>
|
||||
{!hasStats ? (
|
||||
<div className="bg-white/4 ring-1 ring-white/10 rounded-2xl p-10 text-center shadow-xl shadow-black/20">
|
||||
<i className="fa-solid fa-chart-bar text-3xl text-slate-600 mb-3 block" />
|
||||
<p className="text-slate-400 font-medium">No stats available yet</p>
|
||||
<p className="text-slate-600 text-sm mt-1">Stats will appear once there is activity on this profile.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-500 mb-4 flex items-center gap-2">
|
||||
<i className="fa-solid fa-chart-bar text-green-400 fa-fw" />
|
||||
Lifetime Statistics
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
{kpis.map((kpi) => (
|
||||
<KpiCard key={kpi.label} {...kpi} />
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-slate-600 mt-6 text-center">
|
||||
More detailed analytics (charts, trends) coming soon.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -77,9 +77,13 @@ export default function NovaSelect({
|
||||
const dropH = Math.min(280, filtered.length * 38 + 52) // approx
|
||||
const openUp = spaceBelow < dropH + 8 && spaceAbove > spaceBelow
|
||||
|
||||
// Clamp horizontal position so the dropdown doesn't render off-screen or far away
|
||||
const padding = 8
|
||||
const leftClamped = Math.max(padding, Math.min(rect.left, window.innerWidth - rect.width - padding))
|
||||
|
||||
setDropPos({
|
||||
top: openUp ? rect.top - dropH - 4 : rect.bottom + 4,
|
||||
left: rect.left,
|
||||
left: leftClamped,
|
||||
width: rect.width,
|
||||
openUp,
|
||||
})
|
||||
|
||||
219
resources/js/components/upload/PublishPanel.jsx
Normal file
219
resources/js/components/upload/PublishPanel.jsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import ReadinessChecklist from './ReadinessChecklist'
|
||||
import SchedulePublishPicker from './SchedulePublishPicker'
|
||||
import Checkbox from '../../Components/ui/Checkbox'
|
||||
|
||||
/**
|
||||
* PublishPanel
|
||||
*
|
||||
* Right-sidebar panel (or mobile bottom-sheet) that shows:
|
||||
* - Thumbnail preview + title
|
||||
* - Status pill
|
||||
* - ReadinessChecklist
|
||||
* - Visibility selector
|
||||
* - Publish now / Schedule controls
|
||||
* - Primary action button
|
||||
*
|
||||
* Props mirror what UploadWizard collects.
|
||||
*/
|
||||
|
||||
const STATUS_PILL = {
|
||||
idle: null,
|
||||
initializing: { label: 'Uploading', cls: 'bg-sky-500/20 text-sky-200 border-sky-300/30' },
|
||||
uploading: { label: 'Uploading', cls: 'bg-sky-500/25 text-sky-100 border-sky-300/40' },
|
||||
finishing: { label: 'Processing', cls: 'bg-amber-500/20 text-amber-200 border-amber-300/30' },
|
||||
processing: { label: 'Processing', cls: 'bg-amber-500/20 text-amber-200 border-amber-300/30' },
|
||||
ready_to_publish: { label: 'Ready', cls: 'bg-emerald-500/20 text-emerald-100 border-emerald-300/35' },
|
||||
publishing: { label: 'Publishing…', cls: 'bg-sky-500/25 text-sky-100 border-sky-300/40' },
|
||||
complete: { label: 'Published', cls: 'bg-emerald-500/25 text-emerald-100 border-emerald-300/50' },
|
||||
scheduled: { label: 'Scheduled', cls: 'bg-violet-500/20 text-violet-200 border-violet-300/30' },
|
||||
error: { label: 'Error', cls: 'bg-red-500/20 text-red-200 border-red-300/30' },
|
||||
cancelled: { label: 'Cancelled', cls: 'bg-white/8 text-white/40 border-white/10' },
|
||||
}
|
||||
|
||||
export default function PublishPanel({
|
||||
// Asset
|
||||
primaryPreviewUrl = null,
|
||||
isArchive = false,
|
||||
screenshots = [],
|
||||
// Metadata
|
||||
metadata = {},
|
||||
// Readiness
|
||||
machineState = 'idle',
|
||||
uploadReady = false,
|
||||
canPublish = false,
|
||||
isPublishing = false,
|
||||
isArchiveRequiresScreenshot = false,
|
||||
// Publish options
|
||||
publishMode = 'now', // 'now' | 'schedule'
|
||||
scheduledAt = null,
|
||||
timezone = Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
visibility = 'public', // 'public' | 'unlisted' | 'private'
|
||||
onPublishModeChange,
|
||||
onScheduleAt,
|
||||
onVisibilityChange,
|
||||
onToggleRights,
|
||||
// Actions
|
||||
onPublish,
|
||||
onCancel,
|
||||
// Navigation helpers (for checklist quick-links)
|
||||
onGoToStep,
|
||||
}) {
|
||||
const pill = STATUS_PILL[machineState] ?? null
|
||||
const hasPreview = Boolean(primaryPreviewUrl && !isArchive)
|
||||
const hasAnyPreview = hasPreview || (isArchive && screenshots.length > 0)
|
||||
const previewSrc = hasPreview ? primaryPreviewUrl : (screenshots[0]?.preview ?? screenshots[0] ?? null)
|
||||
|
||||
const title = String(metadata.title || '').trim()
|
||||
const hasTitle = Boolean(title)
|
||||
const hasCategory = Boolean(metadata.rootCategoryId)
|
||||
const hasTag = Array.isArray(metadata.tags) && metadata.tags.length > 0
|
||||
const hasRights = Boolean(metadata.rightsAccepted)
|
||||
const hasScreenshot = !isArchiveRequiresScreenshot || screenshots.length > 0
|
||||
|
||||
const checklist = [
|
||||
{ label: 'File uploaded & processed', ok: uploadReady },
|
||||
{ label: 'Title', ok: hasTitle, onClick: () => onGoToStep?.(2) },
|
||||
{ label: 'Category', ok: hasCategory, onClick: () => onGoToStep?.(2) },
|
||||
{ label: 'Rights confirmed', ok: hasRights, onClick: () => onGoToStep?.(2) },
|
||||
...( isArchiveRequiresScreenshot
|
||||
? [{ label: 'Screenshot (required for pack)', ok: hasScreenshot, onClick: () => onGoToStep?.(1) }]
|
||||
: [] ),
|
||||
{ label: 'At least 1 tag', ok: hasTag, onClick: () => onGoToStep?.(2) },
|
||||
]
|
||||
|
||||
const publishLabel = useCallback(() => {
|
||||
if (isPublishing) return 'Publishing…'
|
||||
if (publishMode === 'schedule') return 'Schedule publish'
|
||||
return 'Publish now'
|
||||
}, [isPublishing, publishMode])
|
||||
|
||||
const canSchedulePublish =
|
||||
publishMode === 'schedule' ? Boolean(scheduledAt) && canPublish : canPublish
|
||||
|
||||
const rightsError = uploadReady && !hasRights ? 'Rights confirmation is required.' : null
|
||||
|
||||
return (
|
||||
<div className="bg-panel/80 backdrop-blur rounded-2xl shadow-xl shadow-black/40 ring-1 ring-white/10 p-5 space-y-5 h-fit">
|
||||
{/* Preview + title */}
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Thumbnail */}
|
||||
<div className="shrink-0 h-[72px] w-[72px] overflow-hidden rounded-xl ring-1 ring-white/10 bg-black/30 flex items-center justify-center">
|
||||
{previewSrc ? (
|
||||
<img
|
||||
src={previewSrc}
|
||||
alt="Artwork preview"
|
||||
className="max-h-full max-w-full object-contain"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
width={72}
|
||||
height={72}
|
||||
/>
|
||||
) : (
|
||||
<svg className="h-6 w-6 text-white/25" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title + status */}
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
<p className="truncate text-sm font-semibold text-white leading-snug">
|
||||
{hasTitle ? title : <span className="italic text-white/35">Untitled artwork</span>}
|
||||
</p>
|
||||
{pill && (
|
||||
<span className={`mt-1 inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] ${pill.cls}`}>
|
||||
{['uploading', 'initializing', 'finishing', 'processing', 'publishing'].includes(machineState) && (
|
||||
<span className="relative flex h-1.5 w-1.5 shrink-0" aria-hidden="true">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-60" />
|
||||
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-current" />
|
||||
</span>
|
||||
)}
|
||||
{pill.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t border-white/8" />
|
||||
|
||||
{/* Readiness checklist */}
|
||||
<ReadinessChecklist items={checklist} />
|
||||
|
||||
{/* Visibility */}
|
||||
<div>
|
||||
<label className="block text-[10px] uppercase tracking-wider text-white/40 mb-1.5" htmlFor="publish-visibility">
|
||||
Visibility
|
||||
</label>
|
||||
<select
|
||||
id="publish-visibility"
|
||||
value={visibility}
|
||||
onChange={(e) => onVisibilityChange?.(e.target.value)}
|
||||
disabled={!canPublish && machineState !== 'ready_to_publish'}
|
||||
className="w-full appearance-none rounded-lg border border-white/15 bg-white/8 px-3 py-1.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-sky-400/60 disabled:opacity-50"
|
||||
>
|
||||
<option value="public">Public</option>
|
||||
<option value="unlisted">Unlisted</option>
|
||||
<option value="private">Private (draft)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Schedule picker – only shows when upload is ready */}
|
||||
{uploadReady && machineState !== 'complete' && (
|
||||
<SchedulePublishPicker
|
||||
mode={publishMode}
|
||||
scheduledAt={scheduledAt}
|
||||
timezone={timezone}
|
||||
onModeChange={onPublishModeChange}
|
||||
onScheduleAt={onScheduleAt}
|
||||
disabled={!canPublish || isPublishing}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Rights confirmation (required before publish) */}
|
||||
<div>
|
||||
<Checkbox
|
||||
id="publish-rights-confirm"
|
||||
checked={Boolean(metadata.rightsAccepted)}
|
||||
onChange={(event) => onToggleRights?.(event.target.checked)}
|
||||
variant="emerald"
|
||||
size={18}
|
||||
label={<span className="text-xs text-white/85">I confirm I own the rights to this content.</span>}
|
||||
hint={<span className="text-[11px] text-white/50">Required before publishing.</span>}
|
||||
error={rightsError}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Primary action button */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canSchedulePublish || isPublishing}
|
||||
onClick={() => onPublish?.()}
|
||||
title={!canPublish ? 'Complete all requirements first' : undefined}
|
||||
className={[
|
||||
'w-full rounded-xl py-2.5 text-sm font-semibold transition',
|
||||
canSchedulePublish && !isPublishing
|
||||
? publishMode === 'schedule'
|
||||
? 'bg-violet-500/80 text-white hover:bg-violet-500 shadow-[0_4px_16px_rgba(139,92,246,0.25)]'
|
||||
: 'btn-primary'
|
||||
: 'cursor-not-allowed bg-white/8 text-white/35 ring-1 ring-white/10',
|
||||
].join(' ')}
|
||||
>
|
||||
{publishLabel()}
|
||||
</button>
|
||||
|
||||
{/* Cancel link */}
|
||||
{onCancel && machineState !== 'idle' && machineState !== 'complete' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="w-full text-center text-xs text-white/35 hover:text-white/70 transition"
|
||||
>
|
||||
Cancel upload
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
55
resources/js/components/upload/ReadinessChecklist.jsx
Normal file
55
resources/js/components/upload/ReadinessChecklist.jsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React from 'react'
|
||||
|
||||
/**
|
||||
* ReadinessChecklist
|
||||
*
|
||||
* Shows upload readiness requirements with status icons.
|
||||
* Each item can have an optional `href` to jump to the section for a quick fix.
|
||||
*/
|
||||
export default function ReadinessChecklist({ items = [] }) {
|
||||
const allOk = items.every((item) => item.ok)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-2 text-[10px] uppercase tracking-wider text-white/40">
|
||||
Readiness
|
||||
</p>
|
||||
<ul className="space-y-1" role="list">
|
||||
{items.map((item) => (
|
||||
<li key={item.label} className="flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className={[
|
||||
'flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-[9px] font-bold',
|
||||
item.ok
|
||||
? 'bg-emerald-500/25 text-emerald-300'
|
||||
: 'bg-white/8 text-white/30',
|
||||
].join(' ')}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{item.ok ? '✓' : '○'}
|
||||
</span>
|
||||
<span className={item.ok ? 'text-white/70' : 'text-white/40'}>
|
||||
{(item.onClick || item.href) && !item.ok ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={item.onClick}
|
||||
className="text-sky-400 hover:underline focus-visible:outline-none focus-visible:underline"
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
) : (
|
||||
item.label
|
||||
)}
|
||||
</span>
|
||||
{item.optional && !item.ok && (
|
||||
<span className="ml-auto text-[9px] text-white/25 italic">optional</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{allOk && (
|
||||
<p className="mt-2 text-[11px] text-emerald-300/80">All requirements met.</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
219
resources/js/components/upload/SchedulePublishPicker.jsx
Normal file
219
resources/js/components/upload/SchedulePublishPicker.jsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
/**
|
||||
* SchedulePublishPicker
|
||||
*
|
||||
* Toggle between "Publish now" and "Schedule publish".
|
||||
* When scheduled, shows a date + time input with validation
|
||||
* (must be >= now + 5 minutes).
|
||||
*
|
||||
* Props:
|
||||
* mode 'now' | 'schedule'
|
||||
* scheduledAt ISO string | null – current scheduled datetime (UTC)
|
||||
* timezone string – IANA tz (e.g. 'Europe/Ljubljana')
|
||||
* onModeChange (mode) => void
|
||||
* onScheduleAt (iso | null) => void
|
||||
* disabled bool
|
||||
*/
|
||||
function toLocalDateTimeString(isoString, tz) {
|
||||
if (!isoString) return { date: '', time: '' }
|
||||
try {
|
||||
const d = new Date(isoString)
|
||||
const opts = { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }
|
||||
const dateStr = new Intl.DateTimeFormat('en-CA', opts).format(d) // en-CA gives YYYY-MM-DD
|
||||
const timeStr = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: tz,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(d)
|
||||
return { date: dateStr, time: timeStr }
|
||||
} catch {
|
||||
return { date: '', time: '' }
|
||||
}
|
||||
}
|
||||
|
||||
function formatPreviewLabel(isoString, tz) {
|
||||
if (!isoString) return null
|
||||
try {
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: tz,
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
timeZoneName: 'short',
|
||||
}).format(new Date(isoString))
|
||||
} catch {
|
||||
return isoString
|
||||
}
|
||||
}
|
||||
|
||||
function localToUtcIso(dateStr, timeStr, tz) {
|
||||
if (!dateStr || !timeStr) return null
|
||||
try {
|
||||
const dtStr = `${dateStr}T${timeStr}:00`
|
||||
const local = new Date(
|
||||
new Date(dtStr).toLocaleString('en-US', { timeZone: tz })
|
||||
)
|
||||
const utcOffset = new Date(dtStr) - local
|
||||
const utcDate = new Date(new Date(dtStr).getTime() + utcOffset)
|
||||
return utcDate.toISOString()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const MIN_FUTURE_MS = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
export default function SchedulePublishPicker({
|
||||
mode = 'now',
|
||||
scheduledAt = null,
|
||||
timezone = Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
onModeChange,
|
||||
onScheduleAt,
|
||||
disabled = false,
|
||||
}) {
|
||||
const initial = useMemo(
|
||||
() => toLocalDateTimeString(scheduledAt, timezone),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[]
|
||||
)
|
||||
const [dateStr, setDateStr] = useState(initial.date || '')
|
||||
const [timeStr, setTimeStr] = useState(initial.time || '')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const validate = useCallback(
|
||||
(d, t) => {
|
||||
if (!d || !t) return 'Date and time are required.'
|
||||
const iso = localToUtcIso(d, t, timezone)
|
||||
if (!iso) return 'Invalid date or time.'
|
||||
const target = new Date(iso)
|
||||
if (Number.isNaN(target.getTime())) return 'Invalid date or time.'
|
||||
if (target.getTime() - Date.now() < MIN_FUTURE_MS) {
|
||||
return 'Scheduled time must be at least 5 minutes in the future.'
|
||||
}
|
||||
return ''
|
||||
},
|
||||
[timezone]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== 'schedule') {
|
||||
setError('')
|
||||
return
|
||||
}
|
||||
if (!dateStr && !timeStr) {
|
||||
setError('')
|
||||
onScheduleAt?.(null)
|
||||
return
|
||||
}
|
||||
const err = validate(dateStr, timeStr)
|
||||
setError(err)
|
||||
if (!err) {
|
||||
onScheduleAt?.(localToUtcIso(dateStr, timeStr, timezone))
|
||||
} else {
|
||||
onScheduleAt?.(null)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dateStr, timeStr, mode])
|
||||
|
||||
const previewLabel = useMemo(() => {
|
||||
if (mode !== 'schedule' || error) return null
|
||||
const iso = localToUtcIso(dateStr, timeStr, timezone)
|
||||
return formatPreviewLabel(iso, timezone)
|
||||
}, [mode, error, dateStr, timeStr, timezone])
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2" role="group" aria-label="Publish mode">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
onModeChange?.('now')
|
||||
setError('')
|
||||
}}
|
||||
className={[
|
||||
'flex-1 rounded-lg border py-2 text-sm transition',
|
||||
mode === 'now'
|
||||
? 'border-sky-300/60 bg-sky-500/25 text-white'
|
||||
: 'border-white/15 bg-white/6 text-white/60 hover:bg-white/10',
|
||||
disabled ? 'cursor-not-allowed opacity-50' : '',
|
||||
].join(' ')}
|
||||
aria-pressed={mode === 'now'}
|
||||
>
|
||||
Publish now
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onModeChange?.('schedule')}
|
||||
className={[
|
||||
'flex-1 rounded-lg border py-2 text-sm transition',
|
||||
mode === 'schedule'
|
||||
? 'border-sky-300/60 bg-sky-500/25 text-white'
|
||||
: 'border-white/15 bg-white/6 text-white/60 hover:bg-white/10',
|
||||
disabled ? 'cursor-not-allowed opacity-50' : '',
|
||||
].join(' ')}
|
||||
aria-pressed={mode === 'schedule'}
|
||||
>
|
||||
Schedule
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode === 'schedule' && (
|
||||
<div className="space-y-2 rounded-xl border border-white/10 bg-white/[0.03] p-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<div className="flex-1">
|
||||
<label className="block text-[10px] uppercase tracking-wide text-white/40 mb-1" htmlFor="schedule-date">
|
||||
Date
|
||||
</label>
|
||||
<input
|
||||
id="schedule-date"
|
||||
type="date"
|
||||
disabled={disabled}
|
||||
value={dateStr}
|
||||
onChange={(e) => setDateStr(e.target.value)}
|
||||
min={new Date().toISOString().slice(0, 10)}
|
||||
className="w-full rounded-lg border border-white/15 bg-white/8 px-3 py-1.5 text-sm text-white placeholder-white/30 focus:outline-none focus:ring-1 focus:ring-sky-400/60 disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-28 shrink-0">
|
||||
<label className="block text-[10px] uppercase tracking-wide text-white/40 mb-1" htmlFor="schedule-time">
|
||||
Time
|
||||
</label>
|
||||
<input
|
||||
id="schedule-time"
|
||||
type="time"
|
||||
disabled={disabled}
|
||||
value={timeStr}
|
||||
onChange={(e) => setTimeStr(e.target.value)}
|
||||
className="w-full rounded-lg border border-white/15 bg-white/8 px-3 py-1.5 text-sm text-white placeholder-white/30 focus:outline-none focus:ring-1 focus:ring-sky-400/60 disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-white/35">
|
||||
Timezone: <span className="text-white/55">{timezone}</span>
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-red-400" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{previewLabel && (
|
||||
<p className="text-xs text-emerald-300/80">
|
||||
Will publish on: <span className="font-medium">{previewLabel}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
141
resources/js/components/upload/StudioStatusBar.jsx
Normal file
141
resources/js/components/upload/StudioStatusBar.jsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import React from 'react'
|
||||
import { motion, useReducedMotion } from 'framer-motion'
|
||||
|
||||
/**
|
||||
* StudioStatusBar
|
||||
*
|
||||
* Sticky header beneath the main nav that shows:
|
||||
* - Step pills (reuse UploadStepper visual style but condensed)
|
||||
* - Upload progress bar (visible while uploading/processing)
|
||||
* - Machine-state pill
|
||||
* - Back / Next primary actions
|
||||
*/
|
||||
const STATE_LABELS = {
|
||||
idle: null,
|
||||
initializing: 'Initializing…',
|
||||
uploading: 'Uploading',
|
||||
finishing: 'Finishing…',
|
||||
processing: 'Processing',
|
||||
ready_to_publish: 'Ready',
|
||||
publishing: 'Publishing…',
|
||||
complete: 'Published',
|
||||
error: 'Error',
|
||||
cancelled: 'Cancelled',
|
||||
}
|
||||
|
||||
const STATE_COLORS = {
|
||||
idle: '',
|
||||
initializing: 'bg-sky-500/20 text-sky-200 border-sky-300/30',
|
||||
uploading: 'bg-sky-500/25 text-sky-100 border-sky-300/40',
|
||||
finishing: 'bg-sky-400/20 text-sky-200 border-sky-300/30',
|
||||
processing: 'bg-amber-500/20 text-amber-100 border-amber-300/30',
|
||||
ready_to_publish: 'bg-emerald-500/20 text-emerald-100 border-emerald-300/35',
|
||||
publishing: 'bg-sky-500/25 text-sky-100 border-sky-300/40',
|
||||
complete: 'bg-emerald-500/25 text-emerald-100 border-emerald-300/50',
|
||||
error: 'bg-red-500/20 text-red-200 border-red-300/30',
|
||||
cancelled: 'bg-white/8 text-white/50 border-white/15',
|
||||
}
|
||||
|
||||
export default function StudioStatusBar({
|
||||
steps = [],
|
||||
activeStep = 1,
|
||||
highestUnlockedStep = 1,
|
||||
machineState = 'idle',
|
||||
progress = 0,
|
||||
showProgress = false,
|
||||
onStepClick,
|
||||
}) {
|
||||
const prefersReducedMotion = useReducedMotion()
|
||||
const transition = prefersReducedMotion ? { duration: 0 } : { duration: 0.3, ease: 'easeOut' }
|
||||
const stateLabel = STATE_LABELS[machineState] ?? machineState
|
||||
const stateColor = STATE_COLORS[machineState] ?? 'bg-white/8 text-white/50 border-white/15'
|
||||
|
||||
return (
|
||||
<div className="sticky top-0 z-20 -mx-4 px-4 pt-2 pb-0 sm:-mx-6 sm:px-6">
|
||||
{/* Blur backdrop */}
|
||||
<div className="absolute inset-0 bg-slate-950/80 backdrop-blur-md" aria-hidden="true" />
|
||||
|
||||
<div className="relative">
|
||||
{/* Step pills row */}
|
||||
<nav aria-label="Upload steps">
|
||||
<ol className="flex flex-nowrap items-center gap-2 overflow-x-auto py-3 pr-1 sm:gap-3">
|
||||
{steps.map((step, index) => {
|
||||
const number = index + 1
|
||||
const isActive = number === activeStep
|
||||
const isComplete = number < activeStep
|
||||
const isLocked = number > highestUnlockedStep
|
||||
const canNavigate = !isLocked && number < activeStep
|
||||
|
||||
const btnClass = [
|
||||
'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] sm:text-xs transition',
|
||||
isActive
|
||||
? 'border-sky-300/70 bg-sky-500/25 text-white'
|
||||
: isComplete
|
||||
? 'border-emerald-300/30 bg-emerald-500/15 text-emerald-100 hover:bg-emerald-500/25 cursor-pointer'
|
||||
: isLocked
|
||||
? 'cursor-default border-white/10 bg-white/5 text-white/35 pointer-events-none'
|
||||
: 'border-white/15 bg-white/6 text-white/70 hover:bg-white/12 cursor-pointer',
|
||||
].join(' ')
|
||||
|
||||
const circleClass = isComplete
|
||||
? 'border-emerald-300/50 bg-emerald-500/20 text-emerald-100'
|
||||
: isActive
|
||||
? 'border-sky-300/50 bg-sky-500/25 text-white'
|
||||
: 'border-white/20 bg-white/6 text-white/60'
|
||||
|
||||
return (
|
||||
<li key={step.key} className="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => canNavigate && onStepClick?.(number)}
|
||||
disabled={isLocked}
|
||||
aria-disabled={isLocked}
|
||||
aria-current={isActive ? 'step' : undefined}
|
||||
className={btnClass}
|
||||
>
|
||||
<span className={`grid h-4 w-4 place-items-center rounded-full border text-[10px] shrink-0 ${circleClass}`}>
|
||||
{isComplete ? '✓' : number}
|
||||
</span>
|
||||
<span className="whitespace-nowrap">{step.label}</span>
|
||||
</button>
|
||||
{index < steps.length - 1 && (
|
||||
<span className="text-white/30 select-none text-xs" aria-hidden="true">›</span>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Spacer */}
|
||||
<li className="flex-1" aria-hidden="true" />
|
||||
|
||||
{/* State pill */}
|
||||
{stateLabel && (
|
||||
<li className="shrink-0">
|
||||
<span className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] ${stateColor}`}>
|
||||
{['uploading', 'initializing', 'finishing', 'processing', 'publishing'].includes(machineState) && (
|
||||
<span className="relative flex h-2 w-2 shrink-0" aria-hidden="true">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-300 opacity-60" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-sky-300" />
|
||||
</span>
|
||||
)}
|
||||
{stateLabel}
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
{/* Progress bar (shown during upload/processing) */}
|
||||
{showProgress && (
|
||||
<div className="h-0.5 w-full overflow-hidden rounded-full bg-white/8">
|
||||
<motion.div
|
||||
className="h-full rounded-full bg-gradient-to-r from-sky-400 via-cyan-300 to-emerald-300"
|
||||
animate={{ width: `${progress}%` }}
|
||||
transition={transition}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
217
resources/js/components/upload/UploadOverlay.jsx
Normal file
217
resources/js/components/upload/UploadOverlay.jsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import React from 'react'
|
||||
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
|
||||
|
||||
/**
|
||||
* UploadOverlay
|
||||
*
|
||||
* A frosted-glass floating panel that rises from the bottom of the step content
|
||||
* area while an upload or processing job is in flight.
|
||||
*
|
||||
* Shows:
|
||||
* - State icon + label + live percentage
|
||||
* - Thick animated progress bar with gradient
|
||||
* - Processing transparency label (what the backend is doing)
|
||||
* - Error strip with Retry / Reset when something goes wrong
|
||||
*/
|
||||
|
||||
const ACTIVE_STATES = new Set([
|
||||
'initializing',
|
||||
'uploading',
|
||||
'finishing',
|
||||
'processing',
|
||||
])
|
||||
|
||||
const STATE_META = {
|
||||
initializing: {
|
||||
label: 'Initializing',
|
||||
sublabel: 'Preparing your upload…',
|
||||
color: 'text-sky-300',
|
||||
barColor: 'from-sky-500 via-sky-400 to-cyan-300',
|
||||
icon: (
|
||||
<svg className="h-4 w-4 shrink-0 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle className="opacity-20" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
|
||||
<path className="opacity-80" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
uploading: {
|
||||
label: 'Uploading',
|
||||
sublabel: 'Sending your file to the server…',
|
||||
color: 'text-sky-300',
|
||||
barColor: 'from-sky-500 via-cyan-400 to-teal-300',
|
||||
icon: (
|
||||
<svg className="h-4 w-4 shrink-0" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M10 3a1 1 0 011 1v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 111.414-1.414L9 11.586V4a1 1 0 011-1zM3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
finishing: {
|
||||
label: 'Finishing',
|
||||
sublabel: 'Wrapping up the transfer…',
|
||||
color: 'text-cyan-300',
|
||||
barColor: 'from-cyan-500 via-teal-400 to-emerald-300',
|
||||
icon: (
|
||||
<svg className="h-4 w-4 shrink-0 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle className="opacity-20" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
|
||||
<path className="opacity-80" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
processing: {
|
||||
label: 'Processing',
|
||||
sublabel: 'Analyzing your artwork…',
|
||||
color: 'text-amber-300',
|
||||
barColor: 'from-amber-500 via-yellow-400 to-lime-300',
|
||||
icon: (
|
||||
<svg className="h-4 w-4 shrink-0" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clipRule="evenodd" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
error: {
|
||||
label: 'Upload failed',
|
||||
sublabel: null,
|
||||
color: 'text-rose-300',
|
||||
barColor: 'from-rose-600 via-rose-500 to-rose-400',
|
||||
icon: (
|
||||
<svg className="h-4 w-4 shrink-0" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
export default function UploadOverlay({
|
||||
machineState = 'idle',
|
||||
progress = 0,
|
||||
processingLabel = null,
|
||||
error = null,
|
||||
onRetry,
|
||||
onReset,
|
||||
}) {
|
||||
const prefersReducedMotion = useReducedMotion()
|
||||
|
||||
const isVisible = ACTIVE_STATES.has(machineState) || machineState === 'error'
|
||||
const meta = STATE_META[machineState] ?? STATE_META.uploading
|
||||
|
||||
const barTransition = prefersReducedMotion
|
||||
? { duration: 0 }
|
||||
: { duration: 0.4, ease: 'easeOut' }
|
||||
|
||||
const overlayTransition = prefersReducedMotion
|
||||
? { duration: 0 }
|
||||
: { duration: 0.25, ease: [0.32, 0.72, 0, 1] }
|
||||
|
||||
const displayLabel = processingLabel || meta.sublabel
|
||||
|
||||
return (
|
||||
<AnimatePresence initial={false}>
|
||||
{isVisible && (
|
||||
<motion.div
|
||||
key="upload-overlay"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label={`${meta.label}${progress > 0 ? ` — ${progress}%` : ''}`}
|
||||
initial={prefersReducedMotion ? false : { opacity: 0, y: 24, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={prefersReducedMotion ? {} : { opacity: 0, y: 16, scale: 0.98 }}
|
||||
transition={overlayTransition}
|
||||
className="absolute inset-x-0 bottom-0 z-30 pointer-events-none"
|
||||
>
|
||||
{/* Fade-out gradient so step content peeks through above */}
|
||||
<div
|
||||
className="absolute inset-x-0 -top-12 h-12 bg-gradient-to-t from-slate-950/70 to-transparent pointer-events-none rounded-b-2xl"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="pointer-events-auto mx-0 rounded-b-2xl rounded-t-xl border border-white/10 bg-slate-950/88 px-5 pb-5 pt-4 shadow-2xl shadow-black/70 ring-1 ring-inset ring-white/6 backdrop-blur-xl">
|
||||
{/* ── Header: icon + state label + percentage ── */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className={`flex items-center gap-2 ${meta.color}`}>
|
||||
{meta.icon}
|
||||
<span className="text-sm font-semibold tracking-wide">
|
||||
{meta.label}
|
||||
</span>
|
||||
{/* Pulsing dot for active states */}
|
||||
{machineState !== 'error' && (
|
||||
<span className="relative flex h-2 w-2 shrink-0" aria-hidden="true">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-50" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-current opacity-80" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{machineState !== 'error' && (
|
||||
<span className={`tabular-nums text-sm font-bold ${meta.color}`}>
|
||||
{progress}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Progress bar ── */}
|
||||
<div className="mt-3 h-2 w-full overflow-hidden rounded-full bg-white/8">
|
||||
<motion.div
|
||||
className={`h-full rounded-full bg-gradient-to-r ${meta.barColor}`}
|
||||
animate={{ width: machineState === 'error' ? '100%' : `${progress}%` }}
|
||||
transition={barTransition}
|
||||
style={machineState === 'error' ? { opacity: 0.35 } : {}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Sublabel / transparency message ── */}
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{machineState !== 'error' && displayLabel && (
|
||||
<motion.p
|
||||
key={displayLabel}
|
||||
initial={prefersReducedMotion ? false : { opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={prefersReducedMotion ? {} : { opacity: 0, y: -4 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="mt-2 text-xs text-white/50"
|
||||
>
|
||||
{displayLabel}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* ── Error details + actions ── */}
|
||||
<AnimatePresence initial={false}>
|
||||
{machineState === 'error' && (
|
||||
<motion.div
|
||||
key="error-block"
|
||||
initial={prefersReducedMotion ? false : { opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={prefersReducedMotion ? {} : { opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="mt-3 rounded-lg border border-rose-400/20 bg-rose-500/10 px-3 py-2.5">
|
||||
<p className="text-xs text-rose-200 leading-relaxed">
|
||||
{error || 'Something went wrong. You can retry safely.'}
|
||||
</p>
|
||||
<div className="mt-2.5 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="rounded-md border border-rose-300/30 bg-rose-400/15 px-3 py-1 text-xs font-medium text-rose-100 transition hover:bg-rose-400/25 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-300/60"
|
||||
>
|
||||
Retry upload
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReset}
|
||||
className="rounded-md border border-white/20 bg-white/8 px-3 py-1 text-xs font-medium text-white/60 transition hover:bg-white/14 hover:text-white/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40"
|
||||
>
|
||||
Start over
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,187 @@
|
||||
import React from 'react'
|
||||
import React, { useCallback, useRef, useState } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import TagPicker from '../tags/TagPicker'
|
||||
import Checkbox from '../../Components/ui/Checkbox'
|
||||
|
||||
function ToolbarButton({ title, onClick, children }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
onClick()
|
||||
}}
|
||||
className="inline-flex h-7 min-w-7 items-center justify-center rounded-md px-1.5 text-xs font-semibold text-white/55 transition hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function MarkdownEditor({ id, value, onChange, placeholder, error }) {
|
||||
const [tab, setTab] = useState('write')
|
||||
const textareaRef = useRef(null)
|
||||
|
||||
const wrapSelection = useCallback((before, after) => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
|
||||
const current = String(value || '')
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const selected = current.slice(start, end)
|
||||
const replacement = before + (selected || 'text') + after
|
||||
const next = current.slice(0, start) + replacement + current.slice(end)
|
||||
onChange?.(next)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus()
|
||||
textarea.selectionStart = selected ? start + replacement.length : start + before.length
|
||||
textarea.selectionEnd = selected ? start + replacement.length : start + before.length + 4
|
||||
})
|
||||
}, [onChange, value])
|
||||
|
||||
const prefixLines = useCallback((prefix) => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
|
||||
const current = String(value || '')
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const selected = current.slice(start, end)
|
||||
const lines = (selected || '').split('\n')
|
||||
const normalized = (lines.length ? lines : ['']).map((line) => `${prefix}${line}`).join('\n')
|
||||
const next = current.slice(0, start) + normalized + current.slice(end)
|
||||
onChange?.(next)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus()
|
||||
textarea.selectionStart = start
|
||||
textarea.selectionEnd = start + normalized.length
|
||||
})
|
||||
}, [onChange, value])
|
||||
|
||||
const insertLink = useCallback(() => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
|
||||
const current = String(value || '')
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const selected = current.slice(start, end)
|
||||
const replacement = selected && /^https?:\/\//i.test(selected)
|
||||
? `[link](${selected})`
|
||||
: `[${selected || 'link'}](https://)`
|
||||
const next = current.slice(0, start) + replacement + current.slice(end)
|
||||
onChange?.(next)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus()
|
||||
})
|
||||
}, [onChange, value])
|
||||
|
||||
const handleKeyDown = useCallback((event) => {
|
||||
const withModifier = event.ctrlKey || event.metaKey
|
||||
if (!withModifier) return
|
||||
|
||||
switch (event.key.toLowerCase()) {
|
||||
case 'b':
|
||||
event.preventDefault()
|
||||
wrapSelection('**', '**')
|
||||
break
|
||||
case 'i':
|
||||
event.preventDefault()
|
||||
wrapSelection('*', '*')
|
||||
break
|
||||
case 'k':
|
||||
event.preventDefault()
|
||||
insertLink()
|
||||
break
|
||||
case 'e':
|
||||
event.preventDefault()
|
||||
wrapSelection('`', '`')
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}, [insertLink, wrapSelection])
|
||||
|
||||
return (
|
||||
<div className={`mt-2 rounded-xl border bg-white/10 ${error ? 'border-red-300/60' : 'border-white/15'}`}>
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-2 py-1.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('write')}
|
||||
className={`rounded-md px-2.5 py-1 text-xs font-medium transition ${tab === 'write' ? 'bg-white/12 text-white' : 'text-white/55 hover:text-white/80'}`}
|
||||
>
|
||||
Write
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('preview')}
|
||||
className={`rounded-md px-2.5 py-1 text-xs font-medium transition ${tab === 'preview' ? 'bg-white/12 text-white' : 'text-white/55 hover:text-white/80'}`}
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tab === 'write' && (
|
||||
<>
|
||||
<div className="flex items-center gap-1 border-b border-white/10 px-2 py-1">
|
||||
<ToolbarButton title="Bold (Ctrl+B)" onClick={() => wrapSelection('**', '**')}>B</ToolbarButton>
|
||||
<ToolbarButton title="Italic (Ctrl+I)" onClick={() => wrapSelection('*', '*')}>I</ToolbarButton>
|
||||
<ToolbarButton title="Inline code (Ctrl+E)" onClick={() => wrapSelection('`', '`')}>{'</>'}</ToolbarButton>
|
||||
<ToolbarButton title="Link (Ctrl+K)" onClick={insertLink}>Link</ToolbarButton>
|
||||
<span className="mx-1 h-4 w-px bg-white/10" aria-hidden="true" />
|
||||
<ToolbarButton title="Bulleted list" onClick={() => prefixLines('- ')}>• List</ToolbarButton>
|
||||
<ToolbarButton title="Quote" onClick={() => prefixLines('> ')}>❝</ToolbarButton>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
id={id}
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(event) => onChange?.(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={5}
|
||||
className="w-full resize-y bg-transparent px-3 py-2 text-sm text-white placeholder-white/45 focus:outline-none"
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
|
||||
<p className="px-3 pb-2 text-[11px] text-white/45">
|
||||
Markdown supported · Ctrl+B bold · Ctrl+I italic · Ctrl+K link
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'preview' && (
|
||||
<div className="min-h-[132px] px-3 py-2">
|
||||
{String(value || '').trim() ? (
|
||||
<div className="prose prose-invert prose-sm max-w-none text-white/85 [&_a]:text-sky-300 [&_a]:no-underline hover:[&_a]:underline [&_blockquote]:border-l-2 [&_blockquote]:border-white/20 [&_blockquote]:pl-3 [&_code]:rounded [&_code]:bg-white/10 [&_code]:px-1 [&_code]:py-0.5 [&_ul]:pl-4 [&_ol]:pl-4">
|
||||
<ReactMarkdown
|
||||
allowedElements={['p', 'strong', 'em', 'a', 'code', 'pre', 'ul', 'ol', 'li', 'blockquote', 'br']}
|
||||
unwrapDisallowed
|
||||
components={{
|
||||
a: ({ href, children }) => (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer nofollow">{children}</a>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{String(value || '')}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm italic text-white/35">Nothing to preview</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function UploadSidebar({
|
||||
title = 'Artwork details',
|
||||
description = 'Complete metadata before publishing',
|
||||
@@ -44,15 +224,15 @@ export default function UploadSidebar({
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium text-white/90">Description</span>
|
||||
<textarea
|
||||
<span className="text-sm font-medium text-white/90">Description <span className="text-red-300">*</span></span>
|
||||
<MarkdownEditor
|
||||
id="upload-sidebar-description"
|
||||
value={metadata.description}
|
||||
onChange={(event) => onChangeDescription?.(event.target.value)}
|
||||
rows={5}
|
||||
className="mt-2 w-full rounded-xl border border-white/15 bg-white/10 px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-sky-300/70"
|
||||
onChange={onChangeDescription}
|
||||
placeholder="Describe your artwork (Markdown supported)."
|
||||
error={errors.description}
|
||||
/>
|
||||
{errors.description && <p className="mt-1 text-xs text-red-200">{errors.description}</p>}
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -15,8 +15,10 @@ import useUploadMachine, { machineStates } from '../../hooks/upload/useUploadMac
|
||||
import useFileValidation from '../../hooks/upload/useFileValidation'
|
||||
import useVisionTags from '../../hooks/upload/useVisionTags'
|
||||
|
||||
import UploadStepper from './UploadStepper'
|
||||
import StudioStatusBar from './StudioStatusBar'
|
||||
import UploadOverlay from './UploadOverlay'
|
||||
import UploadActions from './UploadActions'
|
||||
import PublishPanel from './PublishPanel'
|
||||
import Step1FileUpload from './steps/Step1FileUpload'
|
||||
import Step2Details from './steps/Step2Details'
|
||||
import Step3Publish from './steps/Step3Publish'
|
||||
@@ -24,6 +26,7 @@ import Step3Publish from './steps/Step3Publish'
|
||||
import {
|
||||
buildCategoryTree,
|
||||
getContentTypeValue,
|
||||
getProcessingTransparencyLabel,
|
||||
} from '../../lib/uploadUtils'
|
||||
|
||||
// ─── Wizard step config ───────────────────────────────────────────────────────
|
||||
@@ -59,6 +62,7 @@ export default function UploadWizard({
|
||||
contentTypes = [],
|
||||
suggestedTags = [],
|
||||
}) {
|
||||
const [notices, setNotices] = useState([])
|
||||
// ── UI state ──────────────────────────────────────────────────────────────
|
||||
const [activeStep, setActiveStep] = useState(1)
|
||||
const [showRestoredBanner, setShowRestoredBanner] = useState(Boolean(initialDraftId))
|
||||
@@ -68,6 +72,15 @@ export default function UploadWizard({
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : null
|
||||
})
|
||||
|
||||
// ── Publish options (Studio) ──────────────────────────────────────────────
|
||||
const [publishMode, setPublishMode] = useState('now') // 'now' | 'schedule'
|
||||
const [scheduledAt, setScheduledAt] = useState(null) // UTC ISO or null
|
||||
const [visibility, setVisibility] = useState('public') // 'public'|'unlisted'|'private'
|
||||
const [showMobilePublishPanel, setShowMobilePublishPanel] = useState(false)
|
||||
const userTimezone = useMemo(() => {
|
||||
try { return Intl.DateTimeFormat().resolvedOptions().timeZone } catch { return 'UTC' }
|
||||
}, [])
|
||||
|
||||
// ── File + screenshot state ───────────────────────────────────────────────
|
||||
const [primaryFile, setPrimaryFile] = useState(null)
|
||||
const [screenshots, setScreenshots] = useState([])
|
||||
@@ -117,6 +130,17 @@ export default function UploadWizard({
|
||||
metadata,
|
||||
chunkSize,
|
||||
onArtworkCreated: (id) => setResolvedArtworkId(id),
|
||||
onNotice: (notice) => {
|
||||
if (!notice?.message) return
|
||||
const normalizedType = ['success', 'warning', 'error'].includes(String(notice.type || '').toLowerCase())
|
||||
? String(notice.type).toLowerCase()
|
||||
: 'error'
|
||||
const id = `${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
setNotices((prev) => [...prev, { id, type: normalizedType, message: String(notice.message) }])
|
||||
window.setTimeout(() => {
|
||||
setNotices((prev) => prev.filter((item) => item.id !== id))
|
||||
}, 4500)
|
||||
},
|
||||
})
|
||||
|
||||
// ── Upload-ready flag (needed before vision hook) ─────────────────────────
|
||||
@@ -177,12 +201,14 @@ export default function UploadWizard({
|
||||
const metadataErrors = useMemo(() => {
|
||||
const errors = {}
|
||||
if (!String(metadata.title || '').trim()) errors.title = 'Title is required.'
|
||||
if (!String(metadata.description || '').trim()) errors.description = 'Description is required.'
|
||||
if (!metadata.contentType) errors.contentType = 'Content type is required.'
|
||||
if (!metadata.rootCategoryId) errors.category = 'Root category is required.'
|
||||
if (metadata.rootCategoryId && requiresSubCategory && !metadata.subCategoryId) {
|
||||
errors.category = 'Subcategory is required for the selected category.'
|
||||
}
|
||||
if (!metadata.rightsAccepted) errors.rights = 'Rights confirmation is required.'
|
||||
if (!Array.isArray(metadata.tags) || metadata.tags.length === 0) errors.tags = 'At least one tag is required.'
|
||||
return errors
|
||||
}, [metadata, requiresSubCategory])
|
||||
|
||||
@@ -212,6 +238,8 @@ export default function UploadWizard({
|
||||
// ── Derived flags ─────────────────────────────────────────────────────────
|
||||
const highestUnlockedStep = uploadReady ? (detailsValid ? 3 : 2) : 1
|
||||
const showProgress = ![machineStates.idle, machineStates.cancelled].includes(machine.state)
|
||||
const processingLabel = getProcessingTransparencyLabel(machine.processingStatus, machine.state)
|
||||
const showOverlay = ['initializing', 'uploading', 'finishing', 'processing', 'error'].includes(machine.state)
|
||||
|
||||
const canPublish = useMemo(() => (
|
||||
uploadReady &&
|
||||
@@ -219,11 +247,11 @@ export default function UploadWizard({
|
||||
machine.state !== machineStates.publishing
|
||||
), [uploadReady, metadata.rightsAccepted, machine.state])
|
||||
|
||||
const stepProgressPercent = useMemo(() => {
|
||||
if (activeStep === 1) return 33
|
||||
if (activeStep === 2) return 66
|
||||
return 100
|
||||
}, [activeStep])
|
||||
const canScheduleSubmit = useMemo(() => {
|
||||
if (!canPublish) return false
|
||||
if (publishMode === 'schedule') return Boolean(scheduledAt)
|
||||
return true
|
||||
}, [canPublish, publishMode, scheduledAt])
|
||||
|
||||
// ── Validation surface for parent ────────────────────────────────────────
|
||||
const validationErrors = useMemo(
|
||||
@@ -269,7 +297,13 @@ export default function UploadWizard({
|
||||
clearPolling()
|
||||
}
|
||||
}, [abortAllRequests, clearPolling])
|
||||
|
||||
// ── ESC key closes mobile drawer (spec §7) ─────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!showMobilePublishPanel) return
|
||||
const handler = (e) => { if (e.key === 'Escape') setShowMobilePublishPanel(false) }
|
||||
document.addEventListener('keydown', handler)
|
||||
return () => document.removeEventListener('keydown', handler)
|
||||
}, [showMobilePublishPanel])
|
||||
// ── Metadata helpers ──────────────────────────────────────────────────────
|
||||
const setMeta = useCallback((patch) => setMetadata((prev) => ({ ...prev, ...patch })), [])
|
||||
|
||||
@@ -281,6 +315,10 @@ export default function UploadWizard({
|
||||
setMetadata(initialMetadata)
|
||||
setIsUploadLocked(false)
|
||||
hasAutoAdvancedRef.current = false
|
||||
setPublishMode('now')
|
||||
setScheduledAt(null)
|
||||
setVisibility('public')
|
||||
setShowMobilePublishPanel(false)
|
||||
setResolvedArtworkId(() => {
|
||||
const parsed = Number(initialDraftId)
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : null
|
||||
@@ -296,36 +334,45 @@ export default function UploadWizard({
|
||||
const renderStepContent = () => {
|
||||
// Complete / success screen
|
||||
if (machine.state === machineStates.complete) {
|
||||
const wasScheduled = machine.lastAction === 'schedule'
|
||||
return (
|
||||
<motion.div
|
||||
initial={prefersReducedMotion ? false : { opacity: 0, scale: 0.98 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.28, ease: 'easeOut' }}
|
||||
className="rounded-2xl ring-1 ring-emerald-300/25 bg-emerald-500/8 p-8 text-center"
|
||||
className={`rounded-2xl p-8 text-center ${wasScheduled ? 'ring-1 ring-violet-300/25 bg-violet-500/8' : 'ring-1 ring-emerald-300/25 bg-emerald-500/8'}`}
|
||||
>
|
||||
<motion.div
|
||||
initial={prefersReducedMotion ? false : { scale: 0.7, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={prefersReducedMotion ? { duration: 0 } : { delay: 0.1, duration: 0.26, ease: 'backOut' }}
|
||||
className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full ring-2 ring-emerald-300/40 bg-emerald-500/20 text-emerald-200"
|
||||
className={`mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full text-2xl`}
|
||||
>
|
||||
<svg className="h-7 w-7" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{wasScheduled ? '🕐' : '🎉'}
|
||||
</motion.div>
|
||||
|
||||
<h3 className="text-xl font-semibold text-white">Your artwork is live 🎉</h3>
|
||||
<p className="mt-2 text-sm text-emerald-100/75">
|
||||
It has been published and is now visible to the community.
|
||||
<h3 className="text-xl font-semibold text-white">
|
||||
{wasScheduled ? 'Artwork scheduled!' : 'Your artwork is live!'}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-white/65">
|
||||
{wasScheduled
|
||||
? scheduledAt
|
||||
? `Will publish on ${new Intl.DateTimeFormat('en-GB', { timeZone: userTimezone, weekday: 'short', day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date(scheduledAt))}`
|
||||
: 'Your artwork is scheduled for future publishing.'
|
||||
: 'It has been published and is now visible to the community.'}
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex flex-wrap justify-center gap-3">
|
||||
<a
|
||||
href={resolvedArtworkId ? `/artwork/${resolvedArtworkId}` : '/'}
|
||||
className="rounded-lg ring-1 ring-emerald-300/45 bg-emerald-400/20 px-4 py-2 text-sm font-medium text-emerald-50 hover:bg-emerald-400/30 transition"
|
||||
>
|
||||
View artwork
|
||||
</a>
|
||||
<div className="mt-6 flex flex-wrap justify-center gap-3">
|
||||
{!wasScheduled && (
|
||||
<a
|
||||
href={resolvedArtworkId
|
||||
? `/art/${resolvedArtworkId}${machine.slug ? `/${machine.slug}` : ''}`
|
||||
: '/'}
|
||||
className="rounded-lg ring-1 ring-emerald-300/45 bg-emerald-400/20 px-4 py-2 text-sm font-medium text-emerald-50 hover:bg-emerald-400/30 transition"
|
||||
>
|
||||
View artwork
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReset}
|
||||
@@ -355,9 +402,6 @@ export default function UploadWizard({
|
||||
screenshotPerFileErrors={screenshotPerFileErrors}
|
||||
onScreenshotsChange={setScreenshots}
|
||||
machine={machine}
|
||||
showProgress={showProgress}
|
||||
onRetry={() => handleRetry(canPublish)}
|
||||
onReset={handleReset}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -400,6 +444,12 @@ export default function UploadWizard({
|
||||
metadata={metadata}
|
||||
canPublish={canPublish}
|
||||
uploadReady={uploadReady}
|
||||
publishMode={publishMode}
|
||||
scheduledAt={scheduledAt}
|
||||
timezone={userTimezone}
|
||||
visibility={visibility}
|
||||
allRootCategoryOptions={allRootCategoryOptions}
|
||||
filteredCategoryTree={filteredCategoryTree}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -407,7 +457,7 @@ export default function UploadWizard({
|
||||
// ── Action bar helpers ────────────────────────────────────────────────────
|
||||
const disableReason = (() => {
|
||||
if (activeStep === 1) return validationErrors[0] || machine.error || 'Complete upload requirements first.'
|
||||
if (activeStep === 2) return metadataErrors.title || metadataErrors.contentType || metadataErrors.category || metadataErrors.rights || 'Complete required metadata.'
|
||||
if (activeStep === 2) return metadataErrors.title || metadataErrors.contentType || metadataErrors.category || metadataErrors.rights || metadataErrors.tags || 'Complete required metadata.'
|
||||
return machine.error || 'Publish is available when upload is ready and rights are confirmed.'
|
||||
})()
|
||||
|
||||
@@ -415,9 +465,31 @@ export default function UploadWizard({
|
||||
return (
|
||||
<section
|
||||
ref={stepContentRef}
|
||||
className="space-y-6 pb-24 text-white lg:pb-0"
|
||||
className="space-y-4 pb-32 text-white lg:pb-6"
|
||||
data-is-archive={isArchive ? 'true' : 'false'}
|
||||
>
|
||||
{notices.length > 0 && (
|
||||
<div className="fixed right-4 top-4 z-[70] w-[min(92vw,420px)] space-y-2">
|
||||
{notices.map((notice) => (
|
||||
<div
|
||||
key={notice.id}
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
className={[
|
||||
'rounded-xl border px-4 py-3 text-sm shadow-lg backdrop-blur',
|
||||
notice.type === 'success'
|
||||
? 'border-emerald-400/45 bg-emerald-500/12 text-emerald-100'
|
||||
: notice.type === 'warning'
|
||||
? 'border-amber-400/45 bg-amber-500/12 text-amber-100'
|
||||
: 'border-red-400/45 bg-red-500/12 text-red-100',
|
||||
].join(' ')}
|
||||
>
|
||||
{notice.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Restored draft banner */}
|
||||
{showRestoredBanner && (
|
||||
<div className="rounded-xl ring-1 ring-sky-300/25 bg-sky-500/10 px-4 py-2.5 text-sm text-sky-100">
|
||||
@@ -434,70 +506,192 @@ export default function UploadWizard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step indicator */}
|
||||
<UploadStepper
|
||||
{/* ── Studio Status Bar (sticky step header + progress) ────────────── */}
|
||||
<StudioStatusBar
|
||||
steps={wizardSteps}
|
||||
activeStep={activeStep}
|
||||
highestUnlockedStep={highestUnlockedStep}
|
||||
machineState={machine.state}
|
||||
progress={machine.progress}
|
||||
showProgress={showProgress}
|
||||
onStepClick={goToStep}
|
||||
/>
|
||||
|
||||
{/* Thin progress bar */}
|
||||
<div className="-mt-3 rounded-full bg-white/8 p-0.5">
|
||||
<motion.div
|
||||
className="h-1.5 rounded-full bg-gradient-to-r from-sky-400/90 via-cyan-300/90 to-emerald-300/90"
|
||||
animate={{ width: `${stepProgressPercent}%` }}
|
||||
transition={quickTransition}
|
||||
/>
|
||||
{/* ── Main body: two-column on desktop ─────────────────────────────── */}
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:gap-6">
|
||||
{/* Left / main column: step content */}
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* Step content + floating progress overlay */}
|
||||
<div className={`relative transition-[padding-bottom] duration-300 ${showOverlay ? 'pb-36' : ''}`}>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={`step-${activeStep}`}
|
||||
initial={prefersReducedMotion ? false : { opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={prefersReducedMotion ? {} : { opacity: 0, y: -8 }}
|
||||
transition={quickTransition}
|
||||
>
|
||||
{renderStepContent()}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
<UploadOverlay
|
||||
machineState={machine.state}
|
||||
progress={machine.progress}
|
||||
processingLabel={processingLabel}
|
||||
error={machine.error}
|
||||
onRetry={() => handleRetry(canPublish, { mode: publishMode, publishAt: scheduledAt, timezone: userTimezone, visibility })}
|
||||
onReset={handleReset}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Wizard action bar (nav: back/next/start/retry) */}
|
||||
{machine.state !== machineStates.complete && (
|
||||
<div className="mt-4">
|
||||
<UploadActions
|
||||
step={activeStep}
|
||||
canStart={canStartUpload && [machineStates.idle, machineStates.error, machineStates.cancelled].includes(machine.state)}
|
||||
canContinue={detailsValid}
|
||||
canPublish={canScheduleSubmit}
|
||||
canGoBack={activeStep > 1 && machine.state !== machineStates.complete}
|
||||
canReset={Boolean(primaryFile || screenshots.length || metadata.title || metadata.description || metadata.tags.length)}
|
||||
canCancel={activeStep === 1 && [
|
||||
machineStates.initializing,
|
||||
machineStates.uploading,
|
||||
machineStates.finishing,
|
||||
machineStates.processing,
|
||||
].includes(machine.state)}
|
||||
canRetry={machine.state === machineStates.error}
|
||||
isUploading={[machineStates.uploading, machineStates.initializing].includes(machine.state)}
|
||||
isProcessing={[machineStates.processing, machineStates.finishing].includes(machine.state)}
|
||||
isPublishing={machine.state === machineStates.publishing}
|
||||
isCancelling={machine.isCancelling}
|
||||
disableReason={disableReason}
|
||||
onStart={runUploadFlow}
|
||||
onContinue={() => detailsValid && setActiveStep(3)}
|
||||
onPublish={() => handlePublish(canPublish, { mode: publishMode, publishAt: scheduledAt, timezone: userTimezone, visibility })}
|
||||
onBack={() => setActiveStep((s) => Math.max(1, s - 1))}
|
||||
onCancel={handleCancel}
|
||||
onReset={handleReset}
|
||||
onRetry={() => handleRetry(canPublish, { mode: publishMode, publishAt: scheduledAt, timezone: userTimezone, visibility })}
|
||||
onSaveDraft={() => {}}
|
||||
showSaveDraft={activeStep === 2}
|
||||
resetLabel={isUploadLocked ? 'Reset upload' : 'Reset'}
|
||||
mobileSticky
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right column: PublishPanel (sticky sidebar on lg+) */}
|
||||
{(primaryFile || resolvedArtworkId) && machine.state !== machineStates.complete && (
|
||||
<div className="hidden lg:block lg:w-72 xl:w-80 shrink-0 lg:sticky lg:top-20 lg:self-start">
|
||||
<PublishPanel
|
||||
primaryPreviewUrl={primaryPreviewUrl}
|
||||
isArchive={isArchive}
|
||||
screenshots={screenshots}
|
||||
metadata={metadata}
|
||||
machineState={machine.state}
|
||||
uploadReady={uploadReady}
|
||||
canPublish={canPublish}
|
||||
isPublishing={machine.state === machineStates.publishing}
|
||||
isArchiveRequiresScreenshot={isArchive}
|
||||
publishMode={publishMode}
|
||||
scheduledAt={scheduledAt}
|
||||
timezone={userTimezone}
|
||||
visibility={visibility}
|
||||
onPublishModeChange={setPublishMode}
|
||||
onScheduleAt={setScheduledAt}
|
||||
onVisibilityChange={setVisibility}
|
||||
onToggleRights={(checked) => setMeta({ rightsAccepted: Boolean(checked) })}
|
||||
onPublish={() => handlePublish(canPublish, { mode: publishMode, publishAt: scheduledAt, timezone: userTimezone, visibility })}
|
||||
onCancel={handleCancel}
|
||||
onGoToStep={goToStep}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Animated step content */}
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={`step-${activeStep}`}
|
||||
initial={prefersReducedMotion ? false : { opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={prefersReducedMotion ? {} : { opacity: 0, y: -8 }}
|
||||
transition={quickTransition}
|
||||
>
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{renderStepContent()}
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
{/* ── Mobile: floating "Publish" button that opens bottom sheet ────── */}
|
||||
{(primaryFile || resolvedArtworkId) && machine.state !== machineStates.complete && (
|
||||
<div className="fixed bottom-4 right-4 z-30 lg:hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowMobilePublishPanel((v) => !v)}
|
||||
className="flex items-center gap-2 rounded-full bg-sky-500 px-4 py-2.5 text-sm font-semibold text-white shadow-lg shadow-sky-900/40 transition hover:bg-sky-400 active:scale-95"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Publish
|
||||
{!canPublish && (
|
||||
<span className="ml-1 rounded-full bg-white/20 px-1.5 text-[10px]">
|
||||
{[...(!uploadReady ? [1] : []), ...(!metadata.title ? [1] : []), ...(!metadata.rightsAccepted ? [1] : [])].length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sticky action bar */}
|
||||
<UploadActions
|
||||
step={activeStep}
|
||||
canStart={canStartUpload && [machineStates.idle, machineStates.error, machineStates.cancelled].includes(machine.state)}
|
||||
canContinue={detailsValid}
|
||||
canPublish={canPublish}
|
||||
canGoBack={activeStep > 1 && machine.state !== machineStates.complete}
|
||||
canReset={Boolean(primaryFile || screenshots.length || metadata.title || metadata.description || metadata.tags.length)}
|
||||
canCancel={activeStep === 1 && [
|
||||
machineStates.initializing,
|
||||
machineStates.uploading,
|
||||
machineStates.finishing,
|
||||
machineStates.processing,
|
||||
].includes(machine.state)}
|
||||
canRetry={machine.state === machineStates.error}
|
||||
isUploading={[machineStates.uploading, machineStates.initializing].includes(machine.state)}
|
||||
isProcessing={[machineStates.processing, machineStates.finishing].includes(machine.state)}
|
||||
isPublishing={machine.state === machineStates.publishing}
|
||||
isCancelling={machine.isCancelling}
|
||||
disableReason={disableReason}
|
||||
onStart={runUploadFlow}
|
||||
onContinue={() => detailsValid && setActiveStep(3)}
|
||||
onPublish={() => handlePublish(canPublish)}
|
||||
onBack={() => setActiveStep((s) => Math.max(1, s - 1))}
|
||||
onCancel={handleCancel}
|
||||
onReset={handleReset}
|
||||
onRetry={() => handleRetry(canPublish)}
|
||||
onSaveDraft={() => {}}
|
||||
showSaveDraft={activeStep === 2}
|
||||
resetLabel={isUploadLocked ? 'Reset upload' : 'Reset'}
|
||||
mobileSticky
|
||||
/>
|
||||
{/* ── Mobile Publish panel bottom-sheet overlay ────────────────────── */}
|
||||
<AnimatePresence>
|
||||
{showMobilePublishPanel && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
key="mobile-panel-backdrop"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm lg:hidden"
|
||||
onClick={() => setShowMobilePublishPanel(false)}
|
||||
/>
|
||||
{/* Sheet */}
|
||||
<motion.div
|
||||
key="mobile-panel-sheet"
|
||||
initial={{ y: '100%' }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: '100%' }}
|
||||
transition={prefersReducedMotion ? { duration: 0 } : { type: 'spring', damping: 30, stiffness: 300 }}
|
||||
className="fixed bottom-0 left-0 right-0 z-50 max-h-[80vh] overflow-y-auto rounded-t-2xl bg-slate-900 ring-1 ring-white/10 p-5 pb-8 lg:hidden"
|
||||
>
|
||||
<div className="mx-auto mb-4 h-1 w-12 rounded-full bg-white/20" aria-hidden="true" />
|
||||
<PublishPanel
|
||||
primaryPreviewUrl={primaryPreviewUrl}
|
||||
isArchive={isArchive}
|
||||
screenshots={screenshots}
|
||||
metadata={metadata}
|
||||
machineState={machine.state}
|
||||
uploadReady={uploadReady}
|
||||
canPublish={canPublish}
|
||||
isPublishing={machine.state === machineStates.publishing}
|
||||
isArchiveRequiresScreenshot={isArchive}
|
||||
publishMode={publishMode}
|
||||
scheduledAt={scheduledAt}
|
||||
timezone={userTimezone}
|
||||
visibility={visibility}
|
||||
onPublishModeChange={setPublishMode}
|
||||
onScheduleAt={setScheduledAt}
|
||||
onVisibilityChange={setVisibility}
|
||||
onToggleRights={(checked) => setMeta({ rightsAccepted: Boolean(checked) })}
|
||||
onPublish={() => {
|
||||
setShowMobilePublishPanel(false)
|
||||
handlePublish(canPublish, { mode: publishMode, publishAt: scheduledAt, timezone: userTimezone, visibility })
|
||||
}}
|
||||
onCancel={() => {
|
||||
setShowMobilePublishPanel(false)
|
||||
handleCancel()
|
||||
}}
|
||||
onGoToStep={(s) => {
|
||||
setShowMobilePublishPanel(false)
|
||||
goToStep(s)
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { cleanup, render, screen, waitFor, within } from '@testing-library/react
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import UploadWizard from '../UploadWizard'
|
||||
|
||||
function installAxiosStubs({ statusValue = 'ready', initError = null, holdChunk = false } = {}) {
|
||||
function installAxiosStubs({ statusValue = 'ready', initError = null, holdChunk = false, finishError = null } = {}) {
|
||||
window.axios = {
|
||||
post: vi.fn((url, payload, config = {}) => {
|
||||
if (url === '/api/uploads/init') {
|
||||
@@ -44,6 +44,7 @@ function installAxiosStubs({ statusValue = 'ready', initError = null, holdChunk
|
||||
}
|
||||
|
||||
if (url === '/api/uploads/finish') {
|
||||
if (finishError) return Promise.reject(finishError)
|
||||
return Promise.resolve({ data: { processing_state: statusValue, status: statusValue } })
|
||||
}
|
||||
|
||||
@@ -281,6 +282,30 @@ describe('UploadWizard step flow', () => {
|
||||
expect((bar.className || '').includes('bottom-0')).toBe(true)
|
||||
})
|
||||
|
||||
it('shows mapped duplicate hash toast when finish returns duplicate_hash', async () => {
|
||||
installAxiosStubs({
|
||||
finishError: {
|
||||
response: {
|
||||
status: 409,
|
||||
data: {
|
||||
reason: 'duplicate_hash',
|
||||
message: 'Duplicate upload is not allowed. This file already exists.',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await renderWizard({ initialDraftId: 310 })
|
||||
await uploadPrimary(new File(['img'], 'duplicate.png', { type: 'image/png' }))
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(await screen.findByRole('button', { name: /start upload/i }))
|
||||
})
|
||||
|
||||
const toast = await screen.findByRole('alert')
|
||||
expect(toast.textContent).toMatch(/already exists in skinbase/i)
|
||||
})
|
||||
|
||||
it('locks step 1 file input after upload and unlocks after reset', async () => {
|
||||
installAxiosStubs({ statusValue: 'ready' })
|
||||
await renderWizard({ initialDraftId: 309 })
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import React from 'react'
|
||||
import UploadDropzone from '../UploadDropzone'
|
||||
import ScreenshotUploader from '../ScreenshotUploader'
|
||||
import UploadProgress from '../UploadProgress'
|
||||
import { machineStates } from '../../../hooks/upload/useUploadMachine'
|
||||
import { getProcessingTransparencyLabel } from '../../../lib/uploadUtils'
|
||||
|
||||
/**
|
||||
* Step1FileUpload
|
||||
@@ -28,24 +25,9 @@ export default function Step1FileUpload({
|
||||
screenshotErrors,
|
||||
screenshotPerFileErrors,
|
||||
onScreenshotsChange,
|
||||
// Machine state
|
||||
// Machine state (passed for potential future use)
|
||||
machine,
|
||||
showProgress,
|
||||
onRetry,
|
||||
onReset,
|
||||
}) {
|
||||
const processingTransparencyLabel = getProcessingTransparencyLabel(
|
||||
machine.processingStatus,
|
||||
machine.state
|
||||
)
|
||||
|
||||
const progressStatus = (() => {
|
||||
if (machine.state === machineStates.ready_to_publish) return 'Ready'
|
||||
if (machine.state === machineStates.uploading) return 'Uploading'
|
||||
if (machine.state === machineStates.processing || machine.state === machineStates.finishing) return 'Processing'
|
||||
return 'Idle'
|
||||
})()
|
||||
|
||||
return (
|
||||
<div className="bg-panel/80 backdrop-blur rounded-2xl shadow-xl shadow-black/40 ring-1 ring-white/10 p-6 space-y-5">
|
||||
{/* Step header */}
|
||||
@@ -107,23 +89,6 @@ export default function Step1FileUpload({
|
||||
looksGoodText="Looks good"
|
||||
onFilesChange={onScreenshotsChange}
|
||||
/>
|
||||
|
||||
{/* Progress panel */}
|
||||
{showProgress && (
|
||||
<UploadProgress
|
||||
title="Upload progress"
|
||||
description="Upload and processing status"
|
||||
status={progressStatus}
|
||||
progress={machine.progress}
|
||||
state={machine.state}
|
||||
processingStatus={machine.processingStatus}
|
||||
isCancelling={machine.isCancelling}
|
||||
error={machine.error}
|
||||
processingLabel={processingTransparencyLabel}
|
||||
onRetry={onRetry}
|
||||
onReset={onReset}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,7 +24,11 @@ function PublishCheckBadge({ label, ok }) {
|
||||
* Step3Publish
|
||||
*
|
||||
* Step 3 of the upload wizard: review summary and publish action.
|
||||
* Shows a compact artwork preview, metadata summary, and readiness badges.
|
||||
* Shows a compact artwork preview, metadata summary, readiness badges,
|
||||
* and a summary of publish mode / schedule + visibility.
|
||||
*
|
||||
* Publish controls (mode/schedule picker) live in PublishPanel (sidebar).
|
||||
* This step serves as the final review before the user clicks Publish.
|
||||
*/
|
||||
export default function Step3Publish({
|
||||
headingRef,
|
||||
@@ -39,17 +43,36 @@ export default function Step3Publish({
|
||||
// Readiness
|
||||
canPublish,
|
||||
uploadReady,
|
||||
// Publish options (from wizard state, for summary display only)
|
||||
publishMode = 'now',
|
||||
scheduledAt = null,
|
||||
timezone = null,
|
||||
visibility = 'public',
|
||||
// Category tree (for label lookup)
|
||||
allRootCategoryOptions = [],
|
||||
filteredCategoryTree = [],
|
||||
}) {
|
||||
const prefersReducedMotion = useReducedMotion()
|
||||
const quickTransition = prefersReducedMotion ? { duration: 0 } : { duration: 0.2, ease: 'easeOut' }
|
||||
|
||||
const hasPreview = Boolean(primaryPreviewUrl && !isArchive)
|
||||
|
||||
// ── Category label lookup ────────────────────────────────────────────────
|
||||
const rootCategory = allRootCategoryOptions.find(
|
||||
(r) => String(r.id) === String(metadata.rootCategoryId)
|
||||
) ?? null
|
||||
const rootLabel = rootCategory?.name ?? null
|
||||
const subCategory = rootCategory?.children?.find(
|
||||
(c) => String(c.id) === String(metadata.subCategoryId)
|
||||
) ?? null
|
||||
const subLabel = subCategory?.name ?? null
|
||||
|
||||
const checks = [
|
||||
{ label: 'File uploaded', ok: uploadReady },
|
||||
{ label: 'Scan passed', ok: uploadReady },
|
||||
{ label: 'Preview ready', ok: hasPreview || (isArchive && screenshots.length > 0) },
|
||||
{ label: 'Rights confirmed', ok: Boolean(metadata.rightsAccepted) },
|
||||
{ label: 'Tags added', ok: Array.isArray(metadata.tags) && metadata.tags.length > 0 },
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -104,11 +127,11 @@ export default function Step3Publish({
|
||||
{metadata.contentType && (
|
||||
<span className="capitalize">Type: <span className="text-white/75">{metadata.contentType}</span></span>
|
||||
)}
|
||||
{metadata.rootCategoryId && (
|
||||
<span>Category: <span className="text-white/75">{metadata.rootCategoryId}</span></span>
|
||||
{rootLabel && (
|
||||
<span>Category: <span className="text-white/75">{rootLabel}</span></span>
|
||||
)}
|
||||
{metadata.subCategoryId && (
|
||||
<span>Sub: <span className="text-white/75">{metadata.subCategoryId}</span></span>
|
||||
{subLabel && (
|
||||
<span>Sub: <span className="text-white/75">{subLabel}</span></span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -129,6 +152,32 @@ export default function Step3Publish({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Publish summary: visibility + schedule */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/15 bg-white/6 px-2.5 py-1 text-xs text-white/60">
|
||||
👁 {visibility === 'public' ? 'Public' : visibility === 'unlisted' ? 'Unlisted' : 'Private'}
|
||||
</span>
|
||||
{publishMode === 'schedule' && scheduledAt ? (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-violet-300/30 bg-violet-500/15 px-2.5 py-1 text-xs text-violet-200">
|
||||
🕐 Scheduled
|
||||
{timezone && (
|
||||
<span className="text-violet-300/70">
|
||||
{' '}·{' '}
|
||||
{new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: timezone,
|
||||
weekday: 'short', day: 'numeric', month: 'short',
|
||||
hour: '2-digit', minute: '2-digit', hour12: false,
|
||||
}).format(new Date(scheduledAt))}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-emerald-300/30 bg-emerald-500/12 px-2.5 py-1 text-xs text-emerald-200">
|
||||
⚡ Publish immediately
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Readiness badges */}
|
||||
<div>
|
||||
<p className="mb-2.5 text-xs uppercase tracking-wide text-white/40">Readiness checks</p>
|
||||
|
||||
@@ -606,7 +606,7 @@ export default function UploadWizard({
|
||||
<div className="grid grid-cols-1 gap-3 text-sm text-white/80 md:grid-cols-2">
|
||||
<div><span className="text-white/50">Upload ID:</span> {uploadId || '—'}</div>
|
||||
<div><span className="text-white/50">Title:</span> {details.title || '—'}</div>
|
||||
<div className="md:col-span-2"><span className="text-white/50">URL preview:</span> /artwork/{urlPreviewSlug}</div>
|
||||
<div className="md:col-span-2"><span className="text-white/50">URL preview:</span> /art/{urlPreviewSlug}</div>
|
||||
<div><span className="text-white/50">Category:</span> {details.category_id || '—'}</div>
|
||||
<div><span className="text-white/50">Tags:</span> {(details.tags || []).join(', ') || '—'}</div>
|
||||
<div className="md:col-span-2"><span className="text-white/50">Preview path:</span> {previewPath || 'Will be resolved by backend pipeline'}</div>
|
||||
|
||||
37
resources/js/entry-forum.jsx
Normal file
37
resources/js/entry-forum.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Forum React Island Entry Point
|
||||
*
|
||||
* Auto-detects which forum page mount-point is present in the DOM
|
||||
* and renders the corresponding React component with server-provided props.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
|
||||
const MOUNTS = [
|
||||
{ rootId: 'forum-index-root', propsId: 'forum-index-props', loader: () => import('./Pages/Forum/ForumIndex') },
|
||||
{ rootId: 'forum-category-root', propsId: 'forum-category-props', loader: () => import('./Pages/Forum/ForumCategory') },
|
||||
{ rootId: 'forum-thread-root', propsId: 'forum-thread-props', loader: () => import('./Pages/Forum/ForumThread') },
|
||||
{ rootId: 'forum-new-thread-root', propsId: 'forum-new-thread-props', loader: () => import('./Pages/Forum/ForumNewThread') },
|
||||
{ rootId: 'forum-edit-post-root', propsId: 'forum-edit-post-props', loader: () => import('./Pages/Forum/ForumEditPost') },
|
||||
]
|
||||
|
||||
for (const { rootId, propsId, loader } of MOUNTS) {
|
||||
const el = document.getElementById(rootId)
|
||||
if (!el) continue
|
||||
|
||||
let props = {}
|
||||
try {
|
||||
const propsEl = document.getElementById(propsId)
|
||||
props = propsEl ? JSON.parse(propsEl.textContent || '{}') : {}
|
||||
} catch {
|
||||
props = {}
|
||||
}
|
||||
|
||||
loader().then((mod) => {
|
||||
const Component = mod.default
|
||||
createRoot(el).render(<Component {...props} />)
|
||||
})
|
||||
|
||||
break // Only one forum page per request
|
||||
}
|
||||
25
resources/js/feed.jsx
Normal file
25
resources/js/feed.jsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import './bootstrap'
|
||||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { createInertiaApp } from '@inertiajs/react'
|
||||
import FollowingFeed from './Pages/Feed/FollowingFeed'
|
||||
import TrendingFeed from './Pages/Feed/TrendingFeed'
|
||||
import HashtagFeed from './Pages/Feed/HashtagFeed'
|
||||
import SavedFeed from './Pages/Feed/SavedFeed'
|
||||
import SearchFeed from './Pages/Feed/SearchFeed'
|
||||
|
||||
const pages = {
|
||||
'Feed/FollowingFeed': FollowingFeed,
|
||||
'Feed/TrendingFeed': TrendingFeed,
|
||||
'Feed/HashtagFeed': HashtagFeed,
|
||||
'Feed/SavedFeed': SavedFeed,
|
||||
'Feed/SearchFeed': SearchFeed,
|
||||
}
|
||||
|
||||
createInertiaApp({
|
||||
resolve: (name) => pages[name],
|
||||
setup({ el, App, props }) {
|
||||
const root = createRoot(el)
|
||||
root.render(<App {...props} />)
|
||||
},
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useReducer, useRef } from 'react'
|
||||
import { emitUploadEvent } from '../../lib/uploadAnalytics'
|
||||
import * as uploadEndpoints from '../../lib/uploadEndpoints'
|
||||
import { mapUploadErrorNotice, mapUploadResultNotice } from '../../lib/uploadNotices'
|
||||
|
||||
// ─── Constants ──────────────────────────────────────────────────────────────
|
||||
const DEFAULT_CHUNK_SIZE_BYTES = 5 * 1024 * 1024
|
||||
@@ -29,6 +30,7 @@ const initialMachineState = {
|
||||
isCancelling: false,
|
||||
error: '',
|
||||
lastAction: null,
|
||||
slug: null,
|
||||
}
|
||||
|
||||
function machineReducer(state, action) {
|
||||
@@ -52,7 +54,9 @@ function machineReducer(state, action) {
|
||||
case 'PUBLISH_START':
|
||||
return { ...state, state: machineStates.publishing, error: '', lastAction: 'publish' }
|
||||
case 'PUBLISH_SUCCESS':
|
||||
return { ...state, state: machineStates.complete, error: '' }
|
||||
return { ...state, state: machineStates.complete, error: '', slug: action.slug ?? state.slug }
|
||||
case 'SCHEDULED':
|
||||
return { ...state, state: machineStates.complete, error: '', lastAction: 'schedule', slug: action.slug ?? state.slug }
|
||||
case 'CANCEL_START':
|
||||
return { ...state, isCancelling: true, error: '', lastAction: 'cancel' }
|
||||
case 'CANCELLED':
|
||||
@@ -108,6 +112,7 @@ export default function useUploadMachine({
|
||||
metadata,
|
||||
chunkSize = DEFAULT_CHUNK_SIZE_BYTES,
|
||||
onArtworkCreated,
|
||||
onNotice,
|
||||
}) {
|
||||
const [machine, dispatchMachine] = useReducer(machineReducer, initialMachineState)
|
||||
|
||||
@@ -178,16 +183,18 @@ export default function useUploadMachine({
|
||||
} else if (processingValue === 'rejected' || processingValue === 'error' || payload?.failure_reason) {
|
||||
const failureMessage = payload?.failure_reason || payload?.message || `Processing ended with status: ${processingValue}`
|
||||
dispatchMachine({ type: 'ERROR', error: failureMessage })
|
||||
onNotice?.({ type: 'error', message: failureMessage })
|
||||
clearPolling()
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.name === 'CanceledError' || error?.code === 'ERR_CANCELED') return
|
||||
const message = error?.response?.data?.message || 'Processing status check failed.'
|
||||
dispatchMachine({ type: 'ERROR', error: message })
|
||||
emitUploadEvent('upload_error', { stage: 'processing_poll', message })
|
||||
const notice = mapUploadErrorNotice(error, 'Processing status check failed.')
|
||||
dispatchMachine({ type: 'ERROR', error: notice.message })
|
||||
onNotice?.(notice)
|
||||
emitUploadEvent('upload_error', { stage: 'processing_poll', message: notice.message })
|
||||
clearPolling()
|
||||
}
|
||||
}, [fetchProcessingStatus, registerController, unregisterController, clearPolling])
|
||||
}, [fetchProcessingStatus, registerController, unregisterController, clearPolling, onNotice])
|
||||
|
||||
const startPolling = useCallback((sessionId, uploadToken) => {
|
||||
clearPolling()
|
||||
@@ -290,7 +297,12 @@ export default function useUploadMachine({
|
||||
const finishController = registerController()
|
||||
const finishResponse = await window.axios.post(
|
||||
uploadEndpoints.finish(),
|
||||
{ session_id: sessionId, upload_token: uploadToken, artwork_id: artworkIdForUpload },
|
||||
{
|
||||
session_id: sessionId,
|
||||
upload_token: uploadToken,
|
||||
artwork_id: artworkIdForUpload,
|
||||
file_name: String(primaryFile?.name || ''),
|
||||
},
|
||||
{ signal: finishController.signal, headers: { 'X-Upload-Token': uploadToken } }
|
||||
)
|
||||
unregisterController(finishController)
|
||||
@@ -298,6 +310,14 @@ export default function useUploadMachine({
|
||||
const finishStatus = getProcessingValue(finishResponse?.data || {})
|
||||
dispatchMachine({ type: 'FINISH_SUCCESS', processingStatus: finishStatus })
|
||||
|
||||
const finishNotice = mapUploadResultNotice(finishResponse?.data || {}, {
|
||||
fallbackType: finishStatus === 'queued' ? 'warning' : 'success',
|
||||
fallbackMessage: finishStatus === 'queued'
|
||||
? 'Upload received. Processing is queued.'
|
||||
: 'Upload completed successfully.',
|
||||
})
|
||||
onNotice?.(finishNotice)
|
||||
|
||||
if (isReadyToPublishStatus(finishStatus)) {
|
||||
dispatchMachine({ type: 'READY_TO_PUBLISH' })
|
||||
} else {
|
||||
@@ -307,9 +327,10 @@ export default function useUploadMachine({
|
||||
emitUploadEvent('upload_complete', { session_id: sessionId, artwork_id: artworkIdForUpload })
|
||||
} catch (error) {
|
||||
if (error?.name === 'CanceledError' || error?.code === 'ERR_CANCELED') return
|
||||
const message = error?.response?.data?.message || error?.message || 'Upload failed.'
|
||||
dispatchMachine({ type: 'ERROR', error: message })
|
||||
emitUploadEvent('upload_error', { stage: 'upload_flow', message })
|
||||
const notice = mapUploadErrorNotice(error, 'Upload failed.')
|
||||
dispatchMachine({ type: 'ERROR', error: notice.message })
|
||||
onNotice?.(notice)
|
||||
emitUploadEvent('upload_error', { stage: 'upload_flow', message: notice.message })
|
||||
}
|
||||
}, [
|
||||
primaryFile,
|
||||
@@ -323,6 +344,7 @@ export default function useUploadMachine({
|
||||
clearPolling,
|
||||
startPolling,
|
||||
onArtworkCreated,
|
||||
onNotice,
|
||||
])
|
||||
|
||||
// ── Cancel ─────────────────────────────────────────────────────────────────
|
||||
@@ -341,55 +363,88 @@ export default function useUploadMachine({
|
||||
)
|
||||
}
|
||||
dispatchMachine({ type: 'CANCELLED' })
|
||||
onNotice?.({ type: 'warning', message: 'Upload cancelled.' })
|
||||
emitUploadEvent('upload_cancel', { session_id: machine.sessionId || null })
|
||||
} catch (error) {
|
||||
const message = error?.response?.data?.message || 'Cancel failed.'
|
||||
dispatchMachine({ type: 'ERROR', error: message })
|
||||
emitUploadEvent('upload_error', { stage: 'cancel', message })
|
||||
const notice = mapUploadErrorNotice(error, 'Cancel failed.')
|
||||
dispatchMachine({ type: 'ERROR', error: notice.message })
|
||||
onNotice?.(notice)
|
||||
emitUploadEvent('upload_error', { stage: 'cancel', message: notice.message })
|
||||
}
|
||||
}, [machine, abortAllRequests, clearPolling])
|
||||
}, [machine, abortAllRequests, clearPolling, onNotice])
|
||||
|
||||
// ── Publish ────────────────────────────────────────────────────────────────
|
||||
const handlePublish = useCallback(async (canPublish) => {
|
||||
/**
|
||||
* handlePublish
|
||||
*
|
||||
* @param {boolean} canPublish
|
||||
* @param {{ mode?: 'now'|'schedule', publishAt?: string|null, timezone?: string, visibility?: string }} [opts]
|
||||
*/
|
||||
const handlePublish = useCallback(async (canPublish, opts = {}) => {
|
||||
if (!canPublish || publishLockRef.current) return
|
||||
|
||||
publishLockRef.current = true
|
||||
dispatchMachine({ type: 'PUBLISH_START' })
|
||||
|
||||
const { mode = 'now', publishAt = null, timezone = null, visibility = 'public' } = opts
|
||||
|
||||
const buildPayload = () => ({
|
||||
title: String(metadata.title || '').trim() || undefined,
|
||||
description: String(metadata.description || '').trim() || null,
|
||||
mode,
|
||||
...(mode === 'schedule' && publishAt ? { publish_at: publishAt } : {}),
|
||||
...(timezone ? { timezone } : {}),
|
||||
visibility,
|
||||
})
|
||||
|
||||
try {
|
||||
const publishTargetId =
|
||||
resolvedArtworkIdRef.current || initialDraftId || machine.sessionId
|
||||
|
||||
if (resolvedArtworkIdRef.current && resolvedArtworkIdRef.current > 0) {
|
||||
dispatchMachine({ type: 'PUBLISH_SUCCESS' })
|
||||
emitUploadEvent('upload_publish', { id: publishTargetId })
|
||||
const publishController = registerController()
|
||||
const publishRes = await window.axios.post(
|
||||
uploadEndpoints.publish(String(resolvedArtworkIdRef.current)),
|
||||
buildPayload(),
|
||||
{ signal: publishController.signal }
|
||||
)
|
||||
unregisterController(publishController)
|
||||
const publishedSlug = publishRes?.data?.slug ?? null
|
||||
dispatchMachine({ type: mode === 'schedule' ? 'SCHEDULED' : 'PUBLISH_SUCCESS', slug: publishedSlug })
|
||||
onNotice?.(mapUploadResultNotice(publishRes?.data || {}, {
|
||||
fallbackType: 'success',
|
||||
fallbackMessage: mode === 'schedule' ? 'Artwork scheduled successfully.' : 'Artwork published successfully.',
|
||||
}))
|
||||
emitUploadEvent('upload_publish', { id: publishTargetId, mode })
|
||||
return
|
||||
}
|
||||
|
||||
if (!publishTargetId) throw new Error('Missing publish id.')
|
||||
|
||||
const publishController = registerController()
|
||||
await window.axios.post(
|
||||
const publishRes2 = await window.axios.post(
|
||||
uploadEndpoints.publish(publishTargetId),
|
||||
{
|
||||
title: String(metadata.title || '').trim() || undefined,
|
||||
description: String(metadata.description || '').trim() || null,
|
||||
},
|
||||
buildPayload(),
|
||||
{ signal: publishController.signal }
|
||||
)
|
||||
unregisterController(publishController)
|
||||
|
||||
dispatchMachine({ type: 'PUBLISH_SUCCESS' })
|
||||
emitUploadEvent('upload_publish', { id: publishTargetId })
|
||||
const publishedSlug2 = publishRes2?.data?.slug ?? null
|
||||
dispatchMachine({ type: mode === 'schedule' ? 'SCHEDULED' : 'PUBLISH_SUCCESS', slug: publishedSlug2 })
|
||||
onNotice?.(mapUploadResultNotice(publishRes2?.data || {}, {
|
||||
fallbackType: 'success',
|
||||
fallbackMessage: mode === 'schedule' ? 'Artwork scheduled successfully.' : 'Artwork published successfully.',
|
||||
}))
|
||||
emitUploadEvent('upload_publish', { id: publishTargetId, mode })
|
||||
} catch (error) {
|
||||
if (error?.name === 'CanceledError' || error?.code === 'ERR_CANCELED') return
|
||||
const message = error?.response?.data?.message || error?.message || 'Publish failed.'
|
||||
dispatchMachine({ type: 'ERROR', error: message })
|
||||
emitUploadEvent('upload_error', { stage: 'publish', message })
|
||||
const notice = mapUploadErrorNotice(error, 'Publish failed.')
|
||||
dispatchMachine({ type: 'ERROR', error: notice.message })
|
||||
onNotice?.(notice)
|
||||
emitUploadEvent('upload_error', { stage: 'publish', message: notice.message })
|
||||
} finally {
|
||||
publishLockRef.current = false
|
||||
}
|
||||
}, [machine, initialDraftId, metadata.title, metadata.description, registerController, unregisterController])
|
||||
}, [machine, initialDraftId, metadata, registerController, unregisterController, onNotice])
|
||||
|
||||
// ── Reset ──────────────────────────────────────────────────────────────────
|
||||
const resetMachine = useCallback(() => {
|
||||
@@ -404,11 +459,20 @@ export default function useUploadMachine({
|
||||
}, [clearPolling, abortAllRequests, initialDraftId])
|
||||
|
||||
// ── Retry ──────────────────────────────────────────────────────────────────
|
||||
const handleRetry = useCallback((canPublish) => {
|
||||
/**
|
||||
* handleRetry
|
||||
*
|
||||
* Re-attempts the last action. When the last action was a publish/schedule,
|
||||
* opts must be forwarded so scheduled-publish options are not lost on retry.
|
||||
*
|
||||
* @param {boolean} canPublish
|
||||
* @param {{ mode?: string, publishAt?: string|null, timezone?: string, visibility?: string }} [opts]
|
||||
*/
|
||||
const handleRetry = useCallback((canPublish, opts = {}) => {
|
||||
clearPolling()
|
||||
abortAllRequests()
|
||||
if (machine.lastAction === 'publish') {
|
||||
handlePublish(canPublish)
|
||||
handlePublish(canPublish, opts)
|
||||
return
|
||||
}
|
||||
runUploadFlow()
|
||||
|
||||
71
resources/js/lib/uploadNotices.js
Normal file
71
resources/js/lib/uploadNotices.js
Normal file
@@ -0,0 +1,71 @@
|
||||
const REASON_MAP = {
|
||||
duplicate_hash: {
|
||||
type: 'error',
|
||||
message: 'This file already exists in Skinbase. Please upload a different file.',
|
||||
},
|
||||
validation_failed: {
|
||||
type: 'error',
|
||||
message: 'Upload validation failed. Please check the file and try again.',
|
||||
},
|
||||
scan_failed: {
|
||||
type: 'error',
|
||||
message: 'Upload scan failed. Please try another file.',
|
||||
},
|
||||
quota_exceeded: {
|
||||
type: 'warning',
|
||||
message: 'Upload limit reached. Please wait before uploading again.',
|
||||
},
|
||||
}
|
||||
|
||||
function normalizeType(value, fallback = 'error') {
|
||||
const normalized = String(value || '').toLowerCase()
|
||||
if (normalized === 'success' || normalized === 'warning' || normalized === 'error') return normalized
|
||||
return fallback
|
||||
}
|
||||
|
||||
export function mapUploadErrorNotice(error, fallback = 'Upload failed.') {
|
||||
const status = Number(error?.response?.status || 0)
|
||||
const payload = error?.response?.data || {}
|
||||
const reason = String(payload?.reason || '').toLowerCase()
|
||||
const mapped = REASON_MAP[reason]
|
||||
|
||||
const type = mapped?.type
|
||||
? mapped.type
|
||||
: normalizeType(payload?.type || payload?.level, status >= 500 ? 'error' : 'warning')
|
||||
|
||||
const message =
|
||||
mapped?.message ||
|
||||
(typeof payload?.message === 'string' && payload.message.trim()) ||
|
||||
(typeof error?.message === 'string' && error.message.trim()) ||
|
||||
fallback
|
||||
|
||||
return {
|
||||
type,
|
||||
message,
|
||||
reason,
|
||||
status,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapUploadResultNotice(payload, options = {}) {
|
||||
const {
|
||||
fallbackType = 'success',
|
||||
fallbackMessage = 'Operation completed successfully.',
|
||||
} = options
|
||||
|
||||
const reason = String(payload?.reason || '').toLowerCase()
|
||||
const mapped = REASON_MAP[reason]
|
||||
|
||||
const type = mapped?.type || normalizeType(payload?.type || payload?.level, fallbackType)
|
||||
const message =
|
||||
mapped?.message ||
|
||||
(typeof payload?.message === 'string' && payload.message.trim()) ||
|
||||
fallbackMessage
|
||||
|
||||
return {
|
||||
type,
|
||||
message,
|
||||
reason,
|
||||
status: Number(payload?.status_code || 0),
|
||||
}
|
||||
}
|
||||
40
resources/js/lib/uploadNotices.test.js
Normal file
40
resources/js/lib/uploadNotices.test.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mapUploadErrorNotice, mapUploadResultNotice } from './uploadNotices'
|
||||
|
||||
describe('uploadNotices mapping', () => {
|
||||
it('maps duplicate_hash reason to a clear error message', () => {
|
||||
const notice = mapUploadErrorNotice({
|
||||
response: {
|
||||
status: 409,
|
||||
data: {
|
||||
reason: 'duplicate_hash',
|
||||
message: 'Duplicate upload is not allowed. This file already exists.',
|
||||
},
|
||||
},
|
||||
}, 'Upload failed.')
|
||||
|
||||
expect(notice.type).toBe('error')
|
||||
expect(notice.reason).toBe('duplicate_hash')
|
||||
expect(notice.message).toBe('This file already exists in Skinbase. Please upload a different file.')
|
||||
})
|
||||
|
||||
it('keeps success messages as success', () => {
|
||||
const notice = mapUploadResultNotice({
|
||||
type: 'success',
|
||||
message: 'Artwork published successfully.',
|
||||
})
|
||||
|
||||
expect(notice.type).toBe('success')
|
||||
expect(notice.message).toBe('Artwork published successfully.')
|
||||
})
|
||||
|
||||
it('normalizes warning messages for queued processing', () => {
|
||||
const notice = mapUploadResultNotice({
|
||||
level: 'warning',
|
||||
message: 'Upload received. Processing is queued.',
|
||||
})
|
||||
|
||||
expect(notice.type).toBe('warning')
|
||||
expect(notice.message).toBe('Upload received. Processing is queued.')
|
||||
})
|
||||
})
|
||||
17
resources/js/profile.jsx
Normal file
17
resources/js/profile.jsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import './bootstrap'
|
||||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { createInertiaApp } from '@inertiajs/react'
|
||||
import ProfileShow from './Pages/Profile/ProfileShow'
|
||||
|
||||
const pages = {
|
||||
'Profile/ProfileShow': ProfileShow,
|
||||
}
|
||||
|
||||
createInertiaApp({
|
||||
resolve: (name) => pages[name],
|
||||
setup({ el, App, props }) {
|
||||
const root = createRoot(el)
|
||||
root.render(<App {...props} />)
|
||||
},
|
||||
})
|
||||
@@ -1,49 +1,103 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@php($gridV2 = request()->query('grid') === 'v2')
|
||||
@php
|
||||
$galleryItems = collect($artworks->items())->map(fn ($art) => [
|
||||
'id' => $art->id ?? null,
|
||||
'name' => $art->name ?? $art->title ?? null,
|
||||
'title' => $art->title ?? $art->name ?? null,
|
||||
'thumb' => $art->thumb ?? $art->thumb_url ?? null,
|
||||
'thumb_url' => $art->thumb_url ?? $art->thumb ?? null,
|
||||
'slug' => $art->slug ?? '',
|
||||
'author' => $art->author ?? '',
|
||||
'uname' => $art->uname ?? $art->author ?? '',
|
||||
'username' => $art->username ?? '',
|
||||
'avatar_url' => $art->avatar_url ?? null,
|
||||
'category_name' => $art->category_name ?? '',
|
||||
'category_slug' => $art->category_slug ?? '',
|
||||
'width' => $art->width ?? null,
|
||||
'height' => $art->height ?? null,
|
||||
'likes' => $art->likes ?? 0,
|
||||
'comments_count' => $art->comments_count ?? 0,
|
||||
])->values();
|
||||
@endphp
|
||||
|
||||
@push('scripts')
|
||||
@vite('resources/js/entry-masonry-gallery.jsx')
|
||||
@endpush
|
||||
|
||||
@push('head')
|
||||
<style>
|
||||
.nb-hero-fade {
|
||||
background: linear-gradient(180deg, rgba(17,24,39,0) 0%, rgba(7,10,15,0.9) 60%, rgba(7,10,15,1) 100%);
|
||||
}
|
||||
.nb-hero-gradient {
|
||||
background: linear-gradient(135deg, rgba(224,122,33,0.08) 0%, rgba(15,23,36,0) 50%, rgba(21,36,58,0.4) 100%);
|
||||
animation: nb-hero-shimmer 8s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes nb-hero-shimmer {
|
||||
0% { opacity: 0.6; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
<div class="container mx-auto py-8">
|
||||
<h1 class="text-2xl font-semibold mb-4">Favourites</h1>
|
||||
<div class="container-fluid legacy-page">
|
||||
<div class="pt-0">
|
||||
<div class="mx-auto w-full">
|
||||
<div class="relative min-h-[calc(100vh-64px)]">
|
||||
<main class="w-full">
|
||||
<div class="relative overflow-hidden nb-hero-radial">
|
||||
<div class="absolute inset-0 nb-hero-gradient" aria-hidden="true"></div>
|
||||
<div class="absolute inset-0 opacity-20 bg-[radial-gradient(ellipse_80%_60%_at_50%_-10%,#E07A2130,transparent)]" aria-hidden="true"></div>
|
||||
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<div class="text-sm text-muted">Showing your favourites</div>
|
||||
<div>
|
||||
<form method="GET" class="inline">
|
||||
<label class="text-sm mr-2">Sort</label>
|
||||
<select name="sort" onchange="this.form.submit()" class="rounded bg-panel px-2 py-1 text-sm">
|
||||
<option value="newest" {{ ($sort ?? 'newest') === 'newest' ? 'selected' : '' }}>Newest first</option>
|
||||
<option value="oldest" {{ ($sort ?? '') === 'oldest' ? 'selected' : '' }}>Oldest first</option>
|
||||
</select>
|
||||
</form>
|
||||
<div class="relative px-6 py-10 md:px-10 md:py-14">
|
||||
<nav class="flex items-center gap-1.5 flex-wrap text-sm text-neutral-400" aria-label="Breadcrumb">
|
||||
<a class="hover:text-white transition-colors" href="/browse">Gallery</a>
|
||||
<span class="opacity-40" aria-hidden="true">›</span>
|
||||
<span class="text-white/85">Favourites</span>
|
||||
</nav>
|
||||
|
||||
<div class="mt-4 py-5">
|
||||
<h1 class="text-3xl md:text-4xl font-bold tracking-tight text-white/95 leading-tight">
|
||||
My Favourites
|
||||
</h1>
|
||||
<p class="mt-2 text-sm leading-6 text-neutral-400 max-w-xl">
|
||||
Artworks you saved, displayed in the same gallery layout as Browse.
|
||||
</p>
|
||||
@if($artworks->total() > 0)
|
||||
<div class="mt-3 flex items-center gap-1.5 text-xs text-neutral-500">
|
||||
<svg class="h-3.5 w-3.5 text-accent/70" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span>{{ number_format($artworks->total()) }} artworks</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="absolute left-0 right-0 bottom-0 h-16 nb-hero-fade pointer-events-none" aria-hidden="true"></div>
|
||||
</div>
|
||||
|
||||
<section class="px-6 pb-10 pt-8 md:px-10">
|
||||
@if($artworks->isEmpty())
|
||||
<div class="rounded-xl border border-white/10 bg-white/5 p-8 text-center text-white/60">
|
||||
You have no favourites yet.
|
||||
</div>
|
||||
@else
|
||||
<div
|
||||
data-react-masonry-gallery
|
||||
data-artworks='@json($galleryItems)'
|
||||
data-gallery-type="dashboard-favorites"
|
||||
@if($artworks->nextPageUrl()) data-next-page-url="{{ $artworks->nextPageUrl() }}" @endif
|
||||
data-limit="20"
|
||||
class="min-h-32"
|
||||
></div>
|
||||
@endif
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($artworks->isEmpty())
|
||||
<p class="text-sm text-gray-500">You have no favourites yet.</p>
|
||||
@else
|
||||
<section data-nova-gallery data-gallery-type="dashboard-favorites">
|
||||
<div class="{{ $gridV2 ? 'gallery' : 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6' }}" data-gallery-grid>
|
||||
@foreach($artworks as $art)
|
||||
<div class="relative gallery-item">
|
||||
<x-artwork-card :art="$art" />
|
||||
<div class="absolute right-2 top-2 z-40">
|
||||
<form method="POST" action="{{ route('dashboard.favorites.destroy', ['artwork' => $art->id]) }}" onsubmit="return confirm('Really remove from favourites?');">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="rounded-md border border-white/15 bg-black/60 px-2 py-1 text-xs text-red-300 hover:text-red-200">Remove</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="mt-6" data-gallery-pagination>{{ $artworks->links() }}</div>
|
||||
<div class="hidden" data-gallery-skeleton-template aria-hidden="true">
|
||||
<x-skeleton.artwork-card />
|
||||
</div>
|
||||
<div class="hidden mt-8" data-gallery-skeleton></div>
|
||||
</section>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
9
resources/views/feed/following.blade.php
Normal file
9
resources/views/feed/following.blade.php
Normal file
@@ -0,0 +1,9 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@push('head')
|
||||
@vite(['resources/js/feed.jsx'])
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
@inertia
|
||||
@endsection
|
||||
9
resources/views/feed/hashtag.blade.php
Normal file
9
resources/views/feed/hashtag.blade.php
Normal file
@@ -0,0 +1,9 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@push('head')
|
||||
@vite(['resources/js/feed.jsx'])
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
@inertia
|
||||
@endsection
|
||||
9
resources/views/feed/saved.blade.php
Normal file
9
resources/views/feed/saved.blade.php
Normal file
@@ -0,0 +1,9 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@push('head')
|
||||
@vite(['resources/js/feed.jsx'])
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
@inertia
|
||||
@endsection
|
||||
9
resources/views/feed/search.blade.php
Normal file
9
resources/views/feed/search.blade.php
Normal file
@@ -0,0 +1,9 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@push('head')
|
||||
@vite(['resources/js/feed.jsx'])
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
@inertia
|
||||
@endsection
|
||||
9
resources/views/feed/trending.blade.php
Normal file
9
resources/views/feed/trending.blade.php
Normal file
@@ -0,0 +1,9 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@push('head')
|
||||
@vite(['resources/js/feed.jsx'])
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
@inertia
|
||||
@endsection
|
||||
@@ -1,27 +1,19 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@php
|
||||
$forumEditPostProps = json_encode([
|
||||
'post' => ['id' => $post->id, 'content' => $post->content],
|
||||
'thread' => ['id' => $thread->id, 'title' => $thread->title, 'slug' => $thread->slug],
|
||||
'csrfToken' => csrf_token(),
|
||||
'errors' => $errors->toArray(),
|
||||
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
|
||||
@endphp
|
||||
|
||||
@section('content')
|
||||
<div class="legacy-page max-w-3xl">
|
||||
<div class="mb-6">
|
||||
<a href="{{ route('forum.thread.show', ['thread' => $thread->id, 'slug' => $thread->slug]) }}" class="text-sm text-sky-300 hover:text-sky-200">← Back to thread</a>
|
||||
<h1 class="mt-2 text-2xl font-semibold text-white">Edit post</h1>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ route('forum.post.update', ['post' => $post->id]) }}" class="space-y-4 rounded-lg border border-white/10 bg-zinc-900/70 p-4">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
<div>
|
||||
<label for="content" class="mb-1 block text-sm font-medium text-zinc-200">Content</label>
|
||||
<textarea id="content" name="content" rows="10" required class="w-full rounded border border-white/10 bg-zinc-950 px-3 py-2 text-zinc-100">{{ old('content', $post->content) }}</textarea>
|
||||
@error('content')
|
||||
<div class="mt-1 text-xs text-red-400">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button type="submit" class="rounded bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-500">Save changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div id="forum-edit-post-root"></div>
|
||||
<script type="application/json" id="forum-edit-post-props">{!! $forumEditPostProps !!}</script>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
@vite(['resources/js/entry-forum.jsx'])
|
||||
@endpush
|
||||
|
||||
@@ -1,34 +1,19 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@php
|
||||
$forumNewThreadProps = json_encode([
|
||||
'category' => ['id' => $category->id, 'name' => $category->name, 'slug' => $category->slug],
|
||||
'csrfToken' => csrf_token(),
|
||||
'errors' => $errors->toArray(),
|
||||
'oldValues' => ['title' => old('title', ''), 'content' => old('content', '')],
|
||||
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
|
||||
@endphp
|
||||
|
||||
@section('content')
|
||||
<div class="legacy-page max-w-3xl">
|
||||
<div class="mb-6">
|
||||
<a href="{{ route('forum.category.show', ['category' => $category->slug]) }}" class="text-sm text-sky-300 hover:text-sky-200">← Back to section</a>
|
||||
<h1 class="mt-2 text-2xl font-semibold text-white">Create thread in {{ $category->name }}</h1>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ route('forum.thread.store', ['category' => $category->slug]) }}" class="space-y-4 rounded-lg border border-white/10 bg-zinc-900/70 p-4">
|
||||
@csrf
|
||||
|
||||
<div>
|
||||
<label for="title" class="mb-1 block text-sm font-medium text-zinc-200">Title</label>
|
||||
<input id="title" name="title" value="{{ old('title') }}" required maxlength="255" class="w-full rounded border border-white/10 bg-zinc-950 px-3 py-2 text-zinc-100" />
|
||||
@error('title')
|
||||
<div class="mt-1 text-xs text-red-400">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="content" class="mb-1 block text-sm font-medium text-zinc-200">Content</label>
|
||||
<textarea id="content" name="content" rows="10" required class="w-full rounded border border-white/10 bg-zinc-950 px-3 py-2 text-zinc-100">{{ old('content') }}</textarea>
|
||||
@error('content')
|
||||
<div class="mt-1 text-xs text-red-400">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button type="submit" class="rounded bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-500">Publish thread</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div id="forum-new-thread-root"></div>
|
||||
<script type="application/json" id="forum-new-thread-props">{!! $forumNewThreadProps !!}</script>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
@vite(['resources/js/entry-forum.jsx'])
|
||||
@endpush
|
||||
|
||||
@@ -1,74 +1,37 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@php
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
$threadsData = collect($subtopics->items())->map(fn ($sub) => [
|
||||
'topic_id' => (int) ($sub->topic_id ?? $sub->id ?? 0),
|
||||
'topic' => $sub->topic ?? $sub->title ?? 'Untitled',
|
||||
'discuss' => $sub->discuss ?? null,
|
||||
'num_posts' => (int) ($sub->num_posts ?? 0),
|
||||
'uname' => $sub->uname ?? 'Unknown',
|
||||
'last_update' => $sub->last_update ?? $sub->post_date ?? null,
|
||||
'is_pinned' => $sub->is_pinned ?? false,
|
||||
])->values();
|
||||
|
||||
$paginationData = (isset($subtopics) && method_exists($subtopics, 'currentPage'))
|
||||
? [
|
||||
'current_page' => $subtopics->currentPage(),
|
||||
'last_page' => $subtopics->lastPage(),
|
||||
'per_page' => $subtopics->perPage(),
|
||||
'total' => $subtopics->total(),
|
||||
] : null;
|
||||
|
||||
$forumCategoryProps = json_encode([
|
||||
'category' => ['id' => $category->id ?? null, 'name' => $category->name ?? '', 'slug' => $category->slug ?? ''],
|
||||
'threads' => $threadsData,
|
||||
'pagination' => $paginationData,
|
||||
'isAuthenticated' => auth()->check(),
|
||||
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
|
||||
@endphp
|
||||
|
||||
@section('content')
|
||||
<div class="legacy-page">
|
||||
<div class="mb-6">
|
||||
<a href="{{ route('forum.index') }}" class="text-sm text-sky-300 hover:text-sky-200">← Back to forum</a>
|
||||
<h1 class="mt-2 text-2xl font-semibold text-white">{{ $topic->topic ?? $topic->title ?? 'Topic' }}</h1>
|
||||
@if (!empty($topic->discuss))
|
||||
<p class="mt-1 text-sm text-zinc-300">{!! Str::limit(strip_tags((string) $topic->discuss), 220) !!}</p>
|
||||
@endif
|
||||
@if (isset($category) && auth()->check())
|
||||
<div class="mt-3">
|
||||
<a href="{{ route('forum.thread.create', ['category' => $category->slug]) }}" class="rounded bg-sky-600 px-3 py-2 text-xs font-medium text-white hover:bg-sky-500">New thread</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-lg border border-white/10 bg-zinc-900/70">
|
||||
<div class="border-b border-white/10 px-4 py-3 text-sm font-semibold text-zinc-100">Threads</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-white/10 text-sm">
|
||||
<thead class="bg-zinc-800/60 text-zinc-300">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left font-medium">Thread</th>
|
||||
<th class="px-4 py-3 text-center font-medium">Posts</th>
|
||||
<th class="px-4 py-3 text-center font-medium">By</th>
|
||||
<th class="px-4 py-3 text-right font-medium">Last Update</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-white/10 text-zinc-100">
|
||||
@forelse (($subtopics ?? []) as $sub)
|
||||
@php
|
||||
$id = (int) ($sub->topic_id ?? $sub->id ?? 0);
|
||||
$title = $sub->topic ?? $sub->title ?? 'Untitled';
|
||||
@endphp
|
||||
<tr class="hover:bg-white/5">
|
||||
<td class="px-4 py-3 align-top">
|
||||
<a class="font-medium text-sky-300 hover:text-sky-200" href="{{ route('forum.thread.show', ['thread' => $id, 'slug' => Str::slug($title)]) }}">{{ $title }}</a>
|
||||
@if (!empty($sub->discuss))
|
||||
<div class="mt-1 text-xs text-zinc-400">{!! Str::limit(strip_tags((string) $sub->discuss), 180) !!}</div>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center text-zinc-300">{{ $sub->num_posts ?? 0 }}</td>
|
||||
<td class="px-4 py-3 text-center text-zinc-300">{{ $sub->uname ?? 'Unknown' }}</td>
|
||||
<td class="px-4 py-3 text-right text-zinc-400">
|
||||
@if (!empty($sub->last_update))
|
||||
{{ Carbon::parse($sub->last_update)->format('d.m.Y H:i') }}
|
||||
@elseif (!empty($sub->post_date))
|
||||
{{ Carbon::parse($sub->post_date)->format('d.m.Y H:i') }}
|
||||
@else
|
||||
-
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="4" class="px-4 py-6 text-center text-zinc-400">No threads in this section yet.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isset($subtopics) && method_exists($subtopics, 'links'))
|
||||
<div class="mt-4">{{ $subtopics->withQueryString()->links() }}</div>
|
||||
@endif
|
||||
</div>
|
||||
<div id="forum-category-root"></div>
|
||||
<script type="application/json" id="forum-category-props">{!! $forumCategoryProps !!}</script>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
@vite(['resources/js/entry-forum.jsx'])
|
||||
@endpush
|
||||
|
||||
@@ -1,24 +1,16 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
<main class="min-h-screen bg-slate-950 px-4 py-10" aria-labelledby="forum-page-title">
|
||||
<div class="mx-auto max-w-7xl">
|
||||
<header class="mb-8">
|
||||
<h1 id="forum-page-title" class="text-3xl font-semibold text-white">Forum</h1>
|
||||
<p class="mt-2 text-sm text-white/60">Browse forum sections and latest activity.</p>
|
||||
</header>
|
||||
@php
|
||||
$forumIndexProps = json_encode([
|
||||
'categories' => $categories ?? [],
|
||||
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
|
||||
@endphp
|
||||
|
||||
@if (($categories ?? collect())->isEmpty())
|
||||
<div class="rounded-xl border border-white/10 bg-slate-900/60 p-8 text-center text-white/70">
|
||||
No forum categories available yet.
|
||||
</div>
|
||||
@else
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3" role="list" aria-label="Forum categories">
|
||||
@foreach ($categories as $category)
|
||||
<x-forum.category-card :category="$category" />
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</main>
|
||||
@section('content')
|
||||
<div id="forum-index-root"></div>
|
||||
<script type="application/json" id="forum-index-props">{!! $forumIndexProps !!}</script>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
@vite(['resources/js/entry-forum.jsx'])
|
||||
@endpush
|
||||
|
||||
@@ -1,124 +1,85 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@php
|
||||
use App\Support\ForumPostContent;
|
||||
use App\Support\AvatarUrl;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
$filesBaseUrl = rtrim((string) config('cdn.files_url', ''), '/');
|
||||
|
||||
$serializePost = function ($post) use ($filesBaseUrl) {
|
||||
$user = $post->user ?? null;
|
||||
return [
|
||||
'id' => $post->id,
|
||||
'user_id' => $post->user_id,
|
||||
'content' => $post->content,
|
||||
'rendered_content' => ForumPostContent::render($post->content),
|
||||
'created_at' => $post->created_at?->toIso8601String(),
|
||||
'edited_at' => $post->edited_at?->toIso8601String(),
|
||||
'is_edited' => (bool) $post->is_edited,
|
||||
'can_edit' => auth()->check() && (
|
||||
(int) $post->user_id === (int) auth()->id() || Gate::allows('moderate-forum')
|
||||
),
|
||||
'current_user_id' => auth()->id(),
|
||||
'user' => $user ? [
|
||||
'id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'avatar_url' => AvatarUrl::forUser((int) $user->id, $user->profile?->avatar_hash ?? null),
|
||||
'role' => $user->role ?? 'member',
|
||||
] : null,
|
||||
'attachments' => collect($post->attachments ?? [])->map(fn ($a) => [
|
||||
'id' => $a->id,
|
||||
'mime_type' => $a->mime_type,
|
||||
'url' => $filesBaseUrl !== '' ? $filesBaseUrl . '/' . ltrim($a->file_path, '/') : '/' . ltrim($a->file_path, '/'),
|
||||
'file_size' => $a->file_size,
|
||||
'width' => $a->width,
|
||||
'height' => $a->height,
|
||||
])->values()->all(),
|
||||
];
|
||||
};
|
||||
|
||||
$serializedOp = isset($opPost) && $opPost ? $serializePost($opPost) : null;
|
||||
$serializedPosts = collect($posts->items())->map($serializePost)->values()->all();
|
||||
|
||||
$paginationData = [
|
||||
'current_page' => $posts->currentPage(),
|
||||
'last_page' => $posts->lastPage(),
|
||||
'per_page' => $posts->perPage(),
|
||||
'total' => $posts->total(),
|
||||
];
|
||||
@endphp
|
||||
|
||||
@section('content')
|
||||
<main class="min-h-screen bg-slate-950 px-4 py-8" aria-labelledby="thread-title">
|
||||
<div class="mx-auto max-w-5xl space-y-5">
|
||||
<x-forum.thread.breadcrumbs :thread="$thread" :category="$category" />
|
||||
|
||||
@if (session('status'))
|
||||
<div class="rounded-xl border border-emerald-500/20 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-300">
|
||||
{{ session('status') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<section class="rounded-xl border border-white/5 bg-slate-900/70 p-5 backdrop-blur">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 id="thread-title" class="text-2xl font-semibold text-white">{{ $thread->title }}</h1>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-2 text-xs text-zinc-400">
|
||||
<span>By {{ $author->name ?? 'Unknown' }}</span>
|
||||
<span aria-hidden="true">•</span>
|
||||
<time datetime="{{ optional($thread->created_at)?->toIso8601String() }}">{{ optional($thread->created_at)?->format('d.m.Y H:i') }}</time>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span class="rounded-full bg-sky-500/15 px-2.5 py-1 text-sky-300">{{ number_format((int) ($thread->views ?? 0)) }} views</span>
|
||||
<span class="rounded-full bg-cyan-500/15 px-2.5 py-1 text-cyan-300">{{ number_format((int) ($reply_count ?? 0)) }} replies</span>
|
||||
@if ($thread->is_pinned)
|
||||
<span class="rounded-full bg-amber-500/15 px-2.5 py-1 text-amber-300">Pinned</span>
|
||||
@endif
|
||||
@if ($thread->is_locked)
|
||||
<span class="rounded-full bg-red-500/15 px-2.5 py-1 text-red-300">Locked</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@can('moderate-forum')
|
||||
<div class="mt-4 flex flex-wrap items-center gap-2 border-t border-white/10 pt-3 text-xs">
|
||||
@if ($thread->is_locked)
|
||||
<form method="POST" action="{{ route('forum.thread.unlock', ['thread' => $thread->id]) }}">
|
||||
@csrf
|
||||
<button type="submit" class="rounded-md bg-red-500/15 px-2.5 py-1 text-red-300 hover:bg-red-500/25">Unlock thread</button>
|
||||
</form>
|
||||
@else
|
||||
<form method="POST" action="{{ route('forum.thread.lock', ['thread' => $thread->id]) }}">
|
||||
@csrf
|
||||
<button type="submit" class="rounded-md bg-red-500/15 px-2.5 py-1 text-red-300 hover:bg-red-500/25">Lock thread</button>
|
||||
</form>
|
||||
@endif
|
||||
|
||||
@if ($thread->is_pinned)
|
||||
<form method="POST" action="{{ route('forum.thread.unpin', ['thread' => $thread->id]) }}">
|
||||
@csrf
|
||||
<button type="submit" class="rounded-md bg-amber-500/15 px-2.5 py-1 text-amber-300 hover:bg-amber-500/25">Unpin thread</button>
|
||||
</form>
|
||||
@else
|
||||
<form method="POST" action="{{ route('forum.thread.pin', ['thread' => $thread->id]) }}">
|
||||
@csrf
|
||||
<button type="submit" class="rounded-md bg-amber-500/15 px-2.5 py-1 text-amber-300 hover:bg-amber-500/25">Pin thread</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
@endcan
|
||||
</section>
|
||||
|
||||
@if (isset($opPost) && $opPost)
|
||||
<x-forum.thread.post-card :post="$opPost" :thread="$thread" :is-op="true" />
|
||||
@endif
|
||||
|
||||
<section class="space-y-4" aria-label="Replies">
|
||||
@forelse ($posts as $post)
|
||||
<x-forum.thread.post-card :post="$post" :thread="$thread" />
|
||||
@empty
|
||||
<div class="rounded-xl border border-white/10 bg-slate-900/60 px-4 py-6 text-center text-zinc-400">
|
||||
No replies yet.
|
||||
</div>
|
||||
@endforelse
|
||||
</section>
|
||||
|
||||
@if (method_exists($posts, 'links'))
|
||||
<div class="sticky bottom-3 z-10 rounded-xl border border-white/10 bg-slate-900/80 p-2 backdrop-blur supports-[backdrop-filter]:bg-slate-900/70">
|
||||
{{ $posts->withQueryString()->links() }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@auth
|
||||
@if (!$thread->is_locked)
|
||||
<form method="POST" action="{{ route('forum.thread.reply', ['thread' => $thread->id]) }}" class="space-y-3 rounded-xl border border-white/5 bg-slate-900/70 p-4 backdrop-blur">
|
||||
@csrf
|
||||
<div class="flex items-center justify-between">
|
||||
<label for="reply-content" class="text-sm font-medium text-zinc-200">Reply</label>
|
||||
<span class="text-xs text-zinc-500">Minimum 2 characters</span>
|
||||
</div>
|
||||
<div class="rounded-lg border border-white/10 bg-slate-950 p-2">
|
||||
<div class="mb-2 flex items-center gap-2 text-xs">
|
||||
<button type="button" class="rounded bg-slate-800 px-2 py-1 text-zinc-200" aria-pressed="true">Write</button>
|
||||
<span class="rounded bg-slate-900 px-2 py-1 text-zinc-500">Preview (coming soon)</span>
|
||||
</div>
|
||||
<textarea id="reply-content" name="content" rows="6" required minlength="2" maxlength="10000" class="w-full rounded-lg border border-white/10 bg-slate-950 px-3 py-2 text-zinc-100 placeholder-zinc-500 focus:border-cyan-400 focus:outline-none focus:ring-1 focus:ring-cyan-400">{{ $reply_prefill ?? old('content') }}</textarea>
|
||||
</div>
|
||||
@error('content')
|
||||
<p class="text-xs text-red-400">{{ $message }}</p>
|
||||
@enderror
|
||||
@if (!empty($quoted_post))
|
||||
<p class="text-xs text-cyan-300">Replying with quote from {{ data_get($quoted_post, 'user.name', 'Anonymous') }}.</p>
|
||||
@endif
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-xs text-zinc-500">Markdown/BBCode + attachments will be enabled in next pass</p>
|
||||
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-sky-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-400">Post reply</button>
|
||||
</div>
|
||||
</form>
|
||||
@else
|
||||
<div class="rounded-xl border border-red-500/20 bg-red-500/5 px-4 py-3 text-sm text-red-300">
|
||||
This thread is locked. Replies are disabled.
|
||||
</div>
|
||||
@endif
|
||||
@else
|
||||
<div class="rounded-xl border border-white/10 bg-slate-900/60 px-4 py-4 text-sm text-zinc-300">
|
||||
<a href="{{ route('login') }}" class="text-sky-300 hover:text-sky-200">Sign in</a> to post a reply.
|
||||
</div>
|
||||
@endauth
|
||||
</div>
|
||||
</main>
|
||||
<div id="forum-thread-root"></div>
|
||||
@php
|
||||
$forumThreadProps = json_encode([
|
||||
'thread' => [
|
||||
'id' => $thread->id,
|
||||
'title' => $thread->title,
|
||||
'slug' => $thread->slug,
|
||||
'views' => (int) ($thread->views ?? 0),
|
||||
'is_pinned' => (bool) $thread->is_pinned,
|
||||
'is_locked' => (bool) $thread->is_locked,
|
||||
'created_at' => $thread->created_at?->toIso8601String(),
|
||||
],
|
||||
'category' => ['id' => $category->id ?? null, 'name' => $category->name ?? '', 'slug' => $category->slug ?? ''],
|
||||
'author' => ['name' => $author->name ?? 'Unknown'],
|
||||
'opPost' => $serializedOp,
|
||||
'posts' => $serializedPosts,
|
||||
'pagination' => $paginationData,
|
||||
'replyCount' => (int) ($reply_count ?? 0),
|
||||
'sort' => $sort ?? 'asc',
|
||||
'quotedPost' => $quoted_post ? ['user' => ['name' => data_get($quoted_post, 'user.name', 'Anonymous')]] : null,
|
||||
'replyPrefill' => $reply_prefill ?? '',
|
||||
'isAuthenticated' => auth()->check(),
|
||||
'canModerate' => Gate::allows('moderate-forum'),
|
||||
'csrfToken' => csrf_token(),
|
||||
'status' => session('status'),
|
||||
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
|
||||
@endphp
|
||||
<script type="application/json" id="forum-thread-props">{!! $forumThreadProps !!}</script>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
@vite(['resources/js/entry-forum.jsx'])
|
||||
@endpush
|
||||
|
||||
@@ -76,9 +76,31 @@
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Posts & Feed Settings --}}
|
||||
<div class="border-t border-gray-200 pt-6">
|
||||
<h3 class="text-sm font-medium text-gray-900">{{ __('Posts & Feed') }}</h3>
|
||||
<div class="mt-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-700">{{ __('Auto-post new uploads') }}</p>
|
||||
<p class="text-xs text-gray-500 mt-0.5">{{ __('Automatically create a feed post when you publish new artwork.') }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="hidden" name="auto_post_upload" value="0" />
|
||||
<input
|
||||
id="auto_post_upload"
|
||||
type="checkbox"
|
||||
name="auto_post_upload"
|
||||
value="1"
|
||||
{{ optional(auth()->user()->profile)->auto_post_upload ? 'checked' : '' }}
|
||||
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500"
|
||||
/>
|
||||
<label for="auto_post_upload" class="text-sm text-gray-600">{{ __('Enabled') }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<x-primary-button>{{ __('Save') }}</x-primary-button>
|
||||
|
||||
@if (session('status') === 'profile-updated')
|
||||
<p
|
||||
x-data="{ show: true }"
|
||||
|
||||
20
resources/views/profile/show.blade.php
Normal file
20
resources/views/profile/show.blade.php
Normal file
@@ -0,0 +1,20 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@push('head')
|
||||
@vite(['resources/js/profile.jsx'])
|
||||
{{-- OG image (not in nova base layout) --}}
|
||||
@if(!empty($og_image))
|
||||
<meta property="og:image" content="{{ $og_image }}">
|
||||
@endif
|
||||
<meta property="og:url" content="{{ $page_canonical ?? url()->current() }}">
|
||||
<style>
|
||||
/* Ensure profile tab bar does not hide behind the main navbar */
|
||||
.profile-tabs-sticky {
|
||||
top: var(--navbar-height, 4rem);
|
||||
}
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
@inertia
|
||||
@endsection
|
||||
38
resources/views/web/discover/_nav.blade.php
Normal file
38
resources/views/web/discover/_nav.blade.php
Normal file
@@ -0,0 +1,38 @@
|
||||
{{--
|
||||
Discover section-switcher pills.
|
||||
|
||||
Expected variable: $section (string) — the active section slug, e.g. 'trending', 'for-you'
|
||||
Expected variable: $isAuthenticated (bool, optional) — whether the user is logged in
|
||||
--}}
|
||||
|
||||
@php
|
||||
$active = $section ?? '';
|
||||
$isAuth = $isAuthenticated ?? auth()->check();
|
||||
|
||||
$sections = collect([
|
||||
'for-you' => ['label' => 'For You', 'icon' => 'fa-wand-magic-sparkles', 'auth' => true, 'activeClass' => 'bg-yellow-500/20 text-yellow-300 border border-yellow-400/20'],
|
||||
'following' => ['label' => 'Following', 'icon' => 'fa-user-group', 'auth' => true, 'activeClass' => 'bg-sky-600 text-white'],
|
||||
'trending' => ['label' => 'Trending', 'icon' => 'fa-fire', 'auth' => false, 'activeClass' => 'bg-sky-600 text-white'],
|
||||
'rising' => ['label' => 'Rising', 'icon' => 'fa-rocket', 'auth' => false, 'activeClass' => 'bg-sky-600 text-white'],
|
||||
'fresh' => ['label' => 'Fresh', 'icon' => 'fa-bolt', 'auth' => false, 'activeClass' => 'bg-sky-600 text-white'],
|
||||
'top-rated' => ['label' => 'Top Rated', 'icon' => 'fa-medal', 'auth' => false, 'activeClass' => 'bg-sky-600 text-white'],
|
||||
'most-downloaded' => ['label' => 'Most Downloaded', 'icon' => 'fa-download', 'auth' => false, 'activeClass' => 'bg-sky-600 text-white'],
|
||||
'on-this-day' => ['label' => 'On This Day', 'icon' => 'fa-calendar-day', 'auth' => false, 'activeClass' => 'bg-sky-600 text-white'],
|
||||
]);
|
||||
@endphp
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 text-sm">
|
||||
@foreach($sections as $slug => $meta)
|
||||
@if($meta['auth'] && !$isAuth)
|
||||
@continue
|
||||
@endif
|
||||
<a href="{{ route('discover.' . $slug) }}"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors
|
||||
{{ $active === $slug
|
||||
? $meta['activeClass']
|
||||
: 'bg-white/[0.05] text-white/60 hover:bg-white/[0.1] hover:text-white' }}">
|
||||
<i class="fa-solid {{ $meta['icon'] }} text-xs {{ $active === $slug && $slug === 'for-you' ? '' : '' }}"></i>
|
||||
{{ $meta['label'] }}
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@@ -15,28 +15,7 @@
|
||||
</div>
|
||||
|
||||
{{-- Section switcher pills --}}
|
||||
<div class="flex flex-wrap items-center gap-2 text-sm">
|
||||
<a href="{{ route('discover.for-you') }}"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors bg-yellow-500/20 text-yellow-300 border border-yellow-400/20">
|
||||
<i class="fa-solid fa-wand-magic-sparkles text-xs"></i>
|
||||
For You
|
||||
</a>
|
||||
@php
|
||||
$sections = [
|
||||
'trending' => ['label' => 'Trending', 'icon' => 'fa-fire'],
|
||||
'fresh' => ['label' => 'Fresh', 'icon' => 'fa-bolt'],
|
||||
'top-rated' => ['label' => 'Top Rated', 'icon' => 'fa-medal'],
|
||||
'most-downloaded' => ['label' => 'Most Downloaded', 'icon' => 'fa-download'],
|
||||
];
|
||||
@endphp
|
||||
@foreach($sections as $slug => $meta)
|
||||
<a href="{{ route('discover.' . $slug) }}"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors bg-white/[0.05] text-white/60 hover:bg-white/[0.1] hover:text-white">
|
||||
<i class="fa-solid {{ $meta['icon'] }} text-xs"></i>
|
||||
{{ $meta['label'] }}
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@include('web.discover._nav', ['section' => 'for-you'])
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -17,34 +17,7 @@
|
||||
</div>
|
||||
|
||||
{{-- Section switcher pills --}}
|
||||
<div class="flex flex-wrap items-center gap-2 text-sm">
|
||||
@auth
|
||||
<a href="{{ route('discover.for-you') }}"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors bg-white/[0.05] text-white/60 hover:bg-white/[0.1] hover:text-white">
|
||||
<i class="fa-solid fa-wand-magic-sparkles text-xs text-yellow-400/80"></i>
|
||||
For You
|
||||
</a>
|
||||
@endauth
|
||||
@php
|
||||
$sections = [
|
||||
'trending' => ['label' => 'Trending', 'icon' => 'fa-fire'],
|
||||
'rising' => ['label' => 'Rising', 'icon' => 'fa-rocket'],
|
||||
'fresh' => ['label' => 'Fresh', 'icon' => 'fa-bolt'],
|
||||
'top-rated' => ['label' => 'Top Rated', 'icon' => 'fa-medal'],
|
||||
'most-downloaded' => ['label' => 'Most Downloaded', 'icon' => 'fa-download'],
|
||||
'on-this-day' => ['label' => 'On This Day', 'icon' => 'fa-calendar-day'],
|
||||
];
|
||||
$active = $section ?? '';
|
||||
@endphp
|
||||
@foreach($sections as $slug => $meta)
|
||||
<a href="{{ route('discover.' . $slug) }}"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors
|
||||
{{ $active === $slug ? 'bg-sky-600 text-white' : 'bg-white/[0.05] text-white/60 hover:bg-white/[0.1] hover:text-white' }}">
|
||||
<i class="fa-solid {{ $meta['icon'] }} text-xs"></i>
|
||||
{{ $meta['label'] }}
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@include('web.discover._nav', ['section' => $section ?? ''])
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user