├── .dockerignore ├── .editorconfig ├── .env.example ├── .github ├── CODEOWNERS ├── FUNDING.yml ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml └── workflows │ └── assertions.yml ├── .gitignore ├── .nvmrc ├── .prettierignore ├── .vscode ├── extensions.json └── settings.json ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── app ├── (default) │ ├── about │ │ └── page.tsx │ ├── layout.tsx │ ├── not-found.tsx │ ├── page.tsx │ └── r │ │ └── [slug] │ │ └── page.tsx ├── apple-icon.png ├── favicon.ico ├── icon.png ├── layout.tsx ├── manifest.ts ├── robots.ts └── sitemap.ts ├── components ├── BackToTop │ ├── BackToTop.module.css │ └── BackToTop.tsx ├── BossButton │ ├── BossButton.module.css │ └── BossButton.tsx ├── Favorite │ └── Favorite.tsx ├── Header │ ├── Header.module.css │ ├── Header.test.tsx │ ├── Header.tsx │ └── HeaderIcons.tsx ├── HlsPlayer │ └── HlsPlayer.tsx ├── Media │ └── Media.tsx ├── MediaContainer │ ├── MediaContainer.module.css │ └── MediaContainer.tsx ├── PostCard │ ├── PostCard.module.css │ └── PostCard.tsx ├── Posts │ └── Posts.tsx ├── ResponsiveImage │ └── ResponsiveImage.tsx ├── Search │ ├── Search.module.css │ └── Search.tsx ├── Settings │ └── Settings.tsx ├── Sidebar │ ├── Sidebar.tsx │ ├── SidebarNavLink.tsx │ └── SidebarSection.tsx ├── SubredditName │ ├── SubredditName.module.css │ └── SubredditName.tsx └── YouTubePlayer │ └── YouTubePlayer.tsx ├── eslint.config.mjs ├── globals.d.ts ├── lefthook.yml ├── lib ├── actions │ ├── redditToken.test.ts │ └── redditToken.ts ├── config.ts ├── hooks │ ├── useBossButton.ts │ ├── useHeaderState.ts │ ├── useHlsVideo.ts │ ├── useInfinitePosts.ts │ ├── useMediaAssets.ts │ ├── useMediaType.ts │ ├── useRemoveItemFromHistory.ts │ ├── useSidebarSection.ts │ ├── useSubredditSearch.ts │ ├── useToggleFavorite.ts │ ├── useTrackRecentSubreddit.ts │ └── useYouTubeVideo.ts ├── store │ ├── StoreProvider.tsx │ ├── features │ │ ├── settingsSlice.ts │ │ └── transientSlice.ts │ ├── hooks.ts │ ├── index.ts │ └── services │ │ ├── redditApi.test.ts │ │ └── redditApi.ts ├── types │ ├── about.ts │ ├── index.ts │ ├── popular.ts │ ├── posts.ts │ ├── search.ts │ └── token.ts └── utils │ ├── debounce.ts │ ├── extractChildren.ts │ ├── formatTimeAgo.ts │ ├── getIsVertical.ts │ ├── getMediumImage.ts │ ├── logError.ts │ ├── mediaCache.ts │ ├── sanitizeText.ts │ ├── storage.ts │ ├── subredditMapper.ts │ └── token.ts ├── next.config.ts ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── prettier.config.mjs ├── public ├── ads.txt ├── not-found.webp ├── social-share.psd └── social-share.webp ├── test-utils ├── index.ts ├── mocks │ ├── about.ts │ ├── browserMocks.ts │ ├── popular.ts │ ├── search.ts │ ├── subreddit.ts │ └── token.ts ├── msw │ ├── browser.ts │ ├── handlers.ts │ └── server.ts ├── render.tsx └── renderHook.tsx ├── tsconfig.json ├── vitest.config.ts └── vitest.setup.ts /.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | .dockerignore 3 | node_modules 4 | npm-debug.log 5 | README.md 6 | .next 7 | .git 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | end_of_line = lf 10 | insert_final_newline = true 11 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Your Reddit Client ID 2 | # Get one here: https://www.reddit.com/prefs/apps 3 | REDDIT_CLIENT_ID="YOUR-TOKEN-HERE" 4 | 5 | # Your Reddit Client Secret 6 | # Get one here: https://www.reddit.com/prefs/apps 7 | REDDIT_CLIENT_SECRET="YOUR-TOKEN-HERE" 8 | 9 | # Used on production to verify the site with Google Webmaster Tools. 10 | GOOGLE_SITE_VERIFICATION="YOUR-TOKEN-HERE" 11 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Code Owners 2 | # https://help.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners 3 | * @gregrickaby 4 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: gregrickaby 4 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Related Issue 2 | 3 | Fixes # 4 | 5 | ### Description 6 | 7 | What does your feature do? Give some context. 8 | 9 | ### Screenshot 10 | 11 | If possible, add some screenshots of your feature. 12 | 13 | ### Verification 14 | 15 | How will a stakeholder test this PR? 16 | 17 | 1. 18 | 1. 19 | 1. 20 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'npm' 4 | directory: '/' 5 | schedule: 6 | interval: 'weekly' 7 | day: 'monday' 8 | -------------------------------------------------------------------------------- /.github/workflows/assertions.yml: -------------------------------------------------------------------------------- 1 | name: Assertions 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | assertions: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Checkout Repository 18 | uses: actions/checkout@main 19 | 20 | - name: Setup Node 21 | uses: actions/setup-node@main 22 | with: 23 | node-version: '22' 24 | cache: 'npm' 25 | 26 | - name: Copy .env 27 | run: cp .env.example .env 28 | 29 | - name: Install Dependencies 30 | run: npm ci --ignore-scripts 31 | 32 | - name: Lint 33 | run: npm run lint 34 | 35 | - name: Test 36 | run: npm run test 37 | 38 | - name: Build 39 | run: npm run build 40 | -------------------------------------------------------------------------------- /.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.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 22 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 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 | 38 | # public 39 | public/ 40 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "EditorConfig.EditorConfig", 4 | "bradlc.vscode-tailwindcss", 5 | "dbaeumer.vscode-eslint", 6 | "esbenp.prettier-vscode", 7 | "stylelint.vscode-stylelint", 8 | "sonarsource.sonarlint-vscode" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | "editor.formatOnSave": true, 4 | "editor.formatOnPaste": true, 5 | "eslint.run": "onSave", 6 | "typescript.suggest.autoImports": true, 7 | "typescript.updateImportsOnFileMove.enabled": "always", 8 | "[typescript]": { 9 | "editor.codeActionsOnSave": { 10 | "source.fixAll": "explicit", 11 | "source.organizeImports": "explicit" 12 | } 13 | }, 14 | "[typescriptreact]": { 15 | "editor.codeActionsOnSave": { 16 | "source.fixAll": "explicit", 17 | "source.organizeImports": "explicit" 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Here are the ways to get involved with this project: 4 | 5 | - [Issues \& Discussions](#issues--discussions) 6 | - [Reddit API](#reddit-api) 7 | - [Contributing Code](#contributing-code) 8 | - [Install Locally](#install-locally) 9 | - [Git Workflow](#git-workflow) 10 | - [ENV Variables](#env-variables) 11 | - [NPM Scripts](#npm-scripts) 12 | - [Vercel CLI](#vercel-cli) 13 | - [Legal Stuff](#legal-stuff) 14 | 15 | ## Issues & Discussions 16 | 17 | Before submitting your issue, make sure it has not been mentioned earlier. You can search through the [existing issues](https://github.com/gregrickaby/viewer-for-reddit/issues) or active [discussions](https://github.com/gregrickaby/viewer-for-reddit/discussions). 18 | 19 | --- 20 | 21 | ## Reddit API 22 | 23 | Please review the [Reddit API Documentation and Rules](https://github.com/reddit-archive/reddit/wiki/API) before submitting a patch. Also, this project is not affiliated with Reddit in any way. 24 | 25 | --- 26 | 27 | ## Contributing Code 28 | 29 | Found a bug you can fix? Fantastic! Pull requests are always welcome. Please follow the steps below to get started. 30 | 31 | --- 32 | 33 | ### Install Locally 34 | 35 | Use `npx` and `create-next-app` to install the project locally: 36 | 37 | ```bash 38 | npx create-next-app --example https://github.com/gregrickaby/viewer-for-reddit viewer-for-reddit 39 | ``` 40 | 41 | --- 42 | 43 | ### Git Workflow 44 | 45 | 1. Fork the repo and create a `feature/` or `hotfix/` branch off `main` 46 | 2. Work locally adhering to coding standards 47 | 3. Run `npm run lint` 48 | 4. Make sure the app builds locally with `npm run build && npm run start` 49 | 5. Push your code to Github and open your PR (note: Your PR will be deployed to Vercel for QA after approval) 50 | 6. Fill out the PR template and request a peer review 51 | 7. After peer review, the PR will be merged back into `main` 52 | 8. Repeat ♻️ 53 | 54 | > Your PR must pass automated assertions, deploy to Vercel successfully, and pass a peer review before it can be merged. 55 | 56 | --- 57 | 58 | ### ENV Variables 59 | 60 | In order to authenticate with the Reddit API, you'll need to [create a Reddit app](https://github.com/reddit-archive/reddit/wiki/OAuth2): 61 | 62 | 1. Visit 63 | 1. Name: `My App` 64 | 2. Type `script` 65 | 3. Description: `My App Description` 66 | 4. About URL: `https://example.com` 67 | 5. Redirect URI: `https://my-app.vercel.app` 68 | 69 | Take note of the `client id` and `secret` values. You will need these in a moment. 70 | 71 | 2. Create an `.env` file in the root of the project: 72 | 73 | ```bash 74 | cp .env.example .env.local 75 | ``` 76 | 77 | 3. Add your token to the `.env.local` file: 78 | 79 | ```text 80 | # Your Reddit Client ID 81 | # Get one here: https://www.reddit.com/prefs/apps 82 | REDDIT_CLIENT_ID="YOUR-TOKEN-HERE" 83 | 84 | # Search Secret 85 | NEXT_PUBLIC_SEARCH_SECRET="ANY-RANDOM-STRING-HERE" 86 | 87 | # Used on production to verify the site with Google Webmaster Tools. 88 | NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION="YOUR-TOKEN-HERE" 89 | ``` 90 | 91 | > The `NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION` is only needed on Production. You can leave it as-is for local development. 92 | 93 | --- 94 | 95 | ### NPM Scripts 96 | 97 | There are a few NPM scripts available: 98 | 99 | - `npm run dev` - Starts the development server 100 | - `npm run lint` - Runs ESLint and Prettier 101 | - `npm run build && npm start` - Builds the app for production and starts the server. This is great for catching bugs locally prior to a deployment. 102 | 103 | --- 104 | 105 | ### Vercel CLI 106 | 107 | I've found that running `vercel` locally is a great way to verify Edge Functions and Middleware are working as expected. 108 | 109 | To install the [Vercel CLI](https://vercel.com/docs/cli), run: 110 | 111 | ```bash 112 | npm i -g vercel 113 | ``` 114 | 115 | Then, pull down the ENV variables from Vercel: 116 | 117 | ```bash 118 | vercel env pull 119 | ``` 120 | 121 | Finally, start a Vercel development server locally: 122 | 123 | ```bash 124 | vercel dev 125 | ``` 126 | 127 | --- 128 | 129 | ## Legal Stuff 130 | 131 | This repo is maintained by [Greg Rickaby](https://gregrickaby.com/). By contributing code you grant its use under the [MIT](https://github.com/gregrickaby/viewer-for-reddit/blob/main/LICENSE). 132 | 133 | --- 134 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker.io/docker/dockerfile:1 2 | 3 | FROM node:22-alpine AS base 4 | 5 | # Install dependencies only when needed 6 | FROM base AS deps 7 | # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. 8 | RUN apk add --no-cache libc6-compat 9 | WORKDIR /app 10 | 11 | # Install dependencies based on the preferred package manager 12 | COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./ 13 | RUN \ 14 | if [ -f yarn.lock ]; then yarn --frozen-lockfile; \ 15 | elif [ -f package-lock.json ]; then npm ci; \ 16 | elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \ 17 | else echo "Lockfile not found." && exit 1; \ 18 | fi 19 | 20 | 21 | # Rebuild the source code only when needed 22 | FROM base AS builder 23 | WORKDIR /app 24 | COPY --from=deps /app/node_modules ./node_modules 25 | COPY . . 26 | 27 | # Next.js collects completely anonymous telemetry data about general usage. 28 | # Learn more here: https://nextjs.org/telemetry 29 | # Uncomment the following line in case you want to disable telemetry during the build. 30 | # ENV NEXT_TELEMETRY_DISABLED=1 31 | 32 | RUN \ 33 | if [ -f yarn.lock ]; then yarn run build; \ 34 | elif [ -f package-lock.json ]; then npm run build; \ 35 | elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \ 36 | else echo "Lockfile not found." && exit 1; \ 37 | fi 38 | 39 | # Production image, copy all the files and run next 40 | FROM base AS runner 41 | WORKDIR /app 42 | 43 | ENV NODE_ENV=production 44 | # Uncomment the following line in case you want to disable telemetry during runtime. 45 | # ENV NEXT_TELEMETRY_DISABLED=1 46 | 47 | RUN addgroup --system --gid 1001 nodejs 48 | RUN adduser --system --uid 1001 nextjs 49 | 50 | COPY --from=builder /app/public ./public 51 | 52 | # Automatically leverage output traces to reduce image size 53 | # https://nextjs.org/docs/advanced-features/output-file-tracing 54 | COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ 55 | COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static 56 | 57 | USER nextjs 58 | 59 | EXPOSE 3000 60 | 61 | ENV PORT=3000 62 | 63 | # server.js is created by next build from the standalone output 64 | # https://nextjs.org/docs/pages/api-reference/config/next-config-js/output 65 | ENV HOSTNAME="0.0.0.0" 66 | CMD ["node", "server.js"] 67 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright © 2022 Greg Rickaby 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Viewer for Reddit 2 | 3 | 🙈 Anonymously browse Reddit [https://reddit-viewer.com/](https://reddit-viewer.com/) 4 | 5 | Viewer for Reddit has been a fast, private way to browse media on Reddit since 2020. 6 | 7 | There's no tracking, no ads, and no personalized feeds or algorithms — just a clean, fast browsing experience. 8 | 9 | --- 10 | 11 | ## Contributing 12 | 13 | Please read the [contributing guide](/CONTRIBUTING.md) to get started. 14 | 15 | --- 16 | -------------------------------------------------------------------------------- /app/(default)/about/page.tsx: -------------------------------------------------------------------------------- 1 | import config from '@/lib/config' 2 | import {Container} from '@mantine/core' 3 | import {SiBuymeacoffee} from 'react-icons/si' 4 | 5 | /** 6 | * Generate metadata. 7 | */ 8 | export async function generateMetadata() { 9 | return { 10 | title: `About - ${config.siteName}`, 11 | description: `${config.siteName} has been a fast, private way to browse Reddit media since 2020. No ads. No tracking.`, 12 | alternates: { 13 | canonical: `${config.siteUrl}about` 14 | }, 15 | openGraph: { 16 | title: `About - ${config.siteName}`, 17 | description: `Learn more about the motivation and creator behind ${config.siteName}, a privacy-first Reddit viewer.`, 18 | url: `${config.siteUrl}about`, 19 | images: [ 20 | { 21 | url: `${config.siteUrl}social-share.webp`, 22 | width: 1200, 23 | height: 630, 24 | alt: config.siteName 25 | } 26 | ] 27 | } 28 | } 29 | } 30 | 31 | /** 32 | * The about page. 33 | */ 34 | export default async function About() { 35 | return ( 36 | 37 |

About

38 |

39 | Viewer for Reddit has been a fast, private way to 40 | browse media on Reddit since 2020. 41 |

42 |

43 | There's no tracking, no ads, and no personalized feeds or algorithms — 44 | just a clean, fast browsing experience. 45 |

46 |

47 | Built and maintained by{' '} 48 | {' '} 55 | ( 56 | 62 | view source code 63 | 64 | ) 65 |

66 |

67 | Enjoying the app?{' '} 68 | 74 | Buy me a coffee! 75 | 76 | 77 |

78 |
79 | ) 80 | } 81 | -------------------------------------------------------------------------------- /app/(default)/layout.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import {Header} from '@/components/Header/Header' 4 | import {Sidebar} from '@/components/Sidebar/Sidebar' 5 | import {useHeaderState} from '@/lib/hooks/useHeaderState' 6 | import {AppShell} from '@mantine/core' 7 | import {useViewportSize} from '@mantine/hooks' 8 | 9 | /** 10 | * The client-side layout component with the AppShell. 11 | */ 12 | export default function Layout({ 13 | children 14 | }: Readonly<{ 15 | children: React.ReactNode 16 | }>) { 17 | const {showNavbar: isNavbarCollapsed} = useHeaderState() 18 | 19 | const isMobile = useViewportSize().width < 480 20 | 21 | return ( 22 | 32 | 33 |
34 | 35 | 36 | 37 | 38 | 39 | 40 | {children} 41 | 42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /app/(default)/not-found.tsx: -------------------------------------------------------------------------------- 1 | import notFound from '@/public/not-found.webp' 2 | import Image from 'next/image' 3 | 4 | /** 5 | * The 404 component. 6 | */ 7 | export default function NotFound() { 8 | return ( 9 |
10 |

404 - Not Found

11 | Not Found 12 |

The page you're looking for cannot be found.

13 |
14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /app/(default)/page.tsx: -------------------------------------------------------------------------------- 1 | import BackToTop from '@/components/BackToTop/BackToTop' 2 | import BossButton from '@/components/BossButton/BossButton' 3 | import {Posts} from '@/components/Posts/Posts' 4 | 5 | export default async function Home() { 6 | return ( 7 | <> 8 | 9 | 10 | 11 | 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /app/(default)/r/[slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import BackToTop from '@/components/BackToTop/BackToTop' 2 | import BossButton from '@/components/BossButton/BossButton' 3 | import {Posts} from '@/components/Posts/Posts' 4 | import config from '@/lib/config' 5 | import type {Params, SearchParams, SortingOption} from '@/lib/types' 6 | 7 | /** 8 | * Generate metadata. 9 | */ 10 | export async function generateMetadata(props: {params: Params}) { 11 | const params = await props.params 12 | const slug = params.slug 13 | 14 | return { 15 | title: `/r/${slug} - ${config.siteName}`, 16 | description: `Browse posts in /r/${slug} anonymously with Viewer for Reddit.`, 17 | alternates: { 18 | canonical: `${config.siteUrl}r/${slug}` 19 | }, 20 | openGraph: { 21 | title: `/r/${slug} - ${config.siteName}`, 22 | description: `Posts in /r/${slug}, updated in real time.`, 23 | url: `${config.siteUrl}r/${slug}`, 24 | images: [ 25 | { 26 | url: `${config.siteUrl}social-share.webp`, 27 | width: 1200, 28 | height: 630, 29 | alt: config.siteName 30 | } 31 | ] 32 | } 33 | } 34 | } 35 | 36 | /** 37 | * The single subreddit page. 38 | */ 39 | export default async function Page(props: { 40 | params: Params 41 | searchParams: SearchParams 42 | }) { 43 | const params = await props.params 44 | const slug = params.slug 45 | const searchParams = await props.searchParams 46 | const sort = searchParams.sort as SortingOption 47 | 48 | return ( 49 | <> 50 | 51 | 52 | 53 | 54 | ) 55 | } 56 | -------------------------------------------------------------------------------- /app/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gregrickaby/viewer-for-reddit/9fbcfc3b3f426ba5f4e2170d4b73c5caa2fe276d/app/apple-icon.png -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gregrickaby/viewer-for-reddit/9fbcfc3b3f426ba5f4e2170d4b73c5caa2fe276d/app/favicon.ico -------------------------------------------------------------------------------- /app/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gregrickaby/viewer-for-reddit/9fbcfc3b3f426ba5f4e2170d4b73c5caa2fe276d/app/icon.png -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | 'use cache' 2 | 3 | import config from '@/lib/config' 4 | import {StoreProvider} from '@/lib/store/StoreProvider' 5 | import { 6 | ColorSchemeScript, 7 | MantineProvider, 8 | mantineHtmlProps 9 | } from '@mantine/core' 10 | import '@mantine/core/styles.css' 11 | import {Notifications} from '@mantine/notifications' 12 | import '@mantine/notifications/styles.css' 13 | import type {Metadata, Viewport} from 'next' 14 | 15 | /** 16 | * Generate metadata. 17 | * 18 | * @see https://nextjs.org/docs/app/api-reference/functions/generate-metadata 19 | */ 20 | export const metadata: Metadata = { 21 | metadataBase: new URL(config.siteUrl), 22 | title: `${config.siteName} - ${config.siteDescription}`, 23 | description: config.metaDescription, 24 | robots: 'follow, index', 25 | alternates: { 26 | canonical: config.siteUrl 27 | }, 28 | openGraph: { 29 | description: config.metaDescription, 30 | locale: 'en_US', 31 | title: config.siteName, 32 | type: 'website', 33 | url: config.siteUrl, 34 | images: [ 35 | { 36 | url: `${config.siteUrl}social-share.webp`, 37 | width: 1200, 38 | height: 630, 39 | alt: config.siteName 40 | } 41 | ] 42 | }, 43 | manifest: '/manifest.webmanifest', 44 | verification: { 45 | google: process.env.GOOGLE_SITE_VERIFICATION ?? '' 46 | } 47 | } 48 | 49 | /** 50 | * Setup viewport. 51 | * 52 | * @see https://nextjs.org/docs/app/api-reference/functions/generate-viewport#the-viewport-object 53 | */ 54 | export const viewport: Viewport = { 55 | colorScheme: 'dark', 56 | themeColor: '#18181b' 57 | } 58 | 59 | /** 60 | * The server-rendered root layout component. 61 | * 62 | * This component sets up the global layout for the application. 63 | * It includes the MantineProvider for theming and styles, 64 | * and the StoreProvider for Redux state management. 65 | * 66 | * It also handles the initial color scheme, SEO metadata, and viewport settings. 67 | */ 68 | export default async function RootLayout({ 69 | children 70 | }: Readonly<{ 71 | children: React.ReactNode 72 | }>) { 73 | return ( 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | {children} 82 | 83 | 84 | 85 | 86 | 87 | ) 88 | } 89 | -------------------------------------------------------------------------------- /app/manifest.ts: -------------------------------------------------------------------------------- 1 | import config from '@/lib/config' 2 | import {MetadataRoute} from 'next' 3 | 4 | /** 5 | * The manifest.webmanifest route. 6 | * 7 | * @see https://nextjs.org/docs/app/api-reference/file-conventions/metadata/manifest 8 | */ 9 | export default function manifest(): MetadataRoute.Manifest { 10 | return { 11 | name: config.siteName, 12 | short_name: config.siteName, 13 | description: config.siteDescription, 14 | start_url: '/', 15 | display: 'standalone', 16 | background_color: '#18181b', 17 | theme_color: '#18181b', 18 | icons: [ 19 | { 20 | src: '/icon.png', 21 | sizes: '192x192', 22 | type: 'image/png' 23 | } 24 | ] 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/robots.ts: -------------------------------------------------------------------------------- 1 | import config from '@/lib/config' 2 | import {MetadataRoute} from 'next' 3 | 4 | /** 5 | * The robots.txt route. 6 | * 7 | * @see https://nextjs.org/docs/app/api-reference/file-conventions/metadata/robots 8 | */ 9 | export default function robots(): MetadataRoute.Robots { 10 | return { 11 | rules: { 12 | userAgent: '*', 13 | allow: '/' 14 | }, 15 | sitemap: `${config.siteUrl}/sitemap.xml` 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /app/sitemap.ts: -------------------------------------------------------------------------------- 1 | import config from '@/lib/config' 2 | import {MetadataRoute} from 'next' 3 | 4 | /** 5 | * Revalidate every 12 hours. 6 | */ 7 | export const revalidate = 43200 8 | 9 | /** 10 | * Top subreddits from getPopularSubreddits({ limit: 25 }) 11 | */ 12 | const popularSubreddits = [ 13 | 'funny', 14 | 'AskReddit', 15 | 'gaming', 16 | 'worldnews', 17 | 'movies', 18 | 'pics', 19 | 'news', 20 | 'AmItheAsshole', 21 | 'Damnthatsinteresting', 22 | 'pcmasterrace', 23 | 'interestingasfuck', 24 | 'Unexpected', 25 | 'mildlyinfuriating', 26 | 'politics', 27 | 'leagueoflegends', 28 | 'facepalm', 29 | 'NoStupidQuestions', 30 | 'AITAH', 31 | 'LivestreamFail', 32 | 'BaldursGate3', 33 | 'Piracy', 34 | 'PeterExplainsTheJoke', 35 | 'Helldivers', 36 | 'Palworld', 37 | 'Home' 38 | ] 39 | 40 | /** 41 | * Sitemap generator. 42 | * 43 | * @see https://nextjs.org/docs/app/api-reference/file-conventions/metadata/sitemap 44 | */ 45 | export default function sitemap(): MetadataRoute.Sitemap { 46 | const base = config.siteUrl 47 | const lastModified = new Date() 48 | 49 | const staticPages: MetadataRoute.Sitemap = [ 50 | { 51 | url: base, 52 | lastModified, 53 | changeFrequency: 'daily', 54 | priority: 1 55 | }, 56 | { 57 | url: `${base}/about`, 58 | lastModified, 59 | changeFrequency: 'monthly', 60 | priority: 0.5 61 | } 62 | ] 63 | 64 | const subredditPages: MetadataRoute.Sitemap = popularSubreddits.map( 65 | (slug) => ({ 66 | url: `${base}/r/${slug}`, 67 | lastModified, 68 | changeFrequency: 'hourly', 69 | priority: 0.8 70 | }) 71 | ) 72 | 73 | return [...staticPages, ...subredditPages] 74 | } 75 | -------------------------------------------------------------------------------- /components/BackToTop/BackToTop.module.css: -------------------------------------------------------------------------------- 1 | .backToTop { 2 | bottom: var(--mantine-spacing-lg); 3 | height: 48px; 4 | position: fixed; 5 | right: var(--mantine-spacing-lg); 6 | z-index: 999999; 7 | } 8 | -------------------------------------------------------------------------------- /components/BackToTop/BackToTop.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import {Button, VisuallyHidden} from '@mantine/core' 4 | import {useWindowScroll} from '@mantine/hooks' 5 | import {FaChevronUp} from 'react-icons/fa' 6 | import classes from './BackToTop.module.css' 7 | 8 | export default function BackToTop() { 9 | const [scroll, scrollTo] = useWindowScroll() 10 | const buttonText = 'Go back to the top of the page' 11 | 12 | if (scroll.y <= 200) { 13 | return null 14 | } 15 | 16 | return ( 17 | 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /components/BossButton/BossButton.module.css: -------------------------------------------------------------------------------- 1 | .bossButton { 2 | height: 48px; 3 | position: fixed; 4 | right: var(--mantine-spacing-lg); 5 | top: 96px; 6 | z-index: 100; 7 | } 8 | -------------------------------------------------------------------------------- /components/BossButton/BossButton.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import {useBossButton} from '@/lib/hooks/useBossButton' 4 | import {Button, Tooltip, VisuallyHidden} from '@mantine/core' 5 | import {MdExitToApp} from 'react-icons/md' 6 | import classes from './BossButton.module.css' 7 | 8 | export default function BossButton() { 9 | const {shouldShow, redirectUrl, buttonText} = useBossButton( 10 | 'https://duckduckgo.com/' 11 | ) 12 | 13 | if (!shouldShow) return null 14 | 15 | return ( 16 | 17 | 27 | 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /components/Favorite/Favorite.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import {useToggleFavorite} from '@/lib/hooks/useToggleFavorite' 4 | import {ActionIcon, Tooltip} from '@mantine/core' 5 | import {FaHeart, FaRegHeart} from 'react-icons/fa' 6 | 7 | interface FavoriteProps { 8 | subreddit: string 9 | } 10 | 11 | export function Favorite({subreddit}: Readonly) { 12 | const {isFavorite, loading, toggle} = useToggleFavorite(subreddit) 13 | 14 | if (subreddit === 'all' || subreddit === 'popular') return null 15 | 16 | const label = isFavorite ? 'Remove from favorites' : 'Add to favorites' 17 | const icon = isFavorite ? : 18 | const color = isFavorite ? 'red' : 'gray' 19 | 20 | return ( 21 | 22 | 32 | {icon} 33 | 34 | 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /components/Header/Header.module.css: -------------------------------------------------------------------------------- 1 | .header { 2 | align-items: center; 3 | display: flex; 4 | gap: var(--mantine-spacing-md); 5 | justify-content: space-between; 6 | } 7 | 8 | .headerLeft { 9 | align-items: center; 10 | display: flex; 11 | gap: var(--mantine-spacing-md); 12 | 13 | a { 14 | color: var(--mantine-text-color); 15 | text-decoration: none; 16 | } 17 | 18 | h1 { 19 | font-size: var(--mantine-font-size-lg); 20 | margin: 0; 21 | padding: 0; 22 | } 23 | } 24 | 25 | .headerRight { 26 | display: flex; 27 | align-items: center; 28 | justify-content: end; 29 | gap: var(--mantine-spacing-md); 30 | width: 70%; 31 | 32 | .icons { 33 | gap: var(--mantine-spacing-xs); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /components/Header/Header.test.tsx: -------------------------------------------------------------------------------- 1 | import {Header} from '@/components/Header/Header' 2 | import {render, screen} from '@/test-utils' 3 | 4 | describe('Header', () => { 5 | it('renders the header', () => { 6 | render(
) 7 | expect(screen.getByText('Viewer for Reddit')).toBeInTheDocument() 8 | }) 9 | }) 10 | -------------------------------------------------------------------------------- /components/Header/Header.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import icon from '@/app/icon.png' 4 | import {HeaderIcons} from '@/components/Header/HeaderIcons' 5 | import {Search} from '@/components/Search/Search' 6 | import config from '@/lib/config' 7 | import {useHeaderState} from '@/lib/hooks/useHeaderState' 8 | import {Burger, Group, Title, VisuallyHidden} from '@mantine/core' 9 | import Image from 'next/image' 10 | import Link from 'next/link' 11 | import classes from './Header.module.css' 12 | 13 | export function Header() { 14 | const {showNavbar, toggleNavbarHandler} = useHeaderState() 15 | 16 | return ( 17 |
18 |
19 | 25 | 26 | 27 | Logo 28 | {config.siteName} 29 | 30 | 31 | {config.metaDescription} 32 |
33 |
34 | 35 | 36 |
37 |
38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /components/Header/HeaderIcons.tsx: -------------------------------------------------------------------------------- 1 | import {Settings} from '@/components/Settings/Settings' 2 | import config from '@/lib/config' 3 | import {ActionIcon, Group, Tooltip} from '@mantine/core' 4 | import {FaGithub} from 'react-icons/fa' 5 | import {SiBuymeacoffee} from 'react-icons/si' 6 | 7 | export function HeaderIcons() { 8 | return ( 9 | 10 | 11 | 12 | 23 | 24 | 25 | 26 | 27 | 38 | 39 | 40 | 41 | 42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /components/HlsPlayer/HlsPlayer.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import {useHlsVideo} from '@/lib/hooks/useHlsVideo' 4 | import type {HlsPlayerProps} from '@/lib/types' 5 | import {Center, Loader} from '@mantine/core' 6 | 7 | export function HlsPlayer({ 8 | src, 9 | fallbackUrl, 10 | poster, 11 | id, 12 | dataHint, 13 | autoPlay = false, 14 | controls = true, 15 | loop = true, 16 | playsInline = true, 17 | preload = 'none' 18 | }: Readonly) { 19 | const {videoRef, isLoading, isMuted} = useHlsVideo({ 20 | src, 21 | fallbackUrl, 22 | autoPlay 23 | }) 24 | 25 | return ( 26 | <> 27 | {isLoading && ( 28 |
29 | 30 |
31 | )} 32 | 33 | {/* eslint-disable-next-line jsx-a11y/media-has-caption */} 34 |