diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 8a3e42c6..00000000 --- a/.dockerignore +++ /dev/null @@ -1,26 +0,0 @@ -# Package Managers - -yarn.lock -node_modules - -# Editor Configs - -.idea -.vscode -.DS_Store - -# Miscelaneous - -/.cache -/build -/public/build -.env - -# Prisma - -/prisma/data.db -/prisma/data.db-journal - -# Tests - -/coverage diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..796629c8 --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +LITEFS_DIR="/litefs/data" +DATABASE_PATH="./prisma/data.db" +DATABASE_URL="file:./data.db?connection_limit=1" +CACHE_DATABASE_PATH="./other/cache.db" +SESSION_SECRET="super-duper-s3cret" +HONEYPOT_SECRET="super-duper-s3cret" +INTERNAL_COMMAND_TOKEN="some-made-up-token" +RESEND_API_KEY="re_blAh_blaHBlaHblahBLAhBlAh" +SENTRY_DSN="your-dsn" + +# the mocks and some code rely on these two being prefixed with "MOCK_" +# if they aren't then the real github api will be attempted +GITHUB_CLIENT_ID="MOCK_GITHUB_CLIENT_ID" +GITHUB_CLIENT_SECRET="MOCK_GITHUB_CLIENT_SECRET" +GITHUB_TOKEN="MOCK_GITHUB_TOKEN" + +# set this to false to prevent search engines from indexing the website +# default to allow indexing for seo safety +ALLOW_INDEXING="true" diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index b73f196f..00000000 --- a/.eslintignore +++ /dev/null @@ -1,3 +0,0 @@ -/node_modules -/build -/public/build \ No newline at end of file diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 427eacc9..77b562b8 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -1,6 +1,85 @@ -/** - * @type {import('eslint').Linter.Config} - */ +const vitestFiles = ['app/**/__tests__/**/*', 'app/**/*.{spec,test}.*'] +const testFiles = ['**/tests/**', ...vitestFiles] +const appFiles = ['app/**'] + +/** @type {import('@types/eslint').Linter.Config} */ module.exports = { - extends: ['@remix-run/eslint-config', '@remix-run/eslint-config/node', 'prettier'], + extends: [ + '@remix-run/eslint-config', + '@remix-run/eslint-config/node', + 'prettier', + ], + rules: { + // playwright requires destructuring in fixtures even if you don't use anything ๐Ÿคทโ€โ™‚๏ธ + 'no-empty-pattern': 'off', + '@typescript-eslint/consistent-type-imports': [ + 'warn', + { + prefer: 'type-imports', + disallowTypeAnnotations: true, + fixStyle: 'inline-type-imports', + }, + ], + 'import/no-duplicates': ['warn', { 'prefer-inline': true }], + 'import/consistent-type-specifier-style': ['warn', 'prefer-inline'], + 'import/order': [ + 'warn', + { + alphabetize: { order: 'asc', caseInsensitive: true }, + groups: [ + 'builtin', + 'external', + 'internal', + 'parent', + 'sibling', + 'index', + ], + }, + ], + }, + overrides: [ + { + plugins: ['remix-react-routes'], + files: appFiles, + excludedFiles: testFiles, + rules: { + 'remix-react-routes/use-link-for-routes': 'error', + 'remix-react-routes/require-valid-paths': 'error', + // disable this one because it doesn't appear to work with our + // route convention. Someone should dig deeper into this... + 'remix-react-routes/no-relative-paths': [ + 'off', + { allowLinksToSelf: true }, + ], + 'remix-react-routes/no-urls': 'error', + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: testFiles, + message: 'Do not import test files in app files', + }, + ], + }, + ], + }, + }, + { + extends: ['@remix-run/eslint-config/jest-testing-library'], + files: vitestFiles, + rules: { + 'testing-library/no-await-sync-events': 'off', + 'jest-dom/prefer-in-document': 'off', + }, + // we're using vitest which has a very similar API to jest + // (so the linting plugins work nicely), but it means we have to explicitly + // set the jest version. + settings: { + jest: { + version: 28, + }, + }, + }, + ], } diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..84a20848 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,15 @@ + + +## Test Plan + + + +## Checklist + +- [ ] Tests updated +- [ ] Docs updated + +## Screenshots + + diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 510614ee..00000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,20 +0,0 @@ -# Learn about Dependabot: -# https://docs.github.com/en/code-security/dependabot - -version: 2 -updates: - # Enable version updates for npm. - - package-ecosystem: 'npm' - # Look for `package.json` and `lock` files in the `root` directory. - directory: '/' - # Check the npm registry for updates every day. - schedule: - interval: 'weekly' - - # Enable version updates for Github-Actions. - - package-ecosystem: github-actions - # Look in the `root` directory. - directory: / - # Check for updates every day (weekdays) - schedule: - interval: weekly diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..b8f82356 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,180 @@ +name: ๐Ÿš€ Deploy +on: + push: + branches: + - main + - dev + pull_request: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + actions: write + contents: read + +jobs: + lint: + name: โฌฃ ESLint + runs-on: ubuntu-22.04 + steps: + - name: โฌ‡๏ธ Checkout repo + uses: actions/checkout@v3 + + - name: โŽ” Setup node + uses: actions/setup-node@v3 + with: + node-version: 20 + + - name: ๐Ÿ“ฅ Download deps + uses: bahmutov/npm-install@v1 + + - name: ๐Ÿ–ผ Build icons + run: npm run build:icons + + - name: ๐Ÿ”ฌ Lint + run: npm run lint + + typecheck: + name: สฆ TypeScript + runs-on: ubuntu-22.04 + steps: + - name: โฌ‡๏ธ Checkout repo + uses: actions/checkout@v3 + + - name: โŽ” Setup node + uses: actions/setup-node@v3 + with: + node-version: 20 + + - name: ๐Ÿ“ฅ Download deps + uses: bahmutov/npm-install@v1 + + - name: ๐Ÿ–ผ Build icons + run: npm run build:icons + + - name: ๐Ÿ”Ž Type check + run: npm run typecheck --if-present + + vitest: + name: โšก Vitest + runs-on: ubuntu-22.04 + steps: + - name: โฌ‡๏ธ Checkout repo + uses: actions/checkout@v3 + + - name: โŽ” Setup node + uses: actions/setup-node@v3 + with: + node-version: 20 + + - name: ๐Ÿ“ฅ Download deps + uses: bahmutov/npm-install@v1 + + - name: ๐Ÿ„ Copy test env vars + run: cp .env.example .env + + - name: ๐Ÿ–ผ Build icons + run: npm run build:icons + + - name: โšก Run vitest + run: npm run test -- --coverage + + playwright: + name: ๐ŸŽญ Playwright + runs-on: ubuntu-22.04 + timeout-minutes: 60 + steps: + - name: โฌ‡๏ธ Checkout repo + uses: actions/checkout@v3 + + - name: ๐Ÿ„ Copy test env vars + run: cp .env.example .env + + - name: โŽ” Setup node + uses: actions/setup-node@v3 + with: + node-version: 20 + + - name: ๐Ÿ“ฅ Download deps + uses: bahmutov/npm-install@v1 + + - name: ๐Ÿ“ฅ Install Playwright Browsers + run: npm run test:e2e:install + + - name: ๐Ÿ›  Setup Database + run: npx prisma migrate deploy + + - name: ๐Ÿฆ Cache Database + id: db-cache + uses: actions/cache@v3 + with: + path: prisma/data.db + key: + db-cache-schema_${{ hashFiles('./prisma/schema.prisma') + }}-migrations_${{ hashFiles('./prisma/migrations/*/migration.sql') + }} + + - name: ๐ŸŒฑ Seed Database + if: steps.db-cache.outputs.cache-hit != 'true' + run: npx prisma db seed + env: + MINIMAL_SEED: true + + - name: ๐Ÿ— Build + run: npm run build + + - name: ๐ŸŽญ Playwright tests + run: npx playwright test + + - name: ๐Ÿ“Š Upload report + uses: actions/upload-artifact@v3 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 + + deploy: + name: ๐Ÿš€ Deploy + runs-on: ubuntu-22.04 + needs: [lint, typecheck, vitest, playwright] + # only build/deploy branches on pushes + if: ${{ github.event_name == 'push' }} + + steps: + - name: โฌ‡๏ธ Checkout repo + uses: actions/checkout@v4 + + - name: ๐Ÿ‘€ Read app name + uses: SebRollen/toml-action@v1.2.0 + id: app_name + with: + file: 'fly.toml' + field: 'app' + + # move Dockerfile to root + - name: ๐Ÿšš Move Dockerfile + run: | + mv ./other/Dockerfile ./Dockerfile + mv ./other/.dockerignore ./.dockerignore + + - name: ๐ŸŽˆ Setup Fly + uses: superfly/flyctl-actions/setup-flyctl@1.5 + + - name: ๐Ÿš€ Deploy Staging + if: ${{ github.ref == 'refs/heads/dev' }} + run: + flyctl deploy --remote-only --build-arg COMMIT_SHA=${{ github.sha }} + --app ${{ steps.app_name.outputs.value }}-staging + env: + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} + + - name: ๐Ÿš€ Deploy Production + if: ${{ github.ref == 'refs/heads/main' }} + run: + flyctl deploy --remote-only --build-arg COMMIT_SHA=${{ github.sha }} + --build-secret SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} + env: + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml deleted file mode 100644 index 0e15812a..00000000 --- a/.github/workflows/main.yml +++ /dev/null @@ -1,84 +0,0 @@ -name: ๐Ÿ’ฟ Main -on: - push: - branches: - - main - - dev - pull_request: {} - -permissions: - actions: write - contents: read - -jobs: - lint: - name: โฌฃ ESLint - runs-on: ubuntu-latest - steps: - - name: Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.12.1 - - - name: Checkout Repository - uses: actions/checkout@v4.1.5 - - - name: Setup Node - uses: actions/setup-node@v4.0.2 - with: - node-version: 20 - - - name: Install Dependencies - uses: pnpm/action-setup@v4 - with: - version: 8 - run_install: true - - - name: Run Lint - run: pnpm lint - - typecheck: - name: สฆ TypeScript - runs-on: ubuntu-latest - steps: - - name: Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.12.1 - - - name: Checkout Repository - uses: actions/checkout@v4.1.5 - - - name: Setup Node - uses: actions/setup-node@v4.0.2 - with: - node-version: 20 - - - name: Install Dependencies - uses: pnpm/action-setup@v4 - with: - version: 8 - run_install: true - - - name: Run Typechecking - run: pnpm typecheck - - vitest: - name: โšก Vitest - runs-on: ubuntu-latest - steps: - - name: Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.12.1 - - - name: Checkout Repository - uses: actions/checkout@v4.1.5 - - - name: Setup Node.js - uses: actions/setup-node@v4.0.2 - with: - node-version: 20 - - - name: Install Dependencies - uses: pnpm/action-setup@v4 - with: - version: 8 - run_install: true - - - name: Run Vitest - run: pnpm test diff --git a/.gitignore b/.gitignore index 4cf98cdd..a3667f15 100644 --- a/.gitignore +++ b/.gitignore @@ -1,21 +1,25 @@ -# Package Managers -yarn.lock node_modules +.DS_store -# Editor Configs -.idea -.vscode -.DS_Store - -# Miscelaneous -/.cache /build /public/build +/server-build .env -# Prisma /prisma/data.db /prisma/data.db-journal +/tests/prisma -# Tests +/test-results/ +/playwright-report/ +/playwright/.cache/ +/tests/fixtures/email/ /coverage + +/other/cache.db + +# Easy way to create temporary files/folders that won't accidentally be added to git +*.local.* + +# generated files +/app/components/ui/icons diff --git a/.npmrc b/.npmrc index 07b96d57..668efa17 100644 --- a/.npmrc +++ b/.npmrc @@ -1,2 +1,2 @@ -auto-install-peers=true -registry=https://registry.npmjs.org/ \ No newline at end of file +legacy-peer-deps=true +registry=https://registry.npmjs.org/ diff --git a/.prettierignore b/.prettierignore index feadd84a..f022d028 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,7 +1,15 @@ node_modules -package-lock.json -pnpm-lock.yaml /build /public/build +/server-build .env + +/test-results/ +/playwright-report/ +/playwright/.cache/ +/tests/fixtures/email/*.json +/coverage +/prisma/migrations + +package-lock.json diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index bbb278a7..00000000 --- a/.prettierrc +++ /dev/null @@ -1,14 +0,0 @@ -{ - "tabWidth": 2, - "printWidth": 90, - "semi": false, - "useTabs": false, - "bracketSpacing": true, - "bracketSameLine": true, - "singleQuote": true, - "jsxSingleQuote": false, - "singleAttributePerLine": false, - "arrowParens": "always", - "trailingComma": "all", - "plugins": ["prettier-plugin-tailwindcss"] -} diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 00000000..b0ffa1c7 --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,30 @@ +/** @type {import("prettier").Options} */ +export default { + arrowParens: 'avoid', + bracketSameLine: false, + bracketSpacing: true, + embeddedLanguageFormatting: 'auto', + endOfLine: 'lf', + htmlWhitespaceSensitivity: 'css', + insertPragma: false, + jsxSingleQuote: false, + printWidth: 80, + proseWrap: 'always', + quoteProps: 'as-needed', + requirePragma: false, + semi: false, + singleAttributePerLine: false, + singleQuote: true, + tabWidth: 2, + trailingComma: 'all', + useTabs: true, + overrides: [ + { + files: ['**/*.json'], + options: { + useTabs: false, + }, + }, + ], + plugins: ['prettier-plugin-tailwindcss'], +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..7619ac2b --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,11 @@ +{ + "recommendations": [ + "bradlc.vscode-tailwindcss", + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode", + "prisma.prisma", + "qwtel.sqlite-viewer", + "yoavbls.pretty-ts-errors", + "github.vscode-github-actions" + ] +} diff --git a/.vscode/remix.code-snippets b/.vscode/remix.code-snippets new file mode 100644 index 00000000..4d8ec1d8 --- /dev/null +++ b/.vscode/remix.code-snippets @@ -0,0 +1,83 @@ +{ + "loader": { + "prefix": "/loader", + "scope": "typescriptreact,javascriptreact,typescript,javascript", + "body": [ + "import { type LoaderFunctionArgs, json } from \"@remix-run/node\"", + "", + "export async function loader({ request }: LoaderFunctionArgs) {", + " return json({})", + "}", + ], + }, + "action": { + "prefix": "/action", + "scope": "typescriptreact,javascriptreact,typescript,javascript", + "body": [ + "import { type ActionFunctionArgs, json } from \"@remix-run/node\"", + "", + "export async function action({ request }: ActionFunctionArgs) {", + " return json({})", + "}", + ], + }, + "default": { + "prefix": "/default", + "scope": "typescriptreact,javascriptreact,typescript,javascript", + "body": [ + "export default function ${TM_FILENAME_BASE/[^a-zA-Z0-9]*([a-zA-Z0-9])([a-zA-Z0-9]*)/${1:/capitalize}${2}/g}() {", + " return (", + "
", + "

Unknown Route

", + "
", + " )", + "}", + ], + }, + "headers": { + "prefix": "/headers", + "scope": "typescriptreact,javascriptreact,typescript,javascript", + "body": [ + "import type { HeadersFunction } from '@remix-run/node'", + "", + "export const headers: HeadersFunction = ({ loaderHeaders }) => ({", + " 'Cache-Control': loaderHeaders.get('Cache-Control') ?? '',", + "})", + ], + }, + "links": { + "prefix": "/links", + "scope": "typescriptreact,javascriptreact,typescript,javascript", + "body": [ + "import type { LinksFunction } from '@remix-run/node'", + "", + "export const links: LinksFunction = () => {", + " return []", + "}", + ], + }, + "meta": { + "prefix": "/meta", + "scope": "typescriptreact,javascriptreact,typescript,javascript", + "body": [ + "import type { MetaFunction } from '@remix-run/node'", + "", + "export const meta: MetaFunction = ({ data }) => [{", + " title: 'Title',", + "}]", + ], + }, + "shouldRevalidate": { + "prefix": "/shouldRevalidate", + "scope": "typescriptreact,javascriptreact,typescript,javascript", + "body": [ + "import type { ShouldRevalidateFunction } from '@remix-run/react'", + "", + "export const shouldRevalidate: ShouldRevalidateFunction = ({", + " defaultShouldRevalidate", + "}) => {", + " return defaultShouldRevalidate", + "}", + ], + }, +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..374cf10c --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,19 @@ +{ + "typescript.preferences.autoImportFileExcludePatterns": [ + "@remix-run/server-runtime", + "@remix-run/router", + "express", + "@radix-ui/**", + "@react-email/**", + "react-router-dom", + "react-router", + "stream/consumers", + "node:stream/consumers", + "node:test", + "console", + "node:console" + ], + "workbench.editorAssociations": { + "*.db": "sqlite-viewer.view" + } +} diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 161ce816..00000000 --- a/Dockerfile +++ /dev/null @@ -1,64 +0,0 @@ -# syntax = docker/dockerfile:1 - -# Adjust NODE_VERSION as desired -ARG NODE_VERSION=20.11.0 -FROM node:${NODE_VERSION}-slim as base - -LABEL fly_launch_runtime="Remix/Prisma" - -# Remix/Prisma app lives here -WORKDIR /app - -# Set production environment -ENV NODE_ENV="production" - -# Throw-away build stage to reduce size of final image -FROM base as build - -# Install packages needed to build node modules -RUN apt-get update -qq && \ - apt-get install --no-install-recommends -y build-essential node-gyp openssl pkg-config python-is-python3 - -# Install node modules -COPY --link .npmrc package-lock.json package.json ./ -RUN npm ci --include=dev - -# Generate Prisma Client -COPY --link prisma . -RUN npx prisma generate - -# Copy application code -COPY --link . . - -# Build application -RUN npm run build - -# Remove development dependencies -RUN npm prune --omit=dev - -# Final stage for app image -FROM base - -# Install packages needed for deployment -RUN apt-get update -qq && \ - apt-get install --no-install-recommends -y openssl sqlite3 && \ - rm -rf /var/lib/apt/lists /var/cache/apt/archives - -# Copy built application -COPY --from=build /app /app -COPY --from=build /app/node_modules/prisma /app/node_modules/prisma - -# Setup sqlite3 on a separate volume -RUN mkdir -p /data -VOLUME /data - -# add shortcut for connecting to database CLI -RUN echo "#!/bin/sh\nset -x\nsqlite3 \$DATABASE_URL" > /usr/local/bin/database-cli && chmod +x /usr/local/bin/database-cli - -# Entrypoint prepares the database. -ENTRYPOINT [ "/app/docker-entrypoint.js" ] - -# Start the server by default, this can be overwritten at runtime -EXPOSE 3000 -ENV DATABASE_URL="file:///data/sqlite.db" -CMD [ "npm", "run", "start" ] diff --git a/README.md b/README.md index 27798366..cc8f52f4 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,54 @@ -

- ๐Ÿ›๏ธ Remix SaaS -

- -
-

- A Lightweight, Feature-Rich, and Production-Ready Remix Stack for your next SaaS application. -

-
-
+

The Epic Stack ๐Ÿš€

+ + Ditch analysis paralysis and start shipping Epic Web apps. +

- Live Demo - ยท - Documentation - ยท - Twitter + This is an opinionated project starter and reference that allows teams to + ship their ideas to production faster and on a more stable foundation based + on the experience of Kent C. Dodds and + contributors.

```sh -npx create-remix-saas@latest +npx create-epic-app@latest ``` -## [Live Demo](https://remix-saas.fly.dev) +[![The Epic Stack](https://github-production-user-asset-6210df.s3.amazonaws.com/1500684/246885449-1b00286c-aa3d-44b2-9ef2-04f694eb3592.png)](https://www.epicweb.dev/epic-stack) + +[The Epic Stack](https://www.epicweb.dev/epic-stack) + +
-[![Remix SaaS](https://raw.githubusercontent.com/dev-xo/dev-xo/main/remix-saas/intro.png)](https://remix-saas.fly.dev) +## Watch Kent's Introduction to The Epic Stack -We've created a simple demo that displays all template-provided features. Psst! Give the site a few seconds to load! _(It's running on a free tier!)_ +[![Epic Stack Talk slide showing Flynn Rider with knives, the text "I've been around and I've got opinions" and Kent speaking in the corner](https://github-production-user-asset-6210df.s3.amazonaws.com/1500684/277818553-47158e68-4efc-43ae-a477-9d1670d4217d.png)](https://www.epicweb.dev/talks/the-epic-stack) -> [!NOTE] -> Remix SaaS is an Open Source Template that shares common bits of code with: [Indie Stack](https://github.com/remix-run/indie-stack), [Epic Stack](https://github.com/epicweb-dev/epic-stack), [Supa Stripe Stack](https://github.com/rphlmr/supa-stripe-stack), and some other amazing Open Source Remix resources. Check them out, please! +["The Epic Stack" by Kent C. Dodds](https://www.epicweb.dev/talks/the-epic-stack) -## Getting Started +## Docs -Please, read the [Getting Started Documentation](https://github.com/dev-xo/remix-saas/tree/main/docs#remix-saas-documentation) to successfully initialize your **Remix SaaS** Template. +[Read the docs](https://github.com/epicweb-dev/epic-stack/blob/main/docs) +(please ๐Ÿ™). ## Support -If you found **Remix SaaS** helpful, consider supporting it with a โญ [Star](https://github.com/dev-xo/remix-saas). It helps the repository grow and provides the required motivation to continue maintaining the project. Thank you! +- ๐Ÿ†˜ Join the + [discussion on GitHub](https://github.com/epicweb-dev/epic-stack/discussions) + and the [KCD Community on Discord](https://kcd.im/discord). +- ๐Ÿ’ก Create an + [idea discussion](https://github.com/epicweb-dev/epic-stack/discussions/new?category=ideas) + for suggestions. +- ๐Ÿ› Open a [GitHub issue](https://github.com/epicweb-dev/epic-stack/issues) to + report a bug. + +## Branding + +Want to talk about the Epic Stack in a blog post or talk? Great! Here are some +assets you can use in your material: +[EpicWeb.dev/brand](https://epicweb.dev/brand) -## Acknowledgments +## Thanks -Special thanks to [@mw10013](https://github.com/mw10013) who has been part of the Remix SaaS development. +You rock ๐Ÿชจ diff --git a/app/components/error-boundary.tsx b/app/components/error-boundary.tsx new file mode 100644 index 00000000..715b8c8e --- /dev/null +++ b/app/components/error-boundary.tsx @@ -0,0 +1,46 @@ +import { + type ErrorResponse, + isRouteErrorResponse, + useParams, + useRouteError, +} from '@remix-run/react' +import { captureRemixErrorBoundaryError } from '@sentry/remix' +import { getErrorMessage } from '#app/utils/misc.tsx' + +type StatusHandler = (info: { + error: ErrorResponse + params: Record +}) => JSX.Element | null + +export function GeneralErrorBoundary({ + defaultStatusHandler = ({ error }) => ( +

+ {error.status} {error.data} +

+ ), + statusHandlers, + unexpectedErrorHandler = error =>

{getErrorMessage(error)}

, +}: { + defaultStatusHandler?: StatusHandler + statusHandlers?: Record + unexpectedErrorHandler?: (error: unknown) => JSX.Element | null +}) { + const error = useRouteError() + captureRemixErrorBoundaryError(error) + const params = useParams() + + if (typeof document !== 'undefined') { + console.error(error) + } + + return ( +
+ {isRouteErrorResponse(error) + ? (statusHandlers?.[error.status] ?? defaultStatusHandler)({ + error, + params, + }) + : unexpectedErrorHandler(error)} +
+ ) +} diff --git a/app/components/floating-toolbar.tsx b/app/components/floating-toolbar.tsx new file mode 100644 index 00000000..41b5be03 --- /dev/null +++ b/app/components/floating-toolbar.tsx @@ -0,0 +1,2 @@ +export const floatingToolbarClassName = + 'absolute bottom-3 left-3 right-3 flex items-center gap-2 rounded-lg bg-muted/80 p-4 pl-5 shadow-xl shadow-accent backdrop-blur-sm md:gap-4 md:pl-7 justify-end' diff --git a/app/components/forms.tsx b/app/components/forms.tsx new file mode 100644 index 00000000..232f4760 --- /dev/null +++ b/app/components/forms.tsx @@ -0,0 +1,202 @@ +import { useInputControl } from '@conform-to/react' +import { REGEXP_ONLY_DIGITS_AND_CHARS, type OTPInputProps } from 'input-otp' +import React, { useId } from 'react' +import { Checkbox, type CheckboxProps } from './ui/checkbox.tsx' +import { + InputOTP, + InputOTPGroup, + InputOTPSeparator, + InputOTPSlot, +} from './ui/input-otp.tsx' +import { Input } from './ui/input.tsx' +import { Label } from './ui/label.tsx' +import { Textarea } from './ui/textarea.tsx' + +export type ListOfErrors = Array | null | undefined + +export function ErrorList({ + id, + errors, +}: { + errors?: ListOfErrors + id?: string +}) { + const errorsToRender = errors?.filter(Boolean) + if (!errorsToRender?.length) return null + return ( +
    + {errorsToRender.map(e => ( +
  • + {e} +
  • + ))} +
+ ) +} + +export function Field({ + labelProps, + inputProps, + errors, + className, +}: { + labelProps: React.LabelHTMLAttributes + inputProps: React.InputHTMLAttributes + errors?: ListOfErrors + className?: string +}) { + const fallbackId = useId() + const id = inputProps.id ?? fallbackId + const errorId = errors?.length ? `${id}-error` : undefined + return ( +
+
+ ) +} + +export function OTPField({ + labelProps, + inputProps, + errors, + className, +}: { + labelProps: React.LabelHTMLAttributes + inputProps: Partial + errors?: ListOfErrors + className?: string +}) { + const fallbackId = useId() + const id = inputProps.id ?? fallbackId + const errorId = errors?.length ? `${id}-error` : undefined + return ( +
+
+ ) +} + +export function TextareaField({ + labelProps, + textareaProps, + errors, + className, +}: { + labelProps: React.LabelHTMLAttributes + textareaProps: React.TextareaHTMLAttributes + errors?: ListOfErrors + className?: string +}) { + const fallbackId = useId() + const id = textareaProps.id ?? textareaProps.name ?? fallbackId + const errorId = errors?.length ? `${id}-error` : undefined + return ( +
+