155 lines
4.9 KiB
TypeScript
155 lines
4.9 KiB
TypeScript
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
|
import { tokens } from '@fluentui/react-components';
|
|
import { MainLayout } from './layouts/MainLayout';
|
|
import About from './components/About';
|
|
import CreatePost from './components/CreatePost';
|
|
import PostCard from './components/PostCard';
|
|
import { fetchArticles, type Article } from './api';
|
|
import { useLayout } from './context/LayoutContext';
|
|
import './App.css';
|
|
|
|
const Home: React.FC = () => {
|
|
const { refreshTrigger } = useLayout();
|
|
const [articles, setArticles] = useState<Article[]>([]);
|
|
const [page, setPage] = useState(1);
|
|
const [loading, setLoading] = useState(false);
|
|
const [hasMore, setHasMore] = useState(true);
|
|
const [homeRefreshTick, setHomeRefreshTick] = useState(0);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const observer = useRef<IntersectionObserver | null>(null);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const lastRefreshAtRef = useRef<number>(0);
|
|
const REFRESH_COOLDOWN_MS = 5000;
|
|
|
|
const lastArticleRef = useCallback((node: HTMLDivElement | null) => {
|
|
if (loading) return;
|
|
if (observer.current) observer.current.disconnect();
|
|
observer.current = new IntersectionObserver(entries => {
|
|
if (entries[0].isIntersecting && hasMore) {
|
|
setPage(prevPage => prevPage + 1);
|
|
}
|
|
});
|
|
if (node) observer.current.observe(node);
|
|
}, [loading, hasMore]);
|
|
|
|
const doRefresh = useCallback(() => {
|
|
if (refreshing || loading) return;
|
|
const now = Date.now();
|
|
if (now - lastRefreshAtRef.current < REFRESH_COOLDOWN_MS) return;
|
|
lastRefreshAtRef.current = now;
|
|
setRefreshing(true);
|
|
setArticles([]);
|
|
setHasMore(true);
|
|
setPage(1);
|
|
setHomeRefreshTick(t => t + 1);
|
|
if (containerRef.current) containerRef.current.scrollTop = 0;
|
|
}, [refreshing, loading]);
|
|
|
|
const onWheel: React.WheelEventHandler<HTMLDivElement> = (e) => {
|
|
const atTop = (containerRef.current?.scrollTop ?? 0) <= 0;
|
|
if (atTop && e.deltaY < 0) {
|
|
doRefresh();
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (refreshTrigger > 0) {
|
|
doRefresh();
|
|
}
|
|
}, [refreshTrigger, doRefresh]);
|
|
|
|
useEffect(() => {
|
|
const controller = new AbortController();
|
|
const signal = controller.signal;
|
|
|
|
const loadArticles = async () => {
|
|
if (!hasMore) return;
|
|
setLoading(true);
|
|
try {
|
|
const newArticles = await fetchArticles(page, signal);
|
|
if (newArticles.length === 0) {
|
|
setHasMore(false);
|
|
} else {
|
|
setArticles(prev => [...prev, ...newArticles]);
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof Error && error.name !== 'AbortError') {
|
|
console.error('Failed to load articles:', error);
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
if (refreshing) {
|
|
setRefreshing(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
loadArticles();
|
|
return () => controller.abort();
|
|
}, [page, hasMore, homeRefreshTick]);
|
|
|
|
return (
|
|
<div
|
|
style={{ width: '100%', height: '100%', overflowY: 'auto' }}
|
|
ref={containerRef}
|
|
onWheel={onWheel}
|
|
>
|
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', minHeight: '100%' }}>
|
|
{articles.map((article, index) => {
|
|
if (articles.length === index + 1 && hasMore) {
|
|
return (
|
|
<div ref={lastArticleRef} key={article.id}>
|
|
<PostCard
|
|
id={article.id}
|
|
content={article.content}
|
|
upvotes={article.upvotes}
|
|
downvotes={article.downvotes}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<PostCard
|
|
key={article.id}
|
|
id={article.id}
|
|
content={article.content}
|
|
upvotes={article.upvotes}
|
|
downvotes={article.downvotes}
|
|
/>
|
|
);
|
|
})}
|
|
{loading && <div>加载中...</div>}
|
|
{!loading && !hasMore && (
|
|
<div style={{ width: '100%', display: 'flex', alignItems: 'center', margin: '16px 0' }}>
|
|
<div style={{ flex: 1, height: 1, backgroundColor: tokens.colorNeutralStroke2 }} />
|
|
<div style={{ padding: '0 12px', color: tokens.colorNeutralForeground3, textAlign: 'center', whiteSpace: 'nowrap' }}>
|
|
已经到底了喵~
|
|
</div>
|
|
<div style={{ flex: 1, height: 1, backgroundColor: tokens.colorNeutralStroke2 }} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const NotFound = () => <h1>404 Not Found</h1>;
|
|
|
|
function App() {
|
|
return (
|
|
<BrowserRouter>
|
|
<Routes>
|
|
<Route path="/" element={<MainLayout />}>
|
|
<Route index element={<Home />} />
|
|
<Route path="create" element={<CreatePost />} />
|
|
<Route path="about" element={<About />} />
|
|
<Route path="*" element={<NotFound />} />
|
|
</Route>
|
|
</Routes>
|
|
</BrowserRouter>
|
|
);
|
|
}
|
|
|
|
export default App;
|