Repository: stephancill/opencast Branch: main Commit: c163bd00cfc0 Files: 236 Total size: 582.2 KB Directory structure: gitextract_cyfq76i2/ ├── .eslintignore ├── .eslintrc.json ├── .eslintrc.json.bak ├── .github/ │ └── workflows/ │ └── deployment.yaml ├── .gitignore ├── .husky/ │ └── pre-commit.bak ├── .prettierignore ├── .prettierrc.json ├── .vscode/ │ └── settings.json ├── Dockerfile ├── LICENSE ├── README.md ├── docker-compose.yml ├── jest.config.js ├── next.config.js ├── nixpacks.toml ├── package.json ├── postcss.config.js ├── prisma/ │ └── schema.prisma ├── public/ │ └── site.webmanifest ├── src/ │ ├── app/ │ │ └── frames/ │ │ └── route.ts │ ├── components/ │ │ ├── aside/ │ │ │ ├── aside-footer.tsx │ │ │ ├── aside-trends.tsx │ │ │ ├── aside.tsx │ │ │ ├── search-bar.tsx │ │ │ ├── suggestions.tsx.bak │ │ │ └── trends.tsx │ │ ├── common/ │ │ │ ├── app-head.tsx │ │ │ ├── load-more.tsx │ │ │ ├── placeholder.tsx │ │ │ └── seo.tsx │ │ ├── feed/ │ │ │ └── tweet-feed.tsx │ │ ├── frames/ │ │ │ ├── Frame.tsx │ │ │ └── frame-ui.tsx │ │ ├── home/ │ │ │ ├── main-container.tsx │ │ │ ├── main-header.tsx │ │ │ └── update-username.tsx.bak │ │ ├── input/ │ │ │ ├── image-preview.tsx │ │ │ ├── input-accent-radio.tsx │ │ │ ├── input-field.tsx │ │ │ ├── input-form.tsx │ │ │ ├── input-options.tsx │ │ │ ├── input-theme-radio.tsx │ │ │ ├── input.tsx │ │ │ ├── progress-bar.tsx │ │ │ └── search-bar.tsx │ │ ├── layout/ │ │ │ ├── auth-layout.tsx │ │ │ ├── common-layout.tsx │ │ │ ├── main-layout.tsx │ │ │ ├── user-data-layout.tsx │ │ │ ├── user-follow-layout.tsx │ │ │ └── user-home-layout.tsx │ │ ├── login/ │ │ │ ├── login-footer.tsx │ │ │ ├── login-main.tsx │ │ │ └── sign-in-with-warpcast.tsx │ │ ├── modal/ │ │ │ ├── action-modal.tsx │ │ │ ├── display-modal.tsx │ │ │ ├── edit-profile-modal.tsx │ │ │ ├── image-modal.tsx │ │ │ ├── mobile-sidebar-modal.tsx │ │ │ ├── modal.tsx │ │ │ ├── save-passkey-modal.tsx │ │ │ ├── sign-in-modal-wallet.tsx │ │ │ ├── sign-in-modal-warpcast.tsx │ │ │ ├── tip-modal.tsx │ │ │ ├── tweet-reply-modal.tsx │ │ │ ├── tweet-stats-modal.tsx │ │ │ └── username-modal.tsx │ │ ├── search/ │ │ │ ├── search-topics.tsx │ │ │ └── user-search-result.tsx │ │ ├── sidebar/ │ │ │ ├── menu-link.tsx │ │ │ ├── mobile-sidebar-link.tsx │ │ │ ├── mobile-sidebar.tsx │ │ │ ├── more-settings.tsx │ │ │ ├── sidebar-link.tsx │ │ │ ├── sidebar-profile.tsx │ │ │ └── sidebar.tsx │ │ ├── sync/ │ │ │ └── sync-view.tsx │ │ ├── tweet/ │ │ │ ├── number-stats.tsx │ │ │ ├── stats-empty.tsx │ │ │ ├── tweet-actions.tsx │ │ │ ├── tweet-date.tsx │ │ │ ├── tweet-embed.tsx │ │ │ ├── tweet-option.tsx │ │ │ ├── tweet-parent.tsx │ │ │ ├── tweet-parent.tsx.bak │ │ │ ├── tweet-share.tsx │ │ │ ├── tweet-stats.tsx │ │ │ ├── tweet-status.tsx │ │ │ ├── tweet-text.tsx │ │ │ ├── tweet-topic.tsx │ │ │ ├── tweet-with-parent.tsx │ │ │ ├── tweet-with-parent.tsx.bak │ │ │ └── tweet.tsx │ │ ├── ui/ │ │ │ ├── button.tsx │ │ │ ├── caution-warn.tsx │ │ │ ├── custom-icon.tsx │ │ │ ├── error.tsx │ │ │ ├── feed-ordering-selector.tsx │ │ │ ├── follow-button.tsx │ │ │ ├── hero-icon.tsx │ │ │ ├── loading.tsx │ │ │ ├── menu-row.tsx │ │ │ ├── next-image.tsx │ │ │ ├── segmented-nav-link.tsx │ │ │ └── tooltip.tsx │ │ ├── user/ │ │ │ ├── user-avatar.tsx │ │ │ ├── user-card.tsx │ │ │ ├── user-cards.tsx │ │ │ ├── user-details.tsx │ │ │ ├── user-edit-profile.tsx │ │ │ ├── user-fid.tsx │ │ │ ├── user-follow-stats.tsx │ │ │ ├── user-follow.tsx │ │ │ ├── user-following.tsx │ │ │ ├── user-header.tsx │ │ │ ├── user-home-avatar.tsx │ │ │ ├── user-home-cover.tsx │ │ │ ├── user-known-followers.tsx │ │ │ ├── user-name.tsx │ │ │ ├── user-nav.tsx │ │ │ ├── user-share.tsx │ │ │ ├── user-tooltip.tsx │ │ │ └── user-username.tsx │ │ └── view/ │ │ ├── view-parent-tweet.tsx │ │ ├── view-tweet-stats.tsx │ │ └── view-tweet.tsx │ ├── contracts/ │ │ ├── id-registry.ts │ │ ├── index.ts │ │ ├── key-gateway.ts │ │ ├── key-registry.ts │ │ └── validator.ts │ ├── lib/ │ │ ├── api/ │ │ │ ├── auth.ts │ │ │ └── trends.ts │ │ ├── chains/ │ │ │ └── resolve-chain-icon.ts │ │ ├── context/ │ │ │ ├── auth-context.tsx │ │ │ ├── theme-context.tsx │ │ │ ├── user-context.tsx │ │ │ └── window-context.tsx │ │ ├── crypto.ts │ │ ├── date.ts │ │ ├── embeds.ts │ │ ├── env.ts │ │ ├── farcaster/ │ │ │ ├── index.ts │ │ │ └── utils.ts │ │ ├── fetch.ts │ │ ├── hooks/ │ │ │ ├── useConnectedWalletFid.tsx │ │ │ ├── useInfiniteScroll.tsx │ │ │ ├── useInfiniteScrollUsers.tsx │ │ │ ├── useModal.ts │ │ │ └── useRequireAuth.ts │ │ ├── imgur/ │ │ │ └── upload.ts │ │ ├── keys.ts │ │ ├── lru-cache.ts │ │ ├── merge.ts │ │ ├── paginated-reactions.ts │ │ ├── paginated-tweets.ts │ │ ├── passkeys.ts │ │ ├── prisma.ts │ │ ├── random.ts │ │ ├── signers.ts │ │ ├── topics/ │ │ │ └── resolve-topic.ts │ │ ├── types/ │ │ │ ├── app-auth.ts │ │ │ ├── available.ts │ │ │ ├── bookmark.ts │ │ │ ├── feed.ts │ │ │ ├── file.ts │ │ │ ├── keypair.ts │ │ │ ├── notifications.ts │ │ │ ├── online.ts │ │ │ ├── place.ts │ │ │ ├── responses.ts │ │ │ ├── signer.ts │ │ │ ├── stats.ts │ │ │ ├── theme.ts │ │ │ ├── topic.ts │ │ │ ├── trends.ts │ │ │ ├── tweet.ts │ │ │ └── user.ts │ │ ├── user/ │ │ │ └── resolve-user.ts │ │ ├── utils.ts │ │ └── validation.ts │ ├── pages/ │ │ ├── 404.tsx │ │ ├── [...redirect].tsx │ │ ├── _app.tsx │ │ ├── _document.tsx │ │ ├── api/ │ │ │ ├── embeds.ts │ │ │ ├── feed.ts │ │ │ ├── hub/ │ │ │ │ ├── batch.ts │ │ │ │ └── index.ts │ │ │ ├── online/ │ │ │ │ └── index.ts │ │ │ ├── search.ts │ │ │ ├── signer/ │ │ │ │ └── [pubKey]/ │ │ │ │ ├── authorize.ts │ │ │ │ └── user.ts │ │ │ ├── topic/ │ │ │ │ └── index.ts │ │ │ ├── trends/ │ │ │ │ └── index.ts │ │ │ ├── tweet/ │ │ │ │ ├── [id]/ │ │ │ │ │ ├── engagers.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── replies.ts │ │ │ │ └── batch.ts │ │ │ └── user/ │ │ │ ├── [id]/ │ │ │ │ ├── index.ts │ │ │ │ ├── interests.ts │ │ │ │ ├── known-followers.ts │ │ │ │ ├── likes.ts │ │ │ │ ├── links.ts │ │ │ │ ├── notifications.ts │ │ │ │ ├── signers/ │ │ │ │ │ ├── [pubKey]/ │ │ │ │ │ │ ├── backup.ts │ │ │ │ │ │ ├── casts.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── sync.ts │ │ │ │ └── tweets.ts │ │ │ └── resolve-usernames.ts │ │ ├── bookmarks.tsx.bak │ │ ├── home.tsx │ │ ├── index.tsx │ │ ├── login.tsx │ │ ├── notifications.tsx │ │ ├── people.tsx.bak │ │ ├── settings/ │ │ │ ├── index.tsx │ │ │ └── manage-signers/ │ │ │ ├── [pubKey].tsx │ │ │ └── index.tsx │ │ ├── topic/ │ │ │ └── index.tsx │ │ ├── trends.tsx │ │ ├── tweet/ │ │ │ └── [id].tsx │ │ └── user/ │ │ └── [id]/ │ │ ├── followers.tsx │ │ ├── following.tsx │ │ ├── index.tsx │ │ ├── likes.tsx │ │ ├── media.tsx.bak │ │ ├── with_replies.tsx │ │ └── with_replies.tsx.bak │ └── styles/ │ ├── fonts.scss │ └── globals.scss ├── tailwind.config.js └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .eslintignore ================================================ # next config next.config.js # tailwind config tailwind.config.js postcss.config.js # jest config jest.config.js ================================================ FILE: .eslintrc.json ================================================ { "parser": "@typescript-eslint/parser", "parserOptions": { "project": "tsconfig.json" }, "settings": { "import/resolver": { "typescript": true, "node": true } }, "plugins": ["react-hooks"] } ================================================ FILE: .eslintrc.json.bak ================================================ { "parser": "@typescript-eslint/parser", "parserOptions": { "project": "tsconfig.json" }, "plugins": ["@typescript-eslint"], "extends": [ "eslint:recommended", "plugin:import/recommended", "plugin:import/typescript", "plugin:@typescript-eslint/recommended", "plugin:@typescript-eslint/recommended-requiring-type-checking", "next/core-web-vitals" ], "settings": { "import/resolver": { "typescript": true, "node": true } }, "rules": { "semi": ["error", "always"], "curly": ["warn", "multi"], "quotes": ["error", "single", { "avoidEscape": true }], "jsx-quotes": ["error", "prefer-single"], "linebreak-style": ["error", "unix"], "no-console": "warn", "comma-dangle": ["error", "never"], "no-unused-expressions": "error", "no-constant-binary-expression": "error", "import/order": [ "warn", { "pathGroups": [ { "pattern": "*.scss", "group": "builtin", "position": "before", "patternOptions": { "matchBase": true } }, { "pattern": "@lib/**", "group": "external", "position": "after" }, { "pattern": "@components/**", "group": "external", "position": "after" } ], "warnOnUnassignedImports": true, "pathGroupsExcludedImportTypes": ["type"], "groups": [ "builtin", "external", "internal", "parent", "sibling", "index", "object", "type" ] } ], "@typescript-eslint/no-misused-promises": [ "error", { "checksVoidReturn": { "attributes": false } } ], "@typescript-eslint/consistent-type-imports": "warn", "@typescript-eslint/prefer-nullish-coalescing": "warn", "@typescript-eslint/explicit-function-return-type": "warn" } } ================================================ FILE: .github/workflows/deployment.yaml ================================================ name: Deploy 🚀 on: push: branches: ['main'] pull_request: branches: ['main'] jobs: prettier: name: 🧪 Prettier runs-on: ubuntu-latest steps: - name: ⬇️ Checkout repo uses: actions/checkout@v3 - name: 📥 Download deps run: npm ci - name: 🔍 Format run: npm run format eslint: name: ✅ ESLint runs-on: ubuntu-latest steps: - name: ⬇️ Checkout repo uses: actions/checkout@v3 - name: 📥 Download deps run: npm ci - name: 🪄 Lint run: npm run lint jest: name: 🃏 Jest runs-on: ubuntu-latest steps: - name: ⬇️ Checkout repo uses: actions/checkout@v3 - name: 📥 Download deps run: npm ci # ! uncomment this after you add test # - name: 🔬 Test # run: npm run test:ci ================================================ FILE: .gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies /node_modules /.pnp .pnp.js # testing /coverage # next.js /.next/ /out/ # production /build # misc .DS_Store *.pem # debug npm-debug.log* yarn-debug.log* yarn-error.log* .pnpm-debug.log* # local env files .env*.local # vercel .vercel # typescript *.tsbuildinfo next-env.d.ts # python /.mypy_cache *.py .env .idea ================================================ FILE: .husky/pre-commit.bak ================================================ #!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" exec 1> /dev/tty npx lint-staged ================================================ FILE: .prettierignore ================================================ # testing /coverage # next.js /.next/ /.vercel/ /out/ # production /build # compiled js functions /functions/lib/ # python *.py .mypy_cache/ ================================================ FILE: .prettierrc.json ================================================ { "singleQuote": true, "jsxSingleQuote": true, "trailingComma": "none" } ================================================ FILE: .vscode/settings.json ================================================ { "WillLuke.nextjs.hasPrompted": true } ================================================ FILE: Dockerfile ================================================ # https://github.com/vercel/next.js/blob/canary/examples/with-docker/Dockerfile FROM node:18-alpine AS base # Install dependencies only when needed FROM base AS deps # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. RUN apk add --no-cache libc6-compat WORKDIR /app # Install dependencies based on the preferred package manager COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./ RUN \ if [ -f yarn.lock ]; then yarn --frozen-lockfile; \ elif [ -f package-lock.json ]; then npm ci; \ elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \ else echo "Lockfile not found." && exit 1; \ fi # Rebuild the source code only when needed FROM base AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . # Next.js collects completely anonymous telemetry data about general usage. # Learn more here: https://nextjs.org/telemetry # Uncomment the following line in case you want to disable telemetry during the build. # ENV NEXT_TELEMETRY_DISABLED=1 RUN yarn prisma generate RUN \ if [ -f yarn.lock ]; then yarn run build; \ elif [ -f package-lock.json ]; then npm run build; \ elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \ else echo "Lockfile not found." && exit 1; \ fi # Production image, copy all the files and run next FROM base AS runner WORKDIR /app ENV NODE_ENV=production # Uncomment the following line in case you want to disable telemetry during runtime. # ENV NEXT_TELEMETRY_DISABLED=1 RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs COPY --from=builder /app/public ./public # Set the correct permission for prerender cache RUN mkdir .next RUN chown nextjs:nodejs .next # Automatically leverage output traces to reduce image size # https://nextjs.org/docs/advanced-features/output-file-tracing COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static USER nextjs EXPOSE 3000 ENV PORT=3000 # server.js is created by next build from the standalone output # https://nextjs.org/docs/pages/api-reference/next-config-js/output ENV HOSTNAME="0.0.0.0" CMD ["node", "server.js"] ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2022 ccrsxx Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Opencast A fully open source Twitter flavoured Farcaster client. Originally a fork of [ccrsxx/twitter-clone](https://github.com/ccrsxx/twitter-clone). The goal of this project is to be a fully standalone Farcaster client that you can run on your own machine. It only depends on [stephancill/lazy-indexer](https://github.com/stephancill/lazy-indexer) and a connection to a Farcaster Hub. ## Running it yourself ### Prerequisites - [Docker](https://docs.docker.com/engine/install/) 1. Clone the repo ``` git clone git@github.com:stephancill/opencast.git ``` 2. Copy .env.sample, rename it to .env and fill in the values ``` cp .env.sample .env ``` 3. Run the Docker Compose file ``` docker-compose up -d ``` 4. Go to Opencast at http://localhost:3000 and log in. It will take a few moments to index your profile and might require you to refresh the page. ## Development ### Farcaster Indexer This project depends on the Lazy Farcaster Indexer. Follow the instructions at [https://github.com/stephancill/lazy-indexer](https://github.com/stephancill/lazy-indexer) to set up an instance. ### Local Install dependencies ``` yarn install ``` Fill in the environment variables ``` cp .env.dev.sample .env ``` Run the development server ``` yarn dev ``` ## Todo - [ ] Feed - [x] Reverse chronological feed - [x] Pagination - [x] Number of likes, comments, and reposts - [ ] Recasts - [x] Cast detail - [x] Number of likes, comments, and reposts - [x] Paginated replies - [x] User profiles - [x] Casts - [x] Casts with replies - [ ] Media - [x] Likes - [ ] Edit profile - [x] Auth - [x] Engagement actions - [x] Post creation - [x] Text only - [x] Media - [x] Mentions - [x] Embeds - [x] Topic - [x] Post deletion - [ ] Search - [x] User - [ ] Topic - [ ] Posts - [x] Channels (now called Topics) - [x] Channel detail - [x] Channel discovery - [ ] Index channels - [x] Fix mobile layout - [ ] Rebrand - [x] Renaming (casts -> tweets, etc) - [x] Images - [ ] Code - [x] Notifications - [x] Badge counter - [x] Notifications page - [ ] Optimize - [ ] DB queries - [ ] Bandwidth ... ================================================ FILE: docker-compose.yml ================================================ version: '3.8' services: opencast: build: ./ container_name: opencast restart: unless-stopped env_file: # Set in .env # APP_FID # APP_MNENOMIC - .env environment: DATABASE_URL: postgresql://indexer:password@postgres:5432/indexer FC_HUB_URL: 'hub-grpc.pinata.cloud' FC_HUB_USE_TLS: 'true' NEXT_PUBLIC_FC_CLIENT_NAME: 'Opencast' NEXT_PUBLIC_WALLETCONNECT_ID: '0fcda49e9f4acad4b84401373fbc5a4f' NEXT_PUBLIC_URL: 'http://localhost:3000' INDEXER_API_URL: 'http://lazy-indexer:3005' ports: - '3000:3000' depends_on: - lazy-indexer networks: - app-network lazy-indexer: image: stephancill/lazy-indexer container_name: lazy-indexer env_file: # Set in .env # TARGET_SIGNER_FID (usually same as APP_FID) - .env environment: DATABASE_URL: postgresql://indexer:password@postgres:5432/indexer REDIS_URL: redis://redis:6379 HUB_REST_URL: https://hub.pinata.cloud HUB_RPC: hub-grpc.pinata.cloud HUB_SSL: true WORKER_CONCURRENCY: 5 LOG_LEVEL: debug depends_on: postgres: condition: service_healthy redis: condition: service_healthy ports: - '3005:3005' networks: - app-network postgres: image: 'postgres:16-alpine' restart: unless-stopped ports: - '5432:5432' environment: - POSTGRES_DB=indexer - POSTGRES_USER=indexer - POSTGRES_PASSWORD=password volumes: - postgres-data:/var/lib/postgresql/data healthcheck: test: ['CMD-SHELL', 'pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB'] interval: 5s # Check every 5 seconds for readiness timeout: 5s # Allow up to 5 seconds for a response retries: 3 # Fail after 3 unsuccessful attempts start_period: 10s # Start checks after 10 seconds networks: - app-network redis: image: 'redis:7.2-alpine' restart: unless-stopped command: --loglevel warning --maxmemory-policy noeviction volumes: - redis-data:/data ports: - '6379:6379' healthcheck: test: ['CMD-SHELL', 'redis-cli ping'] interval: 5s # Check every 5 seconds timeout: 5s # Allow up to 5 seconds for a response retries: 3 # Fail after 3 unsuccessful attempts start_period: 5s # Start health checks after 5 seconds networks: - app-network volumes: postgres-data: redis-data: networks: app-network: driver: bridge ================================================ FILE: jest.config.js ================================================ // jest.config.js const nextJest = require('next/jest'); const createJestConfig = nextJest({ // Provide the path to your Next.js app to load next.config.js and .env files in your test environment dir: './' }); // Add any custom config to be passed to Jest const customJestConfig = { // Add more setup options before each test is run // setupFilesAfterEnv: ['/jest.setup.js'], // if using TypeScript with a baseUrl set to the root directory then you need the below for alias' to work modulePaths: ['/src'], testEnvironment: 'jest-environment-jsdom', // Math aliases too instead of just baseUrl moduleNameMapper: { '^@components(.*)$': '/src/components$1', '^@lib(.*)$': '/src/lib$1', '^@styles(.*)$': '/src/styles$1' } }; // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async module.exports = createJestConfig(customJestConfig); ================================================ FILE: next.config.js ================================================ /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, swcMinify: true, output: "standalone", webpack: (config) => { config.resolve.fallback = { fs: false, net: false, tls: false }; return config; }, images: { remotePatterns: [ { protocol: 'https', hostname: '*' }, { protocol: 'http', hostname: '*' } ] }, experimental: { scrollRestoration: true }, async headers() { return [ { // matching all API routes source: '/api/:path*', headers: [ { key: 'Access-Control-Allow-Credentials', value: 'true' }, { key: 'Access-Control-Allow-Origin', value: '*' }, { key: 'Access-Control-Allow-Methods', value: 'GET,DELETE,PATCH,POST,PUT' }, { key: 'Access-Control-Allow-Headers', value: 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version' } ] } ]; } }; module.exports = nextConfig; ================================================ FILE: nixpacks.toml ================================================ [phases.setup] nixPkgs = ['...', 'python3', 'gcc'] [phases.install] cmds = ['yarn global add node-gyp', '...'] ================================================ FILE: package.json ================================================ { "name": "opencast", "version": "1.0.0", "private": true, "scripts": { "dev": "next dev", "build": "next build", "start": "next start", "format": "prettier --check .", "lint": "next lint", "test": "jest --watch", "test:ci": "jest --ci", "prepare": "husky install", "postinstall": "patch-package" }, "dependencies": { "@farcaster/core": "^0.13.3", "@farcaster/hub-nodejs": "^0.10.3", "@farcaster/hub-web": "^0.6.0", "@frames.js/render": "^0.2.20", "@headlessui/react": "^1.7.2", "@heroicons/react": "^2.0.11", "@noble/ed25519": "^2.0.0", "@prisma/client": "^5.1.0", "@rainbow-me/rainbowkit": "^2.1.3", "@tanstack/react-query": "^5.49.2", "clsx": "^1.2.1", "firebase": "^9.9.4", "framer-motion": "^7.2.1", "frames.js": "^0.17.1", "lodash": "^4.17.21", "lru-cache": "^10.0.1", "metadata-scraper": "^0.2.61", "next": "^14.1.4", "patch-package": "^8.0.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hot-toast": "^2.3.0", "react-qr-code": "^2.0.11", "react-query": "^3.39.3", "react-textarea-autosize": "^8.3.4", "swr": "^1.3.0", "validator": "^13.11.0", "viem": "2.x", "wagmi": "^2.10.9" }, "devDependencies": { "@testing-library/jest-dom": "^5.16.4", "@testing-library/react": "^13.3.0", "@testing-library/user-event": "^13.5.0", "@types/lodash": "^4.14.197", "@types/node": "18.6.4", "@types/react": "18.0.16", "@types/react-dom": "18.0.6", "@types/validator": "^13.11.1", "@typescript-eslint/eslint-plugin": "^5.32.0", "@typescript-eslint/parser": "^5.32.0", "autoprefixer": "^10.4.8", "dotenv": "^16.4.5", "eslint": "8.21.0", "eslint-config-next": "12.2.4", "eslint-import-resolver-typescript": "^3.4.0", "eslint-plugin-import": "^2.26.0", "eslint-plugin-react-hooks": "^4.6.0", "husky": "^8.0.1", "jest": "^28.1.3", "jest-environment-jsdom": "^28.1.3", "lint-staged": "^13.0.3", "postcss": "^8.4.16", "prettier": "^2.7.1", "prettier-plugin-tailwindcss": "^0.1.13", "prisma": "^5.1.0", "sass": "^1.54.4", "tailwindcss": "^3.2.4", "typescript": "^5.5.3" }, "lint-staged": { "**/*": "prettier --write --ignore-unknown" }, "engines": { "node": ">=18.0.0" }, "resolutions": { "ffi-napi": "https://registry.yarnpkg.com/@favware/skip-dependency/-/skip-dependency-1.0.2.tgz" } } ================================================ FILE: postcss.config.js ================================================ module.exports = { plugins: { tailwindcss: {}, autoprefixer: {} } }; ================================================ FILE: prisma/schema.prisma ================================================ generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } model casts { id String @id @default(dbgenerated("generate_ulid()")) @db.Uuid created_at DateTime @default(now()) @db.Timestamptz(6) updated_at DateTime @default(now()) @db.Timestamptz(6) timestamp DateTime @db.Timestamptz(6) deleted_at DateTime? @db.Timestamptz(6) pruned_at DateTime? @db.Timestamptz(6) fid BigInt parent_fid BigInt? hash Bytes @unique root_parent_hash Bytes? parent_hash Bytes? root_parent_url String? parent_url String? text String signer Bytes embeds Json @default("[]") @db.Json mentions Json @default("[]") @db.Json mentions_positions Json @default("[]") @db.Json @@index([timestamp], map: "casts_timestamp_index") } model fids { fid BigInt @id created_at DateTime @default(now()) @db.Timestamptz(6) updated_at DateTime @default(now()) @db.Timestamptz(6) registered_at DateTime @db.Timestamptz(6) custody_address Bytes recovery_address Bytes } model kysely_migration { name String @id @db.VarChar(255) timestamp String @db.VarChar(255) } model kysely_migration_lock { id String @id @db.VarChar(255) is_locked Int @default(0) } model links { id String @id @default(dbgenerated("generate_ulid()")) @db.Uuid created_at DateTime @default(now()) @db.Timestamptz(6) updated_at DateTime @default(now()) @db.Timestamptz(6) timestamp DateTime @db.Timestamptz(6) deleted_at DateTime? @db.Timestamptz(6) pruned_at DateTime? @db.Timestamptz(6) fid BigInt target_fid BigInt display_timestamp DateTime? @db.Timestamptz(6) type String hash Bytes @unique signer Bytes } model reactions { id String @id @default(dbgenerated("generate_ulid()")) @db.Uuid created_at DateTime @default(now()) @db.Timestamptz(6) updated_at DateTime @default(now()) @db.Timestamptz(6) timestamp DateTime @db.Timestamptz(6) deleted_at DateTime? @db.Timestamptz(6) pruned_at DateTime? @db.Timestamptz(6) fid BigInt target_cast_fid BigInt? type Int @db.SmallInt hash Bytes @unique target_cast_hash Bytes? target_url String? signer Bytes } model signers { id String? @default(dbgenerated("generate_ulid()")) @db.Uuid created_at DateTime @default(now()) @db.Timestamptz(6) updated_at DateTime @default(now()) @db.Timestamptz(6) added_at DateTime @db.Timestamptz(6) removed_at DateTime? @db.Timestamptz(6) fid BigInt requester_fid BigInt key_type Int @db.SmallInt metadata_type Int @db.SmallInt key Bytes metadata Json @db.Json @@unique([fid, key], map: "signers_fid_key_unique") @@index([fid], map: "signers_fid_index") @@index([requester_fid], map: "signers_requester_fid_index") } model user_data { id String @id @default(dbgenerated("generate_ulid()")) @db.Uuid created_at DateTime @default(now()) @db.Timestamptz(6) updated_at DateTime @default(now()) @db.Timestamptz(6) timestamp DateTime @db.Timestamptz(6) deleted_at DateTime? @db.Timestamptz(6) fid BigInt type Int @db.SmallInt hash Bytes @unique value String signer Bytes @@unique([fid, type], map: "user_data_fid_type_unique") } /// This model contains an expression index which requires additional setup for migrations. Visit https://pris.ly/d/expression-indexes for more info. /// This model contains an expression index which requires additional setup for migrations. Visit https://pris.ly/d/expression-indexes for more info. /// This model contains an expression index which requires additional setup for migrations. Visit https://pris.ly/d/expression-indexes for more info. model verifications { id String @id @default(dbgenerated("generate_ulid()")) @db.Uuid created_at DateTime @default(now()) @db.Timestamptz(6) updated_at DateTime @default(now()) @db.Timestamptz(6) timestamp DateTime @db.Timestamptz(6) deleted_at DateTime? @db.Timestamptz(6) fid BigInt hash Bytes signer_address Bytes block_hash Bytes signature Bytes @@unique([signer_address, fid], map: "verifications_signer_address_fid_unique") @@index([fid, timestamp], map: "verifications_fid_timestamp_index") } model hubs { id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid gossip_address String rpc_address String excluded_hashes String[] count Int @default(0) hub_version String network String app_version String timestamp BigInt created_at DateTime @default(now()) @db.Timestamptz(6) updated_at DateTime @default(now()) @db.Timestamptz(6) } model storage { id String? @default(dbgenerated("generate_ulid()")) @db.Uuid created_at DateTime @default(now()) @db.Timestamptz(6) updated_at DateTime @default(now()) @db.Timestamptz(6) rented_at DateTime @db.Timestamptz(6) expires_at DateTime @db.Timestamptz(6) fid BigInt units Int @db.SmallInt payer Bytes @@unique([fid, expires_at], map: "storage_fid_expires_at_unique") @@index([fid, expires_at], map: "storage_fid_expires_at_index") } model targets { id String @id @default(dbgenerated("generate_ulid()")) @db.Uuid created_at DateTime @default(now()) @db.Timestamptz(6) updated_at DateTime @default(now()) @db.Timestamptz(6) fid BigInt @unique(map: "target_fid_unique") } ================================================ FILE: public/site.webmanifest ================================================ { "name": "Opencast - Open Source Farcaster Client", "short_name": "Opencast", "description": "Fully open source Twitter flavoured Farcaster client", "display": "standalone", "start_url": "/", "theme_color": "#fff", "background_color": "#000000", "orientation": "portrait", "icons": [ { "src": "/logo192.png", "type": "image/png", "sizes": "192x192" }, { "src": "/logo512.png", "type": "image/png", "sizes": "512x512" } ] } ================================================ FILE: src/app/frames/route.ts ================================================ export { GET, POST } from '@frames.js/render/next'; ================================================ FILE: src/components/aside/aside-footer.tsx ================================================ const footerLinks = [ ['GitHub', 'https://github.com/stephancill/twitter-farcaster-client'] ] as const; export function AsideFooter(): JSX.Element { return (

Built by @stephancill. Use at own risk.

); } ================================================ FILE: src/components/aside/aside-trends.tsx ================================================ import Link from 'next/link'; import cn from 'clsx'; import { motion } from 'framer-motion'; import { formatNumber } from '@lib/date'; import { preventBubbling } from '@lib/utils'; import { useTrends } from '@lib/api/trends'; import { Error } from '@components/ui/error'; import { HeroIcon } from '@components/ui/hero-icon'; import { Button } from '@components/ui/button'; import { ToolTip } from '@components/ui/tooltip'; import { Loading } from '@components/ui/loading'; import type { MotionProps } from 'framer-motion'; export const variants: MotionProps = { initial: { opacity: 0 }, animate: { opacity: 1 }, transition: { duration: 0.8 } }; type AsideTrendsProps = { inTrendsPage?: boolean; }; export function AsideTrends({ inTrendsPage }: AsideTrendsProps): JSX.Element { const { data, loading } = useTrends(1, inTrendsPage ? 100 : 10, { refreshInterval: 30000 }); const { trends, location } = data ?? {}; return (
{loading ? ( ) : trends ? ( {!inTrendsPage && (

Trends for you

)} {trends.map(({ name, query, tweet_volume, url }) => (

Trending{' '} {location === 'Worldwide' ? 'Worldwide' : `in ${location as string}`}

{name}

{formatNumber(tweet_volume)} tweets

))} {!inTrendsPage && ( Show more )}
) : ( )}
); } ================================================ FILE: src/components/aside/aside.tsx ================================================ import { useWindow } from '@lib/context/window-context'; import Link from 'next/link'; import type { ReactNode } from 'react'; import { User } from '../../lib/types/user'; import { UserSearchResult } from '../search/user-search-result'; import { AsideFooter } from './aside-footer'; import { SearchBar } from './search-bar'; type AsideProps = { children: ReactNode; }; export function Aside({ children }: AsideProps): JSX.Element | null { const { width } = useWindow(); if (width < 1024) return null; return ( ); } ================================================ FILE: src/components/aside/search-bar.tsx ================================================ import { Button } from '@components/ui/button'; import { HeroIcon } from '@components/ui/hero-icon'; import cn from 'clsx'; import { debounce } from 'lodash'; import { useRouter } from 'next/router'; import type { ChangeEvent, FormEvent, KeyboardEvent } from 'react'; import { useCallback, useRef, useState } from 'react'; import useSWR from 'swr'; import { fetchJSON } from '../../lib/fetch'; import { BaseResponse } from '../../lib/types/responses'; import { Loading } from '../ui/loading'; export type SearchBarProps = { urlBuilder: (query: string) => string | null; resultBuilder: (data: T, callback: () => void) => JSX.Element; }; export function SearchBar({ urlBuilder, resultBuilder }: SearchBarProps): JSX.Element { const [inputValue, setInputValue] = useState(''); const [queryValue, setQueryValue] = useState(''); const [resultsVisible, setResultsVisible] = useState(false); const blurTimeout = useRef(); const handleFocusEvent = () => { clearTimeout(blurTimeout.current); setResultsVisible(true); }; const handleBlurEvent = () => { blurTimeout.current = setTimeout(() => { setResultsVisible(false); }, 200); }; const handleClickOnResult = () => { clearTimeout(blurTimeout.current); // your code for when a user clicks a search result setResultsVisible(false); setInputValue(''); setQueryValue(''); }; const { push } = useRouter(); const inputRef = useRef(null); const handleChange = (e: ChangeEvent): void => { setInputValue(e.target.value); }; const handleChangeDebounced = useCallback( debounce((e) => { setQueryValue(e.target.value); }, 1000), [] ); const handleSubmit = (e: FormEvent): void => { e.preventDefault(); // if (inputValue) void push(`/search?q=${inputValue}`); }; const clearInputValue = (focus?: boolean) => (): void => { if (focus) inputRef.current?.focus(); else inputRef.current?.blur(); setInputValue(''); }; const handleEscape = ({ key }: KeyboardEvent): void => { if (key === 'Escape') clearInputValue()(); }; const { data, isValidating } = useSWR( () => urlBuilder(queryValue), async (url) => (await fetchJSON>(url)).result, { revalidateOnFocus: false } ); return (
{/* TODO: Use SearchBar component */}
{resultsVisible && (data || isValidating) && (
{isValidating ? (
{}
) : data && data.length > 0 ? ( data?.map((result) => resultBuilder(result, () => { handleClickOnResult(); }) ) ) : (
No results
)}
)}
); } ================================================ FILE: src/components/aside/suggestions.tsx.bak ================================================ import Link from 'next/link'; import { motion } from 'framer-motion'; import { doc, limit, query, where, orderBy, documentId } from 'firebase/firestore'; import { useAuth } from '@lib/context/auth-context'; import { useCollection } from '@lib/hooks/useCollection'; import { useDocument } from '@lib/hooks/useDocument'; import { usersCollection } from '@lib/firebase/collections'; import { UserCard } from '@components/user/user-card'; import { Loading } from '@components/ui/loading'; import { Error } from '@components/ui/error'; import { variants } from './aside-trends'; export function Suggestions(): JSX.Element { const { randomSeed } = useAuth(); const { data: adminData, loading: adminLoading } = useDocument( doc(usersCollection, 'Twt0A27bx9YcG4vu3RTsR7ifJzf2'), { allowNull: true } ); const { data: suggestionsData, loading: suggestionsLoading } = useCollection( query( usersCollection, where(documentId(), '>=', randomSeed), orderBy(documentId()), limit(2) ), { allowNull: true } ); return (
{adminLoading || suggestionsLoading ? ( ) : suggestionsData ? (

Who to follow

{adminData && } {suggestionsData?.map((userData) => ( ))} Show more
) : ( )}
); } ================================================ FILE: src/components/aside/trends.tsx ================================================ import { Error } from '@components/ui/error'; import { Loading } from '@components/ui/loading'; import { formatNumber } from '@lib/date'; import cn from 'clsx'; import type { MotionProps } from 'framer-motion'; import { motion } from 'framer-motion'; import Link from 'next/link'; import useSWR from 'swr'; import { fetchJSON } from '../../lib/fetch'; import { TrendsResponse } from '../../lib/types/trends'; import { NextImage } from '../ui/next-image'; export const variants: MotionProps = { initial: { opacity: 0 }, animate: { opacity: 1 }, transition: { duration: 0.8 } }; type AsideTrendsProps = { inTrendsPage?: boolean; }; export function AsideTrends({ inTrendsPage }: AsideTrendsProps): JSX.Element { const { data: trends, isValidating: loading } = useSWR( `/api/trends?limit=${inTrendsPage ? 20 : 5}`, async (url: string) => (await fetchJSON(url)).result, { revalidateOnFocus: false } ); return (
{loading ? ( ) : trends ? ( {!inTrendsPage && (

Trending topics

)}
{trends.map(({ topic: topic, volume }) => (
{topic?.image && (
)}

{topic?.name}

{formatNumber(volume)} posts today

))}
{!inTrendsPage && ( Show more )}
) : ( )}
); } ================================================ FILE: src/components/common/app-head.tsx ================================================ import Head from 'next/head'; export function AppHead(): JSX.Element { return ( Opencast ); } ================================================ FILE: src/components/common/load-more.tsx ================================================ import { useEffect, useRef } from 'react'; // TODO: Replace other load more components with this one export function LoadMoreSentinel({ loadMore, isLoading }: { loadMore: () => void; isLoading: boolean; }) { const ref = useRef(null); useEffect(() => { const observer = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting && !isLoading) { loadMore(); } }, { root: null, rootMargin: '0px', threshold: 1.0 } ); if (ref.current) { observer.observe(ref.current); } return () => { if (ref.current) { observer.unobserve(ref.current); } }; }, [loadMore, isLoading]); return
; } ================================================ FILE: src/components/common/placeholder.tsx ================================================ import { CustomIcon } from '@components/ui/custom-icon'; import { SEO } from './seo'; export function Placeholder(): JSX.Element { return (
); } ================================================ FILE: src/components/common/seo.tsx ================================================ import { useRouter } from 'next/router'; import Head from 'next/head'; import { siteURL } from '@lib/env'; type MainLayoutProps = { title: string; image?: string; description?: string; }; export function SEO({ title, image, description }: MainLayoutProps): JSX.Element { const { asPath } = useRouter(); return ( {title} {description && } {description && } {image && } ); } ================================================ FILE: src/components/feed/tweet-feed.tsx ================================================ import useSWR from 'swr'; import useSWRInfinite from 'swr/infinite'; import { useAuth } from '../../lib/context/auth-context'; import { PaginatedTweetsResponse, TweetsResponse } from '../../lib/paginated-tweets'; import { FeedOrderingType } from '../../lib/types/feed'; import { populateTweetUsers } from '../../lib/types/tweet'; import { isPlural } from '../../lib/utils'; import { LoadMoreSentinel } from '../common/load-more'; import { Tweet } from '../tweet/tweet'; import { Error } from '../ui/error'; import { Loading } from '../ui/loading'; interface TweetFeedProps { feedOrdering: FeedOrderingType; apiEndpoint: string; } export function TweetFeed({ feedOrdering, apiEndpoint }: TweetFeedProps) { const { user, timelineCursor, setTimelineCursor } = useAuth(); const { data: pages, size, setSize, isValidating: loading, error } = useSWRInfinite( (pageIndex, prevPage) => { if (!user || !timelineCursor) return null; if (prevPage && !prevPage.result?.nextPageCursor) return null; const baseUrl = `${apiEndpoint}&limit=10&full=true&cursor=${timelineCursor.toISOString()}&ordering=${feedOrdering}`; if (pageIndex === 0) { return `${baseUrl}&skip=0`; } if (!prevPage?.result) return null; return `${baseUrl}&skip=${prevPage.result.nextPageCursor}`; }, { revalidateOnFocus: false, revalidateFirstPage: false } ); const hasMore = !!pages?.[size - 1]?.result?.tweets.length; // Fetch new tweets every 20 seconds const { data: newPage } = useSWR( !!pages && timelineCursor && feedOrdering === 'latest' ? `${apiEndpoint}&cursor=${timelineCursor.toISOString()}&limit=100&after=true` : null, null, { refreshInterval: 10_000 } ); const onShowNewTweets = () => { if (!newPage?.result?.tweets) return; const cursor = new Date(); setTimelineCursor(cursor); }; return (
<> {newPage?.result?.tweets && (newPage.result.tweets.length || 0) > 0 && ( )} {pages?.map(({ result }) => { if (!result) return; const { tweets, users } = result; return tweets.map((tweet) => { if (!users[tweet.createdBy]) { return <>; } return ( ); }); })} {hasMore && ( { setSize(size + 1); }} isLoading={loading} > )} {loading ? ( ) : ( !pages && )}
); } ================================================ FILE: src/components/frames/Frame.tsx ================================================ 'use client'; import { FarcasterFrameContext, FarcasterSigner, signFrameAction } from '@frames.js/render/farcaster'; import { useFrame } from '@frames.js/render/use-frame'; import { useAuth } from '@lib/context/auth-context'; import { Frame as FrameType } from 'frames.js'; import { useEffect, useState } from 'react'; import * as chains from 'viem/chains'; import { useAccount, useChainId, useSendTransaction, useSwitchChain } from 'wagmi'; import { FrameUI } from './frame-ui'; type FrameProps = { url: string; frame: FrameType; frameContext: FarcasterFrameContext; }; const getChainFromId = (id: number): chains.Chain | undefined => { return Object.values(chains).find((chain) => chain.id === id); }; export function Frame({ frame, frameContext, url }: FrameProps) { const { user } = useAuth(); const { address: connectedAddress } = useAccount(); const [farcasterSigner, setFarcasterSigner] = useState< FarcasterSigner | undefined >(undefined); const { sendTransactionAsync, sendTransaction } = useSendTransaction(); const currentChainId = useChainId(); const { switchChainAsync } = useSwitchChain(); useEffect(() => { if (user?.keyPair) { setFarcasterSigner({ fid: parseInt(user.id), privateKey: user.keyPair.privateKey, status: 'approved', publicKey: user.keyPair.publicKey }); } else { setFarcasterSigner(undefined); } }, [user]); const frameState = useFrame({ homeframeUrl: url, frame, frameActionProxy: '/frames', connectedAddress, frameGetProxy: '/frames', frameContext, signerState: { hasSigner: farcasterSigner !== undefined, signer: farcasterSigner, onSignerlessFramePress: () => { // Only run if `hasSigner` is set to `false` // This is a good place to throw an error or prompt the user to login // alert("A frame button was pressed without a signer. Perhaps you want to prompt a login"); }, signFrameAction: signFrameAction }, onTransaction: async ({ transactionData }) => { // Switch to the chain that the transaction is on const chainId = parseInt(transactionData.chainId.split(':')[1]); if (chainId !== currentChainId) { const newChain = await switchChainAsync?.({ chainId }); if (!newChain) { console.error('Failed to switch network'); return null; } } const hash = await sendTransactionAsync({ ...transactionData.params, value: transactionData.params.value ? BigInt(transactionData.params.value) : undefined, chainId: parseInt(transactionData.chainId.split(':')[1]) }); return hash || null; } }); return (
); } ================================================ FILE: src/components/frames/frame-ui.tsx ================================================ import { FrameUI as BaseFrameUI } from '@frames.js/render/ui'; import Image from 'next/image'; import React from 'react'; import cn from 'clsx'; import { HeroIcon } from '../ui/hero-icon'; type Props = React.ComponentProps< typeof BaseFrameUI<{ className?: string; style?: React.CSSProperties }> >; const components: Props['components'] = { Button( { frameButton, isDisabled, onPress, index, frameState }, stylingProps ) { return ( ); }, MessageTooltip(props, stylingProps) { return (
{props.status === 'error' ? ( ) : ( )} {props.message}
); }, LoadingScreen(props, stylingProps) { return (
); }, Image(props, stylingProps) { if (props.status === 'frame-loading') { return
; } return ( Frame image ); } }; const theme: Props['theme'] = { ButtonsContainer: { className: 'flex gap-[8px] px-2 pb-2' }, Root: { className: 'flex flex-col w-full gap-2 border rounded-lg overflow-hidden relative' }, Error: { className: 'flex text-red-500 text-sm p-2 border border-red-500 rounded-md shadow-md aspect-square justify-center items-center' }, LoadingScreen: { className: 'absolute top-0 left-0 right-0 bottom-0 z-10 bg-white dark:bg-light-primary' }, Image: { className: 'w-full object-cover max-h-full' }, ImageContainer: { className: 'relative w-full h-full border-b border-gray-300 overflow-hidden' }, TextInput: { className: 'p-[6px] border rounded border-gray-300 box-border w-full' }, TextInputContainer: { className: 'w-full px-2' } }; export function FrameUI(props: Props) { return ; } ================================================ FILE: src/components/home/main-container.tsx ================================================ import cn from 'clsx'; import type { ReactNode } from 'react'; type MainContainerProps = { children: ReactNode; className?: string; }; export function MainContainer({ children, className }: MainContainerProps): JSX.Element { return (
{children}
); } ================================================ FILE: src/components/home/main-header.tsx ================================================ import cn from 'clsx'; import { Button } from '@components/ui/button'; import { HeroIcon } from '@components/ui/hero-icon'; import { ToolTip } from '@components/ui/tooltip'; import { MobileSidebar } from '@components/sidebar/mobile-sidebar'; import type { ReactNode } from 'react'; import type { IconName } from '@components/ui/hero-icon'; import { NextImage } from '../ui/next-image'; type HomeHeaderProps = { tip?: string; title?: string; description?: string; children?: ReactNode; imageUrl?: string; iconName?: IconName; className?: string; disableSticky?: boolean; useActionButton?: boolean; useMobileSidebar?: boolean; action?: () => void; }; export function MainHeader({ tip, title, description, children, imageUrl, iconName, className, disableSticky, useActionButton, useMobileSidebar, action }: HomeHeaderProps): JSX.Element { return (
{useActionButton && ( )} {title && (
{imageUrl && ( )}
{useMobileSidebar && }

{title}

{description && (

{description}

)}
)} {children}
); } ================================================ FILE: src/components/home/update-username.tsx.bak ================================================ /* eslint-disable react-hooks/exhaustive-deps */ import { useState, useEffect } from 'react'; import { toast } from 'react-hot-toast'; import { checkUsernameAvailability, updateUsername } from '@lib/firebase/utils'; import { useAuth } from '@lib/context/auth-context'; import { useModal } from '@lib/hooks/useModal'; import { isValidUsername } from '@lib/validation'; import { sleep } from '@lib/utils'; import { Button } from '@components/ui/button'; import { HeroIcon } from '@components/ui/hero-icon'; import { ToolTip } from '@components/ui/tooltip'; import { Modal } from '@components/modal/modal'; import { UsernameModal } from '@components/modal/username-modal'; import { InputField } from '@components/input/input-field'; import type { FormEvent, ChangeEvent } from 'react'; export function UpdateUsername(): JSX.Element { const [alreadySet, setAlreadySet] = useState(false); const [available, setAvailable] = useState(false); const [loading, setLoading] = useState(false); const [visited, setVisited] = useState(false); const [inputValue, setInputValue] = useState(''); const [errorMessage, setErrorMessage] = useState(''); const { user } = useAuth(); const { open, openModal, closeModal } = useModal(); useEffect(() => { const checkAvailability = async (value: string): Promise => { const empty = await checkUsernameAvailability(value); if (empty) setAvailable(true); else { setAvailable(false); setErrorMessage('This username has been taken. Please choose another.'); } }; if (!visited && inputValue.length > 0) setVisited(true); if (visited) { if (errorMessage) setErrorMessage(''); const error = isValidUsername(user?.username as string, inputValue); if (error) { setAvailable(false); setErrorMessage(error); } else void checkAvailability(inputValue); } }, [inputValue]); useEffect(() => { if (!user?.updatedAt) openModal(); else setAlreadySet(true); }, []); const changeUsername = async ( e: FormEvent ): Promise => { e.preventDefault(); if (!available) return; setLoading(true); await sleep(500); await updateUsername(user?.id as string, inputValue); closeModal(); setLoading(false); setInputValue(''); setVisited(false); setAvailable(false); toast.success('Username updated successfully'); }; const cancelUpdateUsername = (): void => { closeModal(); if (!alreadySet) void updateUsername(user?.id as string); }; const handleChange = ({ target: { value } }: ChangeEvent): void => setInputValue(value); return ( <> ); } ================================================ FILE: src/components/input/image-preview.tsx ================================================ import { useEffect, useState } from 'react'; import cn from 'clsx'; import { useModal } from '@lib/hooks/useModal'; import { preventBubbling } from '@lib/utils'; import { ImageModal } from '@components/modal/image-modal'; import { Modal } from '@components/modal/modal'; import { NextImage } from '@components/ui/next-image'; import { Button } from '@components/ui/button'; import { HeroIcon } from '@components/ui/hero-icon'; import { ToolTip } from '@components/ui/tooltip'; import type { MotionProps } from 'framer-motion'; import type { ImagesPreview, ImageData } from '@lib/types/file'; type ImagePreviewProps = { tweet?: boolean; viewTweet?: boolean; previewCount: number; imagesPreview: ImagesPreview; removeImage?: (targetId: string) => () => void; }; const variants: MotionProps = { initial: { opacity: 0, scale: 0.5 }, animate: { opacity: 1, scale: 1, transition: { duration: 0.3 } }, exit: { opacity: 0, scale: 0.5 }, transition: { type: 'spring', duration: 0.5 } }; type PostImageBorderRadius = Record; const postImageBorderRadius: Readonly = { 1: ['rounded-2xl'], 2: ['rounded-tl-2xl rounded-bl-2xl', 'rounded-tr-2xl rounded-br-2xl'], 3: ['rounded-tl-2xl rounded-bl-2xl', 'rounded-tr-2xl', 'rounded-br-2xl'], 4: ['rounded-tl-2xl', 'rounded-tr-2xl', 'rounded-bl-2xl', 'rounded-br-2xl'] }; export function ImagePreview({ tweet, viewTweet, previewCount, imagesPreview, removeImage }: ImagePreviewProps): JSX.Element { const [selectedIndex, setSelectedIndex] = useState(0); const [selectedImage, setSelectedImage] = useState(null); const { open, openModal, closeModal } = useModal(); useEffect(() => { const imageData = imagesPreview[selectedIndex]; setSelectedImage(imageData); // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedIndex]); const handleSelectedImage = (index: number) => () => { setSelectedIndex(index); openModal(); }; const handleNextIndex = (type: 'prev' | 'next') => () => { const nextIndex = type === 'prev' ? selectedIndex === 0 ? previewCount - 1 : selectedIndex - 1 : selectedIndex === previewCount - 1 ? 0 : selectedIndex + 1; setSelectedIndex(nextIndex); }; const isTweet = tweet ?? viewTweet; return (
{imagesPreview.map(({ id, src, alt }, index) => ( )} ))}
); } ================================================ FILE: src/components/input/input-accent-radio.tsx ================================================ import cn from 'clsx'; import { useTheme } from '@lib/context/theme-context'; import { HeroIcon } from '@components/ui/hero-icon'; import type { Accent } from '@lib/types/theme'; type InputAccentRadioProps = { type: Accent; }; type InputAccentData = Record; const InputColors: Readonly = { yellow: 'bg-accent-yellow hover:ring-accent-yellow/10 active:ring-accent-yellow/20', blue: 'bg-accent-blue hover:ring-accent-blue/10 active:ring-accent-blue/20', pink: 'bg-accent-pink hover:ring-accent-pink/10 active:ring-accent-pink/20', purple: 'bg-accent-purple hover:ring-accent-purple/10 active:ring-accent-purple/20', orange: 'bg-accent-orange hover:ring-accent-orange/10 active:ring-accent-orange/20', green: 'bg-accent-green hover:ring-accent-green/10 active:ring-accent-green/20' }; export function InputAccentRadio({ type }: InputAccentRadioProps): JSX.Element { const { accent, changeAccent } = useTheme(); const bgColor = InputColors[type]; const isChecked = type === accent; return ( ); } ================================================ FILE: src/components/input/input-field.tsx ================================================ import cn from 'clsx'; import type { User, EditableData } from '@lib/types/user'; import type { KeyboardEvent, ChangeEvent } from 'react'; export type InputFieldProps = { label: string; inputId: EditableData | Extract; inputValue: string | null; inputLimit?: number; useTextArea?: boolean; errorMessage?: string; handleChange: ( e: ChangeEvent ) => void; handleKeyboardShortcut?: ({ key, ctrlKey }: KeyboardEvent) => void; }; export function InputField({ label, inputId, inputValue, inputLimit, useTextArea, errorMessage, handleChange, handleKeyboardShortcut }: InputFieldProps): JSX.Element { const slicedInputValue = inputValue?.slice(0, inputLimit) ?? ''; const inputLength = slicedInputValue.length; const isHittingInputLimit = inputLimit && inputLength > inputLimit; return (
{useTextArea ? (