Add v2 post cards and home feed logic
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
283
front/src/components/PostCard.tsx
Normal file
283
front/src/components/PostCard.tsx
Normal 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;
|
||||||
Reference in New Issue
Block a user