Files
OFApp/client/src/pages/UserPosts.jsx
Trey t c60de19348 Initial commit — OFApp client + server
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 20:07:06 -06:00

268 lines
9.0 KiB
JavaScript

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