268 lines
9.0 KiB
JavaScript
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>
|
|
)
|
|
}
|