├── .env.local ├── public └── favicon.ico ├── styles ├── global.css └── tailwind.css ├── postcss.config.js ├── utils └── urls.ts ├── next-env.d.ts ├── tailwind.config.js ├── hooks ├── useObjectStore.ts ├── useReads.ts ├── useGithubStars.ts ├── useFeed.ts └── useAnimatedCollapse.ts ├── models └── Post.ts ├── .gitignore ├── tsconfig.json ├── components ├── DefaultContent.tsx ├── GithubStar.tsx ├── Button.tsx ├── Layout.tsx ├── YouTubeContent.tsx ├── ArticleContent.tsx └── Post.tsx ├── pages ├── _app.tsx ├── _document.tsx └── index.tsx ├── package.json ├── README.md └── yarn.lock /.env.local: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_API_SERVER="http://localhost:3001" 2 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ksdme/hackerscroll-frontend/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /styles/global.css: -------------------------------------------------------------------------------- 1 | .github-button-wrapper span { 2 | display: flex; 3 | align-items: center; 4 | } 5 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /utils/urls.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Returns the URL for the hackernews page of the post. 3 | */ 4 | export function hnItemUrl(id: number) { 5 | return `https://news.ycombinator.com/item?id=${id}` 6 | } 7 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: [ 3 | './components/**/*.{js,jsx,ts,tsx}', 4 | './pages/**/*.{js,jsx,ts,tsx}', 5 | ], 6 | theme: { 7 | extend: {}, 8 | }, 9 | plugins: [ 10 | require('@tailwindcss/typography'), 11 | require('@tailwindcss/line-clamp'), 12 | require('tailwind-scrollbar-hide'), 13 | ], 14 | darkMode: 'class', 15 | } 16 | -------------------------------------------------------------------------------- /hooks/useObjectStore.ts: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react' 2 | 3 | /* 4 | Non reactive value storeage with handy accessors. 5 | */ 6 | export default function useObjectStore() { 7 | const store = useRef({} as Record) 8 | 9 | // Accessors. 10 | return { 11 | get: (key: string | number) => store.current[key], 12 | set: (key: string | number, value: V) => store.current[key] = value, 13 | reset: () => store.current = {} as Record, 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /models/Post.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Represents the post. 3 | */ 4 | export interface Post { 5 | id: number 6 | hn_id: number 7 | rank?: number 8 | by: string 9 | title: string 10 | url?: string 11 | text?: string 12 | date?: string 13 | Content?: Content 14 | } 15 | 16 | /* 17 | Represents the content of a parsed post. 18 | */ 19 | export interface Content { 20 | id: number 21 | direction?: string 22 | title?: string 23 | byline?: string 24 | content: string 25 | excerpt?: string 26 | length?: number 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 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.development.local 29 | .env.test.local 30 | .env.production 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": false, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve" 16 | }, 17 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 18 | "exclude": ["node_modules"] 19 | } 20 | -------------------------------------------------------------------------------- /hooks/useReads.ts: -------------------------------------------------------------------------------- 1 | import createPersistedState from 'use-persisted-state' 2 | 3 | // Persistent data source for reads. 4 | export const useReadsStore = createPersistedState('reads') 5 | 6 | // Interact with the reads. 7 | export default function useReads() { 8 | const [ 9 | reads, 10 | setReads, 11 | ] = useReadsStore({}) 12 | 13 | // Getter 14 | const get = (id: number) => { 15 | return Boolean(reads[id]) 16 | } 17 | 18 | // Toggle 19 | const toggle = (id: number) => { 20 | setReads({ 21 | ...reads, 22 | [id]: !get(id), 23 | }) 24 | } 25 | 26 | return { 27 | get, 28 | toggle, 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /components/DefaultContent.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react' 2 | import parse from 'url-parse' 3 | import { Post } from '../models/Post' 4 | import { hnItemUrl } from '../utils/urls' 5 | 6 | /* 7 | The default content renderer. 8 | */ 9 | export default function DefaultContent(props: Props) { 10 | const { 11 | post, 12 | } = props 13 | 14 | const url = useMemo(() => { 15 | return parse(post.url ?? hnItemUrl(post.hn_id), true) 16 | }, [ 17 | post.url, 18 | post.hn_id, 19 | ]) 20 | 21 | return ( 22 | 28 | ) 29 | } 30 | 31 | interface Props { 32 | post: Post 33 | } 34 | -------------------------------------------------------------------------------- /hooks/useGithubStars.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { useQuery } from 'react-query' 3 | 4 | /* 5 | Hook to fetch the star count for a given Github repo. The hook is 6 | forgiving and will return null if the data is not available for any 7 | reason. 8 | */ 9 | export default function useGithubStars(namespace: string, repo: string) { 10 | // https://docs.github.com/en/rest/reference/repos 11 | const url = `https://api.github.com/repos/${namespace}/${repo}` 12 | 13 | // Fetcher for querying. 14 | async function fetcher() { 15 | const response = await axios.get(url) 16 | return response?.data?.stargazers_count 17 | } 18 | 19 | const { 20 | data, 21 | } = useQuery(url, fetcher, { 22 | retry: 3, 23 | staleTime: 0, 24 | cacheTime: Infinity, 25 | }) 26 | 27 | return data ?? null 28 | } 29 | 30 | interface Response { 31 | stargazers_count: number 32 | } 33 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import '../styles/tailwind.css' 2 | import '../styles/global.css' 3 | import { AppProps } from 'next/app' 4 | import { GoogleAnalytics, usePagesViews } from 'nextjs-google-analytics' 5 | import { ThemeProvider } from 'next-themes' 6 | import React from 'react' 7 | import { QueryClient, QueryClientProvider } from 'react-query' 8 | 9 | // Shared query client for react-query. 10 | const queryClient = new QueryClient() 11 | 12 | // TODO: Animate theme change 13 | function App({ Component, pageProps }: AppProps) { 14 | // Track route changes. 15 | usePagesViews() 16 | 17 | return ( 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | ) 28 | } 29 | 30 | export default App 31 | -------------------------------------------------------------------------------- /hooks/useFeed.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { useInfiniteQuery } from 'react-query' 3 | import { Post } from '../models/Post' 4 | 5 | /* 6 | Hook to fetch the top posts from the API. 7 | */ 8 | export default function useFeed(initialPage = 1) { 9 | const fetcher = ({ pageParam = initialPage }) => { 10 | return axios.get(process.env.NEXT_PUBLIC_API_SERVER + '/top', { 11 | params: { 12 | page: pageParam, 13 | }, 14 | }) 15 | } 16 | 17 | return useInfiniteQuery('posts', fetcher, { 18 | cacheTime: 7.5 * 60 * 1000, 19 | staleTime: Infinity, 20 | refetchOnMount: false, 21 | refetchOnReconnect: false, 22 | refetchOnWindowFocus: false, 23 | getNextPageParam: (lastPage) => { 24 | if (lastPage.data?.items?.length) { 25 | return lastPage.data.page + 1 26 | } 27 | }, 28 | getPreviousPageParam: (firstPage) => { 29 | return firstPage.data.page - 1 30 | }, 31 | }) 32 | } 33 | 34 | interface FeedResponse { 35 | page: number 36 | items: Post[] 37 | } 38 | -------------------------------------------------------------------------------- /pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import Document, { 2 | DocumentContext, 3 | Html, 4 | Head, 5 | Main, 6 | NextScript, 7 | } from 'next/document' 8 | 9 | export default class AppDocument extends Document { 10 | static async getInitialProps(ctx: DocumentContext) { 11 | return { 12 | ...await Document.getInitialProps(ctx), 13 | } 14 | } 15 | 16 | render() { 17 | return ( 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 |
28 | 29 | 30 | 31 | ) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /components/GithubStar.tsx: -------------------------------------------------------------------------------- 1 | import useGithubStars from '../hooks/useGithubStars' 2 | 3 | /* 4 | Renders a GitHub link button for a repository with the number of 5 | stars. 6 | */ 7 | export default function GithubStar() { 8 | // Get the star count from the Public API. 9 | const stars = useGithubStars('ksdme', 'hackerscroll-frontend') 10 | 11 | return ( 12 | 22 | { 23 | (stars || stars === 0) && ( 24 |
29 | {stars} 30 |
31 | ) 32 | } 33 | 34 |
35 | GitHub 36 |
37 |
38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /styles/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer components { 6 | /* 7 | prose-img does not apply to SVG elements. 8 | */ 9 | .prose svg { 10 | max-width: 100%; 11 | } 12 | 13 | /* 14 | Revert the style of code block in case it's not inline. 15 | */ 16 | .prose pre code { 17 | @apply border-0 p-0 m-0 bg-transparent text-white; 18 | } 19 | 20 | /* 21 | Limit iframe max-width to 100%. 22 | */ 23 | .prose iframe { 24 | @apply max-w-full; 25 | } 26 | } 27 | 28 | @layer utilities { 29 | .no-tap-highlight { 30 | -webkit-tap-highlight-color: rgba(0,0,0,0); 31 | -webkit-tap-highlight-color: transparent; 32 | } 33 | 34 | .font-sans { 35 | font-family: 'IBM Plex Sans', sans-serif; 36 | } 37 | 38 | .font-monospace { 39 | font-family: 'JetBrains Mono', monospace; 40 | } 41 | 42 | .animate-fade-out { 43 | /* TODO: Use variable for animation. */ 44 | animation: fade-out 80ms linear 1 forwards; 45 | } 46 | } 47 | 48 | @keyframes fade-out { 49 | 0% { opacity: 1; } 50 | 50% { opacity: 0.6; } 51 | 100% { opacity: 0; } 52 | } 53 | -------------------------------------------------------------------------------- /components/Button.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | Button Component. 3 | */ 4 | export default function Button(props: Props) { 5 | const { 6 | icon: Icon, 7 | label, 8 | onClick, 9 | disableEventPropagation = false, 10 | } = props 11 | 12 | return ( 13 | 37 | ) 38 | } 39 | 40 | interface Props { 41 | icon?: React.ComponentType<{ className?: string }> 42 | label?: string 43 | onClick?: () => void 44 | disableEventPropagation?: boolean 45 | } 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "dev": "next", 5 | "build": "next build", 6 | "start": "next start", 7 | "type-check": "tsc" 8 | }, 9 | "dependencies": { 10 | "@heroicons/react": "^1.0.5", 11 | "@tailwindcss/line-clamp": "^0.3.0", 12 | "@tailwindcss/typography": "^0.5.0", 13 | "axios": "^0.24.0", 14 | "clsx": "^1.1.1", 15 | "next": "latest", 16 | "next-themes": "^0.0.15", 17 | "nextjs-google-analytics": "^1.0.9", 18 | "react": "^17.0.2", 19 | "react-collapsed": "^3.3.0", 20 | "react-dom": "^17.0.2", 21 | "react-query": "^3.34.6", 22 | "react-topbar-progress-indicator": "^4.1.0", 23 | "tailwind-scrollbar-hide": "^1.1.7", 24 | "url-parse": "^1.5.3", 25 | "use-persisted-state": "^0.3.3" 26 | }, 27 | "devDependencies": { 28 | "@types/node": "^12.12.21", 29 | "@types/react": "^17.0.2", 30 | "@types/react-dom": "^17.0.1", 31 | "@types/url-parse": "^1.4.6", 32 | "@types/use-persisted-state": "^0.3.0", 33 | "autoprefixer": "^10.4.0", 34 | "postcss": "^8.4.5", 35 | "tailwindcss": "^3.0.7", 36 | "tslib": "^2.3.1", 37 | "typescript": "4.0" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /components/Layout.tsx: -------------------------------------------------------------------------------- 1 | import Script from 'next/script' 2 | import React from 'react' 3 | import GithubStar from '../components/GithubStar' 4 | 5 | /* 6 | Layout shell component for a page. 7 | */ 8 | export default function Layout(props: Props) { 9 | const { 10 | children, 11 | } = props 12 | 13 | return ( 14 | 15 |
16 | 27 | 28 |
29 | {children} 30 |
31 |
32 | 33 |