From 0335b0ad81169232a3dbb1be1341fdcfce548645 Mon Sep 17 00:00:00 2001 From: Pinapelz Date: Tue, 2 Jun 2026 02:12:57 -0700 Subject: migrate to pocketbase backend + auth/login --- .gitignore | 2 + README.md | 2 +- package.json | 1 + pb_migrations/1780384083_created_charts.js | 150 +++++ pb_migrations/1780388159_updated_charts.js | 30 + pb_migrations/1780388818_updated_charts.js | 20 + pb_migrations/1780390881_updated_charts.js | 20 + pnpm-lock.yaml | 143 ++-- public/typing.json | 54 -- src/app/context/auth.tsx | 69 ++ src/app/create/page.styles.ts | 168 ----- src/app/create/page.tsx | 207 ------ src/app/game/[slug]/game.stat.ts | 82 +++ src/app/game/[slug]/game.utils.ts | 55 ++ src/app/game/[slug]/page.styles.ts | 650 ++++++++++++++++++ src/app/game/[slug]/page.tsx | 999 +++++++++++++++++++++++++++ src/app/game/game.stat.ts | 82 --- src/app/game/game.utils.ts | 55 -- src/app/game/page.styles.ts | 663 ------------------ src/app/game/page.tsx | 1011 ---------------------------- src/app/layout.tsx | 9 +- src/app/lib/pocketbase.ts | 7 + src/app/page.styles.ts | 193 ++++-- src/app/page.tsx | 144 +++- src/app/signin/page.tsx | 280 ++++++++ src/app/styles/shared.ts | 3 - src/app/typing/page.styles.ts | 168 ----- src/app/typing/page.tsx | 161 ----- 28 files changed, 2709 insertions(+), 2719 deletions(-) create mode 100644 pb_migrations/1780384083_created_charts.js create mode 100644 pb_migrations/1780388159_updated_charts.js create mode 100644 pb_migrations/1780388818_updated_charts.js create mode 100644 pb_migrations/1780390881_updated_charts.js delete mode 100644 public/typing.json create mode 100644 src/app/context/auth.tsx delete mode 100644 src/app/create/page.styles.ts delete mode 100644 src/app/create/page.tsx create mode 100644 src/app/game/[slug]/game.stat.ts create mode 100644 src/app/game/[slug]/game.utils.ts create mode 100644 src/app/game/[slug]/page.styles.ts create mode 100644 src/app/game/[slug]/page.tsx delete mode 100644 src/app/game/game.stat.ts delete mode 100644 src/app/game/game.utils.ts delete mode 100644 src/app/game/page.styles.ts delete mode 100644 src/app/game/page.tsx create mode 100644 src/app/lib/pocketbase.ts create mode 100644 src/app/signin/page.tsx delete mode 100644 src/app/typing/page.styles.ts delete mode 100644 src/app/typing/page.tsx diff --git a/.gitignore b/.gitignore index fd3dbb5..c42419e 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,5 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts +pocketbase +pb_data diff --git a/README.md b/README.md index a369145..7e1320a 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ ## mixx-type (WIP) A typing-tube style game ---- + ## Build diff --git a/package.json b/package.json index 8af4ca6..b0cbb49 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "dependencies": { "font-awesome": "^4.7.0", "next": "16.2.3", + "pocketbase": "^0.27.0", "react": "^19.2.6", "react-bootstrap": "^2.10.10", "react-dom": "^19.2.6", diff --git a/pb_migrations/1780384083_created_charts.js b/pb_migrations/1780384083_created_charts.js new file mode 100644 index 0000000..4e92ef3 --- /dev/null +++ b/pb_migrations/1780384083_created_charts.js @@ -0,0 +1,150 @@ +/// +migrate((app) => { + const collection = new Collection({ + "createRule": null, + "deleteRule": null, + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "exceptDomains": null, + "help": "", + "hidden": false, + "id": "url2000601280", + "name": "lrc", + "onlyDomains": null, + "presentable": false, + "required": false, + "system": false, + "type": "url" + }, + { + "exceptDomains": null, + "help": "", + "hidden": false, + "id": "url1781309708", + "name": "media", + "onlyDomains": null, + "presentable": false, + "required": false, + "system": false, + "type": "url" + }, + { + "exceptDomains": null, + "help": "", + "hidden": false, + "id": "url3277268710", + "name": "thumbnail", + "onlyDomains": null, + "presentable": false, + "required": false, + "system": false, + "type": "url" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text22648455", + "max": 0, + "min": 0, + "name": "artist", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text724990059", + "max": 0, + "min": 0, + "name": "title", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "number1493879504", + "max": null, + "min": null, + "name": "offset", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "number3144380399", + "max": null, + "min": null, + "name": "difficulty", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "id": "pbc_4148374969", + "indexes": [], + "listRule": null, + "name": "charts", + "system": false, + "type": "base", + "updateRule": null, + "viewRule": null + }); + + return app.save(collection); +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_4148374969"); + + return app.delete(collection); +}) diff --git a/pb_migrations/1780388159_updated_charts.js b/pb_migrations/1780388159_updated_charts.js new file mode 100644 index 0000000..2fd10a1 --- /dev/null +++ b/pb_migrations/1780388159_updated_charts.js @@ -0,0 +1,30 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_4148374969") + + // add field + collection.fields.addAt(8, new Field({ + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text105650625", + "max": 0, + "min": 0, + "name": "category", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_4148374969") + + // remove field + collection.fields.removeById("text105650625") + + return app.save(collection) +}) diff --git a/pb_migrations/1780388818_updated_charts.js b/pb_migrations/1780388818_updated_charts.js new file mode 100644 index 0000000..2517c71 --- /dev/null +++ b/pb_migrations/1780388818_updated_charts.js @@ -0,0 +1,20 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_4148374969") + + // update collection data + unmarshal({ + "listRule": "" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_4148374969") + + // update collection data + unmarshal({ + "listRule": null + }, collection) + + return app.save(collection) +}) diff --git a/pb_migrations/1780390881_updated_charts.js b/pb_migrations/1780390881_updated_charts.js new file mode 100644 index 0000000..b9f84ef --- /dev/null +++ b/pb_migrations/1780390881_updated_charts.js @@ -0,0 +1,20 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_4148374969") + + // update collection data + unmarshal({ + "viewRule": "" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_4148374969") + + // update collection data + unmarshal({ + "viewRule": null + }, collection) + + return app.save(collection) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aad8980..1e6c14a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: next: specifier: 16.2.3 version: 16.2.3(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + pocketbase: + specifier: ^0.27.0 + version: 0.27.0 react: specifier: ^19.2.6 version: 19.2.6 @@ -37,7 +40,7 @@ importers: version: 11.1.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) styled-components: specifier: ^6.4.1 - version: 6.4.1(css-to-react-native@3.2.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 6.4.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) devDependencies: '@types/node': specifier: ^25.7.0 @@ -53,10 +56,10 @@ importers: version: 10.5.0(postcss@8.5.14) eslint: specifier: ^10.3.0 - version: 10.3.0(jiti@1.21.7) + version: 10.3.0 eslint-config-next: specifier: 16.2.3 - version: 16.2.3(@typescript-eslint/parser@8.59.3(eslint@10.3.0(jiti@1.21.7))(typescript@6.0.3))(eslint@10.3.0(jiti@1.21.7))(typescript@6.0.3) + version: 16.2.3(@typescript-eslint/parser@8.59.3(eslint@10.3.0)(typescript@6.0.3))(eslint@10.3.0)(typescript@6.0.3) postcss: specifier: ^8.5.14 version: 8.5.14 @@ -815,9 +818,6 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} - camelize@1.0.1: - resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==} - caniuse-lite@1.0.30001792: resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==} @@ -844,13 +844,6 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} - css-color-keywords@1.0.0: - resolution: {integrity: sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==} - engines: {node: '>=4'} - - css-to-react-native@3.2.0: - resolution: {integrity: sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==} - csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -1355,10 +1348,6 @@ packages: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} - jiti@1.21.7: - resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} - hasBin: true - js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -1550,6 +1539,9 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + pocketbase@0.27.0: + resolution: {integrity: sha512-K5N6d93UP/BNMbMnlZ6BUfy9VPCIvLyqhJFOsNI8OsZwzvKWEAfyD36boi5K4ECIOl5HMlo0TzuaeGdKpMwizQ==} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -2072,9 +2064,9 @@ snapshots: '@emotion/memoize@0.9.0': {} - '@eslint-community/eslint-utils@4.9.1(eslint@10.3.0(jiti@1.21.7))': + '@eslint-community/eslint-utils@4.9.1(eslint@10.3.0)': dependencies: - eslint: 10.3.0(jiti@1.21.7) + eslint: 10.3.0 eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -2377,15 +2369,15 @@ snapshots: '@types/warning@3.0.4': {} - '@typescript-eslint/eslint-plugin@8.59.3(@typescript-eslint/parser@8.59.3(eslint@10.3.0(jiti@1.21.7))(typescript@6.0.3))(eslint@10.3.0(jiti@1.21.7))(typescript@6.0.3)': + '@typescript-eslint/eslint-plugin@8.59.3(@typescript-eslint/parser@8.59.3(eslint@10.3.0)(typescript@6.0.3))(eslint@10.3.0)(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.59.3(eslint@10.3.0(jiti@1.21.7))(typescript@6.0.3) + '@typescript-eslint/parser': 8.59.3(eslint@10.3.0)(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.59.3 - '@typescript-eslint/type-utils': 8.59.3(eslint@10.3.0(jiti@1.21.7))(typescript@6.0.3) - '@typescript-eslint/utils': 8.59.3(eslint@10.3.0(jiti@1.21.7))(typescript@6.0.3) + '@typescript-eslint/type-utils': 8.59.3(eslint@10.3.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.59.3(eslint@10.3.0)(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.59.3 - eslint: 10.3.0(jiti@1.21.7) + eslint: 10.3.0 ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@6.0.3) @@ -2393,14 +2385,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.59.3(eslint@10.3.0(jiti@1.21.7))(typescript@6.0.3)': + '@typescript-eslint/parser@8.59.3(eslint@10.3.0)(typescript@6.0.3)': dependencies: '@typescript-eslint/scope-manager': 8.59.3 '@typescript-eslint/types': 8.59.3 '@typescript-eslint/typescript-estree': 8.59.3(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.59.3 debug: 4.4.3 - eslint: 10.3.0(jiti@1.21.7) + eslint: 10.3.0 typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -2423,13 +2415,13 @@ snapshots: dependencies: typescript: 6.0.3 - '@typescript-eslint/type-utils@8.59.3(eslint@10.3.0(jiti@1.21.7))(typescript@6.0.3)': + '@typescript-eslint/type-utils@8.59.3(eslint@10.3.0)(typescript@6.0.3)': dependencies: '@typescript-eslint/types': 8.59.3 '@typescript-eslint/typescript-estree': 8.59.3(typescript@6.0.3) - '@typescript-eslint/utils': 8.59.3(eslint@10.3.0(jiti@1.21.7))(typescript@6.0.3) + '@typescript-eslint/utils': 8.59.3(eslint@10.3.0)(typescript@6.0.3) debug: 4.4.3 - eslint: 10.3.0(jiti@1.21.7) + eslint: 10.3.0 ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: @@ -2452,13 +2444,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.59.3(eslint@10.3.0(jiti@1.21.7))(typescript@6.0.3)': + '@typescript-eslint/utils@8.59.3(eslint@10.3.0)(typescript@6.0.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.3.0(jiti@1.21.7)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.3.0) '@typescript-eslint/scope-manager': 8.59.3 '@typescript-eslint/types': 8.59.3 '@typescript-eslint/typescript-estree': 8.59.3(typescript@6.0.3) - eslint: 10.3.0(jiti@1.21.7) + eslint: 10.3.0 typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -2678,9 +2670,6 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 - camelize@1.0.1: - optional: true - caniuse-lite@1.0.30001792: {} classnames@2.5.1: {} @@ -2701,16 +2690,6 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - css-color-keywords@1.0.0: - optional: true - - css-to-react-native@3.2.0: - dependencies: - camelize: 1.0.1 - css-color-keywords: 1.0.0 - postcss-value-parser: 4.2.0 - optional: true - csstype@3.2.3: {} damerau-levenshtein@1.0.8: {} @@ -2884,18 +2863,18 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-next@16.2.3(@typescript-eslint/parser@8.59.3(eslint@10.3.0(jiti@1.21.7))(typescript@6.0.3))(eslint@10.3.0(jiti@1.21.7))(typescript@6.0.3): + eslint-config-next@16.2.3(@typescript-eslint/parser@8.59.3(eslint@10.3.0)(typescript@6.0.3))(eslint@10.3.0)(typescript@6.0.3): dependencies: '@next/eslint-plugin-next': 16.2.3 - eslint: 10.3.0(jiti@1.21.7) + eslint: 10.3.0 eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@10.3.0(jiti@1.21.7)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.3(eslint@10.3.0(jiti@1.21.7))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@10.3.0(jiti@1.21.7)) - eslint-plugin-jsx-a11y: 6.10.2(eslint@10.3.0(jiti@1.21.7)) - eslint-plugin-react: 7.37.5(eslint@10.3.0(jiti@1.21.7)) - eslint-plugin-react-hooks: 7.1.1(eslint@10.3.0(jiti@1.21.7)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@10.3.0)(typescript@6.0.3))(eslint@10.3.0))(eslint@10.3.0) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.3(eslint@10.3.0)(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@10.3.0) + eslint-plugin-jsx-a11y: 6.10.2(eslint@10.3.0) + eslint-plugin-react: 7.37.5(eslint@10.3.0) + eslint-plugin-react-hooks: 7.1.1(eslint@10.3.0) globals: 16.4.0 - typescript-eslint: 8.59.3(eslint@10.3.0(jiti@1.21.7))(typescript@6.0.3) + typescript-eslint: 8.59.3(eslint@10.3.0)(typescript@6.0.3) optionalDependencies: typescript: 6.0.3 transitivePeerDependencies: @@ -2912,33 +2891,33 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@10.3.0(jiti@1.21.7)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@10.3.0)(typescript@6.0.3))(eslint@10.3.0))(eslint@10.3.0): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 - eslint: 10.3.0(jiti@1.21.7) + eslint: 10.3.0 get-tsconfig: 4.14.0 is-bun-module: 2.0.0 stable-hash: 0.0.5 tinyglobby: 0.2.16 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.3(eslint@10.3.0(jiti@1.21.7))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@10.3.0(jiti@1.21.7)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.3(eslint@10.3.0)(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@10.3.0) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.3(eslint@10.3.0(jiti@1.21.7))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@10.3.0(jiti@1.21.7)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.3(eslint@10.3.0)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@10.3.0)(typescript@6.0.3))(eslint@10.3.0))(eslint@10.3.0))(eslint@10.3.0): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.59.3(eslint@10.3.0(jiti@1.21.7))(typescript@6.0.3) - eslint: 10.3.0(jiti@1.21.7) + '@typescript-eslint/parser': 8.59.3(eslint@10.3.0)(typescript@6.0.3) + eslint: 10.3.0 eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@10.3.0(jiti@1.21.7)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@10.3.0)(typescript@6.0.3))(eslint@10.3.0))(eslint@10.3.0) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@10.3.0(jiti@1.21.7))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@10.3.0(jiti@1.21.7)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@10.3.0)(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@10.3.0): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -2947,9 +2926,9 @@ snapshots: array.prototype.flatmap: 1.3.3 debug: 3.2.7 doctrine: 2.1.0 - eslint: 10.3.0(jiti@1.21.7) + eslint: 10.3.0 eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.3(eslint@10.3.0(jiti@1.21.7))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@10.3.0(jiti@1.21.7)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.3(eslint@10.3.0)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@10.3.0)(typescript@6.0.3))(eslint@10.3.0))(eslint@10.3.0))(eslint@10.3.0) hasown: 2.0.3 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -2961,13 +2940,13 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.59.3(eslint@10.3.0(jiti@1.21.7))(typescript@6.0.3) + '@typescript-eslint/parser': 8.59.3(eslint@10.3.0)(typescript@6.0.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color - eslint-plugin-jsx-a11y@6.10.2(eslint@10.3.0(jiti@1.21.7)): + eslint-plugin-jsx-a11y@6.10.2(eslint@10.3.0): dependencies: aria-query: 5.3.2 array-includes: 3.1.9 @@ -2977,7 +2956,7 @@ snapshots: axobject-query: 4.1.0 damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 - eslint: 10.3.0(jiti@1.21.7) + eslint: 10.3.0 hasown: 2.0.3 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 @@ -2986,18 +2965,18 @@ snapshots: safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 - eslint-plugin-react-hooks@7.1.1(eslint@10.3.0(jiti@1.21.7)): + eslint-plugin-react-hooks@7.1.1(eslint@10.3.0): dependencies: '@babel/core': 7.29.0 '@babel/parser': 7.29.3 - eslint: 10.3.0(jiti@1.21.7) + eslint: 10.3.0 hermes-parser: 0.25.1 zod: 4.4.3 zod-validation-error: 4.0.2(zod@4.4.3) transitivePeerDependencies: - supports-color - eslint-plugin-react@7.37.5(eslint@10.3.0(jiti@1.21.7)): + eslint-plugin-react@7.37.5(eslint@10.3.0): dependencies: array-includes: 3.1.9 array.prototype.findlast: 1.2.5 @@ -3005,7 +2984,7 @@ snapshots: array.prototype.tosorted: 1.1.4 doctrine: 2.1.0 es-iterator-helpers: 1.3.2 - eslint: 10.3.0(jiti@1.21.7) + eslint: 10.3.0 estraverse: 5.3.0 hasown: 2.0.3 jsx-ast-utils: 3.3.5 @@ -3030,9 +3009,9 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.3.0(jiti@1.21.7): + eslint@10.3.0: dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.3.0(jiti@1.21.7)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.3.0) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.23.5 '@eslint/config-helpers': 0.5.5 @@ -3062,8 +3041,6 @@ snapshots: minimatch: 10.2.5 natural-compare: 1.4.0 optionator: 0.9.4 - optionalDependencies: - jiti: 1.21.7 transitivePeerDependencies: - supports-color @@ -3370,9 +3347,6 @@ snapshots: has-symbols: 1.1.0 set-function-name: 2.0.2 - jiti@1.21.7: - optional: true - js-tokens@4.0.0: {} jsesc@3.1.0: {} @@ -3560,6 +3534,8 @@ snapshots: picomatch@4.0.4: {} + pocketbase@0.27.0: {} + possible-typed-array-names@1.1.0: {} postcss-value-parser@4.2.0: {} @@ -3896,14 +3872,13 @@ snapshots: strnum@1.1.2: {} - styled-components@6.4.1(css-to-react-native@3.2.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + styled-components@6.4.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: '@emotion/is-prop-valid': 1.4.0 csstype: 3.2.3 react: 19.2.6 stylis: 4.3.6 optionalDependencies: - css-to-react-native: 3.2.0 react-dom: 19.2.6(react@19.2.6) styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.6): @@ -3976,13 +3951,13 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 - typescript-eslint@8.59.3(eslint@10.3.0(jiti@1.21.7))(typescript@6.0.3): + typescript-eslint@8.59.3(eslint@10.3.0)(typescript@6.0.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.59.3(@typescript-eslint/parser@8.59.3(eslint@10.3.0(jiti@1.21.7))(typescript@6.0.3))(eslint@10.3.0(jiti@1.21.7))(typescript@6.0.3) - '@typescript-eslint/parser': 8.59.3(eslint@10.3.0(jiti@1.21.7))(typescript@6.0.3) + '@typescript-eslint/eslint-plugin': 8.59.3(@typescript-eslint/parser@8.59.3(eslint@10.3.0)(typescript@6.0.3))(eslint@10.3.0)(typescript@6.0.3) + '@typescript-eslint/parser': 8.59.3(eslint@10.3.0)(typescript@6.0.3) '@typescript-eslint/typescript-estree': 8.59.3(typescript@6.0.3) - '@typescript-eslint/utils': 8.59.3(eslint@10.3.0(jiti@1.21.7))(typescript@6.0.3) - eslint: 10.3.0(jiti@1.21.7) + '@typescript-eslint/utils': 8.59.3(eslint@10.3.0)(typescript@6.0.3) + eslint: 10.3.0 typescript: 6.0.3 transitivePeerDependencies: - supports-color diff --git a/public/typing.json b/public/typing.json deleted file mode 100644 index f962d76..0000000 --- a/public/typing.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "K-POP": [ - { - "title": "CRAZY (English)", - "artist": "LE SSERAFIM", - "thumbnail": "https://file.garden/aeFyzu6P_R_1-oSm/LRC-TYPE/CRAZY%20(ENGLISH)%20LE%20SSERAFIM/iI5hnXYo5as-HD.jpg", - "code": "eyJmaWxlMSI6Imh0dHBzOi8vZmlsZS5nYXJkZW4vYWVGeXp1NlBfUl8xLW9TbS9MUkMtVFlQRS9DUkFaWSUyMChFTkdMSVNIKSUyMExFJTIwU1NFUkFGSU0vTEUlMjBTU0VSQUZJTSUyMCglRUIlQTUlQjQlRUMlODQlQjglRUIlOUQlQkMlRUQlOTUlOEMpJTIwJ0NSQVpZJTIwKEVuZ2xpc2glMjB2ZXIuKSclMjBPRkZJQ0lBTCUyME1WJTIwJTVCaUk1aG5YWW81YXMlNUQud2VibSIsImxyYyI6Imh0dHBzOi8vZmlsZS5nYXJkZW4vYWVGeXp1NlBfUl8xLW9TbS9MUkMtVFlQRS9DUkFaWSUyMChFTkdMSVNIKSUyMExFJTIwU1NFUkFGSU0vY3JhenkyLmxyYyIsIm9mZnNldCI6LTEzMDAsInRpdGxlIjoiQ1JBWlkgKEVOR0xJU0gpIiwiYXJ0aXN0IjoiTEUgU1NFUkFGSU0iLCJza2lwX2JhY2tpbmciOnRydWV9" - }, - { - "title": "1-800-hot-n-fun", - "artist": "LE SSERAFIM", - "thumbnail": "https://file.garden/aeFyzu6P_R_1-oSm/LRC-TYPE/LE%20SSERAFIM%201800-hotnfun/rGD5U8u1Dk0-HD.jpg", - "code": "eyJmaWxlMSI6Imh0dHBzOi8vZmlsZS5nYXJkZW4vYWVGeXp1NlBfUl8xLW9TbS9MUkMtVFlQRS9MRSUyMFNTRVJBRklNJTIwMTgwMC1ob3RuZnVuLzEtODAwLWhvdC1uLWZ1biUyMCU1QnJHRDVVOHUxRGswJTVELndlYm0iLCJscmMiOiJodHRwczovL2ZpbGUuZ2FyZGVuL2FlRnl6dTZQX1JfMS1vU20vTFJDLVRZUEUvTEUlMjBTU0VSQUZJTSUyMDE4MDAtaG90bmZ1bi8xODAwaG90bmZ1bi5scmMiLCJvZmZzZXQiOjAsInRpdGxlIjoiMS04MDAtaG90LW4tZnVuIiwiYXJ0aXN0IjoiTEUgU1NFUkFGSU0iLCJza2lwX2JhY2tpbmciOnRydWV9" - }, - { - "title": "All Night (Feat. Saweetie)", - "artist": "IVE", - "thumbnail": "https://file.garden/aeFyzu6P_R_1-oSm/LRC-TYPE/All%20Night%20-%20IVE/xU8mQMLx0tk-HD.jpg", - "code": "eyJmaWxlMSI6Imh0dHBzOi8vZmlsZS5nYXJkZW4vYWVGeXp1NlBfUl8xLW9TbS9MUkMtVFlQRS9BbGwlMjBOaWdodCUyMC0lMjBJVkUvSVZFJTIwJUVDJTk1JTg0JUVDJTlEJUI0JUVCJUI4JThDJTIwJ0FsbCUyME5pZ2h0JTIwKEZlYXQuJTIwU2F3ZWV0aWUpJyUyME9mZmljaWFsJTIwTXVzaWMlMjBWaWRlbyUyMCU1QnhVOG1RTUx4MHRrJTVELndlYm0iLCJscmMiOiJodHRwczovL2ZpbGUuZ2FyZGVuL2FlRnl6dTZQX1JfMS1vU20vTFJDLVRZUEUvQWxsJTIwTmlnaHQlMjAtJTIwSVZFL2FsbF9uaWdodF9pdmUubHJjIiwib2Zmc2V0IjotMTQyMCwidGl0bGUiOiJBbGwgTmlnaHQgKEZlYXQuIFNhd2VldGllKSIsImFydGlzdCI6IklWRSIsInNraXBfYmFja2luZyI6dHJ1ZX0=" - }, - { - "title": "Blue Valentine (English Ver.)", - "artist": "NMIXX", - "thumbnail": "https://file.garden/aeFyzu6P_R_1-oSm/LRC-TYPE/Blue%20Valentine%20(ENG)%20-%20NMIXX/4p22W2KaIIw-HD.jpg", - "code": "eyJmaWxlMSI6Imh0dHBzOi8vZmlsZS5nYXJkZW4vYWVGeXp1NlBfUl8xLW9TbS9MUkMtVFlQRS9CbHVlJTIwVmFsZW50aW5lJTIwKEVORyklMjAtJTIwTk1JWFgvQmx1ZSUyMFZhbGVudGluZSUyMChFbmdsaXNoJTIwVmVyLiklMjAlNUI0cDIyVzJLYUlJdyU1RC53ZWJtIiwibHJjIjoiaHR0cHM6Ly9maWxlLmdhcmRlbi9hZUZ5enU2UF9SXzEtb1NtL0xSQy1UWVBFL0JsdWUlMjBWYWxlbnRpbmUlMjAoRU5HKSUyMC0lMjBOTUlYWC9ibHVlX3ZhbGVudGluZS5scmMiLCJ0aXRsZSI6IkJsdWUgVmFsZW50aW5lIChFbmdsaXNoIFZlci4pIiwiYXJ0aXN0IjoiTk1JWFgiLCJza2lwX2JhY2tpbmciOnRydWV9" - }, - { - "title": "The Feels", - "artist": "TWICE", - "thumbnail": "https://file.garden/aeFyzu6P_R_1-oSm/LRC-TYPE/The%20Feels%20-%20Twice/pQBntSH-qFA-HD.jpg", - "code": "eyJmaWxlMSI6Imh0dHBzOi8vZmlsZS5nYXJkZW4vYWVGeXp1NlBfUl8xLW9TbS9MUkMtVFlQRS9UaGUlMjBGZWVscyUyMC0lMjBUd2ljZS9UaGUlMjBGZWVscyUyMCU1QnBRQm50U0gtcUZBJTVELndlYm0iLCJscmMiOiJodHRwczovL2ZpbGUuZ2FyZGVuL2FlRnl6dTZQX1JfMS1vU20vTFJDLVRZUEUvVGhlJTIwRmVlbHMlMjAtJTIwVHdpY2UvdGhlX2ZlZWxzXzIubHJjIiwidGl0bGUiOiJUaGUgRmVlbHMiLCJhcnRpc3QiOiJUV0lDRSIsInNraXBfYmFja2luZyI6dHJ1ZX0=" - }, - { - "title": "Cupid (TwinVer.)", - "artist": "FIFTY FIFTY", - "thumbnail": "https://file.garden/aeFyzu6P_R_1-oSm/LRC-TYPE/CUPID%20twins%20-%20FIFTY%20FIFTY/FIFTY%20FIFTY%20(%ED%94%BC%ED%94%84%ED%8B%B0%ED%94%BC%ED%94%84%ED%8B%B0)%20-%20'Cupid'%20(TwinVer.)%20Official%20Lyric%20Video%20%5B6uvUTu716rU%5D.jpg", - "code": "eyJmaWxlMSI6Imh0dHBzOi8vZmlsZS5nYXJkZW4vYWVGeXp1NlBfUl8xLW9TbS9MUkMtVFlQRS9DVVBJRCUyMHR3aW5zJTIwLSUyMEZJRlRZJTIwRklGVFkvRklGVFklMjBGSUZUWSUyMCglRUQlOTQlQkMlRUQlOTQlODQlRUQlOEIlQjAlRUQlOTQlQkMlRUQlOTQlODQlRUQlOEIlQjApJTIwLSUyMCdDdXBpZCclMjAoVHdpblZlci4pJTIwT2ZmaWNpYWwlMjBMeXJpYyUyMFZpZGVvJTIwJTVCNnV2VVR1NzE2clUlNUQud2VibSIsImxyYyI6Imh0dHBzOi8vZmlsZS5nYXJkZW4vYWVGeXp1NlBfUl8xLW9TbS9MUkMtVFlQRS9DVVBJRCUyMHR3aW5zJTIwLSUyMEZJRlRZJTIwRklGVFkvY3VwaWQubHJjIiwidGl0bGUiOiJDdXBpZCAoVHdpbiBWZXIuKSIsImFydGlzdCI6IkZJRlRZIEZJRlRZIiwic2tpcF9iYWNraW5nIjp0cnVlfQ==" - }, - { - "title": "LOUD", - "artist": "NMIXX", - "thumbnail": "https://file.garden/aeFyzu6P_R_1-oSm/LRC-TYPE/LOUD%20NMIXX/NMIXX(%EC%97%94%EB%AF%B9%EC%8A%A4)%20%E2%80%9CLOUD%E2%80%9D%20(Official%20Audio)%20%5Be7t8JzqVXcs%5D.webp", - "code": "eyJmaWxlMSI6Imh0dHBzOi8vZmlsZS5nYXJkZW4vYWVGeXp1NlBfUl8xLW9TbS9MUkMtVFlQRS9MT1VEJTIwTk1JWFgvTk1JWFgoJUVDJTk3JTk0JUVCJUFGJUI5JUVDJThBJUE0KSUyMCVFMiU4MCU5Q0xPVUQlRTIlODAlOUQlMjAoT2ZmaWNpYWwlMjBBdWRpbyklMjAlNUJlN3Q4SnpxVlhjcyU1RC53ZWJtIiwibHJjIjoiaHR0cHM6Ly9maWxlLmdhcmRlbi9hZUZ5enU2UF9SXzEtb1NtL0xSQy1UWVBFL0xPVUQlMjBOTUlYWC9sb3VkLmxyYyIsInRpdGxlIjoiTE9VRCIsImFydGlzdCI6Ik5NSVhYIiwic2tpcF9iYWNraW5nIjp0cnVlfQ==" - } - ], - "J-POP": [ - { - "title": "L-O-V-E", - "artist": "Aimer", - "thumbnail": "https://file.garden/aeFyzu6P_R_1-oSm/LRC-TYPE/LOVE%20-%20AIMER/L-O-V-E%20%5BSiOQq8XiSiw%5D.jpg", - "code": "eyJmaWxlMSI6Imh0dHBzOi8vZmlsZS5nYXJkZW4vYWVGeXp1NlBfUl8xLW9TbS9MUkMtVFlQRS9MT1ZFJTIwLSUyMEFJTUVSL0wtTy1WLUUlMjAlNUJTaU9RcThYaVNpdyU1RC53ZWJtIiwibHJjIjoiaHR0cHM6Ly9maWxlLmdhcmRlbi9hZUZ5enU2UF9SXzEtb1NtL0xSQy1UWVBFL0xPVkUlMjAtJTIwQUlNRVIvbG92ZS5scmMiLCJ0aXRsZSI6IkwtTy1WLUUiLCJhcnRpc3QiOiJBaW1lciIsInNraXBfYmFja2luZyI6dHJ1ZX0=" - } - ] -} diff --git a/src/app/context/auth.tsx b/src/app/context/auth.tsx new file mode 100644 index 0000000..f7e7cc2 --- /dev/null +++ b/src/app/context/auth.tsx @@ -0,0 +1,69 @@ +"use client"; +import { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from "react"; +import type { RecordModel } from "pocketbase"; +import pb from "../lib/pocketbase"; + +interface AuthContextValue { + user: RecordModel | null; + loading: boolean; + signIn: (email: string, password: string) => Promise; + signUp: (email: string, username: string, password: string, passwordConfirm: string) => Promise; + signOut: () => void; +} + +const AuthContext = createContext({ + user: null, + loading: true, + signIn: async () => {}, + signUp: async () => {}, + signOut: () => {}, +}); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + setUser(pb.authStore.record ?? null); + setLoading(false); + const unsub = pb.authStore.onChange(() => { + setUser(pb.authStore.record ?? null); + }); + return () => unsub(); + }, []); + + const signIn = useCallback(async (email: string, password: string) => { + await pb.collection("users").authWithPassword(email, password); + setUser(pb.authStore.record ?? null); + }, []); + + const signUp = useCallback( + async (email: string, username: string, password: string, passwordConfirm: string) => { + await pb.collection("users").create({ email, username, password, passwordConfirm }); + await pb.collection("users").authWithPassword(email, password); + setUser(pb.authStore.record ?? null); + }, + [] + ); + + const signOut = useCallback(() => { + pb.authStore.clear(); + setUser(null); + }, []); + + return ( + + {children} + + ); +} + +export function useAuth() { + return useContext(AuthContext); +} diff --git a/src/app/create/page.styles.ts b/src/app/create/page.styles.ts deleted file mode 100644 index b54e095..0000000 --- a/src/app/create/page.styles.ts +++ /dev/null @@ -1,168 +0,0 @@ -import styled from "styled-components"; - -export const Content = styled.div` - max-width: 600px; - margin: 40px auto; - padding: 0 24px 60px; -`; - -export const Heading = styled.h1` - font-size: 22px; - font-weight: 800; - margin: 0 0 4px; -`; - -export const Subheading = styled.p` - font-size: 13px; - color: #909090; - margin: 0 0 32px; -`; - -export const Form = styled.div` - display: flex; - flex-direction: column; - gap: 14px; -`; - -export const FieldGroup = styled.div` - display: flex; - flex-direction: column; - gap: 5px; -`; - -export const Label = styled.label` - font-size: 12px; - font-weight: 600; - color: #606060; - text-transform: uppercase; - letter-spacing: 0.5px; -`; - -export const Input = styled.input` - height: 40px; - padding: 0 12px; - border: 1px solid #d4d4d4; - border-radius: 8px; - font-size: 14px; - color: #1a1a1a; - background-color: #fff; - transition: border-color 0.15s; - &:focus { - outline: none; - border-color: #1a1a1a; - } - &::placeholder { - color: #b0b0b0; - } -`; - -export const Divider = styled.div` - height: 1px; - background-color: #e5e5e5; - margin: 6px 0; -`; - -export const Row = styled.div` - display: grid; - grid-template-columns: 1fr 1fr; - gap: 12px; -`; - -export const GenerateButton = styled.button` - height: 42px; - padding: 0 24px; - border-radius: 10px; - border: none; - background-color: #1a1a1a; - color: #fff; - font-size: 14px; - font-weight: 600; - cursor: pointer; - transition: background-color 0.15s; - margin-top: 6px; - &:hover { - background-color: #333; - } -`; - -export const ModeButton = styled.button<{ $active: boolean }>` - height: 42px; - padding: 0 24px; - border-radius: 10px; - border: none; - cursor: pointer; - font-size: 14px; - font-weight: 600; - background-color: ${(p) => (p.$active ? "#1a1a1a" : "#e5e5e5")}; - color: ${(p) => (p.$active ? "#fff" : "#1a1a1a")}; - transition: background-color 0.15s; -`; - -export const OutputSection = styled.div` - margin-top: 32px; - display: flex; - flex-direction: column; - gap: 14px; -`; - -export const OutputLabel = styled.div` - font-size: 12px; - font-weight: 600; - color: #606060; - text-transform: uppercase; - letter-spacing: 0.5px; - margin-bottom: 5px; -`; - -export const CodeBox = styled.div` - position: relative; - background-color: #f0f0f0; - border: 1px solid #d4d4d4; - border-radius: 10px; - padding: 14px 48px 14px 14px; - font-family: "Courier New", monospace; - font-size: 13px; - color: #1a1a1a; - word-break: break-all; - line-height: 1.5; -`; - -export const CopyButton = styled.button<{ $copied: boolean }>` - position: absolute; - top: 10px; - right: 10px; - width: 30px; - height: 30px; - border-radius: 6px; - border: none; - background-color: ${(p) => (p.$copied ? "#22c55e" : "#d4d4d4")}; - color: ${(p) => (p.$copied ? "#fff" : "#606060")}; - font-size: 13px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: background-color 0.15s, color 0.15s; - &:hover { - background-color: ${(p) => (p.$copied ? "#16a34a" : "#c0c0c0")}; - color: #1a1a1a; - } -`; - -export const OpenLink = styled.a` - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 13px; - font-weight: 500; - color: #1a1a1a; - text-decoration: none; - border: 1px solid #d4d4d4; - border-radius: 8px; - padding: 8px 14px; - background-color: #fff; - transition: background-color 0.15s; - &:hover { - background-color: #f0f0f0; - } -`; diff --git a/src/app/create/page.tsx b/src/app/create/page.tsx deleted file mode 100644 index 744ab95..0000000 --- a/src/app/create/page.tsx +++ /dev/null @@ -1,207 +0,0 @@ -"use client"; -import { useState } from "react"; -import { MdLibraryMusic } from "react-icons/md"; -import { FaCopy, FaCheck, FaExternalLinkAlt } from "react-icons/fa"; -import { Root, Navbar, Logo, LogoIcon, NavLink } from "../styles/shared"; -import { - Content, - Heading, - Subheading, - Form, - FieldGroup, - Label, - Input, - Divider, - Row, - GenerateButton, - OutputSection, - OutputLabel, - CodeBox, - CopyButton, - OpenLink, -} from "./page.styles"; - -interface TypingPayload { - file1?: string; - lrc?: string; - offset?: number; - title?: string; - artist?: string; - skip_backing?: boolean; -} - -export default function CreatePage() { - const [lrc, setLrc] = useState(""); - const [file1, setFile1] = useState(""); - const [offset, setOffset] = useState(""); - - const [typingTitle, setTypingTitle] = useState(""); - const [typingArtist, setTypingArtist] = useState(""); - const [skipBacking, setSkipBacking] = useState(true); - - const [code, setCode] = useState(null); - const [copiedCode, setCopiedCode] = useState(false); - const [copiedUrl, setCopiedUrl] = useState(false); - - const resetCopyStates = () => { - setCopiedCode(false); - setCopiedUrl(false); - }; - - const generate = () => { - const payload: TypingPayload = {}; - if (file1.trim()) payload.file1 = file1.trim(); - if (lrc.trim()) payload.lrc = lrc.trim(); - if (offset.trim() !== "") payload.offset = Number(offset); - if (typingTitle.trim()) payload.title = typingTitle.trim(); - if (typingArtist.trim()) payload.artist = typingArtist.trim(); - payload.skip_backing = skipBacking; - - setCode(btoa(JSON.stringify(payload))); - resetCopyStates(); - }; - - const copy = (text: string, which: "code" | "url") => { - navigator.clipboard.writeText(text); - if (which === "code") { - setCopiedCode(true); - setTimeout(() => setCopiedCode(false), 2000); - } else { - setCopiedUrl(true); - setTimeout(() => setCopiedUrl(false), 2000); - } - }; - - const shareUrl = code ? `${window.location.origin}/game?code=${code}` : ""; - - return ( - - - - - - - LRC-Type - - ← Back - - - - Create a Code - - Generate a shareable code for your typing game session. - - -
- - - setFile1(e.target.value)} - /> - - - - - setLrc(e.target.value)} - /> - - - - - setOffset(e.target.value)} - step="25" - /> - - - - - - - - setTypingTitle(e.target.value)} - /> - - - - setTypingArtist(e.target.value)} - /> - - - - - - - setSkipBacking(e.target.checked)} - style={{ width: "18px", height: "18px", marginTop: "10px" }} - /> - - - - Generate Code - - - {code && ( - -
- Code - - {code} - copy(code, "code")} - aria-label="Copy code" - > - {copiedCode ? : } - - -
- -
- Share URL - - {shareUrl} - copy(shareUrl, "url")} - aria-label="Copy URL" - > - {copiedUrl ? : } - - -
- - - Open in Typing Game - -
- )} -
-
- ); -} diff --git a/src/app/game/[slug]/game.stat.ts b/src/app/game/[slug]/game.stat.ts new file mode 100644 index 0000000..43136e6 --- /dev/null +++ b/src/app/game/[slug]/game.stat.ts @@ -0,0 +1,82 @@ +export interface GState { + displayedLineIdx: number; + typedCount: number; + lineCompleted: boolean; + combo: number; + maxCombo: number; + score: number; + totalCorrect: number; + totalMiss: number; + linesCleared: number; + wpm: number; +} + +export type GAction = + | { type: "ADVANCE"; newIdx: number; prevCompleted: boolean } + | { type: "CORRECT"; willComplete: boolean } + | { type: "WRONG" } + | { type: "RESET" }; + +export const initialGState: GState = { + displayedLineIdx: -1, + typedCount: 0, + lineCompleted: false, + combo: 0, + maxCombo: 0, + score: 0, + totalCorrect: 0, + totalMiss: 0, + linesCleared: 0, + wpm: 0, +}; + +export function gReducer(state: GState, action: GAction): GState { + switch (action.type) { + case "ADVANCE": { + const prevIdx = state.displayedLineIdx; + const comboReset = !action.prevCompleted && prevIdx >= 0; + return { + ...state, + displayedLineIdx: action.newIdx, + typedCount: 0, + lineCompleted: false, + combo: comboReset ? 0 : state.combo, + }; + } + + case "CORRECT": { + const newTypedCount = state.typedCount + 1; + const newCombo = state.combo + 1; + const newMaxCombo = Math.max(state.maxCombo, newCombo); + const comboBonus = Math.min(50, Math.floor(newCombo / 10) * 5); + const newScore = state.score + 10 + comboBonus; + const newTotalCorrect = state.totalCorrect + 1; + if (action.willComplete) { + return { + ...state, + typedCount: newTypedCount, + lineCompleted: true, + combo: newCombo, + maxCombo: newMaxCombo, + score: newScore, + totalCorrect: newTotalCorrect, + linesCleared: state.linesCleared + 1, + }; + } + return { + ...state, + typedCount: newTypedCount, + combo: newCombo, + maxCombo: newMaxCombo, + score: newScore, + totalCorrect: newTotalCorrect, + }; + } + case "WRONG": + return { ...state, totalMiss: state.totalMiss + 1, combo: 0 }; + case "RESET": + return { ...initialGState }; + default: + return state; + } +} \ No newline at end of file diff --git a/src/app/game/[slug]/game.utils.ts b/src/app/game/[slug]/game.utils.ts new file mode 100644 index 0000000..b2037e5 --- /dev/null +++ b/src/app/game/[slug]/game.utils.ts @@ -0,0 +1,55 @@ +export interface GameLine { + millisecond: number; + content: string; +} + +export function parseLrcLines( + lrcText: string, + options?: { skipBacking?: boolean } +): GameLine[] { + const result: GameLine[] = []; + const lineRegex = /\[(\d{2,3}):(\d{2})\.(\d{2,3})\]/g; + const { skipBacking = false } = options ?? {}; + + for (const rawLine of lrcText.split("\n")) { + const timestamps: number[] = []; + let match: RegExpExecArray | null; + let lastIndex = 0; + + lineRegex.lastIndex = 0; + while ((match = lineRegex.exec(rawLine)) !== null) { + const minutes = parseInt(match[1], 10); + const seconds = parseInt(match[2], 10); + const msField = match[3]; + const ms = + msField.length === 2 + ? parseInt(msField, 10) * 10 + : parseInt(msField, 10); + timestamps.push(minutes * 60_000 + seconds * 1_000 + ms); + lastIndex = match.index + match[0].length; + } + + if (timestamps.length === 0) continue; + + const content = (skipBacking + ? rawLine.slice(lastIndex).replace(/\([^)]*\)/g, "") + : rawLine.slice(lastIndex) + ).trim(); + + for (const ms of timestamps) { + result.push({ millisecond: ms, content }); + } + } + + result.sort((a, b) => a.millisecond - b.millisecond); + return result; +} + +export function calculateCPSNeeded(text: string, seconds: number): number { + return text.length / seconds; +} + +export function formatTime(ms: number): string { + const s = Math.max(0, Math.floor(ms / 1000)); + return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; +} diff --git a/src/app/game/[slug]/page.styles.ts b/src/app/game/[slug]/page.styles.ts new file mode 100644 index 0000000..1b5880b --- /dev/null +++ b/src/app/game/[slug]/page.styles.ts @@ -0,0 +1,650 @@ +import styled, { keyframes, css, createGlobalStyle } from "styled-components"; + +/* ----- ANIMATIONS ----- */ + +export const pulseAnim = keyframes` + 0% { opacity: 1; } + 50% { opacity: 0.4; } + 100% { opacity: 1; } +`; + +export const wrongShakeAnim = keyframes` + 0% { transform: translateX(0); } + 20% { transform: translateX(-4px); } + 40% { transform: translateX(4px); } + 60% { transform: translateX(-4px); } + 80% { transform: translateX(4px); } + 100% { transform: translateX(0); } +`; + +export const clearPopAnim = keyframes` + 0% { transform: scale(0.8); opacity: 0; } + 40% { transform: scale(1.2); opacity: 1; } + 100% { transform: scale(1.0); opacity: 0; } +`; + +export const fadeInUpAnim = keyframes` + 0% { transform: translateY(10px); opacity: 0; } + 100% { transform: translateY(0); opacity: 1; } +`; + +export const comboScaleAnim = keyframes` + 0% { transform: scale(1); } + 50% { transform: scale(1.4); } + 100% { transform: scale(1); } +`; + +export const glowAnim = keyframes` + 0% { box-shadow: 0 0 4px 0px rgba(124, 58, 237, 0.4); } + 50% { box-shadow: 0 0 16px 4px rgba(124, 58, 237, 0.8); } + 100% { box-shadow: 0 0 4px 0px rgba(124, 58, 237, 0.4); } +`; + +export const GameGlobalStyle = createGlobalStyle` + html, + body { + height: 100%; + overflow: hidden; + } +`; + +/* ----- LAYOUT ----- */ + +export const GameRoot = styled.div` + position: fixed; + inset: 0; + display: flex; + flex-direction: column; + background: #0d0d14; + color: #ffffff; + font-family: "Roboto", "Segoe UI", Arial, sans-serif; + overflow: hidden; + z-index: 0; +`; + +export const BackgroundVideo = styled.video` + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + pointer-events: none; + z-index: 0; +`; + +export const GameNavbar = styled.nav` + position: sticky; + top: 0; + display: flex; + flex-direction: row; + align-items: center; + height: 52px; + padding: 0 20px; + background: rgba(13, 13, 20, 0.75); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + z-index: 20; +`; + +export const GameContent = styled.div` + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + position: relative; + z-index: 1; +`; + +/* ----- HUD ----- */ + +export const HUD = styled.div` + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 24px; + padding: 10px 24px; + background: rgba(13, 13, 20, 0.75); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + position: relative; + z-index: 2; +`; + +export const HudStat = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +`; + +export const HudValue = styled.span` + font-size: 22px; + font-weight: 700; + color: #ffffff; +`; + +export const HudLabel = styled.span` + font-size: 10px; + letter-spacing: 1.5px; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.45); +`; + +export const ComboValue = styled(HudValue)<{ $animate: boolean }>` + ${({ $animate }) => + $animate && + css` + animation: ${comboScaleAnim} 0.25s ease; + `} +`; + +/* ----- MAIN GAME AREA ----- */ + +export const GameArea = styled.div` + position: relative; + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 24px 32px; + gap: 24px; + overflow: hidden; + + & > * { + position: relative; + z-index: 1; + } +`; + +export const UpcomingWrap = styled.div` + width: 100%; + max-width: 800px; + display: flex; + flex-direction: column; + gap: 6px; +`; + +export const UpcomingLabel = styled.span` + font-size: 10px; + letter-spacing: 2px; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.30); + margin-bottom: 2px; +`; + +export const UpcomingText = styled.p` + font-size: 20px; + color: rgba(255, 255, 255, 0.30); + font-weight: 400; + font-family: "Inter", "Segoe UI", "Helvetica Neue", Arial, sans-serif; + min-height: 28px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin: 0; +`; + +export const CurrentWrap = styled.div` + width: 100%; + max-width: 800px; + display: flex; + flex-direction: column; + gap: 10px; +`; + +export const LineTimingRow = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +`; + +export const LineTimingMeta = styled.div` + display: flex; + align-items: center; + justify-content: flex-start; + font-size: 13px; + letter-spacing: 1px; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.45); +`; + +export const LineTimingValue = styled.span` + font-variant-numeric: tabular-nums; +`; + +export const LineTimingBar = styled.div` + width: 100%; + height: 3px; + background: rgba(255, 255, 255, 0.12); + overflow: hidden; +`; + +export const LineTimingFill = styled.div.attrs<{ $pct: number }>((props) => ({ + style: { + transform: `scaleX(${props.$pct / 100})`, + }, +}))<{ $pct: number }>` + height: 100%; + width: 100%; + background: #7c3aed; + transform-origin: left; + will-change: transform; +`; + +export const CharRow = styled.div` + display: flex; + flex-wrap: wrap; + gap: 2px; + align-items: flex-end; + min-height: 64px; +`; + +export const WordWrap = styled.span` + display: inline-flex; + gap: 2px; + white-space: nowrap; +`; + +export const CharBox = styled.span<{ + $state: "typed" | "active" | "pending" | "wrong"; +}>` + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 36px; + font-weight: 700; + font-family: "Inter", "Segoe UI", "Helvetica Neue", Arial, sans-serif; + padding: 0 3px; + transition: all 0.08s ease; + + ${({ $state }) => { + switch ($state) { + case "typed": + return css` + color: #22c55e; + opacity: 0.7; + `; + case "active": + return css` + color: #fbbf24; + border-bottom: 3px solid #fbbf24; + animation: ${pulseAnim} 1s ease infinite; + `; + case "pending": + return css` + color: rgba(255, 255, 255, 0.25); + `; + case "wrong": + return css` + color: #ef4444; + animation: ${wrongShakeAnim} 0.3s ease; + `; + } + }} +`; + +export const ClearToast = styled.div` + position: absolute; + font-size: 28px; + font-weight: 800; + color: #22c55e; + animation: ${clearPopAnim} 0.7s ease forwards; + pointer-events: none; +`; + +export const GetReadyText = styled.p` + font-size: 28px; + color: rgba(255, 255, 255, 0.50); + font-weight: 500; + text-align: center; + animation: ${pulseAnim} 1.5s ease infinite; + margin: 0; +`; + +export const CompletedLineFade = styled.div` + font-size: 18px; + color: rgba(255, 255, 255, 0.20); + margin-top: 4px; + min-height: 26px; + transition: opacity 0.3s; +`; + +/* ----- FOOTER ----- */ + +export const GameFooter = styled.footer` + display: flex; + align-items: center; + gap: 12px; + padding: 12px 24px; + background: rgba(255, 255, 255, 0.04); + border-top: 1px solid rgba(255, 255, 255, 0.06); + position: relative; + z-index: 2; +`; + +export const ControlBtn = styled.button` + width: 40px; + height: 40px; + border: 1px solid rgba(255, 255, 255, 0.20); + background: rgba(255, 255, 255, 0.08); + color: #ffffff; + font-size: 14px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: background 0.15s; + + &:hover { + background: rgba(255, 255, 255, 0.15); + } +`; + +export const ProgressWrap = styled.div` + flex: 1; + height: 6px; + background: rgba(255, 255, 255, 0.12); + overflow: hidden; + cursor: pointer; +`; + +export const ProgressFill = styled.div.attrs<{ $pct: number }>((props) => ({ + style: { + width: `${props.$pct}%`, + }, +}))<{ $pct: number }>` + height: 100%; + background: #7c3aed; + transition: width 0.3s linear; +`; + +export const TimeText = styled.span` + font-size: 12px; + color: rgba(255, 255, 255, 0.50); + font-family: monospace; + white-space: nowrap; +`; + +/* ----- START SCREEN ----- */ + +export const StartOverlay = styled.div` + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(13, 13, 20, 0.96); + z-index: 10; + animation: ${fadeInUpAnim} 0.4s ease; +`; + +export const StartCard = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; + padding: 40px; + max-width: 520px; + width: 100%; + text-align: center; +`; + +export const OpacityControl = styled.div` + width: 100%; + display: flex; + flex-direction: column; + gap: 8px; + align-items: stretch; +`; + +export const OpacityLabel = styled.div` + font-size: 11px; + color: rgba(255, 255, 255, 0.45); + letter-spacing: 1px; + text-transform: uppercase; + display: flex; + justify-content: space-between; + align-items: center; +`; + +export const OpacityValue = styled.span` + font-variant-numeric: tabular-nums; +`; + +export const OpacitySlider = styled.input` + width: 100%; +`; + +export const PreviewWrap = styled.div` + width: 100%; + display: flex; + flex-direction: column; + gap: 8px; +`; + +export const PreviewBtn = styled.button` + width: 100%; + padding: 10px 16px; + border: 1px solid rgba(255, 255, 255, 0.2); + background: rgba(255, 255, 255, 0.08); + color: #ffffff; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; + + &:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.16); + } + + &:disabled { + opacity: 0.45; + cursor: not-allowed; + } +`; + +export const PreviewHint = styled.div` + font-size: 11px; + color: rgba(255, 255, 255, 0.45); + text-align: center; +`; + +export const CountdownNumber = styled.div` + font-size: 72px; + font-weight: 900; + color: #ffffff; + line-height: 1; + letter-spacing: 2px; +`; + +export const SongTitleText = styled.h1` + font-size: 32px; + font-weight: 800; + color: #ffffff; + line-height: 1.2; + margin: 0; +`; + +export const SongArtistText = styled.p` + font-size: 16px; + color: rgba(255, 255, 255, 0.50); + margin: 0; +`; + +export const StartBtn = styled.button` + padding: 14px 40px; + background: #7c3aed; + color: #ffffff; + font-size: 18px; + font-weight: 700; + border: none; + cursor: pointer; + transition: all 0.15s; + + &:hover:not(:disabled) { + background: #6d28d9; + transform: translateY(-2px); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +`; + +export const CodeSection = styled.div` + width: 100%; + display: flex; + flex-direction: column; + gap: 8px; +`; + +export const CodeInputRow = styled.div` + display: flex; + gap: 8px; +`; + +export const CodeInputField = styled.input` + flex: 1; + padding: 8px 12px; + border: 1px solid rgba(255, 255, 255, 0.15); + background: rgba(255, 255, 255, 0.06); + color: #ffffff; + font-size: 13px; + outline: none; + transition: border-color 0.15s; + + &:focus { + border-color: rgba(255, 255, 255, 0.35); + } + + &::placeholder { + color: rgba(255, 255, 255, 0.30); + } +`; + +export const CodeLoadBtn = styled.button` + padding: 8px 16px; + border: 1px solid rgba(255, 255, 255, 0.15); + background: rgba(255, 255, 255, 0.08); + color: #ffffff; + font-size: 13px; + cursor: pointer; + white-space: nowrap; + transition: background 0.15s; + + &:hover { + background: rgba(255, 255, 255, 0.15); + } +`; + +/* ----- RESULTS SCREEN ----- */ + +export const ResultsOverlay = styled.div` + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(13, 13, 20, 0.96); + z-index: 10; + animation: ${fadeInUpAnim} 0.4s ease; +`; + +export const ResultsCard = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 24px; + padding: 40px; + max-width: 540px; + width: 100%; + text-align: center; +`; + +export const ResultsTitle = styled.p` + font-size: 14px; + letter-spacing: 3px; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.40); + margin: 0; +`; + +export const BigScore = styled.h2` + font-size: 64px; + font-weight: 900; + color: #ffffff; + line-height: 1; + margin: 0; +`; + +export const StatsGrid = styled.div` + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; + width: 100%; +`; + +export const StatBlock = styled.div` + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.08); + padding: 16px; + display: flex; + flex-direction: column; + gap: 4px; +`; + +export const StatValue = styled.span` + font-size: 28px; + font-weight: 700; + color: #ffffff; +`; + +export const StatLabel = styled.span` + font-size: 11px; + letter-spacing: 1.5px; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.40); +`; + +export const ActionRow = styled.div` + display: flex; + gap: 12px; +`; + +export const PlayAgainBtn = styled.button` + padding: 10px 28px; + background: #7c3aed; + color: #ffffff; + font-size: 15px; + font-weight: 700; + border: none; + cursor: pointer; + transition: all 0.15s; + + &:hover { + background: #6d28d9; + transform: translateY(-2px); + } +`; + +export const HomeBtn = styled.button` + padding: 10px 28px; + background: transparent; + border: 1px solid rgba(255, 255, 255, 0.20); + color: #ffffff; + font-size: 15px; + font-weight: 700; + cursor: pointer; + transition: all 0.15s; + + &:hover { + background: rgba(255, 255, 255, 0.08); + } +`; diff --git a/src/app/game/[slug]/page.tsx b/src/app/game/[slug]/page.tsx new file mode 100644 index 0000000..9d449ce --- /dev/null +++ b/src/app/game/[slug]/page.tsx @@ -0,0 +1,999 @@ +"use client"; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useReducer, + useRef, + useState, + Suspense, +} from "react"; +import { useRouter, useParams } from "next/navigation"; +import pb from "../../lib/pocketbase"; +import Link from "next/link"; +import { FaRedo } from "react-icons/fa"; +import { MdLibraryMusic } from "react-icons/md"; +import { toast, ToastContainer } from "react-toastify"; +import "react-toastify/dist/ReactToastify.css"; +import { + GameRoot, + GameGlobalStyle, + GameNavbar, + GameContent, + HUD, + HudStat, + HudValue, + HudLabel, + ComboValue, + GameArea, + UpcomingWrap, + UpcomingLabel, + UpcomingText, + CurrentWrap, + LineTimingMeta, + LineTimingValue, + LineTimingRow, + LineTimingBar, + LineTimingFill, + CharRow, + WordWrap, + CharBox, + ClearToast, + GetReadyText, + BackgroundVideo, + OpacityControl, + OpacityLabel, + OpacitySlider, + OpacityValue, + PreviewWrap, + PreviewBtn, + PreviewHint, + CompletedLineFade, + GameFooter, + ControlBtn, + ProgressWrap, + ProgressFill, + TimeText, + StartOverlay, + StartCard, + CountdownNumber, + SongTitleText, + SongArtistText, + StartBtn, + ResultsOverlay, + ResultsCard, + ResultsTitle, + BigScore, + StatsGrid, + StatBlock, + StatValue, + StatLabel, + ActionRow, + PlayAgainBtn, + HomeBtn, +} from "./page.styles"; +import { gReducer, initialGState } from "./game.stat"; +import { formatTime, parseLrcLines, calculateCPSNeeded } from "./game.utils"; + +type GamePhase = "idle" | "countdown" | "playing" | "paused" | "finished"; + +const VIDEO_EXTENSIONS = new Set(["mp4", "webm", "mov", "m4v", "ogv"]); +const isVideoUrl = (url: string) => { + if (!url) return false; + const cleaned = url.split("?")[0].split("#")[0]; + const ext = cleaned.split(".").pop()?.toLowerCase(); + return !!ext && VIDEO_EXTENSIONS.has(ext); +}; +const BACKGROUND_OPACITY_KEY = "lrcType.backgroundOpacity"; +const AUDIO_VOLUME_KEY = "lrcType.audioVolume"; + +function GameInner() { + const params = useParams<{ slug: string }>(); + const slug = params?.slug ?? ""; + const router = useRouter(); + + useEffect(() => { + window.scrollTo({ top: 0, left: 0, behavior: "auto" }); + }, []); + + const audioRef = useRef(null); + const videoRef = useRef(null); + const gameStartTimeRef = useRef(0); + const lastHandledIdxRef = useRef(-1); + const lastLineAdvanceAtRef = useRef(0); + + const [phase, setPhase] = useState("idle"); + const [currentMs, setCurrentMs] = useState(0); + const [lineTimingPct, setLineTimingPct] = useState(0); + const [lineRemainingMs, setLineRemainingMs] = useState(0); + const [currentLineTime, setCurrentLineTime] = useState(0); + const [duration, setDuration] = useState(0); + const [progressPct, setProgressPct] = useState(0); + const [gameDurationMs, setGameDurationMs] = useState(0); + + const [lrcContent, setLrcContent] = useState(""); + const [audioUrl, setAudioUrl] = useState(""); + const [songTitle, setSongTitle] = useState("Unknown Title"); + const [songArtist, setSongArtist] = useState("Unknown Artist"); + const [offset, setOffset] = useState(0); + const [loadingLrc, setLoadingLrc] = useState(false); + + const [wrongChar, setWrongChar] = useState(false); + const [clearShowing, setClearShowing] = useState(false); + const [comboAnimKey, setComboAnimKey] = useState(0); + const [countdown, setCountdown] = useState(0); + const [backgroundOpacity, setBackgroundOpacity] = useState(0); + const [audioVolume, setAudioVolume] = useState(100); + const [isPreviewPlaying, setIsPreviewPlaying] = useState(false); + const [skipBacking, setSkipBacking] = useState(false); + const isVideo = useMemo(() => isVideoUrl(audioUrl), [audioUrl]); + + useEffect(() => { + const storedOpacity = localStorage.getItem(BACKGROUND_OPACITY_KEY); + if (storedOpacity !== null) { + const parsed = Number(storedOpacity); + if (Number.isFinite(parsed)) + setBackgroundOpacity(Math.min(100, Math.max(0, parsed))); + } + const storedVolume = localStorage.getItem(AUDIO_VOLUME_KEY); + if (storedVolume !== null) { + const parsed = Number(storedVolume); + if (Number.isFinite(parsed)) + setAudioVolume(Math.min(100, Math.max(0, parsed))); + } + }, []); + + useEffect(() => { + localStorage.setItem(BACKGROUND_OPACITY_KEY, String(backgroundOpacity)); + }, [backgroundOpacity]); + + useEffect(() => { + localStorage.setItem(AUDIO_VOLUME_KEY, String(audioVolume)); + }, [audioVolume]); + + useEffect(() => { + const media = isVideo ? videoRef.current : audioRef.current; + if (!media) return; + media.volume = audioVolume / 100; + }, [audioVolume, isVideo, audioUrl]); + + useEffect(() => { + const media = isVideo ? videoRef.current : audioRef.current; + if (!media) { + setIsPreviewPlaying(false); + return; + } + const handlePlay = () => setIsPreviewPlaying(true); + const handlePause = () => setIsPreviewPlaying(false); + const handleEnded = () => setIsPreviewPlaying(false); + media.addEventListener("play", handlePlay); + media.addEventListener("pause", handlePause); + media.addEventListener("ended", handleEnded); + return () => { + media.removeEventListener("play", handlePlay); + media.removeEventListener("pause", handlePause); + media.removeEventListener("ended", handleEnded); + }; + }, [isVideo, audioUrl]); + + useEffect(() => { + setIsPreviewPlaying(false); + }, [audioUrl]); + + const charRowRef = useRef(null); + const charRefs = useRef<(HTMLSpanElement | null)[]>([]); + const [wrapSpaceIndicators, setWrapSpaceIndicators] = useState([]); + const countdownIntervalRef = useRef(null); + + const [g, dispatch] = useReducer(gReducer, initialGState); + + const gameLines = useMemo( + () => parseLrcLines(lrcContent, { skipBacking }), + [lrcContent, skipBacking], + ); + const isReady = !loadingLrc && !!lrcContent && !!audioUrl; + + const accuracy = + g.totalCorrect + g.totalMiss > 0 + ? Math.round((g.totalCorrect / (g.totalCorrect + g.totalMiss)) * 100) + : 100; + + const elapsedMs = + phase === "playing" + ? Math.max(1, Date.now() - gameStartTimeRef.current) + : gameDurationMs; + + const wpm = + elapsedMs > 0 ? Math.round(g.totalCorrect / 5 / (elapsedMs / 60000)) : 0; + + const gRef = useRef(g); + const currentLineContent = + g.displayedLineIdx >= 0 + ? (gameLines[g.displayedLineIdx]?.content ?? "") + : ""; + + useEffect(() => { + charRefs.current = []; + }, [currentLineContent]); + + useLayoutEffect(() => { + if (!charRowRef.current) return; + let frame = 0; + const text = currentLineContent.toLowerCase(); + + const recompute = () => { + const nodes = charRefs.current; + const indicators = new Array(text.length).fill(false); + for (let i = 0; i < text.length - 1; i += 1) { + if (text[i] !== " ") continue; + const curr = nodes[i]; + const next = nodes[i + 1]; + if (!curr || !next) continue; + const currRect = curr.getBoundingClientRect(); + const nextRect = next.getBoundingClientRect(); + if (nextRect.top - currRect.top > 1) { + indicators[i] = true; + } + } + setWrapSpaceIndicators(indicators); + }; + + const schedule = () => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + frame = requestAnimationFrame(recompute); + }); + }; + + schedule(); + + if (document.fonts?.ready) { + document.fonts.ready.then(schedule); + } + + const observer = new ResizeObserver(schedule); + observer.observe(charRowRef.current); + window.addEventListener("resize", schedule); + + return () => { + observer.disconnect(); + window.removeEventListener("resize", schedule); + cancelAnimationFrame(frame); + }; + }, [currentLineContent]); + useEffect(() => { + gRef.current = g; + }, [g]); + + const phaseRef = useRef("idle"); + useEffect(() => { + phaseRef.current = phase; + }, [phase]); + + const offsetRef = useRef(0); + useEffect(() => { + offsetRef.current = offset; + }, [offset]); + + useEffect(() => { + if (!("mediaSession" in navigator)) return; + const mediaSession = navigator.mediaSession; + mediaSession.setActionHandler("pause", () => {}); + return () => { + mediaSession.setActionHandler("pause", null); + }; + }, []); + + useEffect(() => { + return () => { + if (countdownIntervalRef.current !== null) { + clearInterval(countdownIntervalRef.current); + countdownIntervalRef.current = null; + } + }; + }, []); + + const lineAnimRef = useRef({ startMs: 0, endMs: 0, startPerf: 0 }); + + const timeBasedLineIdx = useMemo(() => { + if (!gameLines.length) return -1; + let idx = -1; + for (let i = 0; i < gameLines.length; i++) { + if (gameLines[i].millisecond <= currentMs) idx = i; + else break; + } + return idx; + }, [currentMs, gameLines]); + + const intermissionData = useMemo(() => { + const firstMs = gameLines[0]?.millisecond ?? 0; + const firstMediaMs = firstMs - offsetRef.current; + const remainingMs = Math.max(0, firstMs - currentMs); + if (!gameLines.length || firstMediaMs <= 0) { + return { pct: remainingMs === 0 ? 100 : 0, remainingMs }; + } + + const mediaCurrentMs = currentMs - offsetRef.current; + const pct = Math.min( + 100, + Math.max(0, (mediaCurrentMs / firstMediaMs) * 100), + ); + + return { pct, remainingMs }; + }, [gameLines, currentMs, offset]); + + useEffect(() => { + const idx = g.displayedLineIdx; + if (idx < 0 || !gameLines[idx]) { + lineAnimRef.current = { startMs: 0, endMs: 0, startPerf: 0 }; + setLineTimingPct(0); + setLineRemainingMs(0); + setCurrentLineTime(-1); + return; + } + const start = gameLines[idx].millisecond; + const end = gameLines[idx + 1]?.millisecond ?? start + 5000; + lineAnimRef.current = { + startMs: start, + endMs: end, + startPerf: performance.now(), + }; + setLineTimingPct(0); + const currentLineTime = end - start; + setLineRemainingMs(Math.max(0, currentLineTime)); + setCurrentLineTime(Math.max(currentLineTime, currentLineTime)); + }, [g.displayedLineIdx, gameLines]); + + useEffect(() => { + if (phase !== "playing") return; + let rafId = 0; + const tick = () => { + const { startMs, endMs, startPerf } = lineAnimRef.current; + if (endMs <= startMs) { + setLineTimingPct(100); + setLineRemainingMs(0); + } else { + const elapsed = performance.now() - startPerf; + const duration = endMs - startMs; + const pct = Math.min(100, Math.max(0, (elapsed / duration) * 100)); + const remaining = Math.max(0, duration - elapsed); + setLineTimingPct(pct); + setLineRemainingMs(remaining); + } + rafId = requestAnimationFrame(tick); + }; + rafId = requestAnimationFrame(tick); + return () => cancelAnimationFrame(rafId); + }, [phase]); + + useEffect(() => { + const media = isVideo ? videoRef.current : audioRef.current; + if (!media) return; + const onTimeUpdate = () => { + setCurrentMs(media.currentTime * 1000 + offsetRef.current); + if (media.duration && !isNaN(media.duration)) { + setDuration(media.duration * 1000); + setProgressPct((media.currentTime / media.duration) * 100); + } + }; + const onLoadedMetadata = () => { + if (!isNaN(media.duration)) { + setDuration(media.duration * 1000); + setGameDurationMs(media.duration * 1000); + } + }; + const onEnded = () => { + if (phaseRef.current === "playing") { + setPhase("finished"); + setGameDurationMs(Date.now() - gameStartTimeRef.current); + return; + } + setIsPreviewPlaying(false); + }; + media.addEventListener("timeupdate", onTimeUpdate); + media.addEventListener("loadedmetadata", onLoadedMetadata); + media.addEventListener("ended", onEnded); + return () => { + media.removeEventListener("timeupdate", onTimeUpdate); + media.removeEventListener("loadedmetadata", onLoadedMetadata); + media.removeEventListener("ended", onEnded); + }; + }, [isVideo, audioUrl]); + + useEffect(() => { + if (phaseRef.current !== "playing") return; + if (timeBasedLineIdx < 0) return; + if (timeBasedLineIdx <= lastHandledIdxRef.current) return; + lastHandledIdxRef.current = timeBasedLineIdx; + lastLineAdvanceAtRef.current = performance.now(); + dispatch({ + type: "ADVANCE", + newIdx: timeBasedLineIdx, + prevCompleted: gRef.current.lineCompleted, + }); + }, [timeBasedLineIdx]); + + const loadData = useCallback((data: Record) => { + if (typeof data.lrc === "string" && data.lrc) { + setLoadingLrc(true); + fetch(data.lrc) + .then((r) => r.text()) + .then((t) => { + setLrcContent(t); + setLoadingLrc(false); + }); + } + if (typeof data.media === "string") setAudioUrl(data.media); + if (typeof data.offset === "number") setOffset(data.offset); + if (typeof data.offset === "string" && data.offset.trim() !== "") + setOffset(Number(data.offset)); + if (typeof data.title === "string") setSongTitle(data.title); + if (typeof data.artist === "string") setSongArtist(data.artist); + if (typeof data.skip_backing === "boolean") + setSkipBacking(data.skip_backing); + if (typeof data.skip_backing === "string") + setSkipBacking(data.skip_backing === "true"); + }, []); + + useEffect(() => { + if (!slug) return; + pb.collection("charts") + .getOne(slug) + .then((record) => { + loadData({ + media: (record as Record).media, + lrc: (record as Record).lrc, + offset: (record as Record).offset, + title: (record as Record).title, + artist: (record as Record).artist, + }); + }) + .catch(() => { + try { + const json = atob(slug); + const data = JSON.parse(json) as Record; + loadData(data); + } catch {} + }); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const handlePreviewToggle = useCallback(() => { + if (phase !== "idle") return; + const media = isVideo ? videoRef.current : audioRef.current; + if (!media || !audioUrl) return; + + if (media.paused) { + void media.play().catch(() => { + toast.error( + "Unable to start preview. Try interacting with the page again.", + { + theme: "dark", + }, + ); + }); + return; + } + + media.pause(); + }, [phase, isVideo, audioUrl]); + + const handleStart = useCallback(() => { + const media = isVideo ? videoRef.current : audioRef.current; + if (!media || !lrcContent || !audioUrl) return; + if (countdownIntervalRef.current !== null) { + clearInterval(countdownIntervalRef.current); + countdownIntervalRef.current = null; + } + dispatch({ type: "RESET" }); + lastHandledIdxRef.current = -1; + media.pause(); + media.currentTime = 0; + setIsPreviewPlaying(false); + setPhase("countdown"); + setCountdown(5); + setGameDurationMs(0); + setProgressPct(0); + setCurrentMs(0); + + const beginPlayback = () => { + media.currentTime = 0; + media.play(); + setPhase("playing"); + gameStartTimeRef.current = Date.now(); + }; + + countdownIntervalRef.current = window.setInterval(() => { + setCountdown((c) => { + if (c <= 1) { + if (countdownIntervalRef.current !== null) { + clearInterval(countdownIntervalRef.current); + countdownIntervalRef.current = null; + } + beginPlayback(); + return 0; + } + return c - 1; + }); + }, 1000); + }, [lrcContent, audioUrl, gameLines, isVideo]); + + const handleRestart = useCallback(() => { + const media = isVideo ? videoRef.current : audioRef.current; + if (media) { + media.pause(); + media.currentTime = 0; + } + setIsPreviewPlaying(false); + if (countdownIntervalRef.current !== null) { + clearInterval(countdownIntervalRef.current); + countdownIntervalRef.current = null; + } + setCountdown(0); + dispatch({ type: "RESET" }); + lastHandledIdxRef.current = -1; + setPhase("idle"); + setCurrentMs(0); + setProgressPct(0); + }, [isVideo]); + + const handleKeyPress = useCallback( + (char: string) => { + if (phaseRef.current !== "playing") return; + const line = gameLines[gRef.current.displayedLineIdx]; + if (!line || gRef.current.lineCompleted) return; + const expected = line.content[gRef.current.typedCount]; + if (expected === undefined) return; + if (char.toLowerCase() === expected.toLowerCase()) { + const willComplete = gRef.current.typedCount + 1 >= line.content.length; + dispatch({ type: "CORRECT", willComplete }); + if (willComplete) { + setClearShowing(true); + setTimeout(() => setClearShowing(false), 700); + setComboAnimKey((k) => k + 1); + } + } else { + if (performance.now() - lastLineAdvanceAtRef.current < 100) return; + dispatch({ type: "WRONG" }); + setWrongChar(true); + setTimeout(() => setWrongChar(false), 320); + } + }, + [gameLines], + ); + + useEffect(() => { + if (phase !== "playing") return; + const handler = (e: KeyboardEvent) => { + if (e.key === " ") { + const idx = gRef.current.displayedLineIdx; + if (idx < 0 && gameLines.length > 0) { + const firstMs = gameLines[0]?.millisecond ?? 0; + const media = isVideo ? videoRef.current : audioRef.current; + if (media) { + const currentMsLocal = media.currentTime * 1000 + offsetRef.current; + const intermissionRemaining = Math.max(0, firstMs - currentMsLocal); + if (intermissionRemaining > 5000) { + e.preventDefault(); + const targetMs = firstMs - 3000; + media.currentTime = Math.max( + 0, + (targetMs - offsetRef.current) / 1000, + ); + setCurrentMs(media.currentTime * 1000 + offsetRef.current); + return; + } + } + } + } + if (e.key.length === 1) { + e.preventDefault(); + handleKeyPress(e.key); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [phase, handleKeyPress, gameLines, isVideo]); + + return ( + + + {!isVideo && ( + + ); +} + +export default function GamePage() { + return ( + <> + + +
+ Loading... +
+ + } + > + +
+ + ); +} diff --git a/src/app/game/game.stat.ts b/src/app/game/game.stat.ts deleted file mode 100644 index 43136e6..0000000 --- a/src/app/game/game.stat.ts +++ /dev/null @@ -1,82 +0,0 @@ -export interface GState { - displayedLineIdx: number; - typedCount: number; - lineCompleted: boolean; - combo: number; - maxCombo: number; - score: number; - totalCorrect: number; - totalMiss: number; - linesCleared: number; - wpm: number; -} - -export type GAction = - | { type: "ADVANCE"; newIdx: number; prevCompleted: boolean } - | { type: "CORRECT"; willComplete: boolean } - | { type: "WRONG" } - | { type: "RESET" }; - -export const initialGState: GState = { - displayedLineIdx: -1, - typedCount: 0, - lineCompleted: false, - combo: 0, - maxCombo: 0, - score: 0, - totalCorrect: 0, - totalMiss: 0, - linesCleared: 0, - wpm: 0, -}; - -export function gReducer(state: GState, action: GAction): GState { - switch (action.type) { - case "ADVANCE": { - const prevIdx = state.displayedLineIdx; - const comboReset = !action.prevCompleted && prevIdx >= 0; - return { - ...state, - displayedLineIdx: action.newIdx, - typedCount: 0, - lineCompleted: false, - combo: comboReset ? 0 : state.combo, - }; - } - - case "CORRECT": { - const newTypedCount = state.typedCount + 1; - const newCombo = state.combo + 1; - const newMaxCombo = Math.max(state.maxCombo, newCombo); - const comboBonus = Math.min(50, Math.floor(newCombo / 10) * 5); - const newScore = state.score + 10 + comboBonus; - const newTotalCorrect = state.totalCorrect + 1; - if (action.willComplete) { - return { - ...state, - typedCount: newTypedCount, - lineCompleted: true, - combo: newCombo, - maxCombo: newMaxCombo, - score: newScore, - totalCorrect: newTotalCorrect, - linesCleared: state.linesCleared + 1, - }; - } - return { - ...state, - typedCount: newTypedCount, - combo: newCombo, - maxCombo: newMaxCombo, - score: newScore, - totalCorrect: newTotalCorrect, - }; - } - case "WRONG": - return { ...state, totalMiss: state.totalMiss + 1, combo: 0 }; - case "RESET": - return { ...initialGState }; - default: - return state; - } -} \ No newline at end of file diff --git a/src/app/game/game.utils.ts b/src/app/game/game.utils.ts deleted file mode 100644 index b2037e5..0000000 --- a/src/app/game/game.utils.ts +++ /dev/null @@ -1,55 +0,0 @@ -export interface GameLine { - millisecond: number; - content: string; -} - -export function parseLrcLines( - lrcText: string, - options?: { skipBacking?: boolean } -): GameLine[] { - const result: GameLine[] = []; - const lineRegex = /\[(\d{2,3}):(\d{2})\.(\d{2,3})\]/g; - const { skipBacking = false } = options ?? {}; - - for (const rawLine of lrcText.split("\n")) { - const timestamps: number[] = []; - let match: RegExpExecArray | null; - let lastIndex = 0; - - lineRegex.lastIndex = 0; - while ((match = lineRegex.exec(rawLine)) !== null) { - const minutes = parseInt(match[1], 10); - const seconds = parseInt(match[2], 10); - const msField = match[3]; - const ms = - msField.length === 2 - ? parseInt(msField, 10) * 10 - : parseInt(msField, 10); - timestamps.push(minutes * 60_000 + seconds * 1_000 + ms); - lastIndex = match.index + match[0].length; - } - - if (timestamps.length === 0) continue; - - const content = (skipBacking - ? rawLine.slice(lastIndex).replace(/\([^)]*\)/g, "") - : rawLine.slice(lastIndex) - ).trim(); - - for (const ms of timestamps) { - result.push({ millisecond: ms, content }); - } - } - - result.sort((a, b) => a.millisecond - b.millisecond); - return result; -} - -export function calculateCPSNeeded(text: string, seconds: number): number { - return text.length / seconds; -} - -export function formatTime(ms: number): string { - const s = Math.max(0, Math.floor(ms / 1000)); - return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; -} diff --git a/src/app/game/page.styles.ts b/src/app/game/page.styles.ts deleted file mode 100644 index d410339..0000000 --- a/src/app/game/page.styles.ts +++ /dev/null @@ -1,663 +0,0 @@ -import styled, { keyframes, css, createGlobalStyle } from "styled-components"; - -/* ----- ANIMATIONS ----- */ - -export const pulseAnim = keyframes` - 0% { opacity: 1; } - 50% { opacity: 0.4; } - 100% { opacity: 1; } -`; - -export const wrongShakeAnim = keyframes` - 0% { transform: translateX(0); } - 20% { transform: translateX(-4px); } - 40% { transform: translateX(4px); } - 60% { transform: translateX(-4px); } - 80% { transform: translateX(4px); } - 100% { transform: translateX(0); } -`; - -export const clearPopAnim = keyframes` - 0% { transform: scale(0.8); opacity: 0; } - 40% { transform: scale(1.2); opacity: 1; } - 100% { transform: scale(1.0); opacity: 0; } -`; - -export const fadeInUpAnim = keyframes` - 0% { transform: translateY(10px); opacity: 0; } - 100% { transform: translateY(0); opacity: 1; } -`; - -export const comboScaleAnim = keyframes` - 0% { transform: scale(1); } - 50% { transform: scale(1.4); } - 100% { transform: scale(1); } -`; - -export const glowAnim = keyframes` - 0% { box-shadow: 0 0 4px 0px rgba(124, 58, 237, 0.4); } - 50% { box-shadow: 0 0 16px 4px rgba(124, 58, 237, 0.8); } - 100% { box-shadow: 0 0 4px 0px rgba(124, 58, 237, 0.4); } -`; - -export const GameGlobalStyle = createGlobalStyle` - html, - body { - height: 100%; - overflow: hidden; - } -`; - -/* ----- LAYOUT ----- */ - -export const GameRoot = styled.div` - position: fixed; - inset: 0; - display: flex; - flex-direction: column; - background: #0d0d14; - color: #ffffff; - font-family: "Roboto", "Segoe UI", Arial, sans-serif; - overflow: hidden; - z-index: 0; -`; - -export const BackgroundVideo = styled.video` - position: absolute; - inset: 0; - width: 100%; - height: 100%; - object-fit: cover; - pointer-events: none; - z-index: 0; -`; - -export const GameNavbar = styled.nav` - position: sticky; - top: 0; - display: flex; - flex-direction: row; - align-items: center; - height: 52px; - padding: 0 20px; - background: rgba(13, 13, 20, 0.75); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); - border-bottom: 1px solid rgba(255, 255, 255, 0.08); - z-index: 20; -`; - -export const GameContent = styled.div` - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; - position: relative; - z-index: 1; -`; - -/* ----- HUD ----- */ - -export const HUD = styled.div` - display: flex; - flex-direction: row; - align-items: center; - justify-content: space-between; - gap: 24px; - padding: 10px 24px; - background: rgba(13, 13, 20, 0.75); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); - border-bottom: 1px solid rgba(255, 255, 255, 0.08); - position: relative; - z-index: 2; -`; - -export const HudStat = styled.div` - display: flex; - flex-direction: column; - align-items: center; - gap: 2px; -`; - -export const HudValue = styled.span` - font-size: 22px; - font-weight: 700; - color: #ffffff; -`; - -export const HudLabel = styled.span` - font-size: 10px; - letter-spacing: 1.5px; - text-transform: uppercase; - color: rgba(255, 255, 255, 0.45); -`; - -export const ComboValue = styled(HudValue)<{ $animate: boolean }>` - ${({ $animate }) => - $animate && - css` - animation: ${comboScaleAnim} 0.25s ease; - `} -`; - -/* ----- MAIN GAME AREA ----- */ - -export const GameArea = styled.div` - position: relative; - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 24px 32px; - gap: 24px; - overflow: hidden; - - & > * { - position: relative; - z-index: 1; - } -`; - -export const UpcomingWrap = styled.div` - width: 100%; - max-width: 800px; - display: flex; - flex-direction: column; - gap: 6px; -`; - -export const UpcomingLabel = styled.span` - font-size: 10px; - letter-spacing: 2px; - text-transform: uppercase; - color: rgba(255, 255, 255, 0.30); - margin-bottom: 2px; -`; - -export const UpcomingText = styled.p` - font-size: 20px; - color: rgba(255, 255, 255, 0.30); - font-weight: 400; - font-family: "Inter", "Segoe UI", "Helvetica Neue", Arial, sans-serif; - min-height: 28px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - margin: 0; -`; - -export const CurrentWrap = styled.div` - width: 100%; - max-width: 800px; - display: flex; - flex-direction: column; - gap: 10px; -`; - -export const LineTimingRow = styled.div` - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; -`; - -export const LineTimingMeta = styled.div` - display: flex; - align-items: center; - justify-content: flex-start; - font-size: 13px; - letter-spacing: 1px; - text-transform: uppercase; - color: rgba(255, 255, 255, 0.45); -`; - -export const LineTimingValue = styled.span` - font-variant-numeric: tabular-nums; -`; - -export const LineTimingBar = styled.div` - width: 100%; - height: 3px; - background: rgba(255, 255, 255, 0.12); - border-radius: 2px; - overflow: hidden; -`; - -export const LineTimingFill = styled.div.attrs<{ $pct: number }>((props) => ({ - style: { - transform: `scaleX(${props.$pct / 100})`, - }, -}))<{ $pct: number }>` - height: 100%; - width: 100%; - border-radius: 2px; - background: #7c3aed; - transform-origin: left; - will-change: transform; -`; - -export const CharRow = styled.div` - display: flex; - flex-wrap: wrap; - gap: 2px; - align-items: flex-end; - min-height: 64px; -`; - -export const WordWrap = styled.span` - display: inline-flex; - gap: 2px; - white-space: nowrap; -`; - -export const CharBox = styled.span<{ - $state: "typed" | "active" | "pending" | "wrong"; -}>` - display: inline-flex; - align-items: center; - justify-content: center; - font-size: 36px; - font-weight: 700; - font-family: "Inter", "Segoe UI", "Helvetica Neue", Arial, sans-serif; - padding: 0 3px; - border-radius: 4px; - transition: all 0.08s ease; - - ${({ $state }) => { - switch ($state) { - case "typed": - return css` - color: #22c55e; - opacity: 0.7; - `; - case "active": - return css` - color: #fbbf24; - border-bottom: 3px solid #fbbf24; - animation: ${pulseAnim} 1s ease infinite; - `; - case "pending": - return css` - color: rgba(255, 255, 255, 0.25); - `; - case "wrong": - return css` - color: #ef4444; - animation: ${wrongShakeAnim} 0.3s ease; - `; - } - }} -`; - -export const ClearToast = styled.div` - position: absolute; - font-size: 28px; - font-weight: 800; - color: #22c55e; - animation: ${clearPopAnim} 0.7s ease forwards; - pointer-events: none; -`; - -export const GetReadyText = styled.p` - font-size: 28px; - color: rgba(255, 255, 255, 0.50); - font-weight: 500; - text-align: center; - animation: ${pulseAnim} 1.5s ease infinite; - margin: 0; -`; - -export const CompletedLineFade = styled.div` - font-size: 18px; - color: rgba(255, 255, 255, 0.20); - margin-top: 4px; - min-height: 26px; - transition: opacity 0.3s; -`; - -/* ----- FOOTER ----- */ - -export const GameFooter = styled.footer` - display: flex; - align-items: center; - gap: 12px; - padding: 12px 24px; - background: rgba(255, 255, 255, 0.04); - border-top: 1px solid rgba(255, 255, 255, 0.06); - position: relative; - z-index: 2; -`; - -export const ControlBtn = styled.button` - width: 40px; - height: 40px; - border-radius: 50%; - border: 1px solid rgba(255, 255, 255, 0.20); - background: rgba(255, 255, 255, 0.08); - color: #ffffff; - font-size: 14px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - transition: background 0.15s; - - &:hover { - background: rgba(255, 255, 255, 0.15); - } -`; - -export const ProgressWrap = styled.div` - flex: 1; - height: 6px; - background: rgba(255, 255, 255, 0.12); - border-radius: 3px; - overflow: hidden; - cursor: pointer; -`; - -export const ProgressFill = styled.div.attrs<{ $pct: number }>((props) => ({ - style: { - width: `${props.$pct}%`, - }, -}))<{ $pct: number }>` - height: 100%; - background: #7c3aed; - border-radius: 3px; - transition: width 0.3s linear; -`; - -export const TimeText = styled.span` - font-size: 12px; - color: rgba(255, 255, 255, 0.50); - font-family: monospace; - white-space: nowrap; -`; - -/* ----- START SCREEN ----- */ - -export const StartOverlay = styled.div` - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - background: rgba(13, 13, 20, 0.96); - z-index: 10; - animation: ${fadeInUpAnim} 0.4s ease; -`; - -export const StartCard = styled.div` - display: flex; - flex-direction: column; - align-items: center; - gap: 20px; - padding: 40px; - max-width: 520px; - width: 100%; - text-align: center; -`; - -export const OpacityControl = styled.div` - width: 100%; - display: flex; - flex-direction: column; - gap: 8px; - align-items: stretch; -`; - -export const OpacityLabel = styled.div` - font-size: 11px; - color: rgba(255, 255, 255, 0.45); - letter-spacing: 1px; - text-transform: uppercase; - display: flex; - justify-content: space-between; - align-items: center; -`; - -export const OpacityValue = styled.span` - font-variant-numeric: tabular-nums; -`; - -export const OpacitySlider = styled.input` - width: 100%; -`; - -export const PreviewWrap = styled.div` - width: 100%; - display: flex; - flex-direction: column; - gap: 8px; -`; - -export const PreviewBtn = styled.button` - width: 100%; - padding: 10px 16px; - border-radius: 10px; - border: 1px solid rgba(255, 255, 255, 0.2); - background: rgba(255, 255, 255, 0.08); - color: #ffffff; - font-size: 14px; - font-weight: 600; - cursor: pointer; - transition: all 0.15s; - - &:hover:not(:disabled) { - background: rgba(255, 255, 255, 0.16); - } - - &:disabled { - opacity: 0.45; - cursor: not-allowed; - } -`; - -export const PreviewHint = styled.div` - font-size: 11px; - color: rgba(255, 255, 255, 0.45); - text-align: center; -`; - -export const CountdownNumber = styled.div` - font-size: 72px; - font-weight: 900; - color: #ffffff; - line-height: 1; - letter-spacing: 2px; -`; - -export const SongTitleText = styled.h1` - font-size: 32px; - font-weight: 800; - color: #ffffff; - line-height: 1.2; - margin: 0; -`; - -export const SongArtistText = styled.p` - font-size: 16px; - color: rgba(255, 255, 255, 0.50); - margin: 0; -`; - -export const StartBtn = styled.button` - padding: 14px 40px; - border-radius: 12px; - background: #7c3aed; - color: #ffffff; - font-size: 18px; - font-weight: 700; - border: none; - cursor: pointer; - transition: all 0.15s; - - &:hover:not(:disabled) { - background: #6d28d9; - transform: translateY(-2px); - } - - &:disabled { - opacity: 0.5; - cursor: not-allowed; - } -`; - -export const CodeSection = styled.div` - width: 100%; - display: flex; - flex-direction: column; - gap: 8px; -`; - -export const CodeInputRow = styled.div` - display: flex; - gap: 8px; -`; - -export const CodeInputField = styled.input` - flex: 1; - padding: 8px 12px; - border-radius: 8px; - border: 1px solid rgba(255, 255, 255, 0.15); - background: rgba(255, 255, 255, 0.06); - color: #ffffff; - font-size: 13px; - outline: none; - transition: border-color 0.15s; - - &:focus { - border-color: rgba(255, 255, 255, 0.35); - } - - &::placeholder { - color: rgba(255, 255, 255, 0.30); - } -`; - -export const CodeLoadBtn = styled.button` - padding: 8px 16px; - border-radius: 8px; - border: 1px solid rgba(255, 255, 255, 0.15); - background: rgba(255, 255, 255, 0.08); - color: #ffffff; - font-size: 13px; - cursor: pointer; - white-space: nowrap; - transition: background 0.15s; - - &:hover { - background: rgba(255, 255, 255, 0.15); - } -`; - -/* ----- RESULTS SCREEN ----- */ - -export const ResultsOverlay = styled.div` - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - background: rgba(13, 13, 20, 0.96); - z-index: 10; - animation: ${fadeInUpAnim} 0.4s ease; -`; - -export const ResultsCard = styled.div` - display: flex; - flex-direction: column; - align-items: center; - gap: 24px; - padding: 40px; - max-width: 540px; - width: 100%; - text-align: center; -`; - -export const ResultsTitle = styled.p` - font-size: 14px; - letter-spacing: 3px; - text-transform: uppercase; - color: rgba(255, 255, 255, 0.40); - margin: 0; -`; - -export const BigScore = styled.h2` - font-size: 64px; - font-weight: 900; - color: #ffffff; - line-height: 1; - margin: 0; -`; - -export const StatsGrid = styled.div` - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 16px; - width: 100%; -`; - -export const StatBlock = styled.div` - background: rgba(255, 255, 255, 0.05); - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 12px; - padding: 16px; - display: flex; - flex-direction: column; - gap: 4px; -`; - -export const StatValue = styled.span` - font-size: 28px; - font-weight: 700; - color: #ffffff; -`; - -export const StatLabel = styled.span` - font-size: 11px; - letter-spacing: 1.5px; - text-transform: uppercase; - color: rgba(255, 255, 255, 0.40); -`; - -export const ActionRow = styled.div` - display: flex; - gap: 12px; -`; - -export const PlayAgainBtn = styled.button` - padding: 10px 28px; - border-radius: 12px; - background: #7c3aed; - color: #ffffff; - font-size: 15px; - font-weight: 700; - border: none; - cursor: pointer; - transition: all 0.15s; - - &:hover { - background: #6d28d9; - transform: translateY(-2px); - } -`; - -export const HomeBtn = styled.button` - padding: 10px 28px; - border-radius: 12px; - background: transparent; - border: 1px solid rgba(255, 255, 255, 0.20); - color: #ffffff; - font-size: 15px; - font-weight: 700; - cursor: pointer; - transition: all 0.15s; - - &:hover { - background: rgba(255, 255, 255, 0.08); - } -`; diff --git a/src/app/game/page.tsx b/src/app/game/page.tsx deleted file mode 100644 index bce01b3..0000000 --- a/src/app/game/page.tsx +++ /dev/null @@ -1,1011 +0,0 @@ -"use client"; -import { - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useReducer, - useRef, - useState, - Suspense, -} from "react"; -import { useSearchParams, useRouter } from "next/navigation"; -import Link from "next/link"; -import { FaRedo } from "react-icons/fa"; -import { MdLibraryMusic } from "react-icons/md"; -import { toast, ToastContainer } from "react-toastify"; -import "react-toastify/dist/ReactToastify.css"; -import { - GameRoot, - GameGlobalStyle, - GameNavbar, - GameContent, - HUD, - HudStat, - HudValue, - HudLabel, - ComboValue, - GameArea, - UpcomingWrap, - UpcomingLabel, - UpcomingText, - CurrentWrap, - LineTimingMeta, - LineTimingValue, - LineTimingRow, - LineTimingBar, - LineTimingFill, - CharRow, - WordWrap, - CharBox, - ClearToast, - GetReadyText, - BackgroundVideo, - OpacityControl, - OpacityLabel, - OpacitySlider, - OpacityValue, - PreviewWrap, - PreviewBtn, - PreviewHint, - CompletedLineFade, - GameFooter, - ControlBtn, - ProgressWrap, - ProgressFill, - TimeText, - StartOverlay, - StartCard, - CountdownNumber, - SongTitleText, - SongArtistText, - StartBtn, - CodeSection, - CodeInputRow, - CodeInputField, - CodeLoadBtn, - ResultsOverlay, - ResultsCard, - ResultsTitle, - BigScore, - StatsGrid, - StatBlock, - StatValue, - StatLabel, - ActionRow, - PlayAgainBtn, - HomeBtn, -} from "./page.styles"; -import { gReducer, initialGState } from "./game.stat"; -import { formatTime, parseLrcLines, calculateCPSNeeded } from "./game.utils"; - -type GamePhase = "idle" | "countdown" | "playing" | "paused" | "finished"; - -const VIDEO_EXTENSIONS = new Set(["mp4", "webm", "mov", "m4v", "ogv"]); -const isVideoUrl = (url: string) => { - if (!url) return false; - const cleaned = url.split("?")[0].split("#")[0]; - const ext = cleaned.split(".").pop()?.toLowerCase(); - return !!ext && VIDEO_EXTENSIONS.has(ext); -}; -const BACKGROUND_OPACITY_KEY = "lrcType.backgroundOpacity"; -const AUDIO_VOLUME_KEY = "lrcType.audioVolume"; - -function GameInner() { - const searchParams = useSearchParams(); - const router = useRouter(); - - useEffect(() => { - window.scrollTo({ top: 0, left: 0, behavior: "auto" }); - }, []); - - const audioRef = useRef(null); - const videoRef = useRef(null); - const gameStartTimeRef = useRef(0); - const lastHandledIdxRef = useRef(-1); - const lastLineAdvanceAtRef = useRef(0); - - const [phase, setPhase] = useState("idle"); - const [currentMs, setCurrentMs] = useState(0); - const [lineTimingPct, setLineTimingPct] = useState(0); - const [lineRemainingMs, setLineRemainingMs] = useState(0); - const [currentLineTime, setCurrentLineTime] = useState(0); - const [duration, setDuration] = useState(0); - const [progressPct, setProgressPct] = useState(0); - const [gameDurationMs, setGameDurationMs] = useState(0); - - const [lrcContent, setLrcContent] = useState(""); - const [audioUrl, setAudioUrl] = useState(""); - const [songTitle, setSongTitle] = useState("Unknown Title"); - const [songArtist, setSongArtist] = useState("Unknown Artist"); - const [offset, setOffset] = useState(0); - const [loadingLrc, setLoadingLrc] = useState(false); - - const [codeInput, setCodeInput] = useState(""); - const [wrongChar, setWrongChar] = useState(false); - const [clearShowing, setClearShowing] = useState(false); - const [comboAnimKey, setComboAnimKey] = useState(0); - const [countdown, setCountdown] = useState(0); - const [backgroundOpacity, setBackgroundOpacity] = useState(() => { - if (typeof window === "undefined") return 0; - const stored = localStorage.getItem(BACKGROUND_OPACITY_KEY); - if (stored === null) return 0; - const parsed = Number(stored); - if (!Number.isFinite(parsed)) return 0; - return Math.min(100, Math.max(0, parsed)); - }); - const [audioVolume, setAudioVolume] = useState(() => { - if (typeof window === "undefined") return 100; - const stored = localStorage.getItem(AUDIO_VOLUME_KEY); - if (stored === null) return 100; - const parsed = Number(stored); - if (!Number.isFinite(parsed)) return 100; - return Math.min(100, Math.max(0, parsed)); - }); - const [isPreviewPlaying, setIsPreviewPlaying] = useState(false); - const [skipBacking, setSkipBacking] = useState(false); - const isVideo = useMemo(() => isVideoUrl(audioUrl), [audioUrl]); - - - - useEffect(() => { - localStorage.setItem(BACKGROUND_OPACITY_KEY, String(backgroundOpacity)); - }, [backgroundOpacity]); - - useEffect(() => { - localStorage.setItem(AUDIO_VOLUME_KEY, String(audioVolume)); - }, [audioVolume]); - - useEffect(() => { - const media = isVideo ? videoRef.current : audioRef.current; - if (!media) return; - media.volume = audioVolume / 100; - }, [audioVolume, isVideo, audioUrl]); - - useEffect(() => { - const media = isVideo ? videoRef.current : audioRef.current; - if (!media) { - setIsPreviewPlaying(false); - return; - } - const handlePlay = () => setIsPreviewPlaying(true); - const handlePause = () => setIsPreviewPlaying(false); - const handleEnded = () => setIsPreviewPlaying(false); - media.addEventListener("play", handlePlay); - media.addEventListener("pause", handlePause); - media.addEventListener("ended", handleEnded); - return () => { - media.removeEventListener("play", handlePlay); - media.removeEventListener("pause", handlePause); - media.removeEventListener("ended", handleEnded); - }; - }, [isVideo, audioUrl]); - - useEffect(() => { - setIsPreviewPlaying(false); - }, [audioUrl]); - - const charRowRef = useRef(null); - const charRefs = useRef<(HTMLSpanElement | null)[]>([]); - const [wrapSpaceIndicators, setWrapSpaceIndicators] = useState([]); - const countdownIntervalRef = useRef(null); - - const [g, dispatch] = useReducer(gReducer, initialGState); - - const gameLines = useMemo( - () => parseLrcLines(lrcContent, { skipBacking }), - [lrcContent, skipBacking] - ); - const isReady = !loadingLrc && !!lrcContent && !!audioUrl; - - const accuracy = - g.totalCorrect + g.totalMiss > 0 - ? Math.round((g.totalCorrect / (g.totalCorrect + g.totalMiss)) * 100) - : 100; - - const elapsedMs = - phase === "playing" - ? Math.max(1, Date.now() - gameStartTimeRef.current) - : gameDurationMs; - - const wpm = - elapsedMs > 0 ? Math.round(g.totalCorrect / 5 / (elapsedMs / 60000)) : 0; - - const gRef = useRef(g); - const currentLineContent = - g.displayedLineIdx >= 0 ? gameLines[g.displayedLineIdx]?.content ?? "" : ""; - - useEffect(() => { - charRefs.current = []; - }, [currentLineContent]); - - useLayoutEffect(() => { - if (!charRowRef.current) return; - let frame = 0; - const text = currentLineContent.toLowerCase(); - - const recompute = () => { - const nodes = charRefs.current; - const indicators = new Array(text.length).fill(false); - for (let i = 0; i < text.length - 1; i += 1) { - if (text[i] !== " ") continue; - const curr = nodes[i]; - const next = nodes[i + 1]; - if (!curr || !next) continue; - const currRect = curr.getBoundingClientRect(); - const nextRect = next.getBoundingClientRect(); - if (nextRect.top - currRect.top > 1) { - indicators[i] = true; - } - } - setWrapSpaceIndicators(indicators); - }; - - const schedule = () => { - cancelAnimationFrame(frame); - frame = requestAnimationFrame(() => { - frame = requestAnimationFrame(recompute); - }); - }; - - schedule(); - - if (document.fonts?.ready) { - document.fonts.ready.then(schedule); - } - - const observer = new ResizeObserver(schedule); - observer.observe(charRowRef.current); - window.addEventListener("resize", schedule); - - return () => { - observer.disconnect(); - window.removeEventListener("resize", schedule); - cancelAnimationFrame(frame); - }; - }, [currentLineContent]); - useEffect(() => { - gRef.current = g; - }, [g]); - - const phaseRef = useRef("idle"); - useEffect(() => { - phaseRef.current = phase; - }, [phase]); - - const offsetRef = useRef(0); - useEffect(() => { - offsetRef.current = offset; - }, [offset]); - - useEffect(() => { - if (!("mediaSession" in navigator)) return; - const mediaSession = navigator.mediaSession; - mediaSession.setActionHandler("pause", () => {}); - return () => { - mediaSession.setActionHandler("pause", null); - }; - }, []); - - useEffect(() => { - return () => { - if (countdownIntervalRef.current !== null) { - clearInterval(countdownIntervalRef.current); - countdownIntervalRef.current = null; - } - }; - }, []); - - const lineAnimRef = useRef({ startMs: 0, endMs: 0, startPerf: 0 }); - - const timeBasedLineIdx = useMemo(() => { - if (!gameLines.length) return -1; - let idx = -1; - for (let i = 0; i < gameLines.length; i++) { - if (gameLines[i].millisecond <= currentMs) idx = i; - else break; - } - return idx; - }, [currentMs, gameLines]); - - const intermissionData = useMemo(() => { - const firstMs = gameLines[0]?.millisecond ?? 0; - const firstMediaMs = firstMs - offsetRef.current; - const remainingMs = Math.max(0, firstMs - currentMs); - if (!gameLines.length || firstMediaMs <= 0) { - return { pct: remainingMs === 0 ? 100 : 0, remainingMs }; - } - - const mediaCurrentMs = currentMs - offsetRef.current; - const pct = Math.min(100, Math.max(0, (mediaCurrentMs / firstMediaMs) * 100)); - - return { pct, remainingMs }; - }, [gameLines, currentMs, offset]); - - useEffect(() => { - const idx = g.displayedLineIdx; - if (idx < 0 || !gameLines[idx]) { - lineAnimRef.current = { startMs: 0, endMs: 0, startPerf: 0 }; - setLineTimingPct(0); - setLineRemainingMs(0); - setCurrentLineTime(-1); - return; - } - const start = gameLines[idx].millisecond; - const end = gameLines[idx + 1]?.millisecond ?? start + 5000; - lineAnimRef.current = { - startMs: start, - endMs: end, - startPerf: performance.now(), - }; - setLineTimingPct(0); - const currentLineTime = end - start; - setLineRemainingMs(Math.max(0, currentLineTime)); - setCurrentLineTime(Math.max(currentLineTime, currentLineTime)); - }, [g.displayedLineIdx, gameLines]); - - useEffect(() => { - if (phase !== "playing") return; - let rafId = 0; - const tick = () => { - const { startMs, endMs, startPerf } = lineAnimRef.current; - if (endMs <= startMs) { - setLineTimingPct(100); - setLineRemainingMs(0); - } else { - const elapsed = performance.now() - startPerf; - const duration = endMs - startMs; - const pct = Math.min(100, Math.max(0, (elapsed / duration) * 100)); - const remaining = Math.max(0, duration - elapsed); - setLineTimingPct(pct); - setLineRemainingMs(remaining); - } - rafId = requestAnimationFrame(tick); - }; - rafId = requestAnimationFrame(tick); - return () => cancelAnimationFrame(rafId); - }, [phase]); - - useEffect(() => { - const media = isVideo ? videoRef.current : audioRef.current; - if (!media) return; - const onTimeUpdate = () => { - setCurrentMs(media.currentTime * 1000 + offsetRef.current); - if (media.duration && !isNaN(media.duration)) { - setDuration(media.duration * 1000); - setProgressPct((media.currentTime / media.duration) * 100); - } - }; - const onLoadedMetadata = () => { - if (!isNaN(media.duration)) { - setDuration(media.duration * 1000); - setGameDurationMs(media.duration * 1000); - } - }; - const onEnded = () => { - if (phaseRef.current === "playing") { - setPhase("finished"); - setGameDurationMs(Date.now() - gameStartTimeRef.current); - return; - } - setIsPreviewPlaying(false); - }; - media.addEventListener("timeupdate", onTimeUpdate); - media.addEventListener("loadedmetadata", onLoadedMetadata); - media.addEventListener("ended", onEnded); - return () => { - media.removeEventListener("timeupdate", onTimeUpdate); - media.removeEventListener("loadedmetadata", onLoadedMetadata); - media.removeEventListener("ended", onEnded); - }; - }, [isVideo, audioUrl]); - - useEffect(() => { - if (phaseRef.current !== "playing") return; - if (timeBasedLineIdx < 0) return; - if (timeBasedLineIdx <= lastHandledIdxRef.current) return; - lastHandledIdxRef.current = timeBasedLineIdx; - lastLineAdvanceAtRef.current = performance.now(); - dispatch({ - type: "ADVANCE", - newIdx: timeBasedLineIdx, - prevCompleted: gRef.current.lineCompleted, - }); - }, [timeBasedLineIdx]); - - const loadData = useCallback((data: Record) => { - if (typeof data.lrc === "string" && data.lrc) { - setLoadingLrc(true); - fetch(data.lrc) - .then((r) => r.text()) - .then((t) => { - setLrcContent(t); - setLoadingLrc(false); - }); - } - if (typeof data.file1 === "string") setAudioUrl(data.file1); - if (typeof data.offset === "number") setOffset(data.offset); - if (typeof data.offset === "string" && data.offset.trim() !== "") - setOffset(Number(data.offset)); - if (typeof data.title === "string") setSongTitle(data.title); - if (typeof data.artist === "string") setSongArtist(data.artist); - if (typeof data.skip_backing === "boolean") - setSkipBacking(data.skip_backing); - if (typeof data.skip_backing === "string") - setSkipBacking(data.skip_backing === "true"); - }, []); - - useEffect(() => { - const code = searchParams.get("code"); - if (!code) return; - try { - const json = atob(code); - const data = JSON.parse(json) as Record; - loadData(data); - } catch {} - }, []); // eslint-disable-line react-hooks/exhaustive-deps - - const handlePreviewToggle = useCallback(() => { - if (phase !== "idle") return; - const media = isVideo ? videoRef.current : audioRef.current; - if (!media || !audioUrl) return; - - if (media.paused) { - void media.play().catch(() => { - toast.error("Unable to start preview. Try interacting with the page again.", { - theme: "dark", - }); - }); - return; - } - - media.pause(); - }, [phase, isVideo, audioUrl]); - - const handleStart = useCallback(() => { - const media = isVideo ? videoRef.current : audioRef.current; - if (!media || !lrcContent || !audioUrl) return; - if (countdownIntervalRef.current !== null) { - clearInterval(countdownIntervalRef.current); - countdownIntervalRef.current = null; - } - dispatch({ type: "RESET" }); - lastHandledIdxRef.current = -1; - media.pause(); - media.currentTime = 0; - setIsPreviewPlaying(false); - setPhase("countdown"); - setCountdown(5); - setGameDurationMs(0); - setProgressPct(0); - setCurrentMs(0); - - const beginPlayback = () => { - media.currentTime = 0; - media.play(); - setPhase("playing"); - gameStartTimeRef.current = Date.now(); - }; - - countdownIntervalRef.current = window.setInterval(() => { - setCountdown((c) => { - if (c <= 1) { - if (countdownIntervalRef.current !== null) { - clearInterval(countdownIntervalRef.current); - countdownIntervalRef.current = null; - } - beginPlayback(); - return 0; - } - return c - 1; - }); - }, 1000); - }, [lrcContent, audioUrl, gameLines, isVideo]); - - const handleRestart = useCallback(() => { - const media = isVideo ? videoRef.current : audioRef.current; - if (media) { - media.pause(); - media.currentTime = 0; - } - setIsPreviewPlaying(false); - if (countdownIntervalRef.current !== null) { - clearInterval(countdownIntervalRef.current); - countdownIntervalRef.current = null; - } - setCountdown(0); - dispatch({ type: "RESET" }); - lastHandledIdxRef.current = -1; - setPhase("idle"); - setCurrentMs(0); - setProgressPct(0); - }, [isVideo]); - - const handleLoadCode = useCallback(() => { - if (!codeInput.trim()) return; - try { - const json = atob(codeInput.trim()); - const data = JSON.parse(json) as Record; - loadData(data); - handleRestart(); - toast.success("Song loaded!", { theme: "dark" }); - } catch { - toast.error("Invalid code. Please check and try again.", { - theme: "dark", - }); - } - }, [codeInput, loadData, handleRestart]); - - const handleKeyPress = useCallback( - (char: string) => { - if (phaseRef.current !== "playing") return; - const line = gameLines[gRef.current.displayedLineIdx]; - if (!line || gRef.current.lineCompleted) return; - const expected = line.content[gRef.current.typedCount]; - if (expected === undefined) return; - if (char.toLowerCase() === expected.toLowerCase()) { - const willComplete = gRef.current.typedCount + 1 >= line.content.length; - dispatch({ type: "CORRECT", willComplete }); - if (willComplete) { - setClearShowing(true); - setTimeout(() => setClearShowing(false), 700); - setComboAnimKey((k) => k + 1); - } - } else { - if (performance.now() - lastLineAdvanceAtRef.current < 100) return; - dispatch({ type: "WRONG" }); - setWrongChar(true); - setTimeout(() => setWrongChar(false), 320); - } - }, - [gameLines], - ); - - useEffect(() => { - if (phase !== "playing") return; - const handler = (e: KeyboardEvent) => { - if (e.key === " ") { - const idx = gRef.current.displayedLineIdx; - if (idx < 0 && gameLines.length > 0) { - const firstMs = gameLines[0]?.millisecond ?? 0; - const media = isVideo ? videoRef.current : audioRef.current; - if (media) { - const currentMsLocal = media.currentTime * 1000 + offsetRef.current; - const intermissionRemaining = Math.max(0, firstMs - currentMsLocal); - if (intermissionRemaining > 5000) { - e.preventDefault(); - const targetMs = firstMs - 3000; - media.currentTime = Math.max(0, (targetMs - offsetRef.current) / 1000); - setCurrentMs(media.currentTime * 1000 + offsetRef.current); - return; - } - } - } - } - if (e.key.length === 1) { - e.preventDefault(); - handleKeyPress(e.key); - } - }; - window.addEventListener("keydown", handler); - return () => window.removeEventListener("keydown", handler); - }, [phase, handleKeyPress, gameLines, isVideo]); - - return ( - - - {!isVideo && ( - - ); -} - -export default function GamePage() { - return ( - <> - - -
- Loading... -
- - } - > - -
- - ); -} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 67d8da9..2608b32 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,8 +1,9 @@ import type { Metadata } from "next"; import StyledComponentsRegistry from "./registry"; +import { AuthProvider } from "./context/auth"; export const metadata: Metadata = { - title: "LRC-Type", + title: "TypingMIXX", description: "A typing game powered by LRC lyrics. Type along to your favourite songs!", }; @@ -15,8 +16,10 @@ export default function RootLayout({ return ( - {children} + + {children} + ); -} \ No newline at end of file +} diff --git a/src/app/lib/pocketbase.ts b/src/app/lib/pocketbase.ts new file mode 100644 index 0000000..b9aeb84 --- /dev/null +++ b/src/app/lib/pocketbase.ts @@ -0,0 +1,7 @@ +import PocketBase from "pocketbase"; + +const pb = new PocketBase( + process.env.NEXT_PUBLIC_POCKETBASE_URL ?? "http://127.0.0.1:8090" +); + +export default pb; diff --git a/src/app/page.styles.ts b/src/app/page.styles.ts index 50d28d7..468566a 100644 --- a/src/app/page.styles.ts +++ b/src/app/page.styles.ts @@ -1,25 +1,40 @@ -import styled from "styled-components"; +import styled, { createGlobalStyle } from "styled-components"; import Link from "next/link"; +import { + Root as BaseRoot, + Navbar as BaseNavbar, + Logo as BaseLogo, + LogoIcon as BaseLogoIcon, + NavLink as BaseNavLink, + NavCtaLink as BaseNavCtaLink, +} from "./styles/shared"; -export const NavLeft = styled.div` +// ── Base components (previously in the old app/page.styles.ts) ─────────────── + +const BaseNavLeft = styled.div` display: flex; align-items: center; gap: 14px; `; -export const NavCenter = styled.div` +const BaseNavCenter = styled.div` display: flex; align-items: center; flex: 0 1 560px; `; -export const SearchBox = styled.div` +const BaseNavRight = styled.div` + display: flex; + align-items: center; + gap: 6px; +`; + +const BaseSearchBox = styled.div` display: flex; align-items: center; flex: 1; height: 38px; border: 1px solid #d4d4d4; - border-radius: 10px; overflow: hidden; background-color: #f0f0f0; transition: border-color 0.2s; @@ -28,7 +43,7 @@ export const SearchBox = styled.div` } `; -export const SearchInput = styled.input` +const BaseSearchInput = styled.input` flex: 1; height: 100%; padding: 0 14px; @@ -42,7 +57,7 @@ export const SearchInput = styled.input` } `; -export const SearchButton = styled.button` +const BaseSearchButton = styled.button` width: 52px; height: 100%; background-color: #e8e8e8; @@ -60,15 +75,7 @@ export const SearchButton = styled.button` } `; -export const NavRight = styled.div` - display: flex; - align-items: center; - gap: 6px; -`; - - - -export const ChipsBar = styled.div` +const BaseChipsBar = styled.div` display: flex; align-items: center; gap: 10px; @@ -80,10 +87,9 @@ export const ChipsBar = styled.div` } `; -export const Chip = styled.button<{ $active?: boolean }>` +const BaseChip = styled.button<{ $active?: boolean }>` white-space: nowrap; padding: 7px 16px; - border-radius: 10px; border: 1px solid ${(p) => (p.$active ? "transparent" : "#d4d4d4")}; font-size: 13px; font-weight: 500; @@ -107,9 +113,8 @@ export const CardGrid = styled.div` gap: 20px; `; -export const Card = styled(Link)` +const BaseCard = styled(Link)` cursor: pointer; - border-radius: 14px; text-decoration: none; color: inherit; display: block; @@ -120,11 +125,10 @@ export const Card = styled(Link)` } `; -export const ThumbnailWrapper = styled.div` +const BaseThumbnailWrapper = styled.div` width: 100%; aspect-ratio: 16 / 9; background-color: #e4e4e4; - border-radius: 12px; display: flex; align-items: center; justify-content: center; @@ -147,9 +151,8 @@ export const PlayOverlay = styled.div` align-items: center; justify-content: center; background: rgba(0, 0, 0, 0); - border-radius: 12px; transition: background 0.2s; - ${Card}:hover & { + ${BaseCard}:hover & { background: rgba(0, 0, 0, 0.25); } `; @@ -157,7 +160,6 @@ export const PlayOverlay = styled.div` export const PlayCircle = styled.div` width: 48px; height: 48px; - border-radius: 50%; background: rgba(0, 0, 0, 0.7); color: #fff; display: flex; @@ -167,7 +169,7 @@ export const PlayCircle = styled.div` opacity: 0; transform: scale(0.8); transition: opacity 0.2s, transform 0.2s; - ${Card}:hover & { + ${BaseCard}:hover & { opacity: 1; transform: scale(1); } @@ -187,7 +189,7 @@ export const CardInfo = styled.div` min-width: 0; `; -export const CardTitle = styled.span` +const BaseCardTitle = styled.span` font-size: 14px; font-weight: 600; color: #1a1a1a; @@ -198,13 +200,13 @@ export const CardTitle = styled.span` overflow: hidden; `; -export const CardSub = styled.span` +const BaseCardSub = styled.span` font-size: 12px; color: #909090; line-height: 1.3; `; -export const EmptyState = styled.div` +const BaseEmptyState = styled.div` grid-column: 1 / -1; padding: 48px 0; text-align: center; @@ -212,40 +214,119 @@ export const EmptyState = styled.div` color: #909090; `; -export const CtaSection = styled.div` - padding: 32px 24px; - border-top: 1px solid #e5e5e5; - margin-top: 8px; +// ── Dark-themed exports ─────────────────────────────────────────────────────── + +export { GridContainer as BaseGridContainer }; + +export const TypingGlobalStyle = createGlobalStyle` + html, + body { + background-color: #0b0b10; + } `; -export const SectionHeading = styled.h2` - font-size: 17px; - font-weight: 700; - color: #1a1a1a; - margin: 0 0 14px; +export const Root = styled(BaseRoot)` + background-color: #0b0b10; + color: #f5f5f5; `; -export const OpenPlayerLink = styled(Link)` - display: inline-flex; - align-items: center; - gap: 8px; - padding: 10px 22px; - border-radius: 10px; - background-color: #1a1a1a; +export const Navbar = styled(BaseNavbar)` + background-color: rgba(11, 11, 16, 0.9); + border-bottom: 1px solid #1f1f2a; +`; + +export const Logo = styled(BaseLogo)` + color: #f5f5f5; +`; + +export const LogoIcon = styled(BaseLogoIcon)` + background-color: #f5f5f5; + color: #0b0b10; +`; + +export const NavLink = styled(BaseNavLink)` + color: #b0b3bd; + &:hover { + background-color: #1a1d29; + color: #fff; + } +`; + +export const NavCtaLink = styled(BaseNavCtaLink)` + background-color: #1a1d29; + border-color: #2a2f3d; color: #fff; - font-size: 14px; - font-weight: 600; - text-decoration: none; - transition: background-color 0.15s; &:hover { - background-color: #333; + background-color: #222838; + border-color: #3a4154; } `; -export const PlayerDescription = styled.p` - font-size: 13px; - color: #909090; - margin: 14px 0 0; - line-height: 1.6; - max-width: 480px; +export const NavLeft = styled(BaseNavLeft)``; +export const NavCenter = styled(BaseNavCenter)``; +export const NavRight = styled(BaseNavRight)``; + +export const SearchBox = styled(BaseSearchBox)` + border-color: #2a2f3d; + background-color: #141824; + &:focus-within { + border-color: #4b5563; + } +`; + +export const SearchInput = styled(BaseSearchInput)` + color: #f5f5f5; + &::placeholder { + color: #8b90a0; + } +`; + +export const SearchButton = styled(BaseSearchButton)` + background-color: #1a1d29; + border-left-color: #2a2f3d; + color: #c0c4d0; + &:hover { + background-color: #222838; + color: #fff; + } +`; + +export const ChipsBar = styled(BaseChipsBar)` + background-color: #0f111a; +`; + +export const Chip = styled(BaseChip)` + border-color: #2a2f3d; + color: #b8bcc7; + background-color: transparent; + &:hover { + background-color: #1a1d29; + color: #fff; + } +`; + +export const Card = styled(BaseCard)` + border: 1px solid #1f1f2a; + background-color: #0f111a; + &:hover { + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35); + border-color: #2a2f3d; + } +`; + +export const ThumbnailWrapper = styled(BaseThumbnailWrapper)` + background-color: #1a1d29; + color: #4b5563; +`; + +export const CardTitle = styled(BaseCardTitle)` + color: #f5f5f5; +`; + +export const CardSub = styled(BaseCardSub)` + color: #9aa0ad; +`; + +export const EmptyState = styled(BaseEmptyState)` + color: #9aa0ad; `; diff --git a/src/app/page.tsx b/src/app/page.tsx index f8e1c1b..7a9b718 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,5 +1,143 @@ -import { redirect } from "next/navigation"; +"use client"; +import { useEffect, useState } from "react"; +import { FaPlay, FaMusic, FaSearch } from "react-icons/fa"; +import { MdLibraryMusic } from "react-icons/md"; +import { useAuth } from "./context/auth"; +import pb from "./lib/pocketbase"; +import { + Root, + Navbar, + Logo, + LogoIcon, + NavCtaLink, + NavLeft, + NavCenter, + SearchBox, + SearchInput, + SearchButton, + NavRight, -export default function HomePage() { - redirect("/typing"); + GridContainer, + CardGrid, + Card, + ThumbnailWrapper, + Thumbnail, + PlayOverlay, + PlayCircle, + CardMeta, + CardInfo, + CardTitle, + CardSub, + EmptyState, + TypingGlobalStyle, +} from "./page.styles"; + +interface ChartRecord { + id: string; + title: string; + artist: string; + thumbnail: string; + lrc: string; + media: string; + offset: number; +} + + +export default function TypingPage() { + const { user, signOut } = useAuth(); + const [charts, setCharts] = useState([]); + const [search, setSearch] = useState(""); + + useEffect(() => { + pb.collection("charts") + .getFullList({ sort: "-created" }) + .then(setCharts) + .catch(console.error); + }, []); + + const normalizedSearch = search.trim().toLowerCase(); + const filtered = normalizedSearch + ? charts.filter( + (item) => + item.title.toLowerCase().includes(normalizedSearch) || + item.artist.toLowerCase().includes(normalizedSearch), + ) + : charts; + + return ( + <> + + + + + + + + + TypingMIXX + + + + + + setSearch(e.target.value)} + /> + + + + + + + + {user ? ( + <> + + {user.username || user.name} + + { e.preventDefault(); signOut(); }}> + Sign out + + + ) : ( + Sign in + )} + + + + + + {filtered.length === 0 ? ( + No results found. + ) : ( + filtered.map((item) => ( + + + {item.thumbnail ? ( + + ) : ( + + )} + + + + + + + + + {item.title} + {item.artist} + + + + )) + )} + + + + + ); } diff --git a/src/app/signin/page.tsx b/src/app/signin/page.tsx new file mode 100644 index 0000000..67a4fd3 --- /dev/null +++ b/src/app/signin/page.tsx @@ -0,0 +1,280 @@ +"use client"; +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import styled, { createGlobalStyle, keyframes } from "styled-components"; +import { MdLibraryMusic } from "react-icons/md"; +import Link from "next/link"; +import { useAuth } from "../context/auth"; + +const GlobalStyle = createGlobalStyle` + html, body { + margin: 0; + padding: 0; + background-color: #0b0b10; + font-family: "Roboto", "Segoe UI", Arial, sans-serif; + } +`; + +const slideUp = keyframes` + from { transform: translateY(20px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +`; + +const Page = styled.div` + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 24px 16px; + background-color: #0b0b10; +`; + +const Card = styled.div` + width: 100%; + max-width: 400px; + background: #0f111a; + border: 1px solid #1f1f2a; + padding: 36px 32px 32px; + animation: ${slideUp} 0.25s ease; +`; + +const BrandRow = styled(Link)` + display: flex; + align-items: center; + gap: 8px; + text-decoration: none; + margin-bottom: 28px; +`; + +const BrandIcon = styled.span` + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 24px; + background: #f5f5f5; + color: #0b0b10; + font-size: 12px; +`; + +const BrandName = styled.span` + font-size: 18px; + font-weight: 800; + color: #f5f5f5; + letter-spacing: 0.3px; +`; + +const Tabs = styled.div` + display: flex; + gap: 4px; + background: #1a1d29; + padding: 4px; + margin-bottom: 28px; +`; + +const Tab = styled.button<{ $active: boolean }>` + flex: 1; + padding: 9px 0; + border: none; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, color 0.15s; + background: ${(p) => (p.$active ? "#2a2f3d" : "transparent")}; + color: ${(p) => (p.$active ? "#f5f5f5" : "#8b90a0")}; + &:hover { color: #f5f5f5; } +`; + +const Field = styled.div` + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 16px; +`; + +const Label = styled.label` + font-size: 12px; + font-weight: 600; + color: #8b90a0; + letter-spacing: 0.5px; + text-transform: uppercase; +`; + +const Input = styled.input` + padding: 11px 14px; + border: 1px solid #2a2f3d; + background: #141824; + color: #f5f5f5; + font-size: 14px; + outline: none; + transition: border-color 0.15s; + &:focus { border-color: #a78bfa; } + &::placeholder { color: #4b5563; } +`; + +const ErrorMsg = styled.p` + font-size: 13px; + color: #f87171; + margin: 0 0 16px; + padding: 10px 14px; + background: rgba(248, 113, 113, 0.08); + border: 1px solid rgba(248, 113, 113, 0.2) +`; + +const SubmitBtn = styled.button` + width: 100%; + padding: 12px; + margin-top: 4px; + border: none; + background: #a78bfa; + color: #fff; + font-size: 14px; + font-weight: 700; + cursor: pointer; + transition: background 0.15s, opacity 0.15s; + &:hover:not(:disabled) { background: #9061f9; } + &:disabled { opacity: 0.5; cursor: not-allowed; } +`; + +type Mode = "signin" | "register"; + +export default function SignInPage() { + const router = useRouter(); + const { user, signIn, signUp } = useAuth(); + + const [mode, setMode] = useState("signin"); + const [email, setEmail] = useState(""); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [passwordConfirm, setPasswordConfirm] = useState(""); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + + + useEffect(() => { + if (user) router.replace("/"); + }, [user, router]); + + const switchMode = (next: Mode) => { + setMode(next); + setError(""); + setEmail(""); + setUsername(""); + setPassword(""); + setPasswordConfirm(""); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + + if (mode === "register" && password !== passwordConfirm) { + setError("Passwords do not match."); + return; + } + + setBusy(true); + try { + if (mode === "signin") { + await signIn(email, password); + } else { + await signUp(email, username, password, passwordConfirm); + } + router.replace("/"); + } catch (err: unknown) { + setError( + err instanceof Error ? err.message : "Something went wrong. Please try again." + ); + } finally { + setBusy(false); + } + }; + + return ( + <> + + + + + + TypingMIXX + + + + switchMode("signin")}> + Sign in + + switchMode("register")}> + Register + + + +
+ + + setEmail(e.target.value)} + required + autoFocus + /> + + + {mode === "register" && ( + + + setUsername(e.target.value)} + required + autoComplete="username" + /> + + )} + + + + setPassword(e.target.value)} + required + /> + + + {mode === "register" && ( + + + setPasswordConfirm(e.target.value)} + required + /> + + )} + + {error && {error}} + + + {busy + ? mode === "signin" ? "Signing in…" : "Creating account…" + : mode === "signin" ? "Sign in" : "Create account"} + +
+
+
+ + ); +} diff --git a/src/app/styles/shared.ts b/src/app/styles/shared.ts index d1b0232..ce240c5 100644 --- a/src/app/styles/shared.ts +++ b/src/app/styles/shared.ts @@ -40,7 +40,6 @@ export const LogoIcon = styled.span` justify-content: center; background-color: #1a1a1a; color: #fff; - border-radius: 6px; width: 30px; height: 22px; font-size: 10px; @@ -52,7 +51,6 @@ export const NavLink = styled(Link)` color: #606060; text-decoration: none; padding: 6px 10px; - border-radius: 8px; transition: background-color 0.15s, color 0.15s; &:hover { background-color: #f0f0f0; @@ -66,7 +64,6 @@ export const NavCtaLink = styled(Link)` color: #1a1a1a; text-decoration: none; padding: 6px 12px; - border-radius: 999px; background-color: #f5f5f5; border: 1px solid #e5e5e5; transition: background-color 0.15s, border-color 0.15s, color 0.15s; diff --git a/src/app/typing/page.styles.ts b/src/app/typing/page.styles.ts deleted file mode 100644 index 34f4ebc..0000000 --- a/src/app/typing/page.styles.ts +++ /dev/null @@ -1,168 +0,0 @@ -import styled, { createGlobalStyle } from "styled-components"; -import { - Root as BaseRoot, - Navbar as BaseNavbar, - Logo as BaseLogo, - LogoIcon as BaseLogoIcon, - NavLink as BaseNavLink, - NavCtaLink as BaseNavCtaLink, -} from "../styles/shared"; -import { - NavLeft, - NavCenter, - SearchBox as BaseSearchBox, - SearchInput as BaseSearchInput, - SearchButton as BaseSearchButton, - NavRight, - ChipsBar as BaseChipsBar, - Chip as BaseChip, - GridContainer, - CardGrid, - Card as BaseCard, - ThumbnailWrapper as BaseThumbnailWrapper, - Thumbnail, - PlayOverlay, - PlayCircle, - CardMeta, - CardInfo, - CardTitle as BaseCardTitle, - CardSub as BaseCardSub, - EmptyState as BaseEmptyState, - CtaSection as BaseCtaSection, - SectionHeading as BaseSectionHeading, - OpenPlayerLink as BaseOpenPlayerLink, - PlayerDescription as BasePlayerDescription, -} from "../page.styles"; - -export { NavLeft, NavCenter, NavRight, GridContainer, CardGrid, Thumbnail, PlayOverlay, PlayCircle, CardMeta, CardInfo }; - -export const TypingGlobalStyle = createGlobalStyle` - html, - body { - background-color: #0b0b10; - } -`; - -export const Root = styled(BaseRoot)` - background-color: #0b0b10; - color: #f5f5f5; -`; - -export const Navbar = styled(BaseNavbar)` - background-color: rgba(11, 11, 16, 0.9); - border-bottom: 1px solid #1f1f2a; -`; - -export const Logo = styled(BaseLogo)` - color: #f5f5f5; -`; - -export const LogoIcon = styled(BaseLogoIcon)` - background-color: #f5f5f5; - color: #0b0b10; -`; - -export const NavLink = styled(BaseNavLink)` - color: #b0b3bd; - &:hover { - background-color: #1a1d29; - color: #fff; - } -`; - -export const NavCtaLink = styled(BaseNavCtaLink)` - background-color: #1a1d29; - border-color: #2a2f3d; - color: #fff; - &:hover { - background-color: #222838; - border-color: #3a4154; - } -`; - -export const SearchBox = styled(BaseSearchBox)` - border-color: #2a2f3d; - background-color: #141824; - &:focus-within { - border-color: #4b5563; - } -`; - -export const SearchInput = styled(BaseSearchInput)` - color: #f5f5f5; - &::placeholder { - color: #8b90a0; - } -`; - -export const SearchButton = styled(BaseSearchButton)` - background-color: #1a1d29; - border-left-color: #2a2f3d; - color: #c0c4d0; - &:hover { - background-color: #222838; - color: #fff; - } -`; - - - -export const ChipsBar = styled(BaseChipsBar)` - background-color: #0f111a; -`; - -export const Chip = styled(BaseChip)` - border-color: #2a2f3d; - color: #b8bcc7; - background-color: transparent; - &:hover { - background-color: #1a1d29; - color: #fff; - } -`; - -export const Card = styled(BaseCard)` - border: 1px solid #1f1f2a; - background-color: #0f111a; - &:hover { - box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35); - border-color: #2a2f3d; - } -`; - -export const ThumbnailWrapper = styled(BaseThumbnailWrapper)` - background-color: #1a1d29; - color: #4b5563; -`; - -export const CardTitle = styled(BaseCardTitle)` - color: #f5f5f5; -`; - -export const CardSub = styled(BaseCardSub)` - color: #9aa0ad; -`; - -export const EmptyState = styled(BaseEmptyState)` - color: #9aa0ad; -`; - -export const CtaSection = styled(BaseCtaSection)` - border-top-color: #1f1f2a; -`; - -export const SectionHeading = styled(BaseSectionHeading)` - color: #f5f5f5; -`; - -export const OpenPlayerLink = styled(BaseOpenPlayerLink)` - background-color: #f5f5f5; - color: #0b0b10; - &:hover { - background-color: #e5e7eb; - } -`; - -export const PlayerDescription = styled(BasePlayerDescription)` - color: #9aa0ad; -`; \ No newline at end of file diff --git a/src/app/typing/page.tsx b/src/app/typing/page.tsx deleted file mode 100644 index 27ad173..0000000 --- a/src/app/typing/page.tsx +++ /dev/null @@ -1,161 +0,0 @@ -"use client"; -import { useEffect, useState } from "react"; -import { FaPlay, FaMusic, FaSearch } from "react-icons/fa"; -import { MdLibraryMusic } from "react-icons/md"; -import { - Root, - Navbar, - Logo, - LogoIcon, - NavCtaLink, - NavLeft, - NavCenter, - SearchBox, - SearchInput, - SearchButton, - NavRight, - - ChipsBar, - Chip, - GridContainer, - CardGrid, - Card, - ThumbnailWrapper, - Thumbnail, - PlayOverlay, - PlayCircle, - CardMeta, - CardInfo, - CardTitle, - CardSub, - EmptyState, - TypingGlobalStyle, -} from "./page.styles"; - -interface TypingEntry { - title: string; - artist: string; - thumbnail: string; - code: string; -} - -type TypingData = Record; - -function capitalize(s: string) { - return s.charAt(0).toUpperCase() + s.slice(1); -} - -export default function TypingPage() { - const [data, setData] = useState({}); - const [activeChip, setActiveChip] = useState("all"); - const [search, setSearch] = useState(""); - - useEffect(() => { - fetch("/typing.json") - .then((r) => r.json()) - .then((json: TypingData) => setData(json)) - .catch(() => {}); - }, []); - - const categories = Object.keys(data); - const chips = [ - { key: "all", label: "All" }, - ...categories.map((category) => ({ - key: category, - label: capitalize(category), - })), - ]; - - const visibleItems: TypingEntry[] = - activeChip === "all" ? Object.values(data).flat() : data[activeChip] ?? []; - - const normalizedSearch = search.trim().toLowerCase(); - const searchableItems = normalizedSearch ? Object.values(data).flat() : visibleItems; - - const filtered = normalizedSearch - ? searchableItems.filter( - (item) => - item.title.toLowerCase().includes(normalizedSearch) || - item.artist.toLowerCase().includes(normalizedSearch), - ) - : searchableItems; - - return ( - <> - - - - - - - - - LRC-Type - - - - - - setSearch(e.target.value)} - /> - - - - - - - - Create - - - - - - {chips.map((chip) => ( - setActiveChip(chip.key)} - > - {chip.label} - - ))} - - - - - {filtered.length === 0 ? ( - No results found. - ) : ( - filtered.map((item) => ( - - - {item.thumbnail ? ( - - ) : ( - - )} - - - - - - - - - {item.title} - {item.artist} - - - - )) - )} - - - - - ); -} -- cgit v1.2.3