diff --git a/.env b/.env new file mode 100644 index 00000000..c19f39b0 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +BACKEND_URL=https://api.realworld.io/api \ No newline at end of file diff --git a/.gitignore b/.gitignore index c6bba591..208d50c7 100644 --- a/.gitignore +++ b/.gitignore @@ -73,7 +73,7 @@ web_modules/ .yarn-integrity # dotenv environment variable files -.env +# .env .env.development.local .env.test.local .env.production.local diff --git a/app/(home)/@feeds/(feeds)/@global/page.tsx b/app/(home)/@feeds/(feeds)/@global/page.tsx new file mode 100644 index 00000000..548208cd --- /dev/null +++ b/app/(home)/@feeds/(feeds)/@global/page.tsx @@ -0,0 +1,29 @@ +import { FeedItem } from 'components/home/feed'; +import { Pagination } from 'components/ui'; +import { ARTICLE_PAGE_LIMIT } from 'constants/article'; +import { articleApi } from 'services'; + +type Props = { + searchParams: Record; +}; +const GlobalPage = async ({ searchParams }: Props) => { + const page = Number(searchParams.page ?? 1); + + const { articles, articlesCount } = await articleApi.getArticles({ + limit: ARTICLE_PAGE_LIMIT, + offset: (page - 1) * ARTICLE_PAGE_LIMIT, + }); + + return ( + <> + + + + ); +}; + +export default GlobalPage; diff --git a/components/home/article-section/article-section.tsx b/app/(home)/@feeds/(feeds)/layout.tsx similarity index 77% rename from components/home/article-section/article-section.tsx rename to app/(home)/@feeds/(feeds)/layout.tsx index 49b1782a..dbf38200 100644 --- a/components/home/article-section/article-section.tsx +++ b/app/(home)/@feeds/(feeds)/layout.tsx @@ -1,8 +1,13 @@ +import { ReactNode } from 'react'; + import { Tabs } from 'components/ui'; -import { FeedList } from '../feed-list'; import { clsx } from 'lib/utils'; -export const ArticleSection = () => { +type Props = { + global: ReactNode; +}; + +const FeedsLayout = ({ global }: Props) => { const tabs = [ { key: 'feed', @@ -29,10 +34,10 @@ export const ArticleSection = () => {
No articles are here... yet
- - - + {global} ); }; + +export default FeedsLayout; diff --git a/components/home/index.tsx b/app/(home)/layout.tsx similarity index 54% rename from components/home/index.tsx rename to app/(home)/layout.tsx index 33dceb9f..0834cc5d 100644 --- a/components/home/index.tsx +++ b/app/(home)/layout.tsx @@ -1,17 +1,20 @@ -import { ArticleSection } from './article-section'; -import { Banner } from './banner'; -import { TagList } from './tag-list'; +import { ReactNode } from 'react'; -export const Home = () => { +import { Banner } from 'components/home/banner'; +import { TagList } from 'components/home/tag-list'; + +type Props = { + feeds: ReactNode; +}; + +const HomeLayout = ({ feeds }: Props) => { return (
-
- -
+
{feeds}
@@ -24,3 +27,5 @@ export const Home = () => {
); }; + +export default HomeLayout; diff --git a/app/layout.tsx b/app/layout.tsx index 085f61a3..816dd346 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -20,7 +20,7 @@ const inter = Inter({ variable: '--font-inter', }); -export default async function RootLayout({ children }: { children: ReactNode }) { +const RootLayout = ({ children }: { children: ReactNode }) => { return ( @@ -30,4 +30,6 @@ export default async function RootLayout({ children }: { children: ReactNode }) ); -} +}; + +export default RootLayout; diff --git a/app/page.tsx b/app/page.tsx deleted file mode 100644 index 2f3ce459..00000000 --- a/app/page.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import { Home } from 'components/home'; - -const HomePage = () => { - return ; -}; - -export default HomePage; diff --git a/components/home/article-section/index.ts b/components/home/article-section/index.ts deleted file mode 100644 index 172e0f6c..00000000 --- a/components/home/article-section/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './article-section'; diff --git a/components/home/feed-list/feed-list.tsx b/components/home/feed-list/feed-list.tsx deleted file mode 100644 index c989956e..00000000 --- a/components/home/feed-list/feed-list.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { Pagination } from 'components/ui'; -import { FeedItem } from './feed-item'; - -export const FeedList = () => { - return ( - <> -
    - - - - -
- - - ); -}; diff --git a/components/home/feed-list/feed-item.stories.tsx b/components/home/feed/feed-item.stories.tsx similarity index 100% rename from components/home/feed-list/feed-item.stories.tsx rename to components/home/feed/feed-item.stories.tsx diff --git a/components/home/feed-list/feed-item.tsx b/components/home/feed/feed-item.tsx similarity index 70% rename from components/home/feed-list/feed-item.tsx rename to components/home/feed/feed-item.tsx index b49a8010..c92e3749 100644 --- a/components/home/feed-list/feed-item.tsx +++ b/components/home/feed/feed-item.tsx @@ -1,24 +1,11 @@ import { Avatar, Tag } from 'components/ui'; +import { Article } from 'models'; -export const FeedItem = () => { - const feed = { - slug: 'how-to-train-your-dragon', - title: 'How to train your dragon', - description: 'Ever wonder how?', - body: 'It takes a Jacobian', - tagList: ['dragons', 'training'], - createdAt: '2016-02-18T03:22:56.637Z', - updatedAt: '2016-02-18T03:48:35.824Z', - favorited: false, - favoritesCount: 0, - author: { - username: 'jake', - bio: 'I work at statefarm', - image: 'https://i.stack.imgur.com/xHWG8.jpg', - following: false, - }, - }; +type Props = { + feed: Article; +}; +export const FeedItem = ({ feed }: Props) => { const { slug, title, description, author, favoritesCount, tagList } = feed; return ( diff --git a/components/home/feed-list/index.ts b/components/home/feed/index.ts similarity index 50% rename from components/home/feed-list/index.ts rename to components/home/feed/index.ts index ceb0ed90..832ba1f3 100644 --- a/components/home/feed-list/index.ts +++ b/components/home/feed/index.ts @@ -1,2 +1 @@ -export * from './feed-list'; export * from './feed-item'; diff --git a/components/ui/pagination/pagination.tsx b/components/ui/pagination/pagination.tsx index 59822cd5..c22c66fb 100644 --- a/components/ui/pagination/pagination.tsx +++ b/components/ui/pagination/pagination.tsx @@ -1,5 +1,3 @@ -'use client'; - import { ComponentPropsWithoutRef, forwardRef } from 'react'; import { clsx } from 'lib/utils'; import { Overwrite } from 'lib/type-utils'; @@ -7,26 +5,25 @@ import { Overwrite } from 'lib/type-utils'; type PaginationProps = { total: number; current?: number; - onChange?: (page: number) => void; }; type Props = Overwrite, PaginationProps>; export const Pagination = forwardRef(function pagination( - { total, current = 1, onChange, ...otherProps }, + { total, current = 1, ...otherProps }, ref, ) { const pages = Array.from({ length: total }, (_, i) => i + 1); - const handleItemClick = (page: number) => () => { - onChange?.(page); - }; + if (total < 2) { + return null; + } return (
    {pages.map((page) => ( -
  • - +
  • + {page}
  • diff --git a/constants/article.ts b/constants/article.ts new file mode 100644 index 00000000..163a912d --- /dev/null +++ b/constants/article.ts @@ -0,0 +1 @@ +export const ARTICLE_PAGE_LIMIT = 10; diff --git a/models/article.ts b/models/article.ts new file mode 100644 index 00000000..e2ebd4cf --- /dev/null +++ b/models/article.ts @@ -0,0 +1,22 @@ +import { z } from 'zod'; +import { AuthorSchema } from './author'; + +export const ArticleSchema = z.object({ + slug: z.string(), + title: z.string(), + description: z.string(), + body: z.string(), + tagList: z.array(z.string()), + createdAt: z.string(), + updatedAt: z.string(), + favorited: z.boolean(), + favoritesCount: z.number(), + author: AuthorSchema, +}); + +export const ArticleListSchema = z.object({ + articles: z.array(ArticleSchema), + articlesCount: z.number(), +}); + +export type Article = z.infer; diff --git a/models/author.ts b/models/author.ts new file mode 100644 index 00000000..885e0c69 --- /dev/null +++ b/models/author.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +export const AuthorSchema = z.object({ + username: z.string(), + bio: z.string().nullable(), + image: z.string(), + following: z.boolean(), +}); diff --git a/models/index.ts b/models/index.ts new file mode 100644 index 00000000..57bb4f6d --- /dev/null +++ b/models/index.ts @@ -0,0 +1,3 @@ +export * from './article'; +export * from './author'; +export * from './search-params'; diff --git a/models/search-params.ts b/models/search-params.ts new file mode 100644 index 00000000..13e86138 --- /dev/null +++ b/models/search-params.ts @@ -0,0 +1,7 @@ +export type SearchParams = { + limit: number; + offset: number; + author: string; + tag: string; + favorited: string; +}; diff --git a/next.config.js b/next.config.js index 838d62da..5a0e8804 100644 --- a/next.config.js +++ b/next.config.js @@ -3,7 +3,7 @@ module.exports = { remotePatterns: [ { protocol: 'https', - hostname: 'i.stack.imgur.com', + hostname: 'api.realworld.io', }, ], }, diff --git a/package.json b/package.json index 1b0bf7c0..713aafeb 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "prettier-plugin-tailwindcss": "0.5.4", "storybook": "7.4.0", "tailwindcss": "3.3.3", - "typescript": "5.2.2" + "typescript": "5.2.2", + "zod": "3.22.2" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 572dc3c7..87a4354f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -109,6 +109,9 @@ devDependencies: typescript: specifier: 5.2.2 version: 5.2.2 + zod: + specifier: 3.22.2 + version: 3.22.2 packages: @@ -11484,3 +11487,7 @@ packages: /zod@3.21.4: resolution: {integrity: sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==} + + /zod@3.22.2: + resolution: {integrity: sha512-wvWkphh5WQsJbVk1tbx1l1Ly4yg+XecD+Mq280uBGt9wa5BKSWf4Mhp6GmrkPixhMxmabYY7RbzlwVP32pbGCg==} + dev: true diff --git a/services/apis/article.ts b/services/apis/article.ts new file mode 100644 index 00000000..d874679a --- /dev/null +++ b/services/apis/article.ts @@ -0,0 +1,15 @@ +import { get } from 'services/fetch-utils'; +import { ArticleListSchema, SearchParams } from 'models'; + +class ArticleApiService { + async getArticles({ limit = 10, offset = 0 }: Partial = {}) { + const params = new URLSearchParams({ + limit: limit.toString(), + offset: offset.toString(), + }); + + return await get(`/articles?${params}`, ArticleListSchema); + } +} + +export const articleApi = new ArticleApiService(); diff --git a/services/apis/index.ts b/services/apis/index.ts new file mode 100644 index 00000000..a8e7c146 --- /dev/null +++ b/services/apis/index.ts @@ -0,0 +1 @@ +export * from './article'; diff --git a/services/fetch-utils.ts b/services/fetch-utils.ts new file mode 100644 index 00000000..54ad87ac --- /dev/null +++ b/services/fetch-utils.ts @@ -0,0 +1,82 @@ +import { z } from 'zod'; + +function getHeaders(accessToken: string | undefined) { + return { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${accessToken || ''}`, + }; +} + +export const get = async ( + path: string, + schema: T, + accessToken?: string, +): Promise> => { + const response = await fetch(`${process.env.BACKEND_URL}${path}`, { + method: 'GET', + headers: getHeaders(accessToken), + }); + + if (!response.ok) { + throw new Error('Request failed'); + } + + return schema.parse(await response.json()); +}; + +export const post = async ( + path: string, + schema: T, + body: unknown, + accessToken?: string, +): Promise> => { + const response = await fetch(`${process.env.BACKEND_URL}${path}`, { + body: JSON.stringify(body), + method: 'POST', + headers: getHeaders(accessToken), + }); + + if (!response.ok) { + throw new Error('Request failed'); + } + + return schema.parse(await response.json()); +}; + +export const put = async ( + path: string, + schema: T, + body: unknown, + accessToken?: string, +): Promise> => { + const response = await fetch(`${process.env.BACKEND_URL}${path}`, { + body: JSON.stringify(body), + method: 'PUT', + headers: getHeaders(accessToken), + }); + + if (!response.ok) { + throw new Error(response.statusText); + } + + return schema.parse(await response.json()); +}; + +export const delete_ = async ( + path: string, + schema: T, + body: unknown, + accessToken?: string, +): Promise> => { + const response = await fetch(`${process.env.BACKEND_URL}${path}`, { + body: JSON.stringify(body), + method: 'DELETE', + headers: getHeaders(accessToken), + }); + + if (!response.ok) { + throw new Error(response.statusText); + } + + return schema.parse(await response.json()); +}; diff --git a/services/index.ts b/services/index.ts new file mode 100644 index 00000000..aee54f49 --- /dev/null +++ b/services/index.ts @@ -0,0 +1,2 @@ +export * from './fetch-utils'; +export * from './apis';