├── app ├── src │ ├── vite-env.d.ts │ ├── assets │ │ ├── images │ │ │ ├── homepage.png │ │ │ └── favicon │ │ │ │ ├── favicon.jpg │ │ │ │ ├── favicon.png │ │ │ │ └── favicon.svg │ │ ├── demo │ │ │ ├── mockup_mobile.png │ │ │ └── mockup_desktop.png │ │ ├── fonts │ │ │ ├── IBMPlexSans-Bold.ttf │ │ │ ├── IBMPlexSans-Light.ttf │ │ │ ├── IBMPlexSans-Thin.ttf │ │ │ ├── IBMPlexSans-Italic.ttf │ │ │ ├── IBMPlexSans-Medium.ttf │ │ │ ├── IBMPlexSans-Regular.ttf │ │ │ ├── IBMPlexSans-SemiBold.ttf │ │ │ ├── IBMPlexSans-BoldItalic.ttf │ │ │ ├── IBMPlexSans-ExtraLight.ttf │ │ │ ├── IBMPlexSans-ThinItalic.ttf │ │ │ ├── IBMPlexSans-LightItalic.ttf │ │ │ ├── IBMPlexSans-MediumItalic.ttf │ │ │ ├── IBMPlexSans-SemiBoldItalic.ttf │ │ │ ├── IBMPlexSans-ExtraLightItalic.ttf │ │ │ └── OFL.txt │ │ └── react.svg │ ├── App.module.scss │ ├── main.tsx │ ├── components │ │ ├── icons │ │ │ ├── Up.tsx │ │ │ ├── Down.tsx │ │ │ ├── Moon.tsx │ │ │ ├── Bookmark.tsx │ │ │ ├── Filter.tsx │ │ │ ├── X.tsx │ │ │ ├── Ups.tsx │ │ │ ├── Search.tsx │ │ │ ├── Lock.tsx │ │ │ ├── Chart.tsx │ │ │ ├── Open.tsx │ │ │ ├── Refresh.tsx │ │ │ ├── Comment.tsx │ │ │ ├── Grid.tsx │ │ │ ├── Warning.tsx │ │ │ ├── Reveal.tsx │ │ │ ├── Share.tsx │ │ │ ├── List.tsx │ │ │ ├── Sun.tsx │ │ │ └── Settings.tsx │ │ ├── ui │ │ │ ├── Loader.tsx │ │ │ ├── Tooltip.tsx │ │ │ ├── RateLimitCountdown.module.scss │ │ │ ├── Loader.module.scss │ │ │ ├── Toast.module.scss │ │ │ ├── ConfirmModal.module.scss │ │ │ ├── RateLimitCountdown.tsx │ │ │ ├── Toast.tsx │ │ │ ├── ConfirmModal.tsx │ │ │ ├── FeatureCard.tsx │ │ │ ├── Tooltip.module.scss │ │ │ └── FeatureCard.module.scss │ │ ├── VideoPlayer.module.scss │ │ ├── LoginCallback.module.scss │ │ ├── MobileFilters.module.scss │ │ ├── MobileFilters.tsx │ │ ├── VideoPlayer.tsx │ │ ├── Header.tsx │ │ ├── ImageSlider.module.scss │ │ ├── SettingsModal.module.scss │ │ ├── SettingsModal.tsx │ │ ├── Header.module.scss │ │ ├── LoginCallback.tsx │ │ ├── Filters.module.scss │ │ ├── Post.module.scss │ │ ├── ImageSlider.tsx │ │ └── Post.tsx │ ├── hooks │ │ ├── store │ │ │ ├── index.ts │ │ │ ├── StoreContext.tsx │ │ │ └── useSettingsStore.ts │ │ └── useStore.ts │ ├── types │ │ └── Post.ts │ ├── App.tsx │ ├── index.scss │ └── pages │ │ ├── Home.tsx │ │ └── Posts.module.scss ├── public │ ├── robots.txt │ ├── sitemap.xml │ └── vite.svg ├── example.env ├── .env.example ├── tsconfig.node.json ├── .gitignore ├── vite.config.ts ├── .eslintrc.cjs ├── Dockerfile ├── tsconfig.json ├── package.json └── index.html ├── server ├── .env.example ├── example.env ├── src │ ├── utils │ │ ├── responses.ts │ │ ├── getSecret.ts │ │ ├── errors.ts │ │ └── logger.ts │ ├── types │ │ └── index.ts │ ├── routes │ │ ├── authRoutes.ts │ │ └── redditRoutes.ts │ ├── middleware │ │ ├── auth.ts │ │ └── errorHandler.ts │ ├── proxy.ts │ └── controllers │ │ └── authController.ts ├── .gitignore ├── Dockerfile └── package.json ├── Caddyfile ├── LICENSE ├── docker-compose.yml ├── .github └── workflows │ └── deploy.yml ├── .todo └── README.md /app/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /app/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / 3 | 4 | Sitemap: https://bookmarkeddit.com/sitemap.xml 5 | -------------------------------------------------------------------------------- /app/src/assets/images/homepage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mateussilva98/bookmarkeddit/HEAD/app/src/assets/images/homepage.png -------------------------------------------------------------------------------- /app/example.env: -------------------------------------------------------------------------------- 1 | # Reddit API Configuration 2 | VITE_CLIENT_ID=your_reddit_client_id 3 | VITE_REDIRECT_URI=http://localhost/login/callback -------------------------------------------------------------------------------- /app/src/assets/demo/mockup_mobile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mateussilva98/bookmarkeddit/HEAD/app/src/assets/demo/mockup_mobile.png -------------------------------------------------------------------------------- /app/src/assets/demo/mockup_desktop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mateussilva98/bookmarkeddit/HEAD/app/src/assets/demo/mockup_desktop.png -------------------------------------------------------------------------------- /app/src/assets/fonts/IBMPlexSans-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mateussilva98/bookmarkeddit/HEAD/app/src/assets/fonts/IBMPlexSans-Bold.ttf -------------------------------------------------------------------------------- /app/src/assets/fonts/IBMPlexSans-Light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mateussilva98/bookmarkeddit/HEAD/app/src/assets/fonts/IBMPlexSans-Light.ttf -------------------------------------------------------------------------------- /app/src/assets/fonts/IBMPlexSans-Thin.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mateussilva98/bookmarkeddit/HEAD/app/src/assets/fonts/IBMPlexSans-Thin.ttf -------------------------------------------------------------------------------- /app/src/assets/images/favicon/favicon.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mateussilva98/bookmarkeddit/HEAD/app/src/assets/images/favicon/favicon.jpg -------------------------------------------------------------------------------- /app/src/assets/images/favicon/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mateussilva98/bookmarkeddit/HEAD/app/src/assets/images/favicon/favicon.png -------------------------------------------------------------------------------- /app/src/assets/fonts/IBMPlexSans-Italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mateussilva98/bookmarkeddit/HEAD/app/src/assets/fonts/IBMPlexSans-Italic.ttf -------------------------------------------------------------------------------- /app/src/assets/fonts/IBMPlexSans-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mateussilva98/bookmarkeddit/HEAD/app/src/assets/fonts/IBMPlexSans-Medium.ttf -------------------------------------------------------------------------------- /app/src/assets/fonts/IBMPlexSans-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mateussilva98/bookmarkeddit/HEAD/app/src/assets/fonts/IBMPlexSans-Regular.ttf -------------------------------------------------------------------------------- /app/src/assets/fonts/IBMPlexSans-SemiBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mateussilva98/bookmarkeddit/HEAD/app/src/assets/fonts/IBMPlexSans-SemiBold.ttf -------------------------------------------------------------------------------- /app/src/assets/fonts/IBMPlexSans-BoldItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mateussilva98/bookmarkeddit/HEAD/app/src/assets/fonts/IBMPlexSans-BoldItalic.ttf -------------------------------------------------------------------------------- /app/src/assets/fonts/IBMPlexSans-ExtraLight.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mateussilva98/bookmarkeddit/HEAD/app/src/assets/fonts/IBMPlexSans-ExtraLight.ttf -------------------------------------------------------------------------------- /app/src/assets/fonts/IBMPlexSans-ThinItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mateussilva98/bookmarkeddit/HEAD/app/src/assets/fonts/IBMPlexSans-ThinItalic.ttf -------------------------------------------------------------------------------- /app/src/assets/fonts/IBMPlexSans-LightItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mateussilva98/bookmarkeddit/HEAD/app/src/assets/fonts/IBMPlexSans-LightItalic.ttf -------------------------------------------------------------------------------- /app/src/assets/fonts/IBMPlexSans-MediumItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mateussilva98/bookmarkeddit/HEAD/app/src/assets/fonts/IBMPlexSans-MediumItalic.ttf -------------------------------------------------------------------------------- /app/src/assets/fonts/IBMPlexSans-SemiBoldItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mateussilva98/bookmarkeddit/HEAD/app/src/assets/fonts/IBMPlexSans-SemiBoldItalic.ttf -------------------------------------------------------------------------------- /app/src/assets/fonts/IBMPlexSans-ExtraLightItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mateussilva98/bookmarkeddit/HEAD/app/src/assets/fonts/IBMPlexSans-ExtraLightItalic.ttf -------------------------------------------------------------------------------- /server/.env.example: -------------------------------------------------------------------------------- 1 | # Copy to .env and fill in your values. Do NOT commit real secrets! 2 | 3 | PORT=3000 4 | REDDIT_CLIENT_ID=your_reddit_client_id 5 | REDDIT_CLIENT_SECRET=your_reddit_client_secret 6 | -------------------------------------------------------------------------------- /app/.env.example: -------------------------------------------------------------------------------- 1 | # Example .env file for Bookmarkeddit client (React/Vite) 2 | VITE_API_URL=http://localhost:3000/api 3 | VITE_REDIRECT_URI=http://localhost:5173/login/callback 4 | VITE_CLIENT_ID=your_reddit_client_id 5 | -------------------------------------------------------------------------------- /server/example.env: -------------------------------------------------------------------------------- 1 | # Reddit API credentials 2 | CLIENT_ID=your_reddit_client_id 3 | CLIENT_SECRET=your_reddit_client_secret 4 | USER_AGENT=bookmarkeddit/1.0 5 | 6 | # Server configuration 7 | PORT=3000 8 | NODE_ENV=development -------------------------------------------------------------------------------- /app/src/App.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | margin: 0; 3 | max-width: 100vw; 4 | overflow-x: hidden; 5 | box-sizing: border-box; 6 | } 7 | 8 | /* Media queries for responsive layout */ 9 | @media (min-width: 768px) { 10 | .root { 11 | margin: 0 auto; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /app/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /server/src/utils/responses.ts: -------------------------------------------------------------------------------- 1 | // Format error response 2 | export const formatErrorResponse = ( 3 | status: number, 4 | message: string, 5 | retryAfter?: number 6 | ) => { 7 | const response: any = { error: message, status }; 8 | 9 | if (retryAfter) { 10 | response.retryAfter = retryAfter; 11 | } 12 | 13 | return response; 14 | }; 15 | -------------------------------------------------------------------------------- /app/public/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | https://bookmarkeddit.com/ 5 | 2025-05-27 6 | monthly 7 | 1.0 8 | 9 | 10 | -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | # Node.js dependencies 2 | node_modules/ 3 | 4 | # Logs 5 | logs/ 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | 11 | # Environment variables 12 | .env 13 | .env.local 14 | .env.*.local 15 | 16 | # Build output 17 | dist/ 18 | build/ 19 | 20 | # IDE files 21 | .vscode/ 22 | .idea/ 23 | *.swp 24 | 25 | # OS files 26 | .DS_Store 27 | Thumbs.db -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | .env 15 | 16 | 17 | # Editor directories and files 18 | .vscode/* 19 | !.vscode/extensions.json 20 | .idea 21 | .DS_Store 22 | *.suo 23 | *.ntvs* 24 | *.njsproj 25 | *.sln 26 | *.sw? 27 | -------------------------------------------------------------------------------- /server/src/utils/getSecret.ts: -------------------------------------------------------------------------------- 1 | // Utility to get secrets from Docker secrets or environment variables 2 | import fs from "fs"; 3 | 4 | export function getSecret(name: string, fallback?: string): string | undefined { 5 | const path = `/run/secrets/${name}`; 6 | if (fs.existsSync(path)) { 7 | return fs.readFileSync(path, "utf8").trim(); 8 | } 9 | return process.env[name] || fallback; 10 | } 11 | -------------------------------------------------------------------------------- /server/src/types/index.ts: -------------------------------------------------------------------------------- 1 | export interface RedditUserResponse { 2 | name: string; 3 | id: string; 4 | icon_img?: string; 5 | [key: string]: any; // Allow for other properties 6 | } 7 | 8 | export interface RedditApiResponse { 9 | kind: string; 10 | data: { 11 | after: string | null; 12 | before: string | null; 13 | children: any[]; 14 | dist: number; 15 | modhash: string; 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /Caddyfile: -------------------------------------------------------------------------------- 1 | # Caddyfile for Bookmarkeddit with Automatic SSL 2 | # Replace "yourdomain.com" with your real domain name 3 | 4 | bookmarkeddit.com, www.bookmarkeddit.com { 5 | reverse_proxy /api/* server:3000 6 | reverse_proxy /* client:3000 7 | } 8 | 9 | analytics.bookmarkeddit.com { 10 | reverse_proxy plausible:8000 11 | } 12 | 13 | #localhost { 14 | # reverse_proxy /api/* server:3000 15 | # reverse_proxy /* client:3000 16 | #} -------------------------------------------------------------------------------- /app/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | // import { VitePWA } from "vite-plugin-pwa"; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [ 8 | react(), 9 | // VitePWA({ 10 | // // Either disable in development or configure properly 11 | // disable: process.env.NODE_ENV === "development", 12 | // }), 13 | ], 14 | }); 15 | -------------------------------------------------------------------------------- /server/Dockerfile: -------------------------------------------------------------------------------- 1 | # Build stage 2 | FROM node:20-alpine AS builder 3 | WORKDIR /usr/src/app 4 | 5 | COPY package*.json ./ 6 | RUN npm ci 7 | COPY . . 8 | RUN npm run build 9 | 10 | # Production stage 11 | FROM node:20-alpine AS production 12 | WORKDIR /usr/src/app 13 | 14 | COPY package*.json ./ 15 | RUN npm ci --omit=dev 16 | COPY --from=builder /usr/src/app/dist ./dist 17 | 18 | ENV NODE_ENV=production 19 | 20 | EXPOSE 3000 21 | CMD ["node", "dist/proxy.js"] -------------------------------------------------------------------------------- /app/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { browser: true, es2020: true }, 3 | extends: [ 4 | 'eslint:recommended', 5 | 'plugin:@typescript-eslint/recommended', 6 | 'plugin:react-hooks/recommended', 7 | ], 8 | parser: '@typescript-eslint/parser', 9 | parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, 10 | plugins: ['react-refresh'], 11 | rules: { 12 | 'react-refresh/only-export-components': 'warn', 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /app/src/main.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Application entry point for Bookmarkeddit 3 | * Sets up React with Router for the application 4 | */ 5 | import ReactDOM from "react-dom/client"; 6 | import App from "./App.tsx"; 7 | import "./index.scss"; 8 | import { BrowserRouter } from "react-router-dom"; 9 | 10 | // Create and render the root React component with routing 11 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( 12 | 13 | 14 | 15 | ); 16 | -------------------------------------------------------------------------------- /app/src/components/icons/Up.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | 3 | export const Up: FC = () => { 4 | return ( 5 | 17 | 18 | 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /app/src/components/icons/Down.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | 3 | export const Down: FC = () => { 4 | return ( 5 | 17 | 18 | 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /app/src/components/ui/Loader.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import styles from "./Loader.module.scss"; 3 | 4 | interface LoaderProps { 5 | isVisible?: boolean; 6 | } 7 | 8 | export const Loader: FC = ({ isVisible = true }) => { 9 | if (!isVisible) return null; 10 | 11 | return ( 12 |
13 |
14 |
15 |
16 |
17 |
18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /server/src/routes/authRoutes.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Authentication route definitions 3 | * Endpoints for OAuth flow with Reddit API 4 | */ 5 | import { Router } from "express"; 6 | import { exchangeToken, refreshToken } from "../controllers/authController.js"; 7 | 8 | const router = Router(); 9 | 10 | // Auth Routes - public endpoints (no auth middleware required) 11 | router.post("/token", exchangeToken); // Exchange authorization code for tokens 12 | router.post("/refresh", refreshToken); // Refresh an expired access token 13 | 14 | export default router; 15 | -------------------------------------------------------------------------------- /app/src/components/icons/Moon.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | 3 | export const Moon: FC = () => { 4 | return ( 5 | 17 | 18 | 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /app/src/components/icons/Bookmark.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | 3 | export const Bookmark: FC = () => { 4 | return ( 5 | 17 | 18 | 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /app/src/components/icons/Filter.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | 3 | export const Filter: FC = () => { 4 | return ( 5 | 17 | 18 | 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /app/Dockerfile: -------------------------------------------------------------------------------- 1 | # Use official Node.js image for build 2 | FROM node:18-alpine AS builder 3 | 4 | WORKDIR /app 5 | 6 | COPY package*.json ./ 7 | RUN npm install 8 | 9 | ARG VITE_MODE=production 10 | 11 | COPY . . 12 | RUN npm run build -- --mode $VITE_MODE 13 | 14 | # Production image 15 | FROM node:18-alpine AS production 16 | 17 | WORKDIR /app 18 | 19 | # Install serve to serve the build 20 | RUN npm install -g serve 21 | 22 | # Copy build output from builder 23 | COPY --from=builder /app/dist ./build 24 | 25 | EXPOSE 3000 26 | CMD ["serve", "-s", "build", "-l", "3000"] -------------------------------------------------------------------------------- /app/src/components/icons/X.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | 3 | export const X: FC = () => { 4 | return ( 5 | 17 | 18 | 19 | 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /app/src/components/icons/Ups.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | 3 | export const Ups: FC = () => { 4 | return ( 5 | 17 | 18 | 19 | 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /app/src/components/icons/Search.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | 3 | export const Search: FC = () => { 4 | return ( 5 | 17 | 18 | 19 | 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /app/src/components/icons/Lock.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | 3 | export const Lock: FC = () => { 4 | return ( 5 | 17 | 18 | 19 | 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /app/src/components/ui/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import { FC, ReactNode } from "react"; 2 | import styles from "./Tooltip.module.scss"; 3 | 4 | export type TooltipPosition = "top" | "bottom" | "left" | "right"; 5 | 6 | interface TooltipProps { 7 | children: ReactNode; 8 | text: string; 9 | position?: TooltipPosition; 10 | } 11 | 12 | export const Tooltip: FC = ({ 13 | children, 14 | text, 15 | position = "top", 16 | }) => { 17 | return ( 18 |
19 | {children} 20 | {text} 21 |
22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /app/src/hooks/store/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Store exports 3 | * Provides convenient access to all store hooks and types 4 | */ 5 | 6 | // Main hooks 7 | export { useStore } from "../useStore"; 8 | export { useAuthStore } from "./useAuthStore"; 9 | export { useSettingsStore } from "./useSettingsStore"; 10 | 11 | // Context and provider 12 | export { 13 | StoreContext, 14 | StoreProvider, 15 | type StoreProps, 16 | } from "./StoreContext.tsx"; 17 | 18 | // Types 19 | export type { AuthState } from "./useAuthStore"; 20 | 21 | export type { 22 | ThemeType, 23 | Layout, 24 | SortOption, 25 | SettingsState, 26 | } from "./useSettingsStore"; 27 | -------------------------------------------------------------------------------- /app/src/components/icons/Chart.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | 3 | export const Chart: FC = () => { 4 | return ( 5 | 17 | 18 | 19 | 20 | 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /app/src/components/icons/Open.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | 3 | export const Open: FC = () => { 4 | return ( 5 | 17 | 18 | 19 | 20 | 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /app/src/components/icons/Refresh.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | 3 | export const Refresh: FC = () => { 4 | return ( 5 | 17 | 18 | 19 | 20 | 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /app/src/components/icons/Comment.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | 3 | export const Comment: FC = () => { 4 | return ( 5 | 17 | 18 | 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /app/src/components/icons/Grid.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | 3 | export const Grid: FC = () => { 4 | return ( 5 | 17 | 18 | 19 | 20 | 21 | 22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /app/src/components/icons/Warning.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | 3 | export const Warning: FC = () => { 4 | return ( 5 | 17 | 18 | 19 | 20 | 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "1.0.0", 4 | "description": "Independent server for bookmarkeddit", 5 | "main": "dist/proxy.js", 6 | "type": "module", 7 | "scripts": { 8 | "start": "node dist/proxy.js", 9 | "dev": "nodemon --watch \"src/**/*.ts\" --exec \"npm run build && npm start\"", 10 | "build": "tsc" 11 | }, 12 | "dependencies": { 13 | "cors": "^2.8.5", 14 | "dotenv": "^16.5.0", 15 | "express": "^4.18.2", 16 | "node-fetch": "^3.0.0" 17 | }, 18 | "devDependencies": { 19 | "@types/cors": "^2.8.17", 20 | "@types/express": "^4.17.21", 21 | "@types/node": "^20.2.5", 22 | "nodemon": "^3.1.10", 23 | "typescript": "^5.0.2" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /app/src/components/icons/Reveal.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | 3 | export const Reveal: FC = () => { 4 | return ( 5 | 17 | 18 | 19 | 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /app/src/components/icons/Share.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | 3 | export const Share: FC = () => { 4 | return ( 5 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /app/src/components/icons/List.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | 3 | export const List: FC = () => { 4 | return ( 5 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /app/src/components/icons/Sun.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | 3 | export const Sun: FC = () => { 4 | return ( 5 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /app/src/types/Post.ts: -------------------------------------------------------------------------------- 1 | export type MediaMetadata = { 2 | status: string; 3 | e: string; 4 | m: string; 5 | p: Array<{ 6 | y: number; 7 | x: number; 8 | u?: string; 9 | }>; 10 | s?: { 11 | y: number; 12 | x: number; 13 | u?: string; 14 | }; 15 | }; 16 | 17 | export type VideoInfo = { 18 | url: string; 19 | width?: number; 20 | height?: number; 21 | fallbackUrl?: string; 22 | dashUrl?: string; 23 | hlsUrl?: string; 24 | isGif?: boolean; 25 | duration?: number; 26 | }; 27 | 28 | export type Post = { 29 | id: string; 30 | subreddit: string; 31 | author: string; 32 | createdAt: number; 33 | title: string; 34 | description?: string; 35 | url: string; 36 | score: number; 37 | media_metadata?: { [key: string]: MediaMetadata }; 38 | images?: string[]; // Array to store multiple image URLs 39 | video?: VideoInfo; // Information for video content 40 | type: string; 41 | nsfw: boolean; 42 | commentCount: number; 43 | fullname: string; 44 | }; 45 | -------------------------------------------------------------------------------- /app/src/components/ui/RateLimitCountdown.module.scss: -------------------------------------------------------------------------------- 1 | .rateLimitContainer { 2 | padding: 1.5rem; 3 | background-color: var(--bg-secondary); 4 | border-radius: var(--border-radius); 5 | box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); 6 | width: 100%; 7 | max-width: 500px; 8 | margin: 0 auto; 9 | } 10 | 11 | .message { 12 | margin-bottom: 1rem; 13 | text-align: center; 14 | 15 | h3 { 16 | margin-bottom: 0.5rem; 17 | color: var(--orange); 18 | } 19 | 20 | p { 21 | margin-bottom: 1rem; 22 | color: var(--color-primary); 23 | font-size: 0.95rem; 24 | } 25 | } 26 | 27 | .countdown { 28 | font-weight: bold; 29 | font-size: 1.1rem; 30 | color: var(--orange); 31 | } 32 | 33 | .progressBarContainer { 34 | width: 100%; 35 | height: 10px; 36 | background-color: var(--bg-primary); 37 | border-radius: 5px; 38 | overflow: hidden; 39 | } 40 | 41 | .progressBar { 42 | height: 100%; 43 | background-color: var(--orange); 44 | border-radius: 5px; 45 | transition: width 0.3s ease-in-out; 46 | } 47 | -------------------------------------------------------------------------------- /app/src/components/ui/Loader.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | display: flex; 3 | justify-content: center; 4 | align-items: center; 5 | height: 100vh; 6 | } 7 | 8 | .loader { 9 | width: 60px; 10 | } 11 | 12 | .loader-wheel { 13 | animation: spin 1s infinite linear; 14 | border: 2px solid var(--color-secondary); 15 | border-left: 4px solid var(--color-primary); 16 | border-radius: 50%; 17 | height: 50px; 18 | margin-bottom: 10px; 19 | width: 50px; 20 | } 21 | 22 | .loader-text { 23 | color: var(--color-primary); 24 | font-family: arial, sans-serif; 25 | } 26 | 27 | .loader-text:after { 28 | content: "Loading"; 29 | animation: load 2s linear infinite; 30 | } 31 | 32 | @keyframes spin { 33 | 0% { 34 | transform: rotate(0deg); 35 | } 36 | 100% { 37 | transform: rotate(360deg); 38 | } 39 | } 40 | 41 | @keyframes load { 42 | 0% { 43 | content: "Loading"; 44 | } 45 | 33% { 46 | content: "Loading."; 47 | } 48 | 67% { 49 | content: "Loading.."; 50 | } 51 | 100% { 52 | content: "Loading..."; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /server/src/middleware/auth.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Authentication middleware for protecting API routes 3 | * Validates presence of access tokens in request headers 4 | */ 5 | import { Request, Response, NextFunction } from "express"; 6 | import { AuthenticationError } from "../utils/errors.js"; 7 | 8 | /** 9 | * Middleware to check for valid access token in Authorization header 10 | * Extracts Bearer token and adds it to request headers for controller access 11 | * 12 | * @param req Express request object 13 | * @param res Express response object 14 | * @param next Express next function to pass control to the next middleware 15 | * @throws AuthenticationError if no token is provided 16 | */ 17 | export const checkAuth = (req: Request, res: Response, next: NextFunction) => { 18 | const accessToken = req.headers.authorization?.split(" ")[1]; 19 | 20 | if (!accessToken) { 21 | throw new AuthenticationError("Authentication required"); 22 | } 23 | 24 | // Store token in a standardized location for controllers to access 25 | req.headers.accessToken = accessToken; 26 | next(); 27 | }; 28 | -------------------------------------------------------------------------------- /server/src/routes/redditRoutes.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Reddit API route definitions 3 | * Maps endpoint URLs to controller functions with appropriate middleware 4 | */ 5 | import { Router } from "express"; 6 | import { 7 | getUserProfile, 8 | validateToken, 9 | getSavedPosts, 10 | getAllSavedPosts, 11 | unsavePost, 12 | clearRateLimit, 13 | } from "../controllers/redditController.js"; 14 | import { checkAuth } from "../middleware/auth.js"; 15 | 16 | const router = Router(); 17 | 18 | // Protected API routes - all require authentication 19 | router.get("/me", checkAuth, getUserProfile); // Get authenticated user profile 20 | router.get("/validate-token", checkAuth, validateToken); // Validate access token 21 | router.get("/saved", checkAuth, getSavedPosts); // Get paginated saved posts 22 | router.get("/saved-all", checkAuth, getAllSavedPosts); // Get all saved posts in one request 23 | router.post("/unsave", checkAuth, unsavePost); // Unsave a post or comment 24 | 25 | // Debug/development route - not protected 26 | router.post("/clear-rate-limit", clearRateLimit); 27 | 28 | export default router; 29 | -------------------------------------------------------------------------------- /app/src/App.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Main application component for Bookmarkeddit 3 | * Handles routing and provides global state through StoreProvider 4 | */ 5 | import { StoreProvider } from "./hooks/useStore"; 6 | import { Navigate, Route, Routes } from "react-router-dom"; 7 | import { Home } from "./pages/Home"; 8 | import { Posts } from "./pages/Posts"; 9 | import { LoginCallback } from "./components/LoginCallback"; 10 | 11 | /** 12 | * App component defines the main application routes 13 | * - / => Home page (landing page) 14 | * - /login/callback => OAuth callback handler 15 | * - /posts => Main posts display page (requires authentication) 16 | * - * => Redirect to home for any undefined routes 17 | */ 18 | function App() { 19 | return ( 20 | 21 | 22 | } /> 23 | } /> 24 | } /> 25 | } /> 26 | 27 | 28 | ); 29 | } 30 | 31 | export default App; 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Mateus Silva 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 | -------------------------------------------------------------------------------- /app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bookmarkeddit", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite --mode development", 8 | "build": "vite build --mode production", 9 | "preview": "vite preview --mode production", 10 | "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0" 11 | }, 12 | "dependencies": { 13 | "@types/uuid": "^10.0.0", 14 | "buffer": "^6.0.3", 15 | "react": "^18.2.0", 16 | "react-dom": "^18.2.0", 17 | "react-router-dom": "^6.12.0", 18 | "uuid": "^11.1.0" 19 | }, 20 | "devDependencies": { 21 | "@types/node": "^20.2.5", 22 | "@types/react": "^18.0.37", 23 | "@types/react-dom": "^18.0.11", 24 | "@typescript-eslint/eslint-plugin": "^5.59.0", 25 | "@typescript-eslint/parser": "^5.59.0", 26 | "@vitejs/plugin-react": "^4.0.0", 27 | "eslint": "^8.38.0", 28 | "eslint-plugin-react-hooks": "^4.6.0", 29 | "eslint-plugin-react-refresh": "^0.3.4", 30 | "sass": "^1.62.1", 31 | "typescript": "^5.0.2", 32 | "vite": "^4.3.9", 33 | "vite-plugin-pwa": "^1.0.0" 34 | } 35 | } -------------------------------------------------------------------------------- /app/src/components/icons/Settings.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | 3 | export const Settings: FC = () => { 4 | return ( 5 | 17 | 18 | 19 | 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /app/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/src/components/VideoPlayer.module.scss: -------------------------------------------------------------------------------- 1 | // VideoPlayer styles 2 | .container { 3 | width: 100%; 4 | position: relative; 5 | border-radius: var(--border-radius); 6 | overflow: hidden; 7 | background-color: var(--bg-secondary); 8 | } 9 | 10 | .blurContainer { 11 | position: relative; 12 | } 13 | 14 | .blurredVideo { 15 | filter: blur(20px); 16 | pointer-events: none; 17 | } 18 | 19 | .revealOverlay { 20 | position: absolute; 21 | top: 0; 22 | left: 0; 23 | width: 100%; 24 | height: 100%; 25 | display: flex; 26 | justify-content: center; 27 | align-items: center; 28 | background-color: rgba(0, 0, 0, 0.4); 29 | z-index: 5; 30 | } 31 | 32 | .tooltipWrapper { 33 | position: relative; 34 | 35 | &:hover .tooltip { 36 | visibility: visible; 37 | opacity: 1; 38 | } 39 | 40 | .tooltip { 41 | visibility: hidden; 42 | position: absolute; 43 | bottom: -40px; 44 | left: 50%; 45 | transform: translateX(-50%); 46 | background-color: var(--bg-primary); 47 | color: var(--color-primary); 48 | font-size: 0.75rem; 49 | padding: 5px 10px; 50 | border-radius: 4px; 51 | white-space: nowrap; 52 | opacity: 0; 53 | transition: opacity 0.3s; 54 | z-index: 100; 55 | box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); 56 | border: var(--border); 57 | 58 | &::after { 59 | content: ""; 60 | position: absolute; 61 | top: -5px; 62 | left: 50%; 63 | transform: translateX(-50%); 64 | border-width: 5px; 65 | border-style: solid; 66 | border-color: transparent transparent var(--bg-primary) transparent; 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /app/src/components/ui/Toast.module.scss: -------------------------------------------------------------------------------- 1 | .toastContainer { 2 | position: fixed; 3 | top: 20px; 4 | right: 20px; 5 | z-index: 10000; /* Ensure it's above other elements */ 6 | display: flex; 7 | flex-direction: column; 8 | gap: 10px; 9 | max-width: 350px; 10 | pointer-events: none; /* Allow clicking through the container but not the toasts */ 11 | } 12 | 13 | .toast { 14 | display: flex; 15 | align-items: center; 16 | justify-content: space-between; 17 | padding: 12px 16px; 18 | border-radius: var(--border-radius); 19 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); 20 | opacity: 0; 21 | transform: translateX(100%); 22 | transition: opacity 0.3s ease, transform 0.3s ease; 23 | pointer-events: auto; /* Make toast clickable */ 24 | 25 | .message { 26 | font-size: 14px; 27 | font-weight: 500; 28 | line-height: 1.4; 29 | margin-right: 10px; 30 | } 31 | 32 | .closeButton { 33 | background: none; 34 | border: none; 35 | color: inherit; 36 | cursor: pointer; 37 | font-size: 18px; 38 | padding: 0; 39 | display: flex; 40 | align-items: center; 41 | justify-content: center; 42 | width: 20px; 43 | height: 20px; 44 | opacity: 0.7; 45 | 46 | &:hover { 47 | opacity: 1; 48 | } 49 | } 50 | 51 | &.visible { 52 | opacity: 1; 53 | transform: translateX(0); 54 | } 55 | 56 | &.hidden { 57 | opacity: 0; 58 | transform: translateX(100%); 59 | } 60 | 61 | &.success { 62 | background-color: #4caf50; 63 | color: white; 64 | } 65 | 66 | &.error { 67 | background-color: #f44336; 68 | color: white; 69 | } 70 | 71 | &.info { 72 | background-color: var(--orange); 73 | color: white; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /app/src/components/ui/ConfirmModal.module.scss: -------------------------------------------------------------------------------- 1 | .modalOverlay { 2 | position: fixed; 3 | top: 0; 4 | left: 0; 5 | right: 0; 6 | bottom: 0; 7 | background-color: rgba(0, 0, 0, 0.5); 8 | display: flex; 9 | justify-content: center; 10 | align-items: center; 11 | z-index: 100000; /* Increased to be very high */ 12 | } 13 | 14 | .modal { 15 | background-color: var(--bg-secondary); 16 | border-radius: var(--border-radius); 17 | padding: 24px; 18 | box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); 19 | width: 100%; 20 | max-width: 420px; 21 | animation: fadeIn 0.3s ease; 22 | } 23 | 24 | .title { 25 | font-size: 1.5rem; 26 | margin-bottom: 16px; 27 | color: var(--color-primary); 28 | } 29 | 30 | .message { 31 | font-size: 1rem; 32 | margin-bottom: 24px; 33 | color: var(--color-secondary); 34 | line-height: 1.5; 35 | } 36 | 37 | .buttons { 38 | display: flex; 39 | justify-content: flex-end; 40 | gap: 12px; 41 | } 42 | 43 | .cancelButton { 44 | background-color: transparent; 45 | border: 1px solid var(--border-color); 46 | color: var(--color-primary); 47 | padding: 8px 16px; 48 | border-radius: var(--border-radius); 49 | cursor: pointer; 50 | font-size: 14px; 51 | 52 | &:hover { 53 | background-color: var(--bg-primary); 54 | } 55 | } 56 | 57 | .confirmButton { 58 | background-color: var(--orange); 59 | border: none; 60 | color: white; 61 | padding: 8px 16px; 62 | border-radius: var(--border-radius); 63 | cursor: pointer; 64 | font-size: 14px; 65 | 66 | &:hover { 67 | background-color: var(--btn-hover-color); 68 | } 69 | } 70 | 71 | @keyframes fadeIn { 72 | from { 73 | opacity: 0; 74 | transform: translateY(-20px); 75 | } 76 | to { 77 | opacity: 1; 78 | transform: translateY(0); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /server/src/proxy.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Express server proxy for Reddit API interactions 3 | * Handles authentication flow and provides endpoints for client requests 4 | */ 5 | import "dotenv/config"; 6 | import express from "express"; 7 | import cors from "cors"; 8 | import redditRoutes from "./routes/redditRoutes.js"; 9 | import authRoutes from "./routes/authRoutes.js"; 10 | import { errorHandler, notFoundHandler } from "./middleware/errorHandler.js"; 11 | import { requestLogger, logInfo } from "./utils/logger.js"; 12 | 13 | const app = express(); 14 | 15 | // Middleware setup 16 | app.use(cors()); 17 | app.use(express.json()); 18 | 19 | // Request logging middleware - captures all incoming requests 20 | app.use(requestLogger); 21 | 22 | // This could be kept as a general API request logger 23 | app.use("/api", (req, res, next) => { 24 | // Log the request method and URL 25 | /* console.log(`API Request received: ${req.method} ${req.url}`); */ 26 | next(); // Add next() to continue to the actual route handlers 27 | }); 28 | 29 | // Register API routes with /api prefix 30 | app.use("/api/reddit", redditRoutes); 31 | app.use("/api/reddit/auth", authRoutes); 32 | 33 | // Health check endpoint 34 | app.get("/", (req, res) => { 35 | res.json({ status: "Proxy server is running" }); 36 | }); 37 | 38 | // API Health check endpoint 39 | app.get("/api", (req, res) => { 40 | res.json({ status: "API is running" }); 41 | }); 42 | 43 | // 404 handler for undefined routes - must be after all defined routes 44 | app.use(notFoundHandler); 45 | 46 | // Global error handler - must be the last middleware in the chain 47 | app.use(errorHandler); 48 | 49 | const PORT = process.env.PORT || 3000; 50 | 51 | app.listen(PORT, () => { 52 | logInfo(`Proxy server running:`, { 53 | port: PORT, 54 | environment: process.env.NODE_ENV || "development", 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /app/src/components/ui/RateLimitCountdown.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useEffect, useState } from "react"; 2 | import styles from "./RateLimitCountdown.module.scss"; 3 | 4 | interface RateLimitCountdownProps { 5 | retryAfter: number; // seconds 6 | onComplete?: () => void; 7 | } 8 | 9 | export const RateLimitCountdown: FC = ({ 10 | retryAfter, 11 | onComplete, 12 | }) => { 13 | const [timeLeft, setTimeLeft] = useState(retryAfter); 14 | const [progress, setProgress] = useState(0); 15 | 16 | useEffect(() => { 17 | // Reset timer when retryAfter changes 18 | setTimeLeft(retryAfter); 19 | setProgress(0); 20 | }, [retryAfter]); 21 | 22 | useEffect(() => { 23 | if (timeLeft <= 0) { 24 | if (onComplete) { 25 | onComplete(); 26 | } 27 | return; 28 | } 29 | 30 | const interval = setInterval(() => { 31 | setTimeLeft((prev) => Math.max(prev - 1, 0)); 32 | setProgress(((retryAfter - timeLeft + 1) / retryAfter) * 100); 33 | }, 1000); 34 | 35 | return () => clearInterval(interval); 36 | }, [timeLeft, retryAfter, onComplete]); 37 | 38 | return ( 39 |
40 |
41 |

Reddit Rate Limit Reached

42 |

43 | Reddit limits how frequently we can request data. Retrying 44 | automatically in {timeLeft}{" "} 45 | seconds... 46 |

47 |
48 |
49 |
57 |
58 |
59 | ); 60 | }; 61 | -------------------------------------------------------------------------------- /app/src/components/LoginCallback.module.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | flex-direction: column; 4 | justify-content: center; 5 | align-items: center; 6 | height: 100vh; 7 | width: 100%; 8 | background-color: var(--bg-primary); 9 | } 10 | 11 | .loadingMessage { 12 | margin-top: 1.5rem; 13 | color: var(--color-secondary); 14 | text-align: center; 15 | font-size: 1rem; 16 | } 17 | 18 | .errorContainer { 19 | background-color: var(--bg-secondary); 20 | border-radius: 8px; 21 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); 22 | padding: 2rem; 23 | max-width: 500px; 24 | text-align: center; 25 | border: var(--border); 26 | 27 | h2 { 28 | color: var(--color-primary); 29 | margin-bottom: 1rem; 30 | } 31 | 32 | .errorMessage { 33 | color: var(--btn-hover-color); 34 | margin-bottom: 2rem; 35 | padding: 1rem; 36 | background-color: rgba(255, 69, 0, 0.1); 37 | border-radius: 4px; 38 | border: 1px solid var(--btn-hover-color); 39 | white-space: pre-wrap; /* Allow line breaks */ 40 | word-break: break-word; /* Break long words */ 41 | font-family: monospace; /* Better for error messages */ 42 | text-align: left; 43 | } 44 | 45 | .buttonContainer { 46 | display: flex; 47 | flex-direction: column; 48 | gap: 0.75rem; 49 | 50 | button { 51 | width: 100%; 52 | padding: 0.75rem 1rem; 53 | height: auto; 54 | font-size: 1rem; 55 | } 56 | 57 | .retryButton { 58 | background-color: var(--orange); 59 | } 60 | 61 | .loginButton { 62 | background-color: #4a86e8; /* Blue for login */ 63 | } 64 | 65 | .homeButton { 66 | background-color: transparent; 67 | border: 1px solid var(--color-secondary); 68 | color: var(--color-primary); 69 | 70 | &:hover { 71 | background-color: var(--bg-primary); 72 | border-color: var(--color-primary); 73 | } 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /app/src/components/ui/Toast.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useEffect, useState } from "react"; 2 | import styles from "./Toast.module.scss"; 3 | 4 | export type ToastType = "success" | "error" | "info"; 5 | 6 | interface ToastProps { 7 | message: string; 8 | type: ToastType; 9 | duration?: number; 10 | onClose: () => void; 11 | } 12 | 13 | export const Toast: FC = ({ 14 | message, 15 | type, 16 | duration = 3000, 17 | onClose, 18 | }) => { 19 | const [visible, setVisible] = useState(true); 20 | 21 | useEffect(() => { 22 | const timer = setTimeout(() => { 23 | setVisible(false); 24 | setTimeout(onClose, 300); // Wait for fade out animation before removing 25 | }, duration); 26 | 27 | return () => clearTimeout(timer); 28 | }, [duration, onClose]); 29 | 30 | return ( 31 |
36 |
{message}
37 | 46 |
47 | ); 48 | }; 49 | 50 | // Toast Container Component 51 | interface ToastItem { 52 | id: string; 53 | message: string; 54 | type: ToastType; 55 | } 56 | 57 | interface ToastContainerProps { 58 | toasts: ToastItem[]; 59 | removeToast: (id: string) => void; 60 | } 61 | 62 | export const ToastContainer: FC = ({ 63 | toasts, 64 | removeToast, 65 | }) => { 66 | return ( 67 |
68 | {toasts.map((toast) => ( 69 | removeToast(toast.id)} 74 | /> 75 | ))} 76 |
77 | ); 78 | }; 79 | -------------------------------------------------------------------------------- /app/src/components/MobileFilters.module.scss: -------------------------------------------------------------------------------- 1 | .mobileFilterContainer { 2 | position: fixed; 3 | top: var(--header-height, 60px); 4 | left: 0; 5 | right: 0; 6 | bottom: 0; 7 | z-index: 9999; 8 | display: flex; 9 | pointer-events: none; 10 | visibility: hidden; 11 | } 12 | 13 | .visible { 14 | visibility: visible; 15 | pointer-events: auto; 16 | } 17 | 18 | /* Backdrop overlay */ 19 | .mobileFilterBackdrop { 20 | position: fixed; 21 | top: 0; 22 | left: 0; 23 | right: 0; 24 | bottom: 0; 25 | background: rgba(0, 0, 0, 0.5); 26 | opacity: 0; 27 | transition: opacity 0.3s ease; 28 | pointer-events: none; 29 | } 30 | 31 | .visible .mobileFilterBackdrop { 32 | opacity: 1; 33 | pointer-events: auto; 34 | } 35 | 36 | .closeButton { 37 | position: absolute; 38 | top: var(--header-height + 5px, 65px); 39 | left: 72%; 40 | z-index: 10000; 41 | background-color: var(--bg-primary); 42 | color: var(--btn-hover-color); 43 | border-radius: 50%; 44 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); 45 | display: flex; 46 | justify-content: center; 47 | align-items: center; 48 | width: 40px; 49 | height: 40px; 50 | 51 | svg { 52 | color: var(--btn-hover-color); 53 | width: 40px; 54 | height: 40px; 55 | transition: all 0.3s ease; 56 | } 57 | } 58 | 59 | .mobileFilterSidebar { 60 | position: absolute; 61 | top: 0; 62 | left: 0; 63 | bottom: 0; 64 | width: 70%; 65 | max-width: 320px; 66 | background-color: var(--bg-secondary); 67 | transform: translateX(-100%); 68 | transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1); 69 | box-shadow: 2px 0 10px rgba(0, 0, 0, 0.2); 70 | pointer-events: auto; 71 | display: flex; 72 | flex-direction: column; 73 | overflow: hidden; 74 | border-right: var(--border); 75 | /* border-top: var(--border); */ 76 | } 77 | 78 | .visible .mobileFilterSidebar { 79 | transform: translateX(0); 80 | } 81 | 82 | .mobileFilterContent { 83 | flex: 1; 84 | overflow-y: auto; 85 | /* padding: 0 0.5rem; */ 86 | } 87 | -------------------------------------------------------------------------------- /app/src/components/ui/ConfirmModal.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useEffect, useRef } from "react"; 2 | import styles from "./ConfirmModal.module.scss"; 3 | 4 | interface ConfirmModalProps { 5 | isOpen: boolean; 6 | title: string; 7 | message: string; 8 | confirmText?: string; 9 | cancelText?: string; 10 | onConfirm: () => void; 11 | onCancel: () => void; 12 | } 13 | 14 | export const ConfirmModal: FC = ({ 15 | isOpen, 16 | title, 17 | message, 18 | confirmText = "Confirm", 19 | cancelText = "Cancel", 20 | onConfirm, 21 | onCancel, 22 | }) => { 23 | const modalRef = useRef(null); 24 | 25 | useEffect(() => { 26 | const handleKeyDown = (e: KeyboardEvent) => { 27 | if (e.key === "Escape" && isOpen) { 28 | onCancel(); 29 | } 30 | }; 31 | 32 | const handleClickOutside = (e: MouseEvent) => { 33 | if ( 34 | modalRef.current && 35 | !modalRef.current.contains(e.target as Node) && 36 | isOpen 37 | ) { 38 | onCancel(); 39 | } 40 | }; 41 | 42 | document.addEventListener("keydown", handleKeyDown); 43 | document.addEventListener("mousedown", handleClickOutside); 44 | 45 | return () => { 46 | document.removeEventListener("keydown", handleKeyDown); 47 | document.removeEventListener("mousedown", handleClickOutside); 48 | }; 49 | }, [isOpen, onCancel]); 50 | 51 | if (!isOpen) return null; 52 | 53 | return ( 54 |
55 |
56 |

{title}

57 |

{message}

58 |
59 | 62 | 65 |
66 |
67 |
68 | ); 69 | }; 70 | -------------------------------------------------------------------------------- /app/src/components/ui/FeatureCard.tsx: -------------------------------------------------------------------------------- 1 | import { FC, ReactNode, useEffect, useRef } from "react"; 2 | import styles from "./FeatureCard.module.scss"; 3 | 4 | interface FeatureCardProps { 5 | icon: ReactNode; 6 | title: string; 7 | description: string; 8 | } 9 | 10 | export const FeatureCard: FC = ({ 11 | icon, 12 | title, 13 | description, 14 | }) => { 15 | const cardRef = useRef(null); 16 | 17 | useEffect(() => { 18 | const card = cardRef.current; 19 | if (!card) return; 20 | 21 | const handleMouseMove = (e: MouseEvent) => { 22 | const rect = card.getBoundingClientRect(); 23 | const x = e.clientX - rect.left; 24 | const y = e.clientY - rect.top; 25 | 26 | // Calculate the position relative to the center of the card (in %) 27 | const centerX = rect.width / 2; 28 | const centerY = rect.height / 2; 29 | 30 | // Reduced max tilt to 7 degrees for a more subtle effect 31 | const percentX = ((x - centerX) / centerX) * 7; 32 | const percentY = ((y - centerY) / centerY) * -7; 33 | 34 | // Apply the transform 35 | card.style.transform = `perspective(1000px) rotateX(${percentY}deg) rotateY(${percentX}deg) scale3d(1.01, 1.01, 1.01)`; 36 | }; 37 | 38 | const handleMouseLeave = () => { 39 | // Reset the transform when mouse leaves 40 | card.style.transform = 41 | "perspective(1000px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1)"; 42 | }; 43 | 44 | card.addEventListener("mousemove", handleMouseMove); 45 | card.addEventListener("mouseleave", handleMouseLeave); 46 | 47 | return () => { 48 | card.removeEventListener("mousemove", handleMouseMove); 49 | card.removeEventListener("mouseleave", handleMouseLeave); 50 | }; 51 | }, []); 52 | 53 | return ( 54 |
55 |
56 | {icon} 57 |

{title}

58 |

{description}

59 |
60 | ); 61 | }; 62 | -------------------------------------------------------------------------------- /app/src/components/MobileFilters.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { Filters, SelectedFilters } from "./Filters"; 3 | import styles from "./MobileFilters.module.scss"; 4 | import { X } from "./icons/X"; 5 | 6 | type MobileFiltersProps = { 7 | isVisible: boolean; 8 | onClose: () => void; 9 | subredditCounts: { subreddit: string; count: number }[]; 10 | typeCounts: { type: string; count: number }[]; 11 | nsfwCounts: { nsfw: string; count: number }[]; 12 | onFilterChange: (filters: SelectedFilters) => void; 13 | totalPosts: number; 14 | onRefresh: () => void; 15 | currentFilters?: SelectedFilters; 16 | }; 17 | 18 | /** 19 | * MobileFilters component 20 | * Wrapper for the Filters component that provides mobile-specific UI 21 | * Handles the sliding sidebar and backdrop overlay 22 | */ 23 | export const MobileFilters: FC = ({ 24 | isVisible, 25 | onClose, 26 | subredditCounts, 27 | typeCounts, 28 | nsfwCounts, 29 | onFilterChange, 30 | totalPosts, 31 | onRefresh, 32 | currentFilters, 33 | }) => { 34 | return ( 35 |
40 | {" "} 41 | {/* Backdrop overlay */} 42 | 59 | {/* Sliding sidebar */} 60 |
61 |
62 | 73 |
74 |
75 |
76 | ); 77 | }; 78 | -------------------------------------------------------------------------------- /server/src/utils/errors.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Custom error classes for structured error handling 3 | */ 4 | 5 | // Base App Error class that extends the built-in Error class 6 | export class AppError extends Error { 7 | status: number; 8 | isOperational: boolean; 9 | details?: any; 10 | 11 | constructor( 12 | message: string, 13 | status: number = 500, 14 | isOperational: boolean = true, 15 | details?: any 16 | ) { 17 | super(message); 18 | this.name = this.constructor.name; 19 | this.status = status; 20 | this.isOperational = isOperational; // Operational errors are expected/handled errors vs. programming errors 21 | this.details = details; 22 | 23 | // Capture the stack trace 24 | Error.captureStackTrace(this, this.constructor); 25 | } 26 | } 27 | 28 | // Authentication errors (401 Unauthorized) 29 | export class AuthenticationError extends AppError { 30 | constructor(message: string = "Authentication required", details?: any) { 31 | super(message, 401, true, details); 32 | } 33 | } 34 | 35 | // Authorization errors (403 Forbidden) 36 | export class AuthorizationError extends AppError { 37 | constructor( 38 | message: string = "You do not have permission to perform this action", 39 | details?: any 40 | ) { 41 | super(message, 403, true, details); 42 | } 43 | } 44 | 45 | // Not Found errors (404) 46 | export class NotFoundError extends AppError { 47 | constructor(message: string = "Resource not found", details?: any) { 48 | super(message, 404, true, details); 49 | } 50 | } 51 | 52 | // Validation errors (400 Bad Request) 53 | export class ValidationError extends AppError { 54 | constructor(message: string = "Invalid input data", details?: any) { 55 | super(message, 400, true, details); 56 | } 57 | } 58 | 59 | // Rate Limit errors (429 Too Many Requests) 60 | export class RateLimitError extends AppError { 61 | retryAfter?: number; 62 | 63 | constructor( 64 | message: string = "Rate limit exceeded", 65 | retryAfter?: number, 66 | details?: any 67 | ) { 68 | super(message, 429, true, details); 69 | this.retryAfter = retryAfter; 70 | } 71 | } 72 | 73 | // External API errors (for Reddit API issues) 74 | export class ExternalApiError extends AppError { 75 | constructor( 76 | message: string = "External API error", 77 | status: number = 500, 78 | details?: any 79 | ) { 80 | super(message, status, true, details); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /app/src/components/ui/Tooltip.module.scss: -------------------------------------------------------------------------------- 1 | .tooltipWrapper { 2 | position: relative; 3 | display: inline-block; 4 | // Ensure clicks pass through to the child elements 5 | pointer-events: none; 6 | 7 | // But allow the children to receive pointer events 8 | > * { 9 | pointer-events: auto; 10 | } 11 | 12 | &:hover .tooltip { 13 | visibility: visible; 14 | opacity: 1; 15 | } 16 | } 17 | 18 | .tooltip { 19 | visibility: hidden; 20 | position: absolute; 21 | z-index: 1; 22 | background-color: var(--bg-primary); 23 | color: var(--color-primary); 24 | text-align: center; 25 | border-radius: 4px; 26 | padding: 5px 10px; 27 | font-size: 12px; 28 | white-space: nowrap; 29 | opacity: 0; 30 | transition: opacity 0.3s; 31 | box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); 32 | pointer-events: none; 33 | } 34 | 35 | /* Position variations */ 36 | .top { 37 | bottom: 125%; 38 | left: 50%; 39 | transform: translateX(-50%); 40 | 41 | &::after { 42 | content: ""; 43 | position: absolute; 44 | top: 100%; 45 | left: 50%; 46 | margin-left: -5px; 47 | border-width: 5px; 48 | border-style: solid; 49 | border-color: var(--bg-primary) transparent transparent transparent; 50 | } 51 | } 52 | 53 | .bottom { 54 | top: 125%; 55 | left: 50%; 56 | transform: translateX(-50%); 57 | 58 | &::after { 59 | content: ""; 60 | position: absolute; 61 | bottom: 100%; 62 | left: 50%; 63 | margin-left: -5px; 64 | border-width: 5px; 65 | border-style: solid; 66 | border-color: transparent transparent var(--bg-primary) transparent; 67 | } 68 | } 69 | 70 | .left { 71 | right: 125%; 72 | top: 50%; 73 | transform: translateY(-50%); 74 | margin-right: 10px; 75 | 76 | &::after { 77 | content: ""; 78 | position: absolute; 79 | top: 50%; 80 | left: 100%; 81 | margin-top: -5px; 82 | border-width: 5px; 83 | border-style: solid; 84 | border-color: transparent transparent transparent var(--bg-primary); 85 | } 86 | } 87 | 88 | .right { 89 | left: 125%; 90 | top: 50%; 91 | transform: translateY(-50%); 92 | margin-left: 10px; 93 | 94 | &::after { 95 | content: ""; 96 | position: absolute; 97 | top: 50%; 98 | right: 100%; 99 | margin-top: -5px; 100 | border-width: 5px; 101 | border-style: solid; 102 | border-color: transparent var(--bg-primary) transparent transparent; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /server/src/middleware/errorHandler.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from "express"; 2 | import { AppError } from "../utils/errors.js"; 3 | import { formatErrorResponse } from "../utils/responses.js"; 4 | import { logError } from "../utils/logger.js"; 5 | 6 | /** 7 | * Global error handling middleware 8 | */ 9 | export const errorHandler = ( 10 | err: Error | AppError, 11 | req: Request, 12 | res: Response, 13 | next: NextFunction 14 | ) => { 15 | // Default error values 16 | let statusCode = 500; 17 | let message = "Internal Server Error"; 18 | let retryAfter: number | undefined; 19 | let details: any = undefined; 20 | 21 | // Check if it's one of our custom AppError types 22 | if (err instanceof AppError) { 23 | statusCode = err.status; 24 | message = err.message; 25 | details = err.details; 26 | 27 | // Check for rate limit info 28 | if ("retryAfter" in err && typeof (err as any).retryAfter === "number") { 29 | retryAfter = (err as any).retryAfter; 30 | } 31 | } else if (err instanceof Error) { 32 | // If it's a standard Error but not our AppError 33 | message = err.message || "Something went wrong"; 34 | } 35 | 36 | // Handle unknown errors in production, but provide more details in development 37 | const isDev = process.env.NODE_ENV !== "production"; 38 | if (!isDev && statusCode === 500) { 39 | message = "Internal Server Error"; 40 | } 41 | 42 | // Log the error with our structured logger 43 | logError(`${req.method} ${req.originalUrl} caused error: ${message}`, err, { 44 | statusCode, 45 | method: req.method, 46 | url: req.originalUrl, 47 | ip: req.ip, 48 | retryAfter, 49 | requestBody: req.method !== "GET" ? req.body : undefined, 50 | requestQuery: req.query, 51 | requestParams: req.params, 52 | }); 53 | 54 | // Send the response 55 | res 56 | .status(statusCode) 57 | .json(formatErrorResponse(statusCode, message, retryAfter)); 58 | }; 59 | 60 | /** 61 | * Catch 404 errors for undefined routes 62 | */ 63 | export const notFoundHandler = (req: Request, res: Response) => { 64 | logError(`Route not found: ${req.method} ${req.originalUrl}`, null, { 65 | statusCode: 404, 66 | method: req.method, 67 | url: req.originalUrl, 68 | ip: req.ip, 69 | }); 70 | 71 | res 72 | .status(404) 73 | .json( 74 | formatErrorResponse( 75 | 404, 76 | `Route not found: ${req.method} ${req.originalUrl}` 77 | ) 78 | ); 79 | }; 80 | 81 | /** 82 | * Async handler to avoid try/catch blocks in route handlers 83 | */ 84 | export const asyncHandler = (fn: Function) => { 85 | return (req: Request, res: Response, next: NextFunction) => { 86 | Promise.resolve(fn(req, res, next)).catch(next); 87 | }; 88 | }; 89 | -------------------------------------------------------------------------------- /app/src/components/VideoPlayer.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useState, useRef, useEffect } from "react"; 2 | import styles from "./VideoPlayer.module.scss"; 3 | import { VideoInfo } from "../types/Post"; 4 | import { Reveal } from "./icons/Reveal"; 5 | 6 | interface VideoPlayerProps { 7 | video: VideoInfo; 8 | shouldBlur?: boolean; 9 | } 10 | 11 | export const VideoPlayer: FC = ({ 12 | video, 13 | shouldBlur = false, 14 | }) => { 15 | const [isBlurred, setIsBlurred] = useState(shouldBlur); 16 | const videoRef = useRef(null); 17 | const containerRef = useRef(null); 18 | const [isPlaying, setIsPlaying] = useState(false); 19 | 20 | // Reset blur state when shouldBlur prop changes 21 | useEffect(() => { 22 | setIsBlurred(shouldBlur); 23 | }, [shouldBlur]); 24 | 25 | const handleRevealClick = () => { 26 | setIsBlurred(false); 27 | }; 28 | 29 | const togglePlayPause = () => { 30 | if (videoRef.current) { 31 | if (isPlaying) { 32 | videoRef.current.pause(); 33 | } else { 34 | videoRef.current.play(); 35 | } 36 | setIsPlaying(!isPlaying); 37 | } 38 | }; 39 | 40 | // Choose the best video URL based on what's available 41 | const videoUrl = 42 | video.url || video.fallbackUrl || video.hlsUrl || video.dashUrl; 43 | 44 | // Early return if no valid video URL 45 | if (!videoUrl) { 46 | return null; 47 | } 48 | 49 | return ( 50 |
54 | 77 | 78 | {isBlurred && ( 79 |
80 |
81 | 88 | Click to reveal NSFW content 89 |
90 |
91 | )} 92 |
93 | ); 94 | }; 95 | -------------------------------------------------------------------------------- /app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Bookmarkeddit: Organize & Search Your Reddit Saved Posts 11 | 13 | 15 | 16 | 17 | 18 | 19 | 20 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 31 | 32 | 33 | 34 | 35 | 36 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 |
64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /app/src/hooks/store/StoreContext.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Store Context for Bookmarkeddit 3 | * Central point for global state management based on React Context API 4 | */ 5 | import { 6 | createContext, 7 | Dispatch, 8 | FC, 9 | PropsWithChildren, 10 | SetStateAction, 11 | useEffect, 12 | useState, 13 | } from "react"; 14 | import { initialAuthState, AuthState } from "./useAuthStore"; 15 | import { 16 | initialSettingsState, 17 | SettingsState, 18 | ThemeType, 19 | Layout, 20 | SortOption, 21 | } from "./useSettingsStore"; 22 | 23 | /** 24 | * Main store interface combining all application state 25 | */ 26 | export interface StoreProps extends SettingsState { 27 | auth: AuthState; 28 | } 29 | 30 | // Initial default store values 31 | export const initialStore: StoreProps = { 32 | ...initialSettingsState, 33 | auth: initialAuthState, 34 | }; 35 | 36 | // Create the context with default values 37 | export const StoreContext = createContext<{ 38 | store: StoreProps; 39 | setStore: Dispatch>; 40 | }>({ 41 | store: initialStore, 42 | setStore: () => { 43 | throw new Error("setStore must be used within a StoreProvider"); 44 | }, 45 | }); 46 | 47 | /** 48 | * Store Provider component that wraps the application 49 | * Initializes the store and provides it to all child components 50 | */ 51 | export const StoreProvider: FC = ({ children }) => { 52 | const [store, setStore] = useState(initialStore); 53 | 54 | // Initialize UI preferences from localStorage 55 | useEffect(() => { 56 | const theme = localStorage.getItem("theme") as ThemeType | null; 57 | const layout = localStorage.getItem("layout") as Layout | null; 58 | const sortBy = localStorage.getItem("sortBy") as SortOption | null; 59 | const showImagesStr = localStorage.getItem("showImages"); 60 | const compactTextStr = localStorage.getItem("compactText"); 61 | const blurNSFWStr = localStorage.getItem("blurNSFW"); 62 | const showFiltersStr = localStorage.getItem("showFilters"); 63 | const showImages = showImagesStr !== null ? showImagesStr === "true" : true; 64 | const compactText = 65 | compactTextStr !== null ? compactTextStr === "true" : true; 66 | const blurNSFW = blurNSFWStr !== null ? blurNSFWStr === "true" : true; 67 | const showFilters = 68 | showFiltersStr !== null ? showFiltersStr === "true" : true; 69 | 70 | // Apply theme to body 71 | if (theme) { 72 | document.body.classList.add(theme); 73 | setStore((currentStore) => ({ 74 | ...currentStore, 75 | theme: theme, 76 | })); 77 | } else { 78 | document.body.classList.add("dark"); 79 | } 80 | 81 | // Update store with UI preferences 82 | setStore((currentStore) => ({ 83 | ...currentStore, 84 | layout: layout || initialSettingsState.layout, 85 | sortBy: sortBy || initialSettingsState.sortBy, 86 | showImages, 87 | compactText, 88 | blurNSFW, 89 | showFilters, 90 | })); 91 | }, []); 92 | 93 | return ( 94 | 95 | {children} 96 | 97 | ); 98 | }; 99 | -------------------------------------------------------------------------------- /app/src/components/ui/FeatureCard.module.scss: -------------------------------------------------------------------------------- 1 | .featureCard { 2 | background: rgba(var(--bg-secondary-rgb, 30, 30, 30), 0.7); 3 | backdrop-filter: blur(10px); 4 | -webkit-backdrop-filter: blur(10px); 5 | border-radius: 16px; 6 | padding: 1.8rem; 7 | box-shadow: 0 10px 20px rgba(0, 0, 0, 0.08), 8 | inset 0 0 0 1px rgba(255, 255, 255, 0.05); 9 | transition: box-shadow 0.3s ease; 10 | border: none; 11 | position: relative; 12 | overflow: hidden; 13 | transform-style: preserve-3d; 14 | transform: perspective(1000px) rotateX(0deg) rotateY(0deg); 15 | will-change: transform; 16 | 17 | // Use different styling for light mode 18 | :global(body.light) & { 19 | background: rgba(255, 255, 255, 0.85); 20 | box-shadow: 0 10px 20px rgba(0, 0, 0, 0.05), 21 | inset 0 0 0 1px rgba(0, 0, 0, 0.05); 22 | } 23 | 24 | &::before { 25 | content: ""; 26 | position: absolute; 27 | top: 0; 28 | left: 0; 29 | width: 100%; 30 | height: 4px; 31 | background: linear-gradient( 32 | 90deg, 33 | var(--orange) 0%, 34 | var(--orange-light, #ff7b4a) 100% 35 | ); 36 | transform: translateZ(10px); 37 | } 38 | 39 | &::after { 40 | content: ""; 41 | position: absolute; 42 | top: 4px; 43 | left: 0; 44 | right: 0; 45 | bottom: 0; 46 | border-radius: 0 0 16px 16px; 47 | box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.1); 48 | pointer-events: none; 49 | transform: translateZ(5px); 50 | 51 | // Different inner shadow for light mode 52 | :global(body.light) & { 53 | box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.05); 54 | } 55 | } 56 | 57 | &:hover { 58 | box-shadow: 0 15px 30px rgba(0, 0, 0, 0.2), 59 | inset 0 0 0 1px rgba(255, 255, 255, 0.1), 0 0 20px rgba(255, 69, 0, 0.15); 60 | 61 | // Different hover shadow for light mode 62 | :global(body.light) & { 63 | box-shadow: 0 15px 30px rgba(0, 0, 0, 0.08), 64 | inset 0 0 0 1px rgba(0, 0, 0, 0.05), 0 0 20px rgba(255, 69, 0, 0.1); 65 | } 66 | } 67 | 68 | .featureIcon { 69 | font-size: 3rem; 70 | margin-bottom: 1.2rem; 71 | display: block; 72 | filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.1)); 73 | transform: translateZ(30px); 74 | 75 | svg { 76 | color: var(--orange); 77 | width: 40px; 78 | height: 40px; 79 | } 80 | } 81 | 82 | h3 { 83 | font-size: 1.3rem; 84 | margin-bottom: 0.9rem; 85 | color: var(--color-primary); 86 | font-weight: 600; 87 | transform: translateZ(25px); 88 | } 89 | 90 | p { 91 | color: var(--color-secondary); 92 | line-height: 1.6; 93 | font-size: 0.95rem; 94 | transform: translateZ(20px); 95 | } 96 | 97 | // Shine effect adjustment for light mode 98 | .shine { 99 | position: absolute; 100 | top: 0; 101 | left: 0; 102 | right: 0; 103 | bottom: 0; 104 | background: linear-gradient( 105 | 135deg, 106 | rgba(255, 255, 255, 0) 0%, 107 | rgba(255, 255, 255, 0.03) 50%, 108 | rgba(255, 255, 255, 0) 100% 109 | ); 110 | pointer-events: none; 111 | transform: translateZ(2px); 112 | 113 | :global(body.light) & { 114 | background: linear-gradient( 115 | 135deg, 116 | rgba(255, 255, 255, 0) 0%, 117 | rgba(255, 255, 255, 0.2) 50%, 118 | rgba(255, 255, 255, 0) 100% 119 | ); 120 | } 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /app/src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useState, useEffect } from "react"; 2 | import { useNavigate } from "react-router-dom"; 3 | import styles from "./Header.module.scss"; 4 | import { Settings } from "./icons/Settings"; 5 | import { Filter } from "./icons/Filter"; 6 | import LOGO from "../assets/images/logo.svg"; 7 | import LOGO_WHITE from "../assets/images/logo_white.svg"; 8 | import { useStore } from "../hooks/useStore"; 9 | 10 | interface HeaderProps { 11 | onSettingsClick?: () => void; 12 | onFilterClick?: () => void; 13 | } 14 | 15 | export const Header: FC = ({ onSettingsClick, onFilterClick }) => { 16 | const { store, logout } = useStore(); 17 | const navigate = useNavigate(); 18 | const [isMobile, setIsMobile] = useState(false); 19 | 20 | // Check for mobile screen size on mount and window resize 21 | useEffect(() => { 22 | const checkMobileView = () => { 23 | setIsMobile(window.innerWidth <= 480); 24 | }; 25 | 26 | // Initial check 27 | checkMobileView(); 28 | 29 | // Add event listener for window resize 30 | window.addEventListener("resize", checkMobileView); 31 | 32 | // Clean up 33 | return () => window.removeEventListener("resize", checkMobileView); 34 | }, []); 35 | 36 | const handleLogout = async () => { 37 | await logout(); 38 | navigate("/"); 39 | }; 40 | 41 | const openUserProfile = () => { 42 | window.open( 43 | `https://www.reddit.com/user/${store.auth.user?.name}`, 44 | "_blank" 45 | ); 46 | }; 47 | return ( 48 |
49 |
50 | Bookmarkeddit logo{" "} 55 |
{" "} 56 | {isMobile && onFilterClick && ( 57 |
58 | 65 |
66 | )} 67 |
68 | {store.auth.user && ( 69 |
75 | {store.auth.user.icon_img && ( 76 | {`${store.auth.user.name}'s 81 | )} 82 | u/{store.auth.user.name} 83 |
84 | )}{" "} 85 |
86 | 94 | 95 | 102 |
103 |
104 |
105 | ); 106 | }; 107 | -------------------------------------------------------------------------------- /app/src/components/ImageSlider.module.scss: -------------------------------------------------------------------------------- 1 | .slider { 2 | width: 100%; 3 | position: relative; 4 | margin: 1rem 0; 5 | border-radius: 8px; 6 | overflow: hidden; 7 | } 8 | 9 | .sliderContent { 10 | position: relative; 11 | width: 100%; 12 | height: auto; // Allow height to be determined by content 13 | display: flex; 14 | align-items: center; 15 | justify-content: center; 16 | 17 | img { 18 | width: 100%; 19 | max-width: 100%; 20 | height: auto; 21 | max-height: 500px; 22 | object-fit: contain; 23 | background-color: rgba(0, 0, 0, 0.03); 24 | border-radius: 8px; 25 | transition: opacity 0.3s ease; 26 | } 27 | } 28 | 29 | .singleImage { 30 | width: 100%; 31 | margin: 1rem 0; 32 | position: relative; 33 | 34 | img { 35 | width: 100%; 36 | max-height: 500px; 37 | object-fit: contain; 38 | border-radius: 8px; 39 | } 40 | } 41 | 42 | .navigationButton { 43 | position: absolute; 44 | top: 50%; 45 | transform: translateY(-50%); 46 | background-color: rgba(0, 0, 0, 0.3); 47 | color: white; 48 | border: none; 49 | cursor: pointer; 50 | padding: 0.5rem; 51 | font-size: 1.25rem; 52 | line-height: 1; 53 | width: 2.5rem; 54 | height: 2.5rem; 55 | display: flex; 56 | align-items: center; 57 | justify-content: center; 58 | border-radius: 50%; 59 | transition: background-color 0.2s ease; 60 | z-index: 2; 61 | 62 | &:hover, 63 | &:focus { 64 | background-color: rgba(0, 0, 0, 0.5); 65 | } 66 | 67 | &.prevButton { 68 | left: 0.75rem; 69 | } 70 | 71 | &.nextButton { 72 | right: 0.75rem; 73 | } 74 | } 75 | 76 | .paginationIndicator { 77 | position: absolute; 78 | bottom: 0.75rem; 79 | right: 0.75rem; 80 | background-color: rgba(0, 0, 0, 0.5); 81 | color: white; 82 | padding: 0.25rem 0.5rem; 83 | border-radius: 1rem; 84 | font-size: 0.75rem; 85 | } 86 | 87 | // Blur effect for NSFW content 88 | .blurContainer { 89 | position: relative; 90 | } 91 | 92 | .blurredImage { 93 | filter: blur(15px); 94 | -webkit-filter: blur(15px); 95 | transition: filter 0.3s ease; 96 | } 97 | 98 | .revealOverlay { 99 | position: absolute; 100 | top: 0; 101 | left: 0; 102 | right: 0; 103 | bottom: 0; 104 | display: flex; 105 | justify-content: center; 106 | align-items: center; 107 | background-color: rgba(0, 0, 0, 0.4); 108 | z-index: 3; 109 | transition: background-color 0.3s ease; 110 | 111 | &:hover { 112 | background-color: rgba(0, 0, 0, 0.5); 113 | } 114 | } 115 | 116 | /* Tooltip wrapper and tooltip styles - similar to Post.module.scss */ 117 | .tooltipWrapper { 118 | position: relative; 119 | display: inline-block; 120 | 121 | &:hover .tooltip { 122 | visibility: visible; 123 | opacity: 1; 124 | } 125 | } 126 | 127 | .tooltip { 128 | visibility: hidden; 129 | position: absolute; 130 | z-index: 4; 131 | bottom: 125%; 132 | left: 50%; 133 | transform: translateX(-50%); 134 | background-color: var(--bg-primary); 135 | color: var(--color-primary); 136 | text-align: center; 137 | border-radius: 4px; 138 | padding: 5px 8px; 139 | font-size: 12px; 140 | white-space: nowrap; 141 | opacity: 0; 142 | transition: opacity 0.3s; 143 | 144 | /* Add a small triangle pointer */ 145 | &::after { 146 | content: ""; 147 | position: absolute; 148 | top: 100%; 149 | left: 50%; 150 | margin-left: -5px; 151 | border-width: 5px; 152 | border-style: solid; 153 | border-color: var(--bg-primary) transparent transparent transparent; 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /app/src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/src/components/SettingsModal.module.scss: -------------------------------------------------------------------------------- 1 | .overlay { 2 | position: fixed; 3 | top: 0; 4 | left: 0; 5 | right: 0; 6 | bottom: 0; 7 | background-color: rgba(0, 0, 0, 0.5); 8 | backdrop-filter: blur(2px); 9 | display: flex; 10 | justify-content: center; 11 | align-items: center; 12 | z-index: 1000; 13 | } 14 | 15 | .modal { 16 | background-color: var(--bg-primary); 17 | border-radius: 8px; 18 | width: 90%; 19 | max-width: 500px; 20 | max-height: 80vh; 21 | overflow-y: auto; 22 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); 23 | border: var(--border); 24 | } 25 | 26 | .header { 27 | display: flex; 28 | justify-content: space-between; 29 | align-items: center; 30 | padding: 1rem; 31 | border-bottom: var(--border); 32 | 33 | h2 { 34 | margin: 0; 35 | font-size: 1.2rem; 36 | color: var(--color-primary); 37 | } 38 | } 39 | 40 | .content { 41 | padding: 1rem; 42 | color: var(--color-primary); 43 | } 44 | 45 | .settingItem { 46 | display: flex; 47 | justify-content: space-between; 48 | align-items: center; 49 | padding: 1rem 0; 50 | border-bottom: 1px solid var(--border-color); 51 | 52 | &:last-child { 53 | border-bottom: none; 54 | } 55 | } 56 | 57 | .settingInfo { 58 | flex: 1; 59 | } 60 | 61 | .settingTitle { 62 | font-weight: 500; 63 | margin-bottom: 0.25rem; 64 | } 65 | 66 | .settingDescription { 67 | font-size: 0.85rem; 68 | color: var(--color-secondary); 69 | } 70 | 71 | .toggle { 72 | position: relative; 73 | display: inline-block; 74 | width: 50px; 75 | height: 26px; 76 | margin-left: 1rem; 77 | 78 | input { 79 | opacity: 0; 80 | width: 0; 81 | height: 0; 82 | 83 | &:checked + .slider { 84 | background-color: var(--btn-hover-color); 85 | } 86 | 87 | &:checked + .slider:before { 88 | transform: translateX(24px); 89 | } 90 | 91 | // Position the icon based on the toggle state 92 | &:checked + .slider svg { 93 | left: auto; 94 | right: 6px; 95 | top: 50%; 96 | transform: translateY(-50%); 97 | } 98 | 99 | &:not(:checked) + .slider svg { 100 | right: auto; 101 | left: 6px; 102 | top: 50%; 103 | transform: translateY(-50%); 104 | } 105 | } 106 | 107 | .slider { 108 | position: absolute; 109 | cursor: pointer; 110 | top: 0; 111 | left: 0; 112 | right: 0; 113 | bottom: 0; 114 | background-color: var(--border-color); 115 | transition: 0.3s; 116 | border-radius: 34px; 117 | display: flex; 118 | align-items: center; 119 | 120 | svg { 121 | width: 14px; 122 | height: 14px; 123 | position: absolute; 124 | color: var(--color-primary); 125 | transition: 0.3s; 126 | } 127 | 128 | &:before { 129 | position: absolute; 130 | content: ""; 131 | height: 18px; 132 | width: 18px; 133 | left: 4px; 134 | bottom: 4px; 135 | background-color: var(--bg-primary); 136 | transition: 0.3s; 137 | border-radius: 50%; 138 | } 139 | } 140 | } 141 | 142 | /* Media queries for responsive layout */ 143 | @media (max-width: 768px) { 144 | .modal { 145 | width: 95%; 146 | max-width: 450px; 147 | } 148 | 149 | .title { 150 | font-size: 1.2rem; 151 | } 152 | 153 | .section { 154 | padding: 0.75rem; 155 | } 156 | 157 | .formGroup { 158 | margin-bottom: 0.75rem; 159 | } 160 | 161 | label { 162 | font-size: 0.9rem; 163 | } 164 | } 165 | 166 | @media (max-width: 480px) { 167 | .modal { 168 | width: 95%; 169 | max-width: 100%; 170 | max-height: 85vh; 171 | } 172 | 173 | .header { 174 | padding: 0.75rem; 175 | } 176 | 177 | .title { 178 | font-size: 1.1rem; 179 | } 180 | 181 | .section { 182 | padding: 0.5rem; 183 | } 184 | 185 | .formGroup { 186 | margin-bottom: 0.5rem; 187 | } 188 | 189 | label { 190 | font-size: 0.85rem; 191 | } 192 | 193 | .buttonGroup { 194 | padding: 0.75rem; 195 | flex-wrap: wrap; 196 | gap: 0.5rem; 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | client: 5 | image: bookmarkeddit-client:latest 6 | build: 7 | context: ./app 8 | dockerfile: Dockerfile 9 | env_file: 10 | - ./app/.env.production 11 | deploy: 12 | replicas: 3 13 | update_config: 14 | parallelism: 1 15 | delay: 10s 16 | restart_policy: 17 | condition: on-failure 18 | networks: 19 | - app-network 20 | 21 | server: 22 | image: bookmarkeddit:latest 23 | build: 24 | context: ./server 25 | dockerfile: Dockerfile 26 | # Use Docker secrets for sensitive config, mount as REDDIT_CLIENT_ID and REDDIT_CLIENT_SECRET 27 | deploy: 28 | replicas: 5 29 | update_config: 30 | parallelism: 2 31 | delay: 10s 32 | restart_policy: 33 | condition: on-failure 34 | environment: 35 | - NODE_ENV=production 36 | secrets: 37 | - source: CLIENT_ID 38 | target: REDDIT_CLIENT_ID 39 | - source: CLIENT_SECRET 40 | target: REDDIT_CLIENT_SECRET 41 | networks: 42 | - app-network 43 | 44 | caddy: 45 | image: caddy:2 46 | restart: unless-stopped 47 | ports: 48 | - "80:80" 49 | - "443:443" 50 | volumes: 51 | - ./Caddyfile:/etc/caddy/Caddyfile 52 | - caddy_data:/data 53 | - caddy_config:/config 54 | depends_on: 55 | - server 56 | - client 57 | - plausible 58 | networks: 59 | - app-network 60 | 61 | clickhouse: 62 | image: clickhouse/clickhouse-server:23.8 63 | restart: always 64 | volumes: 65 | - clickhouse_data:/var/lib/clickhouse 66 | ulimits: 67 | nofile: 68 | soft: 262144 69 | hard: 262144 70 | networks: 71 | - app-network 72 | healthcheck: 73 | test: ["CMD", "wget", "--spider", "-q", "http://localhost:8123/ping"] 74 | interval: 10s 75 | timeout: 5s 76 | retries: 5 77 | start_period: 30s 78 | 79 | plausible_db: 80 | image: postgres:14 81 | restart: always 82 | volumes: 83 | - plausible_db_data:/var/lib/postgresql/data 84 | environment: 85 | - POSTGRES_PASSWORD_FILE=/run/secrets/plausible_db_password 86 | - POSTGRES_USER=postgres 87 | - POSTGRES_DB=plausible_db 88 | secrets: 89 | - source: PLAUSIBLE_DB_PASSWORD 90 | target: plausible_db_password 91 | networks: 92 | - app-network 93 | healthcheck: 94 | test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] 95 | interval: 10s 96 | timeout: 5s 97 | retries: 5 98 | start_period: 30s 99 | 100 | plausible: 101 | image: plausible/analytics:latest 102 | restart: always 103 | command: sh -c "echo 'Waiting for plausible_db (PostgreSQL) DNS resolution...'; while ! getent hosts plausible_db > /dev/null; do echo 'plausible_db not resolvable, sleeping...'; sleep 1; done; echo 'plausible_db DNS resolved.'; echo 'Waiting for PostgreSQL port 5432...'; while ! nc -z plausible_db 5432; do echo 'PostgreSQL port not open, sleeping...'; sleep 1; done; echo 'PostgreSQL is up.'; echo 'Waiting for clickhouse DNS resolution...'; while ! getent hosts clickhouse > /dev/null; do echo 'clickhouse not resolvable, sleeping...'; sleep 1; done; echo 'clickhouse DNS resolved.'; echo 'Waiting for ClickHouse port 8123...'; while ! nc -z clickhouse 8123; do echo 'ClickHouse port not open, sleeping...'; sleep 1; done; echo 'ClickHouse is up.'; echo 'Proceeding with Plausible database setup and startup.'; /entrypoint.sh db createdb && /entrypoint.sh db migrate && /entrypoint.sh run" 104 | depends_on: 105 | - plausible_db 106 | - clickhouse 107 | ports: 108 | - "8000:8000" 109 | env_file: 110 | - ./plausible.env 111 | networks: 112 | - app-network 113 | 114 | networks: 115 | app-network: 116 | driver: overlay 117 | 118 | volumes: 119 | caddy_data: 120 | external: true 121 | caddy_config: 122 | external: true 123 | plausible_db_data: {} 124 | clickhouse_data: {} 125 | 126 | secrets: 127 | CLIENT_ID: 128 | external: true 129 | CLIENT_SECRET: 130 | external: true 131 | PLAUSIBLE_DB_PASSWORD: 132 | external: true 133 | -------------------------------------------------------------------------------- /app/src/hooks/store/useSettingsStore.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Settings store for Bookmarkeddit 3 | * Manages UI preferences like theme, layout, and display options 4 | */ 5 | import { useCallback, useContext } from "react"; 6 | import { StoreContext } from "./StoreContext.tsx"; 7 | 8 | // Type definitions for store properties 9 | export type ThemeType = "dark" | "light"; 10 | export type Layout = "grid" | "list"; 11 | export type SortOption = "recent" | "upvotes" | "comments"; 12 | 13 | /** 14 | * Settings state interface 15 | */ 16 | export interface SettingsState { 17 | theme: ThemeType; 18 | layout: Layout; 19 | sortBy: SortOption; 20 | showImages: boolean; 21 | compactText: boolean; 22 | blurNSFW: boolean; 23 | showFilters: boolean; // Controls filters visibility 24 | } 25 | 26 | // Initial settings state 27 | export const initialSettingsState: SettingsState = { 28 | theme: "dark", 29 | layout: "grid", 30 | sortBy: "recent", 31 | showImages: true, 32 | compactText: true, 33 | blurNSFW: true, 34 | showFilters: true, // Default to showing filters 35 | }; 36 | 37 | /** 38 | * Custom hook for managing UI settings and preferences 39 | */ 40 | export const useSettingsStore = () => { 41 | const { store, setStore } = useContext(StoreContext); 42 | 43 | /** 44 | * Toggle between light and dark theme 45 | */ 46 | const changeTheme = useCallback(() => { 47 | const newTheme = store.theme === "dark" ? "light" : "dark"; 48 | document.body.classList.remove(store.theme); 49 | document.body.classList.add(newTheme); 50 | localStorage.setItem("theme", newTheme); 51 | setStore((currentStore) => ({ ...currentStore, theme: newTheme })); 52 | }, [store.theme, setStore]); 53 | 54 | /** 55 | * Change layout between grid and list views 56 | */ 57 | const changeLayout = useCallback( 58 | (layout: Layout) => { 59 | localStorage.setItem("layout", layout); 60 | setStore((currentStore) => ({ ...currentStore, layout })); 61 | }, 62 | [setStore] 63 | ); 64 | 65 | /** 66 | * Change sort order for posts 67 | */ 68 | const changeSortBy = useCallback( 69 | (sortBy: SortOption) => { 70 | localStorage.setItem("sortBy", sortBy); 71 | setStore((currentStore) => ({ ...currentStore, sortBy })); 72 | }, 73 | [setStore] 74 | ); 75 | 76 | /** 77 | * Toggle image display on/off 78 | */ 79 | const toggleShowImages = useCallback(() => { 80 | const newValue = !store.showImages; 81 | localStorage.setItem("showImages", newValue.toString()); 82 | setStore((currentStore) => ({ ...currentStore, showImages: newValue })); 83 | }, [store.showImages, setStore]); 84 | 85 | /** 86 | * Toggle compact text display on/off 87 | */ 88 | const toggleCompactText = useCallback(() => { 89 | const newValue = !store.compactText; 90 | localStorage.setItem("compactText", newValue.toString()); 91 | setStore((currentStore) => ({ ...currentStore, compactText: newValue })); 92 | }, [store.compactText, setStore]); 93 | 94 | /** 95 | * Toggle NSFW content blurring on/off 96 | */ 97 | const toggleBlurNSFW = useCallback(() => { 98 | const newValue = !store.blurNSFW; 99 | localStorage.setItem("blurNSFW", newValue.toString()); 100 | setStore((currentStore) => ({ ...currentStore, blurNSFW: newValue })); 101 | }, [store.blurNSFW, setStore]); 102 | 103 | /** 104 | * Toggle filters sidebar visibility 105 | */ 106 | const toggleFiltersVisibility = useCallback(() => { 107 | const newValue = !store.showFilters; 108 | localStorage.setItem("showFilters", newValue.toString()); 109 | setStore((currentStore) => ({ ...currentStore, showFilters: newValue })); 110 | }, [store.showFilters, setStore]); 111 | 112 | // Return the settings and related methods 113 | return { 114 | // Settings properties 115 | theme: store.theme, 116 | layout: store.layout, 117 | sortBy: store.sortBy, 118 | showImages: store.showImages, 119 | compactText: store.compactText, 120 | blurNSFW: store.blurNSFW, 121 | showFilters: store.showFilters, 122 | 123 | // Methods 124 | changeTheme, 125 | changeLayout, 126 | changeSortBy, 127 | toggleShowImages, 128 | toggleCompactText, 129 | toggleBlurNSFW, 130 | toggleFiltersVisibility, 131 | }; 132 | }; 133 | -------------------------------------------------------------------------------- /app/src/assets/fonts/OFL.txt: -------------------------------------------------------------------------------- 1 | Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" 2 | 3 | This Font Software is licensed under the SIL Open Font License, Version 1.1. 4 | This license is copied below, and is also available with a FAQ at: 5 | http://scripts.sil.org/OFL 6 | 7 | 8 | ----------------------------------------------------------- 9 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 10 | ----------------------------------------------------------- 11 | 12 | PREAMBLE 13 | The goals of the Open Font License (OFL) are to stimulate worldwide 14 | development of collaborative font projects, to support the font creation 15 | efforts of academic and linguistic communities, and to provide a free and 16 | open framework in which fonts may be shared and improved in partnership 17 | with others. 18 | 19 | The OFL allows the licensed fonts to be used, studied, modified and 20 | redistributed freely as long as they are not sold by themselves. The 21 | fonts, including any derivative works, can be bundled, embedded, 22 | redistributed and/or sold with any software provided that any reserved 23 | names are not used by derivative works. The fonts and derivatives, 24 | however, cannot be released under any other type of license. The 25 | requirement for fonts to remain under this license does not apply 26 | to any document created using the fonts or their derivatives. 27 | 28 | DEFINITIONS 29 | "Font Software" refers to the set of files released by the Copyright 30 | Holder(s) under this license and clearly marked as such. This may 31 | include source files, build scripts and documentation. 32 | 33 | "Reserved Font Name" refers to any names specified as such after the 34 | copyright statement(s). 35 | 36 | "Original Version" refers to the collection of Font Software components as 37 | distributed by the Copyright Holder(s). 38 | 39 | "Modified Version" refers to any derivative made by adding to, deleting, 40 | or substituting -- in part or in whole -- any of the components of the 41 | Original Version, by changing formats or by porting the Font Software to a 42 | new environment. 43 | 44 | "Author" refers to any designer, engineer, programmer, technical 45 | writer or other person who contributed to the Font Software. 46 | 47 | PERMISSION & CONDITIONS 48 | Permission is hereby granted, free of charge, to any person obtaining 49 | a copy of the Font Software, to use, study, copy, merge, embed, modify, 50 | redistribute, and sell modified and unmodified copies of the Font 51 | Software, subject to the following conditions: 52 | 53 | 1) Neither the Font Software nor any of its individual components, 54 | in Original or Modified Versions, may be sold by itself. 55 | 56 | 2) Original or Modified Versions of the Font Software may be bundled, 57 | redistributed and/or sold with any software, provided that each copy 58 | contains the above copyright notice and this license. These can be 59 | included either as stand-alone text files, human-readable headers or 60 | in the appropriate machine-readable metadata fields within text or 61 | binary files as long as those fields can be easily viewed by the user. 62 | 63 | 3) No Modified Version of the Font Software may use the Reserved Font 64 | Name(s) unless explicit written permission is granted by the corresponding 65 | Copyright Holder. This restriction only applies to the primary font name as 66 | presented to the users. 67 | 68 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 69 | Software shall not be used to promote, endorse or advertise any 70 | Modified Version, except to acknowledge the contribution(s) of the 71 | Copyright Holder(s) and the Author(s) or with their explicit written 72 | permission. 73 | 74 | 5) The Font Software, modified or unmodified, in part or in whole, 75 | must be distributed entirely under this license, and must not be 76 | distributed under any other license. The requirement for fonts to 77 | remain under this license does not apply to any document created 78 | using the Font Software. 79 | 80 | TERMINATION 81 | This license becomes null and void if any of the above conditions are 82 | not met. 83 | 84 | DISCLAIMER 85 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 86 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 87 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 88 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 89 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 90 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 91 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 92 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 93 | OTHER DEALINGS IN THE FONT SOFTWARE. 94 | -------------------------------------------------------------------------------- /app/src/components/SettingsModal.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useEffect, useRef } from "react"; 2 | import { X } from "./icons/X"; 3 | import { Sun } from "./icons/Sun"; 4 | import { Moon } from "./icons/Moon"; 5 | import styles from "./SettingsModal.module.scss"; 6 | import { useStore } from "../hooks/useStore"; 7 | 8 | interface SettingsModalProps { 9 | isOpen: boolean; 10 | onClose: () => void; 11 | } 12 | 13 | export const SettingsModal: FC = ({ isOpen, onClose }) => { 14 | const { 15 | store, 16 | changeTheme, 17 | toggleCompactText, 18 | toggleShowImages, 19 | toggleBlurNSFW, 20 | } = useStore(); 21 | 22 | const modalRef = useRef(null); 23 | 24 | // Handle ESC key press 25 | useEffect(() => { 26 | const handleKeyDown = (event: KeyboardEvent) => { 27 | if (event.key === "Escape" && isOpen) { 28 | onClose(); 29 | } 30 | }; 31 | 32 | window.addEventListener("keydown", handleKeyDown); 33 | return () => { 34 | window.removeEventListener("keydown", handleKeyDown); 35 | }; 36 | }, [isOpen, onClose]); 37 | 38 | // Handle click outside modal 39 | const handleOverlayClick = (event: React.MouseEvent) => { 40 | if (modalRef.current && !modalRef.current.contains(event.target as Node)) { 41 | onClose(); 42 | } 43 | }; 44 | 45 | if (!isOpen) return null; 46 | 47 | return ( 48 |
49 |
50 |
51 |

Settings

52 | 55 |
56 |
57 |
58 |
59 |
Enable Dark Mode
60 |
61 | Change the appearance of the app 62 |
63 |
64 | 74 |
75 | 76 |
77 |
78 |
Compact Text
79 |
80 | Display text in a more compact format 81 |
82 |
83 | 91 |
92 | 93 |
94 |
95 |
Show Images
96 |
97 | Display images in posts 98 |
99 |
100 | 108 |
109 | 110 |
111 |
112 |
Blur NSFW Images
113 |
114 | Blur images marked as not safe for work 115 |
116 |
117 | 125 |
126 |
127 |
128 |
129 | ); 130 | }; 131 | -------------------------------------------------------------------------------- /app/src/components/Header.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | background-color: var(--bg-secondary); 3 | display: flex; 4 | align-items: center; 5 | justify-content: space-between; 6 | position: sticky; 7 | top: 0; 8 | width: 100%; 9 | padding: 0.5rem 1rem; 10 | border-bottom: var(--border); 11 | z-index: 10; 12 | max-width: 100%; 13 | box-sizing: border-box; 14 | left: 0; 15 | right: 0; 16 | } 17 | 18 | .logoContainer { 19 | display: flex; 20 | align-items: center; 21 | 22 | .logo { 23 | max-height: 32px; 24 | width: auto; 25 | } 26 | } 27 | 28 | .userSection { 29 | display: flex; 30 | align-items: center; 31 | gap: 1rem; 32 | flex-wrap: nowrap; 33 | min-width: 0; /* Allow container to shrink below content size */ 34 | } 35 | 36 | .userInfo { 37 | display: flex; 38 | align-items: center; 39 | gap: 0.5rem; 40 | padding: 0.25rem 0.5rem; 41 | border-radius: var(--border-radius); 42 | cursor: "pointer"; 43 | overflow: hidden; /* Prevent overflow */ 44 | white-space: nowrap; /* Keep username on one line */ 45 | min-width: 0; /* Allow container to shrink */ 46 | 47 | &:hover { 48 | background-color: var(--bg-primary); 49 | } 50 | 51 | .userAvatar { 52 | width: 30px; 53 | height: 30px; 54 | border-radius: 50%; 55 | object-fit: cover; 56 | flex-shrink: 0; /* Prevent avatar from shrinking */ 57 | } 58 | 59 | .username { 60 | color: var(--color-primary); 61 | font-size: 0.9rem; 62 | font-weight: 500; 63 | text-overflow: ellipsis; /* Add ellipsis when text overflows */ 64 | overflow: hidden; /* Hide overflow */ 65 | } 66 | } 67 | 68 | .buttons { 69 | display: flex; 70 | align-items: center; 71 | gap: 0.75rem; 72 | flex-shrink: 0; /* Prevent buttons from shrinking */ 73 | } 74 | 75 | .logoutBtn { 76 | background-color: transparent; 77 | color: var(--color-primary); 78 | border: var(--border); 79 | border-radius: var(--btn-border-radius); 80 | padding: 0.4rem 1rem; 81 | height: auto; 82 | font-size: 0.9rem; 83 | font-weight: normal; 84 | 85 | &:hover { 86 | background-color: rgba(255, 69, 0, 0.1); 87 | color: var(--btn-hover-color); 88 | border-color: var(--btn-hover-color); 89 | } 90 | } 91 | 92 | /* Media queries for responsive layout */ 93 | @media (max-width: 768px) { 94 | .root { 95 | padding: 0.5rem; 96 | width: 100vw; /* Use viewport width for full width */ 97 | margin: 0; 98 | } 99 | 100 | .logoContainer { 101 | .logo { 102 | max-height: 28px; 103 | } 104 | } 105 | 106 | .userSection { 107 | gap: 0.5rem; 108 | flex-grow: 1; /* Allow user section to take up more space */ 109 | justify-content: flex-end; /* Align content to the right */ 110 | margin-left: auto; /* Push user section to the right */ 111 | } 112 | 113 | .buttons { 114 | gap: 0.5rem; 115 | } 116 | 117 | .username { 118 | max-width: 100px; /* Restrict username width on tablet */ 119 | } 120 | 121 | /* .logoutBtn { 122 | padding: 0.4rem 0.75rem; 123 | font-size: 0.8rem; 124 | } */ 125 | } 126 | 127 | .mobileFilterButton { 128 | display: flex; 129 | align-items: center; 130 | margin-right: auto; 131 | margin-left: 15px; 132 | } 133 | 134 | .filterButton { 135 | color: var(--orange); 136 | 137 | &:hover { 138 | background-color: var(--bg-hover); 139 | } 140 | } 141 | 142 | @media (max-width: 480px) { 143 | .root { 144 | padding: 0.5rem 0.3rem; 145 | justify-content: space-between; /* Changed from flex-end to space-between */ 146 | } 147 | 148 | .logoContainer { 149 | display: none; /* Hide logo on mobile devices */ 150 | } 151 | 152 | .logoContainer .logo { 153 | max-height: 24px; 154 | } 155 | 156 | .mobileFilterButton { 157 | display: flex; 158 | align-items: center; 159 | margin-right: auto; /* Push to the left side */ 160 | } 161 | 162 | .userInfo { 163 | padding: 0.25rem; 164 | } 165 | 166 | .username { 167 | max-width: 120px; /* Increase username width since we've removed the logo */ 168 | } 169 | 170 | /* .logoutBtn { 171 | padding: 0.3rem 0.5rem; 172 | font-size: 0.75rem; 173 | } */ 174 | 175 | .buttons { 176 | gap: 0.3rem; 177 | } 178 | } 179 | 180 | /* Extra small devices */ 181 | @media (max-width: 360px) { 182 | .root { 183 | padding: 0.5rem 0.2rem; 184 | justify-content: flex-end; /* Align content to the right */ 185 | } 186 | 187 | .userInfo { 188 | padding: 0.25rem 0.1rem; 189 | } 190 | 191 | .username { 192 | max-width: 80px; 193 | } 194 | 195 | .userAvatar { 196 | width: 25px !important; 197 | height: 25px !important; 198 | } 199 | 200 | .buttons { 201 | gap: 0.2rem; 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to VPS 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v4 14 | 15 | - name: Set up SSH 16 | uses: webfactory/ssh-agent@v0.9.0 17 | with: 18 | ssh-private-key: ${{ secrets.VPS_SSH_KEY }} 19 | 20 | - name: Ensure deploy directory exists on VPS 21 | run: | 22 | ssh -o StrictHostKeyChecking=no ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} "mkdir -p ${{ secrets.VPS_DEPLOY_PATH }}" 23 | 24 | - name: Ensure repo is cloned on VPS 25 | run: | 26 | ssh -o StrictHostKeyChecking=no ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} "if [ ! -d '${{ secrets.VPS_DEPLOY_PATH }}/.git' ]; then rm -rf '${{ secrets.VPS_DEPLOY_PATH }}'; git clone https://github.com/${{ github.repository }} ${{ secrets.VPS_DEPLOY_PATH }}; fi" 27 | 28 | - name: Create app/.env.production on VPS 29 | run: | 30 | ssh -o StrictHostKeyChecking=no ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} "mkdir -p ${{ secrets.VPS_DEPLOY_PATH }}/app && echo 'VITE_API_URL=/api' > ${{ secrets.VPS_DEPLOY_PATH }}/app/.env.production && echo 'VITE_REDIRECT_URI=https://bookmarkeddit.com/login/callback' >> ${{ secrets.VPS_DEPLOY_PATH }}/app/.env.production && echo 'VITE_CLIENT_ID=${{ secrets.SERVER_CLIENT_ID }}' >> ${{ secrets.VPS_DEPLOY_PATH }}/app/.env.production" 31 | 32 | - name: Update code to latest commit 33 | run: | 34 | ssh -o StrictHostKeyChecking=no ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} "cd ${{ secrets.VPS_DEPLOY_PATH }} && git fetch --all && git reset --hard ${{ github.sha }}" 35 | 36 | - name: Remove stack before updating secrets 37 | run: | 38 | ssh -o StrictHostKeyChecking=no ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} "docker stack rm bookmarkeddit || true && sleep 10" 39 | 40 | - name: Ensure Docker Swarm is initialized 41 | run: | 42 | ssh -o StrictHostKeyChecking=no ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} "docker info | grep 'Swarm: active' || docker swarm init" 43 | 44 | - name: Ensure Docker secrets exist on VPS 45 | run: | 46 | ssh -o StrictHostKeyChecking=no ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} "\ 47 | docker secret rm CLIENT_ID 2>/dev/null || true; \ 48 | echo '${{ secrets.SERVER_CLIENT_ID }}' | docker secret create CLIENT_ID -; \ 49 | docker secret rm CLIENT_SECRET 2>/dev/null || true; \ 50 | echo '${{ secrets.SERVER_CLIENT_SECRET }}' | docker secret create CLIENT_SECRET -; \ 51 | docker secret rm PLAUSIBLE_DB_PASSWORD 2>/dev/null || true; \ 52 | echo '${{ secrets.PLAUSIBLE_POSTGRES_PASSWORD }}' | docker secret create PLAUSIBLE_DB_PASSWORD -; \ 53 | " 54 | 55 | - name: Create plausible.env on VPS 56 | run: | 57 | ssh -o StrictHostKeyChecking=no ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} "echo 'BASE_URL=https://analytics.bookmarkeddit.com' > ${{ secrets.VPS_DEPLOY_PATH }}/plausible.env && echo 'DATABASE_URL=postgres://postgres:${{ secrets.PLAUSIBLE_POSTGRES_PASSWORD }}@plausible_db:5432/plausible_db' >> ${{ secrets.VPS_DEPLOY_PATH }}/plausible.env && echo 'SECRET_KEY_BASE=${{ secrets.PLAUSIBLE_SECRET_KEY }}' >> ${{ secrets.VPS_DEPLOY_PATH }}/plausible.env && echo 'CLICKHOUSE_DATABASE_URL=http://clickhouse:8123/plausible' >> ${{ secrets.VPS_DEPLOY_PATH }}/plausible.env && echo 'DISABLE_REGISTRATION=true' >> ${{ secrets.VPS_DEPLOY_PATH }}/plausible.env && echo 'DISABLE_IP_COLLECTION=false' >> ${{ secrets.VPS_DEPLOY_PATH }}/plausible.env" 58 | # ssh -o StrictHostKeyChecking=no ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} "echo 'BASE_URL=https://analytics.bookmarkeddit.com' > ${{ secrets.VPS_DEPLOY_PATH }}/plausible.env && echo 'DATABASE_URL=postgres://postgres:${{ secrets.PLAUSIBLE_POSTGRES_PASSWORD }}@plausible_db:5432/plausible_db' >> ${{ secrets.VPS_DEPLOY_PATH }}/plausible.env && echo 'SECRET_KEY_BASE=${{ secrets.PLAUSIBLE_SECRET_KEY }}' >> ${{ secrets.VPS_DEPLOY_PATH }}/plausible.env && echo 'CLICKHOUSE_DATABASE_URL=http://clickhouse:8123/plausible' >> ${{ secrets.VPS_DEPLOY_PATH }}/plausible.env && echo 'DISABLE_REGISTRATION=true' >> ${{ secrets.VPS_DEPLOY_PATH }}/plausible.env && echo 'DISABLE_IP_COLLECTION=true' >> ${{ secrets.VPS_DEPLOY_PATH }}/plausible.env" 59 | - name: Build server and client images on VPS 60 | run: | 61 | ssh -o StrictHostKeyChecking=no ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} "cd ${{ secrets.VPS_DEPLOY_PATH }} && docker build -t bookmarkeddit:latest -f server/Dockerfile server && docker build -t bookmarkeddit-client:latest -f app/Dockerfile app" 62 | 63 | - name: Deploy with Docker Swarm 64 | run: | 65 | ssh -o StrictHostKeyChecking=no ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} "cd ${{ secrets.VPS_DEPLOY_PATH }} && docker stack deploy -c docker-compose.yml bookmarkeddit" 66 | 67 | # Required GitHub secrets: 68 | # VPS_HOST, VPS_USER, VPS_SSH_KEY, VPS_DEPLOY_PATH 69 | # SERVER_CLIENT_ID, SERVER_CLIENT_SECRET, PLAUSIBLE_POSTGRES_PASSWORD 70 | -------------------------------------------------------------------------------- /app/src/components/LoginCallback.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useCallback, useEffect, useState } from "react"; 2 | import { useLocation, useNavigate } from "react-router"; 3 | import styles from "./LoginCallback.module.scss"; 4 | import { useStore } from "../hooks/useStore"; 5 | import { Loader } from "./ui/Loader"; 6 | import { AuthenticationError, authService } from "../api"; 7 | 8 | export const LoginCallback: FC = () => { 9 | const { handleCodeExchange, store } = useStore(); 10 | const navigate = useNavigate(); 11 | const { search } = useLocation(); 12 | const [error, setError] = useState(null); 13 | const [isRetrying, setIsRetrying] = useState(false); 14 | const [attempts, setAttempts] = useState(0); 15 | const MAX_RETRY_ATTEMPTS = 3; 16 | 17 | const processLoginCallback = useCallback( 18 | async (code: string) => { 19 | try { 20 | setIsRetrying(true); 21 | // Exchange the code for tokens 22 | const success = await handleCodeExchange(code); 23 | if (success) { 24 | navigate("/posts"); 25 | } else { 26 | setError(store.auth.error || "Failed to authenticate with Reddit"); 27 | setIsRetrying(false); 28 | } 29 | } catch (err) { 30 | const errorMessage = 31 | err instanceof AuthenticationError 32 | ? err.message 33 | : "An unexpected error occurred"; 34 | console.error("Authentication error:", err); 35 | setError(errorMessage); 36 | setIsRetrying(false); 37 | } 38 | }, 39 | [handleCodeExchange, navigate, store.auth.error] 40 | ); 41 | 42 | // Handler for retry button 43 | const handleRetry = () => { 44 | const query = new URLSearchParams(search); 45 | const code = query.get("code"); 46 | 47 | if (code && attempts < MAX_RETRY_ATTEMPTS) { 48 | setAttempts((prev) => prev + 1); 49 | setError(null); 50 | processLoginCallback(code); 51 | } else if (attempts >= MAX_RETRY_ATTEMPTS) { 52 | setError("Maximum retry attempts reached. Please try logging in again."); 53 | } 54 | }; 55 | 56 | // Handler for login again button 57 | const handleLoginAgain = () => { 58 | const loginUrl = authService.getLoginUrl(); 59 | window.location.href = loginUrl; 60 | }; 61 | useEffect(() => { 62 | const initAuthentication = async () => { 63 | const query = new URLSearchParams(search); 64 | const code = query.get("code"); 65 | const error = query.get("error"); 66 | 67 | // Handle error from Reddit OAuth 68 | if (error) { 69 | setError(`Authentication error from Reddit: ${error}`); 70 | return; 71 | } 72 | 73 | // No code found in URL 74 | if (!code) { 75 | setError( 76 | "Authorization code is missing from the URL. Please try logging in again." 77 | ); 78 | return; 79 | } 80 | 81 | // Process the authorization code 82 | await processLoginCallback(code); 83 | }; 84 | 85 | if (!isRetrying) { 86 | initAuthentication(); 87 | } 88 | }, [ 89 | search, 90 | handleCodeExchange, 91 | navigate, 92 | store.auth.error, 93 | isRetrying, 94 | processLoginCallback, 95 | ]); 96 | 97 | // If we're still loading or retrying, show the loader 98 | if ((store.auth.isLoading || isRetrying) && !error) { 99 | return ( 100 |
101 | 102 |

103 | {isRetrying 104 | ? `Authenticating attempt ${ 105 | attempts + 1 106 | } of ${MAX_RETRY_ATTEMPTS}...` 107 | : "Authenticating with Reddit..."} 108 |

109 |
110 | ); 111 | } 112 | 113 | // If there's an error, show an error message with retry option 114 | if (error) { 115 | return ( 116 |
117 |
118 |

Authentication Failed

119 |

{error}

120 | 121 |
122 | {attempts < MAX_RETRY_ATTEMPTS && 123 | error.includes("Failed to fetch") && ( 124 | 127 | )} 128 | 129 | 132 | 133 | 136 |
137 |
138 |
139 | ); 140 | } 141 | 142 | // Fallback while redirecting 143 | return ( 144 |
145 | 146 |

Redirecting to saved posts...

147 |
148 | ); 149 | }; 150 | -------------------------------------------------------------------------------- /app/src/assets/images/favicon/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /.todo: -------------------------------------------------------------------------------- 1 | add animations on homepage 2 | add screenshots to readme and homepage 3 | 4 | 5 | remove unused code 6 | refactor readme 7 | remove everything related to the compose and simplify readme 8 | improve clickhouse/postgres connection to use secrets 9 | maybe setup email for plausible 10 | 11 | 12 | https://examples.motion.dev/react/warp-overlay 13 | https://examples.motion.dev/react/family-dialog 14 | https://examples.motion.dev/react/notifications-stack 15 | https://examples.motion.dev/react/hold-to-confirm 16 | https://examples.motion.dev/react/cursor-hover-follow 17 | 18 | 19 | 20 | reeven feeback: 21 | line between categories, 22 | search is filter but not in filters and not reset when clear filters is clicked (it literally says filter by title) xdd, 23 | space between profile and settings icon and logout is not equal, 24 | clear filters and hide filters feel weirdly positioned, 25 | hide filters is unintuitive at least description wise, would never have imagined it closed the sidebar thingy, 26 | something to try out is making a blocky appearance around the layout thingy, so the spacing doesnt feel different there (aka same kinda markup as for the select and search next to it) might feel more in line with the rest too, 27 | sometimes there is a line between items but most of the time there is not, why?, 28 | spacong top and bottom refresh button is not equal (also not equal with slightly more down the other line), 29 | you have the name of the author, might be nice if you could go to their profile directly, same with subreddit, 30 | the go to button could technically be the whole box with all its content like on reddit itself ig, 31 | logo feels slightly too large, or not enough space around it, think I'd try to make it a tad smaller, 32 | accessibility wise the select state of the only nsfw posts is not great, particularly the post count, hard to read in darkmode, 33 | 4 saved posts is not aligned with the filters below it, aligjtly more inward, also refresh icon compared to accordion dings, 34 | in lightmode the icon of layput display is harsubreddit, make it more obvious you can click on it, hover styles, 35 | not sure why, but when i tried ta bbing through everything, i wasnt able to go into the accordions, 36 | some buttons and links are missing focus styles, 37 | when tabbing through the app, i get trapped on the open the post link, not immediately sure why, 38 | lightmode post text is not readable enough, 39 | consider adding a skiplink, 40 | the go to profile is not a tab-able element, so hidden for screen readers too (maybe)? 41 | bunch of buttons without text (see axe core), can make it only visible to screen readers and such, if you're using tailwind (add the "sr-only" class)), 42 | saved post in dark mode is not readable enough in theory, 43 | bunch of links with no text (again sr only), 44 | select needs a label, can be sronly (maybe), 45 | lots more contrast issues in lightmode (see axe core), 46 | in lightmode the orange could be slightly less bright as a focus style, cus might give of terror vibes, which is not what we want, altho if you use reddit, should be smart enough to understand the color schema, so that might be a pebcak, 47 | Uhmn, one last thing, your name in lightmode is not readable enough on your logout homepage, should be readable cus its something to be proud of 48 | 49 | 50 | 51 | 52 | Backlog - to implement: 53 | ☐ post content in markdown 54 | ☐ add locale to settings 55 | ☐ export to csv file 56 | ☐ performance improvements (maybe related to images?) 57 | ☐ Option 1: Implement Virtual Scrolling (Windowing) - The most effective solution would be to implement virtual scrolling (windowing), which only renders the items currently visible in the viewport. 58 | ☐ Option 2: Debounce Search and Resize: - Debounce the search input and grid resize calculations to avoid excessive re-renders and recalculations. (can i calculate based on image properties and associate them with the posts?) 59 | ☐ Option 3: Optimize Grid Calculations - Consider using CSS-only masonry layouts (e.g., grid-template-rows: masonry) if browser support is sufficient, or switch to a simpler layout for large lists. 60 | ☐ Option 4: Pagination or Infinite Scroll - Instead of showing all posts, implement pagination or infinite scrolling to load posts in chunks. (Infinite scroll is already implemented, but maybe not in the best way) 61 | ☐ Option 5: Load low quality images first - Load low-quality images. Since it's just a preview, you can load lower-quality images first and then replace them with higher-quality ones once they are loaded. This can be done using the `srcset` attribute or by using a library like `react-lazyload` or `react-lazyload-image-component`. It can be a new setting 62 | ☐ highlight the seached text on the posts (and show how many posts matched) 63 | ☐ replace implemented logic with a library like Winston, Pino, Morgan, Buyan or log4js that sabes the logs to a file and/or sends them to a remote server for analysis. 64 | 65 | 66 | 67 | 68 | 69 | Backlog - (features to consider for future updates): 70 | ☐ Dashboard and Stats (Create a dashboard showing statistics about saved posts, Track saving patterns, favorite subreddits, etc.) 71 | ☐ Add text customization (font size, line spacing) 72 | ☐ Pin posts (Allow users to pin certain posts to the top of their saved list for easy access) 73 | ☐ PWA (service worker, manifest, install prompt, icons, etc.) 74 | 75 | 76 | Bug Fixes and Improvements: 77 | ☐ Video sound not working. not really important, but would be nice to have 78 | 79 | -------------------------------------------------------------------------------- /app/src/hooks/useStore.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * useStore hook - Main entry point for accessing global store 3 | * Combines authentication and settings stores 4 | */ 5 | import { useContext, useEffect } from "react"; 6 | import { StoreContext } from "./store/StoreContext.tsx"; 7 | import { useAuthStore } from "./store/useAuthStore"; 8 | import { useSettingsStore } from "./store/useSettingsStore"; 9 | import { authService } from "../api"; 10 | 11 | // Global variable to prevent multiple initialization 12 | let globalAuthInitialized = false; 13 | 14 | // Re-export the provider for convenience 15 | export { StoreProvider } from "./store/StoreContext.tsx"; 16 | 17 | /** 18 | * Custom hook for accessing and manipulating the global store 19 | * Provides combined access to both auth and settings state 20 | */ 21 | export const useStore = () => { 22 | const { store, setStore } = useContext(StoreContext); 23 | 24 | // Get authentication and settings functions from separated stores 25 | const auth = useAuthStore(); 26 | const settings = useSettingsStore(); 27 | 28 | // Initialize auth state from localStorage 29 | useEffect(() => { 30 | // CRITICAL: Skip initialization if already done 31 | // This prevents infinite loops when store updates trigger re-renders 32 | if (globalAuthInitialized) { 33 | return; 34 | } 35 | 36 | // Mark as initialized globally 37 | globalAuthInitialized = true; 38 | 39 | const initializeAuthState = async () => { 40 | auth.setAuthLoading(true); 41 | // Initialize authentication state by retrieving tokens from localStorage 42 | 43 | const accessToken = localStorage.getItem("access_token"); 44 | const refreshToken = localStorage.getItem("refresh_token"); 45 | const expiresAt = localStorage.getItem("expires_at"); 46 | const userProfileStr = localStorage.getItem("user_profile"); 47 | if (!accessToken || !refreshToken || !expiresAt) { 48 | // No authentication tokens found, abort initialization 49 | auth.setAuthLoading(false); 50 | return; 51 | } 52 | // Authentication tokens found in localStorage 53 | 54 | // Try to parse stored user profile if it exists 55 | let storedUserProfile: any = null; 56 | if (userProfileStr) { 57 | try { 58 | // Successfully parsed user profile from local storage 59 | storedUserProfile = JSON.parse(userProfileStr); 60 | } catch (e) { 61 | console.error("Failed to parse stored user profile:", e); 62 | } 63 | } 64 | 65 | // Update store with saved tokens and user profile 66 | setStore((currentStore) => ({ 67 | ...currentStore, 68 | auth: { 69 | ...currentStore.auth, 70 | access_token: accessToken, 71 | refresh_token: refreshToken, 72 | expires_at: parseInt(expiresAt), 73 | isAuthenticated: true, 74 | user: storedUserProfile, 75 | }, 76 | })); 77 | 78 | // Authentication state updated in store with user profile and tokens 79 | 80 | // Check if token is expired or will expire soon (5 minutes buffer) 81 | const expiresAtNum = parseInt(expiresAt); 82 | const isExpiringSoon = expiresAtNum < Date.now() + 5 * 60 * 1000; 83 | try { 84 | if (isExpiringSoon) { 85 | // Token is about to expire, initiating refresh process 86 | await auth.refreshAuth(); 87 | } else if (!storedUserProfile) { 88 | // No user profile in cache, fetching from API 89 | const userProfile = await authService.getUserProfile(accessToken); 90 | auth.setUserProfile(userProfile); 91 | } 92 | // Auth initialization completed successfully 93 | auth.setAuthLoading(false); 94 | } catch (error) { 95 | console.error("Error during auth initialization:", error); 96 | // If there's an error, attempt to refresh the token 97 | if (!isExpiringSoon) { 98 | await auth.refreshAuth(); 99 | } else { 100 | auth.setAuthLoading(false); 101 | } 102 | } 103 | }; 104 | 105 | initializeAuthState(); 106 | // Only include dependencies that won't change with every render 107 | // eslint-disable-next-line react-hooks/exhaustive-deps 108 | }, []); 109 | // Return combined store functions and properties 110 | return { 111 | store, 112 | setStore, 113 | 114 | // Auth state and methods 115 | auth: store.auth, 116 | setAuthTokens: auth.setAuthTokens, 117 | setUserProfile: auth.setUserProfile, 118 | setAuthError: auth.setAuthError, 119 | setAuthLoading: auth.setAuthLoading, 120 | handleCodeExchange: auth.handleCodeExchange, 121 | refreshAuth: auth.refreshAuth, 122 | checkTokenExpiration: auth.checkTokenExpiration, 123 | logout: auth.logout, 124 | handleAuthError: auth.handleAuthError, 125 | 126 | // Settings state and methods 127 | theme: store.theme, 128 | layout: store.layout, 129 | sortBy: store.sortBy, 130 | showImages: store.showImages, 131 | compactText: store.compactText, 132 | blurNSFW: store.blurNSFW, 133 | showFilters: store.showFilters, 134 | changeTheme: settings.changeTheme, 135 | changeLayout: settings.changeLayout, 136 | changeSortBy: settings.changeSortBy, 137 | toggleShowImages: settings.toggleShowImages, 138 | toggleCompactText: settings.toggleCompactText, 139 | toggleBlurNSFW: settings.toggleBlurNSFW, 140 | toggleFiltersVisibility: settings.toggleFiltersVisibility, 141 | }; 142 | }; 143 | -------------------------------------------------------------------------------- /app/src/index.scss: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "IBMPlexSans"; 3 | font-weight: 400; 4 | font-display: "swap"; 5 | font-style: "normal"; 6 | src: url("../src/assets/fonts/IBMPlexSans-Regular.ttf") format("truetype"); 7 | } 8 | 9 | @font-face { 10 | font-family: "IBMPlexSans"; 11 | font-weight: 500; 12 | font-display: "swap"; 13 | font-style: "normal"; 14 | src: url("../src/assets/fonts/IBMPlexSans-Medium.ttf") format("truetype"); 15 | } 16 | 17 | @font-face { 18 | font-family: "IBMPlexSans"; 19 | font-weight: 600; 20 | font-display: "swap"; 21 | font-style: "normal"; 22 | src: url("../src/assets/fonts/IBMPlexSans-SemiBold.ttf") format("truetype"); 23 | } 24 | 25 | * { 26 | font-family: IBMPlexSans, Arial, sans-serif; 27 | margin: 0; 28 | padding: 0; 29 | box-sizing: border-box; 30 | } 31 | 32 | :root { 33 | --orange: #ff4500; 34 | --border-radius: 4px; 35 | --btn-border-radius: 9999px; 36 | --btn-hover-color: #ef6a3a; 37 | --bg-selected: #3a536b; 38 | --header-height: 60px; /* Default header height */ 39 | 40 | .light { 41 | --bg-primary: #dae0e6; 42 | --bg-secondary: #fff; 43 | --border-color: #ccc; 44 | --border: 1px solid var(--border-color); 45 | --color-primary: #222222; 46 | --color-secondary: #787c7e; 47 | } 48 | .dark { 49 | --bg-primary: #030303; 50 | --bg-secondary: #1a1a1b; 51 | --border-color: #343536; 52 | --border: 1px solid var(--border-color); 53 | --color-primary: #d7dadc; 54 | --color-secondary: #818384; 55 | } 56 | } 57 | 58 | body { 59 | background-color: var(--bg-primary); 60 | color: var(--color-primary); 61 | //height: 100%; 62 | } 63 | 64 | // format scrollbar 65 | ::-webkit-scrollbar { 66 | width: 8px; 67 | height: 8px; 68 | scrollbar-width: thin; 69 | scrollbar-gutter: stable; 70 | } 71 | 72 | ::-webkit-scrollbar-thumb { 73 | background-color: var(--border-color); /* Thumb color */ 74 | border-radius: 4px; /* Rounded corners for the thumb */ 75 | } 76 | 77 | ::-webkit-scrollbar-track { 78 | background-color: var(--bg-secondary); /* Track color */ 79 | } 80 | 81 | .styled-scrollbars { 82 | scrollbar-width: thin; 83 | scrollbar-gutter: stable; 84 | //scrollbar-background: #0e1113; 85 | scrollbar-color: var(--border-color) var(--btn-hover-color); /* Set visible colors */ 86 | } 87 | 88 | hr { 89 | border: none; 90 | height: 0.3px; 91 | background-color: var(--border-color); 92 | margin: 1rem 0; 93 | } 94 | 95 | input, 96 | textarea { 97 | padding: 0.5rem; 98 | border: 1px solid var(--border-color); 99 | border-radius: var(--border-radius); 100 | background-color: var(--bg-primary); 101 | width: 100%; 102 | color: var(--color-primary); 103 | margin-bottom: 1rem; 104 | //font-weight: 400; 105 | //font-size: 1.25rem; 106 | outline: unset; 107 | &:focus { 108 | outline: 2px solid var(--btn-hover-color); 109 | } 110 | &::placeholder { 111 | color: var(--color-secondary); 112 | } 113 | } 114 | 115 | /* body { 116 | color: var(--color); 117 | //scroll-snap-type: y mandatory; 118 | //height: 100vh; 119 | //overflow: hidden; 120 | color: var(--color-primary); 121 | background-color: var(--background-primary); 122 | -webkit-font-smoothing: antialiased; 123 | -moz-osx-font-smoothing: grayscale; 124 | } 125 | 126 | h1 { 127 | margin-bottom: 2rem; 128 | } 129 | 130 | label { 131 | color: var(--color-tertiary); 132 | font-size: 12px; 133 | font-weight: 700; 134 | //border-radius: 0.25rem; 135 | //border: 1px solid dodgerblue; 136 | //padding: 0.5rem; 137 | } 138 | 139 | a { 140 | text-decoration: none; 141 | padding: unset; 142 | margin: unset; 143 | } 144 | 145 | input, 146 | textarea { 147 | padding: 0.5rem; 148 | border: 1px solid var(--border-color); 149 | border-radius: 0.25rem; 150 | background-color: var(--input-background); 151 | width: 100%; 152 | color: var(--color-primary); 153 | //font-weight: 400; 154 | //font-size: 1.25rem; 155 | outline: unset; 156 | &:focus { 157 | outline: 2px solid var(--border-color-focus); 158 | } 159 | &::placeholder { 160 | color: var(--color-tertiary); 161 | } 162 | } 163 | 164 | textarea { 165 | resize: vertical; 166 | min-height: 5rem; 167 | //max-height: 6rem; 168 | } 169 | 170 | 171 | 172 | */ 173 | 174 | button { 175 | height: 32px; 176 | cursor: pointer; 177 | display: flex; 178 | align-items: center; 179 | justify-content: center; 180 | gap: 0.5rem; 181 | color: #fff; 182 | border-radius: var(--btn-border-radius); 183 | background-color: var(--orange); 184 | font-weight: 600; 185 | font-size: 24px; 186 | border: none; 187 | padding: 1.5rem 3rem; 188 | &:hover { 189 | background-color: var(--btn-hover-color); 190 | } 191 | } 192 | 193 | .btn-icon { 194 | background-color: unset; 195 | color: var(--color-primary); 196 | border: unset; 197 | padding: unset; 198 | &:hover { 199 | color: var(--btn-hover-color); 200 | background-color: transparent; 201 | box-shadow: unset; 202 | } 203 | &:focus { 204 | // added to prevent outline on focus after press esc to close modal 205 | outline: unset; 206 | } 207 | } 208 | 209 | /* .btn-secondary { 210 | background-color: transparent; 211 | } */ 212 | 213 | /* Media queries for responsive layout */ 214 | @media (max-width: 768px) { 215 | :root { 216 | --header-height: 55px; /* Slightly smaller header on tablets */ 217 | } 218 | } 219 | 220 | @media (max-width: 480px) { 221 | :root { 222 | --header-height: 50px; /* Even smaller header on mobile phones */ 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /server/src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Logger utility for server logging functionality 3 | * Provides structured logging for requests, responses, and errors 4 | */ 5 | 6 | // Log levels 7 | enum LogLevel { 8 | INFO = "INFO", 9 | WARN = "WARN", 10 | ERROR = "ERROR", 11 | DEBUG = "DEBUG", 12 | } 13 | 14 | // Log entry structure 15 | interface LogEntry { 16 | timestamp: string; 17 | level: LogLevel; 18 | message: string; 19 | context?: any; 20 | } 21 | 22 | /** 23 | * Format a log entry into a consistent string format 24 | */ 25 | const formatLogEntry = (entry: LogEntry): string => { 26 | const { timestamp, level, message, context } = entry; 27 | 28 | // Basic formatted log message 29 | let formattedMessage = `[${timestamp}] ${level}: ${message}`; 30 | 31 | // Add stringified context if provided 32 | if (context) { 33 | try { 34 | if (typeof context === "object") { 35 | // Remove sensitive data like tokens before logging 36 | const sanitizedContext = { ...context }; 37 | if (sanitizedContext.headers) { 38 | if (sanitizedContext.headers.authorization) { 39 | sanitizedContext.headers.authorization = "[REDACTED]"; 40 | } 41 | if (sanitizedContext.headers.accessToken) { 42 | sanitizedContext.headers.accessToken = "[REDACTED]"; 43 | } 44 | } 45 | formattedMessage += `\nContext: ${JSON.stringify( 46 | sanitizedContext, 47 | null, 48 | 2 49 | )}`; 50 | } else { 51 | formattedMessage += `\nContext: ${context}`; 52 | } 53 | } catch (err) { 54 | formattedMessage += `\nContext: [Error serializing context]`; 55 | } 56 | } 57 | 58 | return formattedMessage; 59 | }; 60 | 61 | /** 62 | * Create a log entry with current timestamp 63 | */ 64 | const createLogEntry = ( 65 | level: LogLevel, 66 | message: string, 67 | context?: any 68 | ): LogEntry => { 69 | return { 70 | timestamp: new Date().toISOString(), 71 | level, 72 | message, 73 | context, 74 | }; 75 | }; 76 | 77 | /** 78 | * Log an informational message (requests, successful operations) 79 | */ 80 | export const logInfo = (message: string, context?: any): void => { 81 | const entry = createLogEntry(LogLevel.INFO, message, context); 82 | const formattedLog = formatLogEntry(entry); 83 | console.log(formattedLog); 84 | }; 85 | 86 | /** 87 | * Log a warning message (potential issues, rate limiting) 88 | */ 89 | export const logWarn = (message: string, context?: any): void => { 90 | const entry = createLogEntry(LogLevel.WARN, message, context); 91 | const formattedLog = formatLogEntry(entry); 92 | console.warn(formattedLog); 93 | }; 94 | 95 | /** 96 | * Log an error message (failures, exceptions) 97 | */ 98 | export const logError = (message: string, error?: any, context?: any): void => { 99 | // Combine error information with context 100 | let combinedContext = context || {}; 101 | 102 | if (error) { 103 | // Extract useful information from error objects 104 | if (error instanceof Error) { 105 | combinedContext = { 106 | ...combinedContext, 107 | errorName: error.name, 108 | errorMessage: error.message, 109 | stack: error.stack, 110 | }; 111 | } else { 112 | combinedContext = { 113 | ...combinedContext, 114 | error, 115 | }; 116 | } 117 | } 118 | 119 | const entry = createLogEntry(LogLevel.ERROR, message, combinedContext); 120 | const formattedLog = formatLogEntry(entry); 121 | console.error(formattedLog); 122 | }; 123 | 124 | /** 125 | * Log debugging information (only in development) 126 | */ 127 | export const logDebug = (message: string, context?: any): void => { 128 | // Only log debug messages in development environment 129 | if (process.env.NODE_ENV !== "production") { 130 | const entry = createLogEntry(LogLevel.DEBUG, message, context); 131 | const formattedLog = formatLogEntry(entry); 132 | console.debug(formattedLog); 133 | } 134 | }; 135 | 136 | /** 137 | * Request logger middleware for Express 138 | */ 139 | export const requestLogger = (req: any, res: any, next: any): void => { 140 | const startTime = Date.now(); 141 | 142 | // Log incoming request 143 | logInfo(`${req.method} ${req.originalUrl}`, { 144 | method: req.method, 145 | url: req.originalUrl, 146 | query: req.query, 147 | body: req.method !== "GET" ? req.body : undefined, 148 | ip: req.ip, 149 | userAgent: req.get("User-Agent"), 150 | }); 151 | 152 | // Capture response info after request completes 153 | res.on("finish", () => { 154 | const duration = Date.now() - startTime; 155 | 156 | const logContext = { 157 | method: req.method, 158 | url: req.originalUrl, 159 | statusCode: res.statusCode, 160 | duration: `${duration}ms`, 161 | }; 162 | 163 | if (res.statusCode >= 400) { 164 | // For error responses 165 | logError( 166 | `${req.method} ${req.originalUrl} responded ${res.statusCode} (${duration}ms)`, 167 | null, 168 | logContext 169 | ); 170 | } else { 171 | // For successful responses 172 | logInfo( 173 | `${req.method} ${req.originalUrl} responded ${res.statusCode} (${duration}ms)`, 174 | logContext 175 | ); 176 | } 177 | }); 178 | 179 | next(); 180 | }; 181 | 182 | // Default export for convenience 183 | export default { 184 | info: logInfo, 185 | warn: logWarn, 186 | error: logError, 187 | debug: logDebug, 188 | requestLogger, 189 | }; 190 | -------------------------------------------------------------------------------- /app/src/components/Filters.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | background-color: var(--bg-secondary); 3 | display: flex; 4 | flex-direction: column; 5 | //align-items: center; 6 | gap: 0.25rem; 7 | border-right: var(--border); 8 | padding: 1rem 0.5rem; 9 | width: 100%; 10 | height: 100%; /* Full height */ 11 | overflow-y: auto; /* Allow scrolling if content overflows */ 12 | position: relative; 13 | } 14 | 15 | .header { 16 | display: flex; 17 | align-items: center; 18 | justify-content: space-between; 19 | width: 100%; 20 | border-radius: var(--border-radius); 21 | margin-bottom: 0.5rem; 22 | padding: 0.5rem; 23 | 24 | &:hover { 25 | cursor: pointer; 26 | background-color: var(--bg-primary); 27 | } 28 | 29 | p { 30 | font-size: small; 31 | font-weight: 100; 32 | color: var(--color-secondary); 33 | } 34 | } 35 | 36 | /* Media queries for responsive layout */ 37 | @media (max-width: 768px) { 38 | .root { 39 | padding: 0.75rem 0.5rem; 40 | } 41 | 42 | .header { 43 | padding: 0.4rem; 44 | margin-bottom: 0.4rem; 45 | } 46 | } 47 | 48 | @media (max-width: 480px) { 49 | .overlay { 50 | display: none; /* Hidden by default */ 51 | } 52 | 53 | .active { 54 | display: block !important; /* Show when active - important to override */ 55 | opacity: 1 !important; 56 | visibility: visible !important; 57 | } 58 | .root { 59 | position: fixed; /* Position fixed for overlay effect */ 60 | top: 0; /* Below the header */ 61 | left: 0; 62 | bottom: 0; 63 | width: 100%; /* Width of the sidebar */ 64 | /* max-width: 300px; */ 65 | z-index: 1000; /* High z-index to appear above content */ 66 | box-shadow: 2px 0 10px rgba(0, 0, 0, 0.2); 67 | transform: translateX(-105%); /* Start offscreen with extra margin */ 68 | transition: transform 0.3s ease, visibility 0.3s ease; 69 | background-color: var(--bg-secondary); /* Ensure background color is set */ 70 | overflow-y: auto; /* Enable scrolling */ 71 | border-right: var(--border); 72 | visibility: hidden; /* Hide by default */ 73 | } 74 | 75 | .mobileVisible { 76 | transform: translateX(0) !important; /* Force slide in */ 77 | visibility: visible !important; /* Show when active */ 78 | } 79 | } 80 | 81 | .item { 82 | display: flex; 83 | align-items: center; 84 | width: 100%; 85 | border-radius: var(--border-radius); 86 | padding: 0.5rem; 87 | gap: 0.5rem; 88 | transition: background-color 0.2s ease; 89 | margin: 5px 0px; 90 | &:hover { 91 | cursor: pointer; 92 | background-color: var(--bg-primary); 93 | } 94 | 95 | h4 { 96 | font-size: medium; 97 | font-weight: 400; 98 | color: var(--color-primary); 99 | } 100 | p { 101 | font-size: small; 102 | font-weight: 100; 103 | color: var(--color-secondary); 104 | } 105 | } 106 | 107 | .fadeIn { 108 | animation: fadeIn 0.3s ease-in-out; 109 | } 110 | 111 | .fadeOut { 112 | animation: fadeOut 0.3s ease-in-out; 113 | } 114 | 115 | @keyframes fadeIn { 116 | from { 117 | opacity: 0; 118 | } 119 | to { 120 | opacity: 1; 121 | } 122 | } 123 | 124 | @keyframes fadeOut { 125 | from { 126 | opacity: 1; 127 | } 128 | to { 129 | opacity: 0; 130 | } 131 | } 132 | 133 | .clearFilters { 134 | margin-top: 2rem; 135 | cursor: pointer; 136 | display: flex; 137 | align-items: center; 138 | justify-content: center; 139 | gap: 0.5rem; 140 | color: #fff; 141 | border-radius: var(--btn-border-radius); 142 | background-color: var(--orange); 143 | font-weight: 600; 144 | font-size: 16px; 145 | border: none; 146 | padding: 0.75rem 1rem; 147 | width: fit-content; 148 | &:hover { 149 | background-color: var(--btn-hover-color); 150 | } 151 | } 152 | 153 | .actionButtons { 154 | display: flex; 155 | flex-direction: column; 156 | align-items: end; 157 | gap: 0.5rem; 158 | } 159 | 160 | .hideButton { 161 | cursor: pointer; 162 | display: flex; 163 | align-items: center; 164 | justify-content: center; 165 | color: var(--color-primary); 166 | border-radius: var(--btn-border-radius); 167 | background-color: transparent; 168 | font-weight: 500; 169 | font-size: 14px; 170 | border: 1px solid var(--border-color); 171 | padding: 0.75rem 1rem; 172 | width: fit-content; 173 | &:hover { 174 | background-color: var(--bg-primary); 175 | } 176 | } 177 | 178 | .totalPosts { 179 | display: flex; 180 | align-items: center; 181 | justify-content: space-between; 182 | padding: 0.2rem 1rem; 183 | margin-bottom: 0.75rem; 184 | border-bottom: 1px solid var(--border-color); 185 | text-align: center; 186 | opacity: 0.85; 187 | } 188 | 189 | .totalPostsInfo { 190 | display: flex; 191 | align-items: center; 192 | gap: 0.25rem; 193 | } 194 | 195 | .totalPostsCount { 196 | font-size: 1rem; 197 | font-weight: 500; 198 | color: var(--color-primary); 199 | } 200 | 201 | .totalPostsLabel { 202 | font-size: 0.875rem; 203 | color: var(--color-secondary); 204 | } 205 | 206 | .refreshIcon { 207 | width: 16px; 208 | height: 16px; 209 | display: flex; 210 | align-items: center; 211 | justify-content: center; 212 | 213 | svg { 214 | width: 100%; 215 | height: 100%; 216 | } 217 | } 218 | 219 | .mobileVisible { 220 | transform: translateX(0) !important; /* Force slide in */ 221 | display: flex !important; /* Ensure it's displayed */ 222 | visibility: visible !important; /* Ensure visibility */ 223 | opacity: 1 !important; /* Ensure opacity */ 224 | } 225 | 226 | /* Mobile specific rules are now handled in MobileFilters.module.scss */ 227 | -------------------------------------------------------------------------------- /app/src/components/Post.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | padding: 0.5rem 1rem; 3 | width: 100%; 4 | box-sizing: border-box; 5 | max-width: 100%; 6 | } 7 | 8 | /* Only apply hover effect on non-mobile devices */ 9 | @media (min-width: 769px) { 10 | .root:hover { 11 | background-color: var(--bg-secondary); 12 | border-radius: var(--border-radius); 13 | } 14 | } 15 | 16 | .header { 17 | /* display: flex; 18 | justify-content: space-between; 19 | align-items: center; */ 20 | margin-bottom: 0.5rem; 21 | 22 | h5 { 23 | a { 24 | text-decoration: none; 25 | color: var(--color-primary); 26 | font-weight: 500; 27 | font-size: normal; 28 | } 29 | span { 30 | font-size: small; 31 | font-weight: 100; 32 | color: var(--color-secondary); 33 | } 34 | } 35 | 36 | h6 { 37 | a { 38 | text-decoration: none; 39 | color: var(--color-secondary); 40 | font-weight: 500; 41 | font-size: normal; 42 | } 43 | } 44 | } 45 | 46 | // Compact title styles 47 | .compactTitle { 48 | display: -webkit-box; 49 | -webkit-line-clamp: 3; 50 | -webkit-box-orient: vertical; 51 | overflow: hidden; 52 | text-overflow: ellipsis; 53 | } 54 | 55 | .nsfw { 56 | color: red; 57 | border: 1px solid red; 58 | display: flex; 59 | align-items: center; 60 | gap: 0.2rem; 61 | padding: 0.1rem 0.2rem; 62 | //use only needed width 63 | width: fit-content; 64 | border-radius: var(--border-radius); 65 | margin-bottom: 0.5rem; 66 | //dont allow to select text 67 | user-select: none; 68 | 69 | .warningIcon { 70 | width: 12px; 71 | height: 12px; 72 | display: flex; 73 | align-items: center; 74 | justify-content: center; 75 | 76 | svg { 77 | width: 100%; 78 | height: 100%; 79 | } 80 | } 81 | 82 | span { 83 | font-size: x-small; 84 | font-weight: 100; 85 | color: red; 86 | } 87 | } 88 | 89 | .descriptionContainer { 90 | display: flex; 91 | margin-bottom: 1rem; 92 | 93 | .commentLine { 94 | display: grid; 95 | grid-template: 1fr 1fr / 1fr 1fr; 96 | //align-items: center; 97 | height: inherit; /* Ensure it inherits the height of .description */ 98 | width: 50px; 99 | padding: 3px; 100 | 101 | /* .commentLineTopLeft { 102 | border-left: 2px solid #ff6347; 103 | border-top: 2px solid #ff6347; 104 | } */ 105 | 106 | .commentLineTopRight { 107 | border-left: 1px solid var(--color-secondary); 108 | border-bottom: 1px solid var(--color-secondary); 109 | border-radius: 0px 0px 0px 6px; 110 | } 111 | 112 | /* .commentLineBottomLeft { 113 | border-left: 2px solid #32cd32; 114 | border-bottom: 2px solid #32cd32; 115 | } */ 116 | 117 | /* .commentLineBottomRight { 118 | border-right: 2px solid #ffd700; 119 | border-bottom: 2px solid #ffd700; 120 | } */ 121 | } 122 | 123 | .description { 124 | flex: 1; 125 | color: var(--color-secondary) !important; 126 | } 127 | 128 | // Compact description styles 129 | .compactDescription { 130 | display: -webkit-box; 131 | -webkit-line-clamp: 15; 132 | -webkit-box-orient: vertical; 133 | overflow: hidden; 134 | text-overflow: ellipsis; 135 | } 136 | } 137 | 138 | .thumbnail { 139 | width: 100%; 140 | max-height: 500px; /* Limit maximum height */ 141 | object-fit: contain; /* Change from cover to contain to preserve aspect ratio */ 142 | border-radius: 8px; 143 | margin-bottom: 0.75rem; 144 | display: block; 145 | } 146 | 147 | .bottom { 148 | display: flex; 149 | justify-content: space-between; 150 | align-items: center; 151 | 152 | a { 153 | text-decoration: none; 154 | color: var(--color-primary); 155 | } 156 | 157 | .stats { 158 | display: flex; 159 | align-items: center; 160 | justify-content: center; 161 | gap: 1rem; 162 | 163 | .stat { 164 | display: flex; 165 | align-items: center; 166 | gap: 0.2rem; 167 | 168 | .icon { 169 | color: var(--btn-hover-color); 170 | width: 16px; 171 | height: 16px; 172 | display: flex; 173 | align-items: center; 174 | justify-content: center; 175 | 176 | svg { 177 | width: 100%; 178 | height: 100%; 179 | } 180 | } 181 | 182 | span { 183 | font-size: small; 184 | font-weight: 100; 185 | } 186 | } 187 | } 188 | } 189 | 190 | .options { 191 | display: flex; 192 | gap: 1rem; 193 | 194 | .unsave { 195 | svg { 196 | fill: var(--color-primary); 197 | &:hover { 198 | fill: none; 199 | } 200 | } 201 | } 202 | } 203 | 204 | /* Media queries for responsive layout */ 205 | @media (max-width: 768px) { 206 | .root { 207 | padding: 0.5rem 0.75rem; 208 | } 209 | 210 | .postContent { 211 | font-size: 0.9rem; 212 | } 213 | 214 | .postInfosContainer { 215 | flex-wrap: wrap; 216 | gap: 0.3rem; 217 | } 218 | } 219 | 220 | @media (max-width: 480px) { 221 | .root { 222 | padding: 0.5rem; 223 | width: 100%; 224 | max-width: 100vw; 225 | overflow-x: hidden; 226 | } 227 | 228 | /* Explicitly override hover effect for mobile */ 229 | .root:hover { 230 | background-color: transparent; 231 | border-radius: 0; 232 | } 233 | 234 | .header h5 a { 235 | font-size: 0.95rem; 236 | } 237 | 238 | .header h6 { 239 | font-size: 0.75rem; 240 | } 241 | 242 | .postContent { 243 | font-size: 0.85rem; 244 | max-width: 100%; 245 | word-break: break-word; 246 | } 247 | 248 | .actions { 249 | gap: 0.5rem; 250 | } 251 | .postInfosContainer { 252 | font-size: 0.75rem; 253 | } 254 | } 255 | 256 | /* Extra small devices */ 257 | @media (max-width: 360px) { 258 | /* Ensure no hover effects on tiny screens */ 259 | .root:hover { 260 | background-color: transparent; 261 | border-radius: 0; 262 | } 263 | } 264 | -------------------------------------------------------------------------------- /app/src/pages/Home.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useEffect } from "react"; 2 | import { useNavigate } from "react-router-dom"; 3 | import styles from "./Home.module.scss"; 4 | import { Sun } from "../components/icons/Sun"; 5 | import { useStore } from "../hooks/useStore"; 6 | import { Moon } from "../components/icons/Moon"; 7 | import { Search } from "../components/icons/Search"; 8 | import { Refresh } from "../components/icons/Refresh"; 9 | import { Chart } from "../components/icons/Chart"; 10 | import { Lock } from "../components/icons/Lock"; 11 | import LOGO from "../assets/images/logo.svg"; 12 | import LOGO_WHITE from "../assets/images/logo_white.svg"; 13 | import MOCKUP_DESKTOP from "../assets/demo/mockup_desktop.png"; 14 | import MOCKUP_MOBILE from "../assets/demo/mockup_mobile.png"; 15 | import { authService } from "../api"; 16 | import { FeatureCard } from "../components/ui/FeatureCard"; 17 | 18 | export const Home: FC = () => { 19 | const { store, changeTheme } = useStore(); 20 | const navigate = useNavigate(); 21 | 22 | // Redirect to posts page if already authenticated 23 | useEffect(() => { 24 | if (store.auth.isAuthenticated && !store.auth.isLoading) { 25 | navigate("/posts"); 26 | } 27 | }, [store.auth.isAuthenticated, store.auth.isLoading, navigate]); 28 | 29 | const handleLogin = () => { 30 | const loginUrl = authService.getLoginUrl(); 31 | window.location.href = loginUrl; 32 | }; 33 | 34 | // Show loading spinner while checking auth status 35 | if (store.auth.isLoading) { 36 | return ( 37 |
38 |
39 |
40 | ); 41 | } 42 | 43 | return ( 44 |
45 |
46 | 49 |
50 | 51 |
52 |
53 | Bookmarkeddit logo 58 |

59 | Reddit's missing save manager: Finally organize what matters to you 60 |

61 |

62 | Organize, filter, and rediscover your saved Reddit content with ease 63 |

64 | 65 | 68 |
69 | 70 |
71 |

Key Features

72 |
73 | } 75 | title="Smart Search" 76 | description="Find anything in your saved content with powerful search capabilities" 77 | /> 78 | } 80 | title="Sync & Organize" 81 | description="Keep your Reddit saves organized and easily accessible" 82 | /> 83 | } 85 | title="Smart Filters" 86 | description="Filter by subreddit, post type, or content to find exactly what you need" 87 | /> 88 | } 90 | title="Privacy First" 91 | description="Your data never leaves your browser - complete privacy guaranteed" 92 | /> 93 |
94 |
95 | 96 |
97 |

App Preview

98 |
99 | Desktop preview of Bookmarkeddit showing saved Reddit posts 104 | Mobile preview of Bookmarkeddit interface displaying organized Reddit saves 109 |
110 | 126 |
127 | 128 |
129 |

Required Permissions

130 |
131 |
132 | identity 133 | 134 | To see your username and profile picture 135 | 136 |
137 |
138 | history 139 | 140 | To access your saved posts 141 | 142 |
143 |
144 | save 145 | 146 | To unsave posts when requested 147 | 148 |
149 |
150 |

151 | Your data never leaves your browser. We don't store any of your 152 | Reddit information on our servers. 153 |

154 |
155 | 156 | 177 |
178 |
179 | ); 180 | }; 181 | -------------------------------------------------------------------------------- /app/src/pages/Posts.module.scss: -------------------------------------------------------------------------------- 1 | /* Add styles for the main layout */ 2 | .root { 3 | display: flex; 4 | flex-direction: column; 5 | height: calc( 6 | 100vh - var(--header-height, 60px) 7 | ); /* Use CSS variable with fallback */ 8 | width: 100%; 9 | box-sizing: border-box; 10 | overflow-x: hidden; /* Prevent horizontal scrolling */ 11 | } 12 | 13 | /* Background fetching and new posts notification styles */ 14 | .notificationBar { 15 | width: 100%; 16 | display: flex; 17 | justify-content: center; 18 | align-items: center; 19 | padding: 8px; 20 | background-color: var(--bg-secondary); 21 | border-bottom: var(--border); 22 | z-index: 10; 23 | } 24 | 25 | .backgroundFetching { 26 | display: flex; 27 | align-items: center; 28 | font-size: 0.9rem; 29 | color: var(--color-secondary); 30 | } 31 | 32 | .fetchingSpinner { 33 | width: 16px; 34 | height: 16px; 35 | border: 2px solid transparent; 36 | border-top-color: var(--color-primary); 37 | border-radius: 50%; 38 | margin-right: 8px; 39 | animation: spin 1s linear infinite; 40 | } 41 | 42 | @keyframes spin { 43 | 0% { 44 | transform: rotate(0deg); 45 | } 46 | 100% { 47 | transform: rotate(360deg); 48 | } 49 | } 50 | 51 | .newPostsNotification { 52 | display: flex; 53 | align-items: center; 54 | } 55 | 56 | .updateButton { 57 | background-color: var(--color-primary); 58 | color: white; 59 | border: none; 60 | border-radius: 4px; 61 | padding: 6px 12px; 62 | font-size: 0.9rem; 63 | cursor: pointer; 64 | transition: background-color 0.2s; 65 | 66 | &:hover { 67 | background-color: var(--color-primary-hover); 68 | } 69 | } 70 | 71 | /* Main content container */ 72 | .mainContent { 73 | display: flex; 74 | flex: 1; 75 | height: calc(100% - 40px); /* Adjust for notification bar */ 76 | width: 100%; 77 | max-width: 100%; 78 | } 79 | 80 | /* Update styles to ensure both Filters and PostsList use 100% height and scroll if content overflows */ 81 | .filters { 82 | width: 250px; /* Fixed width for the sidebar */ 83 | height: 100%; /* Ensure full height */ 84 | } 85 | 86 | /* Filter toggle button when filters are hidden */ 87 | .filtersToggle { 88 | display: flex; 89 | flex-direction: column; 90 | justify-content: center; 91 | align-items: center; 92 | width: 30px; 93 | background-color: var(--bg-secondary); 94 | border-right: var(--border); 95 | height: 100%; 96 | 97 | .toggleIcon { 98 | font-size: 16px; 99 | //color: var(--color-secondary); 100 | } 101 | 102 | /* button { 103 | padding: 6px; 104 | &:hover { 105 | background-color: var(--bg-primary); 106 | } 107 | } */ 108 | } 109 | 110 | .postsList { 111 | flex: 1; /* Take the remaining space */ 112 | height: 100%; /* Ensure full height */ 113 | display: flex; 114 | flex-direction: column; 115 | width: 100%; /* Ensure full width */ 116 | max-width: 100%; /* Prevent overflow */ 117 | } 118 | 119 | /* Error state styling */ 120 | .errorContainer { 121 | display: flex; 122 | flex-direction: column; 123 | align-items: center; 124 | justify-content: center; 125 | padding: 2rem; 126 | text-align: center; 127 | height: calc(100vh - 60px); 128 | 129 | .errorMessage { 130 | color: var(--btn-hover-color); 131 | background-color: rgba(255, 69, 0, 0.1); 132 | border-radius: 8px; 133 | border: 1px solid var(--btn-hover-color); 134 | padding: 1rem 2rem; 135 | margin-bottom: 1.5rem; 136 | max-width: 600px; 137 | } 138 | 139 | .retryButton { 140 | height: auto; 141 | font-size: 1rem; 142 | padding: 0.75rem 2rem; 143 | } 144 | } 145 | 146 | /* Empty state styling */ 147 | .emptyState { 148 | display: flex; 149 | flex-direction: column; 150 | align-items: center; 151 | justify-content: center; 152 | padding: 2rem; 153 | text-align: center; 154 | height: calc(100vh - 60px); 155 | 156 | h2 { 157 | color: var(--color-primary); 158 | margin-bottom: 1rem; 159 | } 160 | 161 | p { 162 | color: var(--color-secondary); 163 | } 164 | } 165 | 166 | /* Rate limit retry UI */ 167 | .retryContainer { 168 | display: flex; 169 | flex-direction: column; 170 | align-items: center; 171 | justify-content: center; 172 | gap: 1.5rem; 173 | height: calc(100vh - 60px); 174 | background-color: var(--bg-primary); 175 | padding: 2rem; 176 | } 177 | 178 | .retryMessage { 179 | background-color: var(--bg-secondary); 180 | padding: 2rem; 181 | border-radius: 8px; 182 | border: var(--border); 183 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 184 | text-align: center; 185 | max-width: 500px; 186 | 187 | h3 { 188 | color: var(--color-primary); 189 | margin-bottom: 1rem; 190 | font-size: 1.5rem; 191 | } 192 | 193 | p { 194 | color: var(--color-secondary); 195 | margin-bottom: 1.5rem; 196 | font-size: 1rem; 197 | line-height: 1.5; 198 | } 199 | } 200 | 201 | .retryProgress { 202 | width: 100%; 203 | height: 10px; 204 | background-color: var(--bg-primary); 205 | border-radius: 5px; 206 | margin-bottom: 1.5rem; 207 | overflow: hidden; 208 | 209 | .retryProgressBar { 210 | height: 100%; 211 | background-color: var(--orange); 212 | transition: width 1s linear; 213 | } 214 | } 215 | 216 | .retryButtons { 217 | display: flex; 218 | gap: 1rem; 219 | justify-content: center; 220 | margin-top: 0.5rem; 221 | 222 | button { 223 | padding: 0.75rem 1.5rem; 224 | font-size: 1rem; 225 | height: auto; 226 | } 227 | 228 | .retryNowButton { 229 | background-color: var(--orange); 230 | 231 | &:hover { 232 | background-color: var(--btn-hover-color); 233 | } 234 | } 235 | 236 | .cancelButton { 237 | background-color: transparent; 238 | border: 1px solid var(--color-secondary); 239 | color: var(--color-primary); 240 | 241 | &:hover { 242 | background-color: var(--bg-primary); 243 | border-color: var(--color-primary); 244 | } 245 | } 246 | } 247 | 248 | /* Media queries for responsive layout */ 249 | @media (max-width: 768px) { 250 | .notificationBar { 251 | padding: 6px; 252 | } 253 | 254 | .backgroundFetching, 255 | .newPostsNotification { 256 | font-size: 0.85rem; 257 | } 258 | 259 | .updateButton, 260 | .fetchMoreBtn { 261 | padding: 5px 10px; 262 | font-size: 0.85rem; 263 | } 264 | 265 | .filters { 266 | width: 200px; /* Smaller width on tablets */ 267 | } 268 | } 269 | 270 | @media (max-width: 480px) { 271 | .notificationBar { 272 | padding: 4px; 273 | } 274 | 275 | .backgroundFetching, 276 | .newPostsNotification { 277 | font-size: 0.8rem; 278 | } 279 | 280 | .fetchingSpinner { 281 | width: 14px; 282 | height: 14px; 283 | margin-right: 6px; 284 | } 285 | 286 | .updateButton, 287 | .fetchMoreBtn { 288 | padding: 4px 8px; 289 | font-size: 0.8rem; 290 | } 291 | .filters { 292 | position: static !important; /* Override for mobile filters */ 293 | width: 0 !important; /* No width but still present in the DOM */ 294 | height: 0 !important; /* No height but still present in the DOM */ 295 | overflow: visible !important; /* Allow content to overflow */ 296 | display: block !important; /* Keep displayed */ 297 | } 298 | 299 | .filtersToggle { 300 | display: none; /* Hide filter toggle on mobile devices */ 301 | } 302 | .mainContent { 303 | width: 100%; /* Full width on mobile */ 304 | max-width: 100vw; /* Use viewport width */ 305 | } 306 | 307 | .postsList { 308 | width: 100%; /* Full width on mobile */ 309 | max-width: 100vw; /* Use viewport width */ 310 | padding: 0; 311 | } 312 | } 313 | -------------------------------------------------------------------------------- /app/src/components/ImageSlider.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useState, useEffect, useRef } from "react"; 2 | import styles from "./ImageSlider.module.scss"; 3 | import { Reveal } from "./icons/Reveal"; 4 | 5 | interface ImageSliderProps { 6 | images: string[]; 7 | shouldBlur?: boolean; 8 | } 9 | 10 | export const ImageSlider: FC = ({ 11 | images, 12 | shouldBlur = false, 13 | }) => { 14 | const [currentImageIndex, setCurrentImageIndex] = useState(0); 15 | const [containerHeight, setContainerHeight] = useState(null); 16 | const [loadedImages, setLoadedImages] = useState>(new Set([0])); 17 | const [isVisible, setIsVisible] = useState(false); 18 | const [isBlurred, setIsBlurred] = useState(shouldBlur); 19 | 20 | const firstImageRef = useRef(null); 21 | const sliderRef = useRef(null); 22 | const observerRef = useRef(null); 23 | 24 | useEffect(() => { 25 | // Reset when images change 26 | setContainerHeight(null); 27 | setCurrentImageIndex(0); 28 | setLoadedImages(new Set([0])); 29 | }, [images]); 30 | 31 | // Reset blur state when shouldBlur prop changes 32 | useEffect(() => { 33 | setIsBlurred(shouldBlur); 34 | }, [shouldBlur]); 35 | 36 | useEffect(() => { 37 | // Initialize Intersection Observer 38 | if (sliderRef.current && !observerRef.current) { 39 | observerRef.current = new IntersectionObserver( 40 | (entries) => { 41 | if (entries[0].isIntersecting) { 42 | setIsVisible(true); 43 | } else { 44 | setIsVisible(false); 45 | } 46 | }, 47 | { 48 | threshold: 0.1, // Trigger when at least 10% of the slider is visible 49 | } 50 | ); 51 | 52 | observerRef.current.observe(sliderRef.current); 53 | } 54 | 55 | return () => { 56 | if (observerRef.current) { 57 | observerRef.current.disconnect(); 58 | } 59 | }; 60 | }, []); 61 | 62 | useEffect(() => { 63 | // When first image loads, set its height as the container height 64 | if ( 65 | currentImageIndex === 0 && 66 | firstImageRef.current && 67 | firstImageRef.current.complete 68 | ) { 69 | setContainerHeight(firstImageRef.current.offsetHeight); 70 | } 71 | }, [currentImageIndex, firstImageRef.current?.complete]); 72 | 73 | useEffect(() => { 74 | // Preload current image and adjacent images when slider is visible 75 | if (isVisible && images.length > 0) { 76 | const imagesToLoad = new Set([currentImageIndex]); 77 | 78 | // Preload previous image if available 79 | if (currentImageIndex > 0) { 80 | imagesToLoad.add(currentImageIndex - 1); 81 | } else if (images.length > 1) { 82 | imagesToLoad.add(images.length - 1); 83 | } 84 | 85 | // Preload next image if available 86 | if (currentImageIndex < images.length - 1) { 87 | imagesToLoad.add(currentImageIndex + 1); 88 | } else if (images.length > 1) { 89 | imagesToLoad.add(0); 90 | } 91 | 92 | setLoadedImages((prev) => { 93 | const newSet = new Set(prev); 94 | imagesToLoad.forEach((idx) => newSet.add(idx)); 95 | return newSet; 96 | }); 97 | } 98 | }, [currentImageIndex, isVisible, images.length]); 99 | 100 | if (!images || images.length === 0) { 101 | return null; 102 | } 103 | 104 | const handleRevealClick = () => { 105 | setIsBlurred(false); 106 | }; 107 | 108 | // Show just the image if there's only one 109 | if (images.length === 1) { 110 | return ( 111 |
116 | Post content 122 | {isBlurred && ( 123 |
124 |
125 | 132 | 133 | Click to reveal NSFW content 134 | 135 |
136 |
137 | )} 138 |
139 | ); 140 | } 141 | 142 | const goToPrevious = () => { 143 | setCurrentImageIndex((prevIndex) => 144 | prevIndex === 0 ? images.length - 1 : prevIndex - 1 145 | ); 146 | }; 147 | 148 | const goToNext = () => { 149 | setCurrentImageIndex((prevIndex) => 150 | prevIndex === images.length - 1 ? 0 : prevIndex + 1 151 | ); 152 | }; 153 | 154 | const handleImageLoad = (e: React.SyntheticEvent) => { 155 | // If this is the first image and we don't have a container height yet 156 | if (currentImageIndex === 0 && !containerHeight) { 157 | setContainerHeight(e.currentTarget.offsetHeight); 158 | } 159 | }; 160 | 161 | return ( 162 |
166 |
170 | {/* Only render image elements for images that should be loaded */} 171 | {images.map( 172 | (src, index) => 173 | loadedImages.has(index) && ( 174 | {`Image 189 | ) 190 | )} 191 | 192 | 199 | 200 | 207 | 208 |
209 | {currentImageIndex + 1} / {images.length} 210 |
211 | 212 | {isBlurred && ( 213 |
214 |
215 | 222 | 223 | Click to reveal NSFW content 224 | 225 |
226 |
227 | )} 228 |
229 |
230 | ); 231 | }; 232 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 📚 Bookmarkeddit 2 | 3 |
4 | Bookmarkeddit Logo 5 |

Reddit's missing save manager: Finally organize what matters to you

6 |

A modern tool to organize, search, and manage your Reddit saved posts and comments.

7 |

8 | Live Demo | 9 | Features | 10 | Getting Started | 11 | Deployment 12 |

13 |
14 | 15 | ## 🌟 Overview 16 | 17 | **Bookmarkeddit** is the solution to Reddit's limited saved post management. Easily organize, filter, search, and rediscover your saved content without the limitations of Reddit's native interface. 18 | 19 | Built with React, TypeScript, and powered by Reddit's API, Bookmarkeddit provides a privacy-first experience—your data never leaves your browser. 20 | 21 | ## 🖼️ App Preview 22 | 23 | ![Bookmarkeddit Desktop Preview](./app/src/assets/demo/mockup_desktop.png) 24 | _Desktop View_ 25 | 26 | ![Bookmarkeddit Mobile Preview](./app/src/assets/demo/mockup_mobile.png) 27 | _Mobile View_ 28 | 29 | [**Desktop Demo**](https://imgur.com/a/5c7KBQm) | [**Mobile Demo**](https://imgur.com/a/GQWOYfW) 30 | 31 | ## ✨ Features 32 | 33 | ### Core Functionality 34 | 35 | - **🔍 Smart Search**: Full-text search through titles and content of your saved posts. 36 | - **🗂️ Intelligent Filters**: Filter by subreddit, post type (text/image/video), or NSFW status. 37 | - **🔄 Dynamic Layouts**: Switch between grid and list views. 38 | - **⬆️ Custom Sorting**: Sort by recency, upvotes, or comments. 39 | - **🔄 Incremental Fetching**: Automatically fetches all saved posts, handling Reddit's API limits. 40 | - **🌓 Light/Dark Mode**: Choose your preferred theme. 41 | - **🛡️ Privacy First**: Your data stays in your browser; nothing is stored on our servers. 42 | 43 | ### Technical Highlights 44 | 45 | - **📱 Responsive Design**: Seamless experience on desktop and mobile. 46 | - **🗄️ Efficient Data Management**: Optimized state management for handling post data. 47 | - **⌨️ Keyboard Shortcuts**: `Ctrl+F` for search, `Ctrl+R` to refresh. 48 | - **🔍 Fuzzy Search**: Find content with partial or inexact matches. 49 | - **📊 Masonry Grid Layout**: Visually appealing display for posts of varying heights. 50 | - **🎥 Media Support**: View images and videos directly in the app. 51 | - **🛑 NSFW Handling**: Option to blur NSFW images with one-click reveal. 52 | - **📦 Infinite Scroll**: Efficiently load and display large numbers of posts. 53 | - **🚀 Performance Optimized**: Fast rendering and data handling. 54 | 55 | ### Powerful Filtering System 56 | 57 | Filter saved posts by community, type, or NSFW status. See exactly how many posts you have in each category. 58 | 59 | ### Advanced Search & Sorting 60 | 61 | Search through all your saved content with instant results and sort by recency, popularity, or engagement. 62 | 63 | ### Content Management 64 | 65 | Easily unsave posts you no longer need, with confirmation to prevent accidental removal. 66 | 67 | ### Saved Post Organization 68 | 69 | View posts in an attractive grid layout or a detailed list view. 70 | 71 | ## 🚀 Getting Started 72 | 73 | ### Prerequisites 74 | 75 | - Node.js (v16 or higher recommended) 76 | - A Reddit account 77 | - Reddit Developer Application credentials (see below) 78 | 79 | ### Reddit API Credentials Setup 80 | 81 | 1. Go to [Reddit's App Preferences](https://www.reddit.com/prefs/apps). 82 | 2. Click "Create App" or "Create Another App". 83 | 3. Fill in the details: 84 | - **Name**: Bookmarkeddit (or your preferred name) 85 | - **App type**: Select "web app" 86 | - **Description**: (Optional) 87 | - **About URL**: (Optional) 88 | - **Redirect URI**: 89 | - For local development: `http://localhost:5173/login/callback` 90 | - _(Ensure this matches your `VITE_REDIRECT_URI` in the respective `.env` file)_ 91 | 4. Click "Create app". 92 | 5. Note your **Client ID** (under the app name) and **Client Secret**. 93 | 94 | ### Installation & Local Development 95 | 96 | 1. Clone the repository: 97 | 98 | ```bash 99 | git clone https://github.com/mateussilva98/bookmarkeddit.git 100 | # Replace with your fork if applicable 101 | cd bookmarkeddit 102 | ``` 103 | 104 | 2. Install dependencies for both the client app and the server: 105 | 106 | ```bash 107 | # Install client app dependencies 108 | cd app 109 | npm install 110 | 111 | # Install server dependencies 112 | cd ../server 113 | npm install 114 | cd .. 115 | # Return to root for next steps 116 | ``` 117 | 118 | 3. Create and configure environment files: 119 | 120 | **For the server (`server/.env`):** 121 | 122 | Copy `server/example.env` to `server/.env` and fill in: 123 | 124 | ```env 125 | NODE_ENV=development 126 | PORT=3000 # Or your preferred port for the proxy server 127 | REDDIT_CLIENT_ID=YOUR_REDDIT_CLIENT_ID 128 | REDDIT_CLIENT_SECRET=YOUR_REDDIT_CLIENT_SECRET 129 | ``` 130 | 131 | **For the client app (`app/.env):** 132 | 133 | Copy `app/example.env` to `app/.env` and fill in: 134 | 135 | ```env 136 | VITE_API_URL=http://localhost:3000/api # Points to the proxy server 137 | VITE_CLIENT_ID=YOUR_REDDIT_CLIENT_ID 138 | VITE_REDIRECT_URI=http://localhost:5173/login/callback 139 | ``` 140 | 141 | _(Note: `VITE_CLIENT_ID` in the app is the same as `REDDIT_CLIENT_ID` for the server)_ 142 | 143 | 4. Start the development servers: 144 | 145 | ```bash 146 | # Start the proxy server 147 | cd server 148 | npm run dev 149 | 150 | # In another terminal, start the frontend 151 | cd ../app 152 | # or cd app from the root directory if you opened a new terminal 153 | npm run dev 154 | ``` 155 | 156 | 5. Open your browser and navigate to `http://localhost:5173`. 157 | 158 | ## 📄 Authentication & Permissions 159 | 160 | Bookmarkeddit requires the following Reddit API permissions during OAuth: 161 | 162 | - `identity`: To display your username and profile picture. 163 | - `history`: To access your saved posts and comments. 164 | - `save`: To allow unsaving posts/comments from within the app. 165 | 166 | ## 🔒 Privacy 167 | 168 | Your Reddit data (saved posts, username) is fetched by the client app and stored in your browser's local storage. The backend server only proxies requests to the Reddit API and does not store any of your personal Reddit data. 169 | 170 | ## 🛠️ Tech Stack 171 | 172 | - **Frontend**: React, TypeScript, SCSS Modules, Vite 173 | - **Backend (Proxy Server)**: Node.js, Express, TypeScript 174 | - **API**: Reddit API 175 | - **State Management**: Zustand (as per `useStore.ts`) 176 | - **Deployment**: Docker, Docker Swarm, Caddy 177 | - **Analytics**: Self-hosted Plausible 178 | 179 | ## 🔮 Upcoming Features 180 | 181 | - Export saved posts (e.g., to CSV/JSON). 182 | - Enhanced Markdown rendering for post content. 183 | - Dashboard with statistics about saved content. 184 | - Pin important posts to the top. 185 | - Advanced caching strategies. 186 | 187 | ## 🤝 Contributing 188 | 189 | Contributions, issues, and feature requests are welcome! Please feel free to: 190 | 191 | - Open an issue to discuss a bug or feature. 192 | - Submit a Pull Request with your improvements. 193 | 194 | ## 📝 License 195 | 196 | This project is licensed under the MIT License - see the [LICENSE](./LICENSE) file for details. 197 | 198 | ## 🙏 Acknowledgements 199 | 200 | - Created by [Mateus Silva](https://github.com/mateussilva98/) 201 | 202 | --- 203 | 204 |

205 | Your data, better organized. Never lose a valuable Reddit post again. 206 |

207 | -------------------------------------------------------------------------------- /server/src/controllers/authController.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Authentication controller for Reddit OAuth flow 3 | * Handles token exchange and token refresh operations 4 | */ 5 | import { Request, Response } from "express"; 6 | import fetch from "node-fetch"; 7 | import { formatErrorResponse } from "../utils/responses.js"; 8 | import { logInfo, logError, logWarn } from "../utils/logger.js"; 9 | import { getSecret } from "../utils/getSecret.js"; 10 | 11 | /** 12 | * Handle token exchange from authorization code 13 | * Converts the OAuth code received from Reddit into access and refresh tokens 14 | * 15 | * @param req Express request object containing code and redirectUri 16 | * @param res Express response object 17 | */ 18 | export async function exchangeToken(req: Request, res: Response) { 19 | try { 20 | const { code, redirectUri } = req.body; 21 | // Use getSecret for prod, fallback to env for dev 22 | const clientId = getSecret("REDDIT_CLIENT_ID"); 23 | const clientSecret = getSecret("REDDIT_CLIENT_SECRET"); 24 | 25 | // Validate required parameters 26 | if (!code || !redirectUri) { 27 | logError("Missing parameters in token exchange request", null, { 28 | body: req.body, 29 | missingCode: !code, 30 | missingRedirectUri: !redirectUri, 31 | }); 32 | 33 | return res 34 | .status(400) 35 | .json( 36 | formatErrorResponse( 37 | 400, 38 | "Missing required parameters. Both code and redirectUri are required." 39 | ) 40 | ); 41 | } 42 | 43 | // Validate server configuration 44 | if (!clientId || !clientSecret) { 45 | logError( 46 | "Missing server environment variables for Reddit API credentials", 47 | null, 48 | { 49 | missingClientId: !clientId, 50 | missingClientSecret: !clientSecret, 51 | } 52 | ); 53 | 54 | return res 55 | .status(500) 56 | .json( 57 | formatErrorResponse( 58 | 500, 59 | "Server configuration error: Missing API credentials" 60 | ) 61 | ); 62 | } 63 | 64 | logInfo(`Attempting token exchange with Reddit`, { 65 | redirectUri, 66 | hasCode: !!code, 67 | }); 68 | 69 | // Create Base64 encoded credentials for Basic Auth 70 | const encodedCredentials = Buffer.from( 71 | `${clientId}:${clientSecret}` 72 | ).toString("base64"); 73 | 74 | // Make the token exchange request to Reddit 75 | const response = await fetch("https://www.reddit.com/api/v1/access_token", { 76 | method: "POST", 77 | headers: { 78 | Authorization: `Basic ${encodedCredentials}`, 79 | "Content-Type": "application/x-www-form-urlencoded", 80 | "User-Agent": process.env.USER_AGENT || "bookmarkeddit/1.0", 81 | }, 82 | body: `grant_type=authorization_code&code=${encodeURIComponent( 83 | code 84 | )}&redirect_uri=${encodeURIComponent(redirectUri)}`, 85 | }); 86 | 87 | // Handle error responses from Reddit API 88 | if (!response.ok) { 89 | const errorText = await response.text(); 90 | logError("Token exchange failed with Reddit API", null, { 91 | status: response.status, 92 | errorText, 93 | }); 94 | 95 | return res 96 | .status(response.status) 97 | .json( 98 | formatErrorResponse( 99 | response.status, 100 | `Failed to exchange token: ${errorText}` 101 | ) 102 | ); 103 | } 104 | 105 | // Process successful response 106 | const data = (await response.json()) as Record; 107 | logInfo("Token exchange successful", { 108 | tokenType: data.token_type, 109 | expiresIn: data.expires_in, 110 | scope: data.scope, 111 | }); 112 | 113 | return res.json(data); 114 | } catch (error) { 115 | // Handle unexpected errors 116 | logError("Error during token exchange", error); 117 | 118 | return res 119 | .status(500) 120 | .json( 121 | formatErrorResponse( 122 | 500, 123 | `Error during token exchange: ${ 124 | error instanceof Error ? error.message : String(error) 125 | }` 126 | ) 127 | ); 128 | } 129 | } 130 | 131 | /** 132 | * Handle token refresh 133 | * Refreshes an expired access token using the refresh token 134 | * 135 | * @param req Express request object containing refreshToken 136 | * @param res Express response object 137 | */ 138 | export async function refreshToken(req: Request, res: Response) { 139 | try { 140 | const { refreshToken } = req.body; 141 | const clientId = getSecret("REDDIT_CLIENT_ID"); 142 | const clientSecret = getSecret("REDDIT_CLIENT_SECRET"); 143 | 144 | // Validate required parameters 145 | if (!refreshToken) { 146 | logError("Missing refresh token in request", null, { body: req.body }); 147 | 148 | return res 149 | .status(400) 150 | .json(formatErrorResponse(400, "Missing refresh token")); 151 | } 152 | 153 | // Validate server configuration 154 | if (!clientId || !clientSecret) { 155 | logError("Missing server environment variables for token refresh", null, { 156 | missingClientId: !clientId, 157 | missingClientSecret: !clientSecret, 158 | }); 159 | 160 | return res 161 | .status(500) 162 | .json( 163 | formatErrorResponse( 164 | 500, 165 | "Server configuration error: Missing API credentials" 166 | ) 167 | ); 168 | } 169 | 170 | logInfo("Attempting to refresh Reddit API token"); 171 | 172 | // Create Base64 encoded credentials for Basic Auth 173 | const encodedCredentials = Buffer.from( 174 | `${clientId}:${clientSecret}` 175 | ).toString("base64"); 176 | 177 | // Make the token refresh request to Reddit 178 | const response = await fetch("https://www.reddit.com/api/v1/access_token", { 179 | method: "POST", 180 | headers: { 181 | Authorization: `Basic ${encodedCredentials}`, 182 | "Content-Type": "application/x-www-form-urlencoded", 183 | "User-Agent": process.env.USER_AGENT || "bookmarkeddit/1.0", 184 | }, 185 | body: `grant_type=refresh_token&refresh_token=${encodeURIComponent( 186 | refreshToken 187 | )}`, 188 | }); 189 | 190 | // Handle error responses from Reddit API 191 | if (!response.ok) { 192 | const errorText = await response.text(); 193 | logError("Token refresh failed with Reddit API", null, { 194 | status: response.status, 195 | errorText, 196 | }); 197 | 198 | return res 199 | .status(response.status) 200 | .json( 201 | formatErrorResponse( 202 | response.status, 203 | `Failed to refresh token: ${errorText}` 204 | ) 205 | ); 206 | } 207 | 208 | // Process successful response 209 | const data = (await response.json()) as Record; 210 | logInfo("Token refresh successful", { 211 | tokenType: data.token_type, 212 | expiresIn: data.expires_in, 213 | }); 214 | 215 | // Reddit doesn't return refresh_token on refresh requests, so add it back 216 | return res.json({ 217 | ...data, 218 | refresh_token: refreshToken, 219 | }); 220 | } catch (error) { 221 | // Handle unexpected errors 222 | logError("Error during token refresh", error); 223 | 224 | return res 225 | .status(500) 226 | .json( 227 | formatErrorResponse( 228 | 500, 229 | `Error during token refresh: ${ 230 | error instanceof Error ? error.message : String(error) 231 | }` 232 | ) 233 | ); 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /app/src/components/Post.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useState } from "react"; 2 | import { Post } from "../types/Post"; 3 | import styles from "./Post.module.scss"; 4 | import { Warning } from "./icons/Warning"; 5 | import { Ups } from "./icons/Ups"; 6 | import { Comment } from "./icons/Comment"; 7 | import { Open } from "./icons/Open"; 8 | import { Bookmark } from "./icons/Bookmark"; 9 | import { Share } from "./icons/Share"; 10 | import { ImageSlider } from "./ImageSlider"; 11 | import { VideoPlayer } from "./VideoPlayer"; 12 | import { useStore } from "../hooks/useStore"; 13 | import { Tooltip } from "./ui/Tooltip"; 14 | import { ConfirmModal } from "./ui/ConfirmModal"; 15 | import { redditApi, ApiError } from "../api"; 16 | 17 | interface PostProps { 18 | post: Post; 19 | onUnsave?: ( 20 | postId: string, 21 | succeeded: boolean, 22 | errorMessage?: string 23 | ) => void; 24 | addToast?: (message: string, type: "success" | "error" | "info") => void; 25 | } 26 | 27 | // Calculate data text - 1 day ago, 4 days ago, 1 motnh ago, etc. 28 | const calculateTimeAgo = (timestamp: number): string => { 29 | const now = new Date(); 30 | const postDate = new Date(timestamp * 1000); // Convert to milliseconds 31 | const diffInSeconds = Math.floor((now.getTime() - postDate.getTime()) / 1000); 32 | 33 | const intervals: { [key: string]: number } = { 34 | year: 31536000, 35 | month: 2592000, 36 | week: 604800, 37 | day: 86400, 38 | hour: 3600, 39 | minute: 60, 40 | second: 1, 41 | }; 42 | 43 | for (const [unit, seconds] of Object.entries(intervals)) { 44 | const count = Math.floor(diffInSeconds / seconds); 45 | if (count > 0) { 46 | return `${count} ${unit}${count > 1 ? "s" : ""} ago`; 47 | } 48 | } 49 | 50 | return "just now"; 51 | }; 52 | 53 | export const PostComponent: FC = ({ post, onUnsave, addToast }) => { 54 | const { store, handleAuthError } = useStore(); 55 | const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false); 56 | const [isUnsaving, setIsUnsaving] = useState(false); 57 | 58 | const share = (url: string) => { 59 | if (navigator.share) { 60 | navigator 61 | .share({ 62 | title: post.title, 63 | text: post.description, 64 | url: url, 65 | }) 66 | .then(() => { 67 | // Web Share API successfully shared the post 68 | if (addToast) { 69 | addToast("Post shared successfully", "success"); 70 | } 71 | }) 72 | .catch((error) => { 73 | console.error("Error sharing post:", error); 74 | if (addToast) { 75 | addToast("Failed to share post", "error"); 76 | } 77 | }); 78 | } else { 79 | // Fallback for browsers that don't support the Web Share API - copy to clipboard 80 | navigator.clipboard.writeText(url).then(() => { 81 | // URL successfully copied to clipboard 82 | if (addToast) { 83 | addToast("Post URL copied to clipboard", "success"); 84 | } else { 85 | alert("Post URL copied to clipboard: " + url); 86 | } 87 | }); 88 | } 89 | }; 90 | const handleUnsaveClick = () => { 91 | setIsConfirmModalOpen(true); 92 | }; 93 | 94 | const handleConfirmUnsave = async () => { 95 | if (!store.auth.access_token || isUnsaving) return; 96 | 97 | setIsUnsaving(true); 98 | 99 | try { 100 | // Call the API to unsave the post 101 | await redditApi.unsaveItem(store.auth.access_token, post.fullname); 102 | 103 | // Notify parent component of successful unsave 104 | if (onUnsave) { 105 | onUnsave(post.id, true); 106 | } 107 | } catch (error) { 108 | console.error("Error unsaving post:", error); 109 | 110 | // Check if this is an authentication error (401/403) 111 | if (error instanceof ApiError && error.isAuthError) { 112 | // Use the auth error handler to clear data and redirect 113 | handleAuthError(); 114 | return; 115 | } 116 | 117 | const errorMessage = 118 | error instanceof ApiError 119 | ? error.message 120 | : "Failed to unsave the post. Please try again."; 121 | 122 | // Notify parent component of failed unsave 123 | if (onUnsave) { 124 | onUnsave(post.id, false, errorMessage); 125 | } 126 | } finally { 127 | setIsUnsaving(false); 128 | setIsConfirmModalOpen(false); 129 | } 130 | }; 131 | 132 | const handleCancelUnsave = () => { 133 | setIsConfirmModalOpen(false); 134 | }; 135 | 136 | return ( 137 |
138 |
139 |
140 | 145 | r/{post.subreddit} •{" "} 146 | 147 | {calculateTimeAgo(post.createdAt)} 148 |
149 |
150 | 155 | u/{post.author} 156 | 157 |
158 |
159 | 160 | {post.nsfw && ( 161 |
162 |
163 | 164 |
165 | NSFW 166 |
167 | )} 168 | 169 |

170 | {post.title} 171 |

172 |
173 | {post.type == "Comment" && ( 174 |
175 |
176 |
177 |
178 |
179 |
180 | )} 181 |

186 | {post.description} 187 |

188 |
189 | 190 | {/* Display media content with video taking priority over images */} 191 | {store.showImages && ( 192 | <> 193 | {post.video ? ( 194 | 198 | ) : post.images && post.images.length > 0 ? ( 199 | 203 | ) : null} 204 | 205 | )} 206 | 207 |
208 |
209 |
210 |
211 | 212 |
213 | {post.score} 214 |
215 |
216 |
217 | 218 |
219 | {post.commentCount} 220 |
221 |
222 |
223 | 224 | 231 | 232 | 233 | 234 | 237 | 238 | 239 | 240 | 241 | 244 | 245 | 246 |
247 |
248 | 249 | 258 |
259 | ); 260 | }; 261 | --------------------------------------------------------------------------------