Files
v2/front/src/App.tsx
2026-01-26 21:41:28 +08:00

184 lines
6.1 KiB
TypeScript

import React, { useCallback, useEffect, useRef, useState } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { tokens, useToastController, Toast, ToastTitle } 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 ImageViewer from './components/ImageViewer';
import { fetchArticles, type Article } from './api';
import { useLayout } from './context/LayoutContext';
import './App.css';
const Home: React.FC<{ onPreviewImage: (src: string, alt?: string) => void }> = ({ onPreviewImage }) => {
const { refreshTrigger } = useLayout();
const { toasterId } = useLayout();
const { dispatchToast } = useToastController(toasterId);
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);
dispatchToast(
<Toast>
<ToastTitle></ToastTitle>
</Toast>,
{ intent: 'success' }
);
}
}
};
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}
onPreviewImage={onPreviewImage}
/>
</div>
);
}
return (
<PostCard
key={article.id}
id={article.id}
content={article.content}
upvotes={article.upvotes}
downvotes={article.downvotes}
onPreviewImage={onPreviewImage}
/>
);
})}
{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() {
const [imageViewer, setImageViewer] = useState<{ open: boolean; src?: string; alt?: string }>({ open: false });
const openImageViewer = (src?: string, alt?: string) => {
if (!src) return;
setImageViewer({ open: true, src, alt });
};
const closeImageViewer = () => setImageViewer({ open: false });
return (
<BrowserRouter>
<Routes>
<Route
path="/"
element={
<MainLayout
imageViewer={
imageViewer.open && imageViewer.src ? (
<ImageViewer src={imageViewer.src!} alt={imageViewer.alt} onClose={closeImageViewer} />
) : null
}
/>
}
>
<Route index element={<Home onPreviewImage={openImageViewer} />} />
<Route path="create" element={<CreatePost />} />
<Route path="about" element={<About />} />
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
</BrowserRouter>
);
}
export default App;