feat: add search page

This commit is contained in:
Justin Sun 2023-04-03 00:51:20 +08:00
parent 3e278944a8
commit 5d71ba0e3f
No known key found for this signature in database
GPG Key ID: B6FCB958F29B7093
14 changed files with 344 additions and 28 deletions

View File

@ -13,7 +13,10 @@
},
"dependencies": {
"@ant-design/cssinjs": "^1.7.1",
"@ant-design/icons": "^5.0.1",
"antd": "^5.3.3",
"axios": "^1.3.4",
"dayjs": "^1.11.7",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.10.0"

View File

@ -2,6 +2,7 @@ lockfileVersion: 5.4
specifiers:
'@ant-design/cssinjs': ^1.7.1
'@ant-design/icons': ^5.0.1
'@trivago/prettier-plugin-sort-imports': ^4.1.1
'@types/react': ^18.0.28
'@types/react-dom': ^18.0.11
@ -10,6 +11,8 @@ specifiers:
'@vitejs/plugin-react': ^3.1.0
antd: ^5.3.3
autoprefixer: ^10.4.14
axios: ^1.3.4
dayjs: ^1.11.7
eslint: ^8.37.0
eslint-config-prettier: ^8.8.0
eslint-plugin-prettier: ^4.2.1
@ -28,7 +31,10 @@ specifiers:
dependencies:
'@ant-design/cssinjs': 1.7.1_biqbaboplfbrettd7655fr4n2y
'@ant-design/icons': 5.0.1_biqbaboplfbrettd7655fr4n2y
antd: 5.3.3_biqbaboplfbrettd7655fr4n2y
axios: 1.3.4
dayjs: 1.11.7
react: 18.2.0
react-dom: 18.2.0_react@18.2.0
react-router-dom: 6.10.0_biqbaboplfbrettd7655fr4n2y
@ -1195,6 +1201,10 @@ packages:
resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==}
dev: false
/asynckit/0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
dev: false
/autoprefixer/10.4.14_postcss@8.4.21:
resolution: {integrity: sha512-FQzyfOsTlwVzjHxKEqRIAdJx9niO6VCBCoEwax/VLSoQF29ggECcPuBqUMZ+u8jCZOPSy8b8/8KnuFbp0SaFZQ==}
engines: {node: ^10 || ^12 || >=14}
@ -1216,6 +1226,16 @@ packages:
engines: {node: '>= 0.4'}
dev: true
/axios/1.3.4:
resolution: {integrity: sha512-toYm+Bsyl6VC5wSkfkbbNB6ROv7KY93PEBBL6xyDczaIHasAiv4wPqQ/c4RjoQzipxRD2W5g21cOqQulZ7rHwQ==}
dependencies:
follow-redirects: 1.15.2
form-data: 4.0.0
proxy-from-env: 1.1.0
transitivePeerDependencies:
- debug
dev: false
/balanced-match/1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
dev: true
@ -1365,6 +1385,13 @@ packages:
resolution: {integrity: sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==}
dev: true
/combined-stream/1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
dependencies:
delayed-stream: 1.0.0
dev: false
/commander/10.0.0:
resolution: {integrity: sha512-zS5PnTI22FIRM6ylNW8G4Ap0IEOyk62fhLSD0+uHRT9McRCLGpkVNvao4bjimpK/GShynyQkFFxHhwMcETmduA==}
engines: {node: '>=14'}
@ -1439,6 +1466,11 @@ packages:
object-keys: 1.1.1
dev: true
/delayed-stream/1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
dev: false
/didyoumean/1.2.2:
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
dev: true
@ -1846,12 +1878,31 @@ packages:
resolution: {integrity: sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==}
dev: true
/follow-redirects/1.15.2:
resolution: {integrity: sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
dev: false
/for-each/0.3.3:
resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==}
dependencies:
is-callable: 1.2.7
dev: true
/form-data/4.0.0:
resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==}
engines: {node: '>= 6'}
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
mime-types: 2.1.35
dev: false
/fraction.js/4.2.0:
resolution: {integrity: sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==}
dev: true
@ -2410,6 +2461,18 @@ packages:
picomatch: 2.3.1
dev: true
/mime-db/1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
dev: false
/mime-types/2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
dependencies:
mime-db: 1.52.0
dev: false
/mimic-fn/2.1.0:
resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
engines: {node: '>=6'}
@ -2746,6 +2809,10 @@ packages:
react-is: 16.13.1
dev: true
/proxy-from-env/1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
dev: false
/punycode/2.3.0:
resolution: {integrity: sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==}
engines: {node: '>=6'}

6
src/api/index.ts Normal file
View File

@ -0,0 +1,6 @@
import { service } from './service';
import type { SearchReq, SearchRes } from './types';
export const getSearch = (params: SearchReq): Promise<SearchRes> => {
return service.get('/search', { params: { ...params, h: true } });
};

23
src/api/service.ts Normal file
View File

@ -0,0 +1,23 @@
import { message } from 'antd';
import axios from 'axios';
export const service = axios.create({
baseURL: import.meta.env.VITE_APP_API_BASE_URL,
});
service.interceptors.request.use((config) => {
message.loading({ content: '加载中', key: 'loading' });
return config;
});
service.interceptors.response.use(
(response) => {
message.destroy('loading');
return response.data;
},
(error) => {
message.destroy('loading');
message.error('请求失败');
return Promise.reject(error);
},
);

24
src/api/types.ts Normal file
View File

@ -0,0 +1,24 @@
export type SearchReq = {
// 关键字
q: string;
// 显示全文
f: boolean;
// 页数
p: number;
};
export type Post = {
id: string;
title: string;
content: string;
author: string;
date: string;
link: string;
tags: string;
// id_feed: string;
// lastSeen: string;
};
export type SearchRes = {
estimatedTotalHits: number;
hits: Array<Post>;
};

View File

@ -2,7 +2,7 @@ import { Outlet } from 'react-router-dom';
const Layout = () => {
return (
<div className="max-w-5xl mx-auto p-8 h-[100dvh]">
<div className="max-w-5xl mx-auto p-8 min-h-[100dvh]">
<Outlet />
</div>
);

View File

@ -0,0 +1,43 @@
import { CalendarOutlined, UserOutlined } from '@ant-design/icons';
import { Card, Tag } from 'antd';
import dayjs from 'dayjs';
import type { Post } from '../api/types';
import { PRIMARY_COLOR } from '../constant';
// TODO: 关键字高亮
const PostCard = ({ post }: { post: Post }) => {
return (
<Card
title={post.title}
className="w-full"
extra={<a href={post.link}></a>}
>
<div className="space-y-3">
<div>
{post.author && (
<Tag icon={<UserOutlined />} color={PRIMARY_COLOR}>
{post.author.slice(1)}
</Tag>
)}
<Tag icon={<CalendarOutlined />} color={PRIMARY_COLOR}>
{dayjs.unix(Number(post.date)).format('YYYY/MM/DD HH:mm:ss')}
</Tag>
</div>
<div className="break-words">{post.content}</div>
{post.tags && (
<div className="flex flex-wrap gap-1">
{post.tags
.slice(1)
.split(' #')
.map((tag, index) => (
<Tag key={index}>#{tag}</Tag>
))}
</div>
)}
</div>
</Card>
);
};
export default PostCard;

View File

@ -0,0 +1,55 @@
import { Checkbox, Form, Input } from 'antd';
import { useNavigate } from 'react-router-dom';
type SearchFormData = { keyword?: string; showFull: boolean };
interface SearchFormProps {
initialData?: SearchFormData;
}
const SearchForm = ({
initialData = { keyword: undefined, showFull: false },
}: SearchFormProps) => {
const [form] = Form.useForm<SearchFormData>();
const navigate = useNavigate();
return (
<Form
form={form}
layout="inline"
className="w-full items-center gap-4 justify-center sm:flex-nowrap"
onFinish={(data) => {
navigate(`/search?q=${data.keyword}&f=${data.showFull}&p=0`);
}}
>
<Form.Item
name="keyword"
className="flex-grow"
initialValue={initialData.keyword}
>
<Input.Search
size="large"
enterButton
placeholder="请输入关键字"
className="w-full"
onSearch={(keyword) => {
if (keyword) {
form.submit();
} else {
navigate('/');
}
}}
/>
</Form.Item>
<Form.Item
name="showFull"
valuePropName="checked"
initialValue={initialData.showFull}
>
<Checkbox className="w-full"></Checkbox>
</Form.Item>
</Form>
);
};
export default SearchForm;

7
src/constant.ts Normal file
View File

@ -0,0 +1,7 @@
export const SEARCH_PARAMS = {
KEYWORD: 'q',
SHOW_FULL: 'f',
PAGE: 'p',
} as const;
export const PRIMARY_COLOR = '#28303d';

View File

@ -2,28 +2,17 @@ import { StyleProvider } from '@ant-design/cssinjs';
import 'antd/dist/reset.css';
import React from 'react';
import ReactDOM from 'react-dom/client';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import Layout from './components/Layout';
import Home from './pages/Home';
import Search from './pages/Search';
import AntdProvider from './providers/antd';
import RouterProvider from './providers/router';
import './tailwind.css';
const router = createBrowserRouter([
{
path: '/',
element: <Layout />,
children: [
{ path: '/', element: <Home /> },
{ path: '/search', element: <Search /> },
],
},
]);
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<StyleProvider hashPriority="high">
<RouterProvider router={router} />
</StyleProvider>
<AntdProvider>
<StyleProvider hashPriority="high">
<RouterProvider />
</StyleProvider>
</AntdProvider>
</React.StrictMode>,
);

View File

@ -1,4 +1,6 @@
import { Checkbox, Form, Input, List } from 'antd';
import { List } from 'antd';
import SearchForm from '../components/SearchForm';
const tips = [
'全文搜索,模糊搜索,简繁同搜,拼音,同音字。',
@ -8,16 +10,12 @@ const tips = [
'如需添加收录,给我发消息 TG: @yzqzss / Email: yzqzss@othing.xyz',
];
// FIXME: 垂直居中
// TODO: 头图
const Home = () => {
return (
<div className="flex justify-center items-center h-5/6 flex-col gap-3">
<Form
layout="inline"
className="w-full items-center gap-4 justify-center sm:flex-nowrap"
>
<Input.Search size="large" enterButton placeholder="请输入关键字" />
<Checkbox className="sm:w-32"></Checkbox>
</Form>
<SearchForm />
<List
dataSource={tips}
bordered

View File

@ -1,5 +1,46 @@
import { Pagination } from 'antd';
import { useLoaderData, useSearchParams } from 'react-router-dom';
import type { SearchRes } from '../api/types';
import PostCard from '../components/PostCard';
import SearchForm from '../components/SearchForm';
import { SEARCH_PARAMS } from '../constant';
const Search = () => {
return <div>Search</div>;
const [searchParams, setSearchParams] = useSearchParams();
const searchRes = useLoaderData() as SearchRes;
const SearchPagination = () => (
<Pagination
showSizeChanger={false}
defaultCurrent={Number(searchParams.get(SEARCH_PARAMS.PAGE)) + 1}
total={searchRes.estimatedTotalHits}
pageSize={10}
onChange={(page) => {
setSearchParams({
...Object.fromEntries(searchParams.entries()),
p: String(page - 1),
});
}}
/>
);
return (
<div className="flex flex-col gap-4 items-center">
<SearchForm
initialData={{
keyword: searchParams.get(SEARCH_PARAMS.KEYWORD)!,
showFull: searchParams.get(SEARCH_PARAMS.SHOW_FULL) === 'true',
}}
/>
<div className="text-sm"> {searchRes.estimatedTotalHits} </div>
<SearchPagination />
{searchRes.hits.map((post) => (
<PostCard post={post} key={post.id} />
))}
<SearchPagination />
</div>
);
};
export default Search;

20
src/providers/antd.tsx Normal file
View File

@ -0,0 +1,20 @@
import { ConfigProvider } from 'antd';
import type React from 'react';
import { PRIMARY_COLOR } from '../constant';
const AntdProvider = ({ children }: { children: React.ReactNode }) => {
return (
<ConfigProvider
theme={{
token: {
colorPrimary: PRIMARY_COLOR,
},
}}
>
{children}
</ConfigProvider>
);
};
export default AntdProvider;

40
src/providers/router.tsx Normal file
View File

@ -0,0 +1,40 @@
import {
createBrowserRouter,
RouterProvider as ReactRouterProvider,
} from 'react-router-dom';
import { getSearch } from '../api';
import type { SearchReq } from '../api/types';
import Layout from '../components/Layout';
import { SEARCH_PARAMS } from '../constant';
import Home from '../pages/Home';
import Search from '../pages/Search';
const router = createBrowserRouter([
{
path: '/',
element: <Layout />,
children: [
{ path: '/', element: <Home /> },
{
path: '/search',
element: <Search />,
loader: async ({ request }) => {
const url = new URL(request.url);
const searchParams: SearchReq = {
q: url.searchParams.get(SEARCH_PARAMS.KEYWORD)!,
f: url.searchParams.get(SEARCH_PARAMS.SHOW_FULL) === 'true',
p: Number(url.searchParams.get(SEARCH_PARAMS.PAGE)),
};
return await getSearch(searchParams);
},
},
],
},
]);
const RouterProvider = () => {
return <ReactRouterProvider router={router} />;
};
export default RouterProvider;