Add v2 post cards and home feed logic

This commit is contained in:
LeonspaceX
2026-01-26 16:24:40 +08:00
parent 16b7f30e78
commit 6268ab1544
4 changed files with 464 additions and 2 deletions

View File

@@ -18,7 +18,8 @@
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router-dom": "^7.12.0", "react-router-dom": "^7.12.0",
"remark-gfm": "^4.0.1" "remark-gfm": "^4.0.1",
"remark-ins": "^1.1.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",

View File

@@ -1,10 +1,139 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom'; import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { tokens } from '@fluentui/react-components';
import { MainLayout } from './layouts/MainLayout'; import { MainLayout } from './layouts/MainLayout';
import About from './components/About'; import About from './components/About';
import CreatePost from './components/CreatePost'; import CreatePost from './components/CreatePost';
import PostCard from './components/PostCard';
import { fetchArticles, type Article } from './api';
import { useLayout } from './context/LayoutContext';
import './App.css'; import './App.css';
const Home = () => <h1>Home Page</h1>; 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>; const NotFound = () => <h1>404 Not Found</h1>;
function App() { function App() {

View File

@@ -162,3 +162,52 @@ export const createPost = async (content: string): Promise<CreatePostResponse> =
throw error; 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<Article[]> => {
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<void> => {
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;
}
};

View File

@@ -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 (
<Card className={styles.card}>
<div className={styles.content}>
<div style={{ whiteSpace: 'pre-wrap' }}>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkIns, remarkTagPlugin]}
components={{
a: ({ node, ...props }) => {
if (props.href && props.href.startsWith('tag:')) {
return <span style={{ color: tokens.colorBrandForeground1 }}>{props.children}</span>;
}
return <a {...props} />;
},
img: (props) => (
<img
{...props}
style={{ cursor: 'zoom-in', maxWidth: '100%', height: 'auto', display: 'block' }}
onClick={() => onPreviewImage?.(props.src as string, props.alt as string)}
/>
),
}}
>
{content}
</ReactMarkdown>
</div>
</div>
<CardFooter>
<div className={styles.actions}>
<Button
icon={<ArrowUp24Regular />}
appearance="transparent"
onClick={async () => {
if (hasVoted) {
dispatchToast(
<Toast>
<ToastTitle></ToastTitle>
</Toast>,
{ intent: 'info' }
);
return;
}
try {
await voteArticle(id, 'up');
setVotes(prev => ({ ...prev, upvotes: prev.upvotes + 1 }));
setHasVoted(true);
} catch (error) {
console.error('Failed to upvote:', error);
dispatchToast(
<Toast>
<ToastTitle></ToastTitle>
</Toast>,
{ intent: 'error' }
);
}
}}
>
{votes.upvotes}
</Button>
<Button
icon={<ArrowDown24Regular />}
appearance="transparent"
onClick={async () => {
if (hasVoted) {
dispatchToast(
<Toast>
<ToastTitle></ToastTitle>
</Toast>,
{ intent: 'info' }
);
return;
}
try {
await voteArticle(id, 'down');
setVotes(prev => ({ ...prev, downvotes: prev.downvotes + 1 }));
setHasVoted(true);
} catch (error) {
console.error('Failed to downvote:', error);
dispatchToast(
<Toast>
<ToastTitle></ToastTitle>
</Toast>,
{ intent: 'error' }
);
}
}}
>
{votes.downvotes}
</Button>
<Button
icon={<Comment24Regular />}
appearance="transparent"
onClick={() => {}}
/>
<Button
icon={<Warning24Regular />}
appearance="transparent"
onClick={() => {}}
/>
</div>
</CardFooter>
</Card>
);
};
export default PostCard;