diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 17f6f6ab..6f59bf02 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/devcontainers/base:jammy as base +FROM mcr.microsoft.com/devcontainers/base:ubuntu as base # install sqlite3 and libc++1 RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 24c205b4..00840733 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -9,7 +9,7 @@ "ghcr.io/devcontainers/features/git:1": {}, "ghcr.io/devcontainers/features/common-utils:2": {}, "ghcr.io/devcontainers/features/node:1": { - "version": "18.3" + "version": "lts" }, "ghcr.io/devcontainers/features/github-cli:1": {}, "ghcr.io/devcontainers/features/docker-in-docker:2": {}, diff --git a/.github/workflows/stripe-build.yml b/.github/workflows/stripe-build.yml deleted file mode 100644 index 504c8220..00000000 --- a/.github/workflows/stripe-build.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Create and publish Discord workers - -on: - workflow_call: - inputs: - environment: - required: true - type: string - -jobs: - deploy: - runs-on: ubuntu-latest - environment: ${{ inputs.environment }} - name: Deploy - steps: - - uses: actions/checkout@v3 - - - uses: pnpm/action-setup@v2 - with: - version: 7 - run_install: false - - - uses: actions/setup-node@v3 - with: - node-version: 18.3 - cache: "pnpm" - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Turbo build - run: npx turbo build --no-cache - working-directory: ./apps/stripe - - - name: Deploy - run: | - npx wrangler deploy --env ${{ inputs.environment }} \ - --minify \ - --var STRIPE_WEBHOOK_SECRET:$STRIPE_WEBHOOK_SECRET \ - STRIPE_SECRET_KEY:$STRIPE_SECRET_KEY \ - PREMIUM_PRODUCT_ID:$PREMIUM_PRODUCT_ID \ - PLUS_PRODUCT_ID:$PLUS_PRODUCT_ID - - working-directory: ./apps/stripe - env: - STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }} - STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }} - PREMIUM_PRODUCT_ID: ${{ secrets.PREMIUM_PRODUCT_ID }} - PLUS_PRODUCT_ID: ${{ secrets.PLUS_PRODUCT_ID }} - CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }} diff --git a/.github/workflows/workers-prd.yml b/.github/workflows/workers-prd.yml index 6cae3393..f52a9e9e 100644 --- a/.github/workflows/workers-prd.yml +++ b/.github/workflows/workers-prd.yml @@ -30,15 +30,6 @@ jobs: with: environment: production - stripe: - name: Deploy Stripe Webhooks - needs: deploy - uses: dougley/frugal/.github/workflows/stripe-build.yml@dev - if: github.event.inputs.skipdeploy != 'true' - secrets: inherit - with: - environment: production - sentry: name: Create Sentry release needs: deploy diff --git a/.github/workflows/workers-stg.yml b/.github/workflows/workers-stg.yml index d9de59fb..5b9e0f2a 100644 --- a/.github/workflows/workers-stg.yml +++ b/.github/workflows/workers-stg.yml @@ -35,15 +35,6 @@ jobs: with: environment: staging - stripe: - name: Deploy Stripe Webhooks - needs: deploy - uses: dougley/frugal/.github/workflows/stripe-build.yml@dev - if: github.event.inputs.skipdeploy != 'true' - secrets: inherit - with: - environment: staging - sentry: name: Create Sentry release needs: deploy diff --git a/.npmrc b/.npmrc index f87a0443..3b1dc5fe 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1,2 @@ -auto-install-peers=true \ No newline at end of file +auto-install-peers=true +link-workspace-packages=true \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 753448fd..b3a6b1d3 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -2,11 +2,10 @@ "recommendations": [ "dbaeumer.vscode-eslint", "IronGeek.vscode-env", - "bungcip.better-toml", + "tamasfe.even-better-toml", "esbenp.prettier-vscode", "ms-azuretools.vscode-docker", "github.vscode-github-actions", - "stripe.vscode-stripe", "bradlc.vscode-tailwindcss", "mtxr.sqltools-driver-sqlite", "mtxr.sqltools" diff --git a/apps/stripe/.eslintrc.json b/apps/discord-new/.eslintrc.json similarity index 100% rename from apps/stripe/.eslintrc.json rename to apps/discord-new/.eslintrc.json diff --git a/apps/discord-new/.gitignore b/apps/discord-new/.gitignore new file mode 100644 index 00000000..a1b29ba8 --- /dev/null +++ b/apps/discord-new/.gitignore @@ -0,0 +1,6 @@ +dist +node_modules +/.vscode/settings.json +*.log +*.env +.dev.vars diff --git a/apps/discord-new/.prettierrc.json b/apps/discord-new/.prettierrc.json new file mode 100644 index 00000000..19448bea --- /dev/null +++ b/apps/discord-new/.prettierrc.json @@ -0,0 +1,9 @@ +{ + "plugins": ["prettier-plugin-organize-imports"], + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "useTabs": false, + "trailingComma": "none", + "printWidth": 120 +} diff --git a/apps/discord-new/package.json b/apps/discord-new/package.json new file mode 100644 index 00000000..0cbf3482 --- /dev/null +++ b/apps/discord-new/package.json @@ -0,0 +1,39 @@ +{ + "name": "@dougley/frugal-discord-new", + "type": "module", + "version": "0.0.0", + "private": true, + "main": "dist/index.mjs", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "dev": "wrangler dev --persist-to='../../.mf' src/index.ts", + "deploy": "wrangler deploy --minify src/index.ts", + "lint": "eslint --ext .ts .", + "lint:fix": "eslint --ext .ts . --fix", + "format": "prettier -w ./src", + "db:migrate:dev": "wrangler d1 migrations apply frugal --local --persist-to='../../.mf'", + "db:migrate": "wrangler d1 migrations apply frugal" + }, + "repository": { + "type": "git", + "url": "https://github.com/Dougley/frugal.git", + "directory": "apps/discord" + }, + "dependencies": { + "@discord-interactions/api": "^0.3.21", + "@discord-interactions/builders": "^0.3.18", + "@discord-interactions/core": "^0.3.23", + "@dougley/frugal-savestate": "workspace:^", + "@sentry/cloudflare": "^8.34.0", + "hono": "^4.6.4", + "kysely": "catalog:", + "kysely-d1": "catalog:" + }, + "devDependencies": { + "@cloudflare/workers-types": "catalog:", + "@dougley/tsconfig": "workspace:^", + "@dougley/types": "workspace:^", + "better-sqlite3": "^11.3.0", + "wrangler": "catalog:" + } +} diff --git a/apps/discord-new/src/commands/index.ts b/apps/discord-new/src/commands/index.ts new file mode 100644 index 00000000..360f8026 --- /dev/null +++ b/apps/discord-new/src/commands/index.ts @@ -0,0 +1,6 @@ +import { PingSlashCommand } from './slash/ping'; + +export { + // slash commands + PingSlashCommand +}; diff --git a/apps/discord-new/src/commands/slash/ping.ts b/apps/discord-new/src/commands/slash/ping.ts new file mode 100644 index 00000000..ef1fca33 --- /dev/null +++ b/apps/discord-new/src/commands/slash/ping.ts @@ -0,0 +1,16 @@ +import { MessageBuilder, SlashCommandBuilder } from '@discord-interactions/builders'; +import { ISlashCommand, SlashCommandContext } from '@discord-interactions/core'; + +export class PingSlashCommand implements ISlashCommand { + public builder = new SlashCommandBuilder('ping', "Test the bot's latency"); + + public handler = async (ctx: SlashCommandContext): Promise => { + await ctx.defer(); + const msg = await ctx.send(new MessageBuilder().setContent('🏓 Pinging...')); + const sigOffset = Date.now() - ctx.signedAt.getTime(); + const rtt = new Date(msg.timestamp).getTime() - ctx.receivedAt.getTime(); + await ctx.edit( + new MessageBuilder().setContent(`🏓 Pong! Signature offset is \`${sigOffset}\`ms, RTT is \`${rtt}\`ms`) + ); + }; +} diff --git a/apps/discord-new/src/index.ts b/apps/discord-new/src/index.ts new file mode 100644 index 00000000..03dee65b --- /dev/null +++ b/apps/discord-new/src/index.ts @@ -0,0 +1,122 @@ +import { + CommandManager, + DiscordApplication, + InteractionHandlerError, + InteractionHandlerNotFound, + InteractionHandlerTimedOut, + UnauthorizedInteraction, + UnknownApplicationCommandType, + UnknownComponentType, + UnknownInteractionType +} from '@discord-interactions/core'; + +import * as Sentry from '@sentry/cloudflare'; +import * as Commands from './commands'; + +export default Sentry.withSentry( + (env) => ({ + dsn: env.SENTRY_DSN, + // Set tracesSampleRate to 1.0 to capture 100% of spans for tracing. + tracesSampleRate: 1.0 + }), + { + async fetch(req: Request, env, ctx) { + if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 }); + + Sentry.instrumentD1WithSentry(env.D1); + + const app = new DiscordApplication({ + clientId: env.DISCORD_APP_ID, + token: env.DISCORD_BOT_TOKEN, + publicKey: env.DISCORD_PUBLIC_KEY, + + //syncMode: SyncMode.Disabled, + + cache: { + get: async (key: string) => env.KV.get(key, 'text'), + set: async (key: string, ttl: number, value: string) => { + await env.KV.put(key, value, { expirationTtl: ttl }); + } + } + }); + + const commandsToRegister = Object.values(Commands).map((Command) => new Command()); + + if (env.DEVELOPMENT_GUILD) { + console.log('Registering commands in development guild'); + const guild = new CommandManager(app, env.DEVELOPMENT_GUILD); + await guild.register(...commandsToRegister); + } else { + await app.commands.register(...commandsToRegister); + } + + const signature = req.headers.get('x-signature-ed25519'); + const timestamp = req.headers.get('x-signature-timestamp'); + + const body = await req.text(); + + if (typeof body !== 'string' || typeof signature !== 'string' || typeof timestamp !== 'string') { + return new Response('Invalid request', { status: 400 }); + } + + try { + const [getResponse, handling] = await app.handleInteraction(body, signature, timestamp); + + ctx.waitUntil(handling); + const response = await getResponse; + + if (response instanceof FormData) { + return new Response(response); + } + + return new Response(JSON.stringify(response), { + headers: { + 'content-type': 'application/json;charset=UTF-8' + } + }); + } catch (err) { + if (err instanceof UnauthorizedInteraction) { + console.error('Unauthorized Interaction'); + return new Response('Invalid request', { status: 401 }); + } + + if (err instanceof InteractionHandlerNotFound) { + console.error('Interaction Handler Not Found'); + console.dir(err.interaction); + + new Response('Invalid request', { status: 404 }); + } + + if (err instanceof InteractionHandlerTimedOut) { + console.error('Interaction Handler Timed Out'); + + new Response('Timed Out', { status: 408 }); + } + + if ( + err instanceof UnknownInteractionType || + err instanceof UnknownApplicationCommandType || + err instanceof UnknownComponentType + ) { + console.error('Unknown Interaction - Library may be out of date.'); + console.dir(err.interaction); + + new Response('Server Error', { status: 500 }); + } + + if (err instanceof InteractionHandlerError) { + console.error('Interaction Handler Error'); + console.error(err.cause); + + new Response('Server Error', { status: 500 }); + } + + console.error(err); + } + + return new Response('Unknown Error', { status: 500 }); + } + } satisfies ExportedHandler +); + +// durable objects diff --git a/apps/stripe/tsconfig.json b/apps/discord-new/tsconfig.json similarity index 100% rename from apps/stripe/tsconfig.json rename to apps/discord-new/tsconfig.json diff --git a/apps/stripe/wrangler.toml b/apps/discord-new/wrangler.toml similarity index 55% rename from apps/stripe/wrangler.toml rename to apps/discord-new/wrangler.toml index cbd5c2c0..8dca2e07 100644 --- a/apps/stripe/wrangler.toml +++ b/apps/discord-new/wrangler.toml @@ -1,7 +1,12 @@ -name = "frugal-stripe-dev" +# --- Defaults --- # + +name = "frugal-core-dev" main = "src/index.ts" -compatibility_date = "2023-04-09" +compatibility_date = "2024-04-05" workers_dev = true +# no_bundle = true + +compatibility_flags = ["nodejs_als"] [[d1_databases]] binding = "D1" @@ -10,8 +15,15 @@ database_id = "ddc7d747-ada2-4e1c-9946-62856277f9a1" preview_database_id = "ddc7d747-ada2-4e1c-9946-62856277f9a1" migrations_dir = "../../packages/d1-database/migrations" +[[kv_namespaces]] +binding = "KV" +id = "dad8c886278d4341aed8926d38747ea4" +preview_id = "dad8c886278d4341aed8926d38747ea4" + +# --- Production --- # + [env.production] -name = "frugal-stripe-prod" +name = "frugal-core-prod" [[env.production.d1_databases]] binding = "D1" @@ -19,11 +31,21 @@ database_name = "frugal" database_id = "a09d43b8-850e-4f4d-ba91-fe47d3be0ec9" migrations_dir = "../../packages/d1-database/migrations" +[[env.production.kv_namespaces]] +binding = "KV" +id = "dad8c886278d4341aed8926d38747ea4" + +# --- Staging --- # + [env.staging] -name = "frugal-stripe-stg" +name = "frugal-core-stg" [[env.staging.d1_databases]] binding = "D1" database_name = "frugal" database_id = "b3f0981c-e213-4b28-be7f-1ba9ad731024" migrations_dir = "../../packages/d1-database/migrations" + +[[env.staging.kv_namespaces]] +binding = "KV" +id = "dad8c886278d4341aed8926d38747ea4" diff --git a/apps/discord/package.json b/apps/discord/package.json index a1c5e8fd..fb7fcc7d 100644 --- a/apps/discord/package.json +++ b/apps/discord/package.json @@ -22,25 +22,25 @@ "directory": "apps/discord" }, "devDependencies": { - "@cloudflare/workers-types": "^4.20240405.0", + "@cloudflare/workers-types": "catalog:", "@dougley/tsconfig": "workspace:^", "@dougley/types": "workspace:^", - "@sentry/esbuild-plugin": "^2.16.1", - "better-sqlite3": "9.5.0", + "@sentry/esbuild-plugin": "^2.22.5", + "better-sqlite3": "11.3.0", "dotenv": "^16.4.5", - "esbuild": "^0.20.2", - "miniflare": "^3.20240405.1", - "rimraf": "^5.0.5", + "esbuild": "^0.24.0", + "miniflare": "^3.20241004.0", + "rimraf": "^6.0.1", "slash-up": "^1.4.2", - "wrangler": "^3.50.0" + "wrangler": "catalog:" }, "dependencies": { "@dougley/d1-database": "workspace:^", "@dougley/frugal-giveaways-do": "workspace:^", "@ticketbridge/hyper-durable": "0.2.0-rc1", "do-proxy": "^1.3.4", - "kysely": "^0.27.3", + "kysely": "^0.27.4", "kysely-d1": "^0.3.0", - "slash-create": "^6.1.2" + "slash-create": "^6.3.0" } } diff --git a/apps/stripe/.gitignore b/apps/stripe/.gitignore deleted file mode 100644 index 49b38889..00000000 --- a/apps/stripe/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.dev.vars \ No newline at end of file diff --git a/apps/stripe/README.md b/apps/stripe/README.md deleted file mode 100644 index 9fc8058d..00000000 --- a/apps/stripe/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# Stripe Webhooks (`@dougley/frugal-stripe`) - -This package holds code for accepting webhooks from Stripe. - -Put simply, this app is responsible for handling webhooks from Stripe and updating the database accordingly. - -We listen to the following events from Stripe: - -- `checkout.session.completed` -- `customer.subscription.created` -- `customer.subscription.updated` -- `customer.subscription.deleted` -- `invoice.paid` - -## Development Setup - -1. Run `pnpm install` - - Install [pnpm](https://pnpm.io/) if you don't have it already -2. Create a Stripe account, if you haven't already -3. Create a `.env` file in the `stripe-webhook` directory - - Copy the contents of `.env.example` into it - - Replace the placeholder values with your own -4. Run `pnpm dev` to start the development server -5. Using the `stripe` CLI, run `stripe listen --forward-to http://localhost:8787` - - If the CLI is not installed, see [here](https://stripe.com/docs/stripe-cli#install), it's pre-installed on our devcontainer configuration. - -## Deployment - -1. Run `npx wrangler login` to log in to Cloudflare -2. Run `pnpm deploy` to deploy the worker to Cloudflare Workers diff --git a/apps/stripe/package.json b/apps/stripe/package.json deleted file mode 100644 index 4a1d0772..00000000 --- a/apps/stripe/package.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "@dougley/frugal-stripe", - "version": "0.0.0", - "private": true, - "scripts": { - "dev": "wrangler dev --persist-to='../../.mf' src/index.ts", - "deploy": "wrangler deploy --minify src/index.ts" - }, - "repository": { - "type": "git", - "url": "https://github.com/Dougley/frugal.git", - "directory": "apps/stripe" - }, - "dependencies": { - "@dougley/d1-database": "workspace:^", - "@dougley/tsconfig": "workspace:^", - "@dougley/types": "workspace:^", - "hono": "^4.2.4", - "kysely": "^0.27.3", - "kysely-d1": "^0.3.0", - "stripe": "^14.25.0" - }, - "devDependencies": { - "@cloudflare/workers-types": "^4.20240405.0", - "wrangler": "^3.50.0" - } -} diff --git a/apps/stripe/src/index.ts b/apps/stripe/src/index.ts deleted file mode 100644 index 3eb302f1..00000000 --- a/apps/stripe/src/index.ts +++ /dev/null @@ -1,123 +0,0 @@ -import type { Database } from "@dougley/d1-database"; -import { Hono } from "hono"; -import { Kysely } from "kysely"; -import { D1Dialect } from "kysely-d1"; -import Stripe from "stripe"; - -export const webCrypto = Stripe.createSubtleCryptoProvider(); - -const app = new Hono<{ - Bindings: Env & { - STRIPE_WEBHOOK_SECRET: string; - STRIPE_SECRET_KEY: string; - PREMIUM_PRODUCT_ID: string; - PLUS_PRODUCT_ID: string; - }; -}>(); - -app.post("/webhook", async (c) => { - const stripe = new Stripe(c.env.STRIPE_SECRET_KEY, { - httpClient: Stripe.createFetchHttpClient(), - }); - const body = await c.req.text(); - const db = new Kysely({ - dialect: new D1Dialect({ database: c.env.D1 }), - }); - let event: Stripe.Event = JSON.parse(body); - if (c.env.STRIPE_WEBHOOK_SECRET) { - const signature = c.req.headers.get("stripe-signature")!; - try { - event = await stripe.webhooks.constructEventAsync( - body, - signature, - c.env.STRIPE_WEBHOOK_SECRET, - undefined, - webCrypto - ); - } catch (err) { - console.log("Invalid signature", err); - return c.text("Invalid signature", 400); - } - } else { - console.log( - "STRIPE_WEBHOOK_SECRET not set, skipping signature verification. This is incredibly insecure and should only be used for testing." - ); - } - console.log("Received event", event.type); - switch (event.type) { - case "customer.subscription.created": { - // since we cant get the discord id from the subscription object, just assume we'll get it later from checkout.session.completed - const subscription = event.data.object as Stripe.Subscription; - await db - .updateTable("premium_subscriptions") - .set({ - subscription_tier: - subscription.items.data[0].plan.product === c.env.PREMIUM_PRODUCT_ID - ? "premium" - : "basic", - stripe_subscription_id: subscription.id, - updated_at: new Date(subscription.created * 1000).toISOString(), - }) - .where("stripe_customer_id", "=", subscription.customer as string) - .execute(); - return c.text("OK"); - } - case "customer.subscription.updated": { - const subscription = event.data.object as Stripe.Subscription; - console.log( - subscription.items.data[0].plan.product, - "should be", - subscription.items.data[0].plan.product === c.env.PREMIUM_PRODUCT_ID - ? "premium" - : "basic" - ); - // subscriptions update when they are canceled, or when someone changes plans - await db - .updateTable("premium_subscriptions") - .set({ - active: ["active", "trialing"].includes(subscription.status) ? 1 : 0, - subscription_tier: - subscription.items.data[0].plan.product === c.env.PREMIUM_PRODUCT_ID - ? "premium" - : "basic", - }) - .where("stripe_subscription_id", "=", subscription.id) - .execute(); - return c.text("OK"); - } - case "customer.subscription.deleted": { - // dont delete the subscription from the database, just mark it as inactive - // we can reuse their customer id if they resubscribe - const subscription = event.data.object as Stripe.Subscription; - await db - .updateTable("premium_subscriptions") - .where("stripe_subscription_id", "=", subscription.id) - .set({ - active: 0, - }) - .execute(); - return c.text("OK"); - } - case "invoice.paid": { - const invoice = event.data.object as Stripe.Invoice; - if (!invoice.subscription) { - console.log("Invoice paid without a subscription, ignoring"); - } else { - await db - .updateTable("premium_subscriptions") - .set({ - active: invoice.paid ? 1 : 0, - }) - .where("stripe_subscription_id", "=", invoice.subscription as string) - .execute(); - } - return c.text("OK"); - } - default: { - console.log("Unhandled event type", event.type); - return c.text("Unhandled event type", 400); - } - } -}); - -export default app; diff --git a/apps/web-new/.eslintrc.cjs b/apps/web-new/.eslintrc.cjs new file mode 100644 index 00000000..c7d65046 --- /dev/null +++ b/apps/web-new/.eslintrc.cjs @@ -0,0 +1,87 @@ +/** + * This is intended to be a basic starting point for linting in your app. + * It relies on recommended configs out of the box for simplicity, but you can + * and should modify this configuration to best suit your team's needs. + */ + +/** @type {import('eslint').Linter.Config} */ +module.exports = { + root: true, + parserOptions: { + ecmaVersion: "latest", + sourceType: "module", + ecmaFeatures: { + jsx: true, + }, + }, + env: { + browser: true, + commonjs: true, + es6: true, + }, + ignorePatterns: ["!**/.server", "!**/.client"], + + // Base config + extends: ["eslint:recommended"], + + overrides: [ + // React + { + files: ["**/*.{js,jsx,ts,tsx}"], + plugins: ["react", "jsx-a11y"], + extends: [ + "plugin:react/recommended", + "plugin:react/jsx-runtime", + "plugin:react-hooks/recommended", + "plugin:jsx-a11y/recommended", + ], + settings: { + react: { + version: "detect", + }, + formComponents: ["Form"], + linkComponents: [ + { name: "Link", linkAttribute: "to" }, + { name: "NavLink", linkAttribute: "to" }, + ], + "import/resolver": { + typescript: { + project: ["./tsconfig.json"], + }, + }, + }, + }, + + // Typescript + { + files: ["**/*.{ts,tsx}"], + plugins: ["@typescript-eslint", "import"], + parser: "@typescript-eslint/parser", + settings: { + "import/internal-regex": "^~/", + "import/resolver": { + node: { + extensions: [".ts", ".tsx"], + }, + typescript: { + alwaysTryTypes: true, + project: ["./tsconfig.json"], + }, + }, + }, + extends: [ + "plugin:@typescript-eslint/recommended", + "plugin:import/recommended", + "plugin:import/typescript", + ], + }, + + // Node + { + files: [".eslintrc.cjs"], + env: { + node: true, + }, + }, + ], +}; diff --git a/apps/web-new/.gitignore b/apps/web-new/.gitignore new file mode 100644 index 00000000..e5bc2d52 --- /dev/null +++ b/apps/web-new/.gitignore @@ -0,0 +1,11 @@ +node_modules + +/.cache +/build +.env +.dev.vars + +.wrangler + +# Sentry Config File +.env.sentry-build-plugin diff --git a/apps/web-new/README.md b/apps/web-new/README.md new file mode 100644 index 00000000..dec7f30b --- /dev/null +++ b/apps/web-new/README.md @@ -0,0 +1,47 @@ +# Welcome to Remix + Cloudflare! + +- 📖 [Remix docs](https://remix.run/docs) +- 📖 [Remix Cloudflare docs](https://remix.run/guides/vite#cloudflare) + +## Development + +Run the dev server: + +```sh +npm run dev +``` + +To run Wrangler: + +```sh +npm run build +npm run start +``` + +## Typegen + +Generate types for your Cloudflare bindings in `wrangler.toml`: + +```sh +npm run typegen +``` + +You will need to rerun typegen whenever you make changes to `wrangler.toml`. + +## Deployment + +First, build your app for production: + +```sh +npm run build +``` + +Then, deploy your app to Cloudflare Pages: + +```sh +npm run deploy +``` + +## Styling + +This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever css framework you prefer. See the [Vite docs on css](https://vitejs.dev/guide/features.html#css) for more information. diff --git a/apps/web-new/app/.server/wikis.tsx b/apps/web-new/app/.server/wikis.tsx new file mode 100644 index 00000000..640b6fac --- /dev/null +++ b/apps/web-new/app/.server/wikis.tsx @@ -0,0 +1,50 @@ +// @ts-nocheck +// prettier-ignore + +export type Frontmatter = { + title: string; + description: string; + published: string; // YYYY-MM-DD + featured: boolean; +}; + +export type WikiMeta = { + slug: string; + frontmatter: Frontmatter; +}; + +export const getWikis = async (): Promise => { + const modules = import.meta.glob<{ frontmatter: Frontmatter }>( + "../routes/wiki.*.mdx", + { eager: true }, + ); + const build = await import("virtual:remix/server-build"); + const wikis = Object.entries(modules).map(([file, wiki]) => { + let id = file.replace("../", "").replace(/\.mdx$/, ""); + let slug = build.routes[id].path; + if (slug === undefined) throw new Error(`No route for ${id}`); + + return { + slug, + frontmatter: wiki.frontmatter, + }; + }); + return sortBy(wikis, (wiki) => wiki.frontmatter.published, "desc"); +}; + +function sortBy( + arr: T[], + key: (item: T) => any, + dir: "asc" | "desc" = "asc", +) { + return arr.sort((a, b) => { + const res = compare(key(a), key(b)); + return dir === "asc" ? res : -res; + }); +} + +function compare(a: T, b: T): number { + if (a < b) return -1; + if (a > b) return 1; + return 0; +} diff --git a/apps/web-new/app/components/Callout.tsx b/apps/web-new/app/components/Callout.tsx new file mode 100644 index 00000000..a949fbac --- /dev/null +++ b/apps/web-new/app/components/Callout.tsx @@ -0,0 +1,245 @@ +import { type ClassValue, clsx } from "clsx"; +import type { FC, ReactNode } from "react"; +import { + LuCheck as CheckIcon, + LuChevronRight as ChevronRightIcon, + LuPin as DrawingPinIcon, + LuAlertTriangle as ExclamationTriangleIcon, + LuPencil as Pencil1Icon, + LuHelpCircle as QuestionMarkIcon, + LuRocket as RocketIcon, +} from "react-icons/lu"; +import { twMerge } from "tailwind-merge"; + +const tw = (strings: TemplateStringsArray, ...args: unknown[]) => + strings.reduce((acc, str, i) => acc + str + (args.at(i) ?? ""), ""); + +const cn = (...inputs: ClassValue[]) => { + return twMerge(clsx(inputs)); +}; + +type Callout = { + label: string; + icon: ReactNode; + className: { + root: string; + title: string; + }; +}; + +export const callouts = { + note: { + label: "Note", + icon: , + className: { + root: tw`border-base-content/50`, + title: tw``, + }, + }, + abstract: { + label: "Abstract", + icon: , + className: { + root: tw`bg-secondary/10 border-secondary/20`, + title: tw`text-secondary`, + }, + }, + important: { + label: "Important", + icon: , + className: { + root: tw`bg-primary/10 border-primary/20`, + title: tw`text-primary`, + }, + }, + success: { + label: "Success", + icon: , + className: { + root: tw`bg-success/10 border-success/20`, + title: tw`text-success`, + }, + }, + question: { + label: "Question", + icon: , + className: { + root: tw`bg-warning/10 border-warning/20`, + title: tw`text-warning`, + }, + }, + caution: { + label: "Caution", + icon: , + className: { + root: tw`bg-error/10 border-error/20`, + title: tw`text-error`, + }, + }, +} as const satisfies Record; + +const getCallout = (type: keyof typeof callouts) => + callouts[type] ?? callouts.note; + +export type CalloutProps = { + type: keyof typeof callouts; + isFoldable: boolean; + defaultFolded?: boolean; + title?: ReactNode; + className?: string; + children: ReactNode; +}; + +export const Callout: FC = ({ + type, + isFoldable, + defaultFolded, + title, + children, + className, +}) => { + const callout = getCallout(type); + const isFoldableString = isFoldable.toString() as "true" | "false"; + const defaultFoldedString = defaultFolded?.toString() as + | "true" + | "false" + | undefined; + + return ( + + + {title} + + {children} + + ); +}; + +type DetailsProps = { + isFoldable: boolean; + defaultFolded?: boolean; + children: ReactNode; + className?: string; +}; + +const Details: FC = ({ + isFoldable, + defaultFolded, + children, + ...props +}) => { + return isFoldable ? ( +
+ {children} +
+ ) : ( +
{children}
+ ); +}; + +type SummaryProps = { + isFoldable: boolean; + children: ReactNode; + className?: string; +}; + +const Summary: FC = ({ isFoldable, children, ...props }) => { + return isFoldable ? ( + {children} + ) : ( +
{children}
+ ); +}; + +export type CalloutRootProps = { + type: keyof typeof callouts; + isFoldable: "true" | "false"; + defaultFolded?: "true" | "false"; + className?: string; + children: ReactNode; +}; + +export const CalloutRoot: FC = ({ + children, + className, + type, + isFoldable: isFoldableString, + defaultFolded: defaultFoldedString, +}) => { + const callout = getCallout(type); + const isFoldable = isFoldableString === "true"; + const defaultFolded = defaultFoldedString === "true"; + + return ( +
+ {children} +
+ ); +}; + +export type CalloutTitleProps = { + type: keyof typeof callouts; + className?: string; + children?: ReactNode; + isFoldable: "true" | "false"; +}; + +export const CalloutTitle: FC = ({ + type, + isFoldable: isFoldableString, + children, +}) => { + const callout = getCallout(type); + const isFoldable = isFoldableString === "true"; + + return ( + + {callout.icon} +
{children ?? callout.label}
+ {isFoldable && ( + + )} +
+ ); +}; + +export type CalloutBodyProps = { + className?: string; + children: ReactNode; +}; + +export const CalloutBody: FC = ({ children }) => { + return ( +
+ {children} +
+ ); +}; diff --git a/apps/web-new/app/components/Document.tsx b/apps/web-new/app/components/Document.tsx new file mode 100644 index 00000000..0cdc74ae --- /dev/null +++ b/apps/web-new/app/components/Document.tsx @@ -0,0 +1,71 @@ +import { useRouteLoaderData } from "@remix-run/react"; +import { Toaster } from "react-hot-toast"; +import { DrawerProvider, useDrawer } from "./contexts/DrawerContext"; +import { Theme, ThemeProvider } from "./contexts/ThemeContext"; +import { DrawerMenu } from "./DrawerMenu"; +import { FooterContent } from "./FooterContent"; +import { GlobalLoading } from "./GlobalLoading"; +import { Navbar } from "./Navbar"; + +export function Document({ children }: React.PropsWithChildren<{}>) { + const data = useRouteLoaderData<{ + theme: Theme; + }>("root"); + const theme = data?.theme; + return ( + + +
+
+ +
+ {children} +
+
+
+
+
+ ); +} + +const DrawerContent: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const { isDrawerOpen, toggleDrawer } = useDrawer(); + + return ( + <> + +
+
+ +
+
+ + {children} +
+
+ +
+
+
+ + +
+ + ); +}; diff --git a/apps/web-new/app/components/DrawerMenu.tsx b/apps/web-new/app/components/DrawerMenu.tsx new file mode 100644 index 00000000..b8a9cf21 --- /dev/null +++ b/apps/web-new/app/components/DrawerMenu.tsx @@ -0,0 +1,101 @@ +import { Link, NavLink } from "@remix-run/react"; +import React, { Suspense } from "react"; +import { + LuExternalLink, + LuGem, + LuHome, + LuPartyPopper, + LuScroll, + LuSettings, +} from "react-icons/lu"; +import { useDrawer } from "./contexts/DrawerContext"; +import { ProfileMenu } from "./ProfileMenu"; +import { ThemeSwitcher } from "./ThemeChanger"; + +// import { ThemeSwitcher } from "./ThemeSwitcher"; + +export function DrawerMenu(): React.ReactElement { + const { toggleDrawer } = useDrawer(); + return ( + + ); +} diff --git a/apps/web-new/app/components/FooterContent.tsx b/apps/web-new/app/components/FooterContent.tsx new file mode 100644 index 00000000..d5af3c06 --- /dev/null +++ b/apps/web-new/app/components/FooterContent.tsx @@ -0,0 +1,82 @@ +import { Link } from "@remix-run/react"; +import { getClient } from "@sentry/remix"; +import { useEffect, useState, type ReactElement } from "react"; +import { FaBluesky, FaGithub } from "react-icons/fa6"; +import { RiRemixRunFill } from "react-icons/ri"; +import Logo from "~/components/Logo"; + +export function FooterContent(): ReactElement { + return ( +
+
+ + Terms of Service + + + Privacy Policy + +
+
+ + + +
+
+ + + + + + +
+
+ + Powered by + + Remix + + + + + + +
+
+ ); +} + +const EnvironmentBadge: React.FC = () => { + const options = getClient()?.getOptions(); + const environment = options?.environment; + + switch (environment) { + case "production": + return PROD; + case "staging": + return STG; + default: + return DEV; + } +}; + +const ReleaseRef: React.FC = () => { + const [release, setRelease] = useState(null); + + useEffect(() => { + const options = getClient()?.getOptions(); + setRelease(options?.release || null); + }, []); + + if (!release) { + return ; + } + + return {release}; +}; diff --git a/apps/web-new/app/components/GlobalLoading.tsx b/apps/web-new/app/components/GlobalLoading.tsx new file mode 100644 index 00000000..fed4e69a --- /dev/null +++ b/apps/web-new/app/components/GlobalLoading.tsx @@ -0,0 +1,65 @@ +import { Transition, TransitionChild } from "@headlessui/react"; +import { useNavigation } from "@remix-run/react"; +import { Fragment, Suspense, useMemo } from "react"; + +const messages = [ + "Thinking about the meaning of life", + "Contemplating the universe", + "Waiting for the stars to align", + "Imagining what it would be like to be a cat", + "Calculating the meaning of life", + "Wondering if the universe is a simulation", + "Asking the universe for answers", +]; + +function GlobalLoading() { + const navigationTransition = useNavigation(); + const active = navigationTransition.state !== "idle"; + const loadingLine = useMemo(() => { + const line = messages[Math.floor(Math.random() * messages.length)]; + return line + "..."; + }, []); + + return ( + + +
+ + +
+
+
+ + {loadingLine} + +
+
+ + + ); +} + +export { GlobalLoading }; diff --git a/apps/web-new/app/components/Logo.tsx b/apps/web-new/app/components/Logo.tsx new file mode 100644 index 00000000..4f3c407a --- /dev/null +++ b/apps/web-new/app/components/Logo.tsx @@ -0,0 +1,25 @@ +import type { ReactElement } from "react"; + +function Logo(): ReactElement { + return ( + + + + + + ); +} + +export default Logo; diff --git a/apps/web-new/app/components/Navbar.tsx b/apps/web-new/app/components/Navbar.tsx new file mode 100644 index 00000000..7757bfa9 --- /dev/null +++ b/apps/web-new/app/components/Navbar.tsx @@ -0,0 +1,24 @@ +import { Link } from "@remix-run/react"; +import type { ReactElement } from "react"; +import { LuMenu, LuPartyPopper } from "react-icons/lu"; + +export function Navbar(): ReactElement { + return ( +
+
+ + + + + GiveawayBot + + +
+
+ ); +} diff --git a/apps/web-new/app/components/ProfileMenu.tsx b/apps/web-new/app/components/ProfileMenu.tsx new file mode 100644 index 00000000..07d226d1 --- /dev/null +++ b/apps/web-new/app/components/ProfileMenu.tsx @@ -0,0 +1,66 @@ +import * as Avatar from "@radix-ui/react-avatar"; +import { Form, Link, useRouteLoaderData } from "@remix-run/react"; +import type { ReactElement } from "react"; +import { LuLogOut, LuUser } from "react-icons/lu"; +import { DiscordUser } from "~/types/DiscordUser"; +import { useDrawer } from "./contexts/DrawerContext"; + +export function ProfileMenu(): ReactElement { + const { toggleDrawer } = useDrawer(); + + const data = useRouteLoaderData<{ loggedIn: DiscordUser | null }>("root"); + if (data && data?.loggedIn) { + const loggedIn = data.loggedIn; + return ( + <> +
+ +
+
+
+
+ +
+
+
+ + ); + } + return ( + + + + ); +} diff --git a/apps/web-new/app/components/ThemeChanger.tsx b/apps/web-new/app/components/ThemeChanger.tsx new file mode 100644 index 00000000..e0317392 --- /dev/null +++ b/apps/web-new/app/components/ThemeChanger.tsx @@ -0,0 +1,36 @@ +// + +import { Suspense } from "react"; +import { LuMoon, LuSun } from "react-icons/lu"; +import { Theme, useTheme } from "./contexts/ThemeContext"; + +export function ThemeSwitcher() { + const [theme, setTheme] = useTheme(); + const toggleTheme = () => { + setTheme((prevTheme) => + prevTheme === Theme.LIGHT ? Theme.DARK : Theme.LIGHT, + ); + }; + return ( + + + + ); +} diff --git a/apps/web-new/app/components/ToplevelErrorBoundary.tsx b/apps/web-new/app/components/ToplevelErrorBoundary.tsx new file mode 100644 index 00000000..3f976447 --- /dev/null +++ b/apps/web-new/app/components/ToplevelErrorBoundary.tsx @@ -0,0 +1,118 @@ +import { Link, isRouteErrorResponse, useRouteError } from "@remix-run/react"; +import { captureRemixErrorBoundaryError } from "@sentry/remix"; +import { + LuBan, + LuCoins, + LuFileQuestion, + LuServerOff, + LuUserX, + LuXCircle, +} from "react-icons/lu"; + +// const Template = ({ +// children, +// }: { +// children: React.ReactNode; +// }): React.ReactElement => { +// return ( +// +// {children} +// +// ); +// }; + +// export const meta: MetaFunction = () => { +// return defaultMeta("Error", "Something went wrong."); +// }; + +export function ToplevelErrorBoundary() { + const error = useRouteError(); + captureRemixErrorBoundaryError(error); + + // when true, this is what used to go to `CatchBoundary` + if (isRouteErrorResponse(error)) { + let message; + switch (error.status) { + // 4xx errors only + case 403: // Forbidden + message = "The Maze Master doesn't want you here. Seek another path."; + break; + case 404: // Not Found + message = "What you're looking for isn't here, sorry."; + break; + case 401: // Unauthorized + message = "You're not authorized to view this page, are you logged in?"; + break; + case 402: // Payment Required + message = ( +
+ This page needs a premium subscription. + + Get one now! + + + Already have one? Make sure you're logged in. + +
+ ); + break; + default: // Other 4xx + message = "Something went wrong."; + break; + } + return ( +
+
+
+ {{ + 403: , // Forbidden + 404: , // Not Found + 401: , // Unauthorized + 402: , // Payment Required + }[error.status] ?? } +
+
+

{error.status}

+

{error.statusText}

+ {message} +
+
+
+ ); + } + + // Don't forget to typecheck with your own logic. + // Any value can be thrown, not just errors! + let errorMessage = "Unknown error"; + if (error instanceof Error) { + if (error.cause) console.error(error.cause); + errorMessage = + process.env.NODE_ENV === "development" + ? error.message + "\n\n" + error.stack! + : error.message; + } + + return ( +
+
+
+ +
+
+

500

+

Internal Server Error

+

Something went wrong. Please try again later.

+
+
+
+ +
+          {errorMessage}
+        
+
+
+ This error has been logged. (Hopefully.) +
+
+ ); +} diff --git a/apps/web-new/app/components/contexts/DrawerContext.tsx b/apps/web-new/app/components/contexts/DrawerContext.tsx new file mode 100644 index 00000000..31a245f6 --- /dev/null +++ b/apps/web-new/app/components/contexts/DrawerContext.tsx @@ -0,0 +1,27 @@ +import React, { createContext, useContext, useState } from 'react'; + +interface DrawerContextType { + isDrawerOpen: boolean; + toggleDrawer: () => void; +} + +const DrawerContext = createContext(undefined); + +export const DrawerProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const toggleDrawer = () => setIsDrawerOpen((prev) => !prev); + + return ( + + {children} + + ); +}; + +export const useDrawer = () => { + const context = useContext(DrawerContext); + if (!context) { + throw new Error('useDrawer must be used within a DrawerProvider'); + } + return context; +}; \ No newline at end of file diff --git a/apps/web-new/app/components/contexts/ThemeContext.tsx b/apps/web-new/app/components/contexts/ThemeContext.tsx new file mode 100644 index 00000000..81b897d0 --- /dev/null +++ b/apps/web-new/app/components/contexts/ThemeContext.tsx @@ -0,0 +1,120 @@ +// adapted from +// https://www.mattstobbs.com/remix-dark-mode/ +import { useFetcher } from "@remix-run/react"; +import type { Dispatch, ReactNode, SetStateAction } from "react"; +import { createContext, useContext, useEffect, useRef, useState } from "react"; + +enum Theme { + DARK = "dark", + LIGHT = "light", +} + +type ThemeContextType = [Theme | null, Dispatch>]; + +const prefersDarkMQ = "(prefers-color-scheme: dark)"; + +const ThemeContext = createContext(undefined); + +const themes: Array = Object.values(Theme); + +function isTheme(value: unknown): value is Theme { + return typeof value === "string" && themes.includes(value as Theme); +} + +function ThemeProvider({ + children, + wantedTheme, +}: { + children: ReactNode; + wantedTheme: Theme | null; +}) { + const [theme, setTheme] = useState(() => { + if (wantedTheme && isTheme(wantedTheme)) { + return wantedTheme; + } + return null; + }); + const persistTheme = useFetcher(); + + const persistThemeRef = useRef(persistTheme); + useEffect(() => { + persistThemeRef.current = persistTheme; + }, [persistTheme]); + + const mountedRef = useRef(false); + + useEffect(() => { + if (!mountedRef.current) { + mountedRef.current = true; + return; + } + if (!theme || !isTheme(theme)) { + return; + } + + persistThemeRef.current.submit( + { theme }, + { + action: "api/actions/preferences/theme", + method: "post", + }, + ); + }, [theme]); + + useEffect(() => { + const mediaQuery = window.matchMedia(prefersDarkMQ); + const handleChange = () => { + setTheme(mediaQuery.matches ? Theme.DARK : Theme.LIGHT); + }; + mediaQuery.addEventListener("change", handleChange); + return () => mediaQuery.removeEventListener("change", handleChange); + }, []); + + return ( + + {children} + + ); +} + +function PreventFlashOfInvertedColors({ render }: { render: boolean }) { + return ( + <> + {!render ? null : ( +