diff --git a/package.json b/package.json index 02eb119..8c34ec4 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 92db54a..0016b70 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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'} diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 0000000..ad5b010 --- /dev/null +++ b/src/api/index.ts @@ -0,0 +1,6 @@ +import { service } from './service'; +import type { SearchReq, SearchRes } from './types'; + +export const getSearch = (params: SearchReq): Promise => { + return service.get('/search', { params: { ...params, h: true } }); +}; diff --git a/src/api/service.ts b/src/api/service.ts new file mode 100644 index 0000000..9106d93 --- /dev/null +++ b/src/api/service.ts @@ -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); + }, +); diff --git a/src/api/types.ts b/src/api/types.ts new file mode 100644 index 0000000..09374a2 --- /dev/null +++ b/src/api/types.ts @@ -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; +}; diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 5d10124..7fadd1b 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -2,7 +2,7 @@ import { Outlet } from 'react-router-dom'; const Layout = () => { return ( -
+
); diff --git a/src/components/PostCard.tsx b/src/components/PostCard.tsx new file mode 100644 index 0000000..cd26641 --- /dev/null +++ b/src/components/PostCard.tsx @@ -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 ( + 查看原文} + > +
+
+ {post.author && ( + } color={PRIMARY_COLOR}> + {post.author.slice(1)} + + )} + } color={PRIMARY_COLOR}> + {dayjs.unix(Number(post.date)).format('YYYY/MM/DD HH:mm:ss')} + +
+
{post.content}
+ {post.tags && ( +
+ {post.tags + .slice(1) + .split(' #') + .map((tag, index) => ( + #{tag} + ))} +
+ )} +
+
+ ); +}; + +export default PostCard; diff --git a/src/components/SearchForm.tsx b/src/components/SearchForm.tsx new file mode 100644 index 0000000..eabdb01 --- /dev/null +++ b/src/components/SearchForm.tsx @@ -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(); + const navigate = useNavigate(); + + return ( +
{ + navigate(`/search?q=${data.keyword}&f=${data.showFull}&p=0`); + }} + > + + { + if (keyword) { + form.submit(); + } else { + navigate('/'); + } + }} + /> + + + 展开全文 + +
+ ); +}; + +export default SearchForm; diff --git a/src/constant.ts b/src/constant.ts new file mode 100644 index 0000000..4a99e50 --- /dev/null +++ b/src/constant.ts @@ -0,0 +1,7 @@ +export const SEARCH_PARAMS = { + KEYWORD: 'q', + SHOW_FULL: 'f', + PAGE: 'p', +} as const; + +export const PRIMARY_COLOR = '#28303d'; diff --git a/src/main.tsx b/src/main.tsx index e65c67e..b20d567 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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: , - children: [ - { path: '/', element: }, - { path: '/search', element: }, - ], - }, -]); - ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( - - - + + + + + , ); diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 4bca63a..828fd97 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -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 (
-
- - 展开全文 - + { - return
Search
; + const [searchParams, setSearchParams] = useSearchParams(); + const searchRes = useLoaderData() as SearchRes; + + const SearchPagination = () => ( + { + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + p: String(page - 1), + }); + }} + /> + ); + + return ( +
+ +
约 {searchRes.estimatedTotalHits} 条结果
+ + {searchRes.hits.map((post) => ( + + ))} + +
+ ); }; export default Search; diff --git a/src/providers/antd.tsx b/src/providers/antd.tsx new file mode 100644 index 0000000..e7c49c9 --- /dev/null +++ b/src/providers/antd.tsx @@ -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 ( + + {children} + + ); +}; + +export default AntdProvider; diff --git a/src/providers/router.tsx b/src/providers/router.tsx new file mode 100644 index 0000000..04ab240 --- /dev/null +++ b/src/providers/router.tsx @@ -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: , + children: [ + { path: '/', element: }, + { + path: '/search', + element: , + 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 ; +}; + +export default RouterProvider;