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:
2026-03-03 09:48:31 +01:00
parent 1266f81d35
commit dc51d65440
178 changed files with 14308 additions and 665 deletions

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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">&ldquo;{query}&rdquo;</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">&ldquo;{query}&rdquo;</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>
)
}

View 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 &amp; engagement over the last 7 days.
</p>
</div>
</aside>
</div>
</div>
</div>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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 ''
}
}

View 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>
)
}

View File

@@ -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>