diff --git a/front/package.json b/front/package.json index e56eb64..d320cba 100644 --- a/front/package.json +++ b/front/package.json @@ -18,7 +18,8 @@ "react-dom": "^19.2.0", "react-markdown": "^10.1.0", "react-router-dom": "^7.12.0", - "remark-gfm": "^4.0.1" + "remark-gfm": "^4.0.1", + "remark-ins": "^1.1.0" }, "devDependencies": { "@eslint/js": "^9.39.1", diff --git a/front/src/App.tsx b/front/src/App.tsx index b5d1b32..2acfb15 100644 --- a/front/src/App.tsx +++ b/front/src/App.tsx @@ -1,10 +1,139 @@ +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 = () =>

Home Page

; +const Home: React.FC = () => { + const { refreshTrigger } = useLayout(); + const [articles, setArticles] = useState([]); + 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(null); + const containerRef = useRef(null); + const lastRefreshAtRef = useRef(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 = (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 ( +
+
+ {articles.map((article, index) => { + if (articles.length === index + 1 && hasMore) { + return ( +
+ +
+ ); + } + return ( + + ); + })} + {loading &&
加载中...
} + {!loading && !hasMore && ( +
+
+
+ 已经到底了喵~ +
+
+
+ )} +
+
+ ); +}; + const NotFound = () =>

404 Not Found

; function App() { diff --git a/front/src/api.ts b/front/src/api.ts index 0c9084b..11f0af8 100644 --- a/front/src/api.ts +++ b/front/src/api.ts @@ -162,3 +162,52 @@ export const createPost = async (content: string): Promise = throw error; } }; + +export interface Article { + id: number; + content: string; + upvotes: number; + downvotes: number; + created_at?: string; + comment_count?: number; + total_pages?: number; +} + +export const fetchArticles = async (page: number, signal?: AbortSignal): Promise => { + try { + const response = await fetch(`/api/10_info?page=${page}`, { signal }); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const json = await response.json(); + if (json.code === 1000 && Array.isArray(json.data)) { + return json.data as Article[]; + } + throw new Error('Invalid response code or missing data'); + } catch (error) { + console.error('Failed to fetch articles:', error); + throw error; + } +}; + +export const voteArticle = async (id: number, type: 'up' | 'down'): Promise => { + try { + const response = await fetch(`/api/${type}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ id, type: 'submission' }), + }); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const json = await response.json(); + if (json.code !== 1000) { + throw new Error('Vote failed'); + } + } catch (error) { + console.error('Failed to vote:', error); + throw error; + } +}; diff --git a/front/src/components/PostCard.tsx b/front/src/components/PostCard.tsx new file mode 100644 index 0000000..1809128 --- /dev/null +++ b/front/src/components/PostCard.tsx @@ -0,0 +1,283 @@ +import { + makeStyles, + Card, + CardFooter, + Button, + tokens, + useToastController, + Toast, + ToastTitle, +} from '@fluentui/react-components'; +import React from 'react'; +import { voteArticle } from '../api'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import remarkIns from 'remark-ins'; +import { + ArrowUp24Regular, + ArrowDown24Regular, + Comment24Regular, + Warning24Regular, +} from '@fluentui/react-icons'; +import { useLayout } from '../context/LayoutContext'; + +// 自定义 remark 插件,用于高亮 #tag +const remarkTagPlugin = () => { + return (tree: any) => { + const transformNode = (node: any, inLink = false) => { + if (node.type === 'link') inLink = true; + + if (node.children) { + node.children = node.children.flatMap((child: any) => { + if (child.type === 'text' && !inLink) { + const parts = child.value.split(/(#\S+)/g); + return parts.map((part: string) => { + if (part.match(/^#\S+$/)) { + return { + type: 'link', + url: 'tag:' + part, + children: [{ type: 'text', value: part }] + }; + } + if (part === "") return []; + return { type: 'text', value: part }; + }).flat(); + } + return transformNode(child, inLink); + }); + } + return node; + }; + transformNode(tree); + }; +}; + +const useStyles = makeStyles({ + card: { + width: '100%', + maxWidth: '800px', + padding: tokens.spacingVerticalL, + marginBottom: tokens.spacingVerticalL, + }, + content: { + paddingTop: tokens.spacingVerticalS, + paddingBottom: tokens.spacingVerticalS, + '& h1, & h2, & h3, & h4, & h5, & h6': { + marginTop: '1em', + marginBottom: '0.5em', + fontWeight: 'bold', + }, + '& p': { + marginTop: '0.5em', + marginBottom: '0.5em', + lineHeight: '1.6', + }, + '& ul, & ol': { + marginTop: '0.5em', + marginBottom: '0.5em', + paddingLeft: '2em', + }, + '& li': { + marginTop: '0.25em', + marginBottom: '0.25em', + }, + '& blockquote': { + margin: '1em 0', + paddingLeft: '1em', + borderLeft: `3px solid ${tokens.colorNeutralStroke1}`, + color: tokens.colorNeutralForeground2, + }, + '& code': { + backgroundColor: tokens.colorNeutralBackground1, + padding: '2px 4px', + borderRadius: '3px', + fontFamily: 'monospace', + }, + '& pre': { + backgroundColor: tokens.colorNeutralBackground1, + padding: '1em', + borderRadius: '5px', + overflowX: 'auto', + marginTop: '1em', + marginBottom: '1em', + }, + '& table': { + borderCollapse: 'collapse', + width: '100%', + marginTop: '1em', + marginBottom: '1em', + }, + '& th, & td': { + border: `1px solid ${tokens.colorNeutralStroke1}`, + padding: '8px', + textAlign: 'left', + }, + '& th': { + backgroundColor: tokens.colorNeutralBackground1, + fontWeight: 'bold', + }, + '& ins': { + textDecoration: 'underline', + backgroundColor: 'transparent', + }, + '& a': { + color: tokens.colorBrandForegroundLink, + textDecoration: 'underline', + wordBreak: 'break-word', + }, + '& a:hover': { + textDecoration: 'underline', + }, + '& a:visited': { + color: tokens.colorBrandForegroundLink, + }, + '& a:focus': { + outline: `2px solid ${tokens.colorNeutralStroke1}`, + outlineOffset: '2px', + }, + '& img': { + maxWidth: '100%', + height: 'auto', + display: 'block', + borderRadius: tokens.borderRadiusSmall, + } + }, + actions: { + display: 'grid', + gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', + alignItems: 'center', + justifyItems: 'center', + gap: '0 8px', + }, +}); + +interface PostCardProps { + id: number; + content: string; + upvotes: number; + downvotes: number; + onPreviewImage?: (src: string, alt?: string) => void; +} + +const PostCard = ({ + id, + content, + upvotes, + downvotes, + onPreviewImage, +}: PostCardProps) => { + const styles = useStyles(); + const { toasterId } = useLayout(); + const { dispatchToast } = useToastController(toasterId); + const [votes, setVotes] = React.useState({ upvotes, downvotes }); + const [hasVoted, setHasVoted] = React.useState(false); + + React.useEffect(() => { + setVotes({ upvotes, downvotes }); + }, [upvotes, downvotes]); + + return ( + +
+
+ { + if (props.href && props.href.startsWith('tag:')) { + return {props.children}; + } + return ; + }, + img: (props) => ( + onPreviewImage?.(props.src as string, props.alt as string)} + /> + ), + }} + > + {content} + +
+
+ +
+ + +
+
+
+ ); +}; + +export default PostCard;