Initial commit — OFApp client + server
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
267
client/src/pages/UserPosts.jsx
Normal file
267
client/src/pages/UserPosts.jsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { getUserPosts, getUser, startDownload, getDownloadCursor } from '../api'
|
||||
import PostCard from '../components/PostCard'
|
||||
|
||||
function decodeHTML(str) {
|
||||
if (!str) return str
|
||||
const el = document.createElement('textarea')
|
||||
el.innerHTML = str
|
||||
return el.value
|
||||
}
|
||||
import Spinner from '../components/Spinner'
|
||||
import LoadMoreButton from '../components/LoadMoreButton'
|
||||
|
||||
export default function UserPosts() {
|
||||
const { userId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
const [user, setUser] = useState(location.state?.user || null)
|
||||
const [posts, setPosts] = useState([])
|
||||
const [tailMarker, setTailMarker] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [downloading, setDownloading] = useState(false)
|
||||
const [showDownloadMenu, setShowDownloadMenu] = useState(false)
|
||||
const [cursorInfo, setCursorInfo] = useState(null)
|
||||
|
||||
const fetchCursor = () => {
|
||||
getDownloadCursor(userId).then((data) => {
|
||||
if (data && data.hasCursor) setCursorInfo(data)
|
||||
else setCursorInfo(null)
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadPosts()
|
||||
fetchCursor()
|
||||
|
||||
// Fetch user info if not passed via location state
|
||||
if (!user) {
|
||||
getUser(userId).then((data) => {
|
||||
if (!data.error) {
|
||||
setUser(data)
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [userId])
|
||||
|
||||
const loadPosts = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const data = await getUserPosts(userId)
|
||||
if (data.error) {
|
||||
setError(data.error)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const items = data.list || data || []
|
||||
setPosts(items)
|
||||
setTailMarker(data.tailMarker || null)
|
||||
setHasMore(items.length > 0 && !!data.tailMarker)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const loadMore = async () => {
|
||||
if (!tailMarker || loadingMore) return
|
||||
|
||||
setLoadingMore(true)
|
||||
const data = await getUserPosts(userId, tailMarker)
|
||||
|
||||
if (!data.error) {
|
||||
const items = data.list || data || []
|
||||
setPosts((prev) => [...prev, ...items])
|
||||
setTailMarker(data.tailMarker || null)
|
||||
setHasMore(items.length > 0 && !!data.tailMarker)
|
||||
}
|
||||
|
||||
setLoadingMore(false)
|
||||
}
|
||||
|
||||
const handleDownload = async (limit, resume) => {
|
||||
setShowDownloadMenu(false)
|
||||
setDownloading(true)
|
||||
const result = await startDownload(userId, limit, resume, user?.username)
|
||||
if (result.error) {
|
||||
console.error('Download failed:', result.error)
|
||||
}
|
||||
setTimeout(() => {
|
||||
setDownloading(false)
|
||||
fetchCursor()
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Back Button */}
|
||||
<button
|
||||
onClick={() => navigate('/users')}
|
||||
className="flex items-center gap-2 text-gray-400 hover:text-white text-sm mb-6 transition-colors"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18"
|
||||
/>
|
||||
</svg>
|
||||
Back to Users
|
||||
</button>
|
||||
|
||||
{/* User Header */}
|
||||
{user && (
|
||||
<div className="flex items-center justify-between bg-[#161616] border border-[#222] rounded-lg p-4 mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt={user.name}
|
||||
className="w-14 h-14 rounded-full object-cover bg-[#1a1a1a]"
|
||||
onError={(e) => {
|
||||
e.target.src = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 56 56"><rect fill="%23333" width="56" height="56" rx="28"/><text x="28" y="35" text-anchor="middle" fill="white" font-size="20">${(user.name || '?')[0]}</text></svg>`
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-white">{decodeHTML(user.name)}</h1>
|
||||
<p className="text-gray-500 text-sm">@{user.username}</p>
|
||||
{(user.postsCount !== undefined || user.mediasCount !== undefined) && (
|
||||
<p className="text-gray-600 text-xs mt-0.5">
|
||||
{user.postsCount !== undefined && `${user.postsCount.toLocaleString()} posts`}
|
||||
{user.postsCount !== undefined && user.mediasCount !== undefined && ' · '}
|
||||
{user.mediasCount !== undefined && `${user.mediasCount.toLocaleString()} media`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowDownloadMenu((v) => !v)}
|
||||
disabled={downloading}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-[#0095f6] hover:bg-[#0081d6] disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
|
||||
/>
|
||||
</svg>
|
||||
{downloading ? 'Starting...' : 'Download Media'}
|
||||
<svg
|
||||
className="w-3 h-3 ml-1"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M19.5 8.25l-7.5 7.5-7.5-7.5"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{showDownloadMenu && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-10"
|
||||
onClick={() => setShowDownloadMenu(false)}
|
||||
/>
|
||||
<div className="absolute right-0 mt-2 w-52 bg-[#1a1a1a] border border-[#333] rounded-lg shadow-xl z-20 overflow-hidden">
|
||||
{cursorInfo && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleDownload(50, true)}
|
||||
className="w-full text-left px-4 py-2.5 text-sm text-[#0095f6] hover:bg-[#252525] font-medium transition-colors"
|
||||
>
|
||||
Continue (next 50)
|
||||
<span className="block text-xs text-gray-500 font-normal mt-0.5">
|
||||
{cursorInfo.postsDownloaded} posts downloaded
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDownload(100, true)}
|
||||
className="w-full text-left px-4 py-2.5 text-sm text-[#0095f6] hover:bg-[#252525] font-medium transition-colors"
|
||||
>
|
||||
Continue (next 100)
|
||||
</button>
|
||||
<div className="border-t border-[#333]" />
|
||||
</>
|
||||
)}
|
||||
{[
|
||||
{ label: 'Last 10 posts', value: 10 },
|
||||
{ label: 'Last 25 posts', value: 25 },
|
||||
{ label: 'Last 50 posts', value: 50 },
|
||||
{ label: 'Last 100 posts', value: 100 },
|
||||
{ label: 'All posts', value: null },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.label}
|
||||
onClick={() => handleDownload(opt.value)}
|
||||
className="w-full text-left px-4 py-2.5 text-sm text-gray-300 hover:bg-[#252525] hover:text-white transition-colors"
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Posts */}
|
||||
{loading ? (
|
||||
<Spinner />
|
||||
) : error ? (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-red-400 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={loadPosts}
|
||||
className="text-[#0095f6] hover:underline text-sm"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
) : posts.length === 0 ? (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-gray-500">No posts found</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{posts.map((post) => (
|
||||
<PostCard key={post.id} post={post} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<LoadMoreButton
|
||||
onClick={loadMore}
|
||||
loading={loadingMore}
|
||||
hasMore={hasMore}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user