├── .editorconfig ├── .env.example ├── .eslintrc.json ├── .gitignore ├── .prettierignore ├── LICENSE ├── README.md ├── app ├── (auth) │ ├── components │ │ └── auth-page.tsx │ ├── login │ │ └── page.tsx │ └── signup │ │ └── page.tsx ├── [type] │ ├── components │ │ ├── type-page.tsx │ │ └── type-stories.tsx │ ├── error.tsx │ └── page.tsx ├── favicon.ico ├── global-error.tsx ├── globals.css ├── item │ ├── components │ │ ├── comment.tsx │ │ ├── comments.tsx │ │ ├── item-with-comment.tsx │ │ ├── reply-dialog.tsx │ │ └── reply-form.tsx │ ├── error.tsx │ └── page.tsx ├── layout.tsx ├── page.tsx ├── robots.ts ├── search │ └── page.tsx ├── submit │ ├── page.tsx │ └── submit-form.tsx └── user │ ├── [tab] │ ├── error.tsx │ └── page.tsx │ └── components │ ├── favorites-tab.tsx │ ├── profile-tab.tsx │ ├── tab-about.tsx │ ├── tab-comments.tsx │ ├── tab-favorites.tsx │ ├── tab-submitted.tsx │ ├── tab-upvoted.tsx │ └── thread.tsx ├── components.json ├── components ├── auth-form.tsx ├── desktop-nav.tsx ├── error-page.tsx ├── fave.tsx ├── footer.tsx ├── header.tsx ├── html-text.tsx ├── icons.tsx ├── item-list.tsx ├── item-skeleton.tsx ├── job-tips.tsx ├── loading.tsx ├── logo.tsx ├── mobile-nav.tsx ├── mode-toggle.tsx ├── search-input.tsx ├── show-tips.tsx ├── story-by.tsx ├── story-comment-count.tsx ├── story-point.tsx ├── story-time.tsx ├── story-url.tsx ├── story.tsx ├── theme-provider.tsx ├── top-tips.tsx ├── ui │ ├── avatar.tsx │ ├── badge.tsx │ ├── button.tsx │ ├── card.tsx │ ├── dialog.tsx │ ├── dropdown-menu.tsx │ ├── form.tsx │ ├── hover-card.tsx │ ├── input.tsx │ ├── label.tsx │ ├── popover.tsx │ ├── scroll-area.tsx │ ├── select.tsx │ ├── separator.tsx │ ├── sheet.tsx │ ├── skeleton.tsx │ ├── sonner.tsx │ ├── switch.tsx │ ├── tabs.tsx │ ├── textarea.tsx │ ├── toast.tsx │ ├── toaster.tsx │ ├── tooltip.tsx │ └── use-toast.ts ├── user-nav.tsx └── vote.tsx ├── config ├── conf.ts └── urls.ts ├── hooks ├── currentUserContext.tsx ├── index.ts ├── useCurrentUser.ts ├── useFormAction.ts └── useGoto.ts ├── lib ├── actions.ts ├── hn-algolia-fetcher.ts ├── hn-api-fetcher.ts ├── hn-item-utils.ts ├── hn-types.ts ├── hn-web-fetcher.ts ├── hn-web-parser.ts ├── hn-web-types.ts ├── session.ts ├── time-utils.ts └── utils.ts ├── middleware.ts ├── next.config.mjs ├── package.json ├── pnpm-lock.yaml ├── postcss.config.cjs ├── prettier.config.js ├── screenshots ├── desktop │ ├── about.png │ ├── comments.png │ ├── index.png │ ├── login.png │ ├── story.png │ └── upvoted.png └── mobile │ ├── about.png │ ├── comments.png │ ├── index.png │ ├── login.png │ ├── story.png │ └── upvoted.png ├── tailwind.config.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_size = 2 8 | indent_style = space 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Used to encrypt the account password 2 | AES_SECRET= 3 | # Optional, The default is 'https://news.ycombinator.com' 4 | NEXT_PUBLIC_HN_WEB_BASE_URL='https://news.ycombinator.com' 5 | # Optional, The default is 'https://hacker-news.firebaseio.com/v0' 6 | NEXT_PUBLIC_HN_API_BASE_URL='https://hacker-news.firebaseio.com/v0' 7 | # Optional, The default is 'https://hn.algolia.com/api/v1' 8 | NEXT_PUBLIC_ALGOLIA_BASE_URL='https://hn.algolia.com/api/v1' 9 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/eslintrc", 3 | "root": true, 4 | "extends": [ 5 | "next/core-web-vitals", 6 | "prettier", 7 | "plugin:tailwindcss/recommended" 8 | ], 9 | "plugins": ["tailwindcss"], 10 | "rules": { 11 | "@next/next/no-html-link-for-pages": "off", 12 | "tailwindcss/no-custom-classname": "off", 13 | "tailwindcss/classnames-order": "error" 14 | }, 15 | "settings": { 16 | "tailwindcss": { 17 | "callees": ["cn", "cva"], 18 | "config": "tailwind.config.ts" 19 | } 20 | }, 21 | "overrides": [ 22 | { 23 | "files": ["*.ts", "*.tsx"], 24 | "parser": "@typescript-eslint/parser" 25 | } 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | .next 4 | build 5 | *.md 6 | *.html 7 | pnpm-lock.yaml 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Wh1te 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Next.js HackerNews 2 | This is a [HackerNews](https://news.ycombinator.com/) clone built with [Next.js](https://nextjs.org/) and [shadcn/ui](https://ui.shadcn.com/). 3 | 4 | ![index](./screenshots/desktop/index.png) 5 | 6 |

Live Demo

7 | 8 | ## Features 9 | 10 | - Next.js App Router 11 | - React Server Components (RSCs) and Suspense 12 | - Server Actions for mutations 13 | - Beautifully designed components from shadcn/ui 14 | - Styling with Tailwind CSS 15 | - Browse stories: Top, Newest, Best, Show, Ask, Jobs. 16 | - Search for stories. 17 | - User authentication: Create an account or log in using your Hacker News account to access personalized features. 18 | - Mark stories as favorite. 19 | - Upvote stories or comments. 20 | - Add comments. 21 | - View user profile: About, Submitted, Comments, Favorites, Upvoted(private). 22 | - Responsive design: Friendly to both mobile and desktop. 23 | - Automatic light/dark mode based on system settings. 24 | 25 | ## Screenshots 26 | 27 | ### Desktop 28 | ![index-desktop](./screenshots/desktop/index.png) 29 | 30 | ![story-desktop](./screenshots/desktop/story.png) 31 | 32 | ![about-desktop](./screenshots/desktop/about.png) 33 | 34 | ![upvoted-desktop](./screenshots/desktop/upvoted.png) 35 | 36 | ![comments-desktop](./screenshots/desktop/comments.png) 37 | 38 | ![login-desktop](./screenshots/desktop/login.png) 39 | 40 | 41 | ### Mobile 42 |
43 | index-mobile 48 | story-mobile 53 |
54 |
55 |
56 | about-mobile 61 | upvoted-mobile 66 |
67 |
68 |
69 | comments-mobile 74 | login-mobile 79 |
80 | 81 | ## Running Locally 82 | 83 | Requires Node.js 18.17 or later. 84 | 85 | 0. Clone the project. 86 | ```bash 87 | git clone https://github.com/WhiteDG/nextjs-hackernews.git 88 | 89 | cd nextjs-hackernews 90 | ``` 91 | 92 | 1. Install dependencies. 93 | ```bash 94 | pnpm install 95 | ``` 96 | 2. Copy `.env.example` to `.env.local` and update the variables. 97 | ```bash 98 | cp .env.example .env.local 99 | ``` 100 | 3. Run the development server with hot reload. 101 | ```bash 102 | pnpm dev 103 | ``` 104 | 4. Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 105 | 106 | 5. Build for production 107 | ```bash 108 | pnpm run build 109 | ``` 110 | 111 | 6. Serve in production mode 112 | ```bash 113 | pnpm start 114 | ``` 115 | 116 | ## APIs 117 | This project leverages the power of various APIs to provide an enriched user experience: 118 | - [HackerNews Official API](https://github.com/HackerNews/API) 119 | - Get stories 120 | - Get comments 121 | - Get user profiles 122 | - [HackerNews Website](https://news.ycombinator.com) 123 | - Login/create account 124 | - Add comments 125 | - Upvote 126 | - Favorite 127 | - Submitted, Comments, Favorites, Upvoted(private) 128 | - [HnAlgolia API](https://hn.algolia.com/api) 129 | - Search 130 | 131 | 132 | ## License 133 | Licensed under the [MIT license](https://github.com/WhiteDG/nextjs-hackernews/blob/main/LICENSE). 134 | -------------------------------------------------------------------------------- /app/(auth)/components/auth-page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | 3 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" 4 | import { AuthForm } from "@/components/auth-form" 5 | 6 | type PageInfo = { 7 | title: string 8 | buttonText: string 9 | switcherTips: string 10 | switcherText: string 11 | switcherHref: string 12 | } 13 | const pageMap = { 14 | login: { 15 | title: "Log in to HackerNews", 16 | buttonText: "Login", 17 | switcherTips: "New to HackerNews?", 18 | switcherText: "Create an account", 19 | switcherHref: "/signup", 20 | }, 21 | signup: { 22 | title: "Create Account", 23 | buttonText: "Create Account", 24 | switcherTips: "Alreay have an account?", 25 | switcherText: "Login", 26 | switcherHref: "/login", 27 | }, 28 | } as Record 29 | 30 | export default function AuthPage({ 31 | page, 32 | searchParams, 33 | }: { 34 | page: string 35 | searchParams: { goto?: string } 36 | }) { 37 | const pageInfo = pageMap[page] 38 | return ( 39 | 40 | 41 | {pageInfo.title} 42 | 43 | 44 |
45 | 46 |
47 | {pageInfo.switcherTips}{" "} 48 | 49 | {pageInfo.switcherText} 50 | 51 |
52 |
53 |
54 |
55 | ) 56 | } 57 | -------------------------------------------------------------------------------- /app/(auth)/login/page.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata } from "next" 2 | 3 | import AuthPage from "../components/auth-page" 4 | 5 | export async function generateMetadata(): Promise { 6 | return { 7 | title: "Login", 8 | } 9 | } 10 | 11 | export default function Page({ 12 | searchParams, 13 | }: { 14 | searchParams: { goto?: string } 15 | }) { 16 | return 17 | } 18 | -------------------------------------------------------------------------------- /app/(auth)/signup/page.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata } from "next" 2 | 3 | import AuthPage from "../components/auth-page" 4 | 5 | export async function generateMetadata(): Promise { 6 | return { 7 | title: "SignUp", 8 | } 9 | } 10 | 11 | export default function Page({ 12 | searchParams, 13 | }: { 14 | searchParams: { goto?: string } 15 | }) { 16 | return 17 | } 18 | -------------------------------------------------------------------------------- /app/[type]/components/type-page.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from "react" 2 | 3 | import { HnStoryType } from "@/lib/hn-types" 4 | import ItemSkeleton from "@/components/item-skeleton" 5 | import JobTips from "@/components/job-tips" 6 | import ShowTips from "@/components/show-tips" 7 | import TopTips from "@/components/top-tips" 8 | import TypeStories from "@/app/[type]/components/type-stories" 9 | 10 | export default function TypePage({ 11 | pathname, 12 | storyType, 13 | currentPage, 14 | }: { 15 | pathname: string 16 | storyType: HnStoryType 17 | currentPage: number 18 | }) { 19 | return ( 20 | <> 21 | {(pathname === "top" || pathname === "") && } 22 | {pathname === "show" && } 23 | {pathname === "jobs" && } 24 | } 27 | > 28 | 34 | 35 | 36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /app/[type]/components/type-stories.tsx: -------------------------------------------------------------------------------- 1 | import { fetchStories, fetchStoryIds } from "@/lib/hn-api-fetcher" 2 | import { HnStoryType } from "@/lib/hn-types" 3 | import ItemList from "@/components/item-list" 4 | 5 | export default async function TypeStories({ 6 | pathname, 7 | storyType, 8 | page = 1, 9 | pageSize = 30, 10 | }: { 11 | page?: number 12 | pageSize?: number 13 | storyType: HnStoryType 14 | pathname: string 15 | }) { 16 | const storyIds = await fetchStoryIds(storyType) 17 | const limit = pageSize || 30 18 | const offset = (page - 1) * limit 19 | const showStoryIds = storyIds.slice(offset, offset + limit) 20 | const stories = await fetchStories(showStoryIds) 21 | 22 | const searchParams = new URLSearchParams() 23 | searchParams.set("page", (+page + 1).toString()) 24 | 25 | const moreLink = 26 | stories.length < limit ? "" : `/${pathname}?${searchParams.toString()}` 27 | return 28 | } 29 | -------------------------------------------------------------------------------- /app/[type]/error.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import ErrorPage from "@/components/error-page" 4 | 5 | export default function Error({ 6 | error, 7 | reset, 8 | }: { 9 | error: Error & { digest?: string } 10 | reset: () => void 11 | }) { 12 | return 13 | } 14 | -------------------------------------------------------------------------------- /app/[type]/page.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata, ResolvingMetadata } from "next" 2 | import { notFound } from "next/navigation" 3 | 4 | import { storyNavConfig } from "@/config/conf" 5 | import TypePage from "@/app/[type]/components/type-page" 6 | 7 | type Props = { 8 | params: { type: string } 9 | searchParams: { [key: string]: string | string[] | undefined } 10 | } 11 | 12 | export async function generateMetadata( 13 | { params, searchParams }: Props, 14 | parent: ResolvingMetadata 15 | ): Promise { 16 | const type = params.type 17 | return { 18 | title: `${type.charAt(0).toUpperCase() + type.slice(1)}`, 19 | } 20 | } 21 | 22 | export default async function Page({ searchParams, params }: Props) { 23 | const currentPage = Number(searchParams?.page) || 1 24 | const pathname = params.type || "top" 25 | const navItem = storyNavConfig.filter( 26 | (navItem) => navItem.name.toLowerCase() === pathname 27 | ) 28 | const storyType = navItem && navItem.length === 1 ? navItem[0].type : null 29 | if (!storyType) { 30 | notFound() 31 | } 32 | return ( 33 | 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WhiteDG/nextjs-hackernews/8c14ec17213692c1216377fc098ef9e980b75945/app/favicon.ico -------------------------------------------------------------------------------- /app/global-error.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import ErrorPage from "@/components/error-page" 4 | 5 | export default function Error({ 6 | error, 7 | reset, 8 | }: { 9 | error: Error & { digest?: string } 10 | reset: () => void 11 | }) { 12 | return 13 | } 14 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 84% 4.9%; 9 | 10 | --card: 0 0% 100%; 11 | --card-foreground: 222.2 84% 4.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 222.2 84% 4.9%; 15 | 16 | --primary: 222.2 47.4% 11.2%; 17 | --primary-foreground: 210 40% 98%; 18 | 19 | --secondary: 210 40% 96.1%; 20 | --secondary-foreground: 222.2 47.4% 11.2%; 21 | 22 | --muted: 210 40% 96.1%; 23 | --muted-foreground: 215.4 16.3% 46.9%; 24 | 25 | --accent: 210 40% 96.1%; 26 | --accent-foreground: 222.2 47.4% 11.2%; 27 | 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 210 40% 98%; 30 | 31 | --border: 214.3 31.8% 91.4%; 32 | --input: 214.3 31.8% 91.4%; 33 | --ring: 222.2 84% 4.9%; 34 | 35 | --radius: 0.5rem; 36 | } 37 | 38 | .dark { 39 | --background: 222.2 84% 4.9%; 40 | --foreground: 210 40% 98%; 41 | 42 | --card: 222.2 84% 4.9%; 43 | --card-foreground: 210 40% 98%; 44 | 45 | --popover: 222.2 84% 4.9%; 46 | --popover-foreground: 210 40% 98%; 47 | 48 | --primary: 210 40% 98%; 49 | --primary-foreground: 222.2 47.4% 11.2%; 50 | 51 | --secondary: 217.2 32.6% 17.5%; 52 | --secondary-foreground: 210 40% 98%; 53 | 54 | --muted: 217.2 32.6% 17.5%; 55 | --muted-foreground: 215 20.2% 65.1%; 56 | 57 | --accent: 217.2 32.6% 17.5%; 58 | --accent-foreground: 210 40% 98%; 59 | 60 | --destructive: 0 62.8% 30.6%; 61 | --destructive-foreground: 210 40% 98%; 62 | 63 | --border: 217.2 32.6% 17.5%; 64 | --input: 217.2 32.6% 17.5%; 65 | --ring: 212.7 26.8% 83.9%; 66 | } 67 | } 68 | 69 | @layer base { 70 | * { 71 | @apply border-border; 72 | } 73 | body { 74 | @apply bg-background text-foreground; 75 | } 76 | } 77 | 78 | @media (max-width: 640px) { 79 | .container { 80 | @apply px-4; 81 | } 82 | } 83 | 84 | pre { 85 | overflow: auto; 86 | white-space: pre-wrap; 87 | overflow-wrap: anywhere; 88 | } 89 | 90 | .item-text p { 91 | overflow: auto; 92 | overflow-wrap: anywhere; 93 | margin-block-start: 0.5em; 94 | margin-block-end: 0.5em; 95 | } 96 | 97 | .item-text a { 98 | @apply underline; 99 | } 100 | -------------------------------------------------------------------------------- /app/item/components/comment.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useState } from "react" 4 | import Link from "next/link" 5 | import { MinusCircleIcon, PlusCircleIcon } from "lucide-react" 6 | 7 | import { HnComment, HnItem } from "@/lib/hn-types" 8 | import { timeAgo } from "@/lib/time-utils" 9 | import { cn } from "@/lib/utils" 10 | import { Badge } from "@/components/ui/badge" 11 | import HtmlText from "@/components/html-text" 12 | import Vote from "@/components/vote" 13 | 14 | import ReplyDialog from "./reply-dialog" 15 | 16 | export default function Comment({ 17 | comment, 18 | story, 19 | }: { 20 | comment: HnComment 21 | story?: HnItem 22 | }) { 23 | const isOp = story?.by === comment.by 24 | const [collapse, setCollapse] = useState(true) 25 | if (comment.deleted || comment.dead) { 26 | return <> 27 | } 28 | 29 | const replies = comment.comments 30 | return ( 31 |
32 |
33 | {" "} 34 | 42 | {comment.by} 43 | 44 | {isOp && ( 45 | 49 | OP 50 | 51 | )} 52 | • 53 | 54 | {timeAgo(comment.time)} ago 55 | 56 |
57 | {comment.text && ( 58 |
59 | 60 |
61 | {replies && replies.length > 0 && ( 62 |
63 | {collapse ? ( 64 | setCollapse(!collapse)} 68 | /> 69 | ) : ( 70 | setCollapse(!collapse)} 74 | /> 75 | )} 76 |
77 | )} 78 | {!story?.dead && } 79 |
80 |
81 | )} 82 | {replies && replies.length > 0 && ( 83 |
89 |
90 | {replies.map((comment) => { 91 | return ( 92 | 93 | ) 94 | })} 95 |
96 |
97 | )} 98 |
99 | ) 100 | } 101 | -------------------------------------------------------------------------------- /app/item/components/comments.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Loader2 } from "lucide-react" 4 | import useSWRInfinite, { SWRInfiniteConfiguration } from "swr/infinite" 5 | 6 | import { fetchComments } from "@/lib/hn-api-fetcher" 7 | import { HnComment, HnItem } from "@/lib/hn-types" 8 | import { cn } from "@/lib/utils" 9 | import { Button } from "@/components/ui/button" 10 | 11 | import Comment from "./comment" 12 | 13 | const PAGE_SIZE = 10 14 | const options = { 15 | revalidateIfStale: false, 16 | revalidateOnFocus: false, 17 | revalidateOnReconnect: false, 18 | revalidateFirstPage: false, 19 | } as SWRInfiniteConfiguration 20 | 21 | const fetcher = async (showIds: number[]) => { 22 | return await fetchComments(showIds) 23 | } 24 | 25 | const getKey = (ids: number[]) => { 26 | return (page: number, previousPageData: any) => { 27 | if (previousPageData && !previousPageData.length) { 28 | return null 29 | } 30 | const offset = page * PAGE_SIZE 31 | const showIds = ids.slice(offset, offset + PAGE_SIZE) 32 | return showIds 33 | } 34 | } 35 | 36 | export default function Comments({ 37 | story, 38 | ids, 39 | }: { 40 | story: HnItem 41 | ids: number[] 42 | }) { 43 | const { data, mutate, size, setSize, isValidating, isLoading } = 44 | useSWRInfinite(getKey(ids), fetcher, options) 45 | 46 | const isNoComment = !ids || ids.length == 0 47 | const comments = data ? [].concat(...data) : [] 48 | const isLoadingMore = 49 | isLoading || (size > 0 && data && typeof data[size - 1] === "undefined") 50 | const isEmpty = data?.[0]?.length === 0 51 | const isReachingEnd = 52 | isEmpty || 53 | (data && data[data.length - 1]?.length < PAGE_SIZE) || 54 | comments.length === ids.length 55 | const text = isNoComment 56 | ? "No comment yet" 57 | : isLoadingMore 58 | ? "Loading..." 59 | : isReachingEnd 60 | ? "No More" 61 | : "More" 62 | 63 | return ( 64 |
65 | {comments.map((comment: HnComment) => { 66 | return 67 | })} 68 | 69 | 81 |
82 | ) 83 | } 84 | -------------------------------------------------------------------------------- /app/item/components/item-with-comment.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | import { notFound } from "next/navigation" 3 | 4 | import { fetchItem } from "@/lib/hn-api-fetcher" 5 | import { commentCount, replyableStroy } from "@/lib/hn-item-utils" 6 | import { isLogin } from "@/lib/session" 7 | import { cn } from "@/lib/utils" 8 | import { Separator } from "@/components/ui/separator" 9 | import Fave from "@/components/fave" 10 | import HtmlText from "@/components/html-text" 11 | import StoryBy from "@/components/story-by" 12 | import StoryPoint from "@/components/story-point" 13 | import StoryTime from "@/components/story-time" 14 | import StoryUrl from "@/components/story-url" 15 | 16 | import Comments from "./comments" 17 | import ReplyForm from "./reply-form" 18 | 19 | export default async function ItemWithComment({ 20 | id, 21 | faved = false, 22 | }: { 23 | id: number 24 | faved?: boolean 25 | }) { 26 | if (!id) { 27 | notFound() 28 | } 29 | const story = await fetchItem(id) 30 | if (!story) { 31 | notFound() 32 | } 33 | return ( 34 |
35 |
36 | 42 | {story.dead ? "[flagged]" : story.title} 43 | 44 |
45 |
46 | {story.url && } 47 | 48 | 49 | 50 |
51 | 52 |
53 | 54 |
55 | {replyableStroy(story) && ( 56 | 57 | )} 58 | 59 | {story.descendants > 0 && ( 60 | {commentCount(story.descendants)} 61 | )} 62 | {story?.kids && } 63 |
64 | ) 65 | } 66 | -------------------------------------------------------------------------------- /app/item/components/reply-dialog.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | import { useCurrentUser } from "@/hooks" 3 | 4 | import { HnComment } from "@/lib/hn-types" 5 | import { inTwoWeeks } from "@/lib/time-utils" 6 | import { Button } from "@/components/ui/button" 7 | import { 8 | Dialog, 9 | DialogContent, 10 | DialogDescription, 11 | DialogHeader, 12 | DialogTitle, 13 | DialogTrigger, 14 | } from "@/components/ui/dialog" 15 | 16 | import ReplyForm from "./reply-form" 17 | 18 | export default function ReplyDialog({ comment }: { comment: HnComment }) { 19 | const currentUser = useCurrentUser() 20 | return ( 21 | 22 | 23 | 31 | 32 | e.preventDefault()} 34 | onInteractOutside={(e) => e.preventDefault()} 35 | > 36 | 37 | 38 | Reply to{" "} 39 | 48 | {comment.by} 49 | 50 | 51 | {comment.text && ( 52 | 56 | )} 57 | 58 | 64 | 65 | 66 | ) 67 | } 68 | -------------------------------------------------------------------------------- /app/item/components/reply-form.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import Link from "next/link" 4 | import { useFormAction, useGoto } from "@/hooks" 5 | import { zodResolver } from "@hookform/resolvers/zod" 6 | import { InfoIcon, Loader2 } from "lucide-react" 7 | import { useFormStatus } from "react-dom" 8 | import { z } from "zod" 9 | 10 | import { replyAction } from "@/lib/actions" 11 | import { cn } from "@/lib/utils" 12 | import { Button } from "@/components/ui/button" 13 | import { 14 | Form, 15 | FormControl, 16 | FormField, 17 | FormItem, 18 | FormMessage, 19 | } from "@/components/ui/form" 20 | import { 21 | Popover, 22 | PopoverContent, 23 | PopoverTrigger, 24 | } from "@/components/ui/popover" 25 | import { Textarea } from "@/components/ui/textarea" 26 | 27 | const replyFormSchema = z.object({ 28 | parent: z.number(), 29 | text: z 30 | .string({ 31 | required_error: "Please enter your comment", 32 | }) 33 | .min(1, { message: "Please enter your comment" }), 34 | }) 35 | type ReplyFormValues = z.infer 36 | 37 | type Props = { 38 | logined: boolean 39 | text?: string 40 | position?: "left" | "right" 41 | parentId: number 42 | } 43 | 44 | export default function ReplyForm({ 45 | logined, 46 | text, 47 | position, 48 | parentId, 49 | }: Props) { 50 | const form = useFormAction({ 51 | resolver: zodResolver(replyFormSchema), 52 | defaultValues: { parent: parentId, text: "" }, 53 | schema: replyFormSchema, 54 | mode: "onSubmit", 55 | }) 56 | const goto = useGoto() 57 | const action = () => { 58 | form.handleAction(replyAction) 59 | } 60 | return ( 61 |
62 | 63 | 64 | ( 68 | 69 | 70 |