├── .changeset ├── README.md └── config.json ├── .codebuddy └── .gitignore ├── .commitlintrc.json ├── .dockerignore ├── .env ├── .env.example ├── .env.test ├── .firebaserc ├── .fixpackrc ├── .gitattributes ├── .github └── workflows │ ├── playwright.yml │ ├── release.yml │ └── run-tests.yml ├── .gitignore ├── .husky └── pre-commit ├── .lintstagedrc.js ├── .npmrc ├── .nvmrc ├── .storybook ├── main.ts ├── manager.ts ├── preview.tsx ├── style.css ├── style.tsx └── theme.ts ├── .vercelignore ├── .vscode └── settings.json ├── @types └── nextjs-routes.d.ts ├── CHANGELOG.md ├── Dockerfile ├── README.md ├── biome.json ├── bun.lockb ├── cloudbuild.yaml ├── codegen.ts ├── dependencies.sh ├── docker-compose.yml ├── dockerStart.sh ├── drizzle.config.ts ├── entrypoint.sh ├── firebase.json ├── messages ├── en.json ├── es.json └── ru.json ├── next.config.mjs ├── openAI.js ├── package.json ├── playwright.config.ts ├── postcss.config.cjs ├── public ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-icon.png ├── browserconfig.xml ├── favicon-32x32.png ├── favicon.ico ├── icon.png ├── images │ ├── btn_google_light_normal_ios.svg │ └── home │ │ ├── undraw_Checklist__re_2w7v.svg │ │ ├── undraw_Throw_away_re_x60k.svg │ │ ├── undraw_To_do_list_re_9nt7.svg │ │ ├── undraw_connected_world_wuay.svg │ │ ├── undraw_secure_login_pdn4.svg │ │ ├── undraw_task_31wc.svg │ │ └── undraw_task_list_6x9d.svg ├── mstile-150x150.png ├── robots.txt └── site.webmanifest ├── renovate.json ├── reviewStaged.sh ├── sentry.client.config.ts ├── sentry.edge.config.ts ├── sentry.server.config.ts ├── src ├── app │ ├── [locale] │ │ ├── (admin) │ │ │ ├── AdminLayout.tsx │ │ │ ├── Authorization.tsx │ │ │ ├── admin │ │ │ │ ├── Page.stories.tsx │ │ │ │ ├── [entity] │ │ │ │ │ ├── EntityTable.tsx │ │ │ │ │ ├── [id] │ │ │ │ │ │ ├── EntityForm.tsx │ │ │ │ │ │ ├── loading.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── loading.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── developers │ │ │ │ │ ├── Content.tsx │ │ │ │ │ ├── FileUpload.tsx │ │ │ │ │ ├── PublishNotification.tsx │ │ │ │ │ ├── Subscription.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── loading.tsx │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ └── loading.tsx │ │ ├── (app) │ │ │ ├── AppLayout.tsx │ │ │ ├── Page.stories.tsx │ │ │ ├── app │ │ │ │ ├── Page.stories.tsx │ │ │ │ ├── PageContent.tsx │ │ │ │ ├── error │ │ │ │ │ └── page.tsx │ │ │ │ ├── loading.tsx │ │ │ │ ├── page.tsx │ │ │ │ ├── profile │ │ │ │ │ ├── Page.stories.tsx │ │ │ │ │ ├── UserProfile │ │ │ │ │ │ ├── UserProfile.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── page.tsx │ │ │ │ └── task │ │ │ │ │ ├── [id] │ │ │ │ │ ├── Page.stories.tsx │ │ │ │ │ └── page.tsx │ │ │ │ │ └── new │ │ │ │ │ ├── Page.stories.tsx │ │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ └── loading.tsx │ │ ├── (auth) │ │ │ ├── AuthLayout.tsx │ │ │ ├── Page.stories.tsx │ │ │ ├── layout.tsx │ │ │ ├── loading.tsx │ │ │ ├── login │ │ │ │ ├── LoginPage.tsx │ │ │ │ ├── Page.stories.tsx │ │ │ │ └── page.tsx │ │ │ ├── logout │ │ │ │ ├── LogoutPage.tsx │ │ │ │ ├── Page.stories.tsx │ │ │ │ └── page.tsx │ │ │ ├── maintenance-mode │ │ │ │ ├── Page.stories.tsx │ │ │ │ └── page.tsx │ │ │ ├── register │ │ │ │ ├── Page.stories.tsx │ │ │ │ ├── RegisterPage.tsx │ │ │ │ └── page.tsx │ │ │ └── reset-password │ │ │ │ ├── Page.stories.tsx │ │ │ │ ├── ResetPasswordPage.tsx │ │ │ │ └── page.tsx │ │ ├── (marketing) │ │ │ ├── IndexContent.tsx │ │ │ ├── MarketingLayout.tsx │ │ │ ├── Page.stories.tsx │ │ │ ├── about │ │ │ │ ├── Page.stories.tsx │ │ │ │ └── page.tsx │ │ │ ├── blog │ │ │ │ ├── Page.stories.tsx │ │ │ │ └── page.tsx │ │ │ ├── contact │ │ │ │ ├── Page.stories.tsx │ │ │ │ └── page.tsx │ │ │ ├── faq │ │ │ │ ├── Page.stories.tsx │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ ├── page.tsx │ │ │ ├── pricing │ │ │ │ ├── Page.stories.tsx │ │ │ │ └── page.tsx │ │ │ ├── privacy │ │ │ │ ├── Page.stories.tsx │ │ │ │ └── page.tsx │ │ │ └── terms │ │ │ │ ├── Page.stories.tsx │ │ │ │ └── page.tsx │ │ ├── LocaleLink.tsx │ │ ├── Page.stories.tsx │ │ ├── error.tsx │ │ ├── global-error.tsx │ │ ├── layout.tsx │ │ ├── loading.tsx │ │ └── ~offline │ │ │ ├── Page.stories.tsx │ │ │ └── page.tsx │ ├── api │ │ ├── error │ │ │ └── route.ts │ │ ├── graphql │ │ │ └── route.ts │ │ └── health │ │ │ └── route.ts │ ├── global-error.tsx │ ├── shared │ │ ├── useRedirectAfterLogin.ts │ │ └── useRedirectParam.ts │ └── sw.ts ├── client │ ├── admin │ │ ├── edit │ │ │ ├── EditCard.tsx │ │ │ ├── FormError.tsx │ │ │ ├── formGeneration.tsx │ │ │ └── useAdminEdit.tsx │ │ ├── entity │ │ │ ├── task │ │ │ │ ├── RenderRows.tsx │ │ │ │ ├── TaskForm.tsx │ │ │ │ └── TaskTable.tsx │ │ │ └── user │ │ │ │ ├── RenderRows.tsx │ │ │ │ ├── UserForm.tsx │ │ │ │ └── UserTable.tsx │ │ └── table │ │ │ ├── AdminTable.tsx │ │ │ ├── FormErrors.tsx │ │ │ ├── FormHelpers.tsx │ │ │ └── useAdminPageQuery.tsx │ ├── components │ │ ├── Icons.tsx │ │ ├── admin │ │ │ ├── Auditing.tsx │ │ │ ├── Chip.tsx │ │ │ ├── Edit.tsx │ │ │ ├── ReadOnlyInput.tsx │ │ │ └── buttons │ │ │ │ ├── CancelButton.tsx │ │ │ │ └── DeleteButton.tsx │ │ ├── auth │ │ │ ├── GoogleButton.tsx │ │ │ ├── PasswordForm.tsx │ │ │ └── Redirecting.tsx │ │ ├── layout │ │ │ ├── Footer.tsx │ │ │ ├── Header.tsx │ │ │ ├── Layout.tsx │ │ │ ├── app │ │ │ │ ├── AcmeLogo.jsx │ │ │ │ ├── Header.tsx │ │ │ │ └── UserButtons.tsx │ │ │ └── header │ │ │ │ └── ProfileButtons.tsx │ │ ├── nextui │ │ │ ├── icons.tsx │ │ │ ├── primitives.ts │ │ │ ├── site.ts │ │ │ └── theme-switch.tsx │ │ └── tasks │ │ │ ├── Task.stories.tsx │ │ │ ├── Task.tsx │ │ │ ├── TaskForm.tsx │ │ │ ├── TaskList.stories.tsx │ │ │ └── TaskList.tsx │ ├── gql │ │ ├── admin-queries.gql.ts │ │ ├── cacheExchange.ts │ │ ├── client-queries.gql.ts │ │ ├── errorHandling.tsx │ │ ├── generated │ │ │ ├── gql.ts │ │ │ ├── graphql.ts │ │ │ ├── index.ts │ │ │ ├── schema.graphql │ │ │ └── schema.json │ │ ├── globalMocks.ts │ │ └── graphql-helpers.ts │ ├── styles │ │ ├── fonts.ts │ │ └── index.css │ ├── ui │ │ ├── Breadcrumb │ │ │ ├── Breadcrumb.stories.ts │ │ │ └── Breadcrumb.tsx │ │ ├── Button │ │ │ ├── Button.stories.tsx │ │ │ └── Button.tsx │ │ ├── ButtonGroup │ │ │ ├── ButtonGroup.stories.tsx │ │ │ └── ButtonGroup.tsx │ │ ├── Card │ │ │ ├── Card.stories.tsx │ │ │ └── Card.tsx │ │ ├── DateInput.tsx │ │ ├── Input │ │ │ ├── Input.stories.ts │ │ │ ├── Input.tsx │ │ │ ├── Textarea.stories.ts │ │ │ └── Textarea.tsx │ │ ├── Link │ │ │ ├── Link.stories.ts │ │ │ └── Link.tsx │ │ ├── NextUIWrapper.tsx │ │ ├── Radio │ │ │ ├── Radio.tsx │ │ │ ├── RadioGroup.stories.tsx │ │ │ └── RadioGroup.tsx │ │ ├── Skeleton │ │ │ ├── Skeleton.stories.tsx │ │ │ └── Skeleton.tsx │ │ ├── Spinner │ │ │ ├── Spinner.stories.tsx │ │ │ └── Spinner.tsx │ │ ├── icons │ │ │ ├── HiddenIcon.tsx │ │ │ ├── VisibleIcon.tsx │ │ │ └── icons.module.css │ │ └── index.ts │ └── utils │ │ ├── cloudflareLoader.ts │ │ ├── date.ts │ │ ├── form.ts │ │ ├── matchesAnyItem.ts │ │ ├── routes.ts │ │ └── storybookTitles.ts ├── cspRules.mjs ├── e2e │ ├── admin.play.ts │ ├── app.play.ts │ ├── auth.setup.ts │ ├── gpu.play.ts │ ├── marketing.play.ts │ └── util.ts ├── env.mjs ├── instrumentation.ts ├── lib │ ├── firebase │ │ └── auth │ │ │ └── server-auth-provider.tsx │ ├── localization │ │ ├── navigation.ts │ │ ├── request.ts │ │ └── routing.ts │ ├── logging │ │ ├── log-level.ts │ │ └── log-util.ts │ ├── sst │ │ ├── paramsAndSecrets.ts │ │ ├── sst-env.d.ts │ │ └── sst.config.ts │ ├── storybook │ │ ├── Introduction.mdx │ │ └── assets │ │ │ ├── code-brackets.svg │ │ │ ├── colors.svg │ │ │ ├── comments.svg │ │ │ ├── direction.svg │ │ │ ├── flow.svg │ │ │ ├── plugin.svg │ │ │ ├── repo.svg │ │ │ └── stackalt.svg │ └── types │ │ ├── next-intl.d.ts │ │ └── reset.d.ts ├── metadata.config.ts ├── middleware.ts └── server │ ├── admin │ ├── admin.model.ts │ └── admin.service.ts │ ├── base │ ├── base.model.ts │ ├── base.service.test.ts │ └── base.service.ts │ ├── db │ ├── __tests__ │ │ └── schema.test.ts │ ├── index.ts │ ├── migrations │ │ ├── 0000_flippant_karnak.sql │ │ ├── 0001_friendly_phalanx.sql │ │ ├── 0002_oval_jetstream.sql │ │ ├── 0003_flimsy_spiral.sql │ │ ├── 0004_loose_mikhail_rasputin.sql │ │ ├── 0005_silky_the_phantom.sql │ │ └── meta │ │ │ ├── 0000_snapshot.json │ │ │ ├── 0001_snapshot.json │ │ │ ├── 0002_snapshot.json │ │ │ ├── 0003_snapshot.json │ │ │ ├── 0004_snapshot.json │ │ │ ├── 0005_snapshot.json │ │ │ └── _journal.json │ └── schema.ts │ ├── graphql │ ├── __tests__ │ │ ├── errors.test.ts │ │ └── server.test.ts │ ├── builder.ts │ ├── errors.ts │ ├── handleCreateOrGetUser.ts │ ├── schema.ts │ ├── server.ts │ ├── sortAndPagination.ts │ └── subscriptions │ │ ├── PubSubChannels.ts │ │ └── notification.ts │ ├── task │ ├── task.model.ts │ ├── task.service.test.ts │ └── task.service.ts │ ├── user │ ├── user.model.ts │ ├── user.service.test.ts │ └── user.service.ts │ └── utils │ ├── accessCheck.ts │ ├── caslAbility.test.ts │ ├── caslAbility.ts │ ├── mocks.ts │ └── sleep.ts ├── tailwind.config.cjs ├── tsconfig.json ├── turbo.json └── vitest.config.ts /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.3.1/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "restricted", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /.codebuddy/.gitignore: -------------------------------------------------------------------------------- 1 | db/ -------------------------------------------------------------------------------- /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@commitlint/config-conventional"] 3 | } 4 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | .dockerignore 3 | node_modules 4 | npm-debug.log 5 | README.md 6 | .next 7 | .git 8 | 9 | # other build stuff 10 | .storybook 11 | functions 12 | examples 13 | .husky 14 | .vscode 15 | .idea 16 | 17 | # SST 18 | .sst 19 | .open-next 20 | sst.config.ts 21 | sst-env.d.ts 22 | src/paramsAndSecrets.ts 23 | 24 | # Storybook 25 | # ReferenceError: Cannot access 'defaultExport' before initialization 26 | **/*.stories.tsx 27 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # https://nextjs.org/docs/pages/building-your-application/configuring/environment-variables#default-environment-variables 2 | # Note: by default docker compose will reference this file so scripts have docker:up and docker:down to disable 3 | 4 | # CLIENT 5 | NEXT_PUBLIC_APP_ENV=$APP_ENV 6 | 7 | ## API 8 | NEXT_PUBLIC_ORIGIN=$ORIGIN 9 | 10 | ## AUTH 11 | NEXT_PUBLIC_FIREBASE_API_KEY=$FIREBASE_API_KEY 12 | NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=$FIREBASE_AUTH_DOMAIN 13 | NEXT_PUBLIC_FIREBASE_DATABASE_URL=$FIREBASE_DATABASE_URL 14 | NEXT_PUBLIC_FIREBASE_PROJECT_ID=$FIREBASE_PROJECT_ID 15 | NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=$FIREBASE_MESSAGING_SENDER_ID 16 | 17 | ## AXIOM 18 | NEXT_PUBLIC_AXIOM_TOKEN=$AXIOM_TOKEN 19 | NEXT_PUBLIC_AXIOM_DATASET=$AXIOM_DATASET 20 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Copy this file into .env.local and ask other devs for initial values 2 | 3 | # When adding additional environment variables 4 | # - only values that can't be checked in go here (shared goes to .env.development) 5 | # - the schema in "./src/env.mjs" should be updated accordingly. 6 | # - client variables need mapping in .env 7 | 8 | # https://nextjs.org/docs/pages/building-your-application/configuring/environment-variables#default-environment-variables 9 | 10 | # EDGE - set to false for api endpoints to be edge (required for cloudflare) 11 | # Cloudflare build doesn't seem to be passing env at build so edge is default 12 | NEXT_RUNTIME_NODE=true 13 | APP_ENV=local 14 | LOG_LEVEL=debug 15 | ORIGIN=http://localhost:3000 16 | 17 | # DATABASE 18 | DATABASE_URL=postgresql://root@localhost:26257/defaultdb?sslmode=disable 19 | # DATABASE_URL=postgresql://root@127.0.0.1:26257/defaultdb?sslmode=disable 20 | 21 | # AUTH 22 | FIREBASE_API_KEY=AIzaXXXXXXXX-XXXXXXXXXXXXXX-XXX 23 | FIREBASE_PROJECT_ID=xxx 24 | FIREBASE_ADMIN_CLIENT_EMAIL=firebase-adminsdk-xxx@xxx.iam.gserviceaccount.com 25 | FIREBASE_ADMIN_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nXXX\n-----END PRIVATE KEY-----\n" 26 | 27 | USE_SECURE_COOKIES=false 28 | COOKIE_SECRET_CURRENT="secret1" 29 | COOKIE_SECRET_PREVIOUS="secret2" 30 | 31 | FIREBASE_AUTH_DOMAIN=xxx.firebaseapp.com 32 | FIREBASE_DATABASE_URL=xxx.firebaseio.com 33 | FIREBASE_MESSAGING_SENDER_ID=xxx 34 | 35 | 36 | # AXIOM LOGGING (log to console if not defined) 37 | # AXIOM_TOKEN= 38 | # AXIOM_DATASET= 39 | 40 | # SENTRY 41 | SENTRY_DSN=https://xxxxxxxxxxxxxxxxxxxxxxxxxx@oxxxxx.ingest.sentry.io/xxxxxxxxxxxxxxx 42 | -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | APP_ENV=test 2 | LOG_LEVEL=debug 3 | NEXT_RUNTIME_NODE=true 4 | SKIP_ENV_VALIDATION=true 5 | 6 | 7 | # Make sure this user has admin access 8 | # TODO: automate creation if not exists and add admin 9 | TEST_ADMIN_EMAIL=enalmada+playwrightAdmin@test.com 10 | TEST_ADMIN_PASSWORD=asdfasdf 11 | TEST_MEMBER_EMAIL=enalmada+playwright@test.com 12 | TEST_MEMBER_PASSWORD=asdfasdf 13 | -------------------------------------------------------------------------------- /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "nextstackexample" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.fixpackrc: -------------------------------------------------------------------------------- 1 | { 2 | "sortToTop": [ 3 | "name", 4 | "version", 5 | "scripts", 6 | "dependencies", 7 | "devDependencies", 8 | "author", 9 | "description", 10 | "license", 11 | "private" 12 | ], 13 | "sortedSubItems": [ 14 | "dependencies", 15 | "devDependencies", 16 | "jshintConfig", 17 | "keywords", 18 | "scripts" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.lockb binary diff=lockb -------------------------------------------------------------------------------- /.github/workflows/playwright.yml: -------------------------------------------------------------------------------- 1 | name: Playwright Tests 2 | 3 | on: 4 | deployment_status: 5 | 6 | jobs: 7 | test: 8 | timeout-minutes: 60 9 | runs-on: ubuntu-latest 10 | if: github.event.deployment_status.state == 'success' 11 | steps: 12 | 13 | - uses: actions/checkout@v4 14 | 15 | - uses: actions/setup-node@v4 16 | with: 17 | node-version: '20' 18 | 19 | - uses: oven-sh/setup-bun@v1 20 | with: 21 | bun-version: latest 22 | 23 | - run: bun install 24 | 25 | - run: bunx playwright install --with-deps 26 | 27 | - name: Run Playwright tests 28 | run: bun test:e2e 29 | env: 30 | #PLAYWRIGHT_TEST_BASE_URL: ${{ github.event.deployment_status.target_url }} 31 | PLAYWRIGHT_TEST_BASE_URL: "https://nextjs-nextgql-boilerplate-preview.vercel.app" 32 | TEST_EMAIL: ${{ secrets.TEST_EMAIL }} 33 | TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }} 34 | 35 | - name: Upload Screenshots 36 | if: failure() 37 | uses: actions/upload-artifact@v4 38 | with: 39 | name: screenshots 40 | path: ./*.png 41 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - develop 7 | 8 | concurrency: ${{ github.workflow }}-${{ github.ref }} 9 | 10 | jobs: 11 | release: 12 | name: Release 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout Repo 16 | uses: actions/checkout@v4 17 | 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: '20' 21 | 22 | - name: Setup Bun 23 | uses: oven-sh/setup-bun@v1 24 | with: 25 | bun-version: latest 26 | 27 | - name: Install Dependencies 28 | run: bun install 29 | 30 | - name: Create Release Pull Request 31 | uses: changesets/action@v1 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | -------------------------------------------------------------------------------- /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - develop 7 | pull_request: 8 | branches: 9 | - develop 10 | 11 | jobs: 12 | test-unit: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version: '20' 19 | - uses: oven-sh/setup-bun@v1 20 | with: 21 | bun-version: latest 22 | - run: bun install 23 | - run: bun run test:unit 24 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | bun run pre-commit 2 | -------------------------------------------------------------------------------- /.lintstagedrc.js: -------------------------------------------------------------------------------- 1 | // https://nextjs.org/docs/pages/building-your-application/configuring/eslint#lint-staged 2 | // https://paulintrognon.fr/blog/typescript-prettier-eslint-next-js 3 | 4 | const tsc = () => "bun --bun tsc --noEmit"; 5 | 6 | export default { 7 | "**/*.{ts,tsx}": [tsc], 8 | "**/*.{js,jsx,ts,tsx,json,yaml,yml,md,css,scss}": () => 9 | "biome check --fix --unsafe", 10 | "src/server/db/schema.ts": () => "bun drizzle:generate", 11 | // 'package.json': ['npm pkg fix', 'fixpack'], 12 | }; 13 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | //.npmrc 2 | // https://adamcoster.com/blog/pnpm-config 3 | publish-branch=main 4 | enable-pre-post-scripts=true 5 | public-hoist-pattern[]=*@nextui-org/theme* 6 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 22.12.0 2 | -------------------------------------------------------------------------------- /.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import type { StorybookConfig } from "@storybook/nextjs"; 2 | 3 | const config: StorybookConfig = { 4 | stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], 5 | staticDirs: ["../public"], 6 | addons: [ 7 | "@storybook/addon-links", 8 | "@storybook/addon-essentials", 9 | "@storybook/addon-interactions", 10 | "storybook-dark-mode", 11 | "storybook-addon-mock", 12 | { 13 | name: "@storybook/addon-styling", 14 | options: { 15 | postCss: true, 16 | }, 17 | }, 18 | ], 19 | framework: { 20 | name: "@storybook/nextjs", 21 | options: { 22 | builder: { 23 | // true has issues with google font 24 | // useSWC: true, 25 | }, 26 | }, 27 | }, 28 | docs: { 29 | autodocs: "tag", 30 | }, 31 | }; 32 | 33 | export default config; 34 | -------------------------------------------------------------------------------- /.storybook/manager.ts: -------------------------------------------------------------------------------- 1 | import { addons } from "@storybook/manager-api"; 2 | 3 | import yourTheme from "./theme"; 4 | 5 | addons.setConfig({ 6 | theme: yourTheme, 7 | }); 8 | -------------------------------------------------------------------------------- /.storybook/style.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | h1 { 6 | @apply text-4xl font-bold !text-foreground; 7 | } 8 | 9 | h2 { 10 | @apply !border-none text-2xl font-bold !text-foreground; 11 | } 12 | 13 | h3 { 14 | @apply text-xl font-bold !text-neutral-600; 15 | } 16 | 17 | .dark .sbdocs-wrapper, 18 | .dark .sbdocs-preview { 19 | background-color: #000000; 20 | color: #fff; 21 | } 22 | 23 | .dark .sbdocs-preview { 24 | border: 1px solid #292929; 25 | } 26 | 27 | .dark .docblock-code-toggle { 28 | background: transparent; 29 | color: #d4d4d4; 30 | } 31 | 32 | .dark div:has(.docblock-code-toggle) { 33 | background: transparent; 34 | } 35 | 36 | .dark .os-theme-dark { 37 | background: #161616; 38 | color: #fff; 39 | } 40 | 41 | .dark .sbdocs-title { 42 | color: #fff; 43 | } 44 | 45 | .dark .docblock-argstable-head { 46 | background: #161616; 47 | } 48 | 49 | .dark .docblock-argstable-head th { 50 | color: #bcbcbc; 51 | border-bottom: 1px solid #292929 !important; 52 | } 53 | 54 | .dark .docblock-argstable-head th span { 55 | color: #bcbcbc; 56 | } 57 | 58 | .dark #docs-root tbody td { 59 | background: #161616 !important; 60 | color: #bcbcbc !important; 61 | } 62 | 63 | .dark #docs-root tbody tr:first-child td:first-child { 64 | border-top-left-radius: 0 !important; 65 | } 66 | 67 | .dark #docs-root tbody tr:first-child td:last-child { 68 | border-top-right-radius: 0 !important; 69 | } 70 | 71 | .dark #docs-root tbody tr:not(:first-child) { 72 | border-top: 1px solid #292929 !important; 73 | } 74 | -------------------------------------------------------------------------------- /.storybook/style.tsx: -------------------------------------------------------------------------------- 1 | import "./style.css"; 2 | 3 | function Style() { 4 | return
; 5 | } 6 | 7 | export default Style; 8 | -------------------------------------------------------------------------------- /.storybook/theme.ts: -------------------------------------------------------------------------------- 1 | import { create } from "@storybook/theming/create"; 2 | 3 | export default create({ 4 | base: "light", 5 | brandTitle: "My custom Storybook", 6 | brandUrl: "https://example.com", 7 | brandImage: "https://storybook.js.org/images/placeholders/350x150.png", 8 | brandTarget: "_self", 9 | }); 10 | -------------------------------------------------------------------------------- /.vercelignore: -------------------------------------------------------------------------------- 1 | .storybook 2 | functions 3 | examples 4 | .husky 5 | .vscode 6 | .idea 7 | 8 | # SST 9 | .sst 10 | .open-next 11 | sst.config.ts 12 | sst-env.d.ts 13 | src/paramsAndSecrets.ts 14 | 15 | # Storybook 16 | # ReferenceError: Cannot access 'defaultExport' before initialization 17 | **/*.stories.tsx 18 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules\\.pnpm\\typescript@5.1.3\\node_modules\\typescript\\lib", 3 | "typescript.enablePromptUseWorkspaceTsdk": true 4 | } 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # nextjs-boilerplate 2 | 3 | ## 0.1.10 4 | 5 | ### Patch Changes 6 | 7 | - ea678f4: turborepo support 8 | 9 | ## 0.1.9 10 | 11 | ### Patch Changes 12 | 13 | - 8265cab: clone-code hook update 14 | - 6b677af: shared base service 15 | - 0d25e72: subscriptions example 16 | 17 | ## 0.1.8 18 | 19 | ### Patch Changes 20 | 21 | - 2bdce4a: migrate from @DuCanhGH/next-pwa (deprecated) to @serwist/next 22 | - 4d0898f: file upload support 23 | 24 | ## 0.1.7 25 | 26 | ### Patch Changes 27 | 28 | - 57151b3: localeDetection true to fix playwright 29 | 30 | ## 0.1.6 31 | 32 | ### Patch Changes 33 | 34 | - 31cfbdf: changes necessary for: next-intl 3, next-ui routing 35 | 36 | ## 0.1.5 37 | 38 | ### Patch Changes 39 | 40 | - 03349da: next-gql graphql-codegen 41 | - 03349da: nextjs 14 42 | - 89911bf: next-gql pothos 43 | 44 | ## 0.1.4 45 | 46 | ### Patch Changes 47 | 48 | - 8ae929f: next-gql yoga server 49 | 50 | ## 0.1.3 51 | 52 | ### Patch Changes 53 | 54 | - f049fb7: fix next.config.mjs vercel deployment 55 | 56 | ## 0.1.2 57 | 58 | ### Patch Changes 59 | 60 | - f3b775a: update pothos version to 3.37 with this https://github.com/hayes/pothos/issues/1056 61 | - 17f26de: bun lockfile is binary 62 | 63 | ## 0.1.1 64 | 65 | ### Patch Changes 66 | 67 | - cfc2e24: setup releases 68 | - 578ccda: renamed repository and setup changesets 69 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", 3 | "vcs": { 4 | "enabled": false, 5 | "clientKind": "git", 6 | "useIgnoreFile": false 7 | }, 8 | "files": { 9 | "ignoreUnknown": false, 10 | "ignore": [ 11 | ".next/**", 12 | ".turbo/**", 13 | "src/client/gql/generated", 14 | "src/server/graphql/schema.graphql", 15 | "src/server/db/migrations", 16 | "@types/nextjs-routes.d.ts" 17 | ] 18 | }, 19 | "formatter": { 20 | "enabled": true, 21 | "indentStyle": "tab" 22 | }, 23 | "organizeImports": { 24 | "enabled": true 25 | }, 26 | "linter": { 27 | "enabled": true, 28 | "rules": { 29 | "suspicious": { 30 | "noConsoleLog": { "level": "error", "fix": "none" }, 31 | "noExplicitAny": "off", 32 | "noExportsInTest": "off", 33 | "noArrayIndexKey": "off", 34 | "noRedeclare": "off", 35 | "noShadowRestrictedNames": "off" 36 | }, 37 | "style": { 38 | "noNonNullAssertion": "off" 39 | }, 40 | "a11y": { 41 | "noSvgWithoutTitle": "off", 42 | "useButtonType": "off", 43 | "useValidAnchor": "off", 44 | "useHtmlLang": "off", 45 | "useHeadingContent": "off" 46 | }, 47 | "correctness": { 48 | "useExhaustiveDependencies": "off", 49 | "noEmptyPattern": "off", 50 | "noUnreachable": "off" 51 | }, 52 | "complexity": { 53 | "noForEach": "off" 54 | }, 55 | "recommended": true 56 | } 57 | }, 58 | "javascript": { 59 | "formatter": { 60 | "quoteStyle": "double" 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enalmada/nextjs-boilerplate/a0543a353bbb346ff5266f626927848be9eab536/bun.lockb -------------------------------------------------------------------------------- /codegen.ts: -------------------------------------------------------------------------------- 1 | import createCodegenConfig from "@enalmada/next-gql-codegen"; 2 | 3 | const config = createCodegenConfig(); 4 | 5 | export default config; 6 | -------------------------------------------------------------------------------- /dependencies.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | sudo apt-get update 4 | sudo apt-get -y upgrade 5 | 6 | bun upgrade 7 | bun update -g 8 | 9 | ncu # manually update local deps if desired -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # https://gist.github.com/dbist/ebb1f39f580ad9d07c04c3a3377e2bff 2 | # admin-ui with `bun crdb:admin` or http://127.0.0.1:8080 3 | 4 | services: 5 | crdb: 6 | image: cockroachdb/cockroach:v24.3.0 7 | container_name: crdb 8 | ports: 9 | - '26257:26257' 10 | - '8080:8080' 11 | command: start-single-node --insecure 12 | volumes: 13 | - 'crdb-data:/cockroach/cockroach-data' 14 | networks: 15 | - my_network 16 | 17 | crdbTest: 18 | image: cockroachdb/cockroach:v24.3.0 19 | container_name: crdbTest 20 | ports: 21 | - '26258:26257' 22 | - '8081:8080' 23 | command: start-single-node --insecure 24 | volumes: 25 | - 'crdb-data-test:/cockroach/cockroach-data-test' 26 | networks: 27 | - my_network 28 | 29 | volumes: 30 | crdb-data: 31 | crdb-data-test: 32 | 33 | networks: 34 | my_network: 35 | driver: bridge 36 | # If you want to use postgres (neon, supabase, etc) 37 | # potentially helpful scripts 38 | # "pgadmin": "cmd.exe /C start http://127.0.0.1:5050", 39 | # "pgadmin:win": "start http://127.0.0.1:5050", 40 | 41 | #version: '3.9' 42 | # 43 | #services: 44 | # db: 45 | # image: 'postgres:16' 46 | # environment: 47 | # POSTGRES_PASSWORD: asdfasdf 48 | # POSTGRES_USER: postgres 49 | # ports: 50 | # - '5432:5432' 51 | # volumes: 52 | # - 'db-data:/var/lib/postgresql/data' 53 | # networks: 54 | # - my_network 55 | # command: -p 5432 56 | # 57 | # dbTest: 58 | # image: 'postgres:16' 59 | # environment: 60 | # POSTGRES_PASSWORD: asdfasdf 61 | # POSTGRES_USER: postgres 62 | # ports: 63 | # - '5433:5433' 64 | # volumes: 65 | # - 'db-data-test:/var/lib/postgresql/data' 66 | # networks: 67 | # - my_network 68 | # command: -p 5433 69 | # 70 | # pgadmin: 71 | # image: dpage/pgadmin4:latest 72 | # environment: 73 | # PGADMIN_DEFAULT_EMAIL: admin@example.com 74 | # PGADMIN_DEFAULT_PASSWORD: admin 75 | # ports: 76 | # - '5050:80' 77 | # volumes: 78 | # - 'pgadmin-data:/var/lib/pgadmin' 79 | # networks: 80 | # - my_network 81 | # 82 | #volumes: 83 | # db-data: 84 | # db-data-test: 85 | # pgadmin-data: 86 | # 87 | #networks: 88 | # my_network: 89 | # driver: bridge 90 | # 91 | -------------------------------------------------------------------------------- /dockerStart.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | # Checking for container name before starting is faster than trying to start 4 | 5 | if [ -z "$(docker ps -q -f name=crdb)" ]; then 6 | echo "Starting Docker containers..." 7 | docker compose --env-file .env.local up -d --remove-orphans 8 | else 9 | echo "Docker containers already running." 10 | fi -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "drizzle-kit"; 2 | 3 | export default { 4 | schema: "./src/server/db/schema.ts", 5 | out: "./src/server/db/migrations", 6 | dialect: "postgresql", 7 | dbCredentials: { 8 | url: process.env.DATABASE_URL!, 9 | }, 10 | } satisfies Config; 11 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Exit the script as soon as a command fails 4 | set -e 5 | 6 | # Create .env.production.local with all environment variables 7 | printenv > /app/.env.production.local 8 | 9 | # Run DB migrations (db url is currently runtime parameter) 10 | bun run drizzle:migrate:prod 11 | 12 | # Start Next.js server bound to all network interfaces 13 | # If you use custom server.js, make sure it binds to 0.0.0.0 14 | HOST=0.0.0.0 exec node server.js 15 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "emulators": { 3 | "singleProjectMode": true, 4 | "auth": { 5 | "port": 9099 6 | }, 7 | "ui": { 8 | "enabled": true 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /messages/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "Index": { 3 | "title": "Hello", 4 | "home": "Home", 5 | "hero-main": "Simple ToDo App" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /messages/es.json: -------------------------------------------------------------------------------- 1 | { 2 | "Index": { 3 | "title": "Hola", 4 | "home": "Inicio", 5 | "hero-main": "Aplicación simple de tareas pendientes" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /messages/ru.json: -------------------------------------------------------------------------------- 1 | { 2 | "Index": { 3 | "title": "Привет", 4 | "home": "Главная", 5 | "hero-main": "Простое приложение ToDo" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | // default value as of next 14 doesn't include tailwind 2 | // json causes trouble with storybook. 3 | // js causes issues with next 14 4 | module.exports = { 5 | plugins: { 6 | tailwindcss: {}, 7 | autoprefixer: {}, 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enalmada/nextjs-boilerplate/a0543a353bbb346ff5266f626927848be9eab536/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enalmada/nextjs-boilerplate/a0543a353bbb346ff5266f626927848be9eab536/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enalmada/nextjs-boilerplate/a0543a353bbb346ff5266f626927848be9eab536/public/apple-icon.png -------------------------------------------------------------------------------- /public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 |Loading...
42 | > 43 | )} 44 | 45 | {error &&Oh no... {error.message}
} 46 | 47 | {data?.uploadFile ? ( 48 |File uploaded: {data.uploadFile.filename}
49 | ) : ( 50 |Loading...
20 | > 21 | )} 22 | 23 | {error &&Oh no... {error.message}
} 24 | 25 | 32 | 33 | {data?.publishNotification && ( 34 |35 | Notification published:{" "} 36 | {data.publishNotification.published === true ? "true" : "false"} 37 |
38 | )} 39 |No new messages
; 28 | } 29 | 30 | return ( 31 | <> 32 | {result.data.map((event: NotificationEvent) => ( 33 |35 | {event.type}: "{event.message}" 36 |
37 |Trigger a sample error:
23 | 60 |{JSON.stringify(user.customClaims, undefined, 2)}62 |
30 | Don't have an account yet? 31 | 38 | Sign up here 39 | 40 |
41 |16 | Please try again later 17 |
18 |30 | Already have an account? 31 | 38 | Sign in here 39 | 40 |
41 |12 | Frequently asked questions 13 |
14 |37 | SAAS platform is a cloud-based software service that allows 38 | users to access and use a variety of tools and functionality. 39 |
40 |11 | Your privacy is important to us. It is Technical Challenge's 12 | policy to respect your privacy regarding any information we may 13 | collect from you across our website,{" "} 14 | 15 | https://TechnicalChallenge.com 16 | 17 | , and other sites we own and operate. 18 |
19 |20 | We only ask for personal information when we truly need it to provide 21 | a service to you. We collect it by fair and lawful means, with your 22 | knowledge and consent. We also let you know why we’re collecting it 23 | and how it will be used. 24 |
25 |26 | We only retain collected information for as long as necessary to 27 | provide you with your requested service. What data we store, we’ll 28 | protect within commercially acceptable means to prevent loss and 29 | theft, as well as unauthorized access, disclosure, copying, use or 30 | modification. 31 |
32 |33 | We don’t share any personally identifying information publicly or with 34 | third-parties, except when required to by law. 35 |
36 |37 | Our website may link to external sites that are not operated by us. 38 | Please be aware that we have no control over the content and practices 39 | of these sites, and cannot accept responsibility or liability for 40 | their respective privacy policies. 41 |
42 |43 | You are free to refuse our request for your personal information, with 44 | the understanding that we may be unable to provide you with some of 45 | your desired services. 46 |
47 |48 | Your continued use of our website will be regarded as acceptance of 49 | our practices around privacy and personal information. If you have any 50 | questions about how we handle user data and personal information, feel 51 | free to contact us. 52 |
53 |This policy is effective as of 27 October 2020.
54 |({ 19 | query: query, 20 | variables: { 21 | input: { 22 | where: { ...input }, 23 | order: { 24 | sortBy: sortDescriptor.column, 25 | sortOrder: sortDescriptor.direction === "ascending" ? "ASC" : "DESC", 26 | }, 27 | pagination: { 28 | page: pageDescriptor.page, 29 | pageSize: pageDescriptor.pageSize, 30 | }, 31 | }, 32 | } as unknown as V, 33 | // May not need this depending on real time requirements. Here for delete example to work 34 | // TODO consider clearing cache on add/delete 35 | // https://github.com/urql-graphql/urql/issues/297#issuecomment-504782794 36 | requestPolicy: "cache-and-network", 37 | ...config, 38 | }); 39 | 40 | return { 41 | data, 42 | fetching, 43 | error, 44 | }; 45 | }; 46 | -------------------------------------------------------------------------------- /src/client/components/admin/Auditing.tsx: -------------------------------------------------------------------------------- 1 | export interface AuditedEntity { 2 | createdAt: Date; 3 | createdById?: string; 4 | updatedAt?: Date | null; 5 | updatedById?: string; 6 | version: number; 7 | } 8 | 9 | interface Props { 10 | entity: AuditedEntity; 11 | } 12 | 13 | const Auditing = (props: Props) => { 14 | const entity = props.entity; 15 | return ( 16 |17 | {entity.createdAt && ( 18 |27 | ); 28 | }; 29 | 30 | export default Auditing; 31 | -------------------------------------------------------------------------------- /src/client/components/admin/Chip.tsx: -------------------------------------------------------------------------------- 1 | import { Chip, type ChipProps } from "@nextui-org/react"; 2 | import React from "react"; 3 | 4 | interface Props extends ChipProps { 5 | label: string; 6 | } 7 | 8 | const ReadOnlyInput = (props: Props) => ( 9 |Created {new Date(entity.createdAt).toLocaleString()}19 | )} 20 | {entity.updatedAt && entity.createdAt !== entity.updatedAt && ( 21 | <> 22 |Updated {new Date(entity.updatedAt).toLocaleString()}23 |Version {entity.version}24 | > 25 | )} 26 |10 | {props.label} 11 | 12 | ); 13 | 14 | export default ReadOnlyInput; 15 | -------------------------------------------------------------------------------- /src/client/components/admin/Edit.tsx: -------------------------------------------------------------------------------- 1 | import { BiEditAlt as EditIcon } from "react-icons/bi"; 2 | 3 | const Edit = () => ( 4 |5 |7 | ); 8 | 9 | export default Edit; 10 | -------------------------------------------------------------------------------- /src/client/components/admin/ReadOnlyInput.tsx: -------------------------------------------------------------------------------- 1 | import { Input } from "@/client/ui"; 2 | import type { InputProps as NextUIInputProps } from "@nextui-org/react"; 3 | 4 | const ReadOnlyInput = (props: NextUIInputProps) => ( 5 |6 | 6 | 16 |17 | ); 18 | 19 | export default ReadOnlyInput; 20 | -------------------------------------------------------------------------------- /src/client/components/admin/buttons/CancelButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/client/ui"; 2 | import type { ButtonProps } from "@nextui-org/react"; 3 | import NextLink from "next/link"; 4 | import React from "react"; 5 | 6 | const CancelButton = (props: ButtonProps) => ( 7 | 10 | ); 11 | 12 | export default CancelButton; 13 | -------------------------------------------------------------------------------- /src/client/components/admin/buttons/DeleteButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/client/ui"; 2 | import type { ButtonProps } from "@nextui-org/react"; 3 | import React from "react"; 4 | 5 | const DeleteButton = (props: ButtonProps) => ( 6 | 9 | ); 10 | 11 | export default DeleteButton; 12 | -------------------------------------------------------------------------------- /src/client/components/auth/Redirecting.tsx: -------------------------------------------------------------------------------- 1 | import { Spinner } from "@/client/ui"; 2 | import type { ReactNode } from "react"; 3 | 4 | interface Props { 5 | children: ReactNode; 6 | } 7 | export default function Redirecting({ children }: Props) { 8 | return ( 9 |10 |13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/client/components/layout/Layout.tsx: -------------------------------------------------------------------------------- 1 | import Footer from "./Footer"; 2 | import Header from "./Header"; 3 | 4 | export default function Layout({ children }: { children: React.ReactNode }) { 5 | return ( 6 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /src/client/components/layout/app/AcmeLogo.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const AcmeLogo = () => ( 4 | 12 | ); 13 | -------------------------------------------------------------------------------- /src/client/components/layout/header/ProfileButtons.tsx: -------------------------------------------------------------------------------- 1 | import { useAuth } from "@enalmada/next-firebase-auth-edge-wrapper"; 2 | import Link from "next/link"; 3 | 4 | export default function AuthButtons() { 5 | const { user } = useAuth(); 6 | 7 | const active = false; 8 | return ( 9 | <> 10 | {user ? ( 11 | 19 | Profile 20 | 21 | ) : ( 22 | 30 | About 31 | 32 | )} 33 | > 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /src/client/components/nextui/primitives.ts: -------------------------------------------------------------------------------- 1 | import { tv } from "tailwind-variants"; 2 | 3 | export const title = tv({ 4 | base: "tracking-tight inline font-semibold", 5 | variants: { 6 | color: { 7 | violet: "from-[#FF1CF7] to-[#b249f8]", 8 | yellow: "from-[#FF705B] to-[#FFB457]", 9 | blue: "from-[#5EA2EF] to-[#0072F5]", 10 | cyan: "from-[#00b7fa] to-[#01cfea]", 11 | green: "from-[#6FEE8D] to-[#17c964]", 12 | pink: "from-[#FF72E1] to-[#F54C7A]", 13 | foreground: "dark:from-[#FFFFFF] dark:to-[#4B4B4B]", 14 | }, 15 | size: { 16 | sm: "text-3xl lg:text-4xl", 17 | md: "text-[2.3rem] lg:text-5xl leading-9", 18 | lg: "text-4xl lg:text-6xl", 19 | }, 20 | fullWidth: { 21 | true: "w-full block", 22 | }, 23 | }, 24 | defaultVariants: { 25 | size: "md", 26 | }, 27 | compoundVariants: [ 28 | { 29 | color: [ 30 | "violet", 31 | "yellow", 32 | "blue", 33 | "cyan", 34 | "green", 35 | "pink", 36 | "foreground", 37 | ], 38 | class: "bg-clip-text text-transparent bg-gradient-to-b", 39 | }, 40 | ], 41 | }); 42 | 43 | export const subtitle = tv({ 44 | base: "w-full md:w-1/2 my-2 text-lg lg:text-xl text-default-600 block max-w-full", 45 | variants: { 46 | fullWidth: { 47 | true: "!w-full", 48 | }, 49 | }, 50 | defaultVariants: { 51 | fullWidth: true, 52 | }, 53 | }); 54 | -------------------------------------------------------------------------------- /src/client/components/nextui/site.ts: -------------------------------------------------------------------------------- 1 | export type SiteConfig = typeof siteConfig; 2 | 3 | export const siteConfig = { 4 | name: "Next.js + NextUI", 5 | description: "Make beautiful websites regardless of your design experience.", 6 | navItems: [ 7 | { 8 | label: "Home", 9 | href: "/", 10 | }, 11 | { 12 | label: "Docs", 13 | href: "/docs", 14 | }, 15 | { 16 | label: "Pricing", 17 | href: "/pricing", 18 | }, 19 | { 20 | label: "Blog", 21 | href: "/blog", 22 | }, 23 | { 24 | label: "About", 25 | href: "/about", 26 | }, 27 | ], 28 | navMenuItems: [ 29 | { 30 | label: "Profile", 31 | href: "/profile", 32 | }, 33 | { 34 | label: "Dashboard", 35 | href: "/dashboard", 36 | }, 37 | { 38 | label: "Projects", 39 | href: "/projects", 40 | }, 41 | { 42 | label: "Team", 43 | href: "/team", 44 | }, 45 | { 46 | label: "Calendar", 47 | href: "/calendar", 48 | }, 49 | { 50 | label: "Settings", 51 | href: "/settings", 52 | }, 53 | { 54 | label: "Help & Feedback", 55 | href: "/help-feedback", 56 | }, 57 | { 58 | label: "Logout", 59 | href: "/logout", 60 | }, 61 | ], 62 | links: { 63 | github: "https://github.com/nextui-org/nextui", 64 | twitter: "https://twitter.com/getnextui", 65 | docs: "https://nextui-docs-v2.vercel.app", 66 | discord: "https://discord.gg/9b6yyZKmH4", 67 | sponsor: "https://patreon.com/jrgarciadev", 68 | }, 69 | }; 70 | -------------------------------------------------------------------------------- /src/client/components/tasks/Task.stories.tsx: -------------------------------------------------------------------------------- 1 | import { TaskBody } from "@/client/components/tasks/Task"; 2 | import { TaskStatus } from "@/client/gql/generated/graphql"; 3 | import { createRandomTask } from "@/client/gql/globalMocks"; 4 | import type { Meta, StoryObj } from "@storybook/react"; 5 | 6 | // More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction 7 | const meta: Meta{children}11 |12 | = { 8 | title: "UI/Task", 9 | component: TaskBody, 10 | tags: ["autodocs"], 11 | argTypes: {}, 12 | }; 13 | 14 | export default meta; 15 | type Story = StoryObj ; 16 | 17 | const defaultProps = { 18 | task: { 19 | ...createRandomTask("tsk_1"), 20 | }, 21 | }; 22 | export const Default: Story = { 23 | args: { 24 | task: defaultProps.task, 25 | }, 26 | }; 27 | // More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args 28 | export const Loading: Story = { 29 | args: { 30 | task: undefined, 31 | }, 32 | }; 33 | 34 | export const NoDescription: Story = { 35 | args: { 36 | ...defaultProps, 37 | task: { 38 | ...defaultProps.task, 39 | description: undefined, 40 | }, 41 | }, 42 | }; 43 | 44 | export const DueDate: Story = { 45 | args: { 46 | ...defaultProps, 47 | task: { 48 | ...defaultProps.task, 49 | dueDate: new Date(), 50 | }, 51 | }, 52 | }; 53 | 54 | export const Done: Story = { 55 | args: { 56 | ...defaultProps, 57 | task: { 58 | ...defaultProps.task, 59 | status: TaskStatus.Completed, 60 | }, 61 | }, 62 | }; 63 | -------------------------------------------------------------------------------- /src/client/components/tasks/TaskList.stories.tsx: -------------------------------------------------------------------------------- 1 | import TaskList, { TaskListLoading } from "@/client/components/tasks/TaskList"; 2 | import type { Meta, StoryObj } from "@storybook/react"; 3 | 4 | // More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction 5 | const meta: Meta = { 6 | title: "UI/TaskList", 7 | component: TaskList, 8 | tags: ["autodocs"], 9 | argTypes: {}, 10 | }; 11 | 12 | export default meta; 13 | type Story = StoryObj ; 14 | 15 | // const { id, title, description, dueDate, status } = props.task; 16 | 17 | // More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args 18 | export const Default: Story = { 19 | args: {}, 20 | }; 21 | 22 | export const Loading: Story = { 23 | render: () => , 24 | }; 25 | 26 | export const Empty: Story = { 27 | args: {}, 28 | }; 29 | 30 | // Can't get network error to work 31 | /* 32 | const apolloError = new ApolloError({ 33 | graphQLErrors: [new GraphQLError('SLOT_ALREADY_BOOKED')], 34 | networkError: null, 35 | }); 36 | 37 | export const NetworkError: Story = { 38 | }; 39 | 40 | NetworkError.parameters = { 41 | apolloClient: { 42 | mocks: [ 43 | { 44 | request: { 45 | query: TASKS, 46 | }, 47 | error: 'bla' 48 | }, 49 | ], 50 | }, 51 | }; 52 | 53 | */ 54 | -------------------------------------------------------------------------------- /src/client/components/tasks/TaskList.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { MY_TASKS } from "@/client/gql/client-queries.gql"; 4 | import type { MyTasksQuery, Task } from "@/client/gql/generated/graphql"; 5 | import { Card, CardBody } from "@/client/ui"; 6 | import { useQuery } from "@enalmada/next-gql/client"; 7 | 8 | import TaskRender, { TaskBody } from "./Task"; 9 | 10 | export const dynamic = "force-dynamic"; 11 | 12 | export const TaskListLoading = () => { 13 | return ( 14 | 15 |19 | ); 20 | }; 21 | 22 | const EmptyState = () => { 23 | return ( 24 |16 | 17 | 18 | 25 | 29 | ); 30 | }; 31 | 32 | export default function TaskList() { 33 | const [{ data, error }] = useQuery26 | 28 |No Items27 |({ query: MY_TASKS }); 34 | 35 | if (error) return {`Error! ${error?.message}`}; 36 | 37 | // It is possible for tasks to be null until it is populated to to ME query not calling it 38 | if (!data?.me?.tasks) return; 39 | 40 | if (!data?.me?.tasks || data?.me?.tasks.length === 0) { 41 | return ; 42 | } 43 | 44 | // TODO this should be sorted on server and paginated 45 | const tasks: Task[] = [...(data?.me?.tasks as Task[])].sort((a, b) => { 46 | // Turn your strings into dates, and then subtract them 47 | // to get a value that is either negative, positive, or zero. 48 | return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); 49 | }); 50 | 51 | return ( 52 | 53 | {tasks.map((task) => { 54 | return57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /src/client/gql/errorHandling.tsx: -------------------------------------------------------------------------------- 1 | // TODO update with better types 2 | // https://formidable.com/open-source/urql/docs/basics/errors/ 3 | 4 | // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents 5 | export const extractErrorMessages = (...errors: unknown[]): string[] => { 6 | return errors.reduce; 55 | })} 56 | ((acc, error) => { 7 | // Check if error is an object and has a 'message' property of type string 8 | if (typeof error === "object" && error !== null && "message" in error) { 9 | const errorMessage = (error as { message: unknown }).message; 10 | if (typeof errorMessage === "string") { 11 | acc.push(errorMessage); 12 | } 13 | } 14 | return acc; 15 | }, []); 16 | }; 17 | 18 | // Older apollo client stuff for reference 19 | /* 20 | type ErrorType = { 21 | message: string; 22 | }; 23 | 24 | export const extractErrorMessages = (error?: unknown): string[] => { 25 | if (!error) { 26 | return []; 27 | } 28 | 29 | const errorMessages: string[] = []; 30 | 31 | // Handle graphQLErrors 32 | if (error.graphQLErrors) { 33 | error.graphQLErrors.forEach((graphQLError) => { 34 | errorMessages.push(graphQLError.message); 35 | }); 36 | } 37 | 38 | // Handle networkError 39 | const networkError = error.networkError; 40 | if (networkError && 'result' in networkError) { 41 | if (typeof networkError.result === 'string') { 42 | errorMessages.push(networkError.result); 43 | } else if (typeof networkError.result === 'object' && 'errors' in networkError.result) { 44 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 45 | const resultErrors = networkError.result.errors; 46 | if (Array.isArray(resultErrors)) { 47 | resultErrors.forEach((resultError) => { 48 | const error = resultError as ErrorType; 49 | errorMessages.push(error.message); 50 | }); 51 | } 52 | } 53 | } 54 | 55 | return errorMessages; 56 | }; 57 | */ 58 | -------------------------------------------------------------------------------- /src/client/gql/generated/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./gql"; -------------------------------------------------------------------------------- /src/client/styles/fonts.ts: -------------------------------------------------------------------------------- 1 | import { Inter as FontSans } from "next/font/google"; 2 | 3 | // https://nextjs.org/docs/app/building-your-application/optimizing/fonts#with-tailwind-css 4 | 5 | export const fontSans = FontSans({ 6 | subsets: ["latin"], 7 | variable: "--font-sans", 8 | display: "swap", 9 | }); 10 | -------------------------------------------------------------------------------- /src/client/styles/index.css: -------------------------------------------------------------------------------- 1 | /* purgecss start ignore */ 2 | @tailwind base; 3 | 4 | @tailwind components; 5 | /* purgecss end ignore */ 6 | 7 | @tailwind utilities; 8 | 9 | @layer base { 10 | html, 11 | body { 12 | min-height: 100%; 13 | height: auto; 14 | } 15 | 16 | .gradient { 17 | background: linear-gradient(90deg, #d53369 0%, #daae51 100%); 18 | } 19 | 20 | /* sticky footer hacking for next.js */ 21 | /* purgecss ignore */ 22 | #__next, 23 | #sticky-footer { 24 | height: 100%; 25 | display: flex; 26 | min-height: 100vh; 27 | flex-direction: column; 28 | } 29 | 30 | .page-class { 31 | margin-top: 72px; 32 | } 33 | @media screen and (max-width: 1023px) { 34 | .page-class { 35 | margin-top: 52px; 36 | } 37 | } 38 | 39 | /* NextUI requires this */ 40 | /* https://github.com/nextui-org/nextui/discussions/804#discussioncomment-4241529 */ 41 | [data-overlay-container="true"] { 42 | height: 100%; 43 | width: 100%; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/client/ui/Breadcrumb/Breadcrumb.stories.ts: -------------------------------------------------------------------------------- 1 | import { Breadcrumb } from "@/client/ui"; 2 | import { getRouteById } from "@/client/utils/routes"; 3 | import type { Meta, StoryObj } from "@storybook/react"; 4 | 5 | // More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction 6 | const meta: Meta = { 7 | title: "Components/Breadcrumb", 8 | component: Breadcrumb, 9 | tags: ["autodocs"], 10 | argTypes: {}, 11 | }; 12 | 13 | export default meta; 14 | type Story = StoryObj ; 15 | 16 | const defaultProps = { 17 | routes: [getRouteById("Home"), getRouteById("Task")], 18 | }; 19 | 20 | export const Default: Story = { 21 | args: { 22 | ...defaultProps, 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /src/client/ui/Breadcrumb/Breadcrumb.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "@/client/ui"; 2 | import type { Route } from "@/client/utils/routes"; 3 | 4 | interface Props { 5 | routes: Route[]; 6 | } 7 | 8 | // https://tailwindcomponents.com/component/breadcrumbs-1 9 | const Breadcrumb = (props: Props) => { 10 | const routesCopy = [...props.routes]; 11 | const last = routesCopy.pop(); 12 | 13 | return ( 14 | 41 | ); 42 | }; 43 | 44 | export default Breadcrumb; 45 | -------------------------------------------------------------------------------- /src/client/ui/Button/Button.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/client/ui"; 2 | import { button } from "@nextui-org/react"; 3 | import type { Meta, StoryObj } from "@storybook/react"; 4 | 5 | // More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction 6 | const meta: Meta = { 7 | title: "Components/Button", 8 | component: Button, 9 | tags: ["autodocs"], 10 | argTypes: {}, 11 | }; 12 | 13 | export default meta; 14 | type Story = StoryObj ; 15 | 16 | const defaultProps = { 17 | children: "Button", 18 | ...button.defaultVariants, 19 | }; 20 | 21 | // More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args 22 | export const Primary: Story = { 23 | args: { 24 | color: "primary", 25 | ...defaultProps, 26 | }, 27 | }; 28 | 29 | export const Secondary: Story = { 30 | args: { 31 | color: "secondary", 32 | }, 33 | }; 34 | 35 | export const Large: Story = { 36 | args: { 37 | size: "lg", 38 | }, 39 | }; 40 | 41 | export const Small: Story = { 42 | args: { 43 | size: "sm", 44 | }, 45 | }; 46 | -------------------------------------------------------------------------------- /src/client/ui/Button/Button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | Button as NextUIButton, 5 | type ButtonProps as NextUIButtonProps, 6 | } from "@nextui-org/react"; 7 | import type React from "react"; 8 | 9 | interface ButtonProps extends NextUIButtonProps { 10 | children: React.ReactNode; 11 | hoverIndication?: boolean; 12 | color?: 13 | | "default" 14 | | "primary" 15 | | "secondary" 16 | | "success" 17 | | "warning" 18 | | "danger" 19 | | undefined; 20 | radius?: "sm" | "md" | "lg" | "none" | "full" | undefined; 21 | className?: string; 22 | } 23 | 24 | export function Button({ 25 | color = "primary", 26 | radius = "sm", 27 | hoverIndication = true, 28 | ...props 29 | }: ButtonProps) { 30 | // Apply some hover effects 31 | let className = props.className || ""; 32 | 33 | if (hoverIndication && color === "primary") { 34 | className = `hover:bg-blue-700 ${className}`; 35 | } 36 | 37 | if (hoverIndication && color === "default") { 38 | className = `hover:bg-gray-400 ${className}`; 39 | } 40 | 41 | return ( 42 | 48 | {props.children} 49 | 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /src/client/ui/ButtonGroup/ButtonGroup.stories.tsx: -------------------------------------------------------------------------------- 1 | // import { ButtonGroup } from '@/client/ui'; 2 | import { Button, ButtonGroup } from "@/client/ui"; 3 | import { button, buttonGroup } from "@nextui-org/react"; 4 | import type { Meta, StoryObj } from "@storybook/react"; 5 | 6 | // More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction 7 | const meta: Meta= { 8 | title: "Components/ButtonGroup", 9 | component: ButtonGroup, 10 | tags: ["autodocs"], 11 | }; 12 | 13 | export default meta; 14 | type Story = StoryObj ; 15 | 16 | const defaultProps = { 17 | ...button.defaultVariants, 18 | ...buttonGroup.defaultVariants, 19 | }; 20 | 21 | // More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args 22 | export const Default: Story = { 23 | args: { 24 | color: "primary", 25 | ...defaultProps, 26 | }, 27 | render: (args) => ( 28 | 29 | {" "} 30 | 31 | 32 | 33 | 34 | ), 35 | }; 36 | -------------------------------------------------------------------------------- /src/client/ui/ButtonGroup/ButtonGroup.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | ButtonGroup as NextUIButtonGroup, 5 | type ButtonGroupProps as NextUIButtonGroupProps, 6 | } from "@nextui-org/react"; 7 | import React from "react"; 8 | 9 | export function ButtonGroup({ ...props }: NextUIButtonGroupProps) { 10 | return{props.children} ; 11 | } 12 | -------------------------------------------------------------------------------- /src/client/ui/Card/Card.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardBody } from "@/client/ui/Card/Card"; 2 | import { card } from "@nextui-org/react"; 3 | import type { Meta, StoryObj } from "@storybook/react"; 4 | 5 | // More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction 6 | const meta: Meta= { 7 | title: "Components/Card", 8 | component: Card, 9 | tags: ["autodocs"], 10 | argTypes: {}, 11 | }; 12 | 13 | export default meta; 14 | type Story = StoryObj ; 15 | 16 | const defaultProps = { 17 | children: "Card", 18 | ...card.defaultVariants, 19 | }; 20 | 21 | // More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args 22 | 23 | export const Default: Story = { 24 | args: { 25 | ...defaultProps, 26 | }, 27 | render: (args) => ( 28 | 29 | 31 | ), 32 | }; 33 | -------------------------------------------------------------------------------- /src/client/ui/Card/Card.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | Card as NextUICard, 5 | CardBody as NextUICardBody, 6 | type CardProps as NextUICardProps, 7 | } from "@nextui-org/react"; 8 | import React from "react"; 9 | 10 | export const Card = ({ ...props }: NextUICardProps) => { 11 | return{args.children} 30 |; 12 | }; 13 | 14 | export const CardBody = ({ ...props }) => { 15 | return ; 16 | }; 17 | -------------------------------------------------------------------------------- /src/client/ui/Input/Input.stories.ts: -------------------------------------------------------------------------------- 1 | import { Input } from "@/client/ui"; 2 | import { input } from "@nextui-org/react"; 3 | import type { Meta, StoryObj } from "@storybook/react"; 4 | 5 | // More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction 6 | const meta: Meta = { 7 | title: "Components/Input", 8 | component: Input, 9 | tags: ["autodocs"], 10 | argTypes: {}, 11 | }; 12 | 13 | export default meta; 14 | type Story = StoryObj ; 15 | 16 | const defaultProps = { 17 | ...input.defaultVariants, 18 | }; 19 | 20 | // More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args 21 | export const Default: Story = { 22 | args: { 23 | ...defaultProps, 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /src/client/ui/Input/Input.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */ 2 | "use client"; 3 | 4 | import { 5 | Input as NextUIInput, 6 | type InputProps as NextUIInputProps, 7 | } from "@nextui-org/react"; 8 | import React from "react"; 9 | import { type Control, Controller } from "react-hook-form"; 10 | 11 | interface InputProps extends NextUIInputProps { 12 | radius?: "sm" | "md" | "lg" | "none" | "full" | undefined; 13 | } 14 | 15 | // export const Input = ({ radius = 'sm', ...props }: InputProps) => ; 16 | export const Input = React.forwardRef ( 17 | function Input({ radius = "sm", ...props }, ref) { 18 | return ; 19 | }, 20 | ); 21 | 22 | export interface ControlProps { 23 | name: string; 24 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 25 | errors: any; 26 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 27 | control: Control | undefined; 28 | label?: string; 29 | labelPlacement?: string; 30 | } 31 | 32 | // Using controller until this is fixed 33 | // https://github.com/nextui-org/nextui/issues/1969 34 | 35 | export const InputControlled = (args: ControlProps & InputProps) => { 36 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment 37 | const errorMessage: string = args.errors[args.name]?.message; 38 | 39 | return ( 40 | ( 44 | 54 | )} 55 | /> 56 | ); 57 | }; 58 | -------------------------------------------------------------------------------- /src/client/ui/Input/Textarea.stories.ts: -------------------------------------------------------------------------------- 1 | import { Textarea } from "@/client/ui"; 2 | import { input } from "@nextui-org/react"; 3 | import type { Meta, StoryObj } from "@storybook/react"; 4 | 5 | // More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction 6 | const meta: Meta = { 7 | title: "Components/Textarea", 8 | component: Textarea, 9 | tags: ["autodocs"], 10 | argTypes: {}, 11 | }; 12 | 13 | export default meta; 14 | type Story = StoryObj ; 15 | 16 | const defaultProps = { 17 | ...input.defaultVariants, 18 | }; 19 | 20 | // More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args 21 | export const Default: Story = { 22 | args: { 23 | ...defaultProps, 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /src/client/ui/Input/Textarea.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */ 2 | 3 | "use client"; 4 | 5 | import type { ControlProps } from "@/client/ui/Input/Input"; 6 | import { 7 | type TextAreaProps as NextUITextAreaProps, 8 | Textarea as NextUITextarea, 9 | } from "@nextui-org/react"; 10 | import React from "react"; 11 | import { Controller } from "react-hook-form"; 12 | 13 | interface InputProps extends NextUITextAreaProps { 14 | radius?: "sm" | "md" | "lg" | "none" | "full" | undefined; 15 | } 16 | 17 | export const Textarea = React.forwardRef ( 18 | function Textarea({ radius = "sm", ...props }, ref) { 19 | return ; 20 | }, 21 | ); 22 | 23 | export const TextareaControlled = (args: ControlProps & InputProps) => { 24 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment 25 | const errorMessage: string = args.errors[args.name]?.message; 26 | 27 | return ( 28 | ( 32 | 41 | )} 42 | /> 43 | ); 44 | }; 45 | -------------------------------------------------------------------------------- /src/client/ui/Link/Link.stories.ts: -------------------------------------------------------------------------------- 1 | import { Link } from "@/client/ui"; 2 | import { link } from "@nextui-org/react"; 3 | import type { Meta, StoryObj } from "@storybook/react"; 4 | 5 | // More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction 6 | const meta: Meta = { 7 | title: "Components/Link", 8 | component: Link, 9 | tags: ["autodocs"], 10 | argTypes: {}, 11 | }; 12 | 13 | export default meta; 14 | type Story = StoryObj ; 15 | 16 | const defaultProps = { 17 | ...link.defaultVariants, 18 | href: "#", 19 | children: "Link", 20 | }; 21 | 22 | // More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args 23 | export const Default: Story = { 24 | args: { 25 | ...defaultProps, 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /src/client/ui/Link/Link.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | "use client"; 3 | 4 | import type * as url from "node:url"; 5 | import { Link as IntlLink } from "@/lib/localization/navigation"; 6 | import { 7 | Link as NextUILink, 8 | type LinkProps as NextUILinkProps, 9 | } from "@nextui-org/react"; 10 | import NextLink from "next/link"; 11 | 12 | type OverriddenProps = "href"; // Add any props you wish to override 13 | 14 | type CombinedLinkProps = Omit ; 15 | 16 | interface LinkProps extends CombinedLinkProps { 17 | children?: React.ReactNode; 18 | // eslint-disable-next-line @typescript-eslint/consistent-type-imports 19 | href: string | url.UrlObject; 20 | } 21 | 22 | export default function Link(props: LinkProps) { 23 | if (props.isExternal) { 24 | return ( 25 | 26 | {props.children} 27 | 28 | ); 29 | } 30 | return ( 31 |32 | {props.children} 33 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /src/client/ui/NextUIWrapper.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { NextUIProvider } from "@nextui-org/react"; 4 | import { ThemeProvider as NextThemesProvider } from "next-themes"; 5 | import { useRouter } from "next/navigation"; 6 | import type { PropsWithChildren, ReactNode } from "react"; 7 | 8 | // Manually defining based on chatgpt. Could export this manually in the future 9 | interface ValueObject { 10 | [themeName: string]: string; 11 | } 12 | 13 | type Attribute = `data-${string}` | "class"; 14 | 15 | interface ThemeProviderProps extends PropsWithChildren { 16 | themes?: string[] | undefined; 17 | forcedTheme?: string | undefined; 18 | enableSystem?: boolean | undefined; 19 | disableTransitionOnChange?: boolean | undefined; 20 | enableColorScheme?: boolean | undefined; 21 | storageKey?: string | undefined; 22 | defaultTheme?: string | undefined; 23 | attribute?: Attribute | Attribute[] | undefined; 24 | value?: ValueObject | undefined; 25 | nonce?: string | undefined; 26 | } 27 | 28 | export interface ProvidersProps { 29 | children: ReactNode; 30 | themeProps?: ThemeProviderProps; 31 | } 32 | 33 | export function NextUIWrapper({ children, themeProps }: ProvidersProps) { 34 | const router = useRouter(); 35 | 36 | return ( 37 |router.push(href)}> 38 | 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /src/client/ui/Radio/Radio.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | Radio as NextUIRadio, 5 | type RadioProps as NextUIRadioProps, 6 | } from "@nextui-org/react"; 7 | import React from "react"; 8 | 9 | interface RadioProps extends NextUIRadioProps { 10 | size?: "sm" | "md" | "lg"; 11 | } 12 | 13 | export const Radio = React.forwardRef{children} 39 |( 14 | ({ size = "md", ...props }, ref) => { 15 | return ; 16 | }, 17 | ); 18 | 19 | Radio.displayName = "Radio"; 20 | -------------------------------------------------------------------------------- /src/client/ui/Radio/RadioGroup.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Radio, RadioGroup } from "@/client/ui"; 2 | import { radioGroup } from "@nextui-org/react"; 3 | import type { Meta, StoryObj } from "@storybook/react"; 4 | 5 | // More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction 6 | const meta: Meta = { 7 | title: "Components/RadioGroup", 8 | component: RadioGroup, 9 | tags: ["autodocs"], 10 | argTypes: {}, 11 | }; 12 | 13 | export default meta; 14 | type Story = StoryObj ; 15 | 16 | const defaultProps = { 17 | ...radioGroup.defaultVariants, 18 | }; 19 | 20 | // More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args 21 | export const Default: Story = { 22 | args: { 23 | ...defaultProps, 24 | }, 25 | render: (args) => ( 26 | 27 | 33 | ), 34 | }; 35 | -------------------------------------------------------------------------------- /src/client/ui/Radio/RadioGroup.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | "use client"; 3 | 4 | import type { ControlProps } from "@/client/ui/Input/Input"; 5 | import { 6 | RadioGroup as NextUIRadioGroup, 7 | type RadioGroupProps as NextUIRadioGroupProps, 8 | } from "@nextui-org/react"; 9 | import { forwardRef } from "react"; 10 | import { Controller } from "react-hook-form"; 11 | 12 | interface RadioGroupProps extends NextUIRadioGroupProps { 13 | color: 14 | | "default" 15 | | "primary" 16 | | "secondary" 17 | | "success" 18 | | "warning" 19 | | "danger" 20 | | undefined; 21 | disableAnimation: boolean; 22 | size: "sm" | "md" | "lg" | undefined; 23 | mapValueToRadio?: (value: boolean | null) => string; 24 | mapRadioToValue?: (value: string) => boolean | null; 25 | } 26 | 27 | export const RadioGroup = forwardRefBuenos Aires 28 |Sydney 29 |San Francisco 30 |London 31 |Tokyo 32 |( 28 | function RadioGroup({ color = "primary", size = "md", ...props }, ref) { 29 | return ; 30 | }, 31 | ); 32 | 33 | export const RadioGroupControlled = (args: ControlProps & RadioGroupProps) => { 34 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment 35 | const errorMessage: string = args.errors[args.name]?.message; 36 | 37 | const { mapValueToRadio, mapRadioToValue, ...otherArgs } = args; 38 | 39 | return ( 40 | ( 44 | field.onChange(mapRadioToValue(e.target.value)) 55 | : field.onChange 56 | } 57 | label={args.label} 58 | isInvalid={!!errorMessage} 59 | errorMessage={errorMessage} 60 | > 61 | {args.children} 62 | 63 | )} 64 | /> 65 | ); 66 | }; 67 | -------------------------------------------------------------------------------- /src/client/ui/Skeleton/Skeleton.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from "@/client/ui"; 2 | import { Card, skeleton } from "@nextui-org/react"; 3 | import type { Meta, StoryObj } from "@storybook/react"; 4 | 5 | // More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction 6 | const meta: Meta= { 7 | title: "Components/Skeleton", 8 | component: Skeleton, 9 | tags: ["autodocs"], 10 | argTypes: {}, 11 | }; 12 | 13 | export default meta; 14 | type Story = StoryObj ; 15 | 16 | const defaultProps = { 17 | ...skeleton.defaultVariants, 18 | }; 19 | 20 | // More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args 21 | export const Default: Story = { 22 | args: { 23 | ...defaultProps, 24 | }, 25 | render: (args) => ( 26 | 27 | 42 | ), 43 | }; 44 | -------------------------------------------------------------------------------- /src/client/ui/Skeleton/Skeleton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | Skeleton as NextUISkeleton, 5 | type SkeletonProps as NextUISkeletonProps, 6 | } from "@nextui-org/react"; 7 | import React from "react"; 8 | 9 | export const Skeleton = ({ ...props }: NextUISkeletonProps) => { 10 | return props.isLoaded ? ( 11 | props.children 12 | ) : ( 13 |28 | 29 | 30 |31 |41 |32 | 33 | 34 |35 | 36 | 37 |38 | 39 | 40 |{props.children} 14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /src/client/ui/Spinner/Spinner.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Spinner } from "@/client/ui"; 2 | import { spinner } from "@nextui-org/react"; 3 | import type { Meta, StoryObj } from "@storybook/react"; 4 | 5 | // More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction 6 | const meta: Meta= { 7 | title: "Components/Spinner", 8 | component: Spinner, 9 | tags: ["autodocs"], 10 | argTypes: {}, 11 | }; 12 | 13 | export default meta; 14 | type Story = StoryObj ; 15 | 16 | const defaultProps = { 17 | ...spinner.defaultVariants, 18 | }; 19 | 20 | // More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args 21 | export const Default: Story = { 22 | args: { 23 | ...defaultProps, 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /src/client/ui/Spinner/Spinner.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Spinner as NextUISpinner } from "@nextui-org/react"; 4 | 5 | export function Spinner() { 6 | return ; 7 | } 8 | -------------------------------------------------------------------------------- /src/client/ui/icons/HiddenIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import type { JSX } from "react"; 3 | import styles from "./icons.module.css"; 4 | 5 | export function HiddenIcon(props: JSX.IntrinsicElements["span"]) { 6 | return ( 7 | 11 | 19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/client/ui/icons/VisibleIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import type { JSX } from "react"; 3 | import styles from "./icons.module.css"; 4 | 5 | export function VisibleIcon(props: JSX.IntrinsicElements["span"]) { 6 | return ( 7 | 11 | 19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/client/ui/icons/icons.module.css: -------------------------------------------------------------------------------- 1 | .icon { 2 | width: 24px; 3 | height: 24px; 4 | display: inline-flex; 5 | color: inherit; 6 | } 7 | -------------------------------------------------------------------------------- /src/client/ui/index.ts: -------------------------------------------------------------------------------- 1 | export { Skeleton } from "./Skeleton/Skeleton"; 2 | export { Button } from "./Button/Button"; 3 | export { ButtonGroup } from "./ButtonGroup/ButtonGroup"; 4 | export { Input } from "./Input/Input"; 5 | export { InputControlled } from "./Input/Input"; 6 | export { Textarea } from "./Input/Textarea"; 7 | export { TextareaControlled } from "./Input/Textarea"; 8 | export { Radio } from "./Radio/Radio"; 9 | export { RadioGroup } from "./Radio/RadioGroup"; 10 | export { RadioGroupControlled } from "./Radio/RadioGroup"; 11 | export { default as Link } from "./Link/Link"; 12 | export { Spinner } from "./Spinner/Spinner"; 13 | export { Card, CardBody } from "./Card/Card"; 14 | export { default as Breadcrumb } from "./Breadcrumb/Breadcrumb"; 15 | -------------------------------------------------------------------------------- /src/client/utils/cloudflareLoader.ts: -------------------------------------------------------------------------------- 1 | // https://developers.cloudflare.com/images/image-resizing/integration-with-frameworks/#nextjs 2 | 3 | const normalizeSrc = (src: string): string => { 4 | return src.startsWith("/") ? src.slice(1) : src; 5 | }; 6 | 7 | type CloudflareLoaderParams = { 8 | src: string; 9 | width: number; 10 | quality?: number; 11 | }; 12 | 13 | // eslint-disable-next-line @typescript-eslint/require-await 14 | export default async function cloudflareLoader({ 15 | src, 16 | width, 17 | quality, 18 | }: CloudflareLoaderParams): Promise { 19 | const params: string[] = [`width=${width}`]; 20 | if (quality) { 21 | params.push(`quality=${quality}`); 22 | } 23 | const paramsString = params.join(","); 24 | return `/cdn-cgi/image/${paramsString}/${normalizeSrc(src)}`; 25 | } 26 | -------------------------------------------------------------------------------- /src/client/utils/date.ts: -------------------------------------------------------------------------------- 1 | import { formatRelative } from "date-fns/formatRelative"; 2 | import { enUS } from "date-fns/locale/en-US"; 3 | 4 | // https://date-fns.org/docs/I18n-Contribution-Guide#formatrelative 5 | // https://github.com/date-fns/date-fns/blob/master/src/locale/en-US/_lib/formatRelative/index.js 6 | // https://github.com/date-fns/date-fns/issues/1218 7 | // https://stackoverflow.com/questions/47244216/how-to-customize-date-fnss-formatrelative 8 | interface FormatRelativeLocale { 9 | lastWeek: string; 10 | yesterday: string; 11 | today: string; 12 | tomorrow: string; 13 | nextWeek: string; 14 | other: string; 15 | [key: string]: string; 16 | } 17 | const formatRelativeLocale: FormatRelativeLocale = { 18 | lastWeek: "'Last' eeee", 19 | yesterday: "'Yesterday'", 20 | today: "'Today'", 21 | tomorrow: "'Tomorrow'", 22 | nextWeek: "'Next' eeee", 23 | other: "MM/dd/yyyy", 24 | }; 25 | 26 | const locale = { 27 | ...enUS, 28 | formatRelative: (token: string) => { 29 | // Return the corresponding string or a default string if the token is not found 30 | return formatRelativeLocale[token] || formatRelativeLocale.other; 31 | }, 32 | }; 33 | 34 | // Remove the time from the default 35 | // https://github.com/date-fns/date-fns/issues/1218#issuecomment-599182307 36 | export const formatRelativeDate = (tomorrow: Date) => { 37 | return formatRelative(tomorrow, new Date(), { locale }); 38 | }; 39 | -------------------------------------------------------------------------------- /src/client/utils/form.ts: -------------------------------------------------------------------------------- 1 | // Currently unused but here for reference to do more powerful validation in future 2 | // https://www.carlrippon.com/react-hook-form-server-validation/ 3 | export function addServerErrors ( 4 | errors: { [P in keyof T]?: string[] }, 5 | setError: ( 6 | fieldName: keyof T, 7 | error: { type: string; message: string }, 8 | ) => void, 9 | ) { 10 | return Object.keys(errors).forEach((key) => { 11 | setError(key as keyof T, { 12 | type: "server", 13 | message: errors[key as keyof T]?.join(". ") || "", // Fallback to an empty string 14 | }); 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /src/client/utils/matchesAnyItem.ts: -------------------------------------------------------------------------------- 1 | export default function matchesAnyItem( 2 | protectedMatcher: string[], 3 | input: string, 4 | ): boolean { 5 | return protectedMatcher.some((item) => { 6 | try { 7 | const regex = new RegExp(item); 8 | return regex.test(input); 9 | } catch (error) { 10 | return input === item; // Invalid regular expression, doesn't match input 11 | } 12 | }); 13 | } 14 | -------------------------------------------------------------------------------- /src/client/utils/storybookTitles.ts: -------------------------------------------------------------------------------- 1 | /* 2 | getRouteById('About').storybook 3 | */ 4 | -------------------------------------------------------------------------------- /src/e2e/admin.play.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "@playwright/test"; 2 | 3 | import { type PageTestConfig, testPageNavigation } from "./util"; 4 | 5 | test("non-admin should get redirected", async ({ page }) => { 6 | // Start from the index page (the baseURL is set via the webServer in the playwright.config.ts) 7 | await page.goto("/admin"); 8 | await expect(page).toHaveURL("/"); 9 | }); 10 | 11 | test.describe("Admin", () => { 12 | test.use({ storageState: "playwright/.auth/admin.json" }); 13 | 14 | // Start from the index page (the baseURL is set via the webServer in the playwright.config.ts) 15 | test.beforeEach(async ({ page }) => { 16 | await page.goto("/admin"); 17 | }); 18 | 19 | test("admin should not get redirected", async ({ page }) => { 20 | await expect(page).toHaveURL("/admin"); 21 | await page.waitForSelector("text=ToDo Co.", { state: "visible" }); 22 | }); 23 | 24 | test("list pages", async ({ page }) => { 25 | const pageConfigs: PageTestConfig[] = [ 26 | { 27 | textToClick: "Users", 28 | expectedURL: "/admin/user", 29 | expectedText: "Avatar", 30 | }, 31 | /* clone-code ENTITY_HOOK 32 | { 33 | "toPlacement": "below", 34 | "replacements": [ 35 | { "find": "Tasks", "replace": "<%= h.inflection.pluralize(h.changeCase.pascalCase(name)) %>" }, 36 | { "find": "task", "replace": "<%= h.changeCase.camelCase(name) %>" } 37 | ] 38 | } 39 | */ 40 | { 41 | textToClick: "Tasks", 42 | expectedURL: "/admin/task", 43 | expectedText: "Title", 44 | }, 45 | /* clone-code ENTITY_HOOK end */ 46 | ]; 47 | 48 | // Wait for the loading spinner or "Loading" text to disappear 49 | await page.waitForSelector("text=ToDo Co.", { state: "visible" }); 50 | 51 | for (const config of pageConfigs) { 52 | await page.goto("/admin"); 53 | await testPageNavigation("section", page, config); 54 | } 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /src/e2e/app.play.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "@playwright/test"; 2 | 3 | test.describe("App", () => { 4 | const uniqueTaskTitle = `Task ${Date.now()}`; 5 | 6 | // Start from the index page (the baseURL is set via the webServer in the playwright.config.ts) 7 | test.beforeEach(async ({ page }) => { 8 | await page.goto("/app"); 9 | }); 10 | 11 | test("happy path", async ({ page }) => { 12 | // list should have text 13 | await expect(page.getByRole("heading", { level: 1 })).toContainText( 14 | "Task Manager", 15 | ); 16 | 17 | // create new 18 | await page.getByRole("button", { name: "New Task" }).click(); 19 | await expect(page).toHaveURL("/app/task/new"); 20 | 21 | await page.fill('input[name="title"]', uniqueTaskTitle); 22 | await page.getByRole("button", { name: "Create" }).click(); 23 | 24 | await expect(page).toHaveURL("/app"); 25 | await expect(page.getByText(uniqueTaskTitle)).toBeVisible(); 26 | 27 | // update 28 | await page.getByRole("link", { name: uniqueTaskTitle }).click(); 29 | 30 | const description = `${uniqueTaskTitle}_description`; 31 | 32 | await page.fill('textarea[name="description"]', description); 33 | await page.getByRole("button", { name: "Save" }).click(); 34 | 35 | await expect(page).toHaveURL("/app"); 36 | await expect(page.getByText(description)).toBeVisible(); 37 | 38 | // delete 39 | await page.getByRole("link", { name: uniqueTaskTitle }).click(); 40 | 41 | await page.getByRole("button", { name: "Delete" }).click(); 42 | 43 | await expect(page).toHaveURL("/app"); 44 | await expect(page.getByText(uniqueTaskTitle)).not.toBeVisible(); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /src/e2e/gpu.play.ts: -------------------------------------------------------------------------------- 1 | /* 2 | import { test, expect } from '@playwright/test'; 3 | 4 | // force GPU hardware acceleration (even in headless mode) 5 | // https://dev.to/perrocontodo/run-playwright-tests-with-hardware-acceleration-on-a-gpu-enabled-ec2-instance-with-docker-support-4j2 6 | 7 | // Confirm it is working (when necessary) 8 | test('GPU hardware acceleration', async ({ page }) => { 9 | await page.goto('chrome://gpu') 10 | const featureStatusList = page.locator('.feature-status-list') 11 | await expect(featureStatusList).toContainText('Hardware accelerated') 12 | }) 13 | 14 | // To see settings, this test will save a file called gpu.png. 15 | test('It should take a snapshot of the GPU Chrome page', async ({ page }) => { 16 | await page.goto('chrome://gpu', { waitUntil: 'domcontentloaded' }); 17 | await page.screenshot({ path: 'gpu.png' }); 18 | await expect(page.locator('text=Graphics Feature Status').first()).toBeVisible(); 19 | }); 20 | */ 21 | -------------------------------------------------------------------------------- /src/e2e/marketing.play.ts: -------------------------------------------------------------------------------- 1 | import { test } from "@playwright/test"; 2 | 3 | import { type PageTestConfig, testPageNavigation } from "./util"; 4 | 5 | const pageConfigs: PageTestConfig[] = [ 6 | { 7 | textToClick: "FAQ", 8 | expectedURL: "/faq", 9 | expectedText: "Frequently asked questions", 10 | }, 11 | { 12 | textToClick: "Pricing", 13 | expectedURL: "/pricing", 14 | expectedText: "Pricing", 15 | }, 16 | { 17 | textToClick: "About", 18 | expectedURL: "/about", 19 | expectedText: "About", 20 | }, 21 | { 22 | textToClick: "Blog", 23 | expectedURL: "/blog", 24 | expectedText: "Blog", 25 | }, 26 | { 27 | textToClick: "Contact Us", 28 | expectedURL: "/contact", 29 | expectedText: "Contact", 30 | }, 31 | { 32 | textToClick: "Terms and Conditions", 33 | expectedURL: "/terms", 34 | expectedText: "Terms of Service", 35 | }, 36 | { 37 | textToClick: "Privacy Policy", 38 | expectedURL: "/privacy", 39 | expectedText: "Privacy Policy", 40 | }, 41 | ]; 42 | 43 | test.describe("Marketing", () => { 44 | test("should navigate to the marketing pages", async ({ page }) => { 45 | await page.goto("/"); 46 | 47 | for (const config of pageConfigs) { 48 | await testPageNavigation("main", page, config); 49 | } 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/e2e/util.ts: -------------------------------------------------------------------------------- 1 | import { type Page, expect } from "@playwright/test"; 2 | 3 | export type PageTestConfig = { 4 | textToClick: string; 5 | expectedURL: string; 6 | expectedText: string; 7 | }; 8 | 9 | export async function testPageNavigation( 10 | locator: string, 11 | page: Page, 12 | config: PageTestConfig, 13 | ) { 14 | // Directly target links within the footer that contain the text 15 | const footerLinkLocator = page.getByRole("link", { 16 | name: config.textToClick, 17 | }); 18 | if ((await footerLinkLocator.count()) === 0) { 19 | throw new Error(`Link with text "${config.textToClick}" not found`); 20 | } 21 | await footerLinkLocator.click(); 22 | 23 | await expect(page).toHaveURL(config.expectedURL); 24 | await expect(page.locator(locator)).toContainText( 25 | new RegExp(config.expectedText), 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /src/instrumentation.ts: -------------------------------------------------------------------------------- 1 | import * as Sentry from "@sentry/nextjs"; 2 | 3 | export async function register() { 4 | if (process.env.NEXT_RUNTIME === "nodejs") { 5 | await import("../sentry.server.config"); 6 | } 7 | 8 | if (process.env.NEXT_RUNTIME === "edge") { 9 | await import("../sentry.edge.config"); 10 | } 11 | } 12 | 13 | export const onRequestError = Sentry.captureRequestError; 14 | -------------------------------------------------------------------------------- /src/lib/firebase/auth/server-auth-provider.tsx: -------------------------------------------------------------------------------- 1 | import { cacheExchange } from "@/client/gql/cacheExchange"; 2 | import { baseURL } from "@/metadata.config"; 3 | import { getUser } from "@enalmada/next-firebase-auth-edge-wrapper"; 4 | import { AuthProvider } from "@enalmada/next-firebase-auth-edge-wrapper/client/AuthProvider"; 5 | import { UrqlWrapper as NextGqlProvider } from "@enalmada/next-gql/client/urql/UrqlWrapper"; 6 | import { cookies, headers } from "next/headers"; 7 | import type React from "react"; 8 | 9 | // I would prefer AuthProvider and UrqlWrapper separate but I would need to create 10 | // a ServerUrqlWrapper that immediately fetches the same data (tokens and cookies). 11 | // feels like a waste so combining for now. 12 | export async function ServerAuthProvider({ 13 | nonce, 14 | children, 15 | }: { 16 | nonce?: string; 17 | children: React.ReactNode; 18 | }) { 19 | const cookieStore = await cookies(); 20 | const url = `${baseURL}/api/graphql`; 21 | 22 | const user = await getUser(cookieStore, await headers()); 23 | 24 | return ( 25 | 26 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/lib/localization/navigation.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type Pathnames, 3 | createLocalizedPathnamesNavigation, 4 | } from "next-intl/navigation"; 5 | 6 | export const locales = ["en", "es", "ru"] as const; 7 | export type Locale = (typeof locales)[number]; 8 | 9 | // The `pathnames` object holds pairs of internal 10 | // and external paths, separated by locale. 11 | export const pathnames = { 12 | // If all locales use the same pathname, a 13 | // single external path can be provided. 14 | "/": "/", 15 | "/blog": "/blog", 16 | "/contact": "/contact", 17 | "/faq": "/faq", 18 | "/privacy": "/privacy", 19 | "/terms": "/terms", 20 | "/pricing": "/pricing", 21 | 22 | "/login": "/login", 23 | "/logout": "/logout", 24 | "/register": "/register", 25 | "/maintenance-mode": "/maintenance-mode", 26 | "/reset-password": "/reset-password", 27 | "/app": "/app", 28 | "/app/task/[id]": "/app/task/[id]", 29 | "/app/task/new": "/app/task/new", 30 | "/app/profile": "/app/profile", 31 | "/app/error": "/app/error", 32 | 33 | // If locales use different paths, you can 34 | // specify each external path per locale. 35 | "/about": { 36 | en: "/about", 37 | es: "/acerca-de", 38 | ru: "/o-nas", 39 | }, 40 | 41 | // Admin 42 | "/admin": "/admin", 43 | "/admin/user": "/admin/user", 44 | "/admin/task": "/admin/task", 45 | "/admin/task/[id]": "/admin/task/[id]", 46 | } satisfies Pathnames33 | {children} 34 | 35 |; 47 | 48 | const navigation = createLocalizedPathnamesNavigation({ 49 | locales, 50 | pathnames, 51 | }); 52 | 53 | export type LinkType = typeof navigation.Link; 54 | 55 | // If you want to use it 56 | export const { Link, redirect, usePathname, useRouter } = navigation; 57 | -------------------------------------------------------------------------------- /src/lib/localization/request.ts: -------------------------------------------------------------------------------- 1 | import { getRequestConfig } from "next-intl/server"; 2 | import { routing } from "./routing"; 3 | 4 | export const timeZone = "America/Los_Angeles"; 5 | 6 | export default getRequestConfig(async ({ requestLocale }) => { 7 | // This typically corresponds to the `[locale]` segment 8 | let locale = await requestLocale; 9 | 10 | // Ensure that a valid locale is used 11 | if (!locale || !routing.locales.includes(locale as any)) { 12 | locale = routing.defaultLocale; 13 | } 14 | 15 | return { 16 | locale, 17 | messages: (await import(`../../../messages/${locale}.json`)).default, 18 | // The time zone can either be statically defined, read from the 19 | // user profile if you store such a setting, or based on dynamic 20 | // request information like the locale or headers. 21 | // https://next-intl-docs.vercel.app/docs/configuration#time-zone 22 | timeZone: "America/Los_Angeles", 23 | // This is the default, a single date instance will be used 24 | // by all Server Components to ensure consistency 25 | now: new Date(), 26 | }; 27 | }); 28 | -------------------------------------------------------------------------------- /src/lib/localization/routing.ts: -------------------------------------------------------------------------------- 1 | import { createNavigation } from "next-intl/navigation"; 2 | import { defineRouting } from "next-intl/routing"; 3 | 4 | export const routing = defineRouting({ 5 | // A list of all locales that are supported 6 | locales: ["en", "es", "ru"], 7 | 8 | // Used when no locale matches 9 | defaultLocale: "en", 10 | }); 11 | 12 | // Lightweight wrappers around Next.js' navigation APIs 13 | // that will consider the routing configuration 14 | export const { Link, redirect, usePathname, useRouter } = 15 | createNavigation(routing); 16 | -------------------------------------------------------------------------------- /src/lib/logging/log-level.ts: -------------------------------------------------------------------------------- 1 | // debug, info, warn, error 2 | // https://github.com/axiomhq/next-axiom#log-levels 3 | const logLevelData = { 4 | "*": "info", 5 | // TaskService: "info", 6 | }; 7 | 8 | export default logLevelData; 9 | -------------------------------------------------------------------------------- /src/lib/sst/paramsAndSecrets.ts: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | // Uncomment to deploy to SST 4 | 5 | import { Config, type Stack } from 'sst/constructs'; 6 | 7 | export function getParamsAndSecrets(stack: Stack) { 8 | const paramConfig = { 9 | APP_ENV: 'local', 10 | LOG_LEVEL: 'info', 11 | EDGE: 'false', 12 | ORIGIN: (() => { 13 | switch (stack.stage) { 14 | case 'production': 15 | return ''; 16 | case 'staging': 17 | return ''; 18 | case 'dev': 19 | return 'https://d9zd9miv2djg4.cloudfront.net'; 20 | default: 21 | return 'http://localhost:3000'; 22 | } 23 | })(), 24 | USE_SECURE_COOKIES: 'true', 25 | }; 26 | 27 | const publicConfig = [ 28 | 'NEXT_PUBLIC_APP_ENV', 29 | 'NEXT_PUBLIC_ORIGIN', 30 | 'NEXT_PUBLIC_FIREBASE_PROJECT_ID', 31 | 'NEXT_PUBLIC_FIREBASE_API_KEY', 32 | 'NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN', 33 | 'NEXT_PUBLIC_FIREBASE_DATABASE_URL', 34 | 'NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID', 35 | 'NEXT_PUBLIC_AXIOM_TOKEN', 36 | 'NEXT_PUBLIC_AXIOM_DATASET', 37 | ]; 38 | 39 | const secretConfig = [ 40 | 'DATABASE_URL', 41 | 'COOKIE_SECRET_CURRENT', 42 | 'COOKIE_SECRET_PREVIOUS', 43 | 'FIREBASE_API_KEY', 44 | 'FIREBASE_ADMIN_CLIENT_EMAIL', 45 | 'FIREBASE_ADMIN_PRIVATE_KEY', 46 | 'AXIOM_TOKEN', 47 | 'AXIOM_DATASET', 48 | 'FIREBASE_PROJECT_ID', 49 | 'FIREBASE_AUTH_DOMAIN', 50 | 'FIREBASE_DATABASE_URL', 51 | 'FIREBASE_MESSAGING_SENDER_ID', 52 | ]; 53 | 54 | const parameters = Object.entries(paramConfig).map(([key, value]) => { 55 | return new Config.Parameter(stack, key, { 56 | value: process.env?.[key] || value, 57 | }); 58 | }); 59 | 60 | const publicParameters = publicConfig.map((key) => { 61 | return new Config.Parameter(stack, key, { 62 | value: '$' + key, 63 | }); 64 | }); 65 | 66 | const secrets = secretConfig.map((key) => { 67 | return new Config.Secret(stack, key); 68 | }); 69 | 70 | return [...parameters, ...publicParameters, ...secrets]; 71 | } 72 | */ 73 | -------------------------------------------------------------------------------- /src/lib/sst/sst-env.d.ts: -------------------------------------------------------------------------------- 1 | // Uncomment and move to root to deploy to SST 2 | 3 | ///// 4 | -------------------------------------------------------------------------------- /src/lib/storybook/assets/code-brackets.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/lib/storybook/assets/comments.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/lib/storybook/assets/direction.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/lib/storybook/assets/flow.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/lib/storybook/assets/plugin.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/lib/storybook/assets/repo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/lib/types/next-intl.d.ts: -------------------------------------------------------------------------------- 1 | import type * as messages from "../../../messages/en.json"; 2 | 3 | /* eslint-disable @typescript-eslint/consistent-type-imports */ 4 | type Messages = typeof messages; 5 | declare interface IntlMessages extends Messages {} 6 | -------------------------------------------------------------------------------- /src/lib/types/reset.d.ts: -------------------------------------------------------------------------------- 1 | // https://github.com/total-typescript/ts-reset 2 | // Do not add any other lines of code to this file! 3 | import "@total-typescript/ts-reset"; 4 | -------------------------------------------------------------------------------- /src/server/admin/admin.model.ts: -------------------------------------------------------------------------------- 1 | import AdminService, { 2 | type NotificationResponse, 3 | type UploadResponse, 4 | } from "@/server/admin/admin.service"; 5 | import { builder } from "@/server/graphql/builder"; 6 | 7 | // File Upload Example 8 | // https://github.com/dotansimha/graphql-yoga/blob/main/examples/file-upload-nextjs-pothos/schema.ts 9 | const UploadResponseObject = 10 | builder.objectRef ("UploadResponse"); 11 | 12 | UploadResponseObject.implement({ 13 | fields: (t) => ({ 14 | filename: t.exposeString("filename", { nullable: false }), 15 | }), 16 | }); 17 | 18 | builder.mutationField("uploadFile", (t) => 19 | t.field({ 20 | type: UploadResponseObject, 21 | nullable: false, 22 | args: { 23 | file: t.arg({ 24 | type: "File", 25 | required: true, 26 | }), 27 | }, 28 | resolve: async (root, { file }, ctx) => { 29 | return new AdminService().uploadFile({ file }, ctx); 30 | }, 31 | }), 32 | ); 33 | 34 | // Notifications 35 | 36 | const NotificationResponseObject = builder.objectRef ( 37 | "NotificationResponse", 38 | ); 39 | 40 | NotificationResponseObject.implement({ 41 | fields: (t) => ({ 42 | published: t.exposeBoolean("published", { nullable: false }), 43 | }), 44 | }); 45 | 46 | builder.mutationField("publishNotification", (t) => 47 | t.fieldWithInput({ 48 | type: NotificationResponseObject, 49 | nullable: true, 50 | input: { 51 | message: t.input.string({ 52 | required: false, 53 | }), 54 | }, 55 | resolve: (root, args, ctx) => { 56 | const finalMessage = args.input.message || "hello"; 57 | return new AdminService().publishNotification( 58 | { message: finalMessage }, 59 | ctx, 60 | ); 61 | }, 62 | }), 63 | ); 64 | -------------------------------------------------------------------------------- /src/server/admin/admin.service.ts: -------------------------------------------------------------------------------- 1 | import Logger from "@/lib/logging/log-util"; 2 | import type { MyContextType } from "@/server/graphql/server"; 3 | import { 4 | NotificationEventType, 5 | publishNotificationEvent, 6 | } from "@/server/graphql/subscriptions/notification"; 7 | import { accessCheck } from "@/server/utils/accessCheck"; 8 | import { nanoid } from "nanoid"; 9 | 10 | export interface UploadResponse { 11 | filename: string; 12 | } 13 | 14 | export interface Upload { 15 | file: File; 16 | } 17 | 18 | export interface NotificationInput { 19 | message: string; 20 | } 21 | 22 | export interface NotificationResponse { 23 | published: boolean; 24 | } 25 | 26 | export default class AdminService { 27 | private readonly logger = new Logger(AdminService.name); 28 | 29 | async uploadFile(input: Upload, ctx: MyContextType): Promise { 30 | const logger = this.logger.logMethodStart(this.uploadFile.name, ctx, { 31 | ...input, 32 | }); 33 | 34 | accessCheck(logger, ctx.currentUser, "manage", "all", input); 35 | 36 | const text = await input.file.text(); 37 | logger.debug(`file content:${text}`); 38 | 39 | return { filename: input.file.name }; 40 | } 41 | 42 | publishNotification( 43 | input: NotificationInput, 44 | ctx: MyContextType, 45 | ): NotificationResponse { 46 | const logger = this.logger.logMethodStart( 47 | this.publishNotification.name, 48 | ctx, 49 | { ...input }, 50 | ); 51 | 52 | accessCheck(logger, ctx.currentUser, "manage", "all", input); 53 | 54 | const event = { 55 | id: `not_${nanoid()}`, 56 | type: NotificationEventType.SystemNotification, 57 | message: input.message, 58 | }; 59 | 60 | publishNotificationEvent(event, ctx); 61 | 62 | return { published: true }; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/server/base/base.model.ts: -------------------------------------------------------------------------------- 1 | import type { BaseEntity } from "@/server/base/base.service"; 2 | import { builder } from "@/server/graphql/builder"; 3 | import { UserType } from "@/server/user/user.model"; 4 | import UserService from "@/server/user/user.service"; 5 | 6 | export const BaseEntityType = builder.interfaceRef ("BaseEntity"); 7 | 8 | builder.interfaceType(BaseEntityType, { 9 | name: "BaseEntity", 10 | fields: (t) => ({ 11 | id: t.exposeID("id", { nullable: false }), 12 | createdAt: t.expose("createdAt", { 13 | type: "DateTime", 14 | nullable: false, 15 | }), 16 | createdBy: t.field({ 17 | type: UserType, 18 | nullable: true, 19 | resolve: (root, args, ctx) => { 20 | return new UserService().get(root.createdById as string, ctx); 21 | }, 22 | }), 23 | updatedAt: t.expose("updatedAt", { 24 | type: "DateTime", 25 | nullable: true, 26 | }), 27 | updatedBy: t.field({ 28 | type: UserType, 29 | nullable: true, 30 | resolve: (root, args, ctx) => { 31 | return new UserService().get(root.updatedById as string, ctx); 32 | }, 33 | }), 34 | version: t.exposeInt("version", { nullable: false }), 35 | }), 36 | }); 37 | -------------------------------------------------------------------------------- /src/server/base/base.service.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | 3 | describe("base.service", () => { 4 | it("TBD", () => { 5 | expect(true).toBe(true); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /src/server/db/__tests__/schema.test.ts: -------------------------------------------------------------------------------- 1 | import { nanoString } from "../schema"; // Import the actual function you're testing 2 | 3 | test("nanoString generates string that starts with prefix", () => { 4 | const prefix = "test"; 5 | const result = nanoString(prefix); 6 | 7 | if (!result.startsWith(prefix)) { 8 | throw new Error( 9 | `Expected result to start with ${prefix}, but got ${result}`, 10 | ); 11 | } 12 | }); 13 | 14 | test("nanoString generates string with length >= 21", () => { 15 | const prefix = "test"; 16 | const result = nanoString(prefix); 17 | 18 | if (result.length < 21) { 19 | throw new Error( 20 | `Expected result length to be >= 21, but got length ${result.length}`, 21 | ); 22 | } 23 | }); 24 | -------------------------------------------------------------------------------- /src/server/db/index.ts: -------------------------------------------------------------------------------- 1 | import { connectToDatabase } from "@enalmada/drizzle-helpers"; 2 | import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; 3 | 4 | import * as schema from "./schema"; 5 | 6 | export const db: PostgresJsDatabase = connectToDatabase< 7 | typeof schema 8 | >({ 9 | nodeEnv: process.env.NODE_ENV || "development", 10 | databaseUrl: process.env.DATABASE_URL!, 11 | schema: schema, 12 | }); 13 | -------------------------------------------------------------------------------- /src/server/db/migrations/0000_flippant_karnak.sql: -------------------------------------------------------------------------------- 1 | CREATE TYPE IF NOT EXISTS "status" AS ENUM ('ACTIVE', 'COMPLETED'); 2 | 3 | --> statement-breakpoint 4 | CREATE TABLE IF NOT EXISTS "task" ( 5 | "id" varchar PRIMARY KEY NOT NULL, 6 | "name" varchar(256) NOT NULL, 7 | "description" varchar(1024), 8 | "status" "status", 9 | "due_date" timestamp, 10 | "version" integer DEFAULT 1 NOT NULL, 11 | "created_at" timestamp DEFAULT now() NOT NULL, 12 | "updated_at" timestamp DEFAULT now() NOT NULL, 13 | "user_id" varchar 14 | ); 15 | 16 | --> statement-breakpoint 17 | CREATE TABLE IF NOT EXISTS "user" ( 18 | "id" varchar PRIMARY KEY NOT NULL, 19 | "firebase_id" varchar, 20 | "name" varchar(255), 21 | "email" varchar(320), 22 | "image" varchar(2083), 23 | "version" integer DEFAULT 1 NOT NULL, 24 | "created_at" timestamp DEFAULT now() NOT NULL, 25 | "updated_at" timestamp DEFAULT now() NOT NULL, 26 | CONSTRAINT "user_firebase_id_unique" UNIQUE("firebase_id") 27 | ); 28 | 29 | --> statement-breakpoint 30 | ALTER TABLE "task" ADD CONSTRAINT IF NOT EXISTS "task_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION; 31 | 32 | -------------------------------------------------------------------------------- /src/server/db/migrations/0001_friendly_phalanx.sql: -------------------------------------------------------------------------------- 1 | CREATE TYPE IF NOT EXISTS "role" AS ENUM('MEMBER', 'ADMIN'); 2 | 3 | --> statement-breakpoint 4 | ALTER TABLE "task" ALTER COLUMN "user_id" SET NOT NULL; 5 | 6 | --> statement-breakpoint 7 | ALTER TABLE "user" ADD COLUMN "role" "role" DEFAULT 'MEMBER'; -------------------------------------------------------------------------------- /src/server/db/migrations/0002_oval_jetstream.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "user" ALTER COLUMN "role" SET NOT NULL; -------------------------------------------------------------------------------- /src/server/db/migrations/0003_flimsy_spiral.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "task" ADD COLUMN "created_by" varchar;--> statement-breakpoint 2 | ALTER TABLE "task" ADD COLUMN "updated_by" varchar;--> statement-breakpoint 3 | ALTER TABLE "user" ADD COLUMN "created_by" varchar;--> statement-breakpoint 4 | ALTER TABLE "user" ADD COLUMN "updated_by" varchar; -------------------------------------------------------------------------------- /src/server/db/migrations/0004_loose_mikhail_rasputin.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "task" RENAME COLUMN "created_by" TO "created_by_id";--> statement-breakpoint 2 | ALTER TABLE "task" RENAME COLUMN "updated_by" TO "updated_by_id";--> statement-breakpoint 3 | ALTER TABLE "user" RENAME COLUMN "created_by" TO "created_by_id";--> statement-breakpoint 4 | ALTER TABLE "user" RENAME COLUMN "updated_by" TO "updated_by_id";--> statement-breakpoint 5 | ALTER TABLE "task" ALTER COLUMN "updated_at" DROP DEFAULT;--> statement-breakpoint 6 | ALTER TABLE "task" ALTER COLUMN "updated_at" DROP NOT NULL;--> statement-breakpoint 7 | ALTER TABLE "user" ALTER COLUMN "updated_at" DROP DEFAULT;--> statement-breakpoint 8 | ALTER TABLE "user" ALTER COLUMN "updated_at" DROP NOT NULL; -------------------------------------------------------------------------------- /src/server/db/migrations/0005_silky_the_phantom.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "task" DROP CONSTRAINT "task_user_id_user_id_fk"; 2 | --> statement-breakpoint 3 | ALTER TABLE "task" ADD CONSTRAINT "task_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; 4 | -------------------------------------------------------------------------------- /src/server/db/migrations/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5", 3 | "dialect": "pg", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "5", 8 | "when": 1692733367145, 9 | "tag": "0000_flippant_karnak", 10 | "breakpoints": true 11 | }, 12 | { 13 | "idx": 1, 14 | "version": "5", 15 | "when": 1693520919906, 16 | "tag": "0001_friendly_phalanx", 17 | "breakpoints": true 18 | }, 19 | { 20 | "idx": 2, 21 | "version": "5", 22 | "when": 1693615861585, 23 | "tag": "0002_oval_jetstream", 24 | "breakpoints": true 25 | }, 26 | { 27 | "idx": 3, 28 | "version": "5", 29 | "when": 1703283601236, 30 | "tag": "0003_flimsy_spiral", 31 | "breakpoints": true 32 | }, 33 | { 34 | "idx": 4, 35 | "version": "5", 36 | "when": 1703290456584, 37 | "tag": "0004_loose_mikhail_rasputin", 38 | "breakpoints": true 39 | }, 40 | { 41 | "idx": 5, 42 | "version": "5", 43 | "when": 1705012866512, 44 | "tag": "0005_silky_the_phantom", 45 | "breakpoints": true 46 | } 47 | ] 48 | } 49 | -------------------------------------------------------------------------------- /src/server/graphql/builder.ts: -------------------------------------------------------------------------------- 1 | import type { MyContextType } from "@/server/graphql/server"; 2 | import { 3 | type DefaultScalars, 4 | initializeBuilder, 5 | } from "@enalmada/next-gql/server"; 6 | import SchemaBuilder, { type InputFieldBuilder } from "@pothos/core"; 7 | import WithInputPlugin from "@pothos/plugin-with-input"; 8 | 9 | // If you would like your ID to be something other than string 10 | // Note that string is recommended though 11 | /* 12 | export interface ExtendedScalars extends DefaultScalars { 13 | Scalars: DefaultScalars['Scalars'] & { 14 | ID: { 15 | Input: string; 16 | Output: string; 17 | }; 18 | }; 19 | } 20 | */ 21 | 22 | // used for DRY inputs 23 | export type InputFieldBuilderType = InputFieldBuilder< 24 | PothosSchemaTypes.ExtendDefaultTypes , 25 | "InputObject" 26 | >; 27 | 28 | type DefaultUserSchemaTypes = DefaultScalars & { Context: MyContextType }; 29 | 30 | // TODO move SchemaBuilder and options to next-gql. They have sideEffects that require them in application 31 | // but perhaps adding sideEffects to their package.json could help. 32 | export const builder = new SchemaBuilder ({ 33 | plugins: [WithInputPlugin], 34 | }); 35 | 36 | initializeBuilder(builder); 37 | -------------------------------------------------------------------------------- /src/server/graphql/errors.ts: -------------------------------------------------------------------------------- 1 | // https://escape.tech/blog/graphql-errors-the-good-the-bad-and-the-ugly/ 2 | // https://the-guild.dev/graphql/yoga-server/tutorial/basic/09-error-handling#recap-of-the-encountered-error 3 | // https://blog.logrocket.com/handling-graphql-errors-like-a-champ-with-unions-and-interfaces/ 4 | import type Logger from "@/lib/logging/log-util"; 5 | import { GraphQLError } from "graphql"; 6 | 7 | export class NotAuthorizedError extends GraphQLError { 8 | constructor(message: string, logger: Logger) { 9 | super(message, { 10 | extensions: { 11 | code: "NOT_AUTHORIZED", 12 | http: { status: 401 }, 13 | }, 14 | }); 15 | 16 | logger.debug(`NotAuthorizedError: ${message}`); 17 | } 18 | } 19 | 20 | export class NotFoundError extends GraphQLError { 21 | constructor(message: string, logger: Logger) { 22 | super(message, { 23 | extensions: { 24 | code: "NOT_FOUND", 25 | http: { status: 404 }, 26 | }, 27 | }); 28 | 29 | logger.warn(`NotFoundError: ${message}`); 30 | } 31 | } 32 | 33 | export class OptimisticLockError extends GraphQLError { 34 | constructor(message: string, logger: Logger) { 35 | super(message, { 36 | extensions: { 37 | code: "OPTIMISTIC_LOCK", 38 | http: { status: 409 }, 39 | }, 40 | }); 41 | 42 | logger.debug(`OptimisticLockError: ${message}`); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/server/graphql/handleCreateOrGetUser.ts: -------------------------------------------------------------------------------- 1 | import type { User } from "@/server/db/schema"; 2 | import UserService from "@/server/user/user.service"; 3 | import { authConfig } from "@enalmada/next-firebase-auth-edge-wrapper"; 4 | import { getFirebaseAuth } from "next-firebase-auth-edge/lib/auth"; 5 | import { getTokens } from "next-firebase-auth-edge/lib/next/tokens"; 6 | import type { NextRequest } from "next/server"; 7 | 8 | export async function handleCreateOrGetUser( 9 | req: Request, 10 | ): Promise { 11 | let firebaseId: string | undefined = undefined; 12 | let email: string | undefined = undefined; 13 | 14 | // Cookie used during normal processing 15 | const tokenCookie = await getTokens((req as NextRequest).cookies, authConfig); 16 | if (tokenCookie?.decodedToken) { 17 | firebaseId = tokenCookie.decodedToken.uid; 18 | email = tokenCookie.decodedToken.email; 19 | } 20 | 21 | // headers used during SSR or layout transition 22 | if (!firebaseId) { 23 | const authorizationHeader = req.headers.get("authorization"); 24 | if (authorizationHeader) { 25 | const { verifyIdToken } = getFirebaseAuth( 26 | { 27 | ...authConfig.serviceAccount, 28 | }, 29 | authConfig.apiKey, 30 | ); 31 | 32 | const tokens = await verifyIdToken(authorizationHeader); 33 | 34 | if (tokens) { 35 | firebaseId = tokens.uid; 36 | email = tokens.email; 37 | } 38 | } 39 | } 40 | 41 | if (firebaseId) { 42 | return await new UserService().createOrGetFirebaseUser(firebaseId, email); 43 | } 44 | 45 | return null; 46 | } 47 | -------------------------------------------------------------------------------- /src/server/graphql/schema.ts: -------------------------------------------------------------------------------- 1 | import { builder } from "@/server/graphql/builder"; 2 | 3 | import "./sortAndPagination"; 4 | import "@/server/graphql/subscriptions/notification"; 5 | import "@/server/admin/admin.model"; 6 | import "@/server/user/user.model"; 7 | /* clone-code ENTITY_HOOK 8 | { 9 | "toPlacement": "below", 10 | "replacements": [ 11 | { "find": "task", "replace": "<%= h.changeCase.camelCase(name) %>" } 12 | ] 13 | } 14 | */ 15 | import "@/server/task/task.model"; 16 | 17 | /* clone-code ENTITY_HOOK end */ 18 | 19 | export const schema = builder.toSchema({}); 20 | -------------------------------------------------------------------------------- /src/server/graphql/server.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable react-hooks/rules-of-hooks */ 2 | import { baseURL } from "@/metadata.config"; 3 | import type { User } from "@/server/db/schema"; 4 | import { handleCreateOrGetUser } from "@/server/graphql/handleCreateOrGetUser"; 5 | import { schema } from "@/server/graphql/schema"; 6 | import type { PubSubChannels } from "@/server/graphql/subscriptions/PubSubChannels"; 7 | import { type PubSub, makeServer } from "@enalmada/next-gql/server"; 8 | import { Logger } from "next-axiom"; 9 | 10 | // export interface MyContextType extends YogaContext {} 11 | 12 | export interface MyContextType { 13 | currentUser?: User; 14 | pubSub: PubSub ; 15 | } 16 | 17 | function logError(message: string) { 18 | const log = new Logger(); 19 | log.error(message); 20 | // TODO await seems to cause trouble for yoga 21 | // eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access 22 | log.flush().catch((e) => { 23 | console.error("Error while flushing log:", e); 24 | }); 25 | } 26 | 27 | export function graphqlServer(graphqlEndpoint: string) { 28 | return makeServer ({ 29 | schema, 30 | graphqlEndpoint, 31 | cors: { 32 | origin: baseURL, 33 | }, 34 | handleCreateOrGetUser, 35 | logError, 36 | }); 37 | } 38 | -------------------------------------------------------------------------------- /src/server/graphql/sortAndPagination.ts: -------------------------------------------------------------------------------- 1 | import { builder } from "@/server/graphql/builder"; 2 | import type { OrderBy, Paging } from "@enalmada/drizzle-helpers"; 3 | 4 | export enum SortOrder { 5 | ASC = "ASC", 6 | DESC = "DESC", 7 | } 8 | 9 | builder.enumType(SortOrder, { 10 | name: "SortOrder", 11 | }); 12 | 13 | export interface OrderInput { 14 | sortBy: string; 15 | sortOrder: SortOrder; 16 | } 17 | 18 | export const OrderInputType = builder.inputRef ("OrderInput"); 19 | 20 | OrderInputType.implement({ 21 | fields: (t) => ({ 22 | sortBy: t.string({ required: true }), 23 | sortOrder: t.field({ type: SortOrder, required: true }), 24 | }), 25 | }); 26 | 27 | export interface PaginationInput { 28 | page: number; 29 | pageSize: number; 30 | } 31 | 32 | export const PaginationInputType = 33 | builder.inputRef ("PaginationInput"); 34 | 35 | PaginationInputType.implement({ 36 | fields: (t) => ({ 37 | page: t.int({ required: true }), 38 | pageSize: t.int({ required: true }), 39 | }), 40 | }); 41 | 42 | export interface TableInput { 43 | order?: OrderInput | null; 44 | pagination?: PaginationInput | null; 45 | } 46 | 47 | export const DEFAULT_PAGE_SIZE = 1000; 48 | 49 | // Map to Drizzle types 50 | export function sortAndPagination(input?: TableInput) { 51 | const order: OrderBy = { 52 | sortBy: input?.order?.sortBy || "id", 53 | sortOrder: input?.order?.sortOrder === SortOrder.ASC ? "asc" : "desc", 54 | }; 55 | 56 | const paging: Paging = { 57 | page: input?.pagination?.page || 1, 58 | pageSize: input?.pagination?.pageSize || DEFAULT_PAGE_SIZE, 59 | }; 60 | 61 | return { order, paging }; 62 | } 63 | -------------------------------------------------------------------------------- /src/server/graphql/subscriptions/PubSubChannels.ts: -------------------------------------------------------------------------------- 1 | import type { NotificationEvent } from "@/server/graphql/subscriptions/notification"; 2 | 3 | export type PubSubChannels = { 4 | NOTIFICATION_EVENT: [NotificationEvent]; 5 | }; 6 | -------------------------------------------------------------------------------- /src/server/graphql/subscriptions/notification.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import { builder } from "@/server/graphql/builder"; 3 | import type { MyContextType } from "@/server/graphql/server"; 4 | 5 | export const NotificationEventLabel = "NOTIFICATION_EVENT"; 6 | 7 | export enum NotificationEventType { 8 | SystemNotification = "SystemNotification", 9 | } 10 | 11 | builder.enumType(NotificationEventType, { 12 | name: "NotificationEventType", 13 | }); 14 | 15 | export interface NotificationEvent { 16 | id: string; 17 | type: NotificationEventType; 18 | message: string; 19 | } 20 | 21 | export const NotificationEventRef = 22 | builder.objectRef ("NotificationEvent"); 23 | 24 | builder.objectType(NotificationEventRef, { 25 | name: "NotificationEvent", 26 | description: "When a notification is posted", 27 | fields: (t) => ({ 28 | id: t.exposeID("id", { nullable: false }), 29 | type: t.field({ 30 | type: NotificationEventType, 31 | nullable: false, 32 | description: "Notification type", 33 | resolve: (event) => { 34 | return event.type; 35 | }, 36 | }), 37 | message: t.exposeString("message", { 38 | description: "Notification message", 39 | nullable: false, 40 | }), 41 | }), 42 | }); 43 | builder.subscriptionField("notificationEvents", (t) => { 44 | return t.field({ 45 | type: NotificationEventRef, 46 | nullable: false, 47 | description: "Events related to notifications", 48 | args: {}, 49 | subscribe: (parent, args, ctx, info) => { 50 | // eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-return 51 | return ctx.pubSub.subscribe(NotificationEventLabel); 52 | }, 53 | resolve: (payload) => { 54 | return payload; 55 | }, 56 | }); 57 | }); 58 | 59 | export function publishNotificationEvent( 60 | event: NotificationEvent, 61 | ctx: MyContextType, 62 | ) { 63 | ctx.pubSub.publish(NotificationEventLabel, event); 64 | } 65 | -------------------------------------------------------------------------------- /src/server/task/task.service.ts: -------------------------------------------------------------------------------- 1 | /* clone-code ENTITY_HOOK 2 | { 3 | "toFile": "src/server/<%= h.changeCase.camelCase(name) %>/<%= h.changeCase.camelCase(name) %>.service.ts", 4 | "replacements": [ 5 | { "find": "Task", "replace": "<%= h.changeCase.pascalCase(name) %>" }, 6 | { "find": "task", "replace": "<%= h.changeCase.camelCase(name) %>" }, 7 | { "find": "TASK", "replace": "<%= h.changeCase.constantCase(name) %>" } 8 | ] 9 | } 10 | */ 11 | import BaseService from "@/server/base/base.service"; 12 | import { db } from "@/server/db"; 13 | import { 14 | type Task, 15 | type TaskInput, 16 | TaskTable, 17 | type User, 18 | } from "@/server/db/schema"; 19 | 20 | export interface TaskWithUser extends Task { 21 | user?: User; 22 | } 23 | 24 | export default class TaskService extends BaseService { 25 | constructor() { 26 | super("Task", TaskTable, db.query.TaskTable); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/server/utils/accessCheck.ts: -------------------------------------------------------------------------------- 1 | import type Logger from "@/lib/logging/log-util"; 2 | import type { User } from "@/server/db/schema"; 3 | import { NotAuthorizedError } from "@/server/graphql/errors"; 4 | import { 5 | type Action, 6 | type SubjectType, 7 | defineAbilitiesFor, 8 | } from "@/server/utils/caslAbility"; 9 | import { subject } from "@casl/ability"; 10 | 11 | export function accessCheck( 12 | logger: Logger, 13 | user: User | undefined, 14 | action: Action, 15 | subjectType: SubjectType, 16 | criteria: object = {}, 17 | field?: string, 18 | ) { 19 | const ability = defineAbilitiesFor(user); 20 | 21 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument,@typescript-eslint/no-explicit-any 22 | if ( 23 | ability.cannot(action, subject(subjectType as any, criteria as any), field) 24 | ) { 25 | throw new NotAuthorizedError( 26 | `You do not have access to ${action} ${subjectType}.`, 27 | logger, 28 | ); 29 | } 30 | 31 | // Alternatively 32 | // ForbiddenError.from(ability).throwUnlessCan(action, subject(subjectType, criteria), field); 33 | } 34 | -------------------------------------------------------------------------------- /src/server/utils/caslAbility.ts: -------------------------------------------------------------------------------- 1 | import type { Task, User, UserRole } from "@/server/db/schema"; 2 | import { 3 | AbilityBuilder, 4 | type ForcedSubject, 5 | type MongoAbility, 6 | createMongoAbility, 7 | } from "@casl/ability"; 8 | 9 | export type Action = 10 | | "manage" 11 | | "list" 12 | | "read" 13 | | "create" 14 | | "update" 15 | | "delete"; 16 | 17 | /* clone-code ENTITY_HOOK 18 | { 19 | "addType": "<%= h.changeCase.pascalCase(name) %>" 20 | } 21 | */ 22 | export type SubjectType = "Task" | "User" | "all"; 23 | 24 | type AppAbilities = [ 25 | Action, 26 | ( 27 | | SubjectType 28 | | ForcedSubject > 29 | | ForcedSubject 30 | ), 31 | ]; 32 | 33 | export type AppAbility = MongoAbility ; 34 | 35 | type DefinePermissions = ( 36 | user: User, 37 | builder: AbilityBuilder , 38 | ) => void; 39 | type Roles = UserRole.MEMBER | UserRole.ADMIN; 40 | 41 | const rolePermissions: Record = { 42 | MEMBER(user, { can }) { 43 | // USER 44 | can("read", "User", { id: user.id }); 45 | 46 | // TASK 47 | can("create", "Task", { userId: user.id }); 48 | can("list", "Task", { userId: user.id }); 49 | can("read", "Task", { userId: user.id }); 50 | can("update", "Task", { userId: user.id }); 51 | can("delete", "Task", { userId: user.id }); 52 | 53 | /* clone-code ENTITY_HOOK 54 | { 55 | "todo": "Add rolePermissions for <%= name %>" 56 | } 57 | */ 58 | }, 59 | ADMIN(user, { can }) { 60 | can("manage", "all"); 61 | }, 62 | }; 63 | 64 | export function defineAbilitiesFor(user: User | undefined) { 65 | const builder = new AbilityBuilder (createMongoAbility); 66 | 67 | if (user) { 68 | if (typeof rolePermissions[user?.role] === "function") { 69 | rolePermissions[user.role](user, builder); 70 | } else { 71 | throw new Error(`Trying to use unknown role "${user?.role}"`); 72 | } 73 | } 74 | 75 | return builder.build(); 76 | } 77 | -------------------------------------------------------------------------------- /src/server/utils/mocks.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | export function createMockRepository(entity: any) { 3 | return { 4 | findFirst: vi.fn((criteria) => { 5 | // Check if the ID matches 'clb_1' 6 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 7 | if (criteria?.id === entity.id) { 8 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return 9 | return entity; 10 | } 11 | // Return undefined or null to simulate 'not found' 12 | return undefined; 13 | }), 14 | findMany: vi.fn(() => Promise.resolve([entity])), 15 | findPage: vi.fn(() => 16 | Promise.resolve({ result: [entity], hasMore: false }), 17 | ), 18 | create: vi.fn(() => Promise.resolve(entity)), 19 | update: vi.fn(() => Promise.resolve(entity)), 20 | delete: vi.fn(() => Promise.resolve(entity)), 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /src/server/utils/sleep.ts: -------------------------------------------------------------------------------- 1 | export default function sleep(ms: number) { 2 | return new Promise((resolve) => setTimeout(resolve, ms)); 3 | } 4 | -------------------------------------------------------------------------------- /tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | const { nextui } = require("@nextui-org/react"); 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | module.exports = { 5 | content: [ 6 | "./app/**/*.{js,ts,jsx,tsx}", 7 | "./src/**/*.{js,ts,jsx,tsx}", 8 | "./node_modules/@nextui-org/theme/dist/**/*.{js,ts,jsx,tsx}", 9 | "./node_modules/@enalmada/nextui-admin/dist/**/*.{js,ts,jsx,tsx}", 10 | ], 11 | theme: { 12 | extend: { 13 | fontFamily: { 14 | sans: [ 15 | "var(--font-sans)", 16 | "system-ui", 17 | "-apple-system", 18 | "BlinkMacSystemFont", 19 | '"Segoe UI"', 20 | "Roboto", 21 | '"Helvetica Neue"', 22 | "Arial", 23 | '"Noto Sans"', 24 | "sans-serif", 25 | '"Apple Color Emoji"', 26 | '"Segoe UI Emoji"', 27 | '"Segoe UI Symbol"', 28 | '"Noto Color Emoji"', 29 | ], 30 | }, 31 | }, 32 | }, 33 | darkMode: "class", 34 | plugins: [require("@tailwindcss/typography"), nextui()], 35 | }; 36 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext", "webworker"], 5 | "types": ["vitest/globals", "@serwist/next/typings"], 6 | 7 | "allowJs": true, 8 | "checkJs": true, 9 | "skipLibCheck": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "noEmit": true, 13 | "esModuleInterop": true, 14 | "module": "esnext", 15 | "moduleResolution": "bundler", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "jsx": "preserve", 19 | "incremental": true, 20 | "noUncheckedIndexedAccess": true, 21 | "baseUrl": ".", 22 | "paths": { 23 | "@/*": ["./src/*"], 24 | "next/*": ["./node_modules/next/*"] // Necessary to solve next request being out of sync with 3rd party peer dependencies 25 | }, 26 | "plugins": [ 27 | { 28 | "name": "next" 29 | } 30 | ], 31 | "allowSyntheticDefaultImports": true, 32 | "sourceMap": true 33 | }, 34 | "include": [ 35 | "src/**/*.ts", 36 | "src/**/*.tsx", 37 | "src/**/*.mjs", 38 | "next-env.d.ts", 39 | "*.cjs", 40 | "*.mjs", 41 | ".next/types/**/*.ts", 42 | "drizzle.config.ts", 43 | "vitest.config.ts", 44 | "codegen.ts", 45 | "playwright.config.ts" 46 | ], 47 | "exclude": ["node_modules", "packages/**", "public/sw.js"] 48 | } 49 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "globalDependencies": ["**/.env.*local"], 4 | "tasks": { 5 | "build": { 6 | "dependsOn": ["^build"], 7 | "outputs": [".next/**", "!.next/cache/**"] 8 | }, 9 | "dev": { 10 | "cache": false, 11 | "persistent": true 12 | }, 13 | "test": { 14 | "inputs": ["src/**/*.tsx", "src/**/*.ts"] 15 | }, 16 | "test:unit": { 17 | "inputs": ["src/**/*.tsx", "src/**/*.ts"] 18 | }, 19 | "test:e2e": { 20 | "inputs": ["src/**/*.tsx", "src/**/*.ts"], 21 | "outputs": ["playwright/.auth/**", "playwright/test-results/**"] 22 | }, 23 | "format": {}, 24 | "next:lint": { 25 | "dependsOn": ["format"] 26 | }, 27 | "check-types": {}, 28 | "dev:install": { 29 | "inputs": ["package.json", "bun.lockb"] 30 | }, 31 | "docker:up": { 32 | "cache": false 33 | }, 34 | "drizzle:migrate": { 35 | "inputs": ["src/server/db/migrations/**"], 36 | "dependsOn": ["docker:up"] 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | // https://dev.to/thejaredwilcurt/improving-vitest-performance-42c6#:~:text=Turning%20isolation%20off%20(%20%2D%2Dno,cause%20issues%20in%20watch%20mode. 2 | import path from "node:path"; 3 | import react from "@vitejs/plugin-react"; 4 | import { configDefaults, defineConfig } from "vitest/config"; 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig({ 8 | plugins: [react()], 9 | test: { 10 | // Graphql is loaded twice and this seems to fix it 11 | // https://github.com/vitejs/vite/issues/7879 12 | // https://github.com/vitest-dev/vitest/issues/2806#issuecomment-1474468560 13 | server: { 14 | deps: { 15 | inline: [/^(?!.*vitest).*$/], 16 | }, 17 | }, 18 | exclude: [...configDefaults.exclude, "src/e2e/*"], 19 | watch: false, 20 | globals: true, 21 | environmentMatchGlobs: [ 22 | ["src/app/**/api/**", "node"], 23 | ["src/(app|client)/**", "happy-dom"], 24 | ["src/server/**", "node"], 25 | ], 26 | }, 27 | resolve: { 28 | alias: { 29 | "@": path.resolve(__dirname, "./src"), 30 | }, 31 | }, 32 | }); 33 | --------------------------------------------------------------------------------