├── .editorconfig ├── .env.sample ├── .env.test ├── .eslintignore ├── .eslintrc.json ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── .npmrc ├── ava.config.js ├── db └── migrations │ ├── .snapshot-next-mikro-orm-trpc-template.json │ └── Migration20230130005748.ts ├── docker-compose.test.yml ├── env.d.ts ├── next.config.js ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── public ├── favicon.ico └── vercel.svg ├── quick-demo.Dockerfile ├── quick-demo.sh ├── readme.md ├── src ├── components │ ├── Anchor │ │ ├── Anchor.tsx │ │ ├── ExternalAnchor.tsx │ │ └── index.tsx │ ├── Button.tsx │ ├── Card.tsx │ ├── ConfirmationDialog.tsx │ ├── FloatingButton.tsx │ ├── Modal │ │ ├── Modal.tsx │ │ ├── ModalContext.tsx │ │ ├── ModalPanel.tsx │ │ ├── ModalTitle.tsx │ │ └── index.tsx │ ├── NoteCard.tsx │ ├── NoteCompleteButton.tsx │ ├── NoteControls │ │ ├── NoteControls.tsx │ │ ├── PauseButton.tsx │ │ ├── PlayButton.tsx │ │ ├── StopButton.tsx │ │ ├── createControlButton.tsx │ │ └── index.tsx │ ├── NoteModal │ │ ├── NoteCreateModal │ │ │ ├── NoteCreateModal.tsx │ │ │ ├── Open.tsx │ │ │ └── index.tsx │ │ ├── NoteUpdateModal │ │ │ ├── NoteUpdateModal.tsx │ │ │ ├── Open.tsx │ │ │ └── index.tsx │ │ ├── createNoteModal.tsx │ │ └── index.tsx │ ├── NoteRemoveDialog │ │ ├── Cancel.tsx │ │ ├── Confirm.tsx │ │ ├── NoteRemoveDialog.tsx │ │ ├── Open.tsx │ │ ├── Options.tsx │ │ ├── Warning.tsx │ │ └── index.tsx │ └── Select │ │ ├── Select.tsx │ │ ├── index.tsx │ │ └── select.module.css ├── contexts │ ├── FakeNotesContext.tsx │ ├── NoteStateContext.tsx │ └── NotesStateContext.tsx ├── layouts │ └── BaseLayout.tsx ├── lib │ ├── contexts │ │ └── createStateContext.tsx │ ├── hooks │ │ └── useSearchParams.ts │ ├── trpc │ │ ├── client.ts │ │ └── server.ts │ ├── types │ │ ├── Maybe.ts │ │ ├── MaybeNull.ts │ │ ├── MaybePromise.ts │ │ ├── MaybeUndefined.ts │ │ ├── PageDataProps.ts │ │ ├── PageProps.ts │ │ ├── PickKeys.ts │ │ ├── PickRequiredKeys.ts │ │ ├── RawDate.ts │ │ └── ReactSelectOption.ts │ └── utils │ │ ├── createPagePropsLoader.ts │ │ ├── formatRelative.ts │ │ ├── formatTime.ts │ │ ├── getEmptyPaths.ts │ │ ├── getPageDataStaticProps.ts │ │ ├── globalObject.ts │ │ ├── isInternalUrl.ts │ │ ├── patchNoteStatus.ts │ │ ├── patchStaticPaths.ts │ │ └── serverAddress.ts ├── pages │ ├── 404.tsx │ ├── _app.tsx │ ├── api │ │ └── trpc │ │ │ └── [trpc].ts │ ├── index.tsx │ └── view │ │ └── [id].tsx ├── server │ ├── __helpers__ │ │ ├── database.ts │ │ └── polyfills.ts │ ├── __macros__ │ │ ├── withORM.ts │ │ └── withTRPC.ts │ ├── db │ │ └── entities │ │ │ ├── Completion.ts │ │ │ ├── Node.ts │ │ │ ├── Note.ts │ │ │ ├── Record.ts │ │ │ └── index.ts │ ├── lib │ │ ├── db │ │ │ ├── cli.ts │ │ │ ├── config.ts │ │ │ └── orm.ts │ │ ├── env.ts │ │ ├── types │ │ │ ├── FilterEntity.ts │ │ │ └── Middleware.ts │ │ └── utils │ │ │ └── assertRequiredEnv.ts │ └── trpc │ │ ├── context.ts │ │ ├── def.ts │ │ ├── errors │ │ └── notFound.ts │ │ ├── helpers │ │ ├── Page.test.ts │ │ ├── Page.ts │ │ ├── PageArgs.test.ts │ │ ├── PageArgs.ts │ │ ├── createCollectionOutput.ts │ │ ├── createPageInput.ts │ │ └── createPageOutput.ts │ │ ├── middlewares │ │ ├── withHttpContext.ts │ │ ├── withOrmContext.ts │ │ └── withPageAssert.ts │ │ ├── procedures │ │ ├── base.ts │ │ └── server.ts │ │ ├── router.ts │ │ ├── routes │ │ ├── note │ │ │ ├── create.test.ts │ │ │ ├── create.ts │ │ │ ├── getById.test.ts │ │ │ ├── getById.ts │ │ │ ├── index.ts │ │ │ ├── remove.test.ts │ │ │ ├── remove.ts │ │ │ ├── restore.test.ts │ │ │ ├── restore.ts │ │ │ ├── update.test.ts │ │ │ └── update.ts │ │ └── notes │ │ │ ├── index.ts │ │ │ ├── list.empty.test.ts │ │ │ ├── list.test.ts │ │ │ └── list.ts │ │ └── types │ │ ├── common │ │ ├── DateTime.test.ts │ │ ├── DateTime.ts │ │ ├── ID.ts │ │ ├── Node.test.ts │ │ ├── Node.ts │ │ ├── NoteStatus.test.ts │ │ ├── NoteStatus.ts │ │ ├── NoteStatusFilter.ts │ │ ├── Record.test.ts │ │ └── Record.ts │ │ ├── completion │ │ ├── CompletionCreateInput.ts │ │ ├── CompletionOutput.ts │ │ └── CompletionUpdateInput.ts │ │ └── note │ │ ├── NoteBaseOutput.ts │ │ ├── NoteCreateInput.ts │ │ ├── NoteOutput.ts │ │ ├── NoteRemoveInput.ts │ │ ├── NoteUpdateInput.ts │ │ ├── NotesPageInput.ts │ │ ├── NotesPageOutput.ts │ │ └── RemoveOutput.ts ├── styles │ ├── global.css │ └── tailwind.css └── views │ ├── NoteView │ ├── NoteDetails.tsx │ ├── NoteDetailsContent.tsx │ ├── NoteFooter.tsx │ ├── NoteInfo.tsx │ ├── NoteNav.tsx │ ├── NoteNoDetails.tsx │ ├── NoteRestoreButton.tsx │ ├── NoteTitle.tsx │ ├── NoteView.tsx │ └── index.tsx │ └── NotesView │ ├── NotesEmpty.tsx │ ├── NotesList.tsx │ ├── NotesTab.tsx │ ├── NotesTabs.tsx │ ├── NotesView.tsx │ └── index.tsx ├── tailwind.config.js ├── tsconfig.ava.json └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | 7 | [*.{ts,tsx,json,md,gitignore,npmignore,lintstagedrc}] 8 | charset = utf-8 9 | indent_style = space 10 | indent_size = 2 11 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | # Sample env file 2 | MIKRO_ORM_DB_NAME= 3 | MIKRO_ORM_HOST= 4 | MIKRO_ORM_PORT= 5 | MIKRO_ORM_USER= 6 | MIKRO_ORM_PASSWORD= 7 | SERVER_URL= 8 | -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | MIKRO_ORM_PORT=3308 2 | MIKRO_ORM_USER=root 3 | 4 | SERVER_URL=http://localhost:3000 5 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /db 3 | 4 | *.js 5 | *.mjs 6 | *.cjs 7 | *.d.ts 8 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@octetstream/eslint-config/typescript/react", 4 | "@octetstream/eslint-config/typescript/ava" 5 | ], 6 | "rules": { 7 | "no-console": "off", 8 | "no-shadow": "off" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | paths: 7 | - "**.ts" 8 | - "**.tsx" 9 | - "tsconfig.json" 10 | - "ava.config.js" 11 | - "next.config.js" 12 | - "postcss.config.js" 13 | - "tailwind.config.js" 14 | - "package.json" 15 | - "pnpm-lock.yaml" 16 | - "docker-compose.test.yml" 17 | - ".github/workflows/ci.yml" 18 | - ".c8rc.json" 19 | pull_request: 20 | branches: [main] 21 | paths: 22 | - "**.ts" 23 | - "**.tsx" 24 | - "tsconfig.json" 25 | - "ava.config.js" 26 | - "next.config.js" 27 | - "postcss.config.js" 28 | - "tailwind.config.js" 29 | - "package.json" 30 | - "pnpm-lock.yaml" 31 | - "docker-compose.test.yml" 32 | - ".github/workflows/ci.yml" 33 | - ".c8rc.json" 34 | 35 | jobs: 36 | test: 37 | name: Test 38 | runs-on: ubuntu-latest 39 | strategy: 40 | matrix: 41 | node: [18.x, 20.x, 21.x] 42 | steps: 43 | - name: Checkout code 44 | uses: actions/checkout@v4 45 | 46 | - name: Setup Node.js v${{matrix.node}} 47 | uses: actions/setup-node@v4 48 | with: 49 | node-version: ${{matrix.node}} 50 | 51 | - name: Setup pnpm 52 | id: pnpm-install 53 | uses: pnpm/action-setup@v3 54 | with: 55 | version: 8 56 | run_install: false 57 | 58 | - name: Get pnpm store directory 59 | id: pnpm-cache 60 | shell: bash 61 | run: | 62 | echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT 63 | 64 | - uses: actions/cache@v4 65 | name: Setup pnpm cache 66 | with: 67 | path: ${{steps.pnpm-cache.outputs.STORE_PATH}} 68 | key: ${{runner.os}}-pnpm-store-${{hashFiles('**/pnpm-lock.yaml')}} 69 | restore-keys: | 70 | ${{runner.os}}-pnpm-store- 71 | 72 | - name: Install dependencies 73 | run: pnpm i --frozen-lockfile 74 | 75 | - name: Setup docker compose 76 | run: docker compose -f docker-compose.test.yml up --wait 77 | 78 | - name: Run tests 79 | run: pnpm run ci 80 | 81 | - name: Teardown docker compose 82 | run: docker compose -f docker-compose.test.yml down 83 | 84 | - name: Upload codecov report 85 | uses: codecov/codecov-action@v4 86 | env: 87 | CODECOV_TOKEN: ${{secrets.CODECOV_TOKEN}} 88 | if: matrix.node == '18.x' 89 | with: 90 | file: ./coverage/coverage-final.json 91 | flags: unittests 92 | fail_ci_if_error: false 93 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | strict-peer-dependencies=false 2 | -------------------------------------------------------------------------------- /ava.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | failFast: true, 3 | extensions: ["ts", "tsx"], 4 | files: ["src/**/*.test.{ts,tsx}"], 5 | require: [ 6 | "global-jsdom/register", 7 | "ts-node/register/transpile-only", 8 | "reflect-metadata", 9 | "./src/server/lib/env.ts", 10 | "./src/server/__helpers__/polyfills.ts" 11 | ], 12 | environmentVariables: { 13 | "TS_NODE_PROJECT": "tsconfig.ava.json" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /db/migrations/.snapshot-next-mikro-orm-trpc-template.json: -------------------------------------------------------------------------------- 1 | { 2 | "namespaces": [], 3 | "tables": [ 4 | { 5 | "columns": { 6 | "id": { 7 | "name": "id", 8 | "type": "varchar(255)", 9 | "unsigned": false, 10 | "autoincrement": false, 11 | "primary": false, 12 | "nullable": false, 13 | "mappedType": "string" 14 | }, 15 | "created_at": { 16 | "name": "created_at", 17 | "type": "datetime", 18 | "unsigned": false, 19 | "autoincrement": false, 20 | "primary": false, 21 | "nullable": false, 22 | "length": 0, 23 | "mappedType": "datetime" 24 | }, 25 | "updated_at": { 26 | "name": "updated_at", 27 | "type": "datetime", 28 | "unsigned": false, 29 | "autoincrement": false, 30 | "primary": false, 31 | "nullable": false, 32 | "length": 0, 33 | "mappedType": "datetime" 34 | }, 35 | "title": { 36 | "name": "title", 37 | "type": "varchar(255)", 38 | "unsigned": false, 39 | "autoincrement": false, 40 | "primary": false, 41 | "nullable": false, 42 | "length": 255, 43 | "mappedType": "string" 44 | }, 45 | "details": { 46 | "name": "details", 47 | "type": "text", 48 | "unsigned": false, 49 | "autoincrement": false, 50 | "primary": false, 51 | "nullable": true, 52 | "mappedType": "text" 53 | }, 54 | "status": { 55 | "name": "status", 56 | "type": "enum('incompleted', 'completed', 'in_progress', 'paused', 'rejected')", 57 | "unsigned": false, 58 | "autoincrement": false, 59 | "primary": false, 60 | "nullable": false, 61 | "default": "'incompleted'", 62 | "enumItems": [ 63 | "incompleted", 64 | "completed", 65 | "in_progress", 66 | "paused", 67 | "rejected" 68 | ], 69 | "mappedType": "enum" 70 | } 71 | }, 72 | "name": "note", 73 | "indexes": [ 74 | { 75 | "keyName": "PRIMARY", 76 | "columnNames": [ 77 | "id" 78 | ], 79 | "composite": false, 80 | "primary": true, 81 | "unique": true 82 | } 83 | ], 84 | "checks": [], 85 | "foreignKeys": {} 86 | }, 87 | { 88 | "columns": { 89 | "id": { 90 | "name": "id", 91 | "type": "varchar(255)", 92 | "unsigned": false, 93 | "autoincrement": false, 94 | "primary": false, 95 | "nullable": false, 96 | "mappedType": "string" 97 | }, 98 | "created_at": { 99 | "name": "created_at", 100 | "type": "datetime", 101 | "unsigned": false, 102 | "autoincrement": false, 103 | "primary": false, 104 | "nullable": false, 105 | "length": 0, 106 | "mappedType": "datetime" 107 | }, 108 | "updated_at": { 109 | "name": "updated_at", 110 | "type": "datetime", 111 | "unsigned": false, 112 | "autoincrement": false, 113 | "primary": false, 114 | "nullable": false, 115 | "length": 0, 116 | "mappedType": "datetime" 117 | }, 118 | "details": { 119 | "name": "details", 120 | "type": "text", 121 | "unsigned": false, 122 | "autoincrement": false, 123 | "primary": false, 124 | "nullable": false, 125 | "mappedType": "text" 126 | }, 127 | "completed": { 128 | "name": "completed", 129 | "type": "tinyint(1)", 130 | "unsigned": false, 131 | "autoincrement": false, 132 | "primary": false, 133 | "nullable": false, 134 | "default": "false", 135 | "mappedType": "boolean" 136 | }, 137 | "note_id": { 138 | "name": "note_id", 139 | "type": "varchar(255)", 140 | "unsigned": false, 141 | "autoincrement": false, 142 | "primary": false, 143 | "nullable": false, 144 | "mappedType": "string" 145 | } 146 | }, 147 | "name": "completion", 148 | "indexes": [ 149 | { 150 | "columnNames": [ 151 | "note_id" 152 | ], 153 | "composite": false, 154 | "keyName": "completion_note_id_index", 155 | "primary": false, 156 | "unique": false 157 | }, 158 | { 159 | "keyName": "PRIMARY", 160 | "columnNames": [ 161 | "id" 162 | ], 163 | "composite": false, 164 | "primary": true, 165 | "unique": true 166 | } 167 | ], 168 | "checks": [], 169 | "foreignKeys": { 170 | "completion_note_id_foreign": { 171 | "constraintName": "completion_note_id_foreign", 172 | "columnNames": [ 173 | "note_id" 174 | ], 175 | "localTableName": "completion", 176 | "referencedColumnNames": [ 177 | "id" 178 | ], 179 | "referencedTableName": "note", 180 | "deleteRule": "cascade", 181 | "updateRule": "cascade" 182 | } 183 | } 184 | } 185 | ] 186 | } 187 | -------------------------------------------------------------------------------- /db/migrations/Migration20230130005748.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from '@mikro-orm/migrations'; 2 | 3 | export class Migration20230130005748 extends Migration { 4 | 5 | async up(): Promise { 6 | this.addSql('create table `note` (`id` varchar(255) not null, `created_at` datetime not null, `updated_at` datetime not null, `title` varchar(255) not null, `details` text null, `status` enum(\'incompleted\', \'completed\', \'in_progress\', \'paused\', \'rejected\') not null default \'incompleted\', primary key (`id`)) default character set utf8mb4 engine = InnoDB;'); 7 | 8 | this.addSql('create table `completion` (`id` varchar(255) not null, `created_at` datetime not null, `updated_at` datetime not null, `details` text not null, `completed` tinyint(1) not null default false, `note_id` varchar(255) not null, primary key (`id`)) default character set utf8mb4 engine = InnoDB;'); 9 | this.addSql('alter table `completion` add index `completion_note_id_index`(`note_id`);'); 10 | 11 | this.addSql('alter table `completion` add constraint `completion_note_id_foreign` foreign key (`note_id`) references `note` (`id`) on update cascade on delete cascade;'); 12 | } 13 | 14 | } 15 | -------------------------------------------------------------------------------- /docker-compose.test.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | mysql: 5 | image: mysql:8-oracle 6 | restart: unless-stopped 7 | healthcheck: 8 | test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root"] 9 | interval: 10s 10 | retries: 5 11 | ports: 12 | - "3308:3306" 13 | environment: 14 | MYSQL_ALLOW_EMPTY_PASSWORD: 1 15 | command: ["mysqld", "--default-authentication-plugin=mysql_native_password"] 16 | volumes: 17 | - mysql:/var/lib/mysql 18 | 19 | volumes: 20 | mysql: 21 | driver_opts: 22 | type: tmpfs 23 | device: tmpfs 24 | -------------------------------------------------------------------------------- /env.d.ts: -------------------------------------------------------------------------------- 1 | namespace NodeJS { 2 | interface ProcessEnv { 3 | MIKRO_ORM_DB_NAME: string 4 | MIKRO_ORM_HOST?: string 5 | MIKRO_ORM_PORT?: string 6 | MIKRO_ORM_USER: string 7 | MIKRO_ORM_PASSWORD: string 8 | RUNS_IN_DOCKER?: string 9 | SERVER_URL: string 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const config = {} 3 | 4 | if (process.env.NEXT_RUNS_IN_DOCKER) { 5 | config.output = "standalone" 6 | } 7 | 8 | module.exports = config 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-mikro-orm-trpc-template", 3 | "description": "Yet another project template", 4 | "author": "Nick K. ", 5 | "license": "MIT", 6 | "repository": "octet-stream/next-mikro-orm-trpc-template", 7 | "private": true, 8 | "scripts": { 9 | "dev": "next dev", 10 | "build": "next build", 11 | "start": "next start", 12 | "demo": "./quick-demo.sh", 13 | "lint": "next lint", 14 | "lint:types": "tsc --noEmit", 15 | "test:compose-up": "docker compose -f docker-compose.test.yml up --wait --remove-orphans", 16 | "test:compose-down": "docker compose -f docker-compose.test.yml down", 17 | "test": "pnpm test:compose-up && (ava ; pnpm test:compose-down)", 18 | "coverage": "pnpm test:compose-up && (c8 ava ; pnpm test:compose-down)", 19 | "report:html": "pnpm test:compose-up && ((c8 ava && c8 report --reporter=html) ; pnpm test:compose-down)", 20 | "ci": "c8 ava && c8 report --reporter=json" 21 | }, 22 | "mikro-orm": { 23 | "useTsNode": true, 24 | "configPaths": [ 25 | "./src/server/lib/db/cli.ts" 26 | ] 27 | }, 28 | "pnpm": { 29 | "updateConfig": { 30 | "ignoreDependencies": [ 31 | "nanoid", 32 | "is-absolute-url", 33 | "superjson" 34 | ] 35 | } 36 | }, 37 | "devDependencies": { 38 | "@headlessui/tailwindcss": "0.2.0", 39 | "@mikro-orm/cli": "6.1.6", 40 | "@mikro-orm/migrations": "6.1.6", 41 | "@mikro-orm/seeder": "6.1.6", 42 | "@next/env": "14.1.0", 43 | "@octetstream/eslint-config": "8.1.0", 44 | "@tailwindcss/typography": "0.5.10", 45 | "@types/lodash": "4.14.202", 46 | "@types/node": "20.11.23", 47 | "@types/react": "18.2.61", 48 | "@types/react-dom": "18.2.19", 49 | "@types/sinon": "17.0.3", 50 | "@types/validator": "13.11.9", 51 | "@typescript-eslint/eslint-plugin": "7.1.0", 52 | "@typescript-eslint/parser": "7.1.0", 53 | "autoprefixer": "10.4.17", 54 | "ava": "6.1.2", 55 | "c8": "9.1.0", 56 | "eslint": "8.57.0", 57 | "eslint-config-airbnb": "19.0.4", 58 | "eslint-config-airbnb-typescript": "17.1.0", 59 | "eslint-config-next": "14.1.0", 60 | "eslint-import-resolver-typescript": "3.6.1", 61 | "eslint-plugin-import": "2.29.1", 62 | "eslint-plugin-jsx-a11y": "6.8.0", 63 | "eslint-plugin-react": "7.33.2", 64 | "eslint-plugin-react-hooks": "4.6.0", 65 | "global-jsdom": "24.0.0", 66 | "jsdom": "24.0.0", 67 | "postcss": "8.4.35", 68 | "sinon": "17.0.1", 69 | "tailwindcss": "3.4.1", 70 | "ts-node": "10.9.2", 71 | "tsconfig-paths": "4.2.0", 72 | "typescript": "5.3.3" 73 | }, 74 | "dependencies": { 75 | "@headlessui/react": "1.7.18", 76 | "@hookform/resolvers": "3.3.4", 77 | "@mikro-orm/core": "6.1.6", 78 | "@mikro-orm/mysql": "6.1.6", 79 | "@trpc/client": "10.45.1", 80 | "@trpc/server": "10.45.1", 81 | "clsx": "2.1.0", 82 | "date-fns": "3.3.1", 83 | "is-absolute-url": "3.0.3", 84 | "lodash": "4.17.21", 85 | "lucide-react": "0.343.0", 86 | "mysql2": "3.9.2", 87 | "nanoid": "3.3.4", 88 | "next": "14.1.0", 89 | "next-auth": "4.24.6", 90 | "next-connect": "1.0.0", 91 | "react": "18.2.0", 92 | "react-dom": "18.2.0", 93 | "react-hook-form": "7.50.1", 94 | "react-hot-toast": "2.4.1", 95 | "react-query": "3.39.3", 96 | "react-select": "5.8.0", 97 | "react-textarea-autosize": "8.5.3", 98 | "react-use": "17.5.0", 99 | "react-use-event-hook": "0.9.6", 100 | "reflect-metadata": "0.2.1", 101 | "rehype-parse": "9.0.0", 102 | "rehype-react": "8.0.0", 103 | "rehype-sanitize": "6.0.0", 104 | "remark-parse": "11.0.0", 105 | "remark-rehype": "11.1.0", 106 | "spinners-react": "1.0.7", 107 | "superjson": "1.13.3", 108 | "type-fest": "4.10.3", 109 | "unified": "11.0.4", 110 | "validator": "13.11.0", 111 | "valtio": "1.13.1", 112 | "zod": "3.22.4" 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {} 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/octet-stream/next-mikro-orm-trpc-template/86bb26fb15eaa82477c5a3ed4e21fb4a13f174d9/public/favicon.ico -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /quick-demo.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-alpine AS base 2 | 3 | FROM base AS deps 4 | WORKDIR /quick-demo 5 | 6 | RUN apk add --no-cache libc6-compat 7 | RUN npm i -g pnpm 8 | 9 | COPY package.json pnpm-lock.yaml ./ 10 | 11 | RUN pnpm i --frozen-lockfile 12 | 13 | FROM base as build 14 | WORKDIR /quick-demo 15 | 16 | # Expose env variables for mikro-orm CLI and the app 17 | ENV MIKRO_ORM_DB_NAME quick-demo 18 | ENV MIKRO_ORM_HOST host.docker.internal 19 | ENV MIKRO_ORM_PORT 3308 20 | ENV MIKRO_ORM_USER root 21 | ENV MIKRO_ORM_PASSWORD= 22 | 23 | ENV NODE_ENV production 24 | ENV NEXT_RUNS_IN_DOCKER 1 25 | ENV NEXT_TELEMETRY_DISABLED 1 26 | 27 | COPY --from=deps /quick-demo/node_modules ./node_modules 28 | COPY . . 29 | 30 | # Run migrations 31 | RUN npm exec -- mikro-orm database:create 32 | RUN npm exec -- mikro-orm migration:fresh 33 | 34 | # Build the app 35 | RUN npm run build 36 | 37 | FROM base as run 38 | WORKDIR /quick-demo 39 | 40 | ENV MIKRO_ORM_DB_NAME quick-demo 41 | ENV MIKRO_ORM_HOST host.docker.internal 42 | ENV MIKRO_ORM_PORT 3308 43 | ENV MIKRO_ORM_USER root 44 | ENV MIKRO_ORM_PASSWORD= 45 | 46 | ENV NEXT_TELEMETRY_DISABLED 1 47 | ENV NEXT_RUNS_IN_DOCKER 1 48 | ENV NODE_ENV production 49 | ENV PORT 3000 50 | 51 | RUN addgroup --system --gid 1001 node-quick-demo 52 | RUN adduser --system --uid 1001 quick-demo 53 | 54 | COPY --from=build /quick-demo/public ./public 55 | COPY --from=build --chown=quick-demo:node-quick-demo /quick-demo/.next/standalone ./ 56 | COPY --from=build --chown=quick-demo:node-quick-demo /quick-demo/.next/static ./.next/static 57 | 58 | USER quick-demo 59 | EXPOSE 3000 60 | 61 | CMD ["node", "server.js"] 62 | -------------------------------------------------------------------------------- /quick-demo.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Start MySQL 4 | docker compose -f docker-compose.test.yml up --wait --remove-orphans 5 | 6 | name=next-mikro-orm-trpc-template-quick-demo 7 | 8 | function build() { 9 | if [[ "$OSTYPE" == "linux-gnu"* ]]; then 10 | docker build --add-host host.docker.internal:host-gateway -t $name -f quick-demo.Dockerfile . 11 | else 12 | docker build -t $name -f quick-demo.Dockerfile . 13 | fi 14 | } 15 | 16 | if [[ $1 = "--build" ]]; then 17 | build 18 | fi 19 | 20 | docker run --rm -d -p 3000:3000 --name $name $name 21 | 22 | trap "done=1" INT 23 | 24 | echo "The app is started on http://localhost:3000" 25 | echo "Press Ctrl+C to stop" 26 | 27 | done=0 28 | while [ "$done" -ne 1 ]; do 29 | sleep 1 30 | done 31 | 32 | echo "Stopping containers." 33 | 34 | docker stop $name 35 | docker compose -f docker-compose.test.yml down 36 | 37 | echo "Done." 38 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # next-mikro-orm-trpc-template 2 | 3 | This template implements minimal architecture on [TypeScript](https://www.typescriptlang.org/) + [Next.js](https://nextjs.org/) + [MikroORM](https://mikro-orm.io/) + [tRPC](https://trpc.io/) + [Tailwind CSS](https://tailwindcss.com/) stack to help you start your next full-stack React application. 4 | 5 | ## What's included? 6 | 7 | * Minimal full-stack application example; 8 | * Bunch of utilities and helpers; 9 | * Tests with AVA, docker-compose and MySQL setup; 10 | * CI config for GitHub Actions. 11 | 12 | ## Quick demo 13 | 14 | If you want to test a demo app built upon this template, just follow these steps: 15 | 16 | 1. Clone this repository: `git clone git@github.com:octet-stream/next-mikro-orm-trpc-template.git quick-demo && cd quick-demo` 17 | 2. Run `./quick-demo.sh --build` script (or via `npm run demo -- --build`). To skip the build step, run this script without `--build` flag. 18 | 3. Once the app is up and running, open [http://localhost:3000](http://localhost:3000) in your browser 19 | 4. To stop demo app, press `Ctrl+C` 20 | 21 | ## Pitfalls 22 | 23 | During my attempts to integrate MikroORM with Next.js I had to fall into several issues worth to mention, so here's the list of those: 24 | 25 | 1. MikroORM can't discover entity, when you're trying to use class constructos to instaniate your entities and them persist and flus the data. I'm not sure why is this happening, but to avoid this problem use `EntiyManager` only with entity classes, not with their instances, so that the entity could be discovered (by it's name). 26 | 27 | So, do this: 28 | 29 | ```ts 30 | const note = orm.em.create(Note, data) // Discovers `Note` entity by `Note.name` and then creates an instance of this entity class filled with given `data` 31 | 32 | await orm.em.persistAndFlush(note) 33 | ``` 34 | 35 | Instead of this: 36 | 37 | ```ts 38 | const note = new Note(data) 39 | 40 | await orm.em.persistAndFlush(note) // Fails on the 2nd attempt to use it with the `Note` instance. 41 | ``` 42 | 43 | 1. During the development, if you'd have to implement a page with dynamic route params, you'll might get MikroORM re-instaniated multiple times (each time you navigate to that page), if you use it in `getStaticPaths` to fetch the data. To avoid this, you can use `lib/util/patchStaticPaths.ts` helper. It simply returns empty paths in `development` env. Here's the example: 44 | 45 | ```ts 46 | import {patchStaticPaths} from "lib/util/patchStaticPaths" 47 | 48 | import {getORM} from "server/lib/db/orm" 49 | 50 | // This function will be returned only in production. 51 | export const getStaticPaths = patchStaticPaths(async () => { 52 | const orm = await getORM() 53 | 54 | const notes = await orm.em.find( 55 | Note, 56 | 57 | {}, 58 | 59 | { 60 | disableIdentityMap: true, 61 | fields: ["id"], 62 | limit: 1000, 63 | orderBy: { 64 | createdAt: "desc" 65 | } 66 | } 67 | ) 68 | 69 | return { 70 | fallback: "blocking", 71 | paths: notes.map(({id}) => ({params: {id}})) 72 | } 73 | }) 74 | ``` 75 | 76 | 3. MikroORM can't discover native TypeScipt enums if you were to define those in a separate file. To fix avoid this problem, you'll have to extract emun values manually (but remember about TS enum [quirks](https://youtu.be/jjMbPt_H3RQ)) and put those to `items` option of the `Enum` decorator, along with the `type` options set to needed column type. Here's how you can do this: 77 | 78 | ```ts 79 | import {Entity, Enum} from "@mikro-orm/core" 80 | import {isString} from "lodash" 81 | 82 | import {NoteStatus} from "server/trpc/type/common/NoteStatus" 83 | 84 | const statuses = Object.values(NoteStatus).filter(isString) 85 | 86 | @Entity() 87 | class Note { 88 | @Enum({type: "string", items: statuses}) 89 | status: NoteStatus = NoteStatus.INCOMPLETED 90 | } 91 | ``` 92 | -------------------------------------------------------------------------------- /src/components/Anchor/Anchor.tsx: -------------------------------------------------------------------------------- 1 | import type {ComponentPropsWithoutRef} from "react" 2 | import {forwardRef, useMemo} from "react" 3 | 4 | import Link from "next/link" 5 | import cn from "clsx" 6 | 7 | import {isInternalUrl} from "lib/utils/isInternalUrl" 8 | 9 | import {ExternalAnchor} from "components/Anchor/ExternalAnchor" 10 | 11 | interface Props extends ComponentPropsWithoutRef<"a"> { 12 | href: string 13 | } 14 | 15 | /** 16 | * Abstract Anchor component. 17 | * Will automatically render `next/link` for internal URL and `` for external. 18 | * 19 | * ```tsx 20 | * import type {FC} from "react" 21 | * import {Fragment} from "react" 22 | * import {Anchor} from "component/Anchor" 23 | * 24 | * // Assume our internal base URL is https://example.com, then the first link will be `next/link` component and the other will be `` tag 25 | * const MyComponent: FC = () => ( 26 | * 27 | * 28 | * Internal link 29 | * 30 | * 31 | * 32 | * External link 33 | * 34 | * 35 | * ) 36 | * ``` 37 | */ 38 | export const Anchor = forwardRef(({ 39 | className, 40 | 41 | ...props 42 | }, ref) => { 43 | const AnchorComponent = useMemo( 44 | () => isInternalUrl(props.href) ? Link : ExternalAnchor, 45 | 46 | [props.href] 47 | ) 48 | 49 | return ( 50 | 56 | ) 57 | }) 58 | -------------------------------------------------------------------------------- /src/components/Anchor/ExternalAnchor.tsx: -------------------------------------------------------------------------------- 1 | import type {ComponentPropsWithoutRef} from "react" 2 | import {forwardRef} from "react" 3 | 4 | type Props = Omit, "rel"> 5 | 6 | export const ExternalAnchor = forwardRef(({ 7 | children, 8 | target = "_blank", 9 | 10 | ...props 11 | }, ref) => ( 12 | 13 | {children} 14 | 15 | )) 16 | -------------------------------------------------------------------------------- /src/components/Anchor/index.tsx: -------------------------------------------------------------------------------- 1 | export {Anchor} from "./Anchor" 2 | -------------------------------------------------------------------------------- /src/components/Button.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/button-has-type */ 2 | import type {ComponentPropsWithoutRef} from "react" 3 | import {forwardRef} from "react" 4 | 5 | import cn from "clsx" 6 | 7 | import {SpinnerCircularFixed} from "spinners-react" 8 | 9 | interface BaseProps extends ComponentPropsWithoutRef<"button"> { 10 | variant?: "primary" | "secondary" 11 | color?: "red" | "brand" 12 | loading?: boolean 13 | } 14 | 15 | interface SqareButton { 16 | wide?: boolean 17 | shape?: "square" 18 | } 19 | 20 | interface CircleButton { 21 | shape: "circle" 22 | } 23 | 24 | type Props = BaseProps & (SqareButton | CircleButton) 25 | 26 | const isCircleButton = (props: Props): props is BaseProps & CircleButton => ( 27 | props.shape === "circle" 28 | ) 29 | 30 | /** 31 | * Abstract styled ` 91 | ) 92 | }) 93 | -------------------------------------------------------------------------------- /src/components/Card.tsx: -------------------------------------------------------------------------------- 1 | import type {FC, ReactNode} from "react" 2 | 3 | import cn from "clsx" 4 | 5 | interface Props { 6 | className?: string 7 | children: ReactNode 8 | } 9 | 10 | export const Card: FC = ({className, children}) => ( 11 |
12 | {children} 13 |
14 | ) 15 | -------------------------------------------------------------------------------- /src/components/ConfirmationDialog.tsx: -------------------------------------------------------------------------------- 1 | import {useEvent} from "react-use-event-hook" 2 | import {useRef, createElement} from "react" 3 | import type {FC, ReactNode} from "react" 4 | 5 | import type {MaybePromise} from "lib/types/MaybePromise" 6 | 7 | import type {ModalRef, BaseModalProps} from "components/Modal" 8 | import {Modal, ModalPanel, ModalTitle} from "components/Modal" 9 | import {Button} from "components/Button" 10 | 11 | interface ConfirmButtonProps { 12 | confirm(): void 13 | } 14 | 15 | interface CancelButtonProps { 16 | close(): void 17 | } 18 | 19 | export type ConfirmButton

= FC

20 | 21 | export type CancelButton

= FC

22 | 23 | interface Props extends BaseModalProps { 24 | title: string 25 | children: ReactNode 26 | onConfirm(): MaybePromise 27 | confirmButton?: ConfirmButton 28 | cancelButton?: CancelButton 29 | } 30 | 31 | export const ConfirmationDialog: FC = ({ 32 | title, 33 | children, 34 | openButton, 35 | onConfirm, 36 | confirmButton, 37 | cancelButton, 38 | onClose 39 | }) => { 40 | const modalRef = useRef() 41 | 42 | const closeModal = useEvent(() => { 43 | modalRef.current?.close() 44 | }) 45 | 46 | const confirm = useEvent(() => { 47 | const result = onConfirm() 48 | 49 | if (result instanceof Promise) { 50 | // TODO: Maybe add scenario when the promise was rejected 51 | return result.then(() => closeModal()) 52 | } 53 | 54 | closeModal() 55 | }) 56 | 57 | return ( 58 | 59 | 60 | 61 | {title} 62 | 63 | 64 |

65 | {children} 66 | 67 |
68 | { 69 | confirmButton ? ( 70 | createElement(confirmButton, {confirm}) 71 | ) : ( 72 | 75 | ) 76 | } 77 | 78 |
79 | 80 | { 81 | cancelButton ? ( 82 | createElement(cancelButton, {close: closeModal}) 83 | ) : ( 84 | 87 | ) 88 | } 89 |
90 |
91 | 92 | 93 | ) 94 | } 95 | -------------------------------------------------------------------------------- /src/components/FloatingButton.tsx: -------------------------------------------------------------------------------- 1 | import type {FC, ComponentPropsWithoutRef} from "react" 2 | import {Plus} from "lucide-react" 3 | 4 | import cn from "clsx" 5 | 6 | import {Button} from "components/Button" 7 | 8 | interface Props extends Omit, "type" | "color"> { } 9 | 10 | export const FloatingButton: FC = ({className, ...props}) => ( 11 | 20 | ) 21 | -------------------------------------------------------------------------------- /src/components/Modal/Modal.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Fragment, 3 | useState, 4 | useImperativeHandle, 5 | forwardRef, 6 | useMemo, 7 | createElement 8 | } from "react" 9 | import type {FC, ReactNode} from "react" 10 | import {useEvent} from "react-use-event-hook" 11 | import {Dialog} from "@headlessui/react" 12 | 13 | import type {MaybeUndefined} from "lib/types/MaybeUndefined" 14 | 15 | import {ModalContext} from "./ModalContext" 16 | 17 | interface RenderProps { 18 | open(): void 19 | } 20 | 21 | export type OpenModalButton

= FC

22 | 23 | export interface BaseModalProps { 24 | openButton: OpenModalButton 25 | onClose?(): void 26 | } 27 | 28 | interface Props extends BaseModalProps { 29 | children: ReactNode 30 | } 31 | 32 | export interface ModalRef { 33 | open(): void 34 | close(): void 35 | isOpen: boolean 36 | } 37 | 38 | type ModalRefInput = MaybeUndefined 39 | 40 | export const Modal = forwardRef(( 41 | { 42 | onClose, 43 | openButton, 44 | children 45 | }, 46 | 47 | ref 48 | ) => { 49 | const [isOpen, setOpen] = useState(false) 50 | 51 | const open = useEvent(() => setOpen(true)) 52 | 53 | const close = useEvent(() => { 54 | setOpen(false) 55 | onClose?.() 56 | }) 57 | 58 | const context = useMemo(() => ({ 59 | isOpen, open, close 60 | }), [isOpen, open, close]) 61 | 62 | useImperativeHandle(ref, () => ({ 63 | open, close, isOpen 64 | })) 65 | 66 | return ( 67 | 68 | 69 |

70 |
71 | 72 |
73 |
74 | {children} 75 |
76 |
77 |
78 | 79 | 80 | {createElement(openButton, {open})} 81 | 82 | ) 83 | }) 84 | -------------------------------------------------------------------------------- /src/components/Modal/ModalContext.tsx: -------------------------------------------------------------------------------- 1 | import {createContext, useContext} from "react" 2 | 3 | import type {ModalRef} from "./Modal" 4 | 5 | export const ModalContext = createContext(undefined) 6 | 7 | export function useModalContext(): ModalRef { 8 | const context = useContext(ModalContext) 9 | 10 | if (!context) { 11 | throw new Error("Can't find ModalContext") 12 | } 13 | 14 | return context 15 | } 16 | -------------------------------------------------------------------------------- /src/components/Modal/ModalPanel.tsx: -------------------------------------------------------------------------------- 1 | import type {FC, ReactNode} from "react" 2 | import {Dialog} from "@headlessui/react" 3 | 4 | import cn from "clsx" 5 | 6 | interface Props { 7 | className?: string 8 | children: ReactNode 9 | } 10 | 11 | export const ModalPanel: FC = ({className, children}) => ( 12 | 13 | {children} 14 | 15 | ) 16 | -------------------------------------------------------------------------------- /src/components/Modal/ModalTitle.tsx: -------------------------------------------------------------------------------- 1 | import type {FC, ReactNode} from "react" 2 | import {Dialog} from "@headlessui/react" 3 | 4 | import {X} from "lucide-react" 5 | 6 | import cn from "clsx" 7 | 8 | import {useModalContext} from "./ModalContext" 9 | 10 | interface Props { 11 | className?: string 12 | children: ReactNode 13 | } 14 | 15 | export const ModalTitle: FC = ({className, children}) => { 16 | const {close} = useModalContext() 17 | 18 | return ( 19 | 20 |
21 | {children} 22 |
23 | 24 |
25 | 26 | 29 | 30 | 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /src/components/Modal/index.tsx: -------------------------------------------------------------------------------- 1 | export type {ModalRef, BaseModalProps, OpenModalButton} from "./Modal" 2 | export {ModalContext, useModalContext} from "./ModalContext" 3 | export {ModalPanel} from "./ModalPanel" 4 | export {ModalTitle} from "./ModalTitle" 5 | export {Modal} from "./Modal" 6 | -------------------------------------------------------------------------------- /src/components/NoteCard.tsx: -------------------------------------------------------------------------------- 1 | import {Check, Lock} from "lucide-react" 2 | import {toast} from "react-hot-toast" 3 | import {useCallback} from "react" 4 | import type {FC} from "react" 5 | 6 | import cn from "clsx" 7 | import Link from "next/link" 8 | 9 | import {NoteStatus} from "server/trpc/types/common/NoteStatus" 10 | 11 | import {patchNodeStatus} from "lib/utils/patchNoteStatus" 12 | import {client} from "lib/trpc/client" 13 | 14 | import {Card} from "components/Card" 15 | import {useNoteStateSnapshot, useNoteStateProxy} from "contexts/NoteStateContext" 16 | 17 | interface Props { } 18 | 19 | export const NoteCard: FC = () => { 20 | const state = useNoteStateProxy() 21 | 22 | const {id, title, isCompleted, isRejected} = useNoteStateSnapshot() 23 | 24 | const notePath = `/view/${id}` 25 | 26 | const updateStatus = useCallback(() => { 27 | const newStatus = isCompleted 28 | ? NoteStatus.INCOMPLETED 29 | : NoteStatus.COMPLETED 30 | 31 | return ( 32 | client.note.update.mutate({id, status: newStatus}) 33 | .then(updated => patchNodeStatus(state, updated)) 34 | .catch(error => { 35 | console.error(error) 36 | toast.error("Can't update this note") 37 | }) 38 | ) 39 | }, [id, isCompleted, state]) 40 | 41 | return ( 42 | 43 |
44 | 45 | 46 | 57 |
58 | 59 | 60 |
61 | {title} 62 |
63 | 64 |
65 | ) 66 | } 67 | -------------------------------------------------------------------------------- /src/components/NoteCompleteButton.tsx: -------------------------------------------------------------------------------- 1 | import {Check, CheckCheck} from "lucide-react" 2 | import {useCallback, useMemo} from "react" 3 | import {toast} from "react-hot-toast" 4 | import type {FC} from "react" 5 | 6 | import cn from "clsx" 7 | 8 | import {NoteStatus} from "../server/trpc/types/common/NoteStatus" 9 | 10 | import {patchNodeStatus} from "../lib/utils/patchNoteStatus" 11 | import {client} from "../lib/trpc/client" 12 | 13 | import { 14 | useNoteStateSnapshot, 15 | useNoteStateProxy 16 | } from "../contexts/NoteStateContext" 17 | 18 | interface Props { 19 | className?: string 20 | } 21 | 22 | export const NoteCompleteButton: FC = ({className}) => { 23 | const state = useNoteStateProxy() 24 | 25 | const {id, isCompleted} = useNoteStateSnapshot() 26 | 27 | const Icon = useMemo(() => isCompleted ? CheckCheck : Check, [isCompleted]) 28 | 29 | const toggle = useCallback(() => ( 30 | client.note.update.mutate({ 31 | id, 32 | 33 | status: isCompleted 34 | ? NoteStatus.INCOMPLETED 35 | : NoteStatus.COMPLETED 36 | }) 37 | .then(updated => patchNodeStatus(state, updated)) 38 | .catch(error => { 39 | console.error(error) 40 | toast.error("Can't update this note.") 41 | }) 42 | ), [id, isCompleted, state]) 43 | 44 | return ( 45 | 54 | ) 55 | } 56 | -------------------------------------------------------------------------------- /src/components/NoteControls/NoteControls.tsx: -------------------------------------------------------------------------------- 1 | import type {FC} from "react" 2 | 3 | import cn from "clsx" 4 | 5 | import {useNoteStateSnapshot} from "contexts/NoteStateContext" 6 | 7 | import {PauseButton} from "./PauseButton" 8 | import {PlayButton} from "./PlayButton" 9 | import {StopButton} from "./StopButton" 10 | 11 | interface Props { } 12 | 13 | export const NoteControls: FC = () => { 14 | const {isCompleted, isPaused, isInProgress} = useNoteStateSnapshot() 15 | 16 | return ( 17 |
18 | 19 | 20 | {!isInProgress && } 21 | 22 | {isInProgress && } 23 |
24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/components/NoteControls/PauseButton.tsx: -------------------------------------------------------------------------------- 1 | import {Pause} from "lucide-react" 2 | 3 | import {NoteStatus} from "server/trpc/types/common/NoteStatus" 4 | 5 | import {createControlButton} from "./createControlButton" 6 | 7 | export const PauseButton = createControlButton({ 8 | icon: Pause, 9 | status: NoteStatus.PAUSED 10 | }) 11 | -------------------------------------------------------------------------------- /src/components/NoteControls/PlayButton.tsx: -------------------------------------------------------------------------------- 1 | import {Play} from "lucide-react" 2 | 3 | import {NoteStatus} from "server/trpc/types/common/NoteStatus" 4 | 5 | import {createControlButton} from "./createControlButton" 6 | 7 | export const PlayButton = createControlButton({ 8 | icon: Play, 9 | status: NoteStatus.IN_PROGRESS 10 | }) 11 | -------------------------------------------------------------------------------- /src/components/NoteControls/StopButton.tsx: -------------------------------------------------------------------------------- 1 | import {Square} from "lucide-react" 2 | 3 | import {NoteStatus} from "server/trpc/types/common/NoteStatus" 4 | 5 | import {createControlButton} from "./createControlButton" 6 | 7 | export const StopButton = createControlButton({ 8 | icon: Square, 9 | status: NoteStatus.INCOMPLETED 10 | }) 11 | -------------------------------------------------------------------------------- /src/components/NoteControls/createControlButton.tsx: -------------------------------------------------------------------------------- 1 | import {forwardRef, createElement, useCallback} from "react" 2 | import type {ComponentPropsWithoutRef} from "react" 3 | import type {LucideIcon} from "lucide-react" 4 | import {toast} from "react-hot-toast" 5 | 6 | import {NoteStatus} from "server/trpc/types/common/NoteStatus" 7 | 8 | import {client} from "lib/trpc/client" 9 | import {patchNodeStatus} from "lib/utils/patchNoteStatus" 10 | 11 | import {useNoteStateProxy, useNoteStateSnapshot} from "contexts/NoteStateContext" 12 | 13 | import cn from "clsx" 14 | 15 | export interface CreateControlButtonOptions { 16 | icon: LucideIcon 17 | status: NoteStatus 18 | } 19 | 20 | type Props = Omit, "type"> 21 | 22 | export const createControlButton = ({ 23 | icon, 24 | status 25 | }: CreateControlButtonOptions) => forwardRef( 26 | ({className, ...props}, ref) => { 27 | const state = useNoteStateProxy() 28 | 29 | const {id} = useNoteStateSnapshot() 30 | 31 | const updateStatus = useCallback(async () => { 32 | try { 33 | patchNodeStatus(state, await client.note.update.mutate({id, status})) 34 | } catch (error) { 35 | console.error(error) 36 | toast.error("Can't update note's status") 37 | } 38 | }, [id, state]) 39 | 40 | return ( 41 | 51 | ) 52 | } 53 | ) 54 | -------------------------------------------------------------------------------- /src/components/NoteControls/index.tsx: -------------------------------------------------------------------------------- 1 | export {NoteControls} from "./NoteControls" 2 | -------------------------------------------------------------------------------- /src/components/NoteModal/NoteCreateModal/NoteCreateModal.tsx: -------------------------------------------------------------------------------- 1 | import type {SubmitHandler} from "react-hook-form" 2 | import {useEvent} from "react-use-event-hook" 3 | import {toast} from "react-hot-toast" 4 | import {useRouter} from "next/router" 5 | import type {FC} from "react" 6 | 7 | import isString from "lodash/isString" 8 | 9 | import {NoteCreateInput} from "server/trpc/types/note/NoteCreateInput" 10 | import type {INoteCreateInput} from "server/trpc/types/note/NoteCreateInput" 11 | 12 | import {client} from "lib/trpc/client" 13 | 14 | import {useNotesStateProxy} from "contexts/NotesStateContext" 15 | 16 | import {createNoteModal} from "../createNoteModal" 17 | 18 | import {Open} from "./Open" 19 | 20 | const Modal = createNoteModal({ 21 | name: "Create", 22 | validate: NoteCreateInput 23 | }) 24 | 25 | interface Props { 26 | redirect?: string | boolean 27 | } 28 | 29 | export const NoteCreateModal: FC = ({redirect}) => { 30 | const router = useRouter() 31 | const state = useNotesStateProxy() 32 | 33 | const submit = useEvent>(async data => { 34 | try { 35 | const note = await client.note.create.mutate(data) 36 | 37 | state.items.unshift(note) 38 | state.itemsCount++ 39 | state.rowsCount++ 40 | 41 | if (redirect) { 42 | return router.replace( 43 | isString(redirect) ? redirect : `/view/${note.id}`, 44 | 45 | undefined, 46 | 47 | { 48 | unstable_skipClientCache: true 49 | } 50 | ) 51 | } 52 | } catch (error) { 53 | console.log(error) 54 | toast.error("Can't create a note.") 55 | } 56 | }) 57 | 58 | return ( 59 | 64 | ) 65 | } 66 | -------------------------------------------------------------------------------- /src/components/NoteModal/NoteCreateModal/Open.tsx: -------------------------------------------------------------------------------- 1 | import type {OpenModalButton} from "components/Modal" 2 | 3 | import {FloatingButton} from "components/FloatingButton" 4 | 5 | export const Open: OpenModalButton = ({open}) => ( 6 | 7 | ) 8 | -------------------------------------------------------------------------------- /src/components/NoteModal/NoteCreateModal/index.tsx: -------------------------------------------------------------------------------- 1 | export {NoteCreateModal} from "./NoteCreateModal" 2 | -------------------------------------------------------------------------------- /src/components/NoteModal/NoteUpdateModal/NoteUpdateModal.tsx: -------------------------------------------------------------------------------- 1 | import type {SubmitHandler} from "react-hook-form" 2 | import {toast} from "react-hot-toast" 3 | import {useCallback} from "react" 4 | import type {FC} from "react" 5 | 6 | import merge from "lodash/merge" 7 | 8 | import {NoteUpdateInput} from "server/trpc/types/note/NoteUpdateInput" 9 | import type {INoteUpdateInput} from "server/trpc/types/note/NoteUpdateInput" 10 | 11 | import {client} from "lib/trpc/client" 12 | 13 | import {useNoteStateProxy, useNoteStateSnapshot} from "contexts/NoteStateContext" 14 | 15 | import {createNoteModal} from "../createNoteModal" 16 | 17 | import {Open} from "./Open" 18 | 19 | type Submit = SubmitHandler> 20 | 21 | const Modal = createNoteModal({ 22 | name: "Update", 23 | validate: NoteUpdateInput.omit({id: true}) 24 | }) 25 | 26 | export const NoteUpdateModal: FC = () => { 27 | const {id, ...rest} = useNoteStateSnapshot() 28 | 29 | const proxy = useNoteStateProxy() 30 | 31 | const submit = useCallback(async data => { 32 | try { 33 | merge(proxy, await client.note.update.mutate({...data, id})) 34 | 35 | toast.success("Note updated!") 36 | } catch (error) { 37 | console.log(error) 38 | toast.error("Can't update this note.") 39 | } 40 | }, [id, proxy]) 41 | 42 | return ( 43 | 49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /src/components/NoteModal/NoteUpdateModal/Open.tsx: -------------------------------------------------------------------------------- 1 | import {Pencil} from "lucide-react" 2 | 3 | import type {OpenModalButton} from "components/Modal" 4 | 5 | export const Open: OpenModalButton = ({open}) => ( 6 | 9 | ) 10 | -------------------------------------------------------------------------------- /src/components/NoteModal/NoteUpdateModal/index.tsx: -------------------------------------------------------------------------------- 1 | export {NoteUpdateModal} from "./NoteUpdateModal" 2 | -------------------------------------------------------------------------------- /src/components/NoteModal/createNoteModal.tsx: -------------------------------------------------------------------------------- 1 | import {useForm, SubmitHandler, FieldValues} from "react-hook-form" 2 | import type {AnyZodObject, infer as Infer} from "zod" 3 | import {zodResolver} from "@hookform/resolvers/zod" 4 | import type {FC, KeyboardEventHandler} from "react" 5 | import {useEvent} from "react-use-event-hook" 6 | import {useRef} from "react" 7 | 8 | import TextArea from "react-textarea-autosize" 9 | import isEmpty from "lodash/isEmpty" 10 | import omitBy from "lodash/omitBy" 11 | 12 | import {INoteCreateInput} from "server/trpc/types/note/NoteCreateInput" 13 | 14 | import type {ModalRef, BaseModalProps} from "components/Modal" 15 | import {Modal, ModalPanel, ModalTitle} from "components/Modal" 16 | import {Button} from "components/Button" 17 | 18 | type BlockReturnHandler = KeyboardEventHandler 19 | 20 | interface Props extends BaseModalProps { 21 | title: string 22 | values?: T 23 | submit: SubmitHandler 24 | } 25 | 26 | interface CreateNoteModalOptions { 27 | name?: string 28 | validate: T 29 | } 30 | 31 | export function createNoteModal({ 32 | name, 33 | validate 34 | }: CreateNoteModalOptions) { 35 | const NoteModal: FC>> = ({ 36 | title, 37 | submit, 38 | openButton, 39 | values 40 | }) => { 41 | const modalRef = useRef() 42 | 43 | const { 44 | reset, 45 | register, 46 | formState, 47 | handleSubmit 48 | } = useForm({ 49 | mode: "onTouched", 50 | resolver: zodResolver(validate), 51 | values: values as INoteCreateInput 52 | }) 53 | 54 | const blockReturn = useEvent(event => { 55 | if (event.key.toLowerCase() === "enter") { 56 | event.preventDefault() 57 | } 58 | }) 59 | 60 | const closeModal = useEvent(() => { 61 | modalRef.current?.close() 62 | }) 63 | 64 | const onCloseReset = useEvent(() => reset()) 65 | 66 | const handler = handleSubmit(data => ( 67 | Promise.resolve(submit(omitBy(data, isEmpty))).then(() => closeModal()) 68 | )) 69 | 70 | return ( 71 | 72 | 73 | 74 | {title} 75 | 76 | 77 |
78 |