├── .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 | 3 | 4 | 5 | 6 | #da532c 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enalmada/nextjs-boilerplate/a0543a353bbb346ff5266f626927848be9eab536/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enalmada/nextjs-boilerplate/a0543a353bbb346ff5266f626927848be9eab536/public/favicon.ico -------------------------------------------------------------------------------- /public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enalmada/nextjs-boilerplate/a0543a353bbb346ff5266f626927848be9eab536/public/icon.png -------------------------------------------------------------------------------- /public/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enalmada/nextjs-boilerplate/a0543a353bbb346ff5266f626927848be9eab536/public/mstile-150x150.png -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # * 2 | User-agent: * 3 | Disallow: 4 | 5 | # Host 6 | Host: http://localhost:3000 7 | 8 | # Sitemaps 9 | Sitemap: http://localhost:3000/sitemap.xml 10 | Sitemap: http://localhost:3000/server-sitemap.xml 11 | -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Todo App", 3 | "short_name": "Todo App", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone", 19 | "id": "/", 20 | "start_url": "/", 21 | "orientation": "portrait" 22 | } 23 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:js-app", 5 | ":automergeMajor", 6 | "schedule:automergeEarlyMondays" 7 | ], 8 | "packageRules": [ 9 | { 10 | "matchDatasources": ["npm"], 11 | "minimumReleaseAge": "3 days" 12 | } 13 | ], 14 | "enabled": false 15 | } 16 | -------------------------------------------------------------------------------- /reviewStaged.sh: -------------------------------------------------------------------------------- 1 | # https://medium.com/@samho1996/how-do-i-make-use-of-chatgpt-to-review-my-code-33efd8f42178 2 | # Get the paths of the staged .js, .ts, .jsx, .tsx files inside ./src 3 | files=$(git diff --cached --name-only --diff-filter=ACM | grep -E '^(./src/)?.*\.(ts|js|jsx|tsx)$') 4 | 5 | # Call the comment script for each staged file 6 | for file in $files 7 | do 8 | echo "Reviewing $file" 9 | npm run gpt:review $file 10 | done 11 | -------------------------------------------------------------------------------- /sentry.client.config.ts: -------------------------------------------------------------------------------- 1 | // This file configures the initialization of Sentry on the client. 2 | // The config you add here will be used whenever a users loads a page in their browser. 3 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/ 4 | 5 | import * as Sentry from "@sentry/nextjs"; 6 | 7 | Sentry.init({ 8 | dsn: process.env.SENTRY_DSN, 9 | 10 | // Adjust this value in production, or use tracesSampler for greater control 11 | tracesSampleRate: 1, 12 | 13 | // Setting this option to true will print useful information to the console while you're setting up Sentry. 14 | debug: false, 15 | 16 | replaysOnErrorSampleRate: 1.0, 17 | 18 | // This sets the sample rate to be 10%. You may want this to be 100% while 19 | // in development and sample at a lower rate in production 20 | // replaysSessionSampleRate: 0.1, 21 | 22 | // You can remove this option if you're not planning to use the Sentry Session Replay feature: 23 | /* 24 | integrations: [ 25 | new Sentry.Replay({ 26 | // Additional Replay configuration goes in here, for example: 27 | maskAllText: true, 28 | blockAllMedia: true, 29 | }), 30 | ], 31 | */ 32 | 33 | environment: process.env.NEXT_PUBLIC_APP_ENV, 34 | 35 | beforeSend: (event, hint) => { 36 | // https://github.com/getsentry/sentry-javascript/issues/1600 37 | console.error(hint.originalException || hint.syntheticException); // eslint-disable-line no-console 38 | if (process.env.NODE_ENV === "test") { 39 | return null; // this drops the event and nothing will be send to sentry 40 | } 41 | 42 | // keep this line separate to comment it out easily locally to watch errors 43 | if (process.env.NODE_ENV === "development") { 44 | return null; // this drops the event and nothing will be send to sentry 45 | } 46 | 47 | return event; 48 | }, 49 | }); 50 | -------------------------------------------------------------------------------- /sentry.edge.config.ts: -------------------------------------------------------------------------------- 1 | // This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on). 2 | // The config you add here will be used whenever one of the edge features is loaded. 3 | // Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally. 4 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/ 5 | 6 | import * as Sentry from "@sentry/nextjs"; 7 | 8 | Sentry.init({ 9 | dsn: "https://e827c97a46cc34fdcf55b79c6a3fe773@o32548.ingest.us.sentry.io/4505625265438720", 10 | 11 | // Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control. 12 | tracesSampleRate: 1, 13 | 14 | // Setting this option to true will print useful information to the console while you're setting up Sentry. 15 | debug: false, 16 | }); 17 | -------------------------------------------------------------------------------- /sentry.server.config.ts: -------------------------------------------------------------------------------- 1 | // This file configures the initialization of Sentry on the server. 2 | // The config you add here will be used whenever the server handles a request. 3 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/ 4 | 5 | import * as Sentry from "@sentry/nextjs"; 6 | 7 | Sentry.init({ 8 | dsn: process.env.SENTRY_DSN, 9 | 10 | // Adjust this value in production, or use tracesSampler for greater control 11 | tracesSampleRate: 1, 12 | 13 | // Setting this option to true will print useful information to the console while you're setting up Sentry. 14 | debug: false, 15 | 16 | environment: process.env.NEXT_PUBLIC_APP_ENV, 17 | 18 | beforeSend: (event, hint) => { 19 | // https://github.com/getsentry/sentry-javascript/issues/1600 20 | console.error(hint.originalException || hint.syntheticException); // eslint-disable-line no-console 21 | if (process.env.NODE_ENV === "test") { 22 | return null; // this drops the event and nothing will be send to sentry 23 | } 24 | 25 | // keep this line separate to comment it out easily locally to watch errors 26 | if (process.env.NODE_ENV === "development") { 27 | return null; // this drops the event and nothing will be send to sentry 28 | } 29 | 30 | return event; 31 | }, 32 | }); 33 | -------------------------------------------------------------------------------- /src/app/[locale]/(admin)/Authorization.tsx: -------------------------------------------------------------------------------- 1 | import type { User } from "@/client/gql/generated/graphql"; 2 | import { 3 | type ExtractSubjectType, 4 | type MongoQuery, 5 | type Subject, 6 | type SubjectRawRule, 7 | type SubjectType, 8 | createMongoAbility, 9 | } from "@casl/ability"; 10 | import { type PackRule, unpackRules } from "@casl/ability/extra"; 11 | import { redirect } from "next/navigation"; 12 | import { useEffect, useMemo } from "react"; 13 | 14 | export const useAuthorization = (me?: User | null) => { 15 | const hasAuthorization = useMemo(() => { 16 | const ability = createMongoAbility(); 17 | 18 | if (!me?.rules) { 19 | return false; 20 | } 21 | 22 | const rules = JSON.parse(me.rules as string) as PackRule< 23 | SubjectRawRule 24 | >[]; 25 | const unpackedRules = unpackRules(rules) as SubjectRawRule< 26 | string, 27 | ExtractSubjectType, 28 | MongoQuery 29 | >[]; 30 | 31 | ability.update(unpackedRules); 32 | return ability.can("manage", "all"); 33 | }, [me?.rules]); 34 | 35 | useEffect(() => { 36 | if (!hasAuthorization) { 37 | redirect("/"); 38 | } 39 | }, [hasAuthorization]); 40 | 41 | return hasAuthorization; 42 | }; 43 | -------------------------------------------------------------------------------- /src/app/[locale]/(admin)/admin/Page.stories.tsx: -------------------------------------------------------------------------------- 1 | import AdminLayout from "@/app/[locale]/(admin)/AdminLayout"; 2 | import Loading from "@/app/[locale]/(admin)/admin/loading"; 3 | import TaskList from "@/client/components/tasks/TaskList"; 4 | import type { Meta, StoryObj } from "@storybook/react"; 5 | import React from "react"; 6 | 7 | import Page from "./page"; 8 | 9 | // More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction 10 | const meta: Meta = { 11 | title: "Pages/Admin/Home", 12 | component: TaskList, 13 | argTypes: {}, 14 | render: () => ( 15 | 16 | 17 | 18 | ), 19 | parameters: { 20 | layout: "fullscreen", 21 | }, 22 | }; 23 | 24 | export default meta; 25 | 26 | type Story = StoryObj; 27 | export const Default: Story = {}; 28 | 29 | export const AdminTaskListLoading: Story = { 30 | render: () => ( 31 | 32 | 33 | 34 | ), 35 | }; 36 | -------------------------------------------------------------------------------- /src/app/[locale]/(admin)/admin/[entity]/EntityTable.tsx: -------------------------------------------------------------------------------- 1 | import { TaskTable } from "@/client/admin/entity/task/TaskTable"; 2 | import { UserTable } from "@/client/admin/entity/user/UserTable"; 3 | 4 | interface Props { 5 | loading?: boolean; 6 | entity?: string; 7 | } 8 | 9 | export default function EntityTable({ loading, entity }: Props) { 10 | // loading pages currently don't get parameters but hopefully that changes 11 | // https://nextjs.org/docs/app/api-reference/file-conventions/loading 12 | if (loading) { 13 | // TODO return form skeleton 14 | return null; 15 | } 16 | 17 | switch (entity) { 18 | case "user": 19 | return ; 20 | /* clone-code ENTITY_HOOK 21 | { 22 | "toPlacement": "below", 23 | "replacements": [ 24 | { "find": "Task", "replace": "<%= h.changeCase.pascalCase(name) %>" }, 25 | { "find": "task", "replace": "<%= h.changeCase.camelCase(name) %>" } 26 | ] 27 | } 28 | */ 29 | case "task": 30 | return ; 31 | /* clone-code ENTITY_HOOK end */ 32 | default: 33 | return "Not Found"; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/app/[locale]/(admin)/admin/[entity]/[id]/EntityForm.tsx: -------------------------------------------------------------------------------- 1 | import TaskForm from "@/client/admin/entity/task/TaskForm"; 2 | import UserForm from "@/client/admin/entity/user/UserForm"; 3 | 4 | interface Props { 5 | loading?: boolean; 6 | entity?: string; 7 | id?: string; 8 | } 9 | 10 | export default function EntityForm({ loading, entity, id }: Props) { 11 | // loading pages currently don't get parameters but hopefully that changes 12 | // https://nextjs.org/docs/app/api-reference/file-conventions/loading 13 | if (loading) { 14 | // TODO return table skeleton 15 | return null; 16 | } 17 | 18 | switch (entity) { 19 | case "user": 20 | return ; 21 | /* clone-code ENTITY_HOOK 22 | { 23 | "toPlacement": "below", 24 | "replacements": [ 25 | { "find": "Task", "replace": "<%= h.changeCase.pascalCase(name) %>" }, 26 | { "find": "task", "replace": "<%= h.changeCase.camelCase(name) %>" } 27 | ] 28 | } 29 | */ 30 | case "task": 31 | return ; 32 | /* clone-code ENTITY_HOOK end */ 33 | default: 34 | return "Not Found"; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/app/[locale]/(admin)/admin/[entity]/[id]/loading.tsx: -------------------------------------------------------------------------------- 1 | import EntityForm from "./EntityForm"; 2 | 3 | export default function Loading() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/app/[locale]/(admin)/admin/[entity]/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | 3 | import EntityForm from "./EntityForm"; 4 | 5 | type Props = { 6 | params: Promise<{ entity: string; id: string }>; 7 | searchParams: Promise<{ [key: string]: string | string[] | undefined }>; 8 | }; 9 | 10 | export async function generateMetadata(props: Props): Promise { 11 | const params = await props.params; 12 | const { entity, id } = params; 13 | 14 | return { 15 | title: `${entity} ${id}`, 16 | }; 17 | } 18 | 19 | export default async function Page(props: Props) { 20 | const params = await props.params; 21 | const { entity, id } = params; 22 | 23 | return ; 24 | } 25 | -------------------------------------------------------------------------------- /src/app/[locale]/(admin)/admin/[entity]/loading.tsx: -------------------------------------------------------------------------------- 1 | import EntityTable from "./EntityTable"; 2 | 3 | export default function Loading() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/app/[locale]/(admin)/admin/[entity]/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | 3 | import EntityTable from "./EntityTable"; 4 | 5 | type Props = { 6 | params: Promise<{ entity: string }>; 7 | searchParams: Promise<{ [key: string]: string | string[] | undefined }>; 8 | }; 9 | 10 | export async function generateMetadata(props: Props): Promise { 11 | const params = await props.params; 12 | const entity = params.entity; 13 | 14 | return { 15 | title: `${entity} list`, 16 | }; 17 | } 18 | 19 | export default async function Page(props: Props) { 20 | const params = await props.params; 21 | const entity = params.entity; 22 | 23 | return ; 24 | } 25 | -------------------------------------------------------------------------------- /src/app/[locale]/(admin)/admin/developers/Content.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import FileUpload from "@/app/[locale]/(admin)/admin/developers/FileUpload"; 4 | import PublishNotification from "@/app/[locale]/(admin)/admin/developers/PublishNotification"; 5 | import Subscription from "@/app/[locale]/(admin)/admin/developers/Subscription"; 6 | import { Card, CardBody, CardHeader } from "@nextui-org/react"; 7 | 8 | export const Content = () => { 9 | return ( 10 | 11 | 12 |

Tech

13 |
14 | 15 | 16 | 17 | 18 | 19 |
20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /src/app/[locale]/(admin)/admin/developers/FileUpload.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { UPLOAD_FILE } from "@/client/gql/admin-queries.gql"; 4 | import type { UploadFileMutation } from "@/client/gql/generated/graphql"; 5 | import { Button, Spinner } from "@nextui-org/react"; 6 | import React, { useRef, useState, type ChangeEvent } from "react"; 7 | import { useMutation } from "urql"; 8 | 9 | // File Upload Example 10 | // https://github.com/urql-graphql/urql/blob/main/examples/with-multipart/src/FileUpload.jsx 11 | 12 | const FileUpload = () => { 13 | const [selectedFile, setSelectedFile] = useState(); 14 | const [result, uploadFile] = useMutation(UPLOAD_FILE); 15 | const fileInputRef = useRef(null); 16 | 17 | const { data, fetching, error } = result; 18 | 19 | const handleFileUpload = () => { 20 | if (selectedFile) { 21 | void uploadFile({ file: selectedFile }); 22 | } 23 | }; 24 | 25 | const handleFileChange = (event: ChangeEvent) => { 26 | if (event.target.files) { 27 | setSelectedFile(event.target.files[0]); 28 | } 29 | }; 30 | 31 | const triggerFileInput = () => { 32 | fileInputRef.current?.click(); 33 | }; 34 | 35 | return ( 36 |
37 |
Test File Upload
38 | 39 | {fetching && ( 40 | <> 41 |

Loading...

42 | 43 | )} 44 | 45 | {error &&

Oh no... {error.message}

} 46 | 47 | {data?.uploadFile ? ( 48 |

File uploaded: {data.uploadFile.filename}

49 | ) : ( 50 |
51 | 57 | 58 | 59 | 60 | {!selectedFile &&
No file chosen
} 61 | {selectedFile && ( 62 | <> 63 |
{selectedFile.name}
64 | 65 | 71 | 72 | )} 73 |
74 | )} 75 |
76 | ); 77 | }; 78 | 79 | export default FileUpload; 80 | -------------------------------------------------------------------------------- /src/app/[locale]/(admin)/admin/developers/PublishNotification.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { PUBLISH_NOTIFICATION } from "@/client/gql/admin-queries.gql"; 4 | import type { PublishNotificationMutation } from "@/client/gql/generated/graphql"; 5 | import { useMutation } from "@enalmada/next-gql/client"; 6 | import { Button, Spinner } from "@nextui-org/react"; 7 | 8 | const PublishNotification = () => { 9 | const [result, publishNotification] = 10 | useMutation(PUBLISH_NOTIFICATION); 11 | const { data, fetching, error } = result; 12 | 13 | return ( 14 |
15 |
Notification Publish
16 | 17 | {fetching && ( 18 | <> 19 |

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 |
40 | ); 41 | }; 42 | 43 | export default PublishNotification; 44 | -------------------------------------------------------------------------------- /src/app/[locale]/(admin)/admin/developers/Subscription.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { NOTIFICATION_EVENTS } from "@/client/gql/admin-queries.gql"; 4 | import type { NotificationEvent } from "@/client/gql/generated/graphql"; 5 | import { useSubscription } from "@enalmada/next-gql/client"; 6 | 7 | // https://formidable.com/open-source/urql/docs/advanced/subscriptions/ 8 | 9 | const handleSubscription = ( 10 | messages: NotificationEvent[] | undefined, 11 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 12 | response: { notificationEvents: any }, 13 | ) => { 14 | // Provide a default empty array if messages is undefined 15 | const safeMessages = messages ?? []; 16 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return 17 | return [response.notificationEvents, ...safeMessages]; 18 | }; 19 | 20 | const Subscription = () => { 21 | const [result] = useSubscription( 22 | { query: NOTIFICATION_EVENTS }, 23 | handleSubscription, 24 | ); 25 | 26 | if (!result.data) { 27 | return

No new messages

; 28 | } 29 | 30 | return ( 31 | <> 32 | {result.data.map((event: NotificationEvent) => ( 33 |
    34 |

    35 | {event.type}: "{event.message}" 36 |

    37 |
38 | ))} 39 | 40 | ); 41 | }; 42 | 43 | export default Subscription; 44 | -------------------------------------------------------------------------------- /src/app/[locale]/(admin)/admin/developers/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next/index"; 2 | import React from "react"; 3 | import { Content } from "./Content"; 4 | 5 | export const metadata: Metadata = { 6 | title: "Admin", 7 | }; 8 | 9 | export default function Page() { 10 | return ; 11 | } 12 | -------------------------------------------------------------------------------- /src/app/[locale]/(admin)/admin/loading.tsx: -------------------------------------------------------------------------------- 1 | export default function AppLoading() { 2 | return null; 3 | } 4 | -------------------------------------------------------------------------------- /src/app/[locale]/(admin)/admin/page.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const metadata = { 4 | title: "Admin", 5 | }; 6 | 7 | export default function Page() { 8 | return ( 9 |
10 |
11 |
12 | {/* Card Section Top */} 13 |
14 |

Admin Dashboard

15 |
16 | Reporting TBD 17 |
18 |
19 |
20 |
21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/app/[locale]/(admin)/layout.tsx: -------------------------------------------------------------------------------- 1 | import AdminLayout from "@/app/[locale]/(admin)/AdminLayout"; 2 | 3 | // Uncomment for Cloudflare next-on-pages (required) or Vercel edge 4 | // export const runtime = 'edge'; 5 | 6 | export const dynamic = "force-dynamic"; 7 | 8 | export default function RootLayout({ 9 | children, 10 | }: { children: React.ReactNode }) { 11 | return {children}; 12 | } 13 | -------------------------------------------------------------------------------- /src/app/[locale]/(admin)/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardBody, Spinner } from "@/client/ui"; 2 | 3 | export default function AdminLoading() { 4 | return ( 5 |
6 |
7 |
8 | 9 | 10 |
11 |
Loading
12 | 13 |
14 |
15 |
16 |
17 |
18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/app/[locale]/(app)/AppLayout.tsx: -------------------------------------------------------------------------------- 1 | import Header from "@/client/components/layout/app/Header"; 2 | 3 | export default function AppLayout({ children }: { children: React.ReactNode }) { 4 | return ( 5 |
6 |
7 |
8 | {children} 9 |
10 | {/* 11 |
12 | 13 | Site 14 |

2023

15 | 16 |
17 | */} 18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/app/[locale]/(app)/Page.stories.tsx: -------------------------------------------------------------------------------- 1 | import AppLayout from "@/app/[locale]/(app)/AppLayout"; 2 | import Loading from "@/app/[locale]/(app)/loading"; 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: "Pages/App/Loading", 8 | component: Loading, 9 | argTypes: {}, 10 | render: () => ( 11 | 12 | 13 | 14 | ), 15 | parameters: { 16 | layout: "fullscreen", 17 | }, 18 | }; 19 | 20 | export default meta; 21 | 22 | type Story = StoryObj; 23 | export const AppLoading: Story = {}; 24 | -------------------------------------------------------------------------------- /src/app/[locale]/(app)/app/Page.stories.tsx: -------------------------------------------------------------------------------- 1 | import AppLayout from "@/app/[locale]/(app)/AppLayout"; 2 | import { PageContent } from "@/app/[locale]/(app)/app/PageContent"; 3 | import Loading from "@/app/[locale]/(app)/app/loading"; 4 | import TaskList from "@/client/components/tasks/TaskList"; 5 | import type { Meta, StoryObj } from "@storybook/react"; 6 | import React from "react"; 7 | 8 | // More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction 9 | const meta: Meta = { 10 | title: "Pages/App/TaskList", 11 | component: TaskList, 12 | argTypes: {}, 13 | render: () => ( 14 | 15 | 16 | 17 | 18 | 19 | ), 20 | parameters: { 21 | layout: "fullscreen", 22 | }, 23 | }; 24 | 25 | export default meta; 26 | 27 | type Story = StoryObj; 28 | export const Default: Story = {}; 29 | 30 | export const TaskListLoading: Story = { 31 | render: () => ( 32 | 33 | 34 | 35 | ), 36 | }; 37 | -------------------------------------------------------------------------------- /src/app/[locale]/(app)/app/PageContent.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/client/ui"; 2 | import { getRouteById } from "@/client/utils/routes"; 3 | import NextLink from "next/link"; 4 | import React, { type PropsWithChildren } from "react"; 5 | 6 | export function PageContent({ children }: PropsWithChildren) { 7 | return ( 8 | <> 9 |
10 |

11 | Task Manager 12 |

13 |
14 |
15 |
16 |
17 | 18 |
19 | 26 |
27 | 28 | {children} 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/app/[locale]/(app)/app/error/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export default function Home() { 4 | const causeError = async () => { 5 | const res = await fetch("/api/error"); 6 | if (!res.ok) { 7 | throw new Error("Example Frontend Error"); 8 | } 9 | }; 10 | 11 | return ( 12 |
13 |
22 |

Trigger a sample error:

23 | 60 |
61 |
62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /src/app/[locale]/(app)/app/loading.tsx: -------------------------------------------------------------------------------- 1 | import { PageContent } from "@/app/[locale]/(app)/app/PageContent"; 2 | import { TaskListLoading } from "@/client/components/tasks/TaskList"; 3 | 4 | export default function Loading() { 5 | return ( 6 | 7 | 8 | 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /src/app/[locale]/(app)/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { PageContent } from "@/app/[locale]/(app)/app/PageContent"; 2 | import TaskList from "@/client/components/tasks/TaskList"; 3 | import React from "react"; 4 | 5 | export const metadata = { 6 | title: "Tasks", 7 | }; 8 | 9 | export default function Page() { 10 | return ( 11 | 12 | 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /src/app/[locale]/(app)/app/profile/Page.stories.tsx: -------------------------------------------------------------------------------- 1 | import AppLayout from "@/app/[locale]/(app)/AppLayout"; 2 | import { ProfileWrapper } from "@/app/[locale]/(app)/app/profile/UserProfile/UserProfile"; 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: "Pages/App/Profile", 8 | component: ProfileWrapper, 9 | argTypes: {}, 10 | render: () => ( 11 | 12 | 13 | 14 | ), 15 | parameters: { 16 | layout: "fullscreen", 17 | }, 18 | }; 19 | 20 | export default meta; 21 | 22 | type Story = StoryObj; 23 | export const Default: Story = {}; 24 | -------------------------------------------------------------------------------- /src/app/[locale]/(app)/app/profile/UserProfile/UserProfile.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button, Card, CardBody } from "@/client/ui"; 4 | import { useAuth } from "@enalmada/next-firebase-auth-edge-wrapper"; 5 | import { Chip } from "@nextui-org/react"; 6 | import Image from "next/image"; 7 | import NextLink from "next/link"; 8 | 9 | interface UserProfileProps { 10 | count: number; 11 | } 12 | 13 | export function ProfileWrapper() { 14 | return ( 15 |
16 | 21 |

Profile page

22 | 23 |
24 | ); 25 | } 26 | 27 | export function UserProfile({ count }: UserProfileProps) { 28 | const { user } = useAuth(); 29 | 30 | if (!user) { 31 | return null; 32 | } 33 | 34 | return ( 35 | <> 36 | 37 | 38 |

You are logged in as

39 |
40 |
41 | {user.photoURL && ( 42 | 43 | )} 44 |
45 | {user.email} 46 |
47 | 48 | {!user.emailVerified && ( 49 |
50 | Email not verified. 51 |
52 | )} 53 | {user.emailVerified && ( 54 |
55 | Email verified. 56 |
57 | )} 58 | 59 |
60 |
Custom claims
61 |
{JSON.stringify(user.customClaims, undefined, 2)}
62 |
63 |
64 |
65 | 66 | 67 | 68 |

69 | {/* defaultCount is updated by server */} 70 | Counter: {count} 71 |

72 |
73 |
74 | 75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /src/app/[locale]/(app)/app/profile/UserProfile/index.ts: -------------------------------------------------------------------------------- 1 | export { UserProfile } from "./UserProfile"; 2 | -------------------------------------------------------------------------------- /src/app/[locale]/(app)/app/profile/page.tsx: -------------------------------------------------------------------------------- 1 | import { ProfileWrapper } from "@/app/[locale]/(app)/app/profile/UserProfile/UserProfile"; 2 | import { 3 | authConfig, 4 | getTokens, 5 | } from "@enalmada/next-firebase-auth-edge-wrapper"; 6 | import type { Metadata } from "next"; 7 | import { cookies } from "next/headers"; 8 | 9 | // Generate customized metadata based on user cookies 10 | // https://nextjs.org/docs/app/building-your-application/optimizing/metadata 11 | export async function generateMetadata(): Promise { 12 | const tokens = await getTokens(await cookies(), authConfig); 13 | 14 | if (!tokens) { 15 | return {}; 16 | } 17 | 18 | return { 19 | title: `${tokens.decodedToken.email} Profile`, 20 | }; 21 | } 22 | 23 | export default function Profile() { 24 | return ; 25 | } 26 | -------------------------------------------------------------------------------- /src/app/[locale]/(app)/app/task/[id]/Page.stories.tsx: -------------------------------------------------------------------------------- 1 | import AppLayout from "@/app/[locale]/(app)/AppLayout"; 2 | import Page from "@/app/[locale]/(app)/app/task/[id]/page"; 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: "Pages/App/Task", 8 | component: Page, 9 | argTypes: {}, 10 | render: () => { 11 | // Simulate the async nature of params by wrapping in Promise.resolve 12 | const mockAsyncParams: Promise<{ id: string }> = Promise.resolve({ 13 | id: "tsk_1", 14 | }); 15 | 16 | return ( 17 | 18 | 19 | 20 | ); 21 | }, 22 | parameters: { 23 | layout: "fullscreen", 24 | }, 25 | }; 26 | 27 | export default meta; 28 | 29 | type Story = StoryObj; 30 | export const Default: Story = {}; 31 | -------------------------------------------------------------------------------- /src/app/[locale]/(app)/app/task/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import TaskForm from "@/client/components/tasks/TaskForm"; 2 | import { Breadcrumb } from "@/client/ui"; 3 | import { getRouteById } from "@/client/utils/routes"; 4 | import { Suspense } from "react"; 5 | 6 | export const dynamic = "force-dynamic"; 7 | 8 | interface Props { 9 | params: Promise<{ 10 | id: string; 11 | }>; 12 | } 13 | 14 | export const metadata = { 15 | title: "Task", 16 | }; 17 | 18 | // TODO - this use of Suspense should be loading.ts instead 19 | export default async function Page(props: Props) { 20 | const id = (await props.params).id; 21 | return ( 22 | <> 23 | 24 | 25 | 26 | 27 | 28 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /src/app/[locale]/(app)/app/task/new/Page.stories.tsx: -------------------------------------------------------------------------------- 1 | import AppLayout from "@/app/[locale]/(app)/AppLayout"; 2 | import Page from "@/app/[locale]/(app)/app/task/new/page"; 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: "Pages/App/NewTask", 8 | component: Page, 9 | argTypes: {}, 10 | render: () => ( 11 | 12 | 13 | 14 | ), 15 | parameters: { 16 | layout: "fullscreen", 17 | }, 18 | }; 19 | 20 | export default meta; 21 | 22 | type Story = StoryObj; 23 | export const Default: Story = {}; 24 | -------------------------------------------------------------------------------- /src/app/[locale]/(app)/app/task/new/page.tsx: -------------------------------------------------------------------------------- 1 | import TaskForm from "@/client/components/tasks/TaskForm"; 2 | import { Breadcrumb } from "@/client/ui"; 3 | import { getRouteById } from "@/client/utils/routes"; 4 | 5 | export const metadata = { 6 | title: "New Task", 7 | }; 8 | 9 | export default function Page() { 10 | return ( 11 | <> 12 | 13 | 14 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /src/app/[locale]/(app)/layout.tsx: -------------------------------------------------------------------------------- 1 | import AppLayout from "@/app/[locale]/(app)/AppLayout"; 2 | 3 | // Uncomment for Cloudflare next-on-pages (required) or Vercel edge 4 | // export const runtime = 'edge'; 5 | 6 | // TODO - confirm we really need to 'force-dynamic' here 7 | export const dynamic = "force-dynamic"; 8 | 9 | export default function RootLayout({ 10 | children, 11 | }: { children: React.ReactNode }) { 12 | return {children}; 13 | } 14 | -------------------------------------------------------------------------------- /src/app/[locale]/(app)/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardBody, Spinner } from "@/client/ui"; 2 | 3 | export default function AppLoading() { 4 | return ( 5 |
6 |
7 |
8 | 9 | 10 |
11 |
Loading
12 | 13 |
14 |
15 |
16 |
17 |
18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/app/[locale]/(auth)/AuthLayout.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardBody } from "@/client/ui"; 2 | 3 | export default function AuthLayout({ 4 | children, 5 | }: { children: React.ReactNode }) { 6 | return ( 7 |
8 |
9 |
10 | 11 | {children} 12 | 13 |
14 |
15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/app/[locale]/(auth)/Page.stories.tsx: -------------------------------------------------------------------------------- 1 | import AuthLayout from "@/app/[locale]/(auth)/AuthLayout"; 2 | import Page from "@/app/[locale]/(auth)/loading"; 3 | import type { Meta, StoryObj } from "@storybook/react"; 4 | 5 | const meta: Meta = { 6 | title: "Pages/Auth/Loading", 7 | component: Page, 8 | argTypes: {}, 9 | render: () => ( 10 | 11 | 12 | 13 | ), 14 | parameters: { 15 | layout: "fullscreen", 16 | }, 17 | }; 18 | 19 | export default meta; 20 | 21 | type Story = StoryObj; 22 | export const AuthLoading: Story = {}; 23 | -------------------------------------------------------------------------------- /src/app/[locale]/(auth)/layout.tsx: -------------------------------------------------------------------------------- 1 | import AuthLayout from "@/app/[locale]/(auth)/AuthLayout"; 2 | 3 | // Uncomment for Cloudflare next-on-pages (required) or Vercel edge 4 | // export const runtime = 'edge'; 5 | 6 | // Using the searchParams Pages prop will opt the page into dynamic rendering at request time. 7 | // https://nextjs.org/docs/app/building-your-application/rendering/static-and-dynamic-rendering#dynamic-functions 8 | 9 | export default function RootLayout({ 10 | children, 11 | }: { children: React.ReactNode }) { 12 | return {children}; 13 | } 14 | -------------------------------------------------------------------------------- /src/app/[locale]/(auth)/loading.tsx: -------------------------------------------------------------------------------- 1 | export default function LoadingLayout() { 2 | return
; 3 | } 4 | -------------------------------------------------------------------------------- /src/app/[locale]/(auth)/login/LoginPage.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import GoogleButton from "@/client/components/auth/GoogleButton"; 4 | import PasswordForm from "@/client/components/auth/PasswordForm"; 5 | import Redirecting from "@/client/components/auth/Redirecting"; 6 | import { Link } from "@/client/ui"; 7 | import React, { useState } from "react"; 8 | 9 | interface Props { 10 | redirect?: string; 11 | } 12 | 13 | export function LoginPage({ redirect }: Props) { 14 | const [hasLogged, setHasLogged] = useState(false); 15 | 16 | return ( 17 | <> 18 | {hasLogged && ( 19 | 20 | Redirecting to {redirect || "/"} 21 | 22 | )} 23 | {!hasLogged && ( 24 | <> 25 |
26 |

27 | Sign in 28 |

29 |

30 | Don't have an account yet?   31 | 38 | Sign up here 39 | 40 |

41 |
42 | 43 |
44 | 45 | Sign in with Google 46 | 47 | 48 |
49 | Or 50 |
51 | 52 | 57 |
58 | 59 | )} 60 | 61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /src/app/[locale]/(auth)/login/Page.stories.tsx: -------------------------------------------------------------------------------- 1 | import AuthLayout from "@/app/[locale]/(auth)/AuthLayout"; 2 | import Page from "@/app/[locale]/(auth)/login/page"; 3 | import type { Meta, StoryObj } from "@storybook/react"; 4 | 5 | const meta: Meta = { 6 | title: "Pages/Auth/Login", 7 | component: Page, 8 | argTypes: {}, 9 | render: () => { 10 | // Simulate the async nature of searchParams by wrapping in Promise.resolve 11 | const mockAsyncSearchParams: Promise<{ redirect?: string }> = 12 | Promise.resolve({ 13 | redirect: "/app", 14 | }); 15 | 16 | return ( 17 | 18 | 19 | 20 | ); 21 | }, 22 | parameters: { 23 | layout: "fullscreen", 24 | }, 25 | }; 26 | 27 | export default meta; 28 | 29 | type Story = StoryObj; 30 | export const Default: Story = {}; 31 | -------------------------------------------------------------------------------- /src/app/[locale]/(auth)/login/page.tsx: -------------------------------------------------------------------------------- 1 | import { LoginPage as ClientLoginPage } from "./LoginPage"; 2 | 3 | export const metadata = { 4 | title: "Login", 5 | }; 6 | 7 | interface Props { 8 | searchParams: Promise<{ 9 | redirect?: string; 10 | }>; 11 | } 12 | 13 | export default async function Login(props: Props) { 14 | const searchParams = await props.searchParams; 15 | const redirect = searchParams.redirect; 16 | 17 | return ; 18 | } 19 | -------------------------------------------------------------------------------- /src/app/[locale]/(auth)/logout/LogoutPage.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { logout } from "@enalmada/next-firebase-auth-edge-wrapper"; 4 | import { useEffect } from "react"; 5 | 6 | export default function LogoutPage() { 7 | useEffect(() => { 8 | const clearCache = async () => { 9 | try { 10 | // Urql cache is unique to tenant and will be cleared when it changes 11 | // https://formidable.com/open-source/urql/docs/advanced/authentication/#cache-invalidation-on-logout 12 | await logout(); 13 | // router.refresh(); // This seems necessary to avoid a full window.reload 14 | // TODO get router.replace working again 15 | // router.replace('/'); 16 | 17 | window.location.replace("/"); 18 | } catch (error) { 19 | console.error("Error clearing client cache:", error); 20 | } 21 | }; 22 | 23 | void clearCache(); 24 | // getFirebaseAuth dependency will cause infinite loading 25 | // eslint-disable-next-line 26 | }, []); 27 | 28 | return null; 29 | } 30 | -------------------------------------------------------------------------------- /src/app/[locale]/(auth)/logout/Page.stories.tsx: -------------------------------------------------------------------------------- 1 | import AuthLayout from "@/app/[locale]/(auth)/AuthLayout"; 2 | import Redirecting from "@/client/components/auth/Redirecting"; 3 | import type { Meta, StoryObj } from "@storybook/react"; 4 | 5 | // Don't hit the actual logout logic 6 | const meta: Meta = { 7 | title: "Pages/App/Logout", 8 | component: Redirecting, 9 | argTypes: {}, 10 | render: () => ( 11 | 12 | Logging out 13 | 14 | ), 15 | parameters: { 16 | layout: "fullscreen", 17 | }, 18 | }; 19 | 20 | export default meta; 21 | 22 | type Story = StoryObj; 23 | export const Default: Story = {}; 24 | -------------------------------------------------------------------------------- /src/app/[locale]/(auth)/logout/page.tsx: -------------------------------------------------------------------------------- 1 | import Redirecting from "@/client/components/auth/Redirecting"; 2 | 3 | import LogoutPage from "./LogoutPage"; 4 | 5 | export const dynamic = "force-dynamic"; 6 | 7 | export const metadata = { 8 | title: "Logout", 9 | }; 10 | 11 | export default function Logout() { 12 | return ( 13 | <> 14 | Logging out 15 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/app/[locale]/(auth)/maintenance-mode/Page.stories.tsx: -------------------------------------------------------------------------------- 1 | import AuthLayout from "@/app/[locale]/(auth)/AuthLayout"; 2 | import Page from "@/app/[locale]/(auth)/maintenance-mode/page"; 3 | import type { Meta, StoryObj } from "@storybook/react"; 4 | 5 | const meta: Meta = { 6 | title: "Pages/Auth/MaintenanceMode", 7 | component: Page, 8 | argTypes: {}, 9 | render: () => ( 10 | 11 | 12 | 13 | ), 14 | parameters: { 15 | layout: "fullscreen", 16 | }, 17 | }; 18 | 19 | export default meta; 20 | 21 | type Story = StoryObj; 22 | export const Default: Story = {}; 23 | -------------------------------------------------------------------------------- /src/app/[locale]/(auth)/maintenance-mode/page.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/client/ui"; 2 | import NextLink from "next/link"; 3 | 4 | export const metadata = { 5 | title: "Maintenance", 6 | }; 7 | 8 | export default function Page() { 9 | return ( 10 |
11 |

12 | Down For Maintenance 13 |

14 |

15 |

16 | Please try again later 17 |

18 |
19 | 22 | 25 |
26 |

27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/app/[locale]/(auth)/register/Page.stories.tsx: -------------------------------------------------------------------------------- 1 | import AuthLayout from "@/app/[locale]/(auth)/AuthLayout"; 2 | import Page from "@/app/[locale]/(auth)/register/page"; 3 | import type { Meta, StoryObj } from "@storybook/react"; 4 | 5 | const meta: Meta = { 6 | title: "Pages/Auth/Register", 7 | component: Page, 8 | argTypes: {}, 9 | render: () => { 10 | const mockAsyncSearchParams: Promise<{ redirect?: string }> = 11 | Promise.resolve({ 12 | redirect: "/app", 13 | }); 14 | 15 | return ( 16 | 17 | 18 | 19 | ); 20 | }, 21 | parameters: { 22 | layout: "fullscreen", 23 | }, 24 | }; 25 | 26 | export default meta; 27 | 28 | type Story = StoryObj; 29 | export const Default: Story = {}; 30 | -------------------------------------------------------------------------------- /src/app/[locale]/(auth)/register/RegisterPage.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import GoogleButton from "@/client/components/auth/GoogleButton"; 4 | import PasswordForm from "@/client/components/auth/PasswordForm"; 5 | import Redirecting from "@/client/components/auth/Redirecting"; 6 | import { Link } from "@/client/ui"; 7 | import React, { useState } from "react"; 8 | 9 | interface Props { 10 | redirect?: string; 11 | } 12 | 13 | export function RegisterPage({ redirect }: Props) { 14 | const [hasLogged, setHasLogged] = useState(false); 15 | 16 | return ( 17 | <> 18 | {hasLogged && ( 19 | 20 | Redirecting to {redirect || "/"} 21 | 22 | )} 23 | {!hasLogged && ( 24 | <> 25 |
26 |

27 | Sign up 28 |

29 |

30 | Already have an account?   31 | 38 | Sign in here 39 | 40 |

41 |
42 | 43 |
44 | 45 | Sign up with Google 46 | 47 | 48 |
49 | Or 50 |
51 | 52 | 57 |
58 | 59 | )} 60 | 61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /src/app/[locale]/(auth)/register/page.tsx: -------------------------------------------------------------------------------- 1 | import { RegisterPage } from "./RegisterPage"; 2 | 3 | export const metadata = { 4 | title: "Register", 5 | }; 6 | 7 | interface Props { 8 | searchParams: Promise<{ 9 | redirect?: string; 10 | }>; 11 | } 12 | 13 | export default async function Register(props: Props) { 14 | const searchParams = await props.searchParams; 15 | const redirect = searchParams.redirect; 16 | 17 | return ; 18 | } 19 | -------------------------------------------------------------------------------- /src/app/[locale]/(auth)/reset-password/Page.stories.tsx: -------------------------------------------------------------------------------- 1 | import AuthLayout from "@/app/[locale]/(auth)/AuthLayout"; 2 | import { Successful } from "@/app/[locale]/(auth)/reset-password/ResetPasswordPage"; 3 | import Page from "@/app/[locale]/(auth)/reset-password/page"; 4 | import type { Meta, StoryObj } from "@storybook/react"; 5 | 6 | const meta: Meta = { 7 | title: "Pages/Auth/ResetPassword", 8 | component: Page, 9 | argTypes: {}, 10 | render: () => { 11 | const mockAsyncSearchParams: Promise<{ redirect?: string }> = 12 | Promise.resolve({ 13 | redirect: "/app", 14 | }); 15 | 16 | return ( 17 | 18 | 19 | 20 | ); 21 | }, 22 | parameters: { 23 | layout: "fullscreen", 24 | }, 25 | }; 26 | 27 | export default meta; 28 | 29 | type Story = StoryObj; 30 | export const Default: Story = {}; 31 | 32 | export const SuccessfulNotice: Story = { 33 | render: () => ( 34 | 35 | 36 | 37 | ), 38 | }; 39 | -------------------------------------------------------------------------------- /src/app/[locale]/(auth)/reset-password/page.tsx: -------------------------------------------------------------------------------- 1 | import { ResetPasswordPage } from "./ResetPasswordPage"; 2 | 3 | export const metadata = { 4 | title: "Reset Password", 5 | }; 6 | 7 | interface Props { 8 | searchParams: Promise<{ 9 | redirect?: string; 10 | }>; 11 | } 12 | 13 | export default async function ResetPassword(props: Props) { 14 | const searchParams = await props.searchParams; 15 | const redirect = searchParams.redirect; 16 | 17 | return ; 18 | } 19 | -------------------------------------------------------------------------------- /src/app/[locale]/(marketing)/MarketingLayout.tsx: -------------------------------------------------------------------------------- 1 | import Footer from "@/client/components/layout/Footer"; 2 | import Header from "@/client/components/layout/Header"; 3 | 4 | export function MarketingLayout({ children }: { children: React.ReactNode }) { 5 | return ( 6 |
7 |
8 |
9 |
10 | {children} 11 |
12 |
13 |
14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /src/app/[locale]/(marketing)/Page.stories.tsx: -------------------------------------------------------------------------------- 1 | import { MarketingLayout as Layout } from "@/app/[locale]/(marketing)/MarketingLayout"; 2 | import Page from "@/app/[locale]/(marketing)/page"; 3 | import type { Meta, StoryObj } from "@storybook/react"; 4 | 5 | const meta: Meta = { 6 | title: "Pages/Marketing/Home", 7 | component: Page, 8 | argTypes: {}, 9 | render: () => ( 10 | 11 | 12 | 13 | ), 14 | parameters: { 15 | layout: "fullscreen", 16 | }, 17 | }; 18 | 19 | export default meta; 20 | 21 | type Story = StoryObj; 22 | 23 | export const Default: Story = {}; 24 | -------------------------------------------------------------------------------- /src/app/[locale]/(marketing)/about/Page.stories.tsx: -------------------------------------------------------------------------------- 1 | import { MarketingLayout as Layout } from "@/app/[locale]/(marketing)/MarketingLayout"; 2 | import Page from "@/app/[locale]/(marketing)/about/page"; 3 | import type { Meta, StoryObj } from "@storybook/react"; 4 | 5 | const meta: Meta = { 6 | title: "Pages/Marketing/About", 7 | component: Page, 8 | argTypes: {}, 9 | render: () => ( 10 | 11 | 12 | 13 | ), 14 | parameters: { 15 | layout: "fullscreen", 16 | }, 17 | }; 18 | 19 | export default meta; 20 | 21 | type Story = StoryObj; 22 | export const Default: Story = {}; 23 | -------------------------------------------------------------------------------- /src/app/[locale]/(marketing)/about/page.tsx: -------------------------------------------------------------------------------- 1 | export const metadata = { 2 | title: "About", 3 | }; 4 | 5 | export default function Page() { 6 | return

About

; 7 | } 8 | -------------------------------------------------------------------------------- /src/app/[locale]/(marketing)/blog/Page.stories.tsx: -------------------------------------------------------------------------------- 1 | import { MarketingLayout as Layout } from "@/app/[locale]/(marketing)/MarketingLayout"; 2 | import Page from "@/app/[locale]/(marketing)/blog/page"; 3 | import type { Meta, StoryObj } from "@storybook/react"; 4 | 5 | const meta: Meta = { 6 | title: "Pages/Marketing/Blog", 7 | component: Page, 8 | argTypes: {}, 9 | render: () => ( 10 | 11 | 12 | 13 | ), 14 | parameters: { 15 | layout: "fullscreen", 16 | }, 17 | }; 18 | 19 | export default meta; 20 | 21 | type Story = StoryObj; 22 | export const Default: Story = {}; 23 | -------------------------------------------------------------------------------- /src/app/[locale]/(marketing)/blog/page.tsx: -------------------------------------------------------------------------------- 1 | export const metadata = { 2 | title: "Blog", 3 | }; 4 | 5 | export default function Page() { 6 | return <>Blog; 7 | } 8 | -------------------------------------------------------------------------------- /src/app/[locale]/(marketing)/contact/Page.stories.tsx: -------------------------------------------------------------------------------- 1 | import { MarketingLayout as Layout } from "@/app/[locale]/(marketing)/MarketingLayout"; 2 | import Page from "@/app/[locale]/(marketing)/contact/page"; 3 | import type { Meta, StoryObj } from "@storybook/react"; 4 | 5 | const meta: Meta = { 6 | title: "Pages/Marketing/Contact", 7 | component: Page, 8 | argTypes: {}, 9 | render: () => ( 10 | 11 | 12 | 13 | ), 14 | parameters: { 15 | layout: "fullscreen", 16 | }, 17 | }; 18 | 19 | export default meta; 20 | 21 | type Story = StoryObj; 22 | export const Default: Story = {}; 23 | -------------------------------------------------------------------------------- /src/app/[locale]/(marketing)/contact/page.tsx: -------------------------------------------------------------------------------- 1 | export const metadata = { 2 | title: "Contact", 3 | }; 4 | export default function Page() { 5 | return <>Contact; 6 | } 7 | -------------------------------------------------------------------------------- /src/app/[locale]/(marketing)/faq/Page.stories.tsx: -------------------------------------------------------------------------------- 1 | import { MarketingLayout as Layout } from "@/app/[locale]/(marketing)/MarketingLayout"; 2 | import Page from "@/app/[locale]/(marketing)/faq/page"; 3 | import type { Meta, StoryObj } from "@storybook/react"; 4 | 5 | const meta: Meta = { 6 | title: "Pages/Marketing/FAQ", 7 | component: Page, 8 | argTypes: {}, 9 | render: () => ( 10 | 11 | 12 | 13 | ), 14 | parameters: { 15 | layout: "fullscreen", 16 | }, 17 | }; 18 | 19 | export default meta; 20 | 21 | type Story = StoryObj; 22 | export const Default: Story = {}; 23 | -------------------------------------------------------------------------------- /src/app/[locale]/(marketing)/faq/page.tsx: -------------------------------------------------------------------------------- 1 | export const metadata = { 2 | title: "FAQ", 3 | }; 4 | 5 | export default function Page() { 6 | return ( 7 | <> 8 |
9 |
10 |

FAQ

11 |

12 | Frequently asked questions 13 |

14 |
15 |
16 |
17 |
18 | 19 | What is a SAAS platform? 20 | 21 | 32 | 33 | 34 | 35 | 36 |

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 |
41 |
42 |
43 |
44 | 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /src/app/[locale]/(marketing)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { MarketingLayout } from "@/app/[locale]/(marketing)/MarketingLayout"; 2 | 3 | // Uncomment for Cloudflare next-on-pages (required) or Vercel edge 4 | // export const runtime = 'edge'; 5 | 6 | // Using the searchParams Pages prop will opt the page into dynamic rendering at request time. 7 | // https://nextjs.org/docs/app/building-your-application/rendering/static-and-dynamic-rendering#dynamic-functions 8 | 9 | /* 10 | export function generateStaticParams() { 11 | return [{ locale: 'en' }, { locale: 'es' }, { locale: 'ru' }]; 12 | } 13 | */ 14 | 15 | type Props = { 16 | children: React.ReactNode; 17 | params?: { 18 | locale?: string; 19 | }; 20 | }; 21 | 22 | export default function RootLayout({ children }: Props) { 23 | return {children}; 24 | } 25 | -------------------------------------------------------------------------------- /src/app/[locale]/(marketing)/pricing/Page.stories.tsx: -------------------------------------------------------------------------------- 1 | import { MarketingLayout as Layout } from "@/app/[locale]/(marketing)/MarketingLayout"; 2 | import Page from "@/app/[locale]/(marketing)/pricing/page"; 3 | import type { Meta, StoryObj } from "@storybook/react"; 4 | 5 | const meta: Meta = { 6 | title: "Pages/Marketing/Pricing", 7 | component: Page, 8 | argTypes: {}, 9 | render: () => ( 10 | 11 | 12 | 13 | ), 14 | parameters: { 15 | layout: "fullscreen", 16 | }, 17 | }; 18 | 19 | export default meta; 20 | 21 | type Story = StoryObj; 22 | export const Default: Story = {}; 23 | -------------------------------------------------------------------------------- /src/app/[locale]/(marketing)/pricing/page.tsx: -------------------------------------------------------------------------------- 1 | export const metadata = { 2 | title: "Pricing", 3 | }; 4 | 5 | export default function Page() { 6 | return <>Pricing; 7 | } 8 | -------------------------------------------------------------------------------- /src/app/[locale]/(marketing)/privacy/Page.stories.tsx: -------------------------------------------------------------------------------- 1 | import { MarketingLayout as Layout } from "@/app/[locale]/(marketing)/MarketingLayout"; 2 | import Page from "@/app/[locale]/(marketing)/privacy/page"; 3 | import type { Meta, StoryObj } from "@storybook/react"; 4 | 5 | const meta: Meta = { 6 | title: "Pages/Marketing/Privacy", 7 | component: Page, 8 | argTypes: {}, 9 | render: () => ( 10 | 11 | 12 | 13 | ), 14 | parameters: { 15 | layout: "fullscreen", 16 | }, 17 | }; 18 | 19 | export default meta; 20 | 21 | type Story = StoryObj; 22 | export const Default: Story = {}; 23 | -------------------------------------------------------------------------------- /src/app/[locale]/(marketing)/privacy/page.tsx: -------------------------------------------------------------------------------- 1 | export const metadata = { 2 | title: "Privacy", 3 | }; 4 | 5 | export default function Page() { 6 | return ( 7 | <> 8 |
9 |

Privacy Policy

10 |

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 |
55 | 56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /src/app/[locale]/(marketing)/terms/Page.stories.tsx: -------------------------------------------------------------------------------- 1 | import { MarketingLayout as Layout } from "@/app/[locale]/(marketing)/MarketingLayout"; 2 | import Page from "@/app/[locale]/(marketing)/terms/page"; 3 | import type { Meta, StoryObj } from "@storybook/react"; 4 | 5 | const meta: Meta = { 6 | title: "Pages/Marketing/Terms", 7 | component: Page, 8 | argTypes: {}, 9 | render: () => ( 10 | 11 | 12 | 13 | ), 14 | parameters: { 15 | layout: "fullscreen", 16 | }, 17 | }; 18 | 19 | export default meta; 20 | 21 | type Story = StoryObj; 22 | export const Default: Story = {}; 23 | -------------------------------------------------------------------------------- /src/app/[locale]/LocaleLink.tsx: -------------------------------------------------------------------------------- 1 | import { Link, type Locale } from "@/lib/localization/navigation"; 2 | import clsx from "clsx"; 3 | import { useLocale } from "next-intl"; 4 | 5 | type Props = { 6 | locale: Locale; 7 | }; 8 | 9 | export default function LocaleLink({ locale }: Props) { 10 | const curLocale = useLocale(); 11 | 12 | return ( 13 | 21 | {locale.toUpperCase()} 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/app/[locale]/Page.stories.tsx: -------------------------------------------------------------------------------- 1 | import Page from "@/app/[locale]/error"; 2 | import type { Meta, StoryObj } from "@storybook/react"; 3 | 4 | import NextError from "./error"; 5 | import NextGlobalError from "./global-error"; 6 | 7 | // More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction 8 | const meta: Meta = { 9 | title: "Pages/Errors", 10 | component: Page, 11 | argTypes: {}, 12 | parameters: { 13 | layout: "fullscreen", 14 | }, 15 | }; 16 | 17 | export default meta; 18 | 19 | type Story = StoryObj; 20 | export const Error: Story = { 21 | parameters: { 22 | layout: "centered", 23 | }, 24 | render: () => ( 25 | null} 27 | error={{ 28 | name: "MockError", 29 | message: "This is a mock error", 30 | digest: "bla", 31 | }} 32 | /> 33 | ), 34 | }; 35 | 36 | export const GlobalError: Story = { 37 | parameters: { 38 | layout: "centered", 39 | }, 40 | render: () => ( 41 | null} 43 | error={{ 44 | name: "MockError", 45 | message: "This is a mock error", 46 | digest: "bla", 47 | }} 48 | /> 49 | ), 50 | }; 51 | -------------------------------------------------------------------------------- /src/app/[locale]/error.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useLogger } from "next-axiom"; 4 | import { useEffect } from "react"; 5 | 6 | export default function GlobalError({ 7 | error, 8 | reset, 9 | }: { 10 | error: Error & { digest?: string }; 11 | reset: () => void; 12 | }) { 13 | const log = useLogger(); 14 | 15 | useEffect(() => { 16 | // Log the error to an error reporting service 17 | log.error(JSON.stringify(error)); 18 | console.error(`${error.message} ${error.digest}`); 19 | }, [error, log]); 20 | 21 | return ( 22 | 23 | 24 |

Something went wrong!

25 | 26 | 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/app/[locale]/global-error.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useLogger } from "next-axiom"; 4 | import { useEffect } from "react"; 5 | 6 | export default function GlobalError({ 7 | error, 8 | reset, 9 | }: { 10 | error: Error & { digest?: string }; 11 | reset: () => void; 12 | }) { 13 | const log = useLogger(); 14 | 15 | useEffect(() => { 16 | // Log the error to an error reporting service 17 | log.error(JSON.stringify(error)); 18 | console.error(`${error.message} ${error.digest}`); 19 | }, [error, log]); 20 | 21 | return ( 22 | 23 | 24 |

Something went wrong!

25 | 26 | 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/app/[locale]/layout.tsx: -------------------------------------------------------------------------------- 1 | import "@/client/styles/index.css"; 2 | 3 | import { fontSans } from "@/client/styles/fonts"; 4 | import { NextUIWrapper } from "@/client/ui/NextUIWrapper"; 5 | import { ServerAuthProvider } from "@/lib/firebase/auth/server-auth-provider"; 6 | import { locales } from "@/lib/localization/navigation"; 7 | import { timeZone } from "@/lib/localization/request"; 8 | import metadataConfig, { viewportConfig } from "@/metadata.config"; 9 | import clsx from "clsx"; 10 | import { type AbstractIntlMessages, NextIntlClientProvider } from "next-intl"; 11 | import { getMessages } from "next-intl/server"; 12 | import { headers } from "next/headers"; 13 | import { notFound } from "next/navigation"; 14 | 15 | type Props = { 16 | children: React.ReactNode; 17 | params?: Promise<{ 18 | locale?: string; 19 | }>; 20 | }; 21 | 22 | export const viewport = { 23 | ...viewportConfig, 24 | }; 25 | 26 | export const metadata = { 27 | ...metadataConfig, 28 | }; 29 | 30 | export default async function LocaleLayout(props: Props) { 31 | // Await on params, defaulting to an object if undefined 32 | const params = (await props.params) ?? {}; 33 | 34 | const { children } = props; 35 | 36 | const { locale = "en" } = params; 37 | const nonce = (await headers()).get("x-nonce") || undefined; 38 | 39 | // Validate that the incoming `locale` parameter is valid 40 | if (!locales.includes(locale)) notFound(); 41 | 42 | // Providing all messages to the client 43 | // side is the easiest way to get started 44 | const messages = await getMessages(); 45 | 46 | return ( 47 | 48 | 54 | {/* */} 55 | 56 | 61 | 68 | {children} 69 | 70 | 71 | 72 | 73 | 74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /src/app/[locale]/loading.tsx: -------------------------------------------------------------------------------- 1 | // Next.js will automatically use the loading.js to wrap page.js in a boundary. 2 | // https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming#instant-loading-states 3 | // Having one is necessary to protect against urql queries without any suspense wrapper which will infinite loop. 4 | export default function LoadingLayout() { 5 | return null; 6 | } 7 | -------------------------------------------------------------------------------- /src/app/[locale]/~offline/Page.stories.tsx: -------------------------------------------------------------------------------- 1 | import Page from "@/app/[locale]/~offline/page"; 2 | import type { Meta, StoryObj } from "@storybook/react"; 3 | 4 | const meta: Meta = { 5 | title: "Pages/Errors", 6 | component: Page, 7 | argTypes: {}, 8 | render: () => , 9 | parameters: { 10 | layout: "fullscreen", 11 | }, 12 | }; 13 | 14 | export default meta; 15 | 16 | type Story = StoryObj; 17 | export const Offline: Story = {}; 18 | -------------------------------------------------------------------------------- /src/app/[locale]/~offline/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button, Card, CardBody } from "@/client/ui"; 4 | import { useRouter } from "next/navigation"; 5 | 6 | // Uncomment for Cloudflare next-on-pages (required) or Vercel edge 7 | // export const runtime = 'edge'; 8 | 9 | export default function Page() { 10 | const router = useRouter(); 11 | 12 | return ( 13 |
14 |
15 |
16 | 17 | 18 |
19 |
Network Unavailable.
20 | Please retry page when network returns. 21 |
22 | 23 |
24 | 27 |
28 |
29 |
30 |
31 |
32 |
33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /src/app/api/error/route.ts: -------------------------------------------------------------------------------- 1 | // A faulty API route to test error monitoring 2 | 3 | // Uncomment for Cloudflare next-on-pages (required) or Vercel edge 4 | // export const runtime = 'edge'; 5 | 6 | export const dynamic = "force-dynamic"; 7 | 8 | export function GET() { 9 | throw new Error("Example API Route Error"); 10 | 11 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 12 | // @ts-ignore 13 | return new Response(JSON.stringify({ working: true }), { 14 | status: 200, 15 | headers: { 16 | "content-type": "application/json", 17 | }, 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /src/app/api/graphql/route.ts: -------------------------------------------------------------------------------- 1 | import { graphqlServer } from "@/server/graphql/server"; 2 | import type { NextRequest } from "next/server"; 3 | 4 | // Uncomment for Cloudflare next-on-pages (required) or Vercel edge 5 | // export const runtime = 'edge'; 6 | 7 | export const dynamic = "force-dynamic"; 8 | 9 | const { handleRequest } = graphqlServer("/api/graphql"); 10 | 11 | export const GET = (request: NextRequest) => { 12 | return handleRequest(request, { 13 | context: (request: NextRequest) => ({ request }), 14 | }); 15 | }; 16 | 17 | export const POST = (request: NextRequest) => { 18 | return handleRequest(request, { 19 | context: (request: NextRequest) => ({ request }), 20 | }); 21 | }; 22 | -------------------------------------------------------------------------------- /src/app/api/health/route.ts: -------------------------------------------------------------------------------- 1 | // https://github.com/echobind/bisonapp/blob/canary/packages/create-bison-app/template/src/pages/api/health.ts 2 | 3 | // Uncomment for Cloudflare next-on-pages (required) or Vercel edge 4 | // export const runtime = 'edge'; 5 | 6 | export const dynamic = "force-dynamic"; 7 | 8 | export function GET() { 9 | let databaseWorking = false; 10 | 11 | try { 12 | // TODO fill this in with something appropriate for serverless db 13 | databaseWorking = true; 14 | } catch (err) {} 15 | 16 | const data = { 17 | status: { 18 | database: databaseWorking, 19 | }, 20 | }; 21 | 22 | const healthy = databaseWorking; 23 | 24 | const statusCode = healthy ? 200 : 503; 25 | 26 | return new Response(JSON.stringify(data), { 27 | status: statusCode, 28 | headers: { 29 | "content-type": "application/json", 30 | }, 31 | }); 32 | } 33 | -------------------------------------------------------------------------------- /src/app/global-error.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as Sentry from "@sentry/nextjs"; 4 | import type Error from "next/error"; 5 | import { useEffect } from "react"; 6 | 7 | export default function GlobalError({ 8 | error, 9 | reset, 10 | }: { 11 | error: Error & { digest?: string }; 12 | reset: () => void; 13 | }) { 14 | useEffect(() => { 15 | Sentry.captureException(error); 16 | }, [error]); 17 | 18 | return ( 19 | 20 | 21 |

Something went wrong!

22 | 23 | 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/app/shared/useRedirectAfterLogin.ts: -------------------------------------------------------------------------------- 1 | import { useRouter } from "next/navigation"; 2 | import { useRedirectParam } from "./useRedirectParam"; 3 | 4 | export function useRedirectAfterLogin() { 5 | const router = useRouter(); 6 | const redirect = useRedirectParam(); 7 | 8 | return () => { 9 | router.push(redirect ?? "/app"); 10 | router.refresh(); 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /src/app/shared/useRedirectParam.ts: -------------------------------------------------------------------------------- 1 | import { useSearchParams } from "next/navigation"; 2 | 3 | export function useRedirectParam(): string | null { 4 | const params = useSearchParams(); 5 | 6 | return params?.get("redirect") ?? null; 7 | } 8 | -------------------------------------------------------------------------------- /src/app/sw.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { defaultCache } from "@serwist/next/worker"; 3 | import type { PrecacheEntry, SerwistGlobalConfig } from "serwist"; 4 | import { Serwist } from "serwist"; 5 | 6 | // This declares the value of `injectionPoint` to TypeScript. 7 | // `injectionPoint` is the string that will be replaced by the 8 | // actual precache manifest. By default, this string is set to 9 | // `"self.__SW_MANIFEST"`. 10 | declare global { 11 | interface WorkerGlobalScope extends SerwistGlobalConfig { 12 | __SW_MANIFEST: (PrecacheEntry | string)[] | undefined; 13 | } 14 | } 15 | 16 | declare const self: ServiceWorkerGlobalScope; 17 | 18 | const serwist = new Serwist({ 19 | precacheEntries: self.__SW_MANIFEST, 20 | skipWaiting: true, 21 | clientsClaim: true, 22 | navigationPreload: true, 23 | runtimeCaching: defaultCache, 24 | }); 25 | 26 | serwist.addEventListeners(); 27 | -------------------------------------------------------------------------------- /src/client/admin/edit/EditCard.tsx: -------------------------------------------------------------------------------- 1 | import FormError from "@/client/admin/edit/FormError"; 2 | import CancelButton from "@/client/components/admin/buttons/CancelButton"; 3 | import DeleteButton from "@/client/components/admin/buttons/DeleteButton"; 4 | import { extractErrorMessages } from "@/client/gql/errorHandling"; 5 | import { Button, Card, CardBody } from "@/client/ui"; 6 | import type { ReactNode } from "react"; 7 | import type { FieldValues, UseFormHandleSubmit } from "react-hook-form"; 8 | 9 | interface EditCardProps { 10 | save: boolean; 11 | errors: unknown[]; 12 | handleSubmit: UseFormHandleSubmit; 13 | onSubmit: (formData: T) => Promise; 14 | children: ReactNode; 15 | isSubmitting: boolean; 16 | handleDelete: () => Promise; 17 | deleteFetching?: boolean; 18 | enableDelete?: boolean; 19 | reUrl: string; 20 | } 21 | 22 | const EditCard = (props: EditCardProps) => { 23 | const { 24 | save, 25 | errors, 26 | handleSubmit, 27 | onSubmit, 28 | children, 29 | isSubmitting, 30 | handleDelete, 31 | deleteFetching, 32 | enableDelete = true, 33 | reUrl, 34 | } = props; 35 | 36 | return ( 37 | 38 | 39 |
40 | 41 | 42 |
void handleSubmit(onSubmit)(event)}> 43 | {children} 44 | 45 |
46 |
47 | 55 | 56 | 57 |
58 | 59 | {enableDelete && save && ( 60 | void handleDelete()} 62 | isLoading={deleteFetching} 63 | /> 64 | )} 65 |
66 |
67 |
68 |
69 |
70 | ); 71 | }; 72 | 73 | export default EditCard; 74 | -------------------------------------------------------------------------------- /src/client/admin/edit/FormError.tsx: -------------------------------------------------------------------------------- 1 | interface FormErrorProps { 2 | errors: string[]; 3 | } 4 | 5 | const FormError = ({ errors }: FormErrorProps) => { 6 | if (!errors || errors.length === 0) { 7 | return null; // Render nothing if there are no errors 8 | } 9 | 10 | return ( 11 |
12 |
13 |
14 | 15 | 16 | 21 | 22 | 23 |
24 |
25 |
26 | Error 27 |
28 |
29 |
    30 | {errors.map((errorMessage, index) => ( 31 |
  • {errorMessage}
  • 32 | ))} 33 |
34 |
35 |
36 |
37 |
38 | ); 39 | }; 40 | 41 | export default FormError; 42 | -------------------------------------------------------------------------------- /src/client/admin/entity/task/RenderRows.tsx: -------------------------------------------------------------------------------- 1 | /* clone-code ENTITY_HOOK 2 | { 3 | "toFile": "src/client/admin/entity/<%= h.inflection.pluralize(h.changeCase.camelCase(name)) %>/RenderRows.tsx", 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 | /* eslint-disable no-console,@typescript-eslint/no-unsafe-assignment */ 12 | import { getAuditProps } from "@/client/admin/table/FormHelpers"; 13 | import Chip from "@/client/components/admin/Chip"; 14 | import { type Task, TaskStatus } from "@/client/gql/generated/graphql"; 15 | import type { TableColumnProps } from "@enalmada/nextui-admin"; 16 | import { Link } from "@nextui-org/react"; 17 | 18 | export const columnProps: TableColumnProps[] = [ 19 | { key: "title", allowsSorting: true }, 20 | { key: "description" }, 21 | { 22 | key: "status", 23 | allowsSorting: true, 24 | renderCell: (task: Task) => ( 25 | 29 | ), 30 | }, 31 | { key: "dueDate", allowsSorting: true }, 32 | { 33 | key: "user", 34 | renderCell: (task: Task) => ( 35 | {task?.user?.email} 36 | ), 37 | }, 38 | ...getAuditProps(), 39 | ]; 40 | -------------------------------------------------------------------------------- /src/client/admin/entity/task/TaskTable.tsx: -------------------------------------------------------------------------------- 1 | /* clone-code ENTITY_HOOK 2 | { 3 | "toFile": "src/app/client/admin/<%= h.changeCase.camelCase(name)) %>/<%= h.changeCase.pascalCase(name) %>Table.tsx", 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 | "use client"; 12 | 13 | import type { FormFieldConfig } from "@/client/admin/edit/formGeneration"; 14 | import AdminTable from "@/client/admin/table/AdminTable"; 15 | import { ADMIN_TASKS_PAGE } from "@/client/gql/admin-queries.gql"; 16 | import type { 17 | AdminTasksPageQuery, 18 | AdminTasksPageQueryVariables, 19 | Task, 20 | TaskWhere, 21 | } from "@/client/gql/generated/graphql"; 22 | 23 | import { columnProps } from "./RenderRows"; 24 | 25 | interface Props { 26 | loading?: boolean; 27 | } 28 | 29 | export const TaskTable = (props: Props) => { 30 | const inputConfig: FormFieldConfig[] = [ 31 | { 32 | key: "id", 33 | }, 34 | { 35 | key: "title", 36 | }, 37 | { 38 | key: "userId", 39 | }, 40 | ]; 41 | 42 | return ( 43 | 49 | inputConfig={inputConfig} 50 | query={ADMIN_TASKS_PAGE} 51 | columnProps={columnProps} 52 | loading={props.loading} 53 | basePath={"/admin/task"} 54 | pageName={"tasksPage"} 55 | entityKey={"tasks"} 56 | /> 57 | ); 58 | }; 59 | -------------------------------------------------------------------------------- /src/client/admin/entity/user/RenderRows.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console,@typescript-eslint/no-unsafe-assignment */ 2 | import { getAuditProps } from "@/client/admin/table/FormHelpers"; 3 | import Chip from "@/client/components/admin/Chip"; 4 | import { type User, UserRole } from "@/client/gql/generated/graphql"; 5 | import type { TableColumnProps } from "@enalmada/nextui-admin"; 6 | import { Link, User as UserChip } from "@nextui-org/react"; 7 | import gravatarUrl from "gravatar-url"; 8 | 9 | export const columnProps: TableColumnProps[] = [ 10 | { 11 | key: "avatar", 12 | renderCell: (user: User) => ( 13 | 20 | ), 21 | }, 22 | { 23 | key: "role", 24 | renderCell: (user: User) => ( 25 | 29 | ), 30 | }, 31 | { 32 | key: "tasks", 33 | renderCell: (user: User) => ( 34 | 35 | {user.tasks?.length} Tasks 36 | 37 | ), 38 | }, 39 | ...getAuditProps(), 40 | ]; 41 | -------------------------------------------------------------------------------- /src/client/admin/entity/user/UserTable.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import type { FormFieldConfig } from "@/client/admin/edit/formGeneration"; 4 | import AdminTable from "@/client/admin/table/AdminTable"; 5 | import { ADMIN_USERS_PAGE } from "@/client/gql/admin-queries.gql"; 6 | import type { 7 | AdminUsersPageQuery, 8 | AdminUsersPageQueryVariables, 9 | User, 10 | UserWhere, 11 | } from "@/client/gql/generated/graphql"; 12 | 13 | import { columnProps } from "./RenderRows"; 14 | 15 | interface Props { 16 | loading?: boolean; 17 | } 18 | 19 | export const UserTable = (props: Props) => { 20 | const inputConfig: FormFieldConfig[] = [ 21 | { 22 | key: "id", 23 | }, 24 | { 25 | key: "email", 26 | }, 27 | ]; 28 | 29 | return ( 30 | 36 | inputConfig={inputConfig} 37 | query={ADMIN_USERS_PAGE} 38 | columnProps={columnProps} 39 | loading={props.loading} 40 | basePath={"/admin/user"} 41 | pageName={"usersPage"} 42 | entityKey={"users"} 43 | /> 44 | ); 45 | }; 46 | -------------------------------------------------------------------------------- /src/client/admin/table/FormErrors.tsx: -------------------------------------------------------------------------------- 1 | import type { FieldErrors } from "react-hook-form"; 2 | 3 | interface FormErrorProps { 4 | errors: FieldErrors; 5 | } 6 | 7 | const FormErrors = ({ errors }: FormErrorProps) => { 8 | if (errors.root) { 9 | return ( 10 |
14 | Error {errors.root.message} 15 |
16 | ); 17 | } 18 | return null; 19 | }; 20 | 21 | export default FormErrors; 22 | -------------------------------------------------------------------------------- /src/client/admin/table/FormHelpers.tsx: -------------------------------------------------------------------------------- 1 | import Auditing, { 2 | type AuditedEntity, 3 | } from "@/client/components/admin/Auditing"; 4 | import Edit from "@/client/components/admin/Edit"; 5 | import type { TableColumnProps } from "@enalmada/nextui-admin"; 6 | 7 | // Define a function that returns the array and takes a generic type T 8 | export function getAuditProps< 9 | T extends AuditedEntity, 10 | >(): TableColumnProps[] { 11 | return [ 12 | { 13 | key: "auditing", 14 | // Ensure the type of 'task' is correctly inferred as T 15 | renderCell: (entity: T) => , 16 | }, 17 | { 18 | key: "actions", 19 | header: null, 20 | align: "end", 21 | renderCell: () => , 22 | }, 23 | ]; 24 | } 25 | -------------------------------------------------------------------------------- /src/client/admin/table/useAdminPageQuery.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-argument */ 2 | import { useQuery } from "@enalmada/next-gql/client"; 3 | import type { PageDescriptor, SortDescriptor } from "@enalmada/nextui-admin"; 4 | import type { DocumentNode } from "graphql"; 5 | 6 | type UseAdminPageQueryProps = { 7 | input?: T; 8 | sortDescriptor: SortDescriptor; 9 | pageDescriptor: PageDescriptor; 10 | pause?: boolean; 11 | }; 12 | 13 | export const useAdminPageQuery = ( 14 | query: DocumentNode, 15 | { input, sortDescriptor, pageDescriptor }: UseAdminPageQueryProps, 16 | config?: any, 17 | ) => { 18 | const [{ data, fetching, error }] = useQuery({ 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 |
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 |
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 | 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 | 6 |
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 | 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 |
{children}
11 | 12 |
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 | 10 |
11 |
{children}
12 |