From ec0d487d814dcaca7a05dbd4cdc6acc48ee18d36 Mon Sep 17 00:00:00 2001
From: kotru21
Date: Wed, 13 Aug 2025 16:19:23 +0300
Subject: [PATCH 01/40] Initial commit
---
.gitattributes | 2 ++
1 file changed, 2 insertions(+)
create mode 100644 .gitattributes
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..dfe0770
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,2 @@
+# Auto detect text files and perform LF normalization
+* text=auto
From 1185c41983000f7692453afb8fae1e111ec6758f Mon Sep 17 00:00:00 2001
From: kotru21
Date: Wed, 13 Aug 2025 18:58:57 +0300
Subject: [PATCH 02/40] Initialize React TypeScript Vite project
Set up project structure with React, TypeScript, Vite, ESLint, Tailwind CSS, and essential providers, entities, shared utilities, and pages. Includes initial configuration files, dependencies, and README documentation.
---
.gitignore | 24 +
README.md | 69 +
eslint.config.js | 23 +
index.html | 36 +
package-lock.json | 5112 ++++++++++++++++++++++++++++
package.json | 47 +
postcss.config.js | 6 +
public/vite.svg | 1 +
src/App.css | 1 +
src/App.tsx | 58 +
src/app/providers/auth-context.ts | 16 +
src/app/providers/auth.tsx | 57 +
src/app/providers/route-guards.tsx | 17 +
src/app/providers/theme-context.ts | 13 +
src/app/providers/theme.tsx | 56 +
src/app/providers/useAuth.ts | 8 +
src/app/providers/useTheme.ts | 8 +
src/entities/question/api.ts | 70 +
src/entities/question/types.ts | 33 +
src/entities/snippet/api.ts | 63 +
src/entities/snippet/types.ts | 26 +
src/index.css | 1 +
src/main.tsx | 45 +
src/pages/HomePage.tsx | 119 +
src/pages/QuestionPage.tsx | 92 +
src/pages/auth/LoginPage.tsx | 90 +
src/pages/auth/RegisterPage.tsx | 103 +
src/shared/api/http.ts | 47 +
src/shared/api/normalize.ts | 63 +
src/shared/api/types/codelang.d.ts | 1508 ++++++++
src/shared/ui/CodeBlock.tsx | 52 +
src/vite-env.d.ts | 1 +
tailwind.config.js | 13 +
tsconfig.app.json | 27 +
tsconfig.json | 7 +
tsconfig.node.json | 25 +
vite.config.ts | 18 +
37 files changed, 7955 insertions(+)
create mode 100644 .gitignore
create mode 100644 README.md
create mode 100644 eslint.config.js
create mode 100644 index.html
create mode 100644 package-lock.json
create mode 100644 package.json
create mode 100644 postcss.config.js
create mode 100644 public/vite.svg
create mode 100644 src/App.css
create mode 100644 src/App.tsx
create mode 100644 src/app/providers/auth-context.ts
create mode 100644 src/app/providers/auth.tsx
create mode 100644 src/app/providers/route-guards.tsx
create mode 100644 src/app/providers/theme-context.ts
create mode 100644 src/app/providers/theme.tsx
create mode 100644 src/app/providers/useAuth.ts
create mode 100644 src/app/providers/useTheme.ts
create mode 100644 src/entities/question/api.ts
create mode 100644 src/entities/question/types.ts
create mode 100644 src/entities/snippet/api.ts
create mode 100644 src/entities/snippet/types.ts
create mode 100644 src/index.css
create mode 100644 src/main.tsx
create mode 100644 src/pages/HomePage.tsx
create mode 100644 src/pages/QuestionPage.tsx
create mode 100644 src/pages/auth/LoginPage.tsx
create mode 100644 src/pages/auth/RegisterPage.tsx
create mode 100644 src/shared/api/http.ts
create mode 100644 src/shared/api/normalize.ts
create mode 100644 src/shared/api/types/codelang.d.ts
create mode 100644 src/shared/ui/CodeBlock.tsx
create mode 100644 src/vite-env.d.ts
create mode 100644 tailwind.config.js
create mode 100644 tsconfig.app.json
create mode 100644 tsconfig.json
create mode 100644 tsconfig.node.json
create mode 100644 vite.config.ts
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..a547bf3
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..7959ce4
--- /dev/null
+++ b/README.md
@@ -0,0 +1,69 @@
+# React + TypeScript + Vite
+
+This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
+
+Currently, two official plugins are available:
+
+- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
+- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
+
+## Expanding the ESLint configuration
+
+If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
+
+```js
+export default tseslint.config([
+ globalIgnores(['dist']),
+ {
+ files: ['**/*.{ts,tsx}'],
+ extends: [
+ // Other configs...
+
+ // Remove tseslint.configs.recommended and replace with this
+ ...tseslint.configs.recommendedTypeChecked,
+ // Alternatively, use this for stricter rules
+ ...tseslint.configs.strictTypeChecked,
+ // Optionally, add this for stylistic rules
+ ...tseslint.configs.stylisticTypeChecked,
+
+ // Other configs...
+ ],
+ languageOptions: {
+ parserOptions: {
+ project: ['./tsconfig.node.json', './tsconfig.app.json'],
+ tsconfigRootDir: import.meta.dirname,
+ },
+ // other options...
+ },
+ },
+])
+```
+
+You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
+
+```js
+// eslint.config.js
+import reactX from 'eslint-plugin-react-x'
+import reactDom from 'eslint-plugin-react-dom'
+
+export default tseslint.config([
+ globalIgnores(['dist']),
+ {
+ files: ['**/*.{ts,tsx}'],
+ extends: [
+ // Other configs...
+ // Enable lint rules for React
+ reactX.configs['recommended-typescript'],
+ // Enable lint rules for React DOM
+ reactDom.configs.recommended,
+ ],
+ languageOptions: {
+ parserOptions: {
+ project: ['./tsconfig.node.json', './tsconfig.app.json'],
+ tsconfigRootDir: import.meta.dirname,
+ },
+ // other options...
+ },
+ },
+])
+```
diff --git a/eslint.config.js b/eslint.config.js
new file mode 100644
index 0000000..d94e7de
--- /dev/null
+++ b/eslint.config.js
@@ -0,0 +1,23 @@
+import js from '@eslint/js'
+import globals from 'globals'
+import reactHooks from 'eslint-plugin-react-hooks'
+import reactRefresh from 'eslint-plugin-react-refresh'
+import tseslint from 'typescript-eslint'
+import { globalIgnores } from 'eslint/config'
+
+export default tseslint.config([
+ globalIgnores(['dist']),
+ {
+ files: ['**/*.{ts,tsx}'],
+ extends: [
+ js.configs.recommended,
+ tseslint.configs.recommended,
+ reactHooks.configs['recommended-latest'],
+ reactRefresh.configs.vite,
+ ],
+ languageOptions: {
+ ecmaVersion: 2020,
+ globals: globals.browser,
+ },
+ },
+])
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..f0579e9
--- /dev/null
+++ b/index.html
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+ Vite + React + TS
+
+
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..af05d33
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,5112 @@
+{
+ "name": "stackoverflow",
+ "version": "0.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "stackoverflow",
+ "version": "0.0.0",
+ "dependencies": {
+ "@codemirror/lang-javascript": "^6.2.4",
+ "@hookform/resolvers": "^5.2.1",
+ "@tanstack/react-query": "^5.85.0",
+ "@tanstack/react-virtual": "^3.13.12",
+ "@uiw/react-codemirror": "^4.25.1",
+ "axios": "^1.11.0",
+ "prism-react-renderer": "^2.4.1",
+ "react": "^19.1.1",
+ "react-dom": "^19.1.1",
+ "react-hook-form": "^7.62.0",
+ "react-intersection-observer": "^9.16.0",
+ "react-router-dom": "^7.8.0",
+ "socket.io-client": "^4.8.1",
+ "zod": "^4.0.17"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.33.0",
+ "@tailwindcss/postcss": "^4.1.11",
+ "@types/react": "^19.1.10",
+ "@types/react-dom": "^19.1.7",
+ "@vitejs/plugin-react-swc": "^4.0.0",
+ "autoprefixer": "^10.4.21",
+ "eslint": "^9.33.0",
+ "eslint-plugin-react-hooks": "^5.2.0",
+ "eslint-plugin-react-refresh": "^0.4.20",
+ "globals": "^16.3.0",
+ "openapi-typescript": "^7.9.1",
+ "postcss": "^8.5.6",
+ "tailwindcss": "^4.1.11",
+ "typescript": "~5.8.3",
+ "typescript-eslint": "^8.39.1",
+ "vite": "^7.1.2",
+ "vite-plugin-mkcert": "^1.17.8"
+ }
+ },
+ "node_modules/@alloc/quick-lru": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
+ "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@ampproject/remapping": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
+ "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
+ "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.27.1",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
+ "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.28.2",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.2.tgz",
+ "integrity": "sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@codemirror/autocomplete": {
+ "version": "6.18.6",
+ "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.18.6.tgz",
+ "integrity": "sha512-PHHBXFomUs5DF+9tCOM/UoW6XQ4R44lLNNhRaW9PKPTU0D7lIjRg3ElxaJnTwsl/oHiR93WSXDBrekhoUGCPtg==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.17.0",
+ "@lezer/common": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/commands": {
+ "version": "6.8.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.8.1.tgz",
+ "integrity": "sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.4.0",
+ "@codemirror/view": "^6.27.0",
+ "@lezer/common": "^1.1.0"
+ }
+ },
+ "node_modules/@codemirror/lang-javascript": {
+ "version": "6.2.4",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz",
+ "integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/language": "^6.6.0",
+ "@codemirror/lint": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.17.0",
+ "@lezer/common": "^1.0.0",
+ "@lezer/javascript": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/language": {
+ "version": "6.11.2",
+ "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.2.tgz",
+ "integrity": "sha512-p44TsNArL4IVXDTbapUmEkAlvWs2CFQbcfc0ymDsis1kH2wh0gcY96AS29c/vp2d0y2Tquk1EDSaawpzilUiAw==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.23.0",
+ "@lezer/common": "^1.1.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0",
+ "style-mod": "^4.0.0"
+ }
+ },
+ "node_modules/@codemirror/lint": {
+ "version": "6.8.5",
+ "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.5.tgz",
+ "integrity": "sha512-s3n3KisH7dx3vsoeGMxsbRAgKe4O1vbrnKBClm99PU0fWxmxsx5rR2PfqQgIt+2MMJBHbiJ5rfIdLYfB9NNvsA==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.35.0",
+ "crelt": "^1.0.5"
+ }
+ },
+ "node_modules/@codemirror/search": {
+ "version": "6.5.11",
+ "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz",
+ "integrity": "sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0",
+ "crelt": "^1.0.5"
+ }
+ },
+ "node_modules/@codemirror/state": {
+ "version": "6.5.2",
+ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz",
+ "integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==",
+ "license": "MIT",
+ "dependencies": {
+ "@marijn/find-cluster-break": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/theme-one-dark": {
+ "version": "6.1.3",
+ "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz",
+ "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0",
+ "@lezer/highlight": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/view": {
+ "version": "6.38.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.1.tgz",
+ "integrity": "sha512-RmTOkE7hRU3OVREqFVITWHz6ocgBjv08GoePscAakgVQfciA3SGCEk7mb9IzwW61cKKmlTpHXG6DUE5Ubx+MGQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.5.0",
+ "crelt": "^1.0.6",
+ "style-mod": "^4.1.0",
+ "w3c-keyname": "^2.2.4"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz",
+ "integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.9.tgz",
+ "integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz",
+ "integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.9.tgz",
+ "integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz",
+ "integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz",
+ "integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz",
+ "integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz",
+ "integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz",
+ "integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz",
+ "integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz",
+ "integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz",
+ "integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz",
+ "integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz",
+ "integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz",
+ "integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz",
+ "integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz",
+ "integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz",
+ "integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz",
+ "integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz",
+ "integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz",
+ "integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz",
+ "integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz",
+ "integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz",
+ "integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz",
+ "integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz",
+ "integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz",
+ "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eslint-visitor-keys": "^3.4.3"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint-community/regexpp": {
+ "version": "4.12.1",
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz",
+ "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@eslint/config-array": {
+ "version": "0.21.0",
+ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz",
+ "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/object-schema": "^2.1.6",
+ "debug": "^4.3.1",
+ "minimatch": "^3.1.2"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/config-helpers": {
+ "version": "0.3.1",
+ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz",
+ "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/core": {
+ "version": "0.15.2",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz",
+ "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/json-schema": "^7.0.15"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/eslintrc": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz",
+ "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^6.12.4",
+ "debug": "^4.3.2",
+ "espree": "^10.0.1",
+ "globals": "^14.0.0",
+ "ignore": "^5.2.0",
+ "import-fresh": "^3.2.1",
+ "js-yaml": "^4.1.0",
+ "minimatch": "^3.1.2",
+ "strip-json-comments": "^3.1.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint/eslintrc/node_modules/globals": {
+ "version": "14.0.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
+ "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@eslint/js": {
+ "version": "9.33.0",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.33.0.tgz",
+ "integrity": "sha512-5K1/mKhWaMfreBGJTwval43JJmkip0RmM+3+IuqupeSKNC/Th2Kc7ucaq5ovTSra/OOKB9c58CGSz3QMVbWt0A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ }
+ },
+ "node_modules/@eslint/object-schema": {
+ "version": "2.1.6",
+ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz",
+ "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/plugin-kit": {
+ "version": "0.3.5",
+ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz",
+ "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.15.2",
+ "levn": "^0.4.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@hookform/resolvers": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.1.tgz",
+ "integrity": "sha512-u0+6X58gkjMcxur1wRWokA7XsiiBJ6aK17aPZxhkoYiK5J+HcTx0Vhu9ovXe6H+dVpO6cjrn2FkJTryXEMlryQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/utils": "^0.3.0"
+ },
+ "peerDependencies": {
+ "react-hook-form": "^7.55.0"
+ }
+ },
+ "node_modules/@humanfs/core": {
+ "version": "0.19.1",
+ "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
+ "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/node": {
+ "version": "0.16.6",
+ "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz",
+ "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/core": "^0.19.1",
+ "@humanwhocodes/retry": "^0.3.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": {
+ "version": "0.3.1",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz",
+ "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@humanwhocodes/module-importer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.22"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@humanwhocodes/retry": {
+ "version": "0.4.3",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
+ "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@isaacs/fs-minipass": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
+ "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "minipass": "^7.0.4"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.30",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz",
+ "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@lezer/common": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.3.tgz",
+ "integrity": "sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==",
+ "license": "MIT"
+ },
+ "node_modules/@lezer/highlight": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.1.tgz",
+ "integrity": "sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.0.0"
+ }
+ },
+ "node_modules/@lezer/javascript": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.1.tgz",
+ "integrity": "sha512-ATOImjeVJuvgm3JQ/bpo2Tmv55HSScE2MTPnKRMRIPx2cLhHGyX2VnqpHhtIV1tVzIjZDbcWQm+NCTF40ggZVw==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.1.3",
+ "@lezer/lr": "^1.3.0"
+ }
+ },
+ "node_modules/@lezer/lr": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.2.tgz",
+ "integrity": "sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.0.0"
+ }
+ },
+ "node_modules/@marijn/find-cluster-break": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
+ "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
+ "license": "MIT"
+ },
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@redocly/ajv": {
+ "version": "8.11.3",
+ "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.3.tgz",
+ "integrity": "sha512-4P3iZse91TkBiY+Dx5DUgxQ9GXkVJf++cmI0MOyLDxV9b5MUBI4II6ES8zA5JCbO72nKAJxWrw4PUPW+YP3ZDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2",
+ "uri-js-replace": "^1.0.1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/@redocly/ajv/node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@redocly/config": {
+ "version": "0.22.2",
+ "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.2.tgz",
+ "integrity": "sha512-roRDai8/zr2S9YfmzUfNhKjOF0NdcOIqF7bhf4MVC5UxpjIysDjyudvlAiVbpPHp3eDRWbdzUgtkK1a7YiDNyQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@redocly/openapi-core": {
+ "version": "1.34.5",
+ "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.5.tgz",
+ "integrity": "sha512-0EbE8LRbkogtcCXU7liAyC00n9uNG9hJ+eMyHFdUsy9lB/WGqnEBgwjA9q2cyzAVcdTkQqTBBU1XePNnN3OijA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@redocly/ajv": "^8.11.2",
+ "@redocly/config": "^0.22.0",
+ "colorette": "^1.2.0",
+ "https-proxy-agent": "^7.0.5",
+ "js-levenshtein": "^1.1.6",
+ "js-yaml": "^4.1.0",
+ "minimatch": "^5.0.1",
+ "pluralize": "^8.0.0",
+ "yaml-ast-parser": "0.0.43"
+ },
+ "engines": {
+ "node": ">=18.17.0",
+ "npm": ">=9.5.0"
+ }
+ },
+ "node_modules/@redocly/openapi-core/node_modules/brace-expansion": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
+ "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/@redocly/openapi-core/node_modules/minimatch": {
+ "version": "5.1.6",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
+ "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-beta.30",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.30.tgz",
+ "integrity": "sha512-whXaSoNUFiyDAjkUF8OBpOm77Szdbk5lGNqFe6CbVbJFrhCCPinCbRA3NjawwlNHla1No7xvXXh+CpSxnPfUEw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.46.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.46.2.tgz",
+ "integrity": "sha512-Zj3Hl6sN34xJtMv7Anwb5Gu01yujyE/cLBDB2gnHTAHaWS1Z38L7kuSG+oAh0giZMqG060f/YBStXtMH6FvPMA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.46.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.46.2.tgz",
+ "integrity": "sha512-nTeCWY83kN64oQ5MGz3CgtPx8NSOhC5lWtsjTs+8JAJNLcP3QbLCtDDgUKQc/Ro/frpMq4SHUaHN6AMltcEoLQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.46.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.46.2.tgz",
+ "integrity": "sha512-HV7bW2Fb/F5KPdM/9bApunQh68YVDU8sO8BvcW9OngQVN3HHHkw99wFupuUJfGR9pYLLAjcAOA6iO+evsbBaPQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.46.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.46.2.tgz",
+ "integrity": "sha512-SSj8TlYV5nJixSsm/y3QXfhspSiLYP11zpfwp6G/YDXctf3Xkdnk4woJIF5VQe0of2OjzTt8EsxnJDCdHd2xMA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.46.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.46.2.tgz",
+ "integrity": "sha512-ZyrsG4TIT9xnOlLsSSi9w/X29tCbK1yegE49RYm3tu3wF1L/B6LVMqnEWyDB26d9Ecx9zrmXCiPmIabVuLmNSg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.46.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.46.2.tgz",
+ "integrity": "sha512-pCgHFoOECwVCJ5GFq8+gR8SBKnMO+xe5UEqbemxBpCKYQddRQMgomv1104RnLSg7nNvgKy05sLsY51+OVRyiVw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.46.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.46.2.tgz",
+ "integrity": "sha512-EtP8aquZ0xQg0ETFcxUbU71MZlHaw9MChwrQzatiE8U/bvi5uv/oChExXC4mWhjiqK7azGJBqU0tt5H123SzVA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.46.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.46.2.tgz",
+ "integrity": "sha512-qO7F7U3u1nfxYRPM8HqFtLd+raev2K137dsV08q/LRKRLEc7RsiDWihUnrINdsWQxPR9jqZ8DIIZ1zJJAm5PjQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.46.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.46.2.tgz",
+ "integrity": "sha512-3dRaqLfcOXYsfvw5xMrxAk9Lb1f395gkoBYzSFcc/scgRFptRXL9DOaDpMiehf9CO8ZDRJW2z45b6fpU5nwjng==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.46.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.46.2.tgz",
+ "integrity": "sha512-fhHFTutA7SM+IrR6lIfiHskxmpmPTJUXpWIsBXpeEwNgZzZZSg/q4i6FU4J8qOGyJ0TR+wXBwx/L7Ho9z0+uDg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loongarch64-gnu": {
+ "version": "4.46.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.46.2.tgz",
+ "integrity": "sha512-i7wfGFXu8x4+FRqPymzjD+Hyav8l95UIZ773j7J7zRYc3Xsxy2wIn4x+llpunexXe6laaO72iEjeeGyUFmjKeA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.46.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.46.2.tgz",
+ "integrity": "sha512-B/l0dFcHVUnqcGZWKcWBSV2PF01YUt0Rvlurci5P+neqY/yMKchGU8ullZvIv5e8Y1C6wOn+U03mrDylP5q9Yw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.46.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.46.2.tgz",
+ "integrity": "sha512-32k4ENb5ygtkMwPMucAb8MtV8olkPT03oiTxJbgkJa7lJ7dZMr0GCFJlyvy+K8iq7F/iuOr41ZdUHaOiqyR3iQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.46.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.46.2.tgz",
+ "integrity": "sha512-t5B2loThlFEauloaQkZg9gxV05BYeITLvLkWOkRXogP4qHXLkWSbSHKM9S6H1schf/0YGP/qNKtiISlxvfmmZw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.46.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.46.2.tgz",
+ "integrity": "sha512-YKjekwTEKgbB7n17gmODSmJVUIvj8CX7q5442/CK80L8nqOUbMtf8b01QkG3jOqyr1rotrAnW6B/qiHwfcuWQA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.46.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.46.2.tgz",
+ "integrity": "sha512-Jj5a9RUoe5ra+MEyERkDKLwTXVu6s3aACP51nkfnK9wJTraCC8IMe3snOfALkrjTYd2G1ViE1hICj0fZ7ALBPA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.46.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.46.2.tgz",
+ "integrity": "sha512-7kX69DIrBeD7yNp4A5b81izs8BqoZkCIaxQaOpumcJ1S/kmqNFjPhDu1LHeVXv0SexfHQv5cqHsxLOjETuqDuA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.46.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.46.2.tgz",
+ "integrity": "sha512-wiJWMIpeaak/jsbaq2HMh/rzZxHVW1rU6coyeNNpMwk5isiPjSTx0a4YLSlYDwBH/WBvLz+EtsNqQScZTLJy3g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.46.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.46.2.tgz",
+ "integrity": "sha512-gBgaUDESVzMgWZhcyjfs9QFK16D8K6QZpwAaVNJxYDLHWayOta4ZMjGm/vsAEy3hvlS2GosVFlBlP9/Wb85DqQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.46.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.46.2.tgz",
+ "integrity": "sha512-CvUo2ixeIQGtF6WvuB87XWqPQkoFAFqW+HUo/WzHwuHDvIwZCtjdWXoYCcr06iKGydiqTclC4jU/TNObC/xKZg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@socket.io/component-emitter": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
+ "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
+ "license": "MIT"
+ },
+ "node_modules/@standard-schema/utils": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
+ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
+ "license": "MIT"
+ },
+ "node_modules/@swc/core": {
+ "version": "1.13.3",
+ "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.13.3.tgz",
+ "integrity": "sha512-ZaDETVWnm6FE0fc+c2UE8MHYVS3Fe91o5vkmGfgwGXFbxYvAjKSqxM/j4cRc9T7VZNSJjriXq58XkfCp3Y6f+w==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@swc/counter": "^0.1.3",
+ "@swc/types": "^0.1.23"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/swc"
+ },
+ "optionalDependencies": {
+ "@swc/core-darwin-arm64": "1.13.3",
+ "@swc/core-darwin-x64": "1.13.3",
+ "@swc/core-linux-arm-gnueabihf": "1.13.3",
+ "@swc/core-linux-arm64-gnu": "1.13.3",
+ "@swc/core-linux-arm64-musl": "1.13.3",
+ "@swc/core-linux-x64-gnu": "1.13.3",
+ "@swc/core-linux-x64-musl": "1.13.3",
+ "@swc/core-win32-arm64-msvc": "1.13.3",
+ "@swc/core-win32-ia32-msvc": "1.13.3",
+ "@swc/core-win32-x64-msvc": "1.13.3"
+ },
+ "peerDependencies": {
+ "@swc/helpers": ">=0.5.17"
+ },
+ "peerDependenciesMeta": {
+ "@swc/helpers": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@swc/core-darwin-arm64": {
+ "version": "1.13.3",
+ "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.13.3.tgz",
+ "integrity": "sha512-ux0Ws4pSpBTqbDS9GlVP354MekB1DwYlbxXU3VhnDr4GBcCOimpocx62x7cFJkSpEBF8bmX8+/TTCGKh4PbyXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-darwin-x64": {
+ "version": "1.13.3",
+ "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.13.3.tgz",
+ "integrity": "sha512-p0X6yhxmNUOMZrbeZ3ZNsPige8lSlSe1llllXvpCLkKKxN/k5vZt1sULoq6Nj4eQ7KeHQVm81/+AwKZyf/e0TA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-linux-arm-gnueabihf": {
+ "version": "1.13.3",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.13.3.tgz",
+ "integrity": "sha512-OmDoiexL2fVWvQTCtoh0xHMyEkZweQAlh4dRyvl8ugqIPEVARSYtaj55TBMUJIP44mSUOJ5tytjzhn2KFxFcBA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-linux-arm64-gnu": {
+ "version": "1.13.3",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.13.3.tgz",
+ "integrity": "sha512-STfKku3QfnuUj6k3g9ld4vwhtgCGYIFQmsGPPgT9MK/dI3Lwnpe5Gs5t1inoUIoGNP8sIOLlBB4HV4MmBjQuhw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-linux-arm64-musl": {
+ "version": "1.13.3",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.13.3.tgz",
+ "integrity": "sha512-bc+CXYlFc1t8pv9yZJGus372ldzOVscBl7encUBlU1m/Sig0+NDJLz6cXXRcFyl6ABNOApWeR4Yl7iUWx6C8og==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-linux-x64-gnu": {
+ "version": "1.13.3",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.13.3.tgz",
+ "integrity": "sha512-dFXoa0TEhohrKcxn/54YKs1iwNeW6tUkHJgXW33H381SvjKFUV53WR231jh1sWVJETjA3vsAwxKwR23s7UCmUA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-linux-x64-musl": {
+ "version": "1.13.3",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.13.3.tgz",
+ "integrity": "sha512-ieyjisLB+ldexiE/yD8uomaZuZIbTc8tjquYln9Quh5ykOBY7LpJJYBWvWtm1g3pHv6AXlBI8Jay7Fffb6aLfA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-win32-arm64-msvc": {
+ "version": "1.13.3",
+ "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.13.3.tgz",
+ "integrity": "sha512-elTQpnaX5vESSbhCEgcwXjpMsnUbqqHfEpB7ewpkAsLzKEXZaK67ihSRYAuAx6ewRQTo7DS5iTT6X5aQD3MzMw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-win32-ia32-msvc": {
+ "version": "1.13.3",
+ "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.13.3.tgz",
+ "integrity": "sha512-nvehQVEOdI1BleJpuUgPLrclJ0TzbEMc+MarXDmmiRFwEUGqj+pnfkTSb7RZyS1puU74IXdK/YhTirHurtbI9w==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-win32-x64-msvc": {
+ "version": "1.13.3",
+ "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.13.3.tgz",
+ "integrity": "sha512-A+JSKGkRbPLVV2Kwx8TaDAV0yXIXm/gc8m98hSkVDGlPBBmydgzNdWy3X7HTUBM7IDk7YlWE7w2+RUGjdgpTmg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/counter": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
+ "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/@swc/types": {
+ "version": "0.1.24",
+ "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.24.tgz",
+ "integrity": "sha512-tjTMh3V4vAORHtdTprLlfoMptu1WfTZG9Rsca6yOKyNYsRr+MUXutKmliB17orgSZk5DpnDxs8GUdd/qwYxOng==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@swc/counter": "^0.1.3"
+ }
+ },
+ "node_modules/@tailwindcss/node": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.11.tgz",
+ "integrity": "sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@ampproject/remapping": "^2.3.0",
+ "enhanced-resolve": "^5.18.1",
+ "jiti": "^2.4.2",
+ "lightningcss": "1.30.1",
+ "magic-string": "^0.30.17",
+ "source-map-js": "^1.2.1",
+ "tailwindcss": "4.1.11"
+ }
+ },
+ "node_modules/@tailwindcss/oxide": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.11.tgz",
+ "integrity": "sha512-Q69XzrtAhuyfHo+5/HMgr1lAiPP/G40OMFAnws7xcFEYqcypZmdW8eGXaOUIeOl1dzPJBPENXgbjsOyhg2nkrg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "detect-libc": "^2.0.4",
+ "tar": "^7.4.3"
+ },
+ "engines": {
+ "node": ">= 10"
+ },
+ "optionalDependencies": {
+ "@tailwindcss/oxide-android-arm64": "4.1.11",
+ "@tailwindcss/oxide-darwin-arm64": "4.1.11",
+ "@tailwindcss/oxide-darwin-x64": "4.1.11",
+ "@tailwindcss/oxide-freebsd-x64": "4.1.11",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.11",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.1.11",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.1.11",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.1.11",
+ "@tailwindcss/oxide-linux-x64-musl": "4.1.11",
+ "@tailwindcss/oxide-wasm32-wasi": "4.1.11",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.1.11",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.1.11"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-android-arm64": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.11.tgz",
+ "integrity": "sha512-3IfFuATVRUMZZprEIx9OGDjG3Ou3jG4xQzNTvjDoKmU9JdmoCohQJ83MYd0GPnQIu89YoJqvMM0G3uqLRFtetg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-arm64": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.11.tgz",
+ "integrity": "sha512-ESgStEOEsyg8J5YcMb1xl8WFOXfeBmrhAwGsFxxB2CxY9evy63+AtpbDLAyRkJnxLy2WsD1qF13E97uQyP1lfQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-x64": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.11.tgz",
+ "integrity": "sha512-EgnK8kRchgmgzG6jE10UQNaH9Mwi2n+yw1jWmof9Vyg2lpKNX2ioe7CJdf9M5f8V9uaQxInenZkOxnTVL3fhAw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-freebsd-x64": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.11.tgz",
+ "integrity": "sha512-xdqKtbpHs7pQhIKmqVpxStnY1skuNh4CtbcyOHeX1YBE0hArj2romsFGb6yUmzkq/6M24nkxDqU8GYrKrz+UcA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.11.tgz",
+ "integrity": "sha512-ryHQK2eyDYYMwB5wZL46uoxz2zzDZsFBwfjssgB7pzytAeCCa6glsiJGjhTEddq/4OsIjsLNMAiMlHNYnkEEeg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.11.tgz",
+ "integrity": "sha512-mYwqheq4BXF83j/w75ewkPJmPZIqqP1nhoghS9D57CLjsh3Nfq0m4ftTotRYtGnZd3eCztgbSPJ9QhfC91gDZQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.11.tgz",
+ "integrity": "sha512-m/NVRFNGlEHJrNVk3O6I9ggVuNjXHIPoD6bqay/pubtYC9QIdAMpS+cswZQPBLvVvEF6GtSNONbDkZrjWZXYNQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.11.tgz",
+ "integrity": "sha512-YW6sblI7xukSD2TdbbaeQVDysIm/UPJtObHJHKxDEcW2exAtY47j52f8jZXkqE1krdnkhCMGqP3dbniu1Te2Fg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.11.tgz",
+ "integrity": "sha512-e3C/RRhGunWYNC3aSF7exsQkdXzQ/M+aYuZHKnw4U7KQwTJotnWsGOIVih0s2qQzmEzOFIJ3+xt7iq67K/p56Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.11.tgz",
+ "integrity": "sha512-Xo1+/GU0JEN/C/dvcammKHzeM6NqKovG+6921MR6oadee5XPBaKOumrJCXvopJ/Qb5TH7LX/UAywbqrP4lax0g==",
+ "bundleDependencies": [
+ "@napi-rs/wasm-runtime",
+ "@emnapi/core",
+ "@emnapi/runtime",
+ "@tybys/wasm-util",
+ "@emnapi/wasi-threads",
+ "tslib"
+ ],
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.4.3",
+ "@emnapi/runtime": "^1.4.3",
+ "@emnapi/wasi-threads": "^1.0.2",
+ "@napi-rs/wasm-runtime": "^0.2.11",
+ "@tybys/wasm-util": "^0.9.0",
+ "tslib": "^2.8.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.11.tgz",
+ "integrity": "sha512-UgKYx5PwEKrac3GPNPf6HVMNhUIGuUh4wlDFR2jYYdkX6pL/rn73zTq/4pzUm8fOjAn5L8zDeHp9iXmUGOXZ+w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.11.tgz",
+ "integrity": "sha512-YfHoggn1j0LK7wR82TOucWc5LDCguHnoS879idHekmmiR7g9HUtMw9MI0NHatS28u/Xlkfi9w5RJWgz2Dl+5Qg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/postcss": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.11.tgz",
+ "integrity": "sha512-q/EAIIpF6WpLhKEuQSEVMZNMIY8KhWoAemZ9eylNAih9jxMGAYPPWBn3I9QL/2jZ+e7OEz/tZkX5HwbBR4HohA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@alloc/quick-lru": "^5.2.0",
+ "@tailwindcss/node": "4.1.11",
+ "@tailwindcss/oxide": "4.1.11",
+ "postcss": "^8.4.41",
+ "tailwindcss": "4.1.11"
+ }
+ },
+ "node_modules/@tanstack/query-core": {
+ "version": "5.83.1",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.83.1.tgz",
+ "integrity": "sha512-OG69LQgT7jSp+5pPuCfzltq/+7l2xoweggjme9vlbCPa/d7D7zaqv5vN/S82SzSYZ4EDLTxNO1PWrv49RAS64Q==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/react-query": {
+ "version": "5.85.0",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.85.0.tgz",
+ "integrity": "sha512-t1HMfToVMGfwEJRya6GG7gbK0luZJd+9IySFNePL1BforU1F3LqQ3tBC2Rpvr88bOrlU6PXyMLgJD0Yzn4ztUw==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/query-core": "5.83.1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^18 || ^19"
+ }
+ },
+ "node_modules/@tanstack/react-virtual": {
+ "version": "3.13.12",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz",
+ "integrity": "sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/virtual-core": "3.13.12"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/@tanstack/virtual-core": {
+ "version": "3.13.12",
+ "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz",
+ "integrity": "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/json-schema": {
+ "version": "7.0.15",
+ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
+ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/prismjs": {
+ "version": "1.26.5",
+ "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.5.tgz",
+ "integrity": "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==",
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "19.1.10",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.10.tgz",
+ "integrity": "sha512-EhBeSYX0Y6ye8pNebpKrwFJq7BoQ8J5SO6NlvNwwHjSj6adXJViPQrKlsyPw7hLBLvckEMO1yxeGdR82YBBlDg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.0.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.1.7",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.7.tgz",
+ "integrity": "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin": {
+ "version": "8.39.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.39.1.tgz",
+ "integrity": "sha512-yYegZ5n3Yr6eOcqgj2nJH8cH/ZZgF+l0YIdKILSDjYFRjgYQMgv/lRjV5Z7Up04b9VYUondt8EPMqg7kTWgJ2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/regexpp": "^4.10.0",
+ "@typescript-eslint/scope-manager": "8.39.1",
+ "@typescript-eslint/type-utils": "8.39.1",
+ "@typescript-eslint/utils": "8.39.1",
+ "@typescript-eslint/visitor-keys": "8.39.1",
+ "graphemer": "^1.4.0",
+ "ignore": "^7.0.0",
+ "natural-compare": "^1.4.0",
+ "ts-api-utils": "^2.1.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "@typescript-eslint/parser": "^8.39.1",
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
+ "version": "7.0.5",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
+ "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/@typescript-eslint/parser": {
+ "version": "8.39.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.39.1.tgz",
+ "integrity": "sha512-pUXGCuHnnKw6PyYq93lLRiZm3vjuslIy7tus1lIQTYVK9bL8XBgJnCWm8a0KcTtHC84Yya1Q6rtll+duSMj0dg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/scope-manager": "8.39.1",
+ "@typescript-eslint/types": "8.39.1",
+ "@typescript-eslint/typescript-estree": "8.39.1",
+ "@typescript-eslint/visitor-keys": "8.39.1",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/project-service": {
+ "version": "8.39.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.39.1.tgz",
+ "integrity": "sha512-8fZxek3ONTwBu9ptw5nCKqZOSkXshZB7uAxuFF0J/wTMkKydjXCzqqga7MlFMpHi9DoG4BadhmTkITBcg8Aybw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/tsconfig-utils": "^8.39.1",
+ "@typescript-eslint/types": "^8.39.1",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/scope-manager": {
+ "version": "8.39.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.39.1.tgz",
+ "integrity": "sha512-RkBKGBrjgskFGWuyUGz/EtD8AF/GW49S21J8dvMzpJitOF1slLEbbHnNEtAHtnDAnx8qDEdRrULRnWVx27wGBw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.39.1",
+ "@typescript-eslint/visitor-keys": "8.39.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/tsconfig-utils": {
+ "version": "8.39.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.39.1.tgz",
+ "integrity": "sha512-ePUPGVtTMR8XMU2Hee8kD0Pu4NDE1CN9Q1sxGSGd/mbOtGZDM7pnhXNJnzW63zk/q+Z54zVzj44HtwXln5CvHA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/type-utils": {
+ "version": "8.39.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.39.1.tgz",
+ "integrity": "sha512-gu9/ahyatyAdQbKeHnhT4R+y3YLtqqHyvkfDxaBYk97EcbfChSJXyaJnIL3ygUv7OuZatePHmQvuH5ru0lnVeA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.39.1",
+ "@typescript-eslint/typescript-estree": "8.39.1",
+ "@typescript-eslint/utils": "8.39.1",
+ "debug": "^4.3.4",
+ "ts-api-utils": "^2.1.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/types": {
+ "version": "8.39.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.39.1.tgz",
+ "integrity": "sha512-7sPDKQQp+S11laqTrhHqeAbsCfMkwJMrV7oTDvtDds4mEofJYir414bYKUEb8YPUm9QL3U+8f6L6YExSoAGdQw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree": {
+ "version": "8.39.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.39.1.tgz",
+ "integrity": "sha512-EKkpcPuIux48dddVDXyQBlKdeTPMmALqBUbEk38McWv0qVEZwOpVJBi7ugK5qVNgeuYjGNQxrrnoM/5+TI/BPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/project-service": "8.39.1",
+ "@typescript-eslint/tsconfig-utils": "8.39.1",
+ "@typescript-eslint/types": "8.39.1",
+ "@typescript-eslint/visitor-keys": "8.39.1",
+ "debug": "^4.3.4",
+ "fast-glob": "^3.3.2",
+ "is-glob": "^4.0.3",
+ "minimatch": "^9.0.4",
+ "semver": "^7.6.0",
+ "ts-api-utils": "^2.1.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
+ "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+ "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/@typescript-eslint/utils": {
+ "version": "8.39.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.39.1.tgz",
+ "integrity": "sha512-VF5tZ2XnUSTuiqZFXCZfZs1cgkdd3O/sSYmdo2EpSyDlC86UM/8YytTmKnehOW3TGAlivqTDT6bS87B/GQ/jyg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.7.0",
+ "@typescript-eslint/scope-manager": "8.39.1",
+ "@typescript-eslint/types": "8.39.1",
+ "@typescript-eslint/typescript-estree": "8.39.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys": {
+ "version": "8.39.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.39.1.tgz",
+ "integrity": "sha512-W8FQi6kEh2e8zVhQ0eeRnxdvIoOkAp/CPAahcNio6nO9dsIwb9b34z90KOlheoyuVf6LSOEdjlkxSkapNEc+4A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.39.1",
+ "eslint-visitor-keys": "^4.2.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@uiw/codemirror-extensions-basic-setup": {
+ "version": "4.25.1",
+ "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.1.tgz",
+ "integrity": "sha512-zxgA2QkvP3ZDKxTBc9UltNFTrSeFezGXcZtZj6qcsBxiMzowoEMP5mVwXcKjpzldpZVRuY+JCC+RsekEgid4vg==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/commands": "^6.0.0",
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/lint": "^6.0.0",
+ "@codemirror/search": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://jaywcjlove.github.io/#/sponsor"
+ },
+ "peerDependencies": {
+ "@codemirror/autocomplete": ">=6.0.0",
+ "@codemirror/commands": ">=6.0.0",
+ "@codemirror/language": ">=6.0.0",
+ "@codemirror/lint": ">=6.0.0",
+ "@codemirror/search": ">=6.0.0",
+ "@codemirror/state": ">=6.0.0",
+ "@codemirror/view": ">=6.0.0"
+ }
+ },
+ "node_modules/@uiw/react-codemirror": {
+ "version": "4.25.1",
+ "resolved": "https://registry.npmjs.org/@uiw/react-codemirror/-/react-codemirror-4.25.1.tgz",
+ "integrity": "sha512-eESBKHndoYkaEGlKCwRO4KrnTw1HkWBxVpEeqntoWTpoFEUYxdLWUYmkPBVk4/u8YzVy9g91nFfIRpqe5LjApg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.18.6",
+ "@codemirror/commands": "^6.1.0",
+ "@codemirror/state": "^6.1.1",
+ "@codemirror/theme-one-dark": "^6.0.0",
+ "@uiw/codemirror-extensions-basic-setup": "4.25.1",
+ "codemirror": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://jaywcjlove.github.io/#/sponsor"
+ },
+ "peerDependencies": {
+ "@babel/runtime": ">=7.11.0",
+ "@codemirror/state": ">=6.0.0",
+ "@codemirror/theme-one-dark": ">=6.0.0",
+ "@codemirror/view": ">=6.0.0",
+ "codemirror": ">=6.0.0",
+ "react": ">=17.0.0",
+ "react-dom": ">=17.0.0"
+ }
+ },
+ "node_modules/@vitejs/plugin-react-swc": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-4.0.0.tgz",
+ "integrity": "sha512-4A1dThI578v07mpG4M+ziNn6lmlMlhtVCheL+2WLvClnLvEULi8rpAZThn2oEKn3GtFXFTOeko6eLRhx2V2kgA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rolldown/pluginutils": "1.0.0-beta.30",
+ "@swc/core": "^1.13.2"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "vite": "^4 || ^5 || ^6 || ^7"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.15.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
+ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-jsx": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/agent-base": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "6.12.6",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ansi-colors": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
+ "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "dev": true,
+ "license": "Python-2.0"
+ },
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "license": "MIT"
+ },
+ "node_modules/autoprefixer": {
+ "version": "10.4.21",
+ "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
+ "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/autoprefixer"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "browserslist": "^4.24.4",
+ "caniuse-lite": "^1.0.30001702",
+ "fraction.js": "^4.3.7",
+ "normalize-range": "^0.1.2",
+ "picocolors": "^1.1.1",
+ "postcss-value-parser": "^4.2.0"
+ },
+ "bin": {
+ "autoprefixer": "bin/autoprefixer"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
+ "node_modules/axios": {
+ "version": "1.11.0",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz",
+ "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==",
+ "license": "MIT",
+ "dependencies": {
+ "follow-redirects": "^1.15.6",
+ "form-data": "^4.0.4",
+ "proxy-from-env": "^1.1.0"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/brace-expansion": {
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.25.2",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.2.tgz",
+ "integrity": "sha512-0si2SJK3ooGzIawRu61ZdPCO1IncZwS8IzuX73sPZsXW6EQ/w/DAfPyKI8l1ETTCr2MnvqWitmlCUxgdul45jA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "caniuse-lite": "^1.0.30001733",
+ "electron-to-chromium": "^1.5.199",
+ "node-releases": "^2.0.19",
+ "update-browserslist-db": "^1.1.3"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/callsites": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001734",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001734.tgz",
+ "integrity": "sha512-uhE1Ye5vgqju6OI71HTQqcBCZrvHugk0MjLak7Q+HfoBgoq5Bi+5YnwjP4fjDgrtYr/l8MVRBvzz9dPD4KyK0A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/change-case": {
+ "version": "5.4.4",
+ "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz",
+ "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/chownr": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
+ "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/codemirror": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
+ "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/commands": "^6.0.0",
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/lint": "^6.0.0",
+ "@codemirror/search": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/colorette": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz",
+ "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "license": "MIT",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cookie": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
+ "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/crelt": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
+ "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
+ "license": "MIT"
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
+ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
+ "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/deep-is": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
+ "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.200",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.200.tgz",
+ "integrity": "sha512-rFCxROw7aOe4uPTfIAx+rXv9cEcGx+buAF4npnhtTqCJk5KDFRnh3+KYj7rdVh6lsFt5/aPs+Irj9rZ33WMA7w==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/engine.io-client": {
+ "version": "6.6.3",
+ "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz",
+ "integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==",
+ "license": "MIT",
+ "dependencies": {
+ "@socket.io/component-emitter": "~3.1.0",
+ "debug": "~4.3.1",
+ "engine.io-parser": "~5.2.1",
+ "ws": "~8.17.1",
+ "xmlhttprequest-ssl": "~2.1.1"
+ }
+ },
+ "node_modules/engine.io-client/node_modules/debug": {
+ "version": "4.3.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
+ "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/engine.io-parser": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
+ "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/enhanced-resolve": {
+ "version": "5.18.3",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
+ "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz",
+ "integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.9",
+ "@esbuild/android-arm": "0.25.9",
+ "@esbuild/android-arm64": "0.25.9",
+ "@esbuild/android-x64": "0.25.9",
+ "@esbuild/darwin-arm64": "0.25.9",
+ "@esbuild/darwin-x64": "0.25.9",
+ "@esbuild/freebsd-arm64": "0.25.9",
+ "@esbuild/freebsd-x64": "0.25.9",
+ "@esbuild/linux-arm": "0.25.9",
+ "@esbuild/linux-arm64": "0.25.9",
+ "@esbuild/linux-ia32": "0.25.9",
+ "@esbuild/linux-loong64": "0.25.9",
+ "@esbuild/linux-mips64el": "0.25.9",
+ "@esbuild/linux-ppc64": "0.25.9",
+ "@esbuild/linux-riscv64": "0.25.9",
+ "@esbuild/linux-s390x": "0.25.9",
+ "@esbuild/linux-x64": "0.25.9",
+ "@esbuild/netbsd-arm64": "0.25.9",
+ "@esbuild/netbsd-x64": "0.25.9",
+ "@esbuild/openbsd-arm64": "0.25.9",
+ "@esbuild/openbsd-x64": "0.25.9",
+ "@esbuild/openharmony-arm64": "0.25.9",
+ "@esbuild/sunos-x64": "0.25.9",
+ "@esbuild/win32-arm64": "0.25.9",
+ "@esbuild/win32-ia32": "0.25.9",
+ "@esbuild/win32-x64": "0.25.9"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eslint": {
+ "version": "9.33.0",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.33.0.tgz",
+ "integrity": "sha512-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.2.0",
+ "@eslint-community/regexpp": "^4.12.1",
+ "@eslint/config-array": "^0.21.0",
+ "@eslint/config-helpers": "^0.3.1",
+ "@eslint/core": "^0.15.2",
+ "@eslint/eslintrc": "^3.3.1",
+ "@eslint/js": "9.33.0",
+ "@eslint/plugin-kit": "^0.3.5",
+ "@humanfs/node": "^0.16.6",
+ "@humanwhocodes/module-importer": "^1.0.1",
+ "@humanwhocodes/retry": "^0.4.2",
+ "@types/estree": "^1.0.6",
+ "@types/json-schema": "^7.0.15",
+ "ajv": "^6.12.4",
+ "chalk": "^4.0.0",
+ "cross-spawn": "^7.0.6",
+ "debug": "^4.3.2",
+ "escape-string-regexp": "^4.0.0",
+ "eslint-scope": "^8.4.0",
+ "eslint-visitor-keys": "^4.2.1",
+ "espree": "^10.4.0",
+ "esquery": "^1.5.0",
+ "esutils": "^2.0.2",
+ "fast-deep-equal": "^3.1.3",
+ "file-entry-cache": "^8.0.0",
+ "find-up": "^5.0.0",
+ "glob-parent": "^6.0.2",
+ "ignore": "^5.2.0",
+ "imurmurhash": "^0.1.4",
+ "is-glob": "^4.0.0",
+ "json-stable-stringify-without-jsonify": "^1.0.1",
+ "lodash.merge": "^4.6.2",
+ "minimatch": "^3.1.2",
+ "natural-compare": "^1.4.0",
+ "optionator": "^0.9.3"
+ },
+ "bin": {
+ "eslint": "bin/eslint.js"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ },
+ "peerDependencies": {
+ "jiti": "*"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-plugin-react-hooks": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz",
+ "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
+ }
+ },
+ "node_modules/eslint-plugin-react-refresh": {
+ "version": "0.4.20",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.20.tgz",
+ "integrity": "sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "eslint": ">=8.40"
+ }
+ },
+ "node_modules/eslint-scope": {
+ "version": "8.4.0",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
+ "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "esrecurse": "^4.3.0",
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint-visitor-keys": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
+ "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/espree": {
+ "version": "10.4.0",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
+ "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "acorn": "^8.15.0",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^4.2.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/esquery": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz",
+ "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "estraverse": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/esrecurse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-glob": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
+ "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.8"
+ },
+ "engines": {
+ "node": ">=8.6.0"
+ }
+ },
+ "node_modules/fast-glob/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-levenshtein": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fastq": {
+ "version": "1.19.1",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
+ "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
+ "node_modules/file-entry-cache": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
+ "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flat-cache": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/find-up": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/flat-cache": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
+ "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flatted": "^3.2.9",
+ "keyv": "^4.5.4"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/flatted": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
+ "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/follow-redirects": {
+ "version": "1.15.11",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
+ "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/RubenVerborgh"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependenciesMeta": {
+ "debug": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/form-data": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
+ "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.2",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fraction.js": {
+ "version": "4.3.7",
+ "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
+ "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "type": "patreon",
+ "url": "https://github.com/sponsors/rawify"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/globals": {
+ "version": "16.3.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-16.3.0.tgz",
+ "integrity": "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/graphemer": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
+ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/ignore": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/import-fresh": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
+ "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
+ "node_modules/index-to-position": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.1.0.tgz",
+ "integrity": "sha512-XPdx9Dq4t9Qk1mTMbWONJqU7boCoumEH7fRET37HX5+khDUl3J2W6PdALxhILYlIYx2amlwYcRPp28p0tSiojg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/jiti": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz",
+ "integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
+ "node_modules/js-levenshtein": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz",
+ "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/js-yaml": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
+ "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/json-buffer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
+ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-stable-stringify-without-jsonify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/keyv": {
+ "version": "4.5.4",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
+ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "json-buffer": "3.0.1"
+ }
+ },
+ "node_modules/levn": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1",
+ "type-check": "~0.4.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.30.1",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
+ "integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-darwin-arm64": "1.30.1",
+ "lightningcss-darwin-x64": "1.30.1",
+ "lightningcss-freebsd-x64": "1.30.1",
+ "lightningcss-linux-arm-gnueabihf": "1.30.1",
+ "lightningcss-linux-arm64-gnu": "1.30.1",
+ "lightningcss-linux-arm64-musl": "1.30.1",
+ "lightningcss-linux-x64-gnu": "1.30.1",
+ "lightningcss-linux-x64-musl": "1.30.1",
+ "lightningcss-win32-arm64-msvc": "1.30.1",
+ "lightningcss-win32-x64-msvc": "1.30.1"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.30.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz",
+ "integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.30.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz",
+ "integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.30.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz",
+ "integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.30.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz",
+ "integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.30.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz",
+ "integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.30.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz",
+ "integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.30.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz",
+ "integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.30.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz",
+ "integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.30.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz",
+ "integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.30.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz",
+ "integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/locate-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/lodash.merge": {
+ "version": "4.6.2",
+ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.17",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
+ "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/merge2": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/minipass": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
+ "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/minizlib": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz",
+ "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "minipass": "^7.1.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/mkdirp": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz",
+ "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "mkdirp": "dist/cjs/src/bin.js"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/natural-compare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.19",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
+ "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/normalize-range": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz",
+ "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/openapi-typescript": {
+ "version": "7.9.1",
+ "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.9.1.tgz",
+ "integrity": "sha512-9gJtoY04mk6iPMbToPjPxEAtfXZ0dTsMZtsgUI8YZta0btPPig9DJFP4jlerQD/7QOwYgb0tl+zLUpDf7vb7VA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@redocly/openapi-core": "^1.34.5",
+ "ansi-colors": "^4.1.3",
+ "change-case": "^5.4.4",
+ "parse-json": "^8.3.0",
+ "supports-color": "^10.1.0",
+ "yargs-parser": "^21.1.1"
+ },
+ "bin": {
+ "openapi-typescript": "bin/cli.js"
+ },
+ "peerDependencies": {
+ "typescript": "^5.x"
+ }
+ },
+ "node_modules/openapi-typescript/node_modules/supports-color": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.1.0.tgz",
+ "integrity": "sha512-GBuewsPrhJPftT+fqDa9oI/zc5HNsG9nREqwzoSFDOIqf0NggOZbHQj2TE1P1CDJK8ZogFnlZY9hWoUiur7I/A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/supports-color?sponsor=1"
+ }
+ },
+ "node_modules/optionator": {
+ "version": "0.9.4",
+ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "deep-is": "^0.1.3",
+ "fast-levenshtein": "^2.0.6",
+ "levn": "^0.4.1",
+ "prelude-ls": "^1.2.1",
+ "type-check": "^0.4.0",
+ "word-wrap": "^1.2.5"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/parent-module": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "callsites": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/parse-json": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz",
+ "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.26.2",
+ "index-to-position": "^1.1.0",
+ "type-fest": "^4.39.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pluralize": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
+ "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.6",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+ "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/postcss-value-parser": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/prelude-ls": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/prism-react-renderer": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/prism-react-renderer/-/prism-react-renderer-2.4.1.tgz",
+ "integrity": "sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/prismjs": "^1.26.0",
+ "clsx": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.0.0"
+ }
+ },
+ "node_modules/proxy-from-env": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
+ "license": "MIT"
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/queue-microtask": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/react": {
+ "version": "19.1.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz",
+ "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.1.1",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz",
+ "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.26.0"
+ },
+ "peerDependencies": {
+ "react": "^19.1.1"
+ }
+ },
+ "node_modules/react-hook-form": {
+ "version": "7.62.0",
+ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.62.0.tgz",
+ "integrity": "sha512-7KWFejc98xqG/F4bAxpL41NB3o1nnvQO1RWZT3TqRZYL8RryQETGfEdVnJN2fy1crCiBLLjkRBVK05j24FxJGA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/react-hook-form"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17 || ^18 || ^19"
+ }
+ },
+ "node_modules/react-intersection-observer": {
+ "version": "9.16.0",
+ "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.16.0.tgz",
+ "integrity": "sha512-w9nJSEp+DrW9KmQmeWHQyfaP6b03v+TdXynaoA964Wxt7mdR3An11z4NNCQgL4gKSK7y1ver2Fq+JKH6CWEzUA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-router": {
+ "version": "7.8.0",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.8.0.tgz",
+ "integrity": "sha512-r15M3+LHKgM4SOapNmsH3smAizWds1vJ0Z9C4mWaKnT9/wD7+d/0jYcj6LmOvonkrO4Rgdyp4KQ/29gWN2i1eg==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "^1.0.1",
+ "set-cookie-parser": "^2.6.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-router-dom": {
+ "version": "7.8.0",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.8.0.tgz",
+ "integrity": "sha512-ntInsnDVnVRdtSu6ODmTQ41cbluak/ENeTif7GBce0L6eztFg6/e1hXAysFQI8X25C8ipKmT9cClbJwxx3Kaqw==",
+ "license": "MIT",
+ "dependencies": {
+ "react-router": "7.8.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/resolve-from": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/reusify": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
+ "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.46.2",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.46.2.tgz",
+ "integrity": "sha512-WMmLFI+Boh6xbop+OAGo9cQ3OgX9MIg7xOQjn+pTCwOkk+FNDAeAemXkJ3HzDJrVXleLOFVa1ipuc1AmEx1Dwg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.46.2",
+ "@rollup/rollup-android-arm64": "4.46.2",
+ "@rollup/rollup-darwin-arm64": "4.46.2",
+ "@rollup/rollup-darwin-x64": "4.46.2",
+ "@rollup/rollup-freebsd-arm64": "4.46.2",
+ "@rollup/rollup-freebsd-x64": "4.46.2",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.46.2",
+ "@rollup/rollup-linux-arm-musleabihf": "4.46.2",
+ "@rollup/rollup-linux-arm64-gnu": "4.46.2",
+ "@rollup/rollup-linux-arm64-musl": "4.46.2",
+ "@rollup/rollup-linux-loongarch64-gnu": "4.46.2",
+ "@rollup/rollup-linux-ppc64-gnu": "4.46.2",
+ "@rollup/rollup-linux-riscv64-gnu": "4.46.2",
+ "@rollup/rollup-linux-riscv64-musl": "4.46.2",
+ "@rollup/rollup-linux-s390x-gnu": "4.46.2",
+ "@rollup/rollup-linux-x64-gnu": "4.46.2",
+ "@rollup/rollup-linux-x64-musl": "4.46.2",
+ "@rollup/rollup-win32-arm64-msvc": "4.46.2",
+ "@rollup/rollup-win32-ia32-msvc": "4.46.2",
+ "@rollup/rollup-win32-x64-msvc": "4.46.2",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/run-parallel": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "queue-microtask": "^1.2.2"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.26.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
+ "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "7.7.2",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
+ "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/set-cookie-parser": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
+ "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
+ "license": "MIT"
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/socket.io-client": {
+ "version": "4.8.1",
+ "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
+ "integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@socket.io/component-emitter": "~3.1.0",
+ "debug": "~4.3.2",
+ "engine.io-client": "~6.6.1",
+ "socket.io-parser": "~4.2.4"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/socket.io-client/node_modules/debug": {
+ "version": "4.3.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
+ "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/socket.io-parser": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
+ "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
+ "license": "MIT",
+ "dependencies": {
+ "@socket.io/component-emitter": "~3.1.0",
+ "debug": "~4.3.1"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/socket.io-parser/node_modules/debug": {
+ "version": "4.3.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
+ "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/style-mod": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.2.tgz",
+ "integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==",
+ "license": "MIT"
+ },
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.11.tgz",
+ "integrity": "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tapable": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz",
+ "integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/tar": {
+ "version": "7.4.3",
+ "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz",
+ "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "@isaacs/fs-minipass": "^4.0.0",
+ "chownr": "^3.0.0",
+ "minipass": "^7.1.2",
+ "minizlib": "^3.0.1",
+ "mkdirp": "^3.0.1",
+ "yallist": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.14",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
+ "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.4.4",
+ "picomatch": "^4.0.2"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyglobby/node_modules/fdir": {
+ "version": "6.4.6",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz",
+ "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tinyglobby/node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/ts-api-utils": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
+ "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.12"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4"
+ }
+ },
+ "node_modules/type-check": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/type-fest": {
+ "version": "4.41.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
+ "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
+ "dev": true,
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.8.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
+ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/typescript-eslint": {
+ "version": "8.39.1",
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.39.1.tgz",
+ "integrity": "sha512-GDUv6/NDYngUlNvwaHM1RamYftxf782IyEDbdj3SeaIHHv8fNQVRC++fITT7kUJV/5rIA/tkoRSSskt6osEfqg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/eslint-plugin": "8.39.1",
+ "@typescript-eslint/parser": "8.39.1",
+ "@typescript-eslint/typescript-estree": "8.39.1",
+ "@typescript-eslint/utils": "8.39.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
+ "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/uri-js-replace": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz",
+ "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vite": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.2.tgz",
+ "integrity": "sha512-J0SQBPlQiEXAF7tajiH+rUooJPo0l8KQgyg4/aMunNtrOa7bwuZJsJbDWzeljqQpgftxuq5yNJxQ91O9ts29UQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.25.0",
+ "fdir": "^6.4.6",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.14"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "lightningcss": "^1.21.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite-plugin-mkcert": {
+ "version": "1.17.8",
+ "resolved": "https://registry.npmjs.org/vite-plugin-mkcert/-/vite-plugin-mkcert-1.17.8.tgz",
+ "integrity": "sha512-S+4tNEyGqdZQ3RLAG54ETeO2qyURHWrVjUWKYikLAbmhh/iJ+36gDEja4OWwFyXNuvyXcZwNt5TZZR9itPeG5Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "axios": "^1.8.3",
+ "debug": "^4.4.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=v16.7.0"
+ },
+ "peerDependencies": {
+ "vite": ">=3"
+ }
+ },
+ "node_modules/vite/node_modules/fdir": {
+ "version": "6.4.6",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz",
+ "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite/node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/w3c-keyname": {
+ "version": "2.2.8",
+ "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
+ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
+ "license": "MIT"
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/word-wrap": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.17.1",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
+ "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/xmlhttprequest-ssl": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
+ "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/yallist": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
+ "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/yaml-ast-parser": {
+ "version": "0.0.43",
+ "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz",
+ "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/yargs-parser": {
+ "version": "21.1.1",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
+ "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yocto-queue": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/zod": {
+ "version": "4.0.17",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.0.17.tgz",
+ "integrity": "sha512-1PHjlYRevNxxdy2JZ8JcNAw7rX8V9P1AKkP+x/xZfxB0K5FYfuV+Ug6P/6NVSR2jHQ+FzDDoDHS04nYUsOIyLQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..241549c
--- /dev/null
+++ b/package.json
@@ -0,0 +1,47 @@
+{
+ "name": "stackoverflow",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "lint": "eslint .",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@codemirror/lang-javascript": "^6.2.4",
+ "@hookform/resolvers": "^5.2.1",
+ "@tanstack/react-query": "^5.85.0",
+ "@tanstack/react-virtual": "^3.13.12",
+ "@uiw/react-codemirror": "^4.25.1",
+ "axios": "^1.11.0",
+ "prism-react-renderer": "^2.4.1",
+ "react": "^19.1.1",
+ "react-dom": "^19.1.1",
+ "react-hook-form": "^7.62.0",
+ "react-intersection-observer": "^9.16.0",
+ "react-router-dom": "^7.8.0",
+ "socket.io-client": "^4.8.1",
+ "zod": "^4.0.17"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.33.0",
+ "@tailwindcss/postcss": "^4.1.11",
+ "@types/react": "^19.1.10",
+ "@types/react-dom": "^19.1.7",
+ "@vitejs/plugin-react-swc": "^4.0.0",
+ "autoprefixer": "^10.4.21",
+ "eslint": "^9.33.0",
+ "eslint-plugin-react-hooks": "^5.2.0",
+ "eslint-plugin-react-refresh": "^0.4.20",
+ "globals": "^16.3.0",
+ "openapi-typescript": "^7.9.1",
+ "postcss": "^8.5.6",
+ "tailwindcss": "^4.1.11",
+ "typescript": "~5.8.3",
+ "typescript-eslint": "^8.39.1",
+ "vite": "^7.1.2",
+ "vite-plugin-mkcert": "^1.17.8"
+ }
+}
diff --git a/postcss.config.js b/postcss.config.js
new file mode 100644
index 0000000..f69c5d4
--- /dev/null
+++ b/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ "@tailwindcss/postcss": {},
+ autoprefixer: {},
+ },
+};
diff --git a/public/vite.svg b/public/vite.svg
new file mode 100644
index 0000000..e7b8dfb
--- /dev/null
+++ b/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/App.css b/src/App.css
new file mode 100644
index 0000000..fa483dd
--- /dev/null
+++ b/src/App.css
@@ -0,0 +1 @@
+/* Purged default Vite styles */
diff --git a/src/App.tsx b/src/App.tsx
new file mode 100644
index 0000000..5930ed9
--- /dev/null
+++ b/src/App.tsx
@@ -0,0 +1,58 @@
+import { Link, Outlet } from "react-router-dom";
+import { useAuth } from "./app/providers/useAuth";
+import { useTheme } from "./app/providers/useTheme";
+
+function Header() {
+ const { user, logout } = useAuth();
+ const { theme, toggle } = useTheme();
+ return (
+
+ );
+}
+
+export default function App() {
+ return (
+
+ );
+}
diff --git a/src/app/providers/auth-context.ts b/src/app/providers/auth-context.ts
new file mode 100644
index 0000000..5b76215
--- /dev/null
+++ b/src/app/providers/auth-context.ts
@@ -0,0 +1,16 @@
+import { createContext } from "react";
+
+export type User = { id: number; username: string; role: "user" | "admin" };
+
+export type AuthContextValue = {
+ user: User | null;
+ loading: boolean;
+ login: (username: string, password: string) => Promise;
+ logout: () => Promise;
+ register: (username: string, password: string) => Promise;
+ refresh: () => Promise;
+};
+
+export const AuthContext = createContext(
+ undefined
+);
diff --git a/src/app/providers/auth.tsx b/src/app/providers/auth.tsx
new file mode 100644
index 0000000..523967f
--- /dev/null
+++ b/src/app/providers/auth.tsx
@@ -0,0 +1,57 @@
+import { useEffect, useRef, useState } from "react";
+import { http, toHttpError } from "../../shared/api/http";
+import { AuthContext, type User } from "./auth-context";
+
+export function AuthProvider({ children }: { children: React.ReactNode }) {
+ const [user, setUser] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ const refresh = async () => {
+ try {
+ const res = await http.get("/auth");
+ setUser(res.data);
+ } catch {
+ setUser(null);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const didInit = useRef(false);
+ useEffect(() => {
+ if (didInit.current) return;
+ didInit.current = true;
+ void refresh();
+ }, []);
+
+ const login = async (username: string, password: string) => {
+ try {
+ await http.post("/auth/login", { username, password });
+ await refresh();
+ } catch (e) {
+ const err = toHttpError(e);
+ throw new Error(err.message || "Login failed");
+ }
+ };
+
+ const logout = async () => {
+ await http.post("/auth/logout");
+ setUser(null);
+ };
+
+ const register = async (username: string, password: string) => {
+ try {
+ await http.post("/register", { username, password });
+ } catch (e) {
+ const err = toHttpError(e);
+ throw new Error(err.message || "Register failed");
+ }
+ };
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/app/providers/route-guards.tsx b/src/app/providers/route-guards.tsx
new file mode 100644
index 0000000..e650bc5
--- /dev/null
+++ b/src/app/providers/route-guards.tsx
@@ -0,0 +1,17 @@
+import { Navigate, Outlet, useLocation } from "react-router-dom";
+import { useAuth } from "./useAuth";
+
+export function RequireAuth() {
+ const { user, loading } = useAuth();
+ const loc = useLocation();
+ if (loading) return null;
+ if (!user) return ;
+ return ;
+}
+
+export function RequireGuest() {
+ const { user, loading } = useAuth();
+ if (loading) return null;
+ if (user) return ;
+ return ;
+}
diff --git a/src/app/providers/theme-context.ts b/src/app/providers/theme-context.ts
new file mode 100644
index 0000000..42579d5
--- /dev/null
+++ b/src/app/providers/theme-context.ts
@@ -0,0 +1,13 @@
+import { createContext } from "react";
+
+export type Theme = "light" | "dark";
+
+export type ThemeContextValue = {
+ theme: Theme;
+ setTheme: (t: Theme) => void;
+ toggle: () => void;
+};
+
+export const ThemeContext = createContext(
+ undefined
+);
diff --git a/src/app/providers/theme.tsx b/src/app/providers/theme.tsx
new file mode 100644
index 0000000..828ee32
--- /dev/null
+++ b/src/app/providers/theme.tsx
@@ -0,0 +1,56 @@
+import { useEffect, useState } from "react";
+import { ThemeContext, type Theme } from "./theme-context";
+
+function applyThemeClass(theme: Theme) {
+ const root = document.documentElement;
+ const body = document.body;
+ const isDark = theme === "dark";
+ root.toggleAttribute("data-theme", true);
+ root.setAttribute("data-theme", theme);
+ if (isDark) root.classList.add("dark");
+ else root.classList.remove("dark");
+ if (body) {
+ body.toggleAttribute("data-theme", true);
+ body.setAttribute("data-theme", theme);
+ if (isDark) body.classList.add("dark");
+ else body.classList.remove("dark");
+ }
+}
+
+function readInitialTheme(): Theme {
+ try {
+ const saved = localStorage.getItem("theme");
+ if (saved === "light" || saved === "dark") return saved;
+ // По умолчанию уважаем системную настройку
+ if (
+ window.matchMedia &&
+ window.matchMedia("(prefers-color-scheme: dark)").matches
+ ) {
+ return "dark";
+ }
+ } catch {
+ // ignore
+ }
+ return "light";
+}
+
+export function ThemeProvider({ children }: { children: React.ReactNode }) {
+ const [theme, setTheme] = useState(readInitialTheme);
+
+ useEffect(() => {
+ applyThemeClass(theme);
+ try {
+ localStorage.setItem("theme", theme);
+ } catch {
+ // ignore
+ }
+ }, [theme]);
+
+ const toggle = () => setTheme((t) => (t === "dark" ? "light" : "dark"));
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/app/providers/useAuth.ts b/src/app/providers/useAuth.ts
new file mode 100644
index 0000000..838ddd3
--- /dev/null
+++ b/src/app/providers/useAuth.ts
@@ -0,0 +1,8 @@
+import { useContext } from "react";
+import { AuthContext, type AuthContextValue } from "./auth-context";
+
+export function useAuth(): AuthContextValue {
+ const ctx = useContext(AuthContext);
+ if (!ctx) throw new Error("useAuth must be used within AuthProvider");
+ return ctx;
+}
diff --git a/src/app/providers/useTheme.ts b/src/app/providers/useTheme.ts
new file mode 100644
index 0000000..d55f5d2
--- /dev/null
+++ b/src/app/providers/useTheme.ts
@@ -0,0 +1,8 @@
+import { useContext } from "react";
+import { ThemeContext, type ThemeContextValue } from "./theme-context";
+
+export function useTheme(): ThemeContextValue {
+ const ctx = useContext(ThemeContext);
+ if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
+ return ctx;
+}
diff --git a/src/entities/question/api.ts b/src/entities/question/api.ts
new file mode 100644
index 0000000..ce76a40
--- /dev/null
+++ b/src/entities/question/api.ts
@@ -0,0 +1,70 @@
+import {
+ useInfiniteQuery,
+ useMutation,
+ useQuery,
+ useQueryClient,
+} from "@tanstack/react-query";
+import { http } from "../../shared/api/http";
+import type { Paginated, Question } from "./types";
+import { normalizePaginated, unwrapData } from "../../shared/api/normalize";
+
+export function useQuestions(params: {
+ page?: number;
+ limit?: number;
+ search?: string;
+ sortBy?: string[];
+}) {
+ const { limit = 15, search = "", sortBy = [] } = params;
+ return useInfiniteQuery({
+ queryKey: ["questions", { limit, search, sortBy }],
+ queryFn: async ({ pageParam = 1 }) => {
+ const res = await http.get("/questions", {
+ params: { page: pageParam, limit, search, sortBy },
+ });
+ const raw = res.data as unknown;
+ const { data, meta } = normalizePaginated(raw, {
+ fallbackLimit: limit,
+ pageParam: Number(pageParam) || 1,
+ });
+ return { data, meta } as Paginated;
+ },
+ getNextPageParam: (last) => {
+ const cp = Number(last.meta?.currentPage ?? 1);
+ const tp = Number(last.meta?.totalPages ?? 1);
+ const next = cp + 1;
+ return next <= tp ? next : undefined;
+ },
+ retry: 1,
+ refetchOnWindowFocus: false,
+ initialPageParam: 1,
+ });
+}
+
+export function useQuestion(id?: string | number) {
+ return useQuery({
+ queryKey: ["question", id],
+ queryFn: async () => {
+ const res = await http.get(`/questions/${id}`);
+ return unwrapData(res.data as unknown);
+ },
+ enabled: !!id,
+ retry: 1,
+ refetchOnWindowFocus: false,
+ });
+}
+
+export function useCreateAnswer(questionId: string | number) {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: async (content: string) => {
+ const res = await http.post("/answers", {
+ content,
+ questionId: Number(questionId),
+ });
+ return res.data;
+ },
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ["question", questionId] });
+ },
+ });
+}
diff --git a/src/entities/question/types.ts b/src/entities/question/types.ts
new file mode 100644
index 0000000..a965b34
--- /dev/null
+++ b/src/entities/question/types.ts
@@ -0,0 +1,33 @@
+export type User = {
+ id: string | number;
+ username: string;
+ role: "user" | "admin";
+};
+
+export type Answer = {
+ id: string | number;
+ content: string;
+ isCorrect: boolean;
+};
+
+export type Question = {
+ id: string | number;
+ title: string;
+ description: string;
+ attachedCode?: string;
+ answers?: Answer[];
+ user: User;
+ isResolved?: boolean;
+};
+
+export type PaginatedMeta = {
+ itemsPerPage: number;
+ totalItems: number;
+ currentPage: number;
+ totalPages: number;
+};
+
+export type Paginated = {
+ data: T[];
+ meta: PaginatedMeta;
+};
diff --git a/src/entities/snippet/api.ts b/src/entities/snippet/api.ts
new file mode 100644
index 0000000..7a29a06
--- /dev/null
+++ b/src/entities/snippet/api.ts
@@ -0,0 +1,63 @@
+import {
+ useInfiniteQuery,
+ useMutation,
+ useQuery,
+ useQueryClient,
+} from "@tanstack/react-query";
+import { http } from "../../shared/api/http";
+import type { Paginated, Snippet, SnippetMark } from "./types";
+import { normalizePaginated } from "../../shared/api/normalize";
+
+export function useSnippets(params: {
+ page?: number;
+ limit?: number;
+ search?: string;
+ userId?: number;
+}) {
+ const { limit = 15, search = "", userId } = params;
+ return useInfiniteQuery({
+ queryKey: ["snippets", { search, userId, limit }],
+ queryFn: async ({ pageParam = 1 }) => {
+ const res = await http.get("/snippets", {
+ params: { page: pageParam, limit, search, userId },
+ });
+ const raw = res.data as unknown;
+ const { data, meta } = normalizePaginated(raw, {
+ fallbackLimit: limit,
+ pageParam: Number(pageParam) || 1,
+ });
+ return { data, meta } as Paginated;
+ },
+ getNextPageParam: (lastPage) => {
+ const cp = Number(lastPage.meta?.currentPage ?? 1);
+ const tp = Number(lastPage.meta?.totalPages ?? 1);
+ const next = cp + 1;
+ return next <= tp ? next : undefined;
+ },
+ retry: 1,
+ refetchOnWindowFocus: false,
+ initialPageParam: 1,
+ });
+}
+
+export function useSnippet(id?: number) {
+ return useQuery({
+ queryKey: ["snippets", id],
+ queryFn: async () => (await http.get(`/snippets/${id}`)).data,
+ enabled: !!id,
+ });
+}
+
+export function useMarkSnippet(id: number) {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: async (mark: SnippetMark) => {
+ await http.post(`/snippets/${id}/mark`, { mark });
+ return mark;
+ },
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ["snippets"] });
+ qc.invalidateQueries({ queryKey: ["snippets", id] });
+ },
+ });
+}
diff --git a/src/entities/snippet/types.ts b/src/entities/snippet/types.ts
new file mode 100644
index 0000000..dc5e3a2
--- /dev/null
+++ b/src/entities/snippet/types.ts
@@ -0,0 +1,26 @@
+export type User = { id: number; username: string; role: "user" | "admin" };
+
+export type Snippet = {
+ id: number;
+ language: string;
+ code: string;
+ user: User;
+ // Optional aggregate fields if backend provides them
+ likesCount?: number;
+ dislikesCount?: number;
+ commentsCount?: number;
+};
+
+export type PaginatedMeta = {
+ itemsPerPage: number;
+ totalItems: number;
+ currentPage: number;
+ totalPages: number;
+};
+
+export type Paginated = {
+ data: T[];
+ meta: PaginatedMeta;
+};
+
+export type SnippetMark = "like" | "dislike" | "none";
diff --git a/src/index.css b/src/index.css
new file mode 100644
index 0000000..f1d8c73
--- /dev/null
+++ b/src/index.css
@@ -0,0 +1 @@
+@import "tailwindcss";
diff --git a/src/main.tsx b/src/main.tsx
new file mode 100644
index 0000000..bd1dec4
--- /dev/null
+++ b/src/main.tsx
@@ -0,0 +1,45 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import "./index.css";
+import { createBrowserRouter, RouterProvider } from "react-router-dom";
+import HomePage from "./pages/HomePage";
+import QuestionPage from "./pages/QuestionPage";
+import LoginPage from "./pages/auth/LoginPage.tsx";
+import RegisterPage from "./pages/auth/RegisterPage.tsx";
+import { RequireGuest } from "./app/providers/route-guards";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import App from "./App.tsx";
+import { AuthProvider } from "./app/providers/auth";
+import { ThemeProvider } from "./app/providers/theme";
+
+const router = createBrowserRouter([
+ {
+ path: "/",
+ element: ,
+ children: [
+ { index: true, element: },
+ { path: "questions/:id", element: },
+ {
+ element: ,
+ children: [
+ { path: "login", element: },
+ { path: "register", element: },
+ ],
+ },
+ ],
+ },
+]);
+
+const queryClient = new QueryClient();
+
+createRoot(document.getElementById("root")!).render(
+
+
+
+
+
+
+
+
+
+);
diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx
new file mode 100644
index 0000000..cf92aaa
--- /dev/null
+++ b/src/pages/HomePage.tsx
@@ -0,0 +1,119 @@
+import { useEffect } from "react";
+import { Link } from "react-router-dom";
+import { useWindowVirtualizer } from "@tanstack/react-virtual";
+import { useQuestions } from "../entities/question/api";
+import type { Question } from "../entities/question/types";
+import { CodeBlock } from "../shared/ui/CodeBlock";
+
+type QuestionCardProps = {
+ id: string | number;
+ title: string;
+ description: string;
+ attachedCode?: string;
+ user: { username: string };
+ answersCount?: number;
+};
+
+function QuestionCard({
+ id,
+ title,
+ description,
+ attachedCode,
+ user,
+ answersCount,
+}: QuestionCardProps) {
+ return (
+
+
+
+ {title}
+
+ @{user.username}
+
+
+ {description}
+
+ {attachedCode && }
+
+ {typeof answersCount !== "undefined" && (
+ Answers: {answersCount}
+ )}
+
+
+
+ Answers →
+
+
+
+ );
+}
+
+export default function HomePage() {
+ const { data, fetchNextPage, hasNextPage, isFetchingNextPage, status } =
+ useQuestions({});
+
+ const items = (data?.pages.flatMap((p) => p.data) ?? []) as Question[];
+ const rowVirtualizer = useWindowVirtualizer({
+ count: items.length,
+ estimateSize: () => 220,
+ overscan: 6,
+ });
+
+ const virtualItems = rowVirtualizer.getVirtualItems();
+ useEffect(() => {
+ if (!hasNextPage || isFetchingNextPage || items.length === 0) return;
+ const last = virtualItems[virtualItems.length - 1];
+ if (last && last.index >= items.length - 1) {
+ void fetchNextPage();
+ }
+ }, [
+ virtualItems,
+ hasNextPage,
+ isFetchingNextPage,
+ items.length,
+ fetchNextPage,
+ ]);
+
+ return (
+
+
Вопросы
+ {status === "pending" &&
Загрузка...
}
+ {items.length === 0 && status === "success" && (
+
Нет вопросов.
+ )}
+
+ {virtualItems.map((v) => {
+ const s = items[v.index];
+ return (
+
+ {s && (
+
+ )}
+
+ );
+ })}
+
+ {isFetchingNextPage &&
Загрузка...
}
+
+ );
+}
diff --git a/src/pages/QuestionPage.tsx b/src/pages/QuestionPage.tsx
new file mode 100644
index 0000000..e9ec745
--- /dev/null
+++ b/src/pages/QuestionPage.tsx
@@ -0,0 +1,92 @@
+import { useParams, Link } from "react-router-dom";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useQuestion, useCreateAnswer } from "../entities/question/api";
+import type { Question, Answer } from "../entities/question/types";
+import { useAuth } from "../app/providers/useAuth";
+import { CodeBlock } from "../shared/ui/CodeBlock";
+
+const schema = z.object({
+ content: z.string().min(1, "Ответ не может быть пустым"),
+});
+
+type FormData = z.infer;
+
+export default function QuestionPage() {
+ const { id } = useParams<{ id: string }>();
+ const { user } = useAuth();
+ const { data: question, status } = useQuestion(id);
+ const { mutateAsync: createAnswer, isPending } = useCreateAnswer(id!);
+ const {
+ register,
+ handleSubmit,
+ reset,
+ formState: { errors, isSubmitting },
+ } = useForm({ resolver: zodResolver(schema) });
+
+ const onSubmit = async (data: FormData) => {
+ await createAnswer(data.content);
+ reset({ content: "" });
+ };
+
+ if (status === "pending") return Загрузка...
;
+ if (!question) return Вопрос не найден
;
+
+ return (
+
+
+
+ ← Назад
+
+
+
{(question as Question).title}
+
+ {(question as Question).description}
+
+ {(question as Question).attachedCode && (
+
+ )}
+
+ {user ? (
+
+ ) : (
+
+ Войдите, чтобы оставить ответ.
+
+ )}
+
+ );
+}
diff --git a/src/pages/auth/LoginPage.tsx b/src/pages/auth/LoginPage.tsx
new file mode 100644
index 0000000..ffaa126
--- /dev/null
+++ b/src/pages/auth/LoginPage.tsx
@@ -0,0 +1,90 @@
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useAuth } from "../../app/providers/useAuth";
+import { useNavigate, useLocation, Link } from "react-router-dom";
+
+const schema = z.object({
+ username: z.string().min(5),
+ password: z.string().min(6),
+});
+
+type FormData = z.infer;
+
+type LocationState = { from?: { pathname?: string } };
+
+export default function LoginPage() {
+ const { login } = useAuth();
+ const navigate = useNavigate();
+ const loc = useLocation();
+ const state = (loc.state || {}) as LocationState;
+ const from = state.from?.pathname || "/";
+
+ const {
+ register,
+ handleSubmit,
+ formState: { errors, isSubmitting },
+ setError,
+ } = useForm({
+ resolver: zodResolver(schema),
+ });
+
+ const onSubmit = async (data: FormData) => {
+ try {
+ await login(data.username, data.password);
+ navigate(from, { replace: true });
+ } catch (e) {
+ const message = e instanceof Error ? e.message : "Ошибка входа";
+ setError("root", { message });
+ }
+ };
+
+ return (
+
+
Вход
+
+
+
Username
+
+ {errors.username && (
+
+ {errors.username.message}
+
+ )}
+
+
+
Password
+
+ {errors.password && (
+
+ {errors.password.message}
+
+ )}
+
+ {errors.root?.message && (
+ {errors.root.message}
+ )}
+
+ {isSubmitting ? "Вход..." : "Войти"}
+
+
+
+ Нет аккаунта?{" "}
+
+ Зарегистрируйтесь
+
+
+
+ );
+}
diff --git a/src/pages/auth/RegisterPage.tsx b/src/pages/auth/RegisterPage.tsx
new file mode 100644
index 0000000..2b9af12
--- /dev/null
+++ b/src/pages/auth/RegisterPage.tsx
@@ -0,0 +1,103 @@
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useAuth } from "../../app/providers/useAuth";
+import { Link, useNavigate } from "react-router-dom";
+
+const schema = z
+ .object({
+ username: z.string().min(5),
+ password: z.string().min(6),
+ confirm: z.string().min(6),
+ })
+ .refine((v) => v.password === v.confirm, {
+ message: "Пароли не совпадают",
+ path: ["confirm"],
+ });
+
+type FormData = z.infer;
+
+export default function RegisterPage() {
+ const { register: doRegister } = useAuth();
+ const navigate = useNavigate();
+ const {
+ register,
+ handleSubmit,
+ formState: { errors, isSubmitting },
+ setError,
+ } = useForm({
+ resolver: zodResolver(schema),
+ });
+
+ const onSubmit = async (data: FormData) => {
+ try {
+ await doRegister(data.username, data.password);
+ navigate("/login", { replace: true });
+ } catch (e) {
+ const message = e instanceof Error ? e.message : "Ошибка регистрации";
+ setError("root", { message });
+ }
+ };
+
+ return (
+
+
Регистрация
+
+
+
Username
+
+ {errors.username && (
+
+ {errors.username.message}
+
+ )}
+
+
+
Password
+
+ {errors.password && (
+
+ {errors.password.message}
+
+ )}
+
+
+
Confirm Password
+
+ {errors.confirm && (
+
+ {errors.confirm.message}
+
+ )}
+
+ {errors.root?.message && (
+ {errors.root.message}
+ )}
+
+ {isSubmitting ? "Создание..." : "Создать аккаунт"}
+
+
+
+ Уже есть аккаунт?{" "}
+
+ Войти
+
+
+
+ );
+}
diff --git a/src/shared/api/http.ts b/src/shared/api/http.ts
new file mode 100644
index 0000000..c3208c9
--- /dev/null
+++ b/src/shared/api/http.ts
@@ -0,0 +1,47 @@
+import axios from "axios";
+
+export const http = axios.create({
+ baseURL: "/api",
+ withCredentials: true,
+});
+
+http.interceptors.response.use(
+ (r) => r,
+ (error) => {
+ return Promise.reject(error);
+ }
+);
+
+export type HttpError = {
+ status?: number;
+ message?: string;
+};
+
+export function toHttpError(e: unknown): HttpError {
+ if (typeof e === "object" && e !== null) {
+ const errObj = e as Record;
+ const hasResponse =
+ "response" in errObj &&
+ typeof errObj.response === "object" &&
+ errObj.response !== null;
+ const res = hasResponse ? (errObj.response as Record) : undefined;
+ const status = res && "status" in res ? (res.status as number | undefined) : undefined;
+ const data = res && "data" in res ? (res.data as unknown) : undefined;
+
+ const extractMessage = (val: unknown): string | undefined => {
+ if (typeof val === "string") return val;
+ if (Array.isArray(val)) return (val as unknown[]).map(String).join("\n");
+ if (val && typeof val === "object") {
+ const obj = val as Record;
+ if (typeof obj.message === "string") return obj.message as string;
+ if (Array.isArray(obj.message)) return (obj.message as unknown[]).map(String).join("\n");
+ if (Array.isArray(obj.errors)) return (obj.errors as unknown[]).map(String).join("\n");
+ }
+ return undefined;
+ };
+
+ const message = extractMessage(data) ?? (typeof errObj.message === "string" ? (errObj.message as string) : undefined);
+ return { status, message };
+ }
+ return { message: String(e) };
+}
diff --git a/src/shared/api/normalize.ts b/src/shared/api/normalize.ts
new file mode 100644
index 0000000..b7aad1f
--- /dev/null
+++ b/src/shared/api/normalize.ts
@@ -0,0 +1,63 @@
+// Универсальные помощники для нормализации ответов API
+
+export function isObj(v: unknown): v is Record {
+ return typeof v === "object" && v !== null;
+}
+
+export function getKey(obj: unknown, key: string): T | undefined {
+ return isObj(obj) && key in obj
+ ? ((obj as Record)[key] as T)
+ : undefined;
+}
+
+export function unwrapData(raw: unknown): T {
+ if (isObj(raw) && "data" in raw) {
+ return (raw as Record)["data"] as T;
+ }
+ return raw as T;
+}
+
+export type PaginatedMeta = {
+ itemsPerPage: number;
+ totalItems: number;
+ currentPage: number;
+ totalPages: number;
+};
+
+export type Paginated = {
+ data: T[];
+ meta: PaginatedMeta;
+};
+
+export function normalizePaginated(
+ raw: unknown,
+ opts: {
+ fallbackLimit: number;
+ pageParam: number;
+ }
+): Paginated {
+ const rawData = getKey(raw, "data");
+ const payload = Array.isArray(rawData)
+ ? (raw as Record)
+ : isObj(rawData)
+ ? (rawData as Record)
+ : isObj(raw)
+ ? (raw as Record)
+ : ({ data: [] } as Record);
+
+ const dataUnknown = getKey(payload, "data");
+ const data = (Array.isArray(dataUnknown) ? (dataUnknown as T[]) : []) as T[];
+
+ const metaUnknown = getKey(payload, "meta");
+ const metaRaw = isObj(metaUnknown)
+ ? (metaUnknown as Record)
+ : {};
+ const meta: PaginatedMeta = {
+ itemsPerPage: Number(metaRaw.itemsPerPage ?? opts.fallbackLimit),
+ totalItems: Number(metaRaw.totalItems ?? data.length),
+ currentPage: Number(metaRaw.currentPage ?? opts.pageParam ?? 1),
+ totalPages: Number(metaRaw.totalPages ?? 1),
+ };
+
+ return { data, meta };
+}
diff --git a/src/shared/api/types/codelang.d.ts b/src/shared/api/types/codelang.d.ts
new file mode 100644
index 0000000..cebfb3a
--- /dev/null
+++ b/src/shared/api/types/codelang.d.ts
@@ -0,0 +1,1508 @@
+/**
+ * This file was auto-generated by openapi-typescript.
+ * Do not make direct changes to the file.
+ */
+
+export interface paths {
+ "/": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get: operations["AppController_healthCheck"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/users": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get: operations["UsersController_findAllUsers"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/users/{id}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get: operations["UsersController_findOneUser"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/users/{id}/statistic": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get: operations["UsersController_getUserStatistic"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/me": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get: operations["MeController_getProfile"];
+ put?: never;
+ post?: never;
+ delete: operations["MeController_deleteProfile"];
+ options?: never;
+ head?: never;
+ patch: operations["MeController_updateProfile"];
+ trace?: never;
+ };
+ "/api/me/password": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch: operations["MeController_changePassword"];
+ trace?: never;
+ };
+ "/api/snippets": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get: operations["SnippetsController_findAllSnippets"];
+ put?: never;
+ post: operations["SnippetsController_createSnippet"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/snippets/languages": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get: operations["SnippetsController_getLanguages"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/snippets/{id}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get: operations["SnippetsController_findOneSnippet"];
+ put?: never;
+ post?: never;
+ delete: operations["SnippetsController_delete"];
+ options?: never;
+ head?: never;
+ patch: operations["SnippetsController_updateSnippet"];
+ trace?: never;
+ };
+ "/api/snippets/{id}/mark": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post: operations["SnippetsController_markSnippet"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/comments": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post: operations["CommentsController_createComment"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/comments/{id}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post?: never;
+ delete: operations["CommentsController_deleteComment"];
+ options?: never;
+ head?: never;
+ patch: operations["CommentsController_updateComment"];
+ trace?: never;
+ };
+ "/api/auth": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get: operations["AuthController_getAuth"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/auth/login": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post: operations["AuthController_login"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/auth/logout": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post: operations["AuthController_logout"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/register": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post: operations["RegisterController_registerUser"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/questions": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get: operations["QuestionsController_getQuestions"];
+ put?: never;
+ post: operations["QuestionsController_createQuestion"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/questions/{id}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get: operations["QuestionsController_getQuestion"];
+ put?: never;
+ post?: never;
+ delete: operations["QuestionsController_deleteQuestion"];
+ options?: never;
+ head?: never;
+ patch: operations["QuestionsController_updateQuestion"];
+ trace?: never;
+ };
+ "/api/answers": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get: operations["AnswersController_getAnswers"];
+ put?: never;
+ post: operations["AnswersController_createAnswer"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/answers/{id}/state/{state}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put: operations["AnswersController_updateState"];
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/answers/{id}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post?: never;
+ delete: operations["AnswersController_deleteAnswer"];
+ options?: never;
+ head?: never;
+ patch: operations["AnswersController_updateAnswer"];
+ trace?: never;
+ };
+}
+export type webhooks = Record;
+export interface components {
+ schemas: {
+ PaginatedMetaDocumented: {
+ /** Number of items per page */
+ itemsPerPage: number;
+ /** Total number of items */
+ totalItems: number;
+ /** Current requested page */
+ currentPage: number;
+ /** Total number of pages */
+ totalPages: number;
+ /** Sorting by columns */
+ sortBy?: (string | ("ASC" | "DESC"))[][];
+ /** Search by fields */
+ searchBy?: string[];
+ /** Search term */
+ search?: string;
+ /** List of selected fields */
+ select?: string[];
+ /** Filters that applied to the query */
+ filter?: Record;
+ };
+ PaginatedLinksDocumented: {
+ /** Link to first page */
+ first?: string;
+ /** Link to previous page */
+ previous?: string;
+ /** Link to current page */
+ current?: string;
+ /** Link to next page */
+ next?: string;
+ /** Link to last page */
+ last?: string;
+ };
+ PaginatedDocumented: {
+ /** Array of entities */
+ data: Record[];
+ /** Pagination Metadata */
+ meta: components["schemas"]["PaginatedMetaDocumented"];
+ /** Links to pages */
+ links: components["schemas"]["PaginatedLinksDocumented"];
+ };
+ /**
+ * @description User's role
+ * @enum {string}
+ */
+ UserRoles: "user" | "admin";
+ UserDto: {
+ /** @description User's identifier */
+ id: number;
+ /** @description User's nickname */
+ username: string;
+ role: components["schemas"]["UserRoles"];
+ };
+ StatisticDto: {
+ /**
+ * @description Number of snippets created by user
+ * @example 5
+ */
+ snippetsCount: number;
+ /**
+ * @description User activity rating
+ * @example 150
+ */
+ rating: number;
+ /**
+ * @description Number of comments made by user
+ * @example 10
+ */
+ commentsCount: number;
+ /**
+ * @description Number of likes given by user
+ * @example 20
+ */
+ likesCount: number;
+ /**
+ * @description Number of dislikes given by user
+ * @example 5
+ */
+ dislikesCount: number;
+ /**
+ * @description Number of questions asked by user
+ * @example 8
+ */
+ questionsCount: number;
+ /**
+ * @description Number of correct answers given by user
+ * @example 12
+ */
+ correctAnswersCount: number;
+ /**
+ * @description Number of regular answers given by user
+ * @example 15
+ */
+ regularAnswersCount: number;
+ };
+ UserStatisticDto: {
+ /** @description User's identifier */
+ id: number;
+ /** @description User's nickname */
+ username: string;
+ role: components["schemas"]["UserRoles"];
+ /** @description User activity statistics */
+ statistic: components["schemas"]["StatisticDto"];
+ };
+ UpdateUserDto: {
+ /** @description New user nickname */
+ username: string;
+ };
+ UpdateResponseDto: {
+ /** @description Amount of updated items */
+ updatedCount: number;
+ };
+ ChangePasswordDto: {
+ /** @description Old user's password */
+ oldPassword: string;
+ /** @description New password */
+ newPassword: string;
+ };
+ SnippetDto: {
+ /** @description Snippet's identifier */
+ id: number;
+ /** @description Snippet's programming language */
+ language: string;
+ /** @description Snippet's content */
+ code: string;
+ /** @description Owner of the snippet */
+ user: components["schemas"]["UserDto"];
+ };
+ /**
+ * @description Snippet's language (programming language)
+ * @enum {string}
+ */
+ Languages: "JavaScript" | "Python" | "Java" | "C/C++" | "C#" | "Go" | "Kotlin" | "Ruby";
+ CreateSnippetDto: {
+ code: components["schemas"]["Languages"];
+ /** @description Snippet's language (programming language) */
+ language: string;
+ };
+ UpdateSnippetDto: {
+ /** @description New snippet content */
+ code?: string;
+ /** @description New snippet language */
+ language?: string;
+ };
+ /**
+ * @description User mark for the snippet. like, dislike or none (remove any existing mark: like or dislike)
+ * @enum {string}
+ */
+ SnippetMark: "like" | "dislike" | "none";
+ MarkSnippetDto: {
+ mark: components["schemas"]["SnippetMark"];
+ };
+ CreateCommentDto: {
+ /** @description Comment's content */
+ content: string;
+ /** @description Id of the commenting snippet */
+ snippetId: number;
+ };
+ CommentDto: {
+ /** @description Comment's identifier */
+ id: number;
+ /** @description Comment's content */
+ content: string;
+ /** @description Owner of the comment */
+ user: components["schemas"]["UserDto"];
+ };
+ UpdateCommentDto: {
+ /** @description New comment content */
+ content: string;
+ };
+ CredentialsDto: {
+ /** @description User's nickname */
+ username: string;
+ /** @description User's password */
+ password: string;
+ };
+ QuestionWithStatusDto: {
+ /** @description Unique identifier of the question */
+ id: number;
+ /** @description Title of the question */
+ title: string;
+ /** @description Detailed description of the question */
+ description: string;
+ /** @description Code snippet attached to the question */
+ attachedCode?: string;
+ /** @description User who created the question */
+ user: Record;
+ /** @description List of answers to this question */
+ answers: unknown[][];
+ /** @description Indicates if question is resolved */
+ isResolved: boolean;
+ };
+ CreateQuestionDto: {
+ /** @description Question title */
+ title: string;
+ /** @description Question description */
+ description: string;
+ /** @description Question attached code */
+ attachedCode: string;
+ };
+ UpdateQuestionDto: {
+ /** @description Question description */
+ title: string;
+ /** @description Question description */
+ description: string;
+ /** @description Question attached code */
+ attachedCode: string;
+ };
+ QuestionDto: {
+ /** @description Unique identifier of the question */
+ id: number;
+ /** @description Title of the question */
+ title: string;
+ /** @description Detailed description of the question */
+ description: string;
+ /** @description Code snippet attached to the question */
+ attachedCode?: string;
+ /** @description User who created the question */
+ user: Record;
+ /** @description List of answers to this question */
+ answers: unknown[][];
+ };
+ CreateAnswerDto: {
+ /** @description Answer content */
+ content: string;
+ /** @description Id of the commenting question */
+ questionId: number;
+ };
+ /** @enum {string} */
+ AnswerState: "correct" | "incorrect";
+ UpdateAnswerDto: {
+ /** @description Answer content */
+ content: string;
+ };
+ AnswerDto: {
+ /**
+ * @description The unique identifier of the answer
+ * @example 1
+ */
+ id: number;
+ /**
+ * @description The content of the answer
+ * @example Paris is the capital of France
+ */
+ content: string;
+ /**
+ * @description Indicates whether this answer is correct
+ * @example true
+ */
+ isCorrect: boolean;
+ };
+ };
+ responses: never;
+ parameters: never;
+ requestBodies: never;
+ headers: never;
+ pathItems: never;
+}
+export type $defs = Record;
+export interface operations {
+ AppController_healthCheck: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ };
+ };
+ UsersController_findAllUsers: {
+ parameters: {
+ query?: {
+ /** @description Page number to retrieve.If you provide invalid value the default page number will applied
+ *
+ * Example: 1
+ *
+ *
+ * Default Value: 1
+ *
+ * */
+ page?: number;
+ /** @description Number of records per page.
+ *
+ * Example: 20
+ *
+ *
+ * Default Value: 15
+ *
+ *
+ * Max Value: 100
+ *
+ *
+ * If provided value is greater than max value, max value will be applied.
+ * */
+ limit?: number;
+ /** @description Parameter to sort by.
+ * To sort by multiple fields, just provide query param multiple types. The order in url defines an order of sorting
+ *
+ * Format: fieldName:DIRECTION
+ *
+ *
+ * Example: sortBy=id:DESC&sortBy=createdAt:ASC
+ *
+ *
+ * Default Value: username:ASC
+ *
+ * Available Fields
+ * */
+ sortBy?: ("id:ASC" | "id:DESC" | "username:ASC" | "username:DESC" | "role:ASC" | "role:DESC")[];
+ /** @description Search term to filter result values
+ *
+ * Example: John
+ *
+ *
+ * Default Value: No default value
+ *
+ * */
+ search?: string;
+ /** @description List of fields to search by term to filter result values
+ *
+ * Example: username
+ *
+ *
+ * Default Value: By default all fields mentioned below will be used to search by term
+ *
+ * Available Fields
+ * */
+ searchBy?: string[];
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["PaginatedDocumented"] & {
+ data?: components["schemas"]["UserDto"][];
+ meta?: {
+ select?: string[];
+ filter?: Record;
+ };
+ };
+ };
+ };
+ };
+ };
+ UsersController_findOneUser: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: number;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Contains found user */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["UserDto"];
+ };
+ };
+ };
+ };
+ UsersController_getUserStatistic: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: number;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Contains found user with statistic information */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["UserStatisticDto"];
+ };
+ };
+ };
+ };
+ MeController_getProfile: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Contains found user */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["UserDto"];
+ };
+ };
+ };
+ };
+ MeController_deleteProfile: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Contains deleted user */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["UserDto"];
+ };
+ };
+ };
+ };
+ MeController_updateProfile: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["UpdateUserDto"];
+ };
+ };
+ responses: {
+ /** @description Contains amount of updated users */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["UpdateResponseDto"];
+ };
+ };
+ };
+ };
+ MeController_changePassword: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["ChangePasswordDto"];
+ };
+ };
+ responses: {
+ /** @description Contains amount of updated users */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["UpdateResponseDto"];
+ };
+ };
+ };
+ };
+ SnippetsController_findAllSnippets: {
+ parameters: {
+ query?: {
+ /** @description Id of the snippet owner */
+ userId?: number;
+ /** @description Page number to retrieve.If you provide invalid value the default page number will applied
+ *
+ * Example: 1
+ *
+ *
+ * Default Value: 1
+ *
+ * */
+ page?: number;
+ /** @description Number of records per page.
+ *
+ * Example: 20
+ *
+ *
+ * Default Value: 15
+ *
+ *
+ * Max Value: 100
+ *
+ *
+ * If provided value is greater than max value, max value will be applied.
+ * */
+ limit?: number;
+ /** @description Parameter to sort by.
+ * To sort by multiple fields, just provide query param multiple types. The order in url defines an order of sorting
+ *
+ * Format: fieldName:DIRECTION
+ *
+ *
+ * Example: sortBy=id:DESC&sortBy=createdAt:ASC
+ *
+ *
+ * Default Value: id:ASC
+ *
+ * Available Fields
+ * */
+ sortBy?: ("id:ASC" | "id:DESC" | "code:ASC" | "code:DESC" | "language:ASC" | "language:DESC")[];
+ /** @description Search term to filter result values
+ *
+ * Example: John
+ *
+ *
+ * Default Value: No default value
+ *
+ * */
+ search?: string;
+ /** @description List of fields to search by term to filter result values
+ *
+ * Example: code,language
+ *
+ *
+ * Default Value: By default all fields mentioned below will be used to search by term
+ *
+ * Available Fields
+ * */
+ searchBy?: string[];
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["PaginatedDocumented"] & {
+ data?: components["schemas"]["SnippetDto"][];
+ meta?: {
+ select?: string[];
+ filter?: Record;
+ };
+ };
+ };
+ };
+ };
+ };
+ SnippetsController_createSnippet: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["CreateSnippetDto"];
+ };
+ };
+ responses: {
+ /** @description Contains created snippet */
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["SnippetDto"];
+ };
+ };
+ };
+ };
+ SnippetsController_getLanguages: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Contains supported languages */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": string[];
+ };
+ };
+ };
+ };
+ SnippetsController_findOneSnippet: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: number;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Contains found snippet */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["SnippetDto"];
+ };
+ };
+ };
+ };
+ SnippetsController_delete: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: number;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Contains deleted snippet */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["SnippetDto"];
+ };
+ };
+ };
+ };
+ SnippetsController_updateSnippet: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: number;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["UpdateSnippetDto"];
+ };
+ };
+ responses: {
+ /** @description Contains count of updated snippets */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["UpdateResponseDto"];
+ };
+ };
+ };
+ };
+ SnippetsController_markSnippet: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: number;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["MarkSnippetDto"];
+ };
+ };
+ responses: {
+ /** @description Contains new user mark for the snippet */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["MarkSnippetDto"];
+ };
+ };
+ };
+ };
+ CommentsController_createComment: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["CreateCommentDto"];
+ };
+ };
+ responses: {
+ /** @description Contains created comment */
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["CommentDto"];
+ };
+ };
+ };
+ };
+ CommentsController_deleteComment: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: number;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Contains deleted comment */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["CommentDto"];
+ };
+ };
+ };
+ };
+ CommentsController_updateComment: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: number;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["UpdateCommentDto"];
+ };
+ };
+ responses: {
+ /** @description Contains amount of updated comments */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["UpdateResponseDto"];
+ };
+ };
+ };
+ };
+ AuthController_getAuth: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Contains authenticated user */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["UserDto"];
+ };
+ };
+ };
+ };
+ AuthController_login: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** @description User credentials */
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["CredentialsDto"];
+ };
+ };
+ responses: {
+ /** @description Contains authenticated user */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["UserDto"];
+ };
+ };
+ };
+ };
+ AuthController_logout: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ };
+ };
+ RegisterController_registerUser: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["CredentialsDto"];
+ };
+ };
+ responses: {
+ /** @description Contains information about registred user */
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["UserDto"];
+ };
+ };
+ };
+ };
+ QuestionsController_getQuestions: {
+ parameters: {
+ query?: {
+ /** @description Page number to retrieve.If you provide invalid value the default page number will applied
+ *
+ * Example: 1
+ *
+ *
+ * Default Value: 1
+ *
+ * */
+ page?: number;
+ /** @description Number of records per page.
+ *
+ * Example: 20
+ *
+ *
+ * Default Value: 15
+ *
+ *
+ * Max Value: 100
+ *
+ *
+ * If provided value is greater than max value, max value will be applied.
+ * */
+ limit?: number;
+ /** @description Parameter to sort by.
+ * To sort by multiple fields, just provide query param multiple types. The order in url defines an order of sorting
+ *
+ * Format: fieldName:DIRECTION
+ *
+ *
+ * Example: sortBy=id:DESC&sortBy=createdAt:ASC
+ *
+ *
+ * Default Value: title:DESC
+ *
+ * Available Fields id
+ * title
+ * description
+ * attachedCode
+ * */
+ sortBy?: ("id:ASC" | "id:DESC" | "title:ASC" | "title:DESC" | "description:ASC" | "description:DESC" | "attachedCode:ASC" | "attachedCode:DESC")[];
+ /** @description Search term to filter result values
+ *
+ * Example: John
+ *
+ *
+ * Default Value: No default value
+ *
+ * */
+ search?: string;
+ /** @description List of fields to search by term to filter result values
+ *
+ * Example: title,description,attachedCode
+ *
+ *
+ * Default Value: By default all fields mentioned below will be used to search by term
+ *
+ * Available Fields title
+ * description
+ * attachedCode
+ * */
+ searchBy?: string[];
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["PaginatedDocumented"] & {
+ data?: components["schemas"]["QuestionWithStatusDto"][];
+ meta?: {
+ select?: string[];
+ filter?: Record;
+ };
+ };
+ };
+ };
+ };
+ };
+ QuestionsController_createQuestion: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["CreateQuestionDto"];
+ };
+ };
+ responses: {
+ /** @description Returns created question */
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["CreateQuestionDto"];
+ };
+ };
+ };
+ };
+ QuestionsController_getQuestion: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: number;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Returns single question by id */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["QuestionWithStatusDto"];
+ };
+ };
+ };
+ };
+ QuestionsController_deleteQuestion: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: number;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Deletes question */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["QuestionDto"];
+ };
+ };
+ };
+ };
+ QuestionsController_updateQuestion: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["UpdateQuestionDto"];
+ };
+ };
+ responses: {
+ /** @description Updates question */
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["QuestionDto"];
+ };
+ };
+ };
+ };
+ AnswersController_getAnswers: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Returns all answers */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ };
+ };
+ AnswersController_createAnswer: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["CreateAnswerDto"];
+ };
+ };
+ responses: {
+ /** @description Returns created answer */
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ };
+ };
+ AnswersController_updateState: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ state: components["schemas"]["AnswerState"];
+ id: number;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Updates correct/incorrect state of answer */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ };
+ };
+ AnswersController_deleteAnswer: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: number;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Deletes answer */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["AnswerDto"];
+ };
+ };
+ };
+ };
+ AnswersController_updateAnswer: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["UpdateAnswerDto"];
+ };
+ };
+ responses: {
+ /** @description Updates answer content */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ };
+ };
+}
diff --git a/src/shared/ui/CodeBlock.tsx b/src/shared/ui/CodeBlock.tsx
new file mode 100644
index 0000000..49581db
--- /dev/null
+++ b/src/shared/ui/CodeBlock.tsx
@@ -0,0 +1,52 @@
+import { Highlight, themes } from "prism-react-renderer";
+import type {
+ Language,
+ RenderProps,
+ Token as PrismToken,
+} from "prism-react-renderer";
+import { useTheme } from "../../app/providers/useTheme";
+
+export type CodeBlockProps = {
+ code: string;
+ language?: Language;
+ className?: string;
+};
+
+export function CodeBlock({
+ code,
+ language = "tsx",
+ className,
+}: CodeBlockProps) {
+ const { theme } = useTheme();
+ const prismTheme =
+ theme === "dark" ? themes.gruvboxMaterialDark : themes.gruvboxMaterialLight;
+
+ return (
+
+
+ {({
+ className: cls,
+ style,
+ tokens,
+ getLineProps,
+ getTokenProps,
+ }: RenderProps) => (
+
+ {tokens.map((line: PrismToken[], i: number) => (
+
+ {line.map((token: PrismToken, key: number) => (
+
+ ))}
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/tailwind.config.js b/tailwind.config.js
new file mode 100644
index 0000000..8a6d6b4
--- /dev/null
+++ b/tailwind.config.js
@@ -0,0 +1,13 @@
+/***** Tailwind v4 compat config for Vite *****/
+/**
+ * If you prefer Tailwind v4 with @import "tailwindcss" only, you can skip this file.
+ * Keeping it for editor tooling and potential v3-style content scanning.
+ */
+export default {
+ darkMode: "class",
+ content: ["./index.html", "./src/**/*.{ts,tsx}"],
+ theme: {
+ extend: {},
+ },
+ plugins: [],
+};
diff --git a/tsconfig.app.json b/tsconfig.app.json
new file mode 100644
index 0000000..227a6c6
--- /dev/null
+++ b/tsconfig.app.json
@@ -0,0 +1,27 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["src"]
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..1ffef60
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
diff --git a/tsconfig.node.json b/tsconfig.node.json
new file mode 100644
index 0000000..f85a399
--- /dev/null
+++ b/tsconfig.node.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "ES2023",
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..6d3da19
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,18 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react-swc";
+
+// https://vite.dev/config/
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ proxy: {
+ "/api": {
+ target: "https://codelang.vercel.app",
+ changeOrigin: true,
+ secure: true,
+ cookieDomainRewrite: "localhost",
+ cookiePathRewrite: "/",
+ },
+ },
+ },
+});
From 2f976f442b213ff633047ea0849f187e28d5ae9c Mon Sep 17 00:00:00 2001
From: kotru21
Date: Wed, 13 Aug 2025 19:05:05 +0300
Subject: [PATCH 03/40] theme fix
---
src/index.css | 2 ++
1 file changed, 2 insertions(+)
diff --git a/src/index.css b/src/index.css
index f1d8c73..7273c76 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1 +1,3 @@
@import "tailwindcss";
+
+@custom-variant dark (:where(.dark, [data-theme="dark"]) &);
From 62e15604af4479411e095095c4b8c51333ea037b Mon Sep 17 00:00:00 2001
From: kotru21
Date: Wed, 13 Aug 2025 19:22:19 +0300
Subject: [PATCH 04/40] Add ExpandableText component and integrate in pages
Introduces a reusable ExpandableText component for handling long text with expand/collapse or navigation options. Integrates ExpandableText into HomePage and QuestionPage to improve display of question descriptions and answers. Also fixes a minor style property in CodeBlock.
---
src/pages/HomePage.tsx | 12 +++-
src/pages/QuestionPage.tsx | 12 +++-
src/shared/ui/CodeBlock.tsx | 2 +-
src/shared/ui/ExpandableText.tsx | 120 +++++++++++++++++++++++++++++++
4 files changed, 140 insertions(+), 6 deletions(-)
create mode 100644 src/shared/ui/ExpandableText.tsx
diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx
index cf92aaa..9e1411b 100644
--- a/src/pages/HomePage.tsx
+++ b/src/pages/HomePage.tsx
@@ -4,6 +4,8 @@ import { useWindowVirtualizer } from "@tanstack/react-virtual";
import { useQuestions } from "../entities/question/api";
import type { Question } from "../entities/question/types";
import { CodeBlock } from "../shared/ui/CodeBlock";
+import { ExpandableText } from "../shared/ui/ExpandableText";
+import { useNavigate } from "react-router-dom";
type QuestionCardProps = {
id: string | number;
@@ -22,6 +24,7 @@ function QuestionCard({
user,
answersCount,
}: QuestionCardProps) {
+ const navigate = useNavigate();
return (
@@ -30,9 +33,12 @@ function QuestionCard({
@{user.username}
-
- {description}
-
+ navigate(`/questions/${id}`)}
+ className="text-sm"
+ />
{attachedCode && }
{typeof answersCount !== "undefined" && (
diff --git a/src/pages/QuestionPage.tsx b/src/pages/QuestionPage.tsx
index e9ec745..43f08a3 100644
--- a/src/pages/QuestionPage.tsx
+++ b/src/pages/QuestionPage.tsx
@@ -6,6 +6,7 @@ import { useQuestion, useCreateAnswer } from "../entities/question/api";
import type { Question, Answer } from "../entities/question/types";
import { useAuth } from "../app/providers/useAuth";
import { CodeBlock } from "../shared/ui/CodeBlock";
+import { ExpandableText } from "../shared/ui/ExpandableText";
const schema = z.object({
content: z.string().min(1, "Ответ не может быть пустым"),
@@ -55,8 +56,15 @@ export default function QuestionPage() {
-
-
{a.content}
+
+
{a.isCorrect && (
correct
)}
diff --git a/src/shared/ui/CodeBlock.tsx b/src/shared/ui/CodeBlock.tsx
index 49581db..465a926 100644
--- a/src/shared/ui/CodeBlock.tsx
+++ b/src/shared/ui/CodeBlock.tsx
@@ -36,7 +36,7 @@ export function CodeBlock({
}: RenderProps) => (
+ style={{ ...style, backgroundColor: "transparent" }}>
{tokens.map((line: PrismToken[], i: number) => (
{line.map((token: PrismToken, key: number) => (
diff --git a/src/shared/ui/ExpandableText.tsx b/src/shared/ui/ExpandableText.tsx
new file mode 100644
index 0000000..4803eed
--- /dev/null
+++ b/src/shared/ui/ExpandableText.tsx
@@ -0,0 +1,120 @@
+import { useEffect, useMemo, useRef, useState } from "react";
+
+type ExpandableTextProps = {
+ text: string;
+ className?: string;
+ maxHeight?: number; // px, when collapsed
+ gradientHeight?: number; // px height of fade overlay
+ mode: "navigate" | "toggle"; // navigate: show more goes to a page; toggle: expands/collapses inline
+ onMoreClick?: () => void; // used when mode="navigate"
+ moreLabel?: string; // default: "Показать весь"
+ lessLabel?: string; // default: "Показать меньше"
+};
+
+export function ExpandableText({
+ text,
+ className,
+ maxHeight = 160,
+ gradientHeight = 56,
+ mode,
+ onMoreClick,
+ moreLabel = "Показать весь",
+ lessLabel = "Показать меньше",
+}: ExpandableTextProps) {
+ const contentRef = useRef
(null);
+ const [isOverflowing, setIsOverflowing] = useState(false);
+ const [expanded, setExpanded] = useState(false);
+
+ // Measure overflow on mount/resize/content changes
+ useEffect(() => {
+ const el = contentRef.current;
+ if (!el) return;
+
+ const check = () => {
+ // Temporarily remove max-height to measure full scrollHeight correctly
+ const prevMaxHeight = el.style.maxHeight;
+ el.style.maxHeight = "none";
+ const overflowing = el.scrollHeight > maxHeight + 1; // buffer
+ el.style.maxHeight = prevMaxHeight;
+ setIsOverflowing(overflowing);
+ };
+
+ check();
+
+ const ro = new ResizeObserver(() => check());
+ ro.observe(el);
+ window.addEventListener("resize", check);
+ return () => {
+ ro.disconnect();
+ window.removeEventListener("resize", check);
+ };
+ }, [maxHeight, text]);
+
+ const showGradient = isOverflowing && !expanded;
+ const containerStyles = useMemo(() => {
+ return expanded ? {} : { maxHeight: `${maxHeight}px` };
+ }, [expanded, maxHeight]);
+
+ const handleMore = () => {
+ if (mode === "navigate") {
+ onMoreClick?.();
+ return;
+ }
+ setExpanded(true);
+ };
+
+ const handleLess = () => setExpanded(false);
+
+ return (
+
+
+
+ {text}
+
+
+ {showGradient && (
+
+ )}
+
+
+ {isOverflowing && (
+
+ {mode === "toggle" ? (
+ expanded ? (
+
+ {lessLabel}
+
+ ) : (
+
+ {moreLabel}
+
+ )
+ ) : (
+
+ {moreLabel}
+
+ )}
+
+ )}
+
+ );
+}
From 371858d4538c2e4be8b4e549215eb082b957cd30 Mon Sep 17 00:00:00 2001
From: kotru21
Date: Wed, 13 Aug 2025 19:25:08 +0300
Subject: [PATCH 05/40] Optimize UI components with React memoization
Refactored HomePage, QuestionPage, CodeBlock, and ExpandableText components to use React.memo for improved rendering performance. Added useCallback and useMemo where appropriate to prevent unnecessary re-renders and optimize component behavior.
---
src/pages/HomePage.tsx | 6 +++---
src/pages/QuestionPage.tsx | 37 +++++++++++++++++---------------
src/shared/ui/CodeBlock.tsx | 14 ++++++++----
src/shared/ui/ExpandableText.tsx | 12 +++++------
4 files changed, 39 insertions(+), 30 deletions(-)
diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx
index 9e1411b..8a2fb95 100644
--- a/src/pages/HomePage.tsx
+++ b/src/pages/HomePage.tsx
@@ -1,4 +1,4 @@
-import { useEffect } from "react";
+import { memo, useEffect } from "react";
import { Link } from "react-router-dom";
import { useWindowVirtualizer } from "@tanstack/react-virtual";
import { useQuestions } from "../entities/question/api";
@@ -16,7 +16,7 @@ type QuestionCardProps = {
answersCount?: number;
};
-function QuestionCard({
+const QuestionCard = memo(function QuestionCard({
id,
title,
description,
@@ -54,7 +54,7 @@ function QuestionCard({
);
-}
+});
export default function HomePage() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, status } =
diff --git a/src/pages/QuestionPage.tsx b/src/pages/QuestionPage.tsx
index 43f08a3..358a5ec 100644
--- a/src/pages/QuestionPage.tsx
+++ b/src/pages/QuestionPage.tsx
@@ -1,4 +1,5 @@
import { useParams, Link } from "react-router-dom";
+import { memo } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -53,23 +54,7 @@ export default function QuestionPage() {
@@ -98,3 +83,21 @@ export default function QuestionPage() {
);
}
+
+const AnswerItem = memo(function AnswerItem({ a }: { a: Answer }) {
+ return (
+
+
+
+ {a.isCorrect && correct }
+
+
+ );
+});
diff --git a/src/shared/ui/CodeBlock.tsx b/src/shared/ui/CodeBlock.tsx
index 465a926..032f153 100644
--- a/src/shared/ui/CodeBlock.tsx
+++ b/src/shared/ui/CodeBlock.tsx
@@ -4,6 +4,7 @@ import type {
RenderProps,
Token as PrismToken,
} from "prism-react-renderer";
+import { memo, useMemo } from "react";
import { useTheme } from "../../app/providers/useTheme";
export type CodeBlockProps = {
@@ -12,14 +13,19 @@ export type CodeBlockProps = {
className?: string;
};
-export function CodeBlock({
+export const CodeBlock = memo(function CodeBlock({
code,
language = "tsx",
className,
}: CodeBlockProps) {
const { theme } = useTheme();
- const prismTheme =
- theme === "dark" ? themes.gruvboxMaterialDark : themes.gruvboxMaterialLight;
+ const prismTheme = useMemo(
+ () =>
+ theme === "dark"
+ ? themes.gruvboxMaterialDark
+ : themes.gruvboxMaterialLight,
+ [theme]
+ );
return (
);
-}
+});
diff --git a/src/shared/ui/ExpandableText.tsx b/src/shared/ui/ExpandableText.tsx
index 4803eed..82d82a9 100644
--- a/src/shared/ui/ExpandableText.tsx
+++ b/src/shared/ui/ExpandableText.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useRef, useState } from "react";
+import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
type ExpandableTextProps = {
text: string;
@@ -11,7 +11,7 @@ type ExpandableTextProps = {
lessLabel?: string; // default: "Показать меньше"
};
-export function ExpandableText({
+export const ExpandableText = memo(function ExpandableText({
text,
className,
maxHeight = 160,
@@ -55,15 +55,15 @@ export function ExpandableText({
return expanded ? {} : { maxHeight: `${maxHeight}px` };
}, [expanded, maxHeight]);
- const handleMore = () => {
+ const handleMore = useCallback(() => {
if (mode === "navigate") {
onMoreClick?.();
return;
}
setExpanded(true);
- };
+ }, [mode, onMoreClick]);
- const handleLess = () => setExpanded(false);
+ const handleLess = useCallback(() => setExpanded(false), []);
return (
@@ -117,4 +117,4 @@ export function ExpandableText({
)}
);
-}
+});
From f0dba6093185e75f0d3462b51e5615f1194cc68a Mon Sep 17 00:00:00 2001
From: kotru21
Date: Wed, 13 Aug 2025 20:18:42 +0300
Subject: [PATCH 06/40] Improve registration validation and error handling
Enhanced password validation on the registration page to require at least one lowercase, one uppercase letter, one digit, and one symbol. Improved error handling in auth provider and registration page to provide more user-friendly messages for common API errors. Updated axios base URL logic to support Vite proxy and better CORS handling. Added vite-plugin-mkcert for local HTTPS development. Added empty Clamp.tsx and tailwind.config.ts files.
---
docs.json | 1 +
src/app/providers/auth.tsx | 8 ++-
src/pages/auth/RegisterPage.tsx | 47 +++++++++++++--
src/shared/api/http.ts | 100 +++++++++++++++++++++++++-------
src/shared/ui/Clamp.tsx | 0
tailwind.config.ts | 0
vite.config.ts | 4 +-
7 files changed, 130 insertions(+), 30 deletions(-)
create mode 100644 docs.json
create mode 100644 src/shared/ui/Clamp.tsx
create mode 100644 tailwind.config.ts
diff --git a/docs.json b/docs.json
new file mode 100644
index 0000000..b6ea7fb
--- /dev/null
+++ b/docs.json
@@ -0,0 +1 @@
+{"openapi":"3.0.0","paths":{"/":{"get":{"operationId":"AppController_healthCheck","parameters":[],"responses":{"200":{"description":""}}}},"/api/users":{"get":{"operationId":"UsersController_findAllUsers","parameters":[{"name":"page","required":false,"in":"query","description":"Page number to retrieve.If you provide invalid value the default page number will applied\n \n Example: 1\n
\n \n Default Value: 1\n
\n ","schema":{"type":"number"}},{"name":"limit","required":false,"in":"query","description":"Number of records per page.\n \n Example: 20\n
\n \n Default Value: 15\n
\n \n Max Value: 100\n
\n\n If provided value is greater than max value, max value will be applied.\n ","schema":{"type":"number"}},{"name":"sortBy","required":false,"in":"query","description":"Parameter to sort by.\n To sort by multiple fields, just provide query param multiple types. The order in url defines an order of sorting
\n \n Format: fieldName:DIRECTION\n
\n \n Example: sortBy=id:DESC&sortBy=createdAt:ASC\n
\n \n Default Value: username:ASC\n
\n Available Fields \n ","schema":{"type":"array","items":{"type":"string","enum":["id:ASC","id:DESC","username:ASC","username:DESC","role:ASC","role:DESC"]}}},{"name":"search","required":false,"in":"query","description":"Search term to filter result values\n \n Example: John\n
\n \n Default Value: No default value\n
\n ","schema":{"type":"string"}},{"name":"searchBy","required":false,"in":"query","description":"List of fields to search by term to filter result values\n \n Example: username\n
\n \n Default Value: By default all fields mentioned below will be used to search by term\n
\n Available Fields \n ","schema":{"type":"array","items":{"type":"string"}}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/PaginatedDocumented"},{"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/UserDto"}},"meta":{"properties":{"select":{"type":"array","items":{"type":"string"}},"filter":{"type":"object","properties":{}}}}}}]}}}}},"tags":["users"]}},"/api/users/{id}":{"get":{"operationId":"UsersController_findOneUser","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"number"}}],"responses":{"200":{"description":"Contains found user","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserDto"}}}}},"tags":["users"]}},"/api/users/{id}/statistic":{"get":{"operationId":"UsersController_getUserStatistic","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"number"}}],"responses":{"200":{"description":"Contains found user with statistic information","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserStatisticDto"}}}}},"tags":["users"]}},"/api/me":{"get":{"operationId":"MeController_getProfile","parameters":[],"responses":{"200":{"description":"Contains found user","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserDto"}}}}},"tags":["me"],"security":[{"cookie":[]}]},"patch":{"operationId":"MeController_updateProfile","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateUserDto"}}}},"responses":{"200":{"description":"Contains amount of updated users","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateResponseDto"}}}}},"tags":["me"],"security":[{"cookie":[]}]},"delete":{"operationId":"MeController_deleteProfile","parameters":[],"responses":{"200":{"description":"Contains deleted user","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserDto"}}}}},"tags":["me"],"security":[{"cookie":[]}]}},"/api/me/password":{"patch":{"operationId":"MeController_changePassword","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangePasswordDto"}}}},"responses":{"200":{"description":"Contains amount of updated users","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateResponseDto"}}}}},"tags":["me"],"security":[{"cookie":[]}]}},"/api/snippets":{"get":{"operationId":"SnippetsController_findAllSnippets","parameters":[{"name":"userId","required":false,"in":"query","description":"Id of the snippet owner","schema":{"type":"number"}},{"name":"page","required":false,"in":"query","description":"Page number to retrieve.If you provide invalid value the default page number will applied\n \n Example: 1\n
\n \n Default Value: 1\n
\n ","schema":{"type":"number"}},{"name":"limit","required":false,"in":"query","description":"Number of records per page.\n \n Example: 20\n
\n \n Default Value: 15\n
\n \n Max Value: 100\n
\n\n If provided value is greater than max value, max value will be applied.\n ","schema":{"type":"number"}},{"name":"sortBy","required":false,"in":"query","description":"Parameter to sort by.\n To sort by multiple fields, just provide query param multiple types. The order in url defines an order of sorting
\n \n Format: fieldName:DIRECTION\n
\n \n Example: sortBy=id:DESC&sortBy=createdAt:ASC\n
\n \n Default Value: id:ASC\n
\n Available Fields \n ","schema":{"type":"array","items":{"type":"string","enum":["id:ASC","id:DESC","code:ASC","code:DESC","language:ASC","language:DESC"]}}},{"name":"search","required":false,"in":"query","description":"Search term to filter result values\n \n Example: John\n
\n \n Default Value: No default value\n
\n ","schema":{"type":"string"}},{"name":"searchBy","required":false,"in":"query","description":"List of fields to search by term to filter result values\n \n Example: code,language\n
\n \n Default Value: By default all fields mentioned below will be used to search by term\n
\n Available Fields \n ","schema":{"type":"array","items":{"type":"string"}}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/PaginatedDocumented"},{"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/SnippetDto"}},"meta":{"properties":{"select":{"type":"array","items":{"type":"string"}},"filter":{"type":"object","properties":{}}}}}}]}}}}},"tags":["snippets"]},"post":{"operationId":"SnippetsController_createSnippet","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSnippetDto"}}}},"responses":{"201":{"description":"Contains created snippet","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SnippetDto"}}}}},"tags":["snippets"],"security":[{"cookie":[]}]}},"/api/snippets/languages":{"get":{"operationId":"SnippetsController_getLanguages","parameters":[],"responses":{"200":{"description":"Contains supported languages","content":{"application/json":{"schema":{"type":"array","items":{"type":"string"}}}}}},"tags":["snippets"],"security":[{"cookie":[]}]}},"/api/snippets/{id}":{"get":{"operationId":"SnippetsController_findOneSnippet","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"number"}}],"responses":{"200":{"description":"Contains found snippet","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SnippetDto"}}}}},"tags":["snippets"]},"patch":{"operationId":"SnippetsController_updateSnippet","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"number"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateSnippetDto"}}}},"responses":{"200":{"description":"Contains count of updated snippets","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateResponseDto"}}}}},"tags":["snippets"],"security":[{"cookie":[]}]},"delete":{"operationId":"SnippetsController_delete","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"number"}}],"responses":{"200":{"description":"Contains deleted snippet","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SnippetDto"}}}}},"tags":["snippets"],"security":[{"cookie":[]}]}},"/api/snippets/{id}/mark":{"post":{"operationId":"SnippetsController_markSnippet","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"number"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MarkSnippetDto"}}}},"responses":{"200":{"description":"Contains new user mark for the snippet","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MarkSnippetDto"}}}}},"tags":["snippets"],"security":[{"cookie":[]}]}},"/api/comments":{"post":{"operationId":"CommentsController_createComment","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCommentDto"}}}},"responses":{"201":{"description":"Contains created comment","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CommentDto"}}}}},"tags":["comments"],"security":[{"cookie":[]}]}},"/api/comments/{id}":{"patch":{"operationId":"CommentsController_updateComment","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"number"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateCommentDto"}}}},"responses":{"200":{"description":"Contains amount of updated comments","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateResponseDto"}}}}},"tags":["comments"],"security":[{"cookie":[]}]},"delete":{"operationId":"CommentsController_deleteComment","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"number"}}],"responses":{"200":{"description":"Contains deleted comment","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CommentDto"}}}}},"tags":["comments"],"security":[{"cookie":[]}]}},"/api/auth":{"get":{"operationId":"AuthController_getAuth","parameters":[],"responses":{"200":{"description":"Contains authenticated user","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserDto"}}}}},"tags":["auth"]}},"/api/auth/login":{"post":{"operationId":"AuthController_login","parameters":[],"requestBody":{"required":true,"description":"User credentials","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CredentialsDto"}}}},"responses":{"200":{"description":"Contains authenticated user","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserDto"}}}}},"tags":["auth"]}},"/api/auth/logout":{"post":{"operationId":"AuthController_logout","parameters":[],"responses":{"200":{"description":""}},"tags":["auth"],"security":[{"cookie":[]}]}},"/api/register":{"post":{"operationId":"RegisterController_registerUser","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CredentialsDto"}}}},"responses":{"201":{"description":"Contains information about registred user","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserDto"}}}}},"tags":["register"]}},"/api/questions":{"get":{"operationId":"QuestionsController_getQuestions","parameters":[{"name":"page","required":false,"in":"query","description":"Page number to retrieve.If you provide invalid value the default page number will applied\n \n Example: 1\n
\n \n Default Value: 1\n
\n ","schema":{"type":"number"}},{"name":"limit","required":false,"in":"query","description":"Number of records per page.\n \n Example: 20\n
\n \n Default Value: 15\n
\n \n Max Value: 100\n
\n\n If provided value is greater than max value, max value will be applied.\n ","schema":{"type":"number"}},{"name":"sortBy","required":false,"in":"query","description":"Parameter to sort by.\n To sort by multiple fields, just provide query param multiple types. The order in url defines an order of sorting
\n \n Format: fieldName:DIRECTION\n
\n \n Example: sortBy=id:DESC&sortBy=createdAt:ASC\n
\n \n Default Value: title:DESC\n
\n Available Fields id \ntitle \ndescription \nattachedCode \n ","schema":{"type":"array","items":{"type":"string","enum":["id:ASC","id:DESC","title:ASC","title:DESC","description:ASC","description:DESC","attachedCode:ASC","attachedCode:DESC"]}}},{"name":"search","required":false,"in":"query","description":"Search term to filter result values\n \n Example: John\n
\n \n Default Value: No default value\n
\n ","schema":{"type":"string"}},{"name":"searchBy","required":false,"in":"query","description":"List of fields to search by term to filter result values\n \n Example: title,description,attachedCode\n
\n \n Default Value: By default all fields mentioned below will be used to search by term\n
\n Available Fields title \ndescription \nattachedCode \n ","schema":{"type":"array","items":{"type":"string"}}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/PaginatedDocumented"},{"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/QuestionWithStatusDto"}},"meta":{"properties":{"select":{"type":"array","items":{"type":"string"}},"filter":{"type":"object","properties":{}}}}}}]}}}}},"tags":["questions"]},"post":{"operationId":"QuestionsController_createQuestion","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateQuestionDto"}}}},"responses":{"201":{"description":"Returns created question","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateQuestionDto"}}}}},"tags":["questions"],"security":[{"cookie":[]}]}},"/api/questions/{id}":{"get":{"operationId":"QuestionsController_getQuestion","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"number"}}],"responses":{"200":{"description":"Returns single question by id","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuestionWithStatusDto"}}}}},"tags":["questions"]},"patch":{"operationId":"QuestionsController_updateQuestion","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateQuestionDto"}}}},"responses":{"201":{"description":"Updates question","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuestionDto"}}}}},"tags":["questions"],"security":[{"cookie":[]}]},"delete":{"operationId":"QuestionsController_deleteQuestion","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"number"}}],"responses":{"200":{"description":"Deletes question","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuestionDto"}}}}},"tags":["questions"],"security":[{"cookie":[]}]}},"/api/answers":{"get":{"operationId":"AnswersController_getAnswers","parameters":[],"responses":{"200":{"description":"Returns all answers"}},"tags":["answers"]},"post":{"operationId":"AnswersController_createAnswer","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateAnswerDto"}}}},"responses":{"201":{"description":"Returns created answer"}},"tags":["answers"],"security":[{"cookie":[]}]}},"/api/answers/{id}/state/{state}":{"put":{"operationId":"AnswersController_updateState","parameters":[{"name":"state","required":true,"in":"path","examples":{"correct":{"value":"correct","description":"Pass if you want to mark answer as correct","summary":"Pass if you want to mark answer as correct"},"incorrect":{"value":"incorrect","description":"Pass if you want to mark answer as incorrect","summary":"Pass if you want to mark answer as incorrect"}},"schema":{"$ref":"#/components/schemas/AnswerState"}},{"name":"id","required":true,"in":"path","schema":{"type":"number"}}],"responses":{"200":{"description":"Updates correct/incorrect state of answer"}},"tags":["answers"],"security":[{"cookie":[]}]}},"/api/answers/{id}":{"patch":{"operationId":"AnswersController_updateAnswer","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateAnswerDto"}}}},"responses":{"200":{"description":"Updates answer content"}},"tags":["answers"],"security":[{"cookie":[]}]},"delete":{"operationId":"AnswersController_deleteAnswer","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"number"}}],"responses":{"200":{"description":"Deletes answer","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnswerDto"}}}}},"tags":["answers"],"security":[{"cookie":[]}]}}},"info":{"title":"Codelang API","description":"The Codelang API description","version":"1.0","contact":{}},"tags":[],"servers":[],"components":{"securitySchemes":{"cookie":{"type":"apiKey","in":"cookie","name":"token"}},"schemas":{"PaginatedMetaDocumented":{"type":"object","properties":{"itemsPerPage":{"type":"number","title":"Number of items per page"},"totalItems":{"type":"number","title":"Total number of items"},"currentPage":{"type":"number","title":"Current requested page"},"totalPages":{"type":"number","title":"Total number of pages"},"sortBy":{"type":"array","title":"Sorting by columns","items":{"type":"array","items":{"oneOf":[{"type":"string"},{"type":"string","enum":["ASC","DESC"]}]}}},"searchBy":{"title":"Search by fields","type":"array","items":{"type":"string"}},"search":{"type":"string","title":"Search term"},"select":{"title":"List of selected fields","type":"array","items":{"type":"string"}},"filter":{"type":"object","title":"Filters that applied to the query"}},"required":["itemsPerPage","totalItems","currentPage","totalPages"]},"PaginatedLinksDocumented":{"type":"object","properties":{"first":{"type":"string","title":"Link to first page"},"previous":{"type":"string","title":"Link to previous page"},"current":{"type":"string","title":"Link to current page"},"next":{"type":"string","title":"Link to next page"},"last":{"type":"string","title":"Link to last page"}}},"PaginatedDocumented":{"type":"object","properties":{"data":{"title":"Array of entities","type":"array","items":{"type":"object"}},"meta":{"title":"Pagination Metadata","allOf":[{"$ref":"#/components/schemas/PaginatedMetaDocumented"}]},"links":{"title":"Links to pages","allOf":[{"$ref":"#/components/schemas/PaginatedLinksDocumented"}]}},"required":["data","meta","links"]},"UserRoles":{"type":"string","description":"User's role","enum":["user","admin"]},"UserDto":{"type":"object","properties":{"id":{"type":"number","description":"User's identifier"},"username":{"type":"string","description":"User's nickname"},"role":{"$ref":"#/components/schemas/UserRoles"}},"required":["id","username","role"]},"StatisticDto":{"type":"object","properties":{"snippetsCount":{"type":"number","description":"Number of snippets created by user","example":5},"rating":{"type":"number","description":"User activity rating","example":150},"commentsCount":{"type":"number","description":"Number of comments made by user","example":10},"likesCount":{"type":"number","description":"Number of likes given by user","example":20},"dislikesCount":{"type":"number","description":"Number of dislikes given by user","example":5},"questionsCount":{"type":"number","description":"Number of questions asked by user","example":8},"correctAnswersCount":{"type":"number","description":"Number of correct answers given by user","example":12},"regularAnswersCount":{"type":"number","description":"Number of regular answers given by user","example":15}},"required":["snippetsCount","rating","commentsCount","likesCount","dislikesCount","questionsCount","correctAnswersCount","regularAnswersCount"]},"UserStatisticDto":{"type":"object","properties":{"id":{"type":"number","description":"User's identifier"},"username":{"type":"string","description":"User's nickname"},"role":{"$ref":"#/components/schemas/UserRoles"},"statistic":{"description":"User activity statistics","allOf":[{"$ref":"#/components/schemas/StatisticDto"}]}},"required":["id","username","role","statistic"]},"UpdateUserDto":{"type":"object","properties":{"username":{"type":"string","description":"New user nickname","minLength":5}},"required":["username"]},"UpdateResponseDto":{"type":"object","properties":{"updatedCount":{"type":"number","description":"Amount of updated items"}},"required":["updatedCount"]},"ChangePasswordDto":{"type":"object","properties":{"oldPassword":{"type":"string","description":"Old user's password"},"newPassword":{"type":"string","description":"New password"}},"required":["oldPassword","newPassword"]},"SnippetDto":{"type":"object","properties":{"id":{"type":"number","description":"Snippet's identifier"},"language":{"type":"string","description":"Snippet's programming language"},"code":{"type":"string","description":"Snippet's content"},"user":{"description":"Owner of the snippet","allOf":[{"$ref":"#/components/schemas/UserDto"}]}},"required":["id","language","code","user"]},"Languages":{"type":"string","description":"Snippet's language (programming language)","enum":["JavaScript","Python","Java","C/C++","C#","Go","Kotlin","Ruby"]},"CreateSnippetDto":{"type":"object","properties":{"code":{"$ref":"#/components/schemas/Languages"},"language":{"type":"string","description":"Snippet's language (programming language)"}},"required":["code","language"]},"UpdateSnippetDto":{"type":"object","properties":{"code":{"type":"string","description":"New snippet content"},"language":{"type":"string","description":"New snippet language"}}},"SnippetMark":{"type":"string","description":"User mark for the snippet. like, dislike or none (remove any existing mark: like or dislike)","enum":["like","dislike","none"]},"MarkSnippetDto":{"type":"object","properties":{"mark":{"$ref":"#/components/schemas/SnippetMark"}},"required":["mark"]},"CreateCommentDto":{"type":"object","properties":{"content":{"type":"string","description":"Comment's content"},"snippetId":{"type":"number","description":"Id of the commenting snippet"}},"required":["content","snippetId"]},"CommentDto":{"type":"object","properties":{"id":{"type":"number","description":"Comment's identifier"},"content":{"type":"string","description":"Comment's content"},"user":{"description":"Owner of the comment","allOf":[{"$ref":"#/components/schemas/UserDto"}]}},"required":["id","content","user"]},"UpdateCommentDto":{"type":"object","properties":{"content":{"type":"string","description":"New comment content"}},"required":["content"]},"CredentialsDto":{"type":"object","properties":{"username":{"type":"string","description":"User's nickname","minLength":5},"password":{"type":"string","description":"User's password","minLength":6}},"required":["username","password"]},"QuestionWithStatusDto":{"type":"object","properties":{"id":{"type":"number","description":"Unique identifier of the question"},"title":{"type":"string","description":"Title of the question"},"description":{"type":"string","description":"Detailed description of the question"},"attachedCode":{"type":"string","description":"Code snippet attached to the question"},"user":{"type":"object","description":"User who created the question"},"answers":{"description":"List of answers to this question","type":"array","items":{"type":"array"}},"isResolved":{"type":"boolean","description":"Indicates if question is resolved"}},"required":["id","title","description","user","answers","isResolved"]},"CreateQuestionDto":{"type":"object","properties":{"title":{"type":"string","description":"Question title"},"description":{"type":"string","description":"Question description"},"attachedCode":{"type":"string","description":"Question attached code"}},"required":["title","description","attachedCode"]},"UpdateQuestionDto":{"type":"object","properties":{"title":{"type":"string","description":"Question description"},"description":{"type":"string","description":"Question description"},"attachedCode":{"type":"string","description":"Question attached code"}},"required":["title","description","attachedCode"]},"QuestionDto":{"type":"object","properties":{"id":{"type":"number","description":"Unique identifier of the question"},"title":{"type":"string","description":"Title of the question"},"description":{"type":"string","description":"Detailed description of the question"},"attachedCode":{"type":"string","description":"Code snippet attached to the question"},"user":{"type":"object","description":"User who created the question"},"answers":{"description":"List of answers to this question","type":"array","items":{"type":"array"}}},"required":["id","title","description","user","answers"]},"CreateAnswerDto":{"type":"object","properties":{"content":{"type":"string","description":"Answer content"},"questionId":{"type":"number","description":"Id of the commenting question"}},"required":["content","questionId"]},"AnswerState":{"type":"string","enum":["correct","incorrect"]},"UpdateAnswerDto":{"type":"object","properties":{"content":{"type":"string","description":"Answer content"}},"required":["content"]},"AnswerDto":{"type":"object","properties":{"id":{"type":"number","description":"The unique identifier of the answer","example":1},"content":{"type":"string","description":"The content of the answer","example":"Paris is the capital of France"},"isCorrect":{"type":"boolean","description":"Indicates whether this answer is correct","example":true}},"required":["id","content","isCorrect"]}}}}
diff --git a/src/app/providers/auth.tsx b/src/app/providers/auth.tsx
index 523967f..b8b92c4 100644
--- a/src/app/providers/auth.tsx
+++ b/src/app/providers/auth.tsx
@@ -30,7 +30,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
await refresh();
} catch (e) {
const err = toHttpError(e);
- throw new Error(err.message || "Login failed");
+ const error = new Error(err.message || "Login failed");
+ (error as Error & { status?: number }).status = err.status;
+ throw error;
}
};
@@ -44,7 +46,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
await http.post("/register", { username, password });
} catch (e) {
const err = toHttpError(e);
- throw new Error(err.message || "Register failed");
+ const error = new Error(err.message || "Register failed");
+ (error as Error & { status?: number }).status = err.status;
+ throw error;
}
};
diff --git a/src/pages/auth/RegisterPage.tsx b/src/pages/auth/RegisterPage.tsx
index 2b9af12..fc99480 100644
--- a/src/pages/auth/RegisterPage.tsx
+++ b/src/pages/auth/RegisterPage.tsx
@@ -6,9 +6,16 @@ import { Link, useNavigate } from "react-router-dom";
const schema = z
.object({
- username: z.string().min(5),
- password: z.string().min(6),
- confirm: z.string().min(6),
+ username: z.string().trim().min(5),
+ password: z
+ .string()
+ .trim()
+ .min(6)
+ .regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9]).+$/, {
+ message:
+ "Пароль должен содержать минимум 1 строчную, 1 заглавную букву, 1 цифру и 1 символ",
+ }),
+ confirm: z.string().trim().min(6),
})
.refine((v) => v.password === v.confirm, {
message: "Пароли не совпадают",
@@ -30,11 +37,39 @@ export default function RegisterPage() {
});
const onSubmit = async (data: FormData) => {
+ const username = data.username.trim();
+ const password = data.password.trim();
try {
- await doRegister(data.username, data.password);
+ await doRegister(username, password);
navigate("/login", { replace: true });
- } catch (e) {
- const message = e instanceof Error ? e.message : "Ошибка регистрации";
+ } catch (e: unknown) {
+ let message = e instanceof Error ? e.message : "Ошибка регистрации";
+ // Provide friendlier messages for common validation cases
+ const hasStatus =
+ typeof e === "object" &&
+ e !== null &&
+ "status" in (e as Record);
+ if (hasStatus) {
+ const status = (e as Record).status as
+ | number
+ | undefined;
+ if (status === 409)
+ message = "Пользователь с таким именем уже существует";
+ if (status === 422) message = message || "Некорректные данные";
+ }
+ // Translate server's password policy message if it comes in English
+ if (
+ /Password must contain at least one lowercase letter, one uppercase letter, one number and one symbol!?/i.test(
+ message
+ )
+ ) {
+ message =
+ "Пароль должен содержать минимум 1 строчную, 1 заглавную букву, 1 цифру и 1 символ";
+ }
+ if (message && /^validation failed!?$/i.test(message)) {
+ message =
+ "Проверьте имя пользователя (не короче 5) и пароль (не короче 6)";
+ }
setError("root", { message });
}
};
diff --git a/src/shared/api/http.ts b/src/shared/api/http.ts
index c3208c9..c9521b2 100644
--- a/src/shared/api/http.ts
+++ b/src/shared/api/http.ts
@@ -1,7 +1,13 @@
import axios from "axios";
+// Use relative '/api' by default to leverage Vite proxy (avoids CORS with credentials).
+// Can be overridden via VITE_API_BASE_URL if your API supports proper CORS for credentials.
+const API_BASE_URL =
+ (import.meta as unknown as { env?: Record }).env
+ ?.VITE_API_BASE_URL || "/api";
+
export const http = axios.create({
- baseURL: "/api",
+ baseURL: API_BASE_URL,
withCredentials: true,
});
@@ -19,28 +25,80 @@ export type HttpError = {
export function toHttpError(e: unknown): HttpError {
if (typeof e === "object" && e !== null) {
- const errObj = e as Record;
+ const anyE = e as Record;
const hasResponse =
- "response" in errObj &&
- typeof errObj.response === "object" &&
- errObj.response !== null;
- const res = hasResponse ? (errObj.response as Record) : undefined;
- const status = res && "status" in res ? (res.status as number | undefined) : undefined;
- const data = res && "data" in res ? (res.data as unknown) : undefined;
-
- const extractMessage = (val: unknown): string | undefined => {
- if (typeof val === "string") return val;
- if (Array.isArray(val)) return (val as unknown[]).map(String).join("\n");
- if (val && typeof val === "object") {
- const obj = val as Record;
- if (typeof obj.message === "string") return obj.message as string;
- if (Array.isArray(obj.message)) return (obj.message as unknown[]).map(String).join("\n");
- if (Array.isArray(obj.errors)) return (obj.errors as unknown[]).map(String).join("\n");
+ "response" in anyE &&
+ typeof anyE.response === "object" &&
+ anyE.response !== null;
+ const status =
+ hasResponse && "status" in (anyE.response as Record)
+ ? ((anyE.response as Record).status as
+ | number
+ | undefined)
+ : undefined;
+ const data =
+ hasResponse && "data" in (anyE.response as Record)
+ ? ((anyE.response as Record).data as Record<
+ string,
+ unknown
+ >)
+ : undefined;
+ // Try to pick a useful message from common API error shapes
+ let message: string | undefined;
+ if (data && typeof data === "object") {
+ const rec = data as Record;
+ const msg = rec["message"];
+ if (typeof msg === "string") message = msg;
+ // NestJS/class-validator often returns { message: string[] }
+ else if (Array.isArray(msg)) {
+ const arr = msg as unknown[];
+ const firstStr = arr.find((it) => typeof it === "string");
+ if (typeof firstStr === "string") message = firstStr;
+ else if (arr.length) {
+ const first = arr[0];
+ if (typeof first === "object" && first !== null) {
+ const obj = first as Record;
+ const constraints = obj["constraints"];
+ if (constraints && typeof constraints === "object") {
+ const values = Object.values(
+ constraints as Record
+ );
+ const cFirst = values.find((v) => typeof v === "string");
+ if (typeof cFirst === "string") message = cFirst;
+ }
+ }
+ }
+ } else {
+ const errorMsg = rec["error"];
+ const detail = rec["detail"];
+ if (typeof errorMsg === "string") message = errorMsg;
+ else if (typeof detail === "string") message = detail;
+ else if (Array.isArray(rec["errors"])) {
+ const arr = rec["errors"] as unknown[];
+ // If API returns array of strings
+ const firstString = arr.find((it) => typeof it === "string");
+ if (typeof firstString === "string") message = firstString;
+ else {
+ // Try documented shape: { field: string; failures: string[]; receivedValue?: unknown }
+ const firstObj = arr.find(
+ (it) => typeof it === "object" && it !== null
+ );
+ if (firstObj) {
+ const obj = firstObj as Record;
+ const failures = obj["failures"];
+ if (Array.isArray(failures)) {
+ const firstFailure = (failures as unknown[]).find(
+ (f) => typeof f === "string"
+ );
+ if (typeof firstFailure === "string") message = firstFailure;
+ }
+ }
+ }
+ }
}
- return undefined;
- };
-
- const message = extractMessage(data) ?? (typeof errObj.message === "string" ? (errObj.message as string) : undefined);
+ }
+ if (!message && typeof anyE.message === "string")
+ message = anyE.message as string;
return { status, message };
}
return { message: String(e) };
diff --git a/src/shared/ui/Clamp.tsx b/src/shared/ui/Clamp.tsx
new file mode 100644
index 0000000..e69de29
diff --git a/tailwind.config.ts b/tailwind.config.ts
new file mode 100644
index 0000000..e69de29
diff --git a/vite.config.ts b/vite.config.ts
index 6d3da19..44349a0 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,10 +1,12 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
+import mkcert from "vite-plugin-mkcert";
// https://vite.dev/config/
export default defineConfig({
- plugins: [react()],
+ plugins: [react(), mkcert()],
server: {
+ https: true,
proxy: {
"/api": {
target: "https://codelang.vercel.app",
From 37e8fa943072812fc407692cb8fc0894687217fc Mon Sep 17 00:00:00 2001
From: kotru21
Date: Thu, 14 Aug 2025 19:52:45 +0300
Subject: [PATCH 07/40] Refactor UI into presentational components
Moved header, home page, question details, answer item, and form UIs into dedicated presentational components for improved separation of concerns and reusability. Updated imports and routing to reflect new file structure. No functional changes, only refactoring for maintainability.
---
src/App.tsx | 52 ++------
src/app/ui/HeaderView.tsx | 51 +++++++
src/main.tsx | 4 +-
src/pages/HomePage.tsx | 125 ------------------
src/pages/auth/LoginPage.tsx | 60 ++-------
src/pages/auth/RegisterPage.tsx | 75 +++--------
src/pages/auth/ui/LoginFormView.tsx | 65 +++++++++
src/pages/auth/ui/RegisterFormView.tsx | 81 ++++++++++++
src/pages/home/HomePage.tsx | 66 +++++++++
src/pages/home/ui/HomePageView.tsx | 50 +++++++
src/pages/home/ui/QuestionCard.tsx | 56 ++++++++
src/pages/{ => question}/QuestionPage.tsx | 47 +++----
src/pages/question/ui/AnswerItemView.tsx | 30 +++++
src/pages/question/ui/QuestionDetailsView.tsx | 23 ++++
14 files changed, 476 insertions(+), 309 deletions(-)
create mode 100644 src/app/ui/HeaderView.tsx
delete mode 100644 src/pages/HomePage.tsx
create mode 100644 src/pages/auth/ui/LoginFormView.tsx
create mode 100644 src/pages/auth/ui/RegisterFormView.tsx
create mode 100644 src/pages/home/HomePage.tsx
create mode 100644 src/pages/home/ui/HomePageView.tsx
create mode 100644 src/pages/home/ui/QuestionCard.tsx
rename src/pages/{ => question}/QuestionPage.tsx (64%)
create mode 100644 src/pages/question/ui/AnswerItemView.tsx
create mode 100644 src/pages/question/ui/QuestionDetailsView.tsx
diff --git a/src/App.tsx b/src/App.tsx
index 5930ed9..4e84935 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,53 +1,19 @@
-import { Link, Outlet } from "react-router-dom";
+import { Outlet } from "react-router-dom";
import { useAuth } from "./app/providers/useAuth";
import { useTheme } from "./app/providers/useTheme";
+import HeaderView from "./app/ui/HeaderView";
-function Header() {
+export default function App() {
const { user, logout } = useAuth();
const { theme, toggle } = useTheme();
- return (
-
- );
-}
-
-export default function App() {
return (
-
+
void logout()}
+ />
diff --git a/src/app/ui/HeaderView.tsx b/src/app/ui/HeaderView.tsx
new file mode 100644
index 0000000..549eab5
--- /dev/null
+++ b/src/app/ui/HeaderView.tsx
@@ -0,0 +1,51 @@
+import { Link } from "react-router-dom";
+
+export type HeaderViewProps = {
+ user: { username: string } | null;
+ theme: "light" | "dark";
+ onToggleTheme: () => void;
+ onLogout: () => void;
+};
+
+export default function HeaderView({
+ user,
+ theme,
+ onToggleTheme,
+ onLogout,
+}: HeaderViewProps) {
+ return (
+
+ );
+}
diff --git a/src/main.tsx b/src/main.tsx
index bd1dec4..7a4f414 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -2,8 +2,8 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import { createBrowserRouter, RouterProvider } from "react-router-dom";
-import HomePage from "./pages/HomePage";
-import QuestionPage from "./pages/QuestionPage";
+import HomePage from "./pages/home/HomePage";
+import QuestionPage from "./pages/question/QuestionPage";
import LoginPage from "./pages/auth/LoginPage.tsx";
import RegisterPage from "./pages/auth/RegisterPage.tsx";
import { RequireGuest } from "./app/providers/route-guards";
diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx
deleted file mode 100644
index 8a2fb95..0000000
--- a/src/pages/HomePage.tsx
+++ /dev/null
@@ -1,125 +0,0 @@
-import { memo, useEffect } from "react";
-import { Link } from "react-router-dom";
-import { useWindowVirtualizer } from "@tanstack/react-virtual";
-import { useQuestions } from "../entities/question/api";
-import type { Question } from "../entities/question/types";
-import { CodeBlock } from "../shared/ui/CodeBlock";
-import { ExpandableText } from "../shared/ui/ExpandableText";
-import { useNavigate } from "react-router-dom";
-
-type QuestionCardProps = {
- id: string | number;
- title: string;
- description: string;
- attachedCode?: string;
- user: { username: string };
- answersCount?: number;
-};
-
-const QuestionCard = memo(function QuestionCard({
- id,
- title,
- description,
- attachedCode,
- user,
- answersCount,
-}: QuestionCardProps) {
- const navigate = useNavigate();
- return (
-
-
-
- {title}
-
- @{user.username}
-
- navigate(`/questions/${id}`)}
- className="text-sm"
- />
- {attachedCode && }
-
- {typeof answersCount !== "undefined" && (
- Answers: {answersCount}
- )}
-
-
-
- Answers →
-
-
-
- );
-});
-
-export default function HomePage() {
- const { data, fetchNextPage, hasNextPage, isFetchingNextPage, status } =
- useQuestions({});
-
- const items = (data?.pages.flatMap((p) => p.data) ?? []) as Question[];
- const rowVirtualizer = useWindowVirtualizer({
- count: items.length,
- estimateSize: () => 220,
- overscan: 6,
- });
-
- const virtualItems = rowVirtualizer.getVirtualItems();
- useEffect(() => {
- if (!hasNextPage || isFetchingNextPage || items.length === 0) return;
- const last = virtualItems[virtualItems.length - 1];
- if (last && last.index >= items.length - 1) {
- void fetchNextPage();
- }
- }, [
- virtualItems,
- hasNextPage,
- isFetchingNextPage,
- items.length,
- fetchNextPage,
- ]);
-
- return (
-
-
Вопросы
- {status === "pending" &&
Загрузка...
}
- {items.length === 0 && status === "success" && (
-
Нет вопросов.
- )}
-
- {virtualItems.map((v) => {
- const s = items[v.index];
- return (
-
- {s && (
-
- )}
-
- );
- })}
-
- {isFetchingNextPage &&
Загрузка...
}
-
- );
-}
diff --git a/src/pages/auth/LoginPage.tsx b/src/pages/auth/LoginPage.tsx
index ffaa126..15b5f54 100644
--- a/src/pages/auth/LoginPage.tsx
+++ b/src/pages/auth/LoginPage.tsx
@@ -2,7 +2,8 @@ import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useAuth } from "../../app/providers/useAuth";
-import { useNavigate, useLocation, Link } from "react-router-dom";
+import { useNavigate, useLocation } from "react-router-dom";
+import LoginFormView from "./ui/LoginFormView";
const schema = z.object({
username: z.string().min(5),
@@ -40,51 +41,16 @@ export default function LoginPage() {
};
return (
-
-
Вход
-
-
-
Username
-
- {errors.username && (
-
- {errors.username.message}
-
- )}
-
-
-
Password
-
- {errors.password && (
-
- {errors.password.message}
-
- )}
-
- {errors.root?.message && (
- {errors.root.message}
- )}
-
- {isSubmitting ? "Вход..." : "Войти"}
-
-
-
- Нет аккаунта?{" "}
-
- Зарегистрируйтесь
-
-
-
+
);
}
diff --git a/src/pages/auth/RegisterPage.tsx b/src/pages/auth/RegisterPage.tsx
index fc99480..a280c8e 100644
--- a/src/pages/auth/RegisterPage.tsx
+++ b/src/pages/auth/RegisterPage.tsx
@@ -2,7 +2,8 @@ import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useAuth } from "../../app/providers/useAuth";
-import { Link, useNavigate } from "react-router-dom";
+import { useNavigate } from "react-router-dom";
+import RegisterFormView from "./ui/RegisterFormView";
const schema = z
.object({
@@ -75,64 +76,18 @@ export default function RegisterPage() {
};
return (
-
-
Регистрация
-
-
-
Username
-
- {errors.username && (
-
- {errors.username.message}
-
- )}
-
-
-
Password
-
- {errors.password && (
-
- {errors.password.message}
-
- )}
-
-
-
Confirm Password
-
- {errors.confirm && (
-
- {errors.confirm.message}
-
- )}
-
- {errors.root?.message && (
- {errors.root.message}
- )}
-
- {isSubmitting ? "Создание..." : "Создать аккаунт"}
-
-
-
- Уже есть аккаунт?{" "}
-
- Войти
-
-
-
+
);
}
diff --git a/src/pages/auth/ui/LoginFormView.tsx b/src/pages/auth/ui/LoginFormView.tsx
new file mode 100644
index 0000000..5f9f539
--- /dev/null
+++ b/src/pages/auth/ui/LoginFormView.tsx
@@ -0,0 +1,65 @@
+import { Link } from "react-router-dom";
+import type { FormEvent, InputHTMLAttributes } from "react";
+
+export type LoginFormViewProps = {
+ onSubmit: (e: FormEvent
) => void;
+ usernameInputProps: InputHTMLAttributes;
+ passwordInputProps: InputHTMLAttributes;
+ errors?: {
+ username?: string;
+ password?: string;
+ root?: string;
+ };
+ isSubmitting?: boolean;
+};
+
+export default function LoginFormView({
+ onSubmit,
+ usernameInputProps,
+ passwordInputProps,
+ errors,
+ isSubmitting,
+}: LoginFormViewProps) {
+ return (
+
+
Вход
+
+
+
Username
+
+ {errors?.username && (
+
{errors.username}
+ )}
+
+
+
Password
+
+ {errors?.password && (
+
{errors.password}
+ )}
+
+ {errors?.root && {errors.root}
}
+
+ {isSubmitting ? "Вход..." : "Войти"}
+
+
+
+ Нет аккаунта?{" "}
+
+ Зарегистрируйтесь
+
+
+
+ );
+}
diff --git a/src/pages/auth/ui/RegisterFormView.tsx b/src/pages/auth/ui/RegisterFormView.tsx
new file mode 100644
index 0000000..6dea7f6
--- /dev/null
+++ b/src/pages/auth/ui/RegisterFormView.tsx
@@ -0,0 +1,81 @@
+import { Link } from "react-router-dom";
+import type { FormEvent, InputHTMLAttributes } from "react";
+
+export type RegisterFormViewProps = {
+ onSubmit: (e: FormEvent) => void;
+ usernameInputProps: InputHTMLAttributes;
+ passwordInputProps: InputHTMLAttributes;
+ confirmInputProps: InputHTMLAttributes;
+ errors?: {
+ username?: string;
+ password?: string;
+ confirm?: string;
+ root?: string;
+ };
+ isSubmitting?: boolean;
+};
+
+export function RegisterFormView({
+ onSubmit,
+ usernameInputProps,
+ passwordInputProps,
+ confirmInputProps,
+ errors,
+ isSubmitting,
+}: RegisterFormViewProps) {
+ return (
+
+
Регистрация
+
+
+
Username
+
+ {errors?.username && (
+
{errors.username}
+ )}
+
+
+
Password
+
+ {errors?.password && (
+
{errors.password}
+ )}
+
+
+
Confirm Password
+
+ {errors?.confirm && (
+
{errors.confirm}
+ )}
+
+ {errors?.root && {errors.root}
}
+
+ {isSubmitting ? "Создание..." : "Создать аккаунт"}
+
+
+
+ Уже есть аккаунт?{" "}
+
+ Войти
+
+
+
+ );
+}
+
+export default RegisterFormView;
diff --git a/src/pages/home/HomePage.tsx b/src/pages/home/HomePage.tsx
new file mode 100644
index 0000000..bf2b132
--- /dev/null
+++ b/src/pages/home/HomePage.tsx
@@ -0,0 +1,66 @@
+import { useEffect } from "react";
+import { useNavigate } from "react-router-dom";
+import { useWindowVirtualizer } from "@tanstack/react-virtual";
+import { useQuestions } from "../../entities/question/api";
+import type { Question } from "../../entities/question/types";
+import { QuestionCard } from "./ui/QuestionCard";
+import HomePageView from "./ui/HomePageView";
+
+export default function HomePage() {
+ const { data, fetchNextPage, hasNextPage, isFetchingNextPage, status } =
+ useQuestions({});
+
+ const items = (data?.pages.flatMap((p) => p.data) ?? []) as Question[];
+ const rowVirtualizer = useWindowVirtualizer({
+ count: items.length,
+ estimateSize: () => 220,
+ overscan: 6,
+ });
+
+ const virtualItems = rowVirtualizer.getVirtualItems();
+ const navigate = useNavigate();
+ useEffect(() => {
+ if (!hasNextPage || isFetchingNextPage || items.length === 0) return;
+ const last = virtualItems[virtualItems.length - 1];
+ if (last && last.index >= items.length - 1) {
+ void fetchNextPage();
+ }
+ }, [
+ virtualItems,
+ hasNextPage,
+ isFetchingNextPage,
+ items.length,
+ fetchNextPage,
+ ]);
+
+ const rows = virtualItems.map((v) => {
+ const s = items[v.index];
+ return {
+ key: (s?.id as React.Key) ?? v.index,
+ start: v.start,
+ index: v.index,
+ ref: rowVirtualizer.measureElement,
+ content: s ? (
+ navigate(`/questions/${s.id}`)}
+ />
+ ) : null,
+ };
+ });
+
+ return (
+ 0}
+ containerHeight={rowVirtualizer.getTotalSize()}
+ rows={rows}
+ isFetchingNextPage={isFetchingNextPage}
+ />
+ );
+}
diff --git a/src/pages/home/ui/HomePageView.tsx b/src/pages/home/ui/HomePageView.tsx
new file mode 100644
index 0000000..0f5e365
--- /dev/null
+++ b/src/pages/home/ui/HomePageView.tsx
@@ -0,0 +1,50 @@
+import type { ReactNode, RefCallback } from "react";
+
+export type HomeRow = {
+ key: React.Key;
+ start: number;
+ index: number;
+ ref?: RefCallback;
+ content: ReactNode;
+};
+
+export type HomePageViewProps = {
+ title?: string;
+ status: "idle" | "pending" | "success" | "error" | string;
+ hasItems: boolean;
+ containerHeight: number;
+ rows: HomeRow[];
+ isFetchingNextPage?: boolean;
+};
+
+export default function HomePageView({
+ title = "Вопросы",
+ status,
+ hasItems,
+ containerHeight,
+ rows,
+ isFetchingNextPage,
+}: HomePageViewProps) {
+ return (
+
+
{title}
+ {status === "pending" &&
Загрузка...
}
+ {!hasItems && status === "success" && (
+
Нет вопросов.
+ )}
+
+ {rows.map((row) => (
+
+ {row.content}
+
+ ))}
+
+ {isFetchingNextPage &&
Загрузка...
}
+
+ );
+}
diff --git a/src/pages/home/ui/QuestionCard.tsx b/src/pages/home/ui/QuestionCard.tsx
new file mode 100644
index 0000000..742af14
--- /dev/null
+++ b/src/pages/home/ui/QuestionCard.tsx
@@ -0,0 +1,56 @@
+import { memo } from "react";
+import { Link } from "react-router-dom";
+import { CodeBlock } from "../../../shared/ui/CodeBlock";
+import { ExpandableText } from "../../../shared/ui/ExpandableText";
+
+export type QuestionCardProps = {
+ id: string | number;
+ title: string;
+ description: string;
+ attachedCode?: string;
+ user: { username: string };
+ answersCount?: number;
+ onMoreClick?: () => void;
+};
+
+export const QuestionCard = memo(function QuestionCard({
+ id,
+ title,
+ description,
+ attachedCode,
+ user,
+ answersCount,
+ onMoreClick,
+}: QuestionCardProps) {
+ return (
+
+
+
+ {title}
+
+ @{user.username}
+
+
+ {attachedCode && }
+
+ {typeof answersCount !== "undefined" && (
+ Answers: {answersCount}
+ )}
+
+
+
+ Answers →
+
+
+
+ );
+});
+
+export default QuestionCard;
diff --git a/src/pages/QuestionPage.tsx b/src/pages/question/QuestionPage.tsx
similarity index 64%
rename from src/pages/QuestionPage.tsx
rename to src/pages/question/QuestionPage.tsx
index 358a5ec..addef9b 100644
--- a/src/pages/QuestionPage.tsx
+++ b/src/pages/question/QuestionPage.tsx
@@ -1,13 +1,12 @@
import { useParams, Link } from "react-router-dom";
-import { memo } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
-import { useQuestion, useCreateAnswer } from "../entities/question/api";
-import type { Question, Answer } from "../entities/question/types";
-import { useAuth } from "../app/providers/useAuth";
-import { CodeBlock } from "../shared/ui/CodeBlock";
-import { ExpandableText } from "../shared/ui/ExpandableText";
+import { useQuestion, useCreateAnswer } from "../../entities/question/api";
+import type { Question, Answer } from "../../entities/question/types";
+import { useAuth } from "../../app/providers/useAuth";
+import QuestionDetailsView from "./ui/QuestionDetailsView";
+import AnswerItemView from "./ui/AnswerItemView";
const schema = z.object({
content: z.string().min(1, "Ответ не может быть пустым"),
@@ -42,19 +41,21 @@ export default function QuestionPage() {
← Назад
- {(question as Question).title}
-
- {(question as Question).description}
-
- {(question as Question).attachedCode && (
-
- )}
+
Ответы
{Array.isArray((question as Question).answers) &&
(question as Question).answers!.map((a: Answer) => (
-
+
))}
@@ -83,21 +84,3 @@ export default function QuestionPage() {
);
}
-
-const AnswerItem = memo(function AnswerItem({ a }: { a: Answer }) {
- return (
-
-
-
- {a.isCorrect && correct }
-
-
- );
-});
diff --git a/src/pages/question/ui/AnswerItemView.tsx b/src/pages/question/ui/AnswerItemView.tsx
new file mode 100644
index 0000000..cd77542
--- /dev/null
+++ b/src/pages/question/ui/AnswerItemView.tsx
@@ -0,0 +1,30 @@
+import { memo } from "react";
+import { ExpandableText } from "../../../shared/ui/ExpandableText";
+
+export type AnswerItemViewProps = {
+ content: string;
+ isCorrect?: boolean;
+};
+
+export const AnswerItemView = memo(function AnswerItemView({
+ content,
+ isCorrect,
+}: AnswerItemViewProps) {
+ return (
+
+
+
+ {isCorrect && correct }
+
+
+ );
+});
+
+export default AnswerItemView;
diff --git a/src/pages/question/ui/QuestionDetailsView.tsx b/src/pages/question/ui/QuestionDetailsView.tsx
new file mode 100644
index 0000000..76f93b9
--- /dev/null
+++ b/src/pages/question/ui/QuestionDetailsView.tsx
@@ -0,0 +1,23 @@
+import { CodeBlock } from "../../../shared/ui/CodeBlock";
+
+export type QuestionDetailsViewProps = {
+ title: string;
+ description: string;
+ attachedCode?: string;
+};
+
+export default function QuestionDetailsView({
+ title,
+ description,
+ attachedCode,
+}: QuestionDetailsViewProps) {
+ return (
+
+
{title}
+
+ {description}
+
+ {attachedCode &&
}
+
+ );
+}
From 6c8f4f614432f3955160f9a32b40e0cc5f838918 Mon Sep 17 00:00:00 2001
From: kotru21
Date: Thu, 14 Aug 2025 20:04:05 +0300
Subject: [PATCH 08/40] Refactor pagination types to shared module
Moved Paginated and PaginatedMeta types from question and snippet modules to a new shared/types/pagination.ts file. Updated imports in related API files to use the shared pagination types for consistency and reusability.
---
src/entities/question/api.ts | 6 ++++--
src/entities/question/types.ts | 12 ------------
src/entities/snippet/api.ts | 11 +++++++----
src/entities/snippet/types.ts | 12 ------------
src/shared/types/pagination.ts | 11 +++++++++++
5 files changed, 22 insertions(+), 30 deletions(-)
create mode 100644 src/shared/types/pagination.ts
diff --git a/src/entities/question/api.ts b/src/entities/question/api.ts
index ce76a40..5ec7b73 100644
--- a/src/entities/question/api.ts
+++ b/src/entities/question/api.ts
@@ -5,7 +5,8 @@ import {
useQueryClient,
} from "@tanstack/react-query";
import { http } from "../../shared/api/http";
-import type { Paginated, Question } from "./types";
+import type { Question } from "./types";
+import type { Paginated } from "../../shared/types/pagination";
import { normalizePaginated, unwrapData } from "../../shared/api/normalize";
export function useQuestions(params: {
@@ -55,9 +56,10 @@ export function useQuestion(id?: string | number) {
export function useCreateAnswer(questionId: string | number) {
const qc = useQueryClient();
+ type CreateAnswerResult = unknown;
return useMutation({
mutationFn: async (content: string) => {
- const res = await http.post("/answers", {
+ const res = await http.post("/answers", {
content,
questionId: Number(questionId),
});
diff --git a/src/entities/question/types.ts b/src/entities/question/types.ts
index a965b34..c64495b 100644
--- a/src/entities/question/types.ts
+++ b/src/entities/question/types.ts
@@ -19,15 +19,3 @@ export type Question = {
user: User;
isResolved?: boolean;
};
-
-export type PaginatedMeta = {
- itemsPerPage: number;
- totalItems: number;
- currentPage: number;
- totalPages: number;
-};
-
-export type Paginated = {
- data: T[];
- meta: PaginatedMeta;
-};
diff --git a/src/entities/snippet/api.ts b/src/entities/snippet/api.ts
index 7a29a06..e83428b 100644
--- a/src/entities/snippet/api.ts
+++ b/src/entities/snippet/api.ts
@@ -5,7 +5,8 @@ import {
useQueryClient,
} from "@tanstack/react-query";
import { http } from "../../shared/api/http";
-import type { Paginated, Snippet, SnippetMark } from "./types";
+import type { Snippet, SnippetMark } from "./types";
+import type { Paginated } from "../../shared/types/pagination";
import { normalizePaginated } from "../../shared/api/normalize";
export function useSnippets(params: {
@@ -41,10 +42,12 @@ export function useSnippets(params: {
}
export function useSnippet(id?: number) {
- return useQuery({
- queryKey: ["snippets", id],
+ return useQuery({
+ queryKey: ["snippet", id],
queryFn: async () => (await http.get(`/snippets/${id}`)).data,
enabled: !!id,
+ retry: 1,
+ refetchOnWindowFocus: false,
});
}
@@ -57,7 +60,7 @@ export function useMarkSnippet(id: number) {
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["snippets"] });
- qc.invalidateQueries({ queryKey: ["snippets", id] });
+ qc.invalidateQueries({ queryKey: ["snippet", id] });
},
});
}
diff --git a/src/entities/snippet/types.ts b/src/entities/snippet/types.ts
index dc5e3a2..d1d8113 100644
--- a/src/entities/snippet/types.ts
+++ b/src/entities/snippet/types.ts
@@ -11,16 +11,4 @@ export type Snippet = {
commentsCount?: number;
};
-export type PaginatedMeta = {
- itemsPerPage: number;
- totalItems: number;
- currentPage: number;
- totalPages: number;
-};
-
-export type Paginated = {
- data: T[];
- meta: PaginatedMeta;
-};
-
export type SnippetMark = "like" | "dislike" | "none";
diff --git a/src/shared/types/pagination.ts b/src/shared/types/pagination.ts
new file mode 100644
index 0000000..c78d87d
--- /dev/null
+++ b/src/shared/types/pagination.ts
@@ -0,0 +1,11 @@
+export type PaginatedMeta = {
+ itemsPerPage: number;
+ totalItems: number;
+ currentPage: number;
+ totalPages: number;
+};
+
+export type Paginated = {
+ data: T[];
+ meta: PaginatedMeta;
+};
From 13a0e3fd8560ecab6494610df8e81bc03302c3cc Mon Sep 17 00:00:00 2001
From: kotru21
Date: Thu, 14 Aug 2025 20:05:11 +0300
Subject: [PATCH 09/40] Update types.ts
---
src/entities/snippet/types.ts | 1 -
1 file changed, 1 deletion(-)
diff --git a/src/entities/snippet/types.ts b/src/entities/snippet/types.ts
index d1d8113..5eddc04 100644
--- a/src/entities/snippet/types.ts
+++ b/src/entities/snippet/types.ts
@@ -5,7 +5,6 @@ export type Snippet = {
language: string;
code: string;
user: User;
- // Optional aggregate fields if backend provides them
likesCount?: number;
dislikesCount?: number;
commentsCount?: number;
From 4f830e1a6a022d259f72b5ffb5ea606f29f19b6a Mon Sep 17 00:00:00 2001
From: kotru21
Date: Fri, 15 Aug 2025 16:12:00 +0300
Subject: [PATCH 10/40] Add snippets to homepage and implement snippet details
page
This update introduces a toggle on the homepage to view either questions or code snippets, with unified card rendering via ItemCard. It adds a dedicated SnippetPage for viewing snippet details and comments, supports commenting on snippets, and refactors shared UI components for reuse. API and types for snippets are extended to handle comments, likes, and dislikes.
---
src/entities/question/api.ts | 4 +-
src/entities/snippet/api.ts | 69 +++++++-
src/entities/snippet/types.ts | 7 +
src/main.tsx | 2 +
src/pages/home/HomePage.tsx | 58 +++++--
src/pages/home/ui/HomePageView.tsx | 39 ++++-
src/pages/home/ui/ItemCard.tsx | 154 ++++++++++++++++++
src/pages/question/QuestionPage.tsx | 9 +-
src/pages/question/ui/QuestionDetailsView.tsx | 5 +-
src/pages/snippet/SnippetPage.tsx | 127 +++++++++++++++
src/shared/ui/BackLink.tsx | 20 +++
src/shared/ui/Clamp.tsx | 111 +++++++++++++
src/shared/ui/CodeBlock.tsx | 3 +-
13 files changed, 576 insertions(+), 32 deletions(-)
create mode 100644 src/pages/home/ui/ItemCard.tsx
create mode 100644 src/pages/snippet/SnippetPage.tsx
create mode 100644 src/shared/ui/BackLink.tsx
diff --git a/src/entities/question/api.ts b/src/entities/question/api.ts
index 5ec7b73..c209fa0 100644
--- a/src/entities/question/api.ts
+++ b/src/entities/question/api.ts
@@ -14,8 +14,9 @@ export function useQuestions(params: {
limit?: number;
search?: string;
sortBy?: string[];
+ enabled?: boolean;
}) {
- const { limit = 15, search = "", sortBy = [] } = params;
+ const { limit = 15, search = "", sortBy = [], enabled = true } = params;
return useInfiniteQuery({
queryKey: ["questions", { limit, search, sortBy }],
queryFn: async ({ pageParam = 1 }) => {
@@ -38,6 +39,7 @@ export function useQuestions(params: {
retry: 1,
refetchOnWindowFocus: false,
initialPageParam: 1,
+ enabled,
});
}
diff --git a/src/entities/snippet/api.ts b/src/entities/snippet/api.ts
index e83428b..2048844 100644
--- a/src/entities/snippet/api.ts
+++ b/src/entities/snippet/api.ts
@@ -5,17 +5,18 @@ import {
useQueryClient,
} from "@tanstack/react-query";
import { http } from "../../shared/api/http";
-import type { Snippet, SnippetMark } from "./types";
+import type { Snippet, SnippetMark, Comment as SnippetComment } from "./types";
import type { Paginated } from "../../shared/types/pagination";
-import { normalizePaginated } from "../../shared/api/normalize";
+import { normalizePaginated, unwrapData } from "../../shared/api/normalize";
export function useSnippets(params: {
page?: number;
limit?: number;
search?: string;
userId?: number;
+ enabled?: boolean;
}) {
- const { limit = 15, search = "", userId } = params;
+ const { limit = 15, search = "", userId, enabled = true } = params;
return useInfiniteQuery({
queryKey: ["snippets", { search, userId, limit }],
queryFn: async ({ pageParam = 1 }) => {
@@ -38,13 +39,73 @@ export function useSnippets(params: {
retry: 1,
refetchOnWindowFocus: false,
initialPageParam: 1,
+ enabled,
});
}
export function useSnippet(id?: number) {
return useQuery({
queryKey: ["snippet", id],
- queryFn: async () => (await http.get(`/snippets/${id}`)).data,
+ queryFn: async () => {
+ const res = await http.get(`/snippets/${id}`);
+ type ApiUser = {
+ id: number | string;
+ username: string;
+ role: "user" | "admin";
+ };
+ type ApiMark = {
+ id: number | string;
+ type: "like" | "dislike";
+ user: ApiUser;
+ };
+ type ApiSnippetDetail = {
+ id: number | string;
+ language?: string;
+ code?: string;
+ user?: ApiUser;
+ marks?: ApiMark[];
+ comments?: { id: number | string; content: string; user: ApiUser }[];
+ };
+ const raw = unwrapData(res.data as unknown);
+ const likesCount = Array.isArray(raw?.marks)
+ ? raw.marks.filter((m) => m?.type === "like").length
+ : undefined;
+ const dislikesCount = Array.isArray(raw?.marks)
+ ? raw.marks.filter((m) => m?.type === "dislike").length
+ : undefined;
+ const apiComments = Array.isArray(raw?.comments) ? raw!.comments : [];
+ const comments: SnippetComment[] | undefined = apiComments.length
+ ? apiComments.map((c) => ({
+ id: Number(c.id),
+ content: c.content,
+ user: {
+ id: Number(c.user.id),
+ username: c.user.username,
+ role: c.user.role,
+ },
+ }))
+ : undefined;
+ const commentsCount =
+ comments?.length ??
+ (Array.isArray(raw?.comments) ? raw!.comments!.length : undefined);
+ const normalized: Snippet = {
+ id: Number(raw?.id ?? 0),
+ language: String(raw?.language ?? ""),
+ code: String(raw?.code ?? ""),
+ user: raw?.user
+ ? {
+ id: Number(raw.user.id),
+ username: raw.user.username,
+ role: raw.user.role,
+ }
+ : { id: 0, username: "unknown", role: "user" },
+ likesCount,
+ dislikesCount,
+ commentsCount,
+ comments,
+ };
+ return normalized;
+ },
enabled: !!id,
retry: 1,
refetchOnWindowFocus: false,
diff --git a/src/entities/snippet/types.ts b/src/entities/snippet/types.ts
index 5eddc04..f8d9bc3 100644
--- a/src/entities/snippet/types.ts
+++ b/src/entities/snippet/types.ts
@@ -8,6 +8,13 @@ export type Snippet = {
likesCount?: number;
dislikesCount?: number;
commentsCount?: number;
+ comments?: Comment[];
};
export type SnippetMark = "like" | "dislike" | "none";
+
+export type Comment = {
+ id: number;
+ content: string;
+ user: User;
+};
diff --git a/src/main.tsx b/src/main.tsx
index 7a4f414..c54c814 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -4,6 +4,7 @@ import "./index.css";
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import HomePage from "./pages/home/HomePage";
import QuestionPage from "./pages/question/QuestionPage";
+import SnippetPage from "./pages/snippet/SnippetPage";
import LoginPage from "./pages/auth/LoginPage.tsx";
import RegisterPage from "./pages/auth/RegisterPage.tsx";
import { RequireGuest } from "./app/providers/route-guards";
@@ -19,6 +20,7 @@ const router = createBrowserRouter([
children: [
{ index: true, element: },
{ path: "questions/:id", element: },
+ { path: "snippets/:id", element: },
{
element: ,
children: [
diff --git a/src/pages/home/HomePage.tsx b/src/pages/home/HomePage.tsx
index bf2b132..2298d3a 100644
--- a/src/pages/home/HomePage.tsx
+++ b/src/pages/home/HomePage.tsx
@@ -1,16 +1,29 @@
-import { useEffect } from "react";
+import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useWindowVirtualizer } from "@tanstack/react-virtual";
import { useQuestions } from "../../entities/question/api";
import type { Question } from "../../entities/question/types";
-import { QuestionCard } from "./ui/QuestionCard";
+import { useSnippets } from "../../entities/snippet/api";
+import type { Snippet } from "../../entities/snippet/types";
+import { ItemCard } from "./ui/ItemCard.tsx";
import HomePageView from "./ui/HomePageView";
export default function HomePage() {
- const { data, fetchNextPage, hasNextPage, isFetchingNextPage, status } =
- useQuestions({});
+ const [mode, setMode] = useState<"questions" | "snippets">("questions");
+ // Queries
+ const q = useQuestions({ enabled: mode === "questions" });
+ const s = useSnippets({ enabled: mode === "snippets" });
- const items = (data?.pages.flatMap((p) => p.data) ?? []) as Question[];
+ const isQuestions = mode === "questions";
+ const fetchNextPage = isQuestions ? q.fetchNextPage : s.fetchNextPage;
+ const hasNextPage = isQuestions ? q.hasNextPage : s.hasNextPage;
+ const isFetchingNextPage = isQuestions
+ ? q.isFetchingNextPage
+ : s.isFetchingNextPage;
+ const status = isQuestions ? q.status : s.status;
+ const itemsQ = (q.data?.pages.flatMap((p) => p.data) ?? []) as Question[];
+ const itemsS = (s.data?.pages.flatMap((p) => p.data) ?? []) as Snippet[];
+ const items = (isQuestions ? itemsQ : itemsS) as (Question | Snippet)[];
const rowVirtualizer = useWindowVirtualizer({
count: items.length,
estimateSize: () => 220,
@@ -34,21 +47,31 @@ export default function HomePage() {
]);
const rows = virtualItems.map((v) => {
- const s = items[v.index];
+ const itemQ = isQuestions
+ ? (itemsQ[v.index] as Question | undefined)
+ : undefined;
+ const itemS = !isQuestions
+ ? (itemsS[v.index] as Snippet | undefined)
+ : undefined;
+ const key = ((itemQ?.id ?? itemS?.id) as React.Key) ?? v.index;
return {
- key: (s?.id as React.Key) ?? v.index,
+ key,
start: v.start,
index: v.index,
ref: rowVirtualizer.measureElement,
- content: s ? (
- navigate(`/questions/${s.id}`)}
+ content: isQuestions ? (
+ itemQ ? (
+ navigate(`/questions/${itemQ.id}`)}
+ />
+ ) : null
+ ) : itemS ? (
+ navigate(`/snippets/${itemS.id}`)}
/>
) : null,
};
@@ -56,6 +79,9 @@ export default function HomePage() {
return (
0}
containerHeight={rowVirtualizer.getTotalSize()}
diff --git a/src/pages/home/ui/HomePageView.tsx b/src/pages/home/ui/HomePageView.tsx
index 0f5e365..1f90c86 100644
--- a/src/pages/home/ui/HomePageView.tsx
+++ b/src/pages/home/ui/HomePageView.tsx
@@ -1,3 +1,4 @@
+import { memo } from "react";
import type { ReactNode, RefCallback } from "react";
export type HomeRow = {
@@ -15,22 +16,52 @@ export type HomePageViewProps = {
containerHeight: number;
rows: HomeRow[];
isFetchingNextPage?: boolean;
+ mode?: "questions" | "snippets";
+ onModeChange?: (m: "questions" | "snippets") => void;
};
-export default function HomePageView({
+function HomePageView({
title = "Вопросы",
status,
hasItems,
containerHeight,
rows,
isFetchingNextPage,
+ mode,
+ onModeChange,
}: HomePageViewProps) {
return (
-
{title}
+
+
{title}
+ {mode && onModeChange && (
+
+ onModeChange("questions")}>
+ Вопросы
+
+ onModeChange("snippets")}>
+ Сниппеты
+
+
+ )}
+
{status === "pending" &&
Загрузка...
}
{!hasItems && status === "success" && (
-
Нет вопросов.
+
Ничего не найдено.
)}
{rows.map((row) => (
@@ -48,3 +79,5 @@ export default function HomePageView({
);
}
+
+export default memo(HomePageView);
diff --git a/src/pages/home/ui/ItemCard.tsx b/src/pages/home/ui/ItemCard.tsx
new file mode 100644
index 0000000..d6a6def
--- /dev/null
+++ b/src/pages/home/ui/ItemCard.tsx
@@ -0,0 +1,154 @@
+import { memo } from "react";
+import type { Question } from "../../../entities/question/types";
+import type { Snippet } from "../../../entities/snippet/types";
+import { CodeBlock } from "../../../shared/ui/CodeBlock";
+import { Clamp } from "../../../shared/ui/Clamp";
+import { ExpandableText } from "../../../shared/ui/ExpandableText";
+import { useAuth } from "../../../app/providers/useAuth";
+import { useMarkSnippet } from "../../../entities/snippet/api";
+
+type BaseProps = {
+ onMoreClick?: () => void;
+ onCommentsClick?: () => void;
+};
+
+type QuestionProps = BaseProps & {
+ type: "question";
+ item: Question;
+};
+
+type SnippetProps = BaseProps & {
+ type: "snippet";
+ item: Snippet;
+};
+
+export type ItemCardProps = QuestionProps | SnippetProps;
+
+function QuestionView({
+ item,
+ onMoreClick,
+}: {
+ item: Question;
+ onMoreClick?: () => void;
+}) {
+ return (
+
+
+
+ {item.title}
+
+ @{item.user.username}
+
+
+ {item.attachedCode && (
+
+ )}
+
+ {Array.isArray(item.answers) && (
+ Answers: {item.answers.length}
+ )}
+
+ {onMoreClick && (
+
+
+ Answers →
+
+
+ )}
+
+ );
+}
+
+function SnippetView({
+ item,
+ onCommentsClick,
+}: {
+ item: Snippet;
+ onCommentsClick?: () => void;
+}) {
+ const { user: authUser } = useAuth();
+ const { mutate: mark, isPending } = useMarkSnippet(item.id);
+ const canInteract = !!authUser;
+ return (
+
+
+
+
+ {item.language}
+
+
+
@{item.user.username}
+
+
+
+
+
+ {typeof item.likesCount !== "undefined" && (
+ Likes: {item.likesCount}
+ )}
+ {typeof item.dislikesCount !== "undefined" && (
+ Dislikes: {item.dislikesCount}
+ )}
+ {typeof item.commentsCount !== "undefined" && (
+
+ Comments: {item.commentsCount}
+
+ )}
+
+
+ mark("like")}
+ className="px-2 py-1 border rounded disabled:opacity-50">
+ Like
+
+ mark("dislike")}
+ className="px-2 py-1 border rounded disabled:opacity-50">
+ Dislike
+
+ {onCommentsClick && (
+
+ Comments →
+
+ )}
+
+ {!canInteract && (
+
+ Войдите, чтобы ставить лайки/дизлайки и комментировать.
+
+ )}
+
+ );
+}
+
+export const ItemCard = memo(function ItemCard(props: ItemCardProps) {
+ if (props.type === "question") {
+ return
;
+ }
+ return (
+
+ );
+});
+
+export default ItemCard;
diff --git a/src/pages/question/QuestionPage.tsx b/src/pages/question/QuestionPage.tsx
index addef9b..8340fdb 100644
--- a/src/pages/question/QuestionPage.tsx
+++ b/src/pages/question/QuestionPage.tsx
@@ -1,4 +1,4 @@
-import { useParams, Link } from "react-router-dom";
+import { useParams } from "react-router-dom";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -7,6 +7,7 @@ import type { Question, Answer } from "../../entities/question/types";
import { useAuth } from "../../app/providers/useAuth";
import QuestionDetailsView from "./ui/QuestionDetailsView";
import AnswerItemView from "./ui/AnswerItemView";
+import { BackLink } from "../../shared/ui/BackLink";
const schema = z.object({
content: z.string().min(1, "Ответ не может быть пустым"),
@@ -36,11 +37,7 @@ export default function QuestionPage() {
return (
-
-
- ← Назад
-
-
+
);
}
+
+export default memo(QuestionDetailsView);
diff --git a/src/pages/snippet/SnippetPage.tsx b/src/pages/snippet/SnippetPage.tsx
new file mode 100644
index 0000000..ab1168e
--- /dev/null
+++ b/src/pages/snippet/SnippetPage.tsx
@@ -0,0 +1,127 @@
+import { useParams } from "react-router-dom";
+import { useSnippet } from "../../entities/snippet/api";
+import { CodeBlock } from "../../shared/ui/CodeBlock";
+import { useAuth } from "../../app/providers/useAuth";
+import { useState } from "react";
+import { http, toHttpError } from "../../shared/api/http";
+import { useQueryClient } from "@tanstack/react-query";
+import { BackLink } from "../../shared/ui/BackLink";
+
+export default function SnippetPage() {
+ const { id } = useParams();
+ const snippetId = Number(id);
+ const { data: snippet, status } = useSnippet(
+ Number.isFinite(snippetId) ? snippetId : undefined
+ );
+ const { user } = useAuth();
+ const [content, setContent] = useState("");
+ const [error, setError] = useState(null);
+ const [ok, setOk] = useState(null);
+ const [pending, setPending] = useState(false);
+ const qc = useQueryClient();
+
+ const submit = async () => {
+ setError(null);
+ setOk(null);
+ setPending(true);
+ try {
+ await http.post("/comments", { content, snippetId });
+ setContent("");
+ setOk("Комментарий отправлен");
+ // refresh snippet to show new comment
+ qc.invalidateQueries({ queryKey: ["snippet", snippetId] });
+ } catch (e) {
+ const err = toHttpError(e);
+ setError(err.message || "Не удалось отправить комментарий");
+ } finally {
+ setPending(false);
+ }
+ };
+
+ if (status === "pending") {
+ return Загрузка...
;
+ }
+
+ if (status === "error") {
+ return Не удалось загрузить сниппет.
;
+ }
+
+ if (!snippet) {
+ return Сниппет не найден.
;
+ }
+
+ return (
+
+
+
+
+
Сниппет #{snippet.id}
+
+ {snippet.language}
+
+
+
+ Автор: @{snippet.user?.username ?? "unknown"}
+
+
+
+ {typeof snippet.likesCount !== "undefined" && (
+ Likes: {snippet.likesCount}
+ )}
+ {typeof snippet.dislikesCount !== "undefined" && (
+ Dislikes: {snippet.dislikesCount}
+ )}
+ {typeof snippet.commentsCount !== "undefined" && (
+ Comments: {snippet.commentsCount}
+ )}
+
+
+
+ {Array.isArray(snippet.comments) && snippet.comments.length > 0 && (
+
+
Комментарии
+
+ {snippet.comments.map((c) => (
+
+
+ @{c.user.username}
+
+ {c.content}
+
+ ))}
+
+
+ )}
+
+ {user ? (
+
+
Оставить комментарий
+
setContent(e.target.value)}
+ rows={4}
+ className="w-full rounded border p-2 bg-white text-black dark:bg-neutral-800 dark:text-white"
+ placeholder="Ваш комментарий..."
+ />
+
+
+ Отправить
+
+ {error && {error} }
+ {ok && {ok} }
+
+
+ ) : (
+
+ Войдите, чтобы оставлять комментарии.
+
+ )}
+
+ );
+}
diff --git a/src/shared/ui/BackLink.tsx b/src/shared/ui/BackLink.tsx
new file mode 100644
index 0000000..d1a2158
--- /dev/null
+++ b/src/shared/ui/BackLink.tsx
@@ -0,0 +1,20 @@
+import { memo } from "react";
+import { Link } from "react-router-dom";
+
+type BackLinkProps = {
+ to?: string;
+ label?: string;
+ className?: string;
+};
+
+export const BackLink = memo(({ to = "/", label = "← Назад", className }: BackLinkProps) => {
+ return (
+
+
+ {label}
+
+
+ );
+});
+
+export default BackLink;
diff --git a/src/shared/ui/Clamp.tsx b/src/shared/ui/Clamp.tsx
index e69de29..282ffd5 100644
--- a/src/shared/ui/Clamp.tsx
+++ b/src/shared/ui/Clamp.tsx
@@ -0,0 +1,111 @@
+import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
+
+type ClampProps = {
+ children: React.ReactNode;
+ className?: string;
+ maxHeight?: number; // px when clamped
+ gradientHeight?: number; // px height of fade overlay
+ mode?: "navigate" | "toggle";
+ onMoreClick?: () => void; // used when mode="navigate"
+ moreLabel?: string; // default: "Показать весь"
+ lessLabel?: string; // default: "Показать меньше"
+};
+
+export const Clamp = memo(function Clamp({
+ children,
+ className,
+ maxHeight = 220,
+ gradientHeight = 56,
+ mode = "navigate",
+ onMoreClick,
+ moreLabel = "Показать весь",
+ lessLabel = "Показать меньше",
+}: ClampProps) {
+ const ref = useRef(null);
+ const [isOverflowing, setIsOverflowing] = useState(false);
+ const [expanded, setExpanded] = useState(false);
+
+ useEffect(() => {
+ const el = ref.current;
+ if (!el) return;
+ const check = () => {
+ const prevMaxHeight = el.style.maxHeight;
+ el.style.maxHeight = "none";
+ const overflowing = el.scrollHeight > maxHeight + 1;
+ el.style.maxHeight = prevMaxHeight;
+ setIsOverflowing(overflowing);
+ };
+ check();
+ const ro = new ResizeObserver(check);
+ ro.observe(el);
+ window.addEventListener("resize", check);
+ return () => {
+ ro.disconnect();
+ window.removeEventListener("resize", check);
+ };
+ }, [maxHeight, children]);
+
+ const containerStyles = useMemo(() => {
+ return expanded ? {} : { maxHeight: `${maxHeight}px` };
+ }, [expanded, maxHeight]);
+
+ const showGradient = isOverflowing && !expanded;
+
+ const handleMore = useCallback(() => {
+ if (mode === "navigate") {
+ onMoreClick?.();
+ return;
+ }
+ setExpanded(true);
+ }, [mode, onMoreClick]);
+
+ const handleLess = useCallback(() => setExpanded(false), []);
+
+ return (
+
+
+
+ {children}
+
+ {showGradient && (
+
+ )}
+
+
+ {isOverflowing && (
+
+ {mode === "toggle" ? (
+ expanded ? (
+
+ {lessLabel}
+
+ ) : (
+
+ {moreLabel}
+
+ )
+ ) : (
+
+ {moreLabel}
+
+ )}
+
+ )}
+
+ );
+});
+
+export default Clamp;
diff --git a/src/shared/ui/CodeBlock.tsx b/src/shared/ui/CodeBlock.tsx
index 032f153..d6c4b05 100644
--- a/src/shared/ui/CodeBlock.tsx
+++ b/src/shared/ui/CodeBlock.tsx
@@ -19,6 +19,7 @@ export const CodeBlock = memo(function CodeBlock({
className,
}: CodeBlockProps) {
const { theme } = useTheme();
+ const safeCode = code ?? "";
const prismTheme = useMemo(
() =>
theme === "dark"
@@ -32,7 +33,7 @@ export const CodeBlock = memo(function CodeBlock({
className={
"rounded border bg-gray-50 dark:bg-neutral-900 " + (className ?? "")
}>
-
+
{({
className: cls,
style,
From ca0778a2669e7eede093398cf87c058244a6e2c1 Mon Sep 17 00:00:00 2001
From: kotru21
Date: Fri, 15 Aug 2025 16:29:44 +0300
Subject: [PATCH 11/40] Add account page and refactor card views
Introduces an account page with profile and password management, user statistics, and supporting UI components. Refactors home and snippet card views into dedicated components for questions and snippets, improving code organization and reusability. Adds user API and types for account-related features, and updates routing to include the account page.
---
src/entities/user/api.ts | 147 ++++++++++++++++++++
src/entities/user/types.ts | 18 +++
src/main.tsx | 2 +
src/pages/account/AccountPage.tsx | 98 +++++++++++++
src/pages/account/ui/AccountInfoView.tsx | 38 +++++
src/pages/account/ui/AccountStatsView.tsx | 54 +++++++
src/pages/account/ui/PasswordFormView.tsx | 47 +++++++
src/pages/account/ui/ProfileFormView.tsx | 36 +++++
src/pages/home/ui/ItemCard.tsx | 121 ++++------------
src/pages/home/ui/QuestionCardView.tsx | 56 ++++++++
src/pages/home/ui/SnippetCardView.tsx | 95 +++++++++++++
src/pages/snippet/SnippetPage.tsx | 34 ++---
src/pages/snippet/ui/SnippetDetailsView.tsx | 52 +++++++
src/shared/ui/BackLink.tsx | 20 +--
14 files changed, 689 insertions(+), 129 deletions(-)
create mode 100644 src/entities/user/api.ts
create mode 100644 src/entities/user/types.ts
create mode 100644 src/pages/account/AccountPage.tsx
create mode 100644 src/pages/account/ui/AccountInfoView.tsx
create mode 100644 src/pages/account/ui/AccountStatsView.tsx
create mode 100644 src/pages/account/ui/PasswordFormView.tsx
create mode 100644 src/pages/account/ui/ProfileFormView.tsx
create mode 100644 src/pages/home/ui/QuestionCardView.tsx
create mode 100644 src/pages/home/ui/SnippetCardView.tsx
create mode 100644 src/pages/snippet/ui/SnippetDetailsView.tsx
diff --git a/src/entities/user/api.ts b/src/entities/user/api.ts
new file mode 100644
index 0000000..f82acb0
--- /dev/null
+++ b/src/entities/user/api.ts
@@ -0,0 +1,147 @@
+import {
+ useMutation,
+ useQuery,
+ useInfiniteQuery,
+ useQueryClient,
+} from "@tanstack/react-query";
+import { http } from "../../shared/api/http";
+import { normalizePaginated, unwrapData } from "../../shared/api/normalize";
+import type { Paginated } from "../../shared/types/pagination";
+import type { User, UserStatistic } from "./types";
+
+export function useUsers(params?: {
+ page?: number;
+ limit?: number;
+ sortBy?: string[];
+ search?: string;
+ searchBy?: string[];
+ enabled?: boolean;
+}) {
+ const { limit = 15, sortBy, search, searchBy, enabled = true } = params ?? {};
+ return useInfiniteQuery({
+ queryKey: ["users", { limit, sortBy, search, searchBy }],
+ queryFn: async ({ pageParam = 1 }) => {
+ const res = await http.get("/users", {
+ params: { page: pageParam, limit, sortBy, search, searchBy },
+ });
+ const raw = res.data as unknown;
+ const { data, meta } = normalizePaginated(raw, {
+ fallbackLimit: limit,
+ pageParam: Number(pageParam) || 1,
+ });
+ return { data, meta } as Paginated;
+ },
+ getNextPageParam: (lastPage) => {
+ const cp = Number(lastPage.meta?.currentPage ?? 1);
+ const tp = Number(lastPage.meta?.totalPages ?? 1);
+ const next = cp + 1;
+ return next <= tp ? next : undefined;
+ },
+ refetchOnWindowFocus: false,
+ initialPageParam: 1,
+ enabled,
+ });
+}
+
+export function useUser(id?: number) {
+ return useQuery({
+ queryKey: ["user", id],
+ queryFn: async () => {
+ const res = await http.get(`/users/${id}`);
+ const raw = unwrapData<{
+ id: number | string;
+ username: string;
+ role: "user" | "admin";
+ }>(res.data as unknown);
+ return {
+ id: Number(raw.id),
+ username: raw.username,
+ role: raw.role,
+ } satisfies User;
+ },
+ enabled: !!id,
+ refetchOnWindowFocus: false,
+ });
+}
+
+export function useUserStatistic(id?: number) {
+ return useQuery<{ user: User; statistic: UserStatistic }>({
+ queryKey: ["user-stat", id],
+ queryFn: async () => {
+ const res = await http.get(`/users/${id}/statistic`);
+ const raw = unwrapData<{
+ id: number | string;
+ username: string;
+ role: "user" | "admin";
+ statistic: UserStatistic;
+ }>(res.data as unknown);
+ const user: User = {
+ id: Number(raw.id),
+ username: raw.username,
+ role: raw.role,
+ };
+ return { user, statistic: raw.statistic };
+ },
+ enabled: !!id,
+ refetchOnWindowFocus: false,
+ });
+}
+
+export function useMe() {
+ return useQuery({
+ queryKey: ["me"],
+ queryFn: async () => {
+ const res = await http.get(`/me`);
+ const raw = unwrapData<{
+ id: number | string;
+ username: string;
+ role: "user" | "admin";
+ }>(res.data as unknown);
+ return {
+ id: Number(raw.id),
+ username: raw.username,
+ role: raw.role,
+ } satisfies User;
+ },
+ refetchOnWindowFocus: false,
+ });
+}
+
+export function useUpdateMe() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: async (payload: { username: string }) => {
+ const res = await http.patch(`/me`, payload);
+ return res.data;
+ },
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ["me"] });
+ },
+ });
+}
+
+export function useUpdatePassword() {
+ return useMutation({
+ mutationFn: async (payload: {
+ oldPassword: string;
+ newPassword: string;
+ }) => {
+ const res = await http.patch(`/me/password`, payload);
+ return res.data;
+ },
+ });
+}
+
+export function useDeleteMe() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: async () => {
+ const res = await http.delete(`/me`);
+ return res.data;
+ },
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ["me"] });
+ qc.invalidateQueries({ queryKey: ["users"] });
+ },
+ });
+}
diff --git a/src/entities/user/types.ts b/src/entities/user/types.ts
new file mode 100644
index 0000000..e999439
--- /dev/null
+++ b/src/entities/user/types.ts
@@ -0,0 +1,18 @@
+export type Role = "user" | "admin";
+
+export type User = {
+ id: number;
+ username: string;
+ role: Role;
+};
+
+export type UserStatistic = {
+ snippetsCount: number;
+ rating: number;
+ commentsCount: number;
+ likesCount: number;
+ dislikesCount: number;
+ questionsCount: number;
+ correctAnswersCount: number;
+ regularAnswersCount: number;
+};
diff --git a/src/main.tsx b/src/main.tsx
index c54c814..8e52d1d 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -5,6 +5,7 @@ import { createBrowserRouter, RouterProvider } from "react-router-dom";
import HomePage from "./pages/home/HomePage";
import QuestionPage from "./pages/question/QuestionPage";
import SnippetPage from "./pages/snippet/SnippetPage";
+import AccountPage from "./pages/account/AccountPage";
import LoginPage from "./pages/auth/LoginPage.tsx";
import RegisterPage from "./pages/auth/RegisterPage.tsx";
import { RequireGuest } from "./app/providers/route-guards";
@@ -21,6 +22,7 @@ const router = createBrowserRouter([
{ index: true, element: },
{ path: "questions/:id", element: },
{ path: "snippets/:id", element: },
+ { path: "account", element: },
{
element: ,
children: [
diff --git a/src/pages/account/AccountPage.tsx b/src/pages/account/AccountPage.tsx
new file mode 100644
index 0000000..94c2bf1
--- /dev/null
+++ b/src/pages/account/AccountPage.tsx
@@ -0,0 +1,98 @@
+import { useEffect, useState } from "react";
+import { useAuth } from "../../app/providers/useAuth";
+import { BackLink } from "../../shared/ui/BackLink";
+import {
+ useMe,
+ useUpdateMe,
+ useUpdatePassword,
+ useUserStatistic,
+} from "../../entities/user/api";
+import AccountInfoView from "./ui/AccountInfoView";
+import AccountStatsView from "./ui/AccountStatsView";
+import ProfileFormView from "./ui/ProfileFormView";
+import PasswordFormView from "./ui/PasswordFormView";
+
+export default function AccountPage() {
+ const { user: authUser } = useAuth();
+ const userId = authUser?.id;
+ const { data: me } = useMe();
+ const idForStat = me?.id ?? userId;
+ const { data: stat, status: statStatus } = useUserStatistic(idForStat);
+ const { mutateAsync: updateMe, isPending: isUpdatingMe } = useUpdateMe();
+ const { mutateAsync: updatePassword, isPending: isUpdatingPassword } =
+ useUpdatePassword();
+
+ const [username, setUsername] = useState(me?.username ?? "");
+ const [oldPassword, setOldPassword] = useState("");
+ const [newPassword, setNewPassword] = useState("");
+ const [message, setMessage] = useState(null);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (me?.username) setUsername(me.username);
+ }, [me?.username]);
+
+ const canEdit = !!authUser;
+
+ const onSaveProfile = async () => {
+ setMessage(null);
+ setError(null);
+ try {
+ await updateMe({ username });
+ setMessage("Профиль обновлён");
+ } catch {
+ setError("Не удалось обновить профиль");
+ }
+ };
+
+ const onChangePassword = async () => {
+ setMessage(null);
+ setError(null);
+ try {
+ await updatePassword({ oldPassword, newPassword });
+ setMessage("Пароль обновлён");
+ setOldPassword("");
+ setNewPassword("");
+ } catch {
+ setError("Не удалось обновить пароль");
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+ {canEdit && (
+
+ Настройки профиля
+
+
+
+
+ )}
+
+ {(message || error) && (
+
+ {message &&
{message}
}
+ {error &&
{error}
}
+
+ )}
+
+ );
+}
diff --git a/src/pages/account/ui/AccountInfoView.tsx b/src/pages/account/ui/AccountInfoView.tsx
new file mode 100644
index 0000000..d15a604
--- /dev/null
+++ b/src/pages/account/ui/AccountInfoView.tsx
@@ -0,0 +1,38 @@
+import { memo } from "react";
+
+export type AccountInfoViewProps = {
+ id?: number;
+ username?: string;
+ role?: string;
+};
+
+export const AccountInfoView = memo(function AccountInfoView({
+ id,
+ username,
+ role,
+}: AccountInfoViewProps) {
+ return (
+
+ Аккаунт
+ {typeof id === "number" && username && role ? (
+
+
+ Имя пользователя: @{username}
+
+
+ Роль: {role}
+
+
+ ID: {id}
+
+
+ ) : (
+
+ Не удалось загрузить данные пользователя.
+
+ )}
+
+ );
+});
+
+export default AccountInfoView;
diff --git a/src/pages/account/ui/AccountStatsView.tsx b/src/pages/account/ui/AccountStatsView.tsx
new file mode 100644
index 0000000..f41a118
--- /dev/null
+++ b/src/pages/account/ui/AccountStatsView.tsx
@@ -0,0 +1,54 @@
+import { memo } from "react";
+import type { UserStatistic } from "../../../entities/user/types";
+
+export type AccountStatsViewProps = {
+ statistic?: UserStatistic;
+ status?: "idle" | "pending" | "success" | "error" | string;
+};
+
+export const AccountStatsView = memo(function AccountStatsView({
+ statistic,
+ status,
+}: AccountStatsViewProps) {
+ return (
+
+ Статистика
+ {status === "pending" && (
+ Загрузка статистики...
+ )}
+ {status === "error" && (
+ Не удалось загрузить статистику.
+ )}
+ {statistic ? (
+
+
+ Сниппеты: {statistic.snippetsCount}
+
+
+ Рейтинг: {Math.round(statistic.rating)}
+
+
+ Комментарии: {statistic.commentsCount}
+
+ Лайки: {statistic.likesCount}
+
+ Дизлайки: {statistic.dislikesCount}
+
+
+ Вопросы: {statistic.questionsCount}
+
+
+ Верных ответов: {statistic.correctAnswersCount}
+
+
+ Обычных ответов: {statistic.regularAnswersCount}
+
+
+ ) : (
+ Нет данных статистики.
+ )}
+
+ );
+});
+
+export default AccountStatsView;
diff --git a/src/pages/account/ui/PasswordFormView.tsx b/src/pages/account/ui/PasswordFormView.tsx
new file mode 100644
index 0000000..de39f26
--- /dev/null
+++ b/src/pages/account/ui/PasswordFormView.tsx
@@ -0,0 +1,47 @@
+import { memo } from "react";
+
+export type PasswordFormViewProps = {
+ oldPassword: string;
+ newPassword: string;
+ onOldPasswordChange: (v: string) => void;
+ onNewPasswordChange: (v: string) => void;
+ onSubmit: () => void;
+ isPending?: boolean;
+};
+
+export const PasswordFormView = memo(function PasswordFormView({
+ oldPassword,
+ newPassword,
+ onOldPasswordChange,
+ onNewPasswordChange,
+ onSubmit,
+ isPending,
+}: PasswordFormViewProps) {
+ return (
+
+ Старый пароль
+ onOldPasswordChange(e.target.value)}
+ />
+ Новый пароль
+ onNewPasswordChange(e.target.value)}
+ />
+
+ Обновить пароль
+
+
+ );
+});
+
+export default PasswordFormView;
diff --git a/src/pages/account/ui/ProfileFormView.tsx b/src/pages/account/ui/ProfileFormView.tsx
new file mode 100644
index 0000000..53ef31e
--- /dev/null
+++ b/src/pages/account/ui/ProfileFormView.tsx
@@ -0,0 +1,36 @@
+import { memo } from "react";
+
+export type ProfileFormViewProps = {
+ username: string;
+ onUsernameChange: (v: string) => void;
+ onSave: () => void;
+ isPending?: boolean;
+};
+
+export const ProfileFormView = memo(function ProfileFormView({
+ username,
+ onUsernameChange,
+ onSave,
+ isPending,
+}: ProfileFormViewProps) {
+ return (
+
+ Имя пользователя
+ onUsernameChange(e.target.value)}
+ placeholder="username"
+ />
+
+ Сохранить профиль
+
+
+ );
+});
+
+export default ProfileFormView;
diff --git a/src/pages/home/ui/ItemCard.tsx b/src/pages/home/ui/ItemCard.tsx
index d6a6def..2f04749 100644
--- a/src/pages/home/ui/ItemCard.tsx
+++ b/src/pages/home/ui/ItemCard.tsx
@@ -1,11 +1,10 @@
import { memo } from "react";
import type { Question } from "../../../entities/question/types";
import type { Snippet } from "../../../entities/snippet/types";
-import { CodeBlock } from "../../../shared/ui/CodeBlock";
-import { Clamp } from "../../../shared/ui/Clamp";
-import { ExpandableText } from "../../../shared/ui/ExpandableText";
import { useAuth } from "../../../app/providers/useAuth";
import { useMarkSnippet } from "../../../entities/snippet/api";
+import { QuestionCardView } from "./QuestionCardView";
+import { SnippetCardView } from "./SnippetCardView";
type BaseProps = {
onMoreClick?: () => void;
@@ -32,38 +31,16 @@ function QuestionView({
onMoreClick?: () => void;
}) {
return (
-
-
-
- {item.title}
-
- @{item.user.username}
-
-
- {item.attachedCode && (
-
- )}
-
- {Array.isArray(item.answers) && (
- Answers: {item.answers.length}
- )}
-
- {onMoreClick && (
-
-
- Answers →
-
-
- )}
-
+
);
}
@@ -78,67 +55,19 @@ function SnippetView({
const { mutate: mark, isPending } = useMarkSnippet(item.id);
const canInteract = !!authUser;
return (
-
-
-
-
- {item.language}
-
-
-
@{item.user.username}
-
-
-
-
-
- {typeof item.likesCount !== "undefined" && (
- Likes: {item.likesCount}
- )}
- {typeof item.dislikesCount !== "undefined" && (
- Dislikes: {item.dislikesCount}
- )}
- {typeof item.commentsCount !== "undefined" && (
-
- Comments: {item.commentsCount}
-
- )}
-
-
- mark("like")}
- className="px-2 py-1 border rounded disabled:opacity-50">
- Like
-
- mark("dislike")}
- className="px-2 py-1 border rounded disabled:opacity-50">
- Dislike
-
- {onCommentsClick && (
-
- Comments →
-
- )}
-
- {!canInteract && (
-
- Войдите, чтобы ставить лайки/дизлайки и комментировать.
-
- )}
-
+ mark("like")}
+ onDislike={() => mark("dislike")}
+ canInteract={canInteract}
+ isPending={isPending}
+ />
);
}
diff --git a/src/pages/home/ui/QuestionCardView.tsx b/src/pages/home/ui/QuestionCardView.tsx
new file mode 100644
index 0000000..07a2365
--- /dev/null
+++ b/src/pages/home/ui/QuestionCardView.tsx
@@ -0,0 +1,56 @@
+import { memo } from "react";
+import { ExpandableText } from "../../../shared/ui/ExpandableText";
+import { CodeBlock } from "../../../shared/ui/CodeBlock";
+
+export type QuestionCardViewProps = {
+ title: string;
+ userName: string;
+ description: string;
+ attachedCode?: string;
+ answersCount?: number;
+ onMoreClick?: () => void;
+};
+
+export const QuestionCardView = memo(function QuestionCardView({
+ title,
+ userName,
+ description,
+ attachedCode,
+ answersCount,
+ onMoreClick,
+}: QuestionCardViewProps) {
+ return (
+
+
+
+ {title}
+
+ @{userName}
+
+
+ {attachedCode && }
+
+ {typeof answersCount === "number" && (
+ Answers: {answersCount}
+ )}
+
+ {onMoreClick && (
+
+
+ Answers →
+
+
+ )}
+
+ );
+});
+
+export default QuestionCardView;
diff --git a/src/pages/home/ui/SnippetCardView.tsx b/src/pages/home/ui/SnippetCardView.tsx
new file mode 100644
index 0000000..4a81d53
--- /dev/null
+++ b/src/pages/home/ui/SnippetCardView.tsx
@@ -0,0 +1,95 @@
+import { memo } from "react";
+import { Clamp } from "../../../shared/ui/Clamp";
+import { CodeBlock } from "../../../shared/ui/CodeBlock";
+
+export type SnippetCardViewProps = {
+ language: string;
+ userName: string;
+ code: string;
+ likesCount?: number;
+ dislikesCount?: number;
+ commentsCount?: number;
+ onCommentsClick?: () => void;
+ onLike?: () => void;
+ onDislike?: () => void;
+ canInteract?: boolean;
+ isPending?: boolean;
+};
+
+export const SnippetCardView = memo(function SnippetCardView({
+ language,
+ userName,
+ code,
+ likesCount,
+ dislikesCount,
+ commentsCount,
+ onCommentsClick,
+ onLike,
+ onDislike,
+ canInteract = false,
+ isPending = false,
+}: SnippetCardViewProps) {
+ return (
+
+
+
+
+ {language}
+
+
+
@{userName}
+
+
+
+
+
+ {typeof likesCount !== "undefined" && Likes: {likesCount} }
+ {typeof dislikesCount !== "undefined" && (
+ Dislikes: {dislikesCount}
+ )}
+ {typeof commentsCount !== "undefined" && (
+
+ Comments: {commentsCount}
+
+ )}
+
+
+
+ Like
+
+
+ Dislike
+
+ {onCommentsClick && (
+
+ Comments →
+
+ )}
+
+ {!canInteract && (
+
+ Войдите, чтобы ставить лайки/дизлайки и комментировать.
+
+ )}
+
+ );
+});
+
+export default SnippetCardView;
diff --git a/src/pages/snippet/SnippetPage.tsx b/src/pages/snippet/SnippetPage.tsx
index ab1168e..b8b5353 100644
--- a/src/pages/snippet/SnippetPage.tsx
+++ b/src/pages/snippet/SnippetPage.tsx
@@ -1,11 +1,11 @@
import { useParams } from "react-router-dom";
import { useSnippet } from "../../entities/snippet/api";
-import { CodeBlock } from "../../shared/ui/CodeBlock";
import { useAuth } from "../../app/providers/useAuth";
import { useState } from "react";
import { http, toHttpError } from "../../shared/api/http";
import { useQueryClient } from "@tanstack/react-query";
import { BackLink } from "../../shared/ui/BackLink";
+import SnippetDetailsView from "./ui/SnippetDetailsView";
export default function SnippetPage() {
const { id } = useParams();
@@ -53,29 +53,15 @@ export default function SnippetPage() {
return (
-
-
-
Сниппет #{snippet.id}
-
- {snippet.language}
-
-
-
- Автор: @{snippet.user?.username ?? "unknown"}
-
-
-
- {typeof snippet.likesCount !== "undefined" && (
- Likes: {snippet.likesCount}
- )}
- {typeof snippet.dislikesCount !== "undefined" && (
- Dislikes: {snippet.dislikesCount}
- )}
- {typeof snippet.commentsCount !== "undefined" && (
- Comments: {snippet.commentsCount}
- )}
-
-
+
{Array.isArray(snippet.comments) && snippet.comments.length > 0 && (
diff --git a/src/pages/snippet/ui/SnippetDetailsView.tsx b/src/pages/snippet/ui/SnippetDetailsView.tsx
new file mode 100644
index 0000000..1fe6e5a
--- /dev/null
+++ b/src/pages/snippet/ui/SnippetDetailsView.tsx
@@ -0,0 +1,52 @@
+import { memo } from "react";
+import { CodeBlock } from "../../../shared/ui/CodeBlock";
+
+export type SnippetDetailsViewProps = {
+ id: number;
+ language?: string;
+ authorName?: string;
+ code: string;
+ likesCount?: number;
+ dislikesCount?: number;
+ commentsCount?: number;
+};
+
+export const SnippetDetailsView = memo(function SnippetDetailsView({
+ id,
+ language,
+ authorName,
+ code,
+ likesCount,
+ dislikesCount,
+ commentsCount,
+}: SnippetDetailsViewProps) {
+ return (
+
+
+
Сниппет #{id}
+ {language && (
+
+ {language}
+
+ )}
+
+ {authorName && (
+
+ Автор: @{authorName}
+
+ )}
+
+
+ {typeof likesCount !== "undefined" && Likes: {likesCount} }
+ {typeof dislikesCount !== "undefined" && (
+ Dislikes: {dislikesCount}
+ )}
+ {typeof commentsCount !== "undefined" && (
+ Comments: {commentsCount}
+ )}
+
+
+ );
+});
+
+export default SnippetDetailsView;
diff --git a/src/shared/ui/BackLink.tsx b/src/shared/ui/BackLink.tsx
index d1a2158..9a86424 100644
--- a/src/shared/ui/BackLink.tsx
+++ b/src/shared/ui/BackLink.tsx
@@ -7,14 +7,16 @@ type BackLinkProps = {
className?: string;
};
-export const BackLink = memo(({ to = "/", label = "← Назад", className }: BackLinkProps) => {
- return (
-
-
- {label}
-
-
- );
-});
+export const BackLink = memo(
+ ({ to = "/", label = "← Назад", className }: BackLinkProps) => {
+ return (
+
+
+ {label}
+
+
+ );
+ }
+);
export default BackLink;
From bb37b36cfed6a181c416716582b093cdd78f3037 Mon Sep 17 00:00:00 2001
From: kotru21
Date: Fri, 15 Aug 2025 16:37:22 +0300
Subject: [PATCH 12/40] Preserve mode in navigation and back links
Syncs the selected mode ('questions' or 'snippets') with the URL search params on HomePage and passes mode state during navigation. BackLink now restores the mode when returning to Home, improving navigation consistency.
---
src/pages/home/HomePage.tsx | 34 ++++++++++++++++++++++++++++++----
src/shared/ui/BackLink.tsx | 17 +++++++++++++++--
2 files changed, 45 insertions(+), 6 deletions(-)
diff --git a/src/pages/home/HomePage.tsx b/src/pages/home/HomePage.tsx
index 2298d3a..4f13535 100644
--- a/src/pages/home/HomePage.tsx
+++ b/src/pages/home/HomePage.tsx
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
-import { useNavigate } from "react-router-dom";
+import { useNavigate, useSearchParams } from "react-router-dom";
import { useWindowVirtualizer } from "@tanstack/react-virtual";
import { useQuestions } from "../../entities/question/api";
import type { Question } from "../../entities/question/types";
@@ -9,7 +9,11 @@ import { ItemCard } from "./ui/ItemCard.tsx";
import HomePageView from "./ui/HomePageView";
export default function HomePage() {
- const [mode, setMode] = useState<"questions" | "snippets">("questions");
+ const [searchParams, setSearchParams] = useSearchParams();
+ const initialMode = (
+ searchParams.get("mode") === "snippets" ? "snippets" : "questions"
+ ) as "questions" | "snippets";
+ const [mode, setMode] = useState<"questions" | "snippets">(initialMode);
// Queries
const q = useQuestions({ enabled: mode === "questions" });
const s = useSnippets({ enabled: mode === "snippets" });
@@ -32,6 +36,20 @@ export default function HomePage() {
const virtualItems = rowVirtualizer.getVirtualItems();
const navigate = useNavigate();
+ useEffect(() => {
+ // keep URL in sync with selected mode
+ const current = searchParams.get("mode");
+ if (current !== mode) {
+ setSearchParams(
+ (prev) => {
+ const next = new URLSearchParams(prev);
+ next.set("mode", mode);
+ return next;
+ },
+ { replace: true }
+ );
+ }
+ }, [mode, searchParams, setSearchParams]);
useEffect(() => {
if (!hasNextPage || isFetchingNextPage || items.length === 0) return;
const last = virtualItems[virtualItems.length - 1];
@@ -64,14 +82,22 @@ export default function HomePage() {
navigate(`/questions/${itemQ.id}`)}
+ onMoreClick={() =>
+ navigate(`/questions/${itemQ.id}`, {
+ state: { fromMode: "questions" },
+ })
+ }
/>
) : null
) : itemS ? (
navigate(`/snippets/${itemS.id}`)}
+ onCommentsClick={() =>
+ navigate(`/snippets/${itemS.id}`, {
+ state: { fromMode: "snippets" },
+ })
+ }
/>
) : null,
};
diff --git a/src/shared/ui/BackLink.tsx b/src/shared/ui/BackLink.tsx
index 9a86424..2fa8a99 100644
--- a/src/shared/ui/BackLink.tsx
+++ b/src/shared/ui/BackLink.tsx
@@ -1,5 +1,5 @@
import { memo } from "react";
-import { Link } from "react-router-dom";
+import { Link, useLocation } from "react-router-dom";
type BackLinkProps = {
to?: string;
@@ -9,9 +9,22 @@ type BackLinkProps = {
export const BackLink = memo(
({ to = "/", label = "← Назад", className }: BackLinkProps) => {
+ const location = useLocation() as { state?: unknown };
+ // Try to preserve the 'mode' when navigating back to Home
+ const stateObj =
+ location.state && typeof location.state === "object"
+ ? (location.state as Record)
+ : undefined;
+ const rawFromMode = stateObj?.["fromMode"];
+ const fromMode = typeof rawFromMode === "string" ? rawFromMode : undefined;
+ const search = new URLSearchParams();
+ if (fromMode === "snippets" || fromMode === "questions")
+ search.set("mode", fromMode);
+ const toWithMode =
+ to === "/" && search.toString() ? `${to}?${search.toString()}` : to;
return (
-
+
{label}
From 817be37c6e5c813e89b45bf21ad41b4b172b6bd5 Mon Sep 17 00:00:00 2001
From: kotru21
Date: Fri, 15 Aug 2025 16:56:14 +0300
Subject: [PATCH 13/40] Add Skeleton loading component and integrate in UI
Introduced a reusable Skeleton component for loading states and replaced text-based loading indicators with Skeleton placeholders across account, home, question, and snippet pages. This improves user experience by providing visual feedback during data fetching.
---
src/pages/account/AccountPage.tsx | 11 ++++-
src/pages/account/ui/AccountInfoView.tsx | 12 ++++-
src/pages/account/ui/AccountStatsView.tsx | 9 +++-
src/pages/account/ui/PasswordFormView.tsx | 55 ++++++++++++++---------
src/pages/account/ui/ProfileFormView.tsx | 39 ++++++++++------
src/pages/home/ui/HomePageView.tsx | 17 ++++++-
src/pages/question/QuestionPage.tsx | 13 +++++-
src/pages/snippet/SnippetPage.tsx | 31 ++++++++++++-
src/shared/ui/Skeleton.tsx | 27 +++++++++++
9 files changed, 173 insertions(+), 41 deletions(-)
create mode 100644 src/shared/ui/Skeleton.tsx
diff --git a/src/pages/account/AccountPage.tsx b/src/pages/account/AccountPage.tsx
index 94c2bf1..ccc4816 100644
--- a/src/pages/account/AccountPage.tsx
+++ b/src/pages/account/AccountPage.tsx
@@ -15,7 +15,7 @@ import PasswordFormView from "./ui/PasswordFormView";
export default function AccountPage() {
const { user: authUser } = useAuth();
const userId = authUser?.id;
- const { data: me } = useMe();
+ const { data: me, status: meStatus } = useMe();
const idForStat = me?.id ?? userId;
const { data: stat, status: statStatus } = useUserStatistic(idForStat);
const { mutateAsync: updateMe, isPending: isUpdatingMe } = useUpdateMe();
@@ -62,7 +62,12 @@ export default function AccountPage() {
-
+
@@ -74,6 +79,7 @@ export default function AccountPage() {
onUsernameChange={setUsername}
onSave={onSaveProfile}
isPending={isUpdatingMe}
+ loading={meStatus === "pending"}
/>
)}
diff --git a/src/pages/account/ui/AccountInfoView.tsx b/src/pages/account/ui/AccountInfoView.tsx
index d15a604..ba55315 100644
--- a/src/pages/account/ui/AccountInfoView.tsx
+++ b/src/pages/account/ui/AccountInfoView.tsx
@@ -4,17 +4,27 @@ export type AccountInfoViewProps = {
id?: number;
username?: string;
role?: string;
+ loading?: boolean;
};
+import { Skeleton } from "../../../shared/ui/Skeleton";
+
export const AccountInfoView = memo(function AccountInfoView({
id,
username,
role,
+ loading,
}: AccountInfoViewProps) {
return (
Аккаунт
- {typeof id === "number" && username && role ? (
+ {loading ? (
+
+
+
+
+
+ ) : typeof id === "number" && username && role ? (
Имя пользователя:
@{username}
diff --git a/src/pages/account/ui/AccountStatsView.tsx b/src/pages/account/ui/AccountStatsView.tsx
index f41a118..2801cd7 100644
--- a/src/pages/account/ui/AccountStatsView.tsx
+++ b/src/pages/account/ui/AccountStatsView.tsx
@@ -1,4 +1,5 @@
import { memo } from "react";
+import { Skeleton } from "../../../shared/ui/Skeleton";
import type { UserStatistic } from "../../../entities/user/types";
export type AccountStatsViewProps = {
@@ -14,7 +15,13 @@ export const AccountStatsView = memo(function AccountStatsView({
Статистика
{status === "pending" && (
- Загрузка статистики...
+
+ {Array.from({ length: 8 }).map((_, i) => (
+
+
+
+ ))}
+
)}
{status === "error" && (
Не удалось загрузить статистику.
diff --git a/src/pages/account/ui/PasswordFormView.tsx b/src/pages/account/ui/PasswordFormView.tsx
index de39f26..5f492fb 100644
--- a/src/pages/account/ui/PasswordFormView.tsx
+++ b/src/pages/account/ui/PasswordFormView.tsx
@@ -7,8 +7,11 @@ export type PasswordFormViewProps = {
onNewPasswordChange: (v: string) => void;
onSubmit: () => void;
isPending?: boolean;
+ loading?: boolean;
};
+import { Skeleton } from "../../../shared/ui/Skeleton";
+
export const PasswordFormView = memo(function PasswordFormView({
oldPassword,
newPassword,
@@ -16,30 +19,42 @@ export const PasswordFormView = memo(function PasswordFormView({
onNewPasswordChange,
onSubmit,
isPending,
+ loading,
}: PasswordFormViewProps) {
return (
Старый пароль
- onOldPasswordChange(e.target.value)}
- />
- Новый пароль
- onNewPasswordChange(e.target.value)}
- />
-
- Обновить пароль
-
+ {loading ? (
+ <>
+
+ Новый пароль
+
+
+ >
+ ) : (
+ <>
+ onOldPasswordChange(e.target.value)}
+ />
+ Новый пароль
+ onNewPasswordChange(e.target.value)}
+ />
+
+ Обновить пароль
+
+ >
+ )}
);
});
diff --git a/src/pages/account/ui/ProfileFormView.tsx b/src/pages/account/ui/ProfileFormView.tsx
index 53ef31e..0bcf606 100644
--- a/src/pages/account/ui/ProfileFormView.tsx
+++ b/src/pages/account/ui/ProfileFormView.tsx
@@ -5,30 +5,43 @@ export type ProfileFormViewProps = {
onUsernameChange: (v: string) => void;
onSave: () => void;
isPending?: boolean;
+ loading?: boolean;
};
+import { Skeleton } from "../../../shared/ui/Skeleton";
+
export const ProfileFormView = memo(function ProfileFormView({
username,
onUsernameChange,
onSave,
isPending,
+ loading,
}: ProfileFormViewProps) {
return (
Имя пользователя
- onUsernameChange(e.target.value)}
- placeholder="username"
- />
-
- Сохранить профиль
-
+ {loading ? (
+ <>
+
+
+ >
+ ) : (
+ <>
+ onUsernameChange(e.target.value)}
+ placeholder="username"
+ />
+
+ Сохранить профиль
+
+ >
+ )}
);
});
diff --git a/src/pages/home/ui/HomePageView.tsx b/src/pages/home/ui/HomePageView.tsx
index 1f90c86..fe5ef4c 100644
--- a/src/pages/home/ui/HomePageView.tsx
+++ b/src/pages/home/ui/HomePageView.tsx
@@ -1,4 +1,5 @@
import { memo } from "react";
+import { Skeleton } from "../../../shared/ui/Skeleton";
import type { ReactNode, RefCallback } from "react";
export type HomeRow = {
@@ -59,7 +60,15 @@ function HomePageView({
)}
- {status === "pending" && Загрузка...
}
+ {status === "pending" && (
+
+ {[...Array(5)].map((_, i) => (
+
+
+
+ ))}
+
+ )}
{!hasItems && status === "success" && (
Ничего не найдено.
)}
@@ -75,7 +84,11 @@ function HomePageView({
))}
- {isFetchingNextPage &&
Загрузка...
}
+ {isFetchingNextPage && (
+
+
+
+ )}
);
}
diff --git a/src/pages/question/QuestionPage.tsx b/src/pages/question/QuestionPage.tsx
index 8340fdb..41bc9eb 100644
--- a/src/pages/question/QuestionPage.tsx
+++ b/src/pages/question/QuestionPage.tsx
@@ -8,6 +8,7 @@ import { useAuth } from "../../app/providers/useAuth";
import QuestionDetailsView from "./ui/QuestionDetailsView";
import AnswerItemView from "./ui/AnswerItemView";
import { BackLink } from "../../shared/ui/BackLink";
+import { Skeleton } from "../../shared/ui/Skeleton";
const schema = z.object({
content: z.string().min(1, "Ответ не может быть пустым"),
@@ -32,7 +33,17 @@ export default function QuestionPage() {
reset({ content: "" });
};
- if (status === "pending") return Загрузка...
;
+ if (status === "pending")
+ return (
+
+ );
if (!question) return Вопрос не найден
;
return (
diff --git a/src/pages/snippet/SnippetPage.tsx b/src/pages/snippet/SnippetPage.tsx
index b8b5353..e6d7b1f 100644
--- a/src/pages/snippet/SnippetPage.tsx
+++ b/src/pages/snippet/SnippetPage.tsx
@@ -6,6 +6,7 @@ import { http, toHttpError } from "../../shared/api/http";
import { useQueryClient } from "@tanstack/react-query";
import { BackLink } from "../../shared/ui/BackLink";
import SnippetDetailsView from "./ui/SnippetDetailsView";
+import { Skeleton } from "../../shared/ui/Skeleton";
export default function SnippetPage() {
const { id } = useParams();
@@ -39,7 +40,35 @@ export default function SnippetPage() {
};
if (status === "pending") {
- return Загрузка...
;
+ return (
+
+
+
+
+
+ {[...Array(2)].map((_, i) => (
+
+
+
+
+ ))}
+
+
+ );
}
if (status === "error") {
diff --git a/src/shared/ui/Skeleton.tsx b/src/shared/ui/Skeleton.tsx
new file mode 100644
index 0000000..9f61cf7
--- /dev/null
+++ b/src/shared/ui/Skeleton.tsx
@@ -0,0 +1,27 @@
+import { memo } from "react";
+
+export type SkeletonProps = {
+ className?: string;
+ width?: number | string;
+ height?: number | string;
+ rounded?: boolean | string;
+};
+
+export const Skeleton = memo(function Skeleton({
+ className = "",
+ width = "100%",
+ height = 16,
+ rounded = true,
+}: SkeletonProps) {
+ const style: React.CSSProperties = { width, height };
+ const roundedClass =
+ typeof rounded === "string" ? rounded : rounded ? "rounded" : "";
+ return (
+
+ );
+});
+
+export default Skeleton;
From 6ab2de52987642ccd413e81b123f08db448a8036 Mon Sep 17 00:00:00 2001
From: kotru21
Date: Fri, 15 Aug 2025 17:06:44 +0300
Subject: [PATCH 14/40] Update answer and comment form layout on pages
Moved the answer form on QuestionPage and the comment form on SnippetPage directly below the details section for improved UX. Adjusted skeleton loaders to reflect the new layout, and reordered the rendering of answers and comments accordingly.
---
src/pages/question/QuestionPage.tsx | 40 ++++++++++++++++--------
src/pages/snippet/SnippetPage.tsx | 47 +++++++++++++++++------------
2 files changed, 55 insertions(+), 32 deletions(-)
diff --git a/src/pages/question/QuestionPage.tsx b/src/pages/question/QuestionPage.tsx
index 41bc9eb..6723e9e 100644
--- a/src/pages/question/QuestionPage.tsx
+++ b/src/pages/question/QuestionPage.tsx
@@ -38,10 +38,24 @@ export default function QuestionPage() {
+ {/* Заголовок и детали вопроса */}
+ {/* Форма ответа (новый лейаут: сразу под деталями) */}
+
+
+
+
+
+ {/* Секция ответов */}
+
+
+ {[...Array(3)].map((_, i) => (
+
+ ))}
+
);
if (!question) return Вопрос не найден
;
@@ -54,19 +68,6 @@ export default function QuestionPage() {
description={(question as Question).description}
attachedCode={(question as Question).attachedCode}
/>
-
-
Ответы
-
- {Array.isArray((question as Question).answers) &&
- (question as Question).answers!.map((a: Answer) => (
-
- ))}
-
-
{user ? (
)}
+
+
Ответы
+
+ {Array.isArray((question as Question).answers) &&
+ (question as Question).answers!.map((a: Answer) => (
+
+ ))}
+
+
);
}
diff --git a/src/pages/snippet/SnippetPage.tsx b/src/pages/snippet/SnippetPage.tsx
index e6d7b1f..4ea879e 100644
--- a/src/pages/snippet/SnippetPage.tsx
+++ b/src/pages/snippet/SnippetPage.tsx
@@ -56,6 +56,15 @@ export default function SnippetPage() {
+ {/* Форма комментария (новый лейаут: сразу под деталями) */}
+
{[...Array(2)].map((_, i) => (
@@ -92,26 +101,8 @@ export default function SnippetPage() {
commentsCount={snippet.commentsCount}
/>
- {Array.isArray(snippet.comments) && snippet.comments.length > 0 && (
-
-
Комментарии
-
- {snippet.comments.map((c) => (
-
-
- @{c.user.username}
-
- {c.content}
-
- ))}
-
-
- )}
-
{user ? (
-
+
Оставить комментарий
)}
+
+ {Array.isArray(snippet.comments) && snippet.comments.length > 0 && (
+
+
Комментарии
+
+ {snippet.comments.map((c) => (
+
+
+ @{c.user.username}
+
+ {c.content}
+
+ ))}
+
+
+ )}
);
}
From 7994e80e58ca417ecb6937007d743d14a6e11dcc Mon Sep 17 00:00:00 2001
From: kotru21
Date: Fri, 15 Aug 2025 17:22:12 +0300
Subject: [PATCH 15/40] Make header sticky and adjust padding on scroll
Header is now sticky at the top of the page and its vertical padding changes based on scroll position for improved UX. Added useEffect and useState to track scroll position and update header style accordingly.
---
src/app/ui/HeaderView.tsx | 18 +++++++++++++++++-
1 file changed, 17 insertions(+), 1 deletion(-)
diff --git a/src/app/ui/HeaderView.tsx b/src/app/ui/HeaderView.tsx
index 549eab5..e25eafc 100644
--- a/src/app/ui/HeaderView.tsx
+++ b/src/app/ui/HeaderView.tsx
@@ -1,4 +1,5 @@
import { Link } from "react-router-dom";
+import { useEffect, useState } from "react";
export type HeaderViewProps = {
user: { username: string } | null;
@@ -13,8 +14,23 @@ export default function HeaderView({
onToggleTheme,
onLogout,
}: HeaderViewProps) {
+ const [atTop, setAtTop] = useState(true);
+
+ useEffect(() => {
+ const onScroll = () => {
+ setAtTop(window.scrollY <= 8);
+ };
+ onScroll();
+ window.addEventListener("scroll", onScroll, { passive: true });
+ return () => window.removeEventListener("scroll", onScroll);
+ }, []);
+
return (
-
+
kinda StackOverflow
From 64d1288716faf416826239c290821e8f5ef5d47d Mon Sep 17 00:00:00 2001
From: kotru21
Date: Fri, 15 Aug 2025 17:30:58 +0300
Subject: [PATCH 16/40] Add Avatar component and integrate in user views
Introduces a reusable Avatar component that displays a colored emoji avatar based on username. Integrates Avatar into HeaderView, QuestionCardView, SnippetCardView, SnippetPage comments, and SnippetDetailsView to visually represent users throughout the UI.
---
src/app/ui/HeaderView.tsx | 4 +-
src/pages/home/ui/QuestionCardView.tsx | 5 +-
src/pages/home/ui/SnippetCardView.tsx | 5 +-
src/pages/snippet/SnippetPage.tsx | 6 +-
src/pages/snippet/ui/SnippetDetailsView.tsx | 5 +-
src/shared/ui/Avatar.tsx | 125 ++++++++++++++++++++
6 files changed, 143 insertions(+), 7 deletions(-)
create mode 100644 src/shared/ui/Avatar.tsx
diff --git a/src/app/ui/HeaderView.tsx b/src/app/ui/HeaderView.tsx
index e25eafc..cd6a596 100644
--- a/src/app/ui/HeaderView.tsx
+++ b/src/app/ui/HeaderView.tsx
@@ -1,5 +1,6 @@
import { Link } from "react-router-dom";
import { useEffect, useState } from "react";
+import Avatar from "../../shared/ui/Avatar";
export type HeaderViewProps = {
user: { username: string } | null;
@@ -44,7 +45,8 @@ export default function HeaderView({
{user ? (
<>
-
+
+
{user.username}
Account
diff --git a/src/pages/home/ui/QuestionCardView.tsx b/src/pages/home/ui/QuestionCardView.tsx
index 07a2365..1cc598f 100644
--- a/src/pages/home/ui/QuestionCardView.tsx
+++ b/src/pages/home/ui/QuestionCardView.tsx
@@ -1,5 +1,6 @@
import { memo } from "react";
import { ExpandableText } from "../../../shared/ui/ExpandableText";
+import Avatar from "../../../shared/ui/Avatar";
import { CodeBlock } from "../../../shared/ui/CodeBlock";
export type QuestionCardViewProps = {
@@ -25,7 +26,9 @@ export const QuestionCardView = memo(function QuestionCardView({
{title}
- @{userName}
+
+ @{userName}
+
- @{userName}
+
+ @{userName}
+
-
- @{c.user.username}
+
{c.content}
diff --git a/src/pages/snippet/ui/SnippetDetailsView.tsx b/src/pages/snippet/ui/SnippetDetailsView.tsx
index 1fe6e5a..bffa1e8 100644
--- a/src/pages/snippet/ui/SnippetDetailsView.tsx
+++ b/src/pages/snippet/ui/SnippetDetailsView.tsx
@@ -1,5 +1,6 @@
import { memo } from "react";
import { CodeBlock } from "../../../shared/ui/CodeBlock";
+import Avatar from "../../../shared/ui/Avatar";
export type SnippetDetailsViewProps = {
id: number;
@@ -31,8 +32,8 @@ export const SnippetDetailsView = memo(function SnippetDetailsView({
)}
{authorName && (
-
- Автор: @{authorName}
+
+ Автор: @{authorName}
)}
diff --git a/src/shared/ui/Avatar.tsx b/src/shared/ui/Avatar.tsx
new file mode 100644
index 0000000..191cd40
--- /dev/null
+++ b/src/shared/ui/Avatar.tsx
@@ -0,0 +1,125 @@
+import { memo, useMemo } from "react";
+
+export type AvatarProps = {
+ username: string;
+ size?: number; // px
+ className?: string;
+ title?: string;
+};
+
+// Soft color palette
+const COLORS = [
+ "#FECACA", // red-200
+ "#FED7AA", // orange-200
+ "#FEF08A", // yellow-200
+ "#BBF7D0", // green-200
+ "#99F6E4", // teal-200
+ "#A5F3FC", // sky-200
+ "#BAE6FD", // blue-200
+ "#C7D2FE", // indigo-200
+ "#E9D5FF", // purple-200
+ "#F5D0FE", // fuchsia-200
+ "#FFD6E7", // pinkish
+ "#E5E7EB", // gray-200
+];
+
+const EMOJIS = [
+ "😀",
+ "😎",
+ "🙂",
+ "😉",
+ "🤓",
+ "🤠",
+ "🥳",
+ "🤗",
+ "🐱",
+ "🐶",
+ "🦊",
+ "🐻",
+ "🐼",
+ "🐵",
+ "🦁",
+ "🐯",
+ "🐨",
+ "🐸",
+ "🐧",
+ "🐹",
+ "🐰",
+ "🐢",
+ "🐙",
+ "🐳",
+ "🦄",
+ "🐝",
+ "🐞",
+ "🦋",
+ "🐺",
+ "🐠",
+ "🐬",
+ "🦖",
+ "⭐",
+ "⚡",
+ "🔥",
+ "🌈",
+ "🌵",
+ "🍀",
+ "🍣",
+ "🍕",
+ "🍩",
+ "☕",
+ "🎧",
+ "🎮",
+ "🚀",
+ "🛡️",
+ "🧠",
+ "🧩",
+];
+
+function hashString(str: string): number {
+ let h = 2166136261; // FNV-1a
+ for (let i = 0; i < str.length; i++) {
+ h ^= str.charCodeAt(i);
+ h = Math.imul(h, 16777619);
+ }
+ return h >>> 0;
+}
+
+function pick(arr: T[], seed: number): T {
+ return arr[Math.abs(seed) % arr.length];
+}
+
+export const Avatar = memo(function Avatar({
+ username,
+ size = 28,
+ className = "",
+ title,
+}: AvatarProps) {
+ const { bg, emoji } = useMemo(() => {
+ const base = hashString((username || "").toLowerCase());
+ const bg = pick(COLORS, base);
+ const emoji = pick(EMOJIS, base * 48271 + 0x9e3779b1);
+ return { bg, emoji };
+ }, [username]);
+
+ const style: React.CSSProperties = {
+ width: size,
+ height: size,
+ backgroundColor: bg,
+ fontSize: Math.max(11, Math.floor(size * 0.6)),
+ lineHeight: 1,
+ };
+
+ return (
+
+ {emoji}
+
+ );
+});
+
+export default Avatar;
From 831a0ceee225055b9a9e312f1481ba348d224f42 Mon Sep 17 00:00:00 2001
From: kotru21
Date: Fri, 15 Aug 2025 18:28:28 +0300
Subject: [PATCH 17/40] Refactor header, question, and snippet UI components
Moved header scroll logic to a new Header container component and updated imports. Extracted answer and comment forms, and comments list into dedicated view components for better separation of concerns and reusability. Removed unused QuestionCard component and updated file structure for ItemCard. Updated HomePage, QuestionPage, and SnippetPage to use new view components.
---
src/App.tsx | 4 +-
src/app/Header.tsx | 18 ++++++++
src/app/ui/HeaderView.tsx | 14 +-----
src/pages/home/HomePage.tsx | 2 +-
src/pages/home/{ui => }/ItemCard.tsx | 12 ++---
src/pages/home/ui/QuestionCard.tsx | 56 -----------------------
src/pages/question/QuestionPage.tsx | 23 +++-------
src/pages/question/ui/AnswerFormView.tsx | 34 ++++++++++++++
src/pages/snippet/SnippetPage.tsx | 49 +++++---------------
src/pages/snippet/ui/CommentFormView.tsx | 45 ++++++++++++++++++
src/pages/snippet/ui/CommentsListView.tsx | 37 +++++++++++++++
11 files changed, 163 insertions(+), 131 deletions(-)
create mode 100644 src/app/Header.tsx
rename src/pages/home/{ui => }/ItemCard.tsx (82%)
delete mode 100644 src/pages/home/ui/QuestionCard.tsx
create mode 100644 src/pages/question/ui/AnswerFormView.tsx
create mode 100644 src/pages/snippet/ui/CommentFormView.tsx
create mode 100644 src/pages/snippet/ui/CommentsListView.tsx
diff --git a/src/App.tsx b/src/App.tsx
index 4e84935..02d3164 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,14 +1,14 @@
import { Outlet } from "react-router-dom";
import { useAuth } from "./app/providers/useAuth";
import { useTheme } from "./app/providers/useTheme";
-import HeaderView from "./app/ui/HeaderView";
+import Header from "./app/Header";
export default function App() {
const { user, logout } = useAuth();
const { theme, toggle } = useTheme();
return (
-
;
+
+export default function Header(props: Props) {
+ const [atTop, setAtTop] = useState(true);
+
+ useEffect(() => {
+ const onScroll = () => setAtTop(window.scrollY <= 8);
+ onScroll();
+ window.addEventListener("scroll", onScroll, { passive: true });
+ return () => window.removeEventListener("scroll", onScroll);
+ }, []);
+
+ return ;
+}
diff --git a/src/app/ui/HeaderView.tsx b/src/app/ui/HeaderView.tsx
index cd6a596..c00afc7 100644
--- a/src/app/ui/HeaderView.tsx
+++ b/src/app/ui/HeaderView.tsx
@@ -1,5 +1,4 @@
import { Link } from "react-router-dom";
-import { useEffect, useState } from "react";
import Avatar from "../../shared/ui/Avatar";
export type HeaderViewProps = {
@@ -7,6 +6,7 @@ export type HeaderViewProps = {
theme: "light" | "dark";
onToggleTheme: () => void;
onLogout: () => void;
+ atTop: boolean;
};
export default function HeaderView({
@@ -14,18 +14,8 @@ export default function HeaderView({
theme,
onToggleTheme,
onLogout,
+ atTop,
}: HeaderViewProps) {
- const [atTop, setAtTop] = useState(true);
-
- useEffect(() => {
- const onScroll = () => {
- setAtTop(window.scrollY <= 8);
- };
- onScroll();
- window.addEventListener("scroll", onScroll, { passive: true });
- return () => window.removeEventListener("scroll", onScroll);
- }, []);
-
return (
void;
diff --git a/src/pages/home/ui/QuestionCard.tsx b/src/pages/home/ui/QuestionCard.tsx
deleted file mode 100644
index 742af14..0000000
--- a/src/pages/home/ui/QuestionCard.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import { memo } from "react";
-import { Link } from "react-router-dom";
-import { CodeBlock } from "../../../shared/ui/CodeBlock";
-import { ExpandableText } from "../../../shared/ui/ExpandableText";
-
-export type QuestionCardProps = {
- id: string | number;
- title: string;
- description: string;
- attachedCode?: string;
- user: { username: string };
- answersCount?: number;
- onMoreClick?: () => void;
-};
-
-export const QuestionCard = memo(function QuestionCard({
- id,
- title,
- description,
- attachedCode,
- user,
- answersCount,
- onMoreClick,
-}: QuestionCardProps) {
- return (
-
-
-
- {title}
-
- @{user.username}
-
-
- {attachedCode && }
-
- {typeof answersCount !== "undefined" && (
- Answers: {answersCount}
- )}
-
-
-
- Answers →
-
-
-
- );
-});
-
-export default QuestionCard;
diff --git a/src/pages/question/QuestionPage.tsx b/src/pages/question/QuestionPage.tsx
index 6723e9e..7e8352a 100644
--- a/src/pages/question/QuestionPage.tsx
+++ b/src/pages/question/QuestionPage.tsx
@@ -7,6 +7,7 @@ import type { Question, Answer } from "../../entities/question/types";
import { useAuth } from "../../app/providers/useAuth";
import QuestionDetailsView from "./ui/QuestionDetailsView";
import AnswerItemView from "./ui/AnswerItemView";
+import AnswerFormView from "./ui/AnswerFormView";
import { BackLink } from "../../shared/ui/BackLink";
import { Skeleton } from "../../shared/ui/Skeleton";
@@ -69,22 +70,12 @@ export default function QuestionPage() {
attachedCode={(question as Question).attachedCode}
/>
{user ? (
-
-
- {errors.content && (
- {errors.content.message}
- )}
-
- Отправить ответ
-
-
+
) : (
Войдите, чтобы оставить ответ.
diff --git a/src/pages/question/ui/AnswerFormView.tsx b/src/pages/question/ui/AnswerFormView.tsx
new file mode 100644
index 0000000..840ba9e
--- /dev/null
+++ b/src/pages/question/ui/AnswerFormView.tsx
@@ -0,0 +1,34 @@
+import { memo } from "react";
+
+export type AnswerFormViewProps = {
+ textareaProps: React.TextareaHTMLAttributes;
+ error?: string;
+ disabled?: boolean;
+ onSubmit: React.FormEventHandler;
+};
+
+export const AnswerFormView = memo(function AnswerFormView({
+ textareaProps,
+ error,
+ disabled,
+ onSubmit,
+}: AnswerFormViewProps) {
+ return (
+
+
+ {error && {error}
}
+
+ Отправить ответ
+
+
+ );
+});
+
+export default AnswerFormView;
diff --git a/src/pages/snippet/SnippetPage.tsx b/src/pages/snippet/SnippetPage.tsx
index fd331b2..7b583a5 100644
--- a/src/pages/snippet/SnippetPage.tsx
+++ b/src/pages/snippet/SnippetPage.tsx
@@ -7,7 +7,8 @@ import { useQueryClient } from "@tanstack/react-query";
import { BackLink } from "../../shared/ui/BackLink";
import SnippetDetailsView from "./ui/SnippetDetailsView";
import { Skeleton } from "../../shared/ui/Skeleton";
-import Avatar from "../../shared/ui/Avatar";
+import CommentFormView from "./ui/CommentFormView";
+import CommentsListView from "./ui/CommentsListView";
export default function SnippetPage() {
const { id } = useParams();
@@ -103,27 +104,14 @@ export default function SnippetPage() {
/>
{user ? (
-
-
Оставить комментарий
-
setContent(e.target.value)}
- rows={4}
- className="w-full rounded border p-2 bg-white text-black dark:bg-neutral-800 dark:text-white"
- placeholder="Ваш комментарий..."
- />
-
-
- Отправить
-
- {error && {error} }
- {ok && {ok} }
-
-
+
) : (
Войдите, чтобы оставлять комментарии.
@@ -131,22 +119,7 @@ export default function SnippetPage() {
)}
{Array.isArray(snippet.comments) && snippet.comments.length > 0 && (
-
-
Комментарии
-
- {snippet.comments.map((c) => (
-
-
-
@
- {c.user.username}
-
- {c.content}
-
- ))}
-
-
+
)}
);
diff --git a/src/pages/snippet/ui/CommentFormView.tsx b/src/pages/snippet/ui/CommentFormView.tsx
new file mode 100644
index 0000000..5cd5298
--- /dev/null
+++ b/src/pages/snippet/ui/CommentFormView.tsx
@@ -0,0 +1,45 @@
+import { memo } from "react";
+
+export type CommentFormViewProps = {
+ content: string;
+ onChange: (v: string) => void;
+ onSubmit: () => void;
+ pending?: boolean;
+ error?: string | null;
+ ok?: string | null;
+};
+
+export const CommentFormView = memo(function CommentFormView({
+ content,
+ onChange,
+ onSubmit,
+ pending,
+ error,
+ ok,
+}: CommentFormViewProps) {
+ return (
+
+
Оставить комментарий
+
onChange(e.target.value)}
+ rows={4}
+ className="w-full rounded border p-2 bg-white text-black dark:bg-neutral-800 dark:text-white"
+ placeholder="Ваш комментарий..."
+ />
+
+
+ Отправить
+
+ {error && {error} }
+ {ok && {ok} }
+
+
+ );
+});
+
+export default CommentFormView;
diff --git a/src/pages/snippet/ui/CommentsListView.tsx b/src/pages/snippet/ui/CommentsListView.tsx
new file mode 100644
index 0000000..b6cbfb6
--- /dev/null
+++ b/src/pages/snippet/ui/CommentsListView.tsx
@@ -0,0 +1,37 @@
+import { memo } from "react";
+import Avatar from "../../../shared/ui/Avatar";
+
+export type Comment = {
+ id: number;
+ content: string;
+ user: { username: string };
+};
+
+export type CommentsListViewProps = {
+ comments: Comment[];
+};
+
+export const CommentsListView = memo(function CommentsListView({
+ comments,
+}: CommentsListViewProps) {
+ if (!comments.length) return null;
+ return (
+
+
Комментарии
+
+ {comments.map((c) => (
+
+
+ {c.content}
+
+ ))}
+
+
+ );
+});
+
+export default CommentsListView;
From 43b03a58a50633990013641a6de6c99a36258dd2 Mon Sep 17 00:00:00 2001
From: kotru21
Date: Sun, 17 Aug 2025 21:52:14 +0300
Subject: [PATCH 18/40] Add create page, socket support, and unify card views
Introduces a new CreatePage for questions and snippets, adds socket.io support for real-time snippet comments, and unifies question/snippet card views into ItemCommonCardView. Refactors code block and editor components for better language handling, memoizes several UI components, and updates API hooks for creating questions/snippets and marking answers. Removes legacy QuestionCardView and SnippetCardView components.
---
package-lock.json | 94 ++++++++
package.json | 1 -
src/App.tsx | 11 +-
src/app/Header.tsx | 6 +-
src/app/ui/HeaderView.tsx | 8 +-
src/entities/question/api.ts | 46 +++-
src/entities/question/types.ts | 6 +
src/entities/snippet/api.ts | 16 +-
src/main.tsx | 7 +-
src/pages/auth/ui/LoginFormView.tsx | 5 +-
src/pages/auth/ui/RegisterFormView.tsx | 5 +-
src/pages/create/CreatePage.tsx | 251 ++++++++++++++++++++
src/pages/home/ItemCard.tsx | 12 +-
src/pages/home/ui/ItemCommonCardView.tsx | 147 ++++++++++++
src/pages/home/ui/QuestionCardView.tsx | 59 -----
src/pages/home/ui/SnippetCardView.tsx | 98 --------
src/pages/question/QuestionPage.tsx | 16 +-
src/pages/question/ui/AnswerItemView.tsx | 30 ++-
src/pages/snippet/SnippetPage.tsx | 29 ++-
src/pages/snippet/ui/SnippetDetailsView.tsx | 2 +-
src/shared/socket.ts | 35 +++
src/shared/ui/CodeBlock.tsx | 72 +++++-
src/shared/ui/CodeEditor.tsx | 145 +++++++++++
23 files changed, 910 insertions(+), 191 deletions(-)
create mode 100644 src/pages/create/CreatePage.tsx
create mode 100644 src/pages/home/ui/ItemCommonCardView.tsx
delete mode 100644 src/pages/home/ui/QuestionCardView.tsx
delete mode 100644 src/pages/home/ui/SnippetCardView.tsx
create mode 100644 src/shared/socket.ts
create mode 100644 src/shared/ui/CodeEditor.tsx
diff --git a/package-lock.json b/package-lock.json
index af05d33..21d6a9c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,7 +8,11 @@
"name": "stackoverflow",
"version": "0.0.0",
"dependencies": {
+ "@codemirror/lang-cpp": "^6.0.3",
+ "@codemirror/lang-go": "^6.0.1",
+ "@codemirror/lang-java": "^6.0.2",
"@codemirror/lang-javascript": "^6.2.4",
+ "@codemirror/lang-python": "^6.2.1",
"@hookform/resolvers": "^5.2.1",
"@tanstack/react-query": "^5.85.0",
"@tanstack/react-virtual": "^3.13.12",
@@ -128,6 +132,39 @@
"@lezer/common": "^1.1.0"
}
},
+ "node_modules/@codemirror/lang-cpp": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-cpp/-/lang-cpp-6.0.3.tgz",
+ "integrity": "sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@lezer/cpp": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/lang-go": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-go/-/lang-go-6.0.1.tgz",
+ "integrity": "sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/language": "^6.6.0",
+ "@codemirror/state": "^6.0.0",
+ "@lezer/common": "^1.0.0",
+ "@lezer/go": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/lang-java": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-java/-/lang-java-6.0.2.tgz",
+ "integrity": "sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@lezer/java": "^1.0.0"
+ }
+ },
"node_modules/@codemirror/lang-javascript": {
"version": "6.2.4",
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz",
@@ -143,6 +180,19 @@
"@lezer/javascript": "^1.0.0"
}
},
+ "node_modules/@codemirror/lang-python": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz",
+ "integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.3.2",
+ "@codemirror/language": "^6.8.0",
+ "@codemirror/state": "^6.0.0",
+ "@lezer/common": "^1.2.1",
+ "@lezer/python": "^1.1.4"
+ }
+ },
"node_modules/@codemirror/language": {
"version": "6.11.2",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.2.tgz",
@@ -944,6 +994,28 @@
"integrity": "sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==",
"license": "MIT"
},
+ "node_modules/@lezer/cpp": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@lezer/cpp/-/cpp-1.1.3.tgz",
+ "integrity": "sha512-ykYvuFQKGsRi6IcE+/hCSGUhb/I4WPjd3ELhEblm2wS2cOznDFzO+ubK2c+ioysOnlZ3EduV+MVQFCPzAIoY3w==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0"
+ }
+ },
+ "node_modules/@lezer/go": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@lezer/go/-/go-1.0.1.tgz",
+ "integrity": "sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.3.0"
+ }
+ },
"node_modules/@lezer/highlight": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.1.tgz",
@@ -953,6 +1025,17 @@
"@lezer/common": "^1.0.0"
}
},
+ "node_modules/@lezer/java": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@lezer/java/-/java-1.1.3.tgz",
+ "integrity": "sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0"
+ }
+ },
"node_modules/@lezer/javascript": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.1.tgz",
@@ -973,6 +1056,17 @@
"@lezer/common": "^1.0.0"
}
},
+ "node_modules/@lezer/python": {
+ "version": "1.1.18",
+ "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.18.tgz",
+ "integrity": "sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0"
+ }
+ },
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
diff --git a/package.json b/package.json
index 241549c..a35cc34 100644
--- a/package.json
+++ b/package.json
@@ -10,7 +10,6 @@
"preview": "vite preview"
},
"dependencies": {
- "@codemirror/lang-javascript": "^6.2.4",
"@hookform/resolvers": "^5.2.1",
"@tanstack/react-query": "^5.85.0",
"@tanstack/react-virtual": "^3.13.12",
diff --git a/src/App.tsx b/src/App.tsx
index 02d3164..e31edb3 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,4 +1,5 @@
import { Outlet } from "react-router-dom";
+import { useCallback } from "react";
import { useAuth } from "./app/providers/useAuth";
import { useTheme } from "./app/providers/useTheme";
import Header from "./app/Header";
@@ -6,13 +7,19 @@ import Header from "./app/Header";
export default function App() {
const { user, logout } = useAuth();
const { theme, toggle } = useTheme();
+ const onLogout = useCallback(() => {
+ void logout();
+ }, [logout]);
+ const onToggleTheme = useCallback(() => {
+ toggle();
+ }, [toggle]);
return (
void logout()}
+ onToggleTheme={onToggleTheme}
+ onLogout={onLogout}
/>
diff --git a/src/app/Header.tsx b/src/app/Header.tsx
index dbd94d2..2eeaf65 100644
--- a/src/app/Header.tsx
+++ b/src/app/Header.tsx
@@ -1,10 +1,10 @@
-import { useEffect, useState } from "react";
+import { memo, useEffect, useState } from "react";
import HeaderView from "./ui/HeaderView";
import type { HeaderViewProps } from "./ui/HeaderView";
type Props = Omit
;
-export default function Header(props: Props) {
+function Header(props: Props) {
const [atTop, setAtTop] = useState(true);
useEffect(() => {
@@ -16,3 +16,5 @@ export default function Header(props: Props) {
return ;
}
+
+export default memo(Header);
diff --git a/src/app/ui/HeaderView.tsx b/src/app/ui/HeaderView.tsx
index c00afc7..e6f676b 100644
--- a/src/app/ui/HeaderView.tsx
+++ b/src/app/ui/HeaderView.tsx
@@ -1,4 +1,5 @@
import { Link } from "react-router-dom";
+import { memo } from "react";
import Avatar from "../../shared/ui/Avatar";
export type HeaderViewProps = {
@@ -9,7 +10,7 @@ export type HeaderViewProps = {
atTop: boolean;
};
-export default function HeaderView({
+function HeaderView({
user,
theme,
onToggleTheme,
@@ -39,6 +40,9 @@ export default function HeaderView({
{user.username}
+
+ Создать
+
Account
Logout +{" "}
@@ -57,3 +61,5 @@ export default function HeaderView({
);
}
+
+export default memo(HeaderView);
diff --git a/src/entities/question/api.ts b/src/entities/question/api.ts
index c209fa0..c0f2aa9 100644
--- a/src/entities/question/api.ts
+++ b/src/entities/question/api.ts
@@ -5,7 +5,7 @@ import {
useQueryClient,
} from "@tanstack/react-query";
import { http } from "../../shared/api/http";
-import type { Question } from "./types";
+import type { Question, CreateQuestionDto } from "./types";
import type { Paginated } from "../../shared/types/pagination";
import { normalizePaginated, unwrapData } from "../../shared/api/normalize";
@@ -52,7 +52,7 @@ export function useQuestion(id?: string | number) {
},
enabled: !!id,
retry: 1,
- refetchOnWindowFocus: false,
+ refetchOnWindowFocus: true,
});
}
@@ -72,3 +72,45 @@ export function useCreateAnswer(questionId: string | number) {
},
});
}
+
+export function useSetAnswerState(questionId: string | number) {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: async (params: {
+ answerId: string | number;
+ state: "correct" | "incorrect";
+ }) => {
+ const { answerId, state } = params;
+ // Убедимся, что путь содержит числовой id (бэкенд ожидает number)
+ const idNum = Number(answerId);
+ const res = await http.put(`/answers/${idNum}/state/${state}`);
+ return res.data as unknown;
+ },
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ["question", questionId] });
+ qc.invalidateQueries({ queryKey: ["questions"] });
+ },
+ onError: () => {
+ // Перестраховка: мягко обновим вопрос, чтобы снять возможные рассинхроны
+ qc.invalidateQueries({ queryKey: ["question", questionId] });
+ // Простое уведомление пользователю (минимально инвазивно)
+ alert("Не удалось обновить статус ответа. Попробуйте ещё раз.");
+ },
+ });
+}
+
+export function useCreateQuestion() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: async (dto: CreateQuestionDto) => {
+ const res = await http.post("/questions", dto);
+ return unwrapData(res.data as unknown);
+ },
+ onSuccess: (created) => {
+ qc.invalidateQueries({ queryKey: ["questions"] });
+ if (created?.id) {
+ // no-op here; navigation will be performed by page-level logic
+ }
+ },
+ });
+}
diff --git a/src/entities/question/types.ts b/src/entities/question/types.ts
index c64495b..c5ca8a5 100644
--- a/src/entities/question/types.ts
+++ b/src/entities/question/types.ts
@@ -19,3 +19,9 @@ export type Question = {
user: User;
isResolved?: boolean;
};
+
+export type CreateQuestionDto = {
+ title: string;
+ description: string;
+ attachedCode?: string;
+};
diff --git a/src/entities/snippet/api.ts b/src/entities/snippet/api.ts
index 2048844..70d95dc 100644
--- a/src/entities/snippet/api.ts
+++ b/src/entities/snippet/api.ts
@@ -108,7 +108,7 @@ export function useSnippet(id?: number) {
},
enabled: !!id,
retry: 1,
- refetchOnWindowFocus: false,
+ refetchOnWindowFocus: true,
});
}
@@ -125,3 +125,17 @@ export function useMarkSnippet(id: number) {
},
});
}
+
+export function useCreateSnippet() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: async (dto: { code: string; language: string }) => {
+ const res = await http.post("/snippets", dto);
+ const created = unwrapData(res.data as unknown);
+ return created;
+ },
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ["snippets"] });
+ },
+ });
+}
diff --git a/src/main.tsx b/src/main.tsx
index 8e52d1d..880d9a5 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -8,7 +8,8 @@ import SnippetPage from "./pages/snippet/SnippetPage";
import AccountPage from "./pages/account/AccountPage";
import LoginPage from "./pages/auth/LoginPage.tsx";
import RegisterPage from "./pages/auth/RegisterPage.tsx";
-import { RequireGuest } from "./app/providers/route-guards";
+import { RequireAuth, RequireGuest } from "./app/providers/route-guards";
+import CreatePage from "./pages/create/CreatePage";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App.tsx";
import { AuthProvider } from "./app/providers/auth";
@@ -23,6 +24,10 @@ const router = createBrowserRouter([
{ path: "questions/:id", element: },
{ path: "snippets/:id", element: },
{ path: "account", element: },
+ {
+ element: ,
+ children: [{ path: "create", element: }],
+ },
{
element: ,
children: [
diff --git a/src/pages/auth/ui/LoginFormView.tsx b/src/pages/auth/ui/LoginFormView.tsx
index 5f9f539..36e7deb 100644
--- a/src/pages/auth/ui/LoginFormView.tsx
+++ b/src/pages/auth/ui/LoginFormView.tsx
@@ -1,4 +1,5 @@
import { Link } from "react-router-dom";
+import { memo } from "react";
import type { FormEvent, InputHTMLAttributes } from "react";
export type LoginFormViewProps = {
@@ -13,7 +14,7 @@ export type LoginFormViewProps = {
isSubmitting?: boolean;
};
-export default function LoginFormView({
+function LoginFormView({
onSubmit,
usernameInputProps,
passwordInputProps,
@@ -63,3 +64,5 @@ export default function LoginFormView({
);
}
+
+export default memo(LoginFormView);
diff --git a/src/pages/auth/ui/RegisterFormView.tsx b/src/pages/auth/ui/RegisterFormView.tsx
index 6dea7f6..bd6ad19 100644
--- a/src/pages/auth/ui/RegisterFormView.tsx
+++ b/src/pages/auth/ui/RegisterFormView.tsx
@@ -1,4 +1,5 @@
import { Link } from "react-router-dom";
+import { memo } from "react";
import type { FormEvent, InputHTMLAttributes } from "react";
export type RegisterFormViewProps = {
@@ -15,7 +16,7 @@ export type RegisterFormViewProps = {
isSubmitting?: boolean;
};
-export function RegisterFormView({
+function RegisterFormView({
onSubmit,
usernameInputProps,
passwordInputProps,
@@ -78,4 +79,4 @@ export function RegisterFormView({
);
}
-export default RegisterFormView;
+export default memo(RegisterFormView);
diff --git a/src/pages/create/CreatePage.tsx b/src/pages/create/CreatePage.tsx
new file mode 100644
index 0000000..2476f2f
--- /dev/null
+++ b/src/pages/create/CreatePage.tsx
@@ -0,0 +1,251 @@
+import { useState } from "react";
+import { z } from "zod";
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useNavigate } from "react-router-dom";
+import { useCreateQuestion } from "../../entities/question/api";
+import { useCreateSnippet } from "../../entities/snippet/api";
+import CodeEditor from "../../shared/ui/CodeEditor";
+import { toHttpError } from "../../shared/api/http";
+
+const questionSchema = z.object({
+ title: z.string().min(3, "Минимум 3 символа"),
+ description: z.string().min(1, "Описание обязательно"),
+ attachedCode: z.string().optional(),
+});
+type QuestionFormData = z.infer;
+
+const SUPPORTED_LANG_HINT =
+ "Допустимые: JavaScript, Python, Java, C/C++, C#, Go, Kotlin, Ruby";
+
+function normalizeLanguageInput(input: string): string | undefined {
+ const raw = (input || "").trim().toLowerCase();
+ if (!raw) return undefined;
+ const cleaned = raw.replace(/\s+/g, ""); // убираем пробелы: "c sharp" -> "csharp"
+ const map: Record = {
+ javascript: "JavaScript",
+ js: "JavaScript",
+ node: "JavaScript",
+ python: "Python",
+ py: "Python",
+ java: "Java",
+ c: "C",
+ "c++": "C++",
+ cpp: "C++",
+ "c#": "C#",
+ csharp: "C#",
+ "c-sharp": "C#",
+ golang: "Go",
+ go: "Go",
+ kotlin: "Kotlin",
+ kt: "Kotlin",
+ ruby: "Ruby",
+ rb: "Ruby",
+ };
+ // сначала пробуем как есть
+ if (map[raw]) return map[raw];
+ // затем по "очищенному" ключу
+ if (map[cleaned]) return map[cleaned];
+ return undefined;
+}
+
+const snippetSchema = z.object({
+ language: z
+ .string()
+ .min(1, "Укажите язык")
+ .transform((v) => normalizeLanguageInput(v) ?? "__invalid__")
+ .refine((v) => v !== "__invalid__", {
+ message: `Неподдерживаемый язык. ${SUPPORTED_LANG_HINT}`,
+ }),
+ code: z.string().min(1, "Код обязателен"),
+});
+type SnippetFormData = z.infer;
+
+export default function CreatePage() {
+ const [mode, setMode] = useState<"question" | "snippet">("question");
+ const nav = useNavigate();
+ const [qError, setQError] = useState(null);
+ const [sError, setSError] = useState(null);
+
+ const {
+ register: qReg,
+ handleSubmit: qSubmit,
+ formState: { errors: qErr, isSubmitting: qSub },
+ setValue: qSetValue,
+ watch: qWatch,
+ } = useForm({ resolver: zodResolver(questionSchema) });
+ const codeQ = qWatch("attachedCode") || "";
+
+ const {
+ register: sReg,
+ handleSubmit: sSubmit,
+ formState: { errors: sErr, isSubmitting: sSub },
+ setValue: sSetValue,
+ watch: sWatch,
+ } = useForm({ resolver: zodResolver(snippetSchema) });
+ const codeS = sWatch("code") || "";
+
+ const { mutateAsync: createQuestion, isPending: qPending } =
+ useCreateQuestion();
+ const { mutateAsync: createSnippet, isPending: sPending } =
+ useCreateSnippet();
+
+ const onCreateQuestion = async (data: QuestionFormData) => {
+ setQError(null);
+ try {
+ const created = await createQuestion(data);
+ const id = (created as { id?: string | number } | undefined)?.id;
+ if (id != null) nav(`/questions/${id}`);
+ else nav("/");
+ } catch (e) {
+ const err = toHttpError(e);
+ setQError(err.message || "Не удалось создать вопрос. Попробуйте позже.");
+ }
+ };
+ const onCreateSnippet = async (data: SnippetFormData) => {
+ setSError(null);
+ try {
+ const created = await createSnippet(data);
+ const id = (created as { id?: string | number } | undefined)?.id;
+ if (id != null) nav(`/snippets/${id}`);
+ else nav("/");
+ } catch (e) {
+ const err = toHttpError(e);
+ setSError(
+ err.message ||
+ "Не удалось создать сниппет. Проверьте корректность данных."
+ );
+ }
+ };
+
+ return (
+
+
+ setMode("question")}>
+ Вопрос
+
+ setMode("snippet")}>
+ Сниппет
+
+
+
+ {mode === "question" ? (
+
+
+
+
+
Заголовок
+
+ {qErr.title && (
+
{qErr.title.message}
+ )}
+
+
+
Описание
+
+ {qErr.description && (
+
+ {qErr.description.message}
+
+ )}
+
+
+
+ Код (необязательно)
+ qSetValue("attachedCode", v)}
+ language="tsx"
+ height="60vh"
+ placeholder="Вставьте минимальный воспроизводимый пример"
+ />
+
+
+ {qError && (
+
+ {qError}
+
+ )}
+
+
+ Создать вопрос
+
+
+
+ ) : (
+
+
+
+
+
Язык
+
+ {sErr.language && (
+
+ {sErr.language.message}
+
+ )}
+ {!sErr.language && (
+
+ {SUPPORTED_LANG_HINT}
+
+ )}
+
+
+
+
Код
+
sSetValue("code", v)}
+ language={sWatch("language")}
+ height="60vh"
+ placeholder="Вставьте код сниппета"
+ />
+ {sErr.code && (
+ {sErr.code.message}
+ )}
+
+
+ {sError && (
+
+ {sError}
+
+ )}
+
+
+ Создать сниппет
+
+
+
+ )}
+
+ );
+}
diff --git a/src/pages/home/ItemCard.tsx b/src/pages/home/ItemCard.tsx
index ec8f0d9..c27e090 100644
--- a/src/pages/home/ItemCard.tsx
+++ b/src/pages/home/ItemCard.tsx
@@ -3,8 +3,7 @@ import type { Question } from "../../entities/question/types";
import type { Snippet } from "../../entities/snippet/types";
import { useAuth } from "../../app/providers/useAuth";
import { useMarkSnippet } from "../../entities/snippet/api";
-import { QuestionCardView } from "./ui/QuestionCardView";
-import { SnippetCardView } from "./ui/SnippetCardView";
+import { ItemCommonCardView } from "./ui/ItemCommonCardView";
type BaseProps = {
onMoreClick?: () => void;
@@ -31,11 +30,12 @@ function QuestionView({
onMoreClick?: () => void;
}) {
return (
- mark("like")}
onDislike={() => mark("dislike")}
canInteract={canInteract}
diff --git a/src/pages/home/ui/ItemCommonCardView.tsx b/src/pages/home/ui/ItemCommonCardView.tsx
new file mode 100644
index 0000000..cc0ff5b
--- /dev/null
+++ b/src/pages/home/ui/ItemCommonCardView.tsx
@@ -0,0 +1,147 @@
+import { memo } from "react";
+import Avatar from "../../../shared/ui/Avatar";
+import { ExpandableText } from "../../../shared/ui/ExpandableText";
+import { Clamp } from "../../../shared/ui/Clamp";
+import { CodeBlock } from "../../../shared/ui/CodeBlock";
+
+export type ItemCommonCardViewProps = {
+ mode: "question" | "snippet";
+ userName: string;
+ // question
+ title?: string;
+ description?: string;
+ answersCount?: number;
+ // snippet
+ language?: string;
+ likesCount?: number;
+ dislikesCount?: number;
+ commentsCount?: number;
+ // shared
+ code?: string;
+ onMoreClick?: () => void; // переход на детали/комменты и раскрытие кода
+ onCommentsClick?: () => void; // для сниппета – переход к комментариям
+ onLike?: () => void;
+ onDislike?: () => void;
+ canInteract?: boolean;
+ isPending?: boolean;
+};
+
+export const ItemCommonCardView = memo(function ItemCommonCardView({
+ mode,
+ userName,
+ title,
+ description,
+ answersCount,
+ language,
+ likesCount,
+ dislikesCount,
+ commentsCount,
+ code,
+ onMoreClick,
+ onCommentsClick,
+ onLike,
+ onDislike,
+ canInteract = false,
+ isPending = false,
+}: ItemCommonCardViewProps) {
+ return (
+
+
+
+ {mode === "question" ? (
+
+ {title}
+
+ ) : (
+
+ {language}
+
+ )}
+
+
+ @{userName}
+
+
+
+ {description && (
+
+ )}
+
+ {code && (
+
+
+
+ )}
+
+ {mode === "question" ? (
+
+ {typeof answersCount === "number" && (
+ Answers: {answersCount}
+ )}
+ {onMoreClick && (
+
+ Answers →
+
+ )}
+
+ ) : (
+
+
+
+
+ Like
+
+
+ Dislike
+
+
+ {typeof likesCount !== "undefined" && (
+
Likes: {likesCount}
+ )}
+ {typeof dislikesCount !== "undefined" && (
+
Dislikes: {dislikesCount}
+ )}
+ {(onCommentsClick || onMoreClick) && (
+
+ {typeof commentsCount !== "undefined"
+ ? `Comments: ${commentsCount}`
+ : "Comments →"}
+
+ )}
+
+ {!canInteract && (
+
+ Войдите, чтобы ставить лайки/дизлайки и комментировать.
+
+ )}
+
+ )}
+
+ );
+});
+
+export default ItemCommonCardView;
diff --git a/src/pages/home/ui/QuestionCardView.tsx b/src/pages/home/ui/QuestionCardView.tsx
deleted file mode 100644
index 1cc598f..0000000
--- a/src/pages/home/ui/QuestionCardView.tsx
+++ /dev/null
@@ -1,59 +0,0 @@
-import { memo } from "react";
-import { ExpandableText } from "../../../shared/ui/ExpandableText";
-import Avatar from "../../../shared/ui/Avatar";
-import { CodeBlock } from "../../../shared/ui/CodeBlock";
-
-export type QuestionCardViewProps = {
- title: string;
- userName: string;
- description: string;
- attachedCode?: string;
- answersCount?: number;
- onMoreClick?: () => void;
-};
-
-export const QuestionCardView = memo(function QuestionCardView({
- title,
- userName,
- description,
- attachedCode,
- answersCount,
- onMoreClick,
-}: QuestionCardViewProps) {
- return (
-
-
-
- {title}
-
-
- @{userName}
-
-
-
- {attachedCode && }
-
- {typeof answersCount === "number" && (
- Answers: {answersCount}
- )}
-
- {onMoreClick && (
-
-
- Answers →
-
-
- )}
-
- );
-});
-
-export default QuestionCardView;
diff --git a/src/pages/home/ui/SnippetCardView.tsx b/src/pages/home/ui/SnippetCardView.tsx
deleted file mode 100644
index c3ffc65..0000000
--- a/src/pages/home/ui/SnippetCardView.tsx
+++ /dev/null
@@ -1,98 +0,0 @@
-import { memo } from "react";
-import { Clamp } from "../../../shared/ui/Clamp";
-import { CodeBlock } from "../../../shared/ui/CodeBlock";
-import Avatar from "../../../shared/ui/Avatar";
-
-export type SnippetCardViewProps = {
- language: string;
- userName: string;
- code: string;
- likesCount?: number;
- dislikesCount?: number;
- commentsCount?: number;
- onCommentsClick?: () => void;
- onLike?: () => void;
- onDislike?: () => void;
- canInteract?: boolean;
- isPending?: boolean;
-};
-
-export const SnippetCardView = memo(function SnippetCardView({
- language,
- userName,
- code,
- likesCount,
- dislikesCount,
- commentsCount,
- onCommentsClick,
- onLike,
- onDislike,
- canInteract = false,
- isPending = false,
-}: SnippetCardViewProps) {
- return (
-
-
-
-
- {language}
-
-
-
- @{userName}
-
-
-
-
-
-
- {typeof likesCount !== "undefined" && Likes: {likesCount} }
- {typeof dislikesCount !== "undefined" && (
- Dislikes: {dislikesCount}
- )}
- {typeof commentsCount !== "undefined" && (
-
- Comments: {commentsCount}
-
- )}
-
-
-
- Like
-
-
- Dislike
-
- {onCommentsClick && (
-
- Comments →
-
- )}
-
- {!canInteract && (
-
- Войдите, чтобы ставить лайки/дизлайки и комментировать.
-
- )}
-
- );
-});
-
-export default SnippetCardView;
diff --git a/src/pages/question/QuestionPage.tsx b/src/pages/question/QuestionPage.tsx
index 7e8352a..716c783 100644
--- a/src/pages/question/QuestionPage.tsx
+++ b/src/pages/question/QuestionPage.tsx
@@ -2,7 +2,11 @@ import { useParams } from "react-router-dom";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
-import { useQuestion, useCreateAnswer } from "../../entities/question/api";
+import {
+ useQuestion,
+ useCreateAnswer,
+ useSetAnswerState,
+} from "../../entities/question/api";
import type { Question, Answer } from "../../entities/question/types";
import { useAuth } from "../../app/providers/useAuth";
import QuestionDetailsView from "./ui/QuestionDetailsView";
@@ -22,6 +26,8 @@ export default function QuestionPage() {
const { user } = useAuth();
const { data: question, status } = useQuestion(id);
const { mutateAsync: createAnswer, isPending } = useCreateAnswer(id!);
+ const { mutateAsync: setAnswerState, isPending: markPending } =
+ useSetAnswerState(id!);
const {
register,
handleSubmit,
@@ -90,6 +96,14 @@ export default function QuestionPage() {
key={a.id}
content={a.content}
isCorrect={a.isCorrect}
+ canMark={!!user}
+ pending={markPending}
+ onMarkCorrect={() =>
+ setAnswerState({ answerId: a.id, state: "correct" })
+ }
+ onMarkIncorrect={() =>
+ setAnswerState({ answerId: a.id, state: "incorrect" })
+ }
/>
))}
diff --git a/src/pages/question/ui/AnswerItemView.tsx b/src/pages/question/ui/AnswerItemView.tsx
index cd77542..97cabcf 100644
--- a/src/pages/question/ui/AnswerItemView.tsx
+++ b/src/pages/question/ui/AnswerItemView.tsx
@@ -4,11 +4,19 @@ import { ExpandableText } from "../../../shared/ui/ExpandableText";
export type AnswerItemViewProps = {
content: string;
isCorrect?: boolean;
+ canMark?: boolean;
+ onMarkCorrect?: () => void;
+ onMarkIncorrect?: () => void;
+ pending?: boolean;
};
export const AnswerItemView = memo(function AnswerItemView({
content,
isCorrect,
+ canMark,
+ onMarkCorrect,
+ onMarkIncorrect,
+ pending,
}: AnswerItemViewProps) {
return (
@@ -21,7 +29,27 @@ export const AnswerItemView = memo(function AnswerItemView({
className="flex-1"
maxHeight={120}
/>
- {isCorrect && correct }
+
+ {isCorrect && correct }
+ {canMark &&
+ (isCorrect ? (
+
+ Снять метку
+
+ ) : (
+
+ Пометить как верный
+
+ ))}
+
);
diff --git a/src/pages/snippet/SnippetPage.tsx b/src/pages/snippet/SnippetPage.tsx
index 7b583a5..3b4c548 100644
--- a/src/pages/snippet/SnippetPage.tsx
+++ b/src/pages/snippet/SnippetPage.tsx
@@ -1,7 +1,7 @@
import { useParams } from "react-router-dom";
import { useSnippet } from "../../entities/snippet/api";
import { useAuth } from "../../app/providers/useAuth";
-import { useState } from "react";
+import { useEffect, useState } from "react";
import { http, toHttpError } from "../../shared/api/http";
import { useQueryClient } from "@tanstack/react-query";
import { BackLink } from "../../shared/ui/BackLink";
@@ -9,6 +9,7 @@ import SnippetDetailsView from "./ui/SnippetDetailsView";
import { Skeleton } from "../../shared/ui/Skeleton";
import CommentFormView from "./ui/CommentFormView";
import CommentsListView from "./ui/CommentsListView";
+import { getSocket } from "../../shared/socket";
export default function SnippetPage() {
const { id } = useParams();
@@ -22,6 +23,30 @@ export default function SnippetPage() {
const [ok, setOk] = useState(null);
const [pending, setPending] = useState(false);
const qc = useQueryClient();
+ // Подписываемся на сокет-события комментариев
+ useEffect(() => {
+ if (!snippetId) return;
+ const socket = getSocket();
+ const channel = `snippet:${snippetId}`;
+ const onCreated = (payload: { snippetId: number }) => {
+ if (payload?.snippetId === snippetId) {
+ qc.invalidateQueries({ queryKey: ["snippet", snippetId] });
+ }
+ };
+ const onDeleted = (payload: { snippetId: number }) => {
+ if (payload?.snippetId === snippetId) {
+ qc.invalidateQueries({ queryKey: ["snippet", snippetId] });
+ }
+ };
+ socket.emit("join", channel);
+ socket.on("comment:created", onCreated);
+ socket.on("comment:deleted", onDeleted);
+ return () => {
+ socket.emit("leave", channel);
+ socket.off("comment:created", onCreated);
+ socket.off("comment:deleted", onDeleted);
+ };
+ }, [snippetId, qc]);
const submit = async () => {
setError(null);
@@ -31,7 +56,7 @@ export default function SnippetPage() {
await http.post("/comments", { content, snippetId });
setContent("");
setOk("Комментарий отправлен");
- // refresh snippet to show new comment
+ // При socket.io обновление прилетит и так; оставим инвалидацию на случай деградации
qc.invalidateQueries({ queryKey: ["snippet", snippetId] });
} catch (e) {
const err = toHttpError(e);
diff --git a/src/pages/snippet/ui/SnippetDetailsView.tsx b/src/pages/snippet/ui/SnippetDetailsView.tsx
index bffa1e8..9bddb9c 100644
--- a/src/pages/snippet/ui/SnippetDetailsView.tsx
+++ b/src/pages/snippet/ui/SnippetDetailsView.tsx
@@ -36,7 +36,7 @@ export const SnippetDetailsView = memo(function SnippetDetailsView({
Автор: @{authorName}
)}
-
+
{typeof likesCount !== "undefined" &&
Likes: {likesCount} }
{typeof dislikesCount !== "undefined" && (
diff --git a/src/shared/socket.ts b/src/shared/socket.ts
new file mode 100644
index 0000000..23da1a8
--- /dev/null
+++ b/src/shared/socket.ts
@@ -0,0 +1,35 @@
+import { io, type Socket } from "socket.io-client";
+
+// Автоконфигурация пути под Vite proxy: если API ходит на /api, сокеты ждём на /api/socket.io
+const API_BASE = (import.meta as unknown as { env?: Record
})
+ .env?.VITE_API_BASE_URL;
+
+const DEFAULT_PATH =
+ API_BASE && API_BASE.startsWith("/api") ? "/api/socket.io" : "/socket.io";
+
+const SOCKET_URL =
+ (import.meta as unknown as { env?: Record }).env
+ ?.VITE_SOCKET_URL || undefined; // undefined => текущий origin
+const SOCKET_PATH =
+ (import.meta as unknown as { env?: Record }).env
+ ?.VITE_SOCKET_PATH || DEFAULT_PATH;
+
+let socketRef: Socket | null = null;
+
+export function getSocket(): Socket {
+ if (socketRef) return socketRef;
+ socketRef = io(SOCKET_URL, {
+ path: SOCKET_PATH,
+ withCredentials: true,
+ autoConnect: true,
+ transports: ["websocket", "polling"],
+ });
+ return socketRef;
+}
+
+export function closeSocket() {
+ if (socketRef) {
+ socketRef.close();
+ socketRef = null;
+ }
+}
diff --git a/src/shared/ui/CodeBlock.tsx b/src/shared/ui/CodeBlock.tsx
index d6c4b05..907e7da 100644
--- a/src/shared/ui/CodeBlock.tsx
+++ b/src/shared/ui/CodeBlock.tsx
@@ -9,7 +9,7 @@ import { useTheme } from "../../app/providers/useTheme";
export type CodeBlockProps = {
code: string;
- language?: Language;
+ language?: Language | string;
className?: string;
};
@@ -19,7 +19,41 @@ export const CodeBlock = memo(function CodeBlock({
className,
}: CodeBlockProps) {
const { theme } = useTheme();
- const safeCode = code ?? "";
+ const safeCode = (code ?? "").replace(/\r\n/g, "\n");
+ // Удаляем единственный завершающий перевод строки, который часто даёт пустую последнюю строку
+ const normalizedCode = safeCode.endsWith("\n")
+ ? safeCode.slice(0, -1)
+ : safeCode;
+
+ function toPrismLanguage(lang?: Language | string): Language {
+ const l = String(lang || "tsx").toLowerCase();
+ if (
+ l.includes("tsx") ||
+ l.includes("jsx") ||
+ l === "ts" ||
+ l === "js" ||
+ l.includes("node")
+ )
+ return "tsx" as Language;
+ if (l === "python" || l === "py") return "python" as Language;
+ if (l === "java") return "java" as Language;
+ if (l === "c++" || l === "cpp") return "cpp" as Language;
+ if (l === "c") return "c" as Language;
+ if (l === "go" || l === "golang") return "go" as Language;
+ if (l === "kotlin" || l === "kt" || l === "kts")
+ return "kotlin" as Language;
+ if (l === "ruby" || l === "rb") return "ruby" as Language;
+ if (l === "php") return "php" as Language;
+ if (l === "swift") return "swift" as Language;
+ if (l === "rust" || l === "rs") return "rust" as Language;
+ if (l === "json") return "json" as Language;
+ if (l === "bash" || l === "sh" || l === "shell") return "bash" as Language;
+ if (l === "html") return "markup" as Language;
+ if (l === "css" || l === "scss" || l === "less") return "css" as Language;
+ return (l as Language) || ("tsx" as Language);
+ }
+
+ const prismLanguage = toPrismLanguage(language as Language | string);
const prismTheme = useMemo(
() =>
theme === "dark"
@@ -33,7 +67,10 @@ export const CodeBlock = memo(function CodeBlock({
className={
"rounded border bg-gray-50 dark:bg-neutral-900 " + (className ?? "")
}>
-
+
{({
className: cls,
style,
@@ -42,15 +79,28 @@ export const CodeBlock = memo(function CodeBlock({
getTokenProps,
}: RenderProps) => (
- {tokens.map((line: PrismToken[], i: number) => (
-
- {line.map((token: PrismToken, key: number) => (
-
- ))}
-
- ))}
+ {(() => {
+ // Убираем полностью пустую последнюю строку, чтобы избежать лишнего вертикального отступа
+ const lines = [...tokens];
+ if (lines.length > 1) {
+ const last = lines[lines.length - 1];
+ const isEmptyLast =
+ last.length === 1 && String(last[0]?.content || "") === "";
+ if (isEmptyLast) lines.pop();
+ }
+ return lines.map((line: PrismToken[], i: number) => (
+
+ {line.map((token: PrismToken, key: number) => (
+
+ ))}
+
+ ));
+ })()}
)}
diff --git a/src/shared/ui/CodeEditor.tsx b/src/shared/ui/CodeEditor.tsx
new file mode 100644
index 0000000..f5c7727
--- /dev/null
+++ b/src/shared/ui/CodeEditor.tsx
@@ -0,0 +1,145 @@
+import React, { memo } from "react";
+import { Highlight, themes } from "prism-react-renderer";
+import type {
+ Language,
+ RenderProps,
+ Token as PrismToken,
+} from "prism-react-renderer";
+import { useTheme } from "../../app/providers/useTheme";
+
+type Props = {
+ value: string;
+ onChange: (value: string) => void;
+ language?: string;
+ placeholder?: string;
+ height?: number | string;
+ className?: string;
+};
+
+function toPrismLanguage(lang?: string): Language {
+ const l = (lang || "").toLowerCase();
+ if (
+ l.includes("tsx") ||
+ l.includes("jsx") ||
+ l.includes("ts") ||
+ l.includes("js") ||
+ l.includes("node")
+ )
+ return "tsx" as Language;
+ if (l === "python" || l === "py") return "python" as Language;
+ if (l === "java") return "java" as Language;
+ if (l === "c++" || l === "cpp") return "cpp" as Language;
+ if (l === "c") return "c" as Language;
+ if (l === "go" || l === "golang") return "go" as Language;
+ if (l === "kotlin" || l === "kt" || l === "kts") return "kotlin" as Language;
+ if (l === "ruby" || l === "rb") return "ruby" as Language;
+ if (l === "php") return "php" as Language;
+ if (l === "swift") return "swift" as Language;
+ if (l === "rust" || l === "rs") return "rust" as Language;
+ if (l === "json") return "json" as Language;
+ if (l === "bash" || l === "sh" || l === "shell") return "bash" as Language;
+ if (l === "html") return "markup" as Language;
+ if (l === "css" || l === "scss" || l === "less") return "css" as Language;
+ return "tsx" as Language;
+}
+
+export function CodeEditor({
+ value,
+ onChange,
+ language,
+ placeholder,
+ height = 240,
+ className,
+}: Props) {
+ const { theme } = useTheme();
+ const prismTheme = React.useMemo(
+ () =>
+ theme === "dark"
+ ? themes.gruvboxMaterialDark
+ : themes.gruvboxMaterialLight,
+ [theme]
+ );
+
+ const lang = toPrismLanguage(language);
+ const containerRef = React.useRef(null);
+ const taRef = React.useRef(null);
+ const overlayRef = React.useRef(null);
+
+ const onScrollSync = () => {
+ const ta = taRef.current;
+ const overlay = overlayRef.current;
+ if (ta && overlay) {
+ overlay.scrollTop = ta.scrollTop;
+ overlay.scrollLeft = ta.scrollLeft;
+ }
+ };
+
+ return (
+
+ {/* Highlighted layer (behind), scroll-synced */}
+
+
+ {({
+ className: cls,
+ style,
+ tokens,
+ getLineProps,
+ getTokenProps,
+ }: RenderProps) => (
+
+ {(() => {
+ const lines = [...tokens];
+ if (lines.length > 1) {
+ const last = lines[lines.length - 1];
+ const isEmptyLast =
+ last.length === 1 && String(last[0]?.content || "") === "";
+ if (isEmptyLast) lines.pop();
+ }
+ return lines.map((line: PrismToken[], i: number) => (
+
+ {line.map((token: PrismToken, key: number) => (
+
+ ))}
+
+ ));
+ })()}
+
+ )}
+
+
+
+ {/* Textarea input layer (on top), with transparent text but visible caret */}
+
onChange(e.target.value)}
+ onScroll={onScrollSync}
+ placeholder={undefined}
+ wrap="off"
+ spellCheck={false}
+ />
+
+ {/* Visible placeholder when empty */}
+ {!value && placeholder && (
+
+ {placeholder}
+
+ )}
+
+ );
+}
+
+export default memo(CodeEditor);
From 048dbb9fe04c44135e6bece1a71883e1eac48239 Mon Sep 17 00:00:00 2001
From: kotru21
Date: Sun, 17 Aug 2025 22:41:36 +0300
Subject: [PATCH 19/40] Add debug logging and owner checks to QuestionPage
Introduces debug logging across AuthProvider, QuestionPage, and HTTP API layer, controlled by environment, query string, or localStorage. Enhances QuestionPage to restrict answer marking to question owners, supporting multiple API data shapes for owner identification.
---
src/app/providers/auth.tsx | 61 +++++++++--
src/pages/question/QuestionPage.tsx | 154 ++++++++++++++++++++++++++--
src/shared/api/http.ts | 73 ++++++++++++-
3 files changed, 273 insertions(+), 15 deletions(-)
diff --git a/src/app/providers/auth.tsx b/src/app/providers/auth.tsx
index b8b92c4..6210116 100644
--- a/src/app/providers/auth.tsx
+++ b/src/app/providers/auth.tsx
@@ -1,33 +1,81 @@
-import { useEffect, useRef, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
import { http, toHttpError } from "../../shared/api/http";
+import { unwrapData } from "../../shared/api/normalize";
import { AuthContext, type User } from "./auth-context";
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
+ const getDebugEnabled = () => {
+ try {
+ const envDev = Boolean(
+ (import.meta as unknown as { env?: Record }).env?.DEV
+ );
+ const qs =
+ typeof window !== "undefined"
+ ? new URLSearchParams(window.location.search)
+ : undefined;
+ const fromQuery = qs?.get("debugQuestionPage") === "1";
+ const fromLocal =
+ typeof window !== "undefined" &&
+ !!window.localStorage &&
+ window.localStorage.getItem("debugQuestionPage") === "1";
+ return envDev || !!fromQuery || !!fromLocal;
+ } catch {
+ return false;
+ }
+ };
- const refresh = async () => {
+ const refresh = useCallback(async () => {
try {
- const res = await http.get("/auth");
- setUser(res.data);
+ const res = await http.get("/auth");
+ const raw = unwrapData(res.data);
+ const normalized: User = {
+ id: Number((raw as Record)?.["id"] ?? 0),
+ username: String((raw as Record)?.["username"] ?? ""),
+ role:
+ ((raw as Record)?.["role"] as "user" | "admin") ||
+ "user",
+ };
+ if (getDebugEnabled()) {
+ console.log("[AuthProvider] /auth OK:", res.status, {
+ data: normalized,
+ });
+ }
+ setUser(normalized);
} catch {
+ if (getDebugEnabled()) {
+ try {
+ const err = await http
+ .get("/auth")
+ .then(() => undefined)
+ .catch((e) => toHttpError(e));
+ console.log("[AuthProvider] /auth FAIL:", err);
+ } catch {
+ console.log("[AuthProvider] /auth FAIL: unknown error");
+ }
+ }
setUser(null);
} finally {
setLoading(false);
}
- };
+ }, []);
const didInit = useRef(false);
useEffect(() => {
if (didInit.current) return;
didInit.current = true;
+ if (getDebugEnabled()) console.log("[AuthProvider] mount");
void refresh();
- }, []);
+ }, [refresh]);
const login = async (username: string, password: string) => {
try {
await http.post("/auth/login", { username, password });
await refresh();
+ if (getDebugEnabled()) {
+ console.log("[AuthProvider] login complete; user=", user);
+ }
} catch (e) {
const err = toHttpError(e);
const error = new Error(err.message || "Login failed");
@@ -39,6 +87,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const logout = async () => {
await http.post("/auth/logout");
setUser(null);
+ if (getDebugEnabled()) console.log("[AuthProvider] logout done");
};
const register = async (username: string, password: string) => {
diff --git a/src/pages/question/QuestionPage.tsx b/src/pages/question/QuestionPage.tsx
index 716c783..aa49018 100644
--- a/src/pages/question/QuestionPage.tsx
+++ b/src/pages/question/QuestionPage.tsx
@@ -1,4 +1,5 @@
import { useParams } from "react-router-dom";
+import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -23,11 +24,30 @@ type FormData = z.infer;
export default function QuestionPage() {
const { id } = useParams<{ id: string }>();
- const { user } = useAuth();
+ const { user, refresh } = useAuth();
const { data: question, status } = useQuestion(id);
const { mutateAsync: createAnswer, isPending } = useCreateAnswer(id!);
const { mutateAsync: setAnswerState, isPending: markPending } =
useSetAnswerState(id!);
+ const debugEnabled = (() => {
+ try {
+ const envDev = Boolean(
+ (import.meta as unknown as { env?: Record }).env?.DEV
+ );
+ const qs =
+ typeof window !== "undefined"
+ ? new URLSearchParams(window.location.search)
+ : undefined;
+ const fromQuery = qs?.get("debugQuestionPage") === "1";
+ const fromLocal =
+ typeof window !== "undefined" &&
+ !!window.localStorage &&
+ window.localStorage.getItem("debugQuestionPage") === "1";
+ return envDev || !!fromQuery || !!fromLocal;
+ } catch {
+ return false;
+ }
+ })();
const {
register,
handleSubmit,
@@ -40,6 +60,52 @@ export default function QuestionPage() {
reset({ content: "" });
};
+ // В режиме отладки: если пользователь ещё не загружен, форсируем refresh(), чтобы увидеть логи AuthProvider
+ useEffect(() => {
+ if (debugEnabled && !user) {
+ try {
+ void refresh();
+ console.log("[QuestionPage] forced refresh() to trigger /auth logs");
+ } catch {
+ // no-op for debug
+ }
+ }
+ }, [debugEnabled, user, refresh]);
+
+ // Dev/log-flag логирование для диагностики
+ if (debugEnabled) {
+ // Статус и базовые данные
+ console.log("[QuestionPage] DEBUG ENABLED");
+ console.log("[QuestionPage] status:", status, "questionId:", id);
+ if (user) {
+ console.log("[QuestionPage] auth user:", {
+ id: (user as unknown as Record)["id"],
+ username: (user as unknown as Record)["username"],
+ role: (user as unknown as Record)["role"],
+ });
+ } else {
+ console.log("[QuestionPage] auth user: null");
+ }
+ if (question) {
+ const qAny = question as unknown as Record;
+ const qUser =
+ (qAny?.["user"] as Record | undefined) ?? undefined;
+ console.log("[QuestionPage] question owner raw fields:", {
+ questionId: qAny?.["id"],
+ user: qUser
+ ? { id: qUser["id"], username: qUser["username"] }
+ : undefined,
+ userId: qAny?.["userId"],
+ authorId: qAny?.["authorId"],
+ ownerId: qAny?.["ownerId"],
+ createdById: qAny?.["createdById"],
+ answersLen: Array.isArray(qAny?.["answers"] as unknown[])
+ ? (qAny?.["answers"] as unknown[]).length
+ : undefined,
+ });
+ }
+ }
+
if (status === "pending")
return (
@@ -67,6 +133,60 @@ export default function QuestionPage() {
);
if (!question) return
Вопрос не найден
;
+ // Только автор вопроса может помечать ответы правильными/неправильными
+ // Поддерживаем разные возможные формы данных от API: user.id | userId | user.userId
+ const qAny = question as unknown as Record
;
+ const qUser =
+ (qAny?.["user"] as Record | undefined) ?? undefined;
+ const ownerIdCandidates: Array = [
+ qUser?.["id"] as number | string | undefined,
+ qAny?.["userId"] as number | string | undefined,
+ qUser?.["userId"] as number | string | undefined,
+ (qAny?.["author"] as Record | undefined)?.["id"] as
+ | number
+ | string
+ | undefined,
+ (qAny?.["owner"] as Record | undefined)?.["id"] as
+ | number
+ | string
+ | undefined,
+ (qAny?.["createdBy"] as Record | undefined)?.["id"] as
+ | number
+ | string
+ | undefined,
+ qAny?.["createdById"] as number | string | undefined,
+ qAny?.["authorId"] as number | string | undefined,
+ qAny?.["ownerId"] as number | string | undefined,
+ ];
+ const ownerId = ownerIdCandidates.find((v) => v !== undefined);
+ const ownerNameCandidates: Array = [
+ (qUser?.["username"] as string | undefined) ?? undefined,
+ (qAny?.["author"] as Record | undefined)?.["username"] as
+ | string
+ | undefined,
+ (qAny?.["owner"] as Record | undefined)?.["username"] as
+ | string
+ | undefined,
+ (qAny?.["createdBy"] as Record | undefined)?.[
+ "username"
+ ] as string | undefined,
+ ];
+ const ownerName = ownerNameCandidates.find((v) => !!v);
+ const isOwner =
+ !!user &&
+ ((ownerId != null && String(user.id) === String(ownerId)) ||
+ (ownerName != null && user.username === ownerName));
+
+ if (debugEnabled) {
+ console.log("[QuestionPage] derived owner:", {
+ ownerId,
+ ownerName,
+ isOwner,
+ currentUserId: user?.id,
+ currentUserName: user?.username,
+ });
+ }
+
return (
@@ -96,14 +216,32 @@ export default function QuestionPage() {
key={a.id}
content={a.content}
isCorrect={a.isCorrect}
- canMark={!!user}
+ canMark={isOwner}
pending={markPending}
- onMarkCorrect={() =>
- setAnswerState({ answerId: a.id, state: "correct" })
- }
- onMarkIncorrect={() =>
- setAnswerState({ answerId: a.id, state: "incorrect" })
- }
+ onMarkCorrect={() => {
+ if (!isOwner) {
+ alert("Только автор вопроса может помечать ответы.");
+ return;
+ }
+ if (debugEnabled) {
+ console.log("[QuestionPage] action: mark correct", {
+ answerId: a.id,
+ });
+ }
+ setAnswerState({ answerId: a.id, state: "correct" });
+ }}
+ onMarkIncorrect={() => {
+ if (!isOwner) {
+ alert("Только автор вопроса может помечать ответы.");
+ return;
+ }
+ if (debugEnabled) {
+ console.log("[QuestionPage] action: unmark (incorrect)", {
+ answerId: a.id,
+ });
+ }
+ setAnswerState({ answerId: a.id, state: "incorrect" });
+ }}
/>
))}
diff --git a/src/shared/api/http.ts b/src/shared/api/http.ts
index c9521b2..c7c8d6e 100644
--- a/src/shared/api/http.ts
+++ b/src/shared/api/http.ts
@@ -9,11 +9,82 @@ const API_BASE_URL =
export const http = axios.create({
baseURL: API_BASE_URL,
withCredentials: true,
+ timeout: 10000,
+});
+// Стартовый лог окружения HTTP при включённом дебаге
+if (getDebugEnabled()) {
+ try {
+ console.log("[HTTP] init", {
+ baseURL: API_BASE_URL,
+ location: typeof window !== "undefined" ? window.location.href : "n/a",
+ withCredentials: true,
+ timeout: http.defaults.timeout,
+ });
+ } catch {
+ /* noop */
+ }
+}
+
+function getDebugEnabled(): boolean {
+ try {
+ const envDev = Boolean(
+ (import.meta as unknown as { env?: Record
}).env?.DEV
+ );
+ const qs =
+ typeof window !== "undefined"
+ ? new URLSearchParams(window.location.search)
+ : undefined;
+ const fromQuery = qs?.get("debugQuestionPage") === "1";
+ const fromLocal =
+ typeof window !== "undefined" &&
+ !!window.localStorage &&
+ window.localStorage.getItem("debugQuestionPage") === "1";
+ return envDev || !!fromQuery || !!fromLocal;
+ } catch {
+ return false;
+ }
+}
+
+http.interceptors.request.use((config) => {
+ if (getDebugEnabled()) {
+ try {
+ console.log("[HTTP] →", config.method?.toUpperCase(), config.url, {
+ baseURL: config.baseURL,
+ withCredentials: config.withCredentials,
+ });
+ } catch {
+ /* noop */
+ }
+ }
+ return config;
});
http.interceptors.response.use(
- (r) => r,
+ (r) => {
+ if (getDebugEnabled()) {
+ try {
+ console.log("[HTTP] ←", r.config.method?.toUpperCase(), r.config.url, {
+ status: r.status,
+ });
+ } catch {
+ /* noop */
+ }
+ }
+ return r;
+ },
(error) => {
+ if (getDebugEnabled()) {
+ try {
+ const cfg = (error?.config ?? {}) as { method?: string; url?: string };
+ const status = error?.response?.status;
+ console.log("[HTTP] ×", cfg.method?.toUpperCase(), cfg.url, {
+ status,
+ message: error?.message,
+ });
+ } catch {
+ /* noop */
+ }
+ }
return Promise.reject(error);
}
);
From a79f34b19a9cc4514bc52871f7a54dd7bd15e621 Mon Sep 17 00:00:00 2001
From: kotru21
Date: Sun, 17 Aug 2025 22:47:09 +0300
Subject: [PATCH 20/40] Remove debug logging and flags from auth and question
modules
Eliminated all debug-related flags, logging, and forced refresh logic from AuthProvider, QuestionPage, and HTTP API modules to clean up production code and improve maintainability.
---
src/app/providers/auth.tsx | 41 +-----------
src/pages/question/QuestionPage.tsx | 99 ++---------------------------
src/shared/api/http.ts | 76 +---------------------
3 files changed, 8 insertions(+), 208 deletions(-)
diff --git a/src/app/providers/auth.tsx b/src/app/providers/auth.tsx
index 6210116..d0eb99b 100644
--- a/src/app/providers/auth.tsx
+++ b/src/app/providers/auth.tsx
@@ -6,25 +6,7 @@ import { AuthContext, type User } from "./auth-context";
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
- const getDebugEnabled = () => {
- try {
- const envDev = Boolean(
- (import.meta as unknown as { env?: Record }).env?.DEV
- );
- const qs =
- typeof window !== "undefined"
- ? new URLSearchParams(window.location.search)
- : undefined;
- const fromQuery = qs?.get("debugQuestionPage") === "1";
- const fromLocal =
- typeof window !== "undefined" &&
- !!window.localStorage &&
- window.localStorage.getItem("debugQuestionPage") === "1";
- return envDev || !!fromQuery || !!fromLocal;
- } catch {
- return false;
- }
- };
+
const refresh = useCallback(async () => {
try {
@@ -37,24 +19,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
((raw as Record)?.["role"] as "user" | "admin") ||
"user",
};
- if (getDebugEnabled()) {
- console.log("[AuthProvider] /auth OK:", res.status, {
- data: normalized,
- });
- }
setUser(normalized);
} catch {
- if (getDebugEnabled()) {
- try {
- const err = await http
- .get("/auth")
- .then(() => undefined)
- .catch((e) => toHttpError(e));
- console.log("[AuthProvider] /auth FAIL:", err);
- } catch {
- console.log("[AuthProvider] /auth FAIL: unknown error");
- }
- }
setUser(null);
} finally {
setLoading(false);
@@ -65,7 +31,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
if (didInit.current) return;
didInit.current = true;
- if (getDebugEnabled()) console.log("[AuthProvider] mount");
void refresh();
}, [refresh]);
@@ -73,9 +38,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
try {
await http.post("/auth/login", { username, password });
await refresh();
- if (getDebugEnabled()) {
- console.log("[AuthProvider] login complete; user=", user);
- }
} catch (e) {
const err = toHttpError(e);
const error = new Error(err.message || "Login failed");
@@ -87,7 +49,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const logout = async () => {
await http.post("/auth/logout");
setUser(null);
- if (getDebugEnabled()) console.log("[AuthProvider] logout done");
};
const register = async (username: string, password: string) => {
diff --git a/src/pages/question/QuestionPage.tsx b/src/pages/question/QuestionPage.tsx
index aa49018..1c2c913 100644
--- a/src/pages/question/QuestionPage.tsx
+++ b/src/pages/question/QuestionPage.tsx
@@ -1,5 +1,4 @@
import { useParams } from "react-router-dom";
-import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -24,30 +23,12 @@ type FormData = z.infer;
export default function QuestionPage() {
const { id } = useParams<{ id: string }>();
- const { user, refresh } = useAuth();
+ const { user } = useAuth();
const { data: question, status } = useQuestion(id);
const { mutateAsync: createAnswer, isPending } = useCreateAnswer(id!);
const { mutateAsync: setAnswerState, isPending: markPending } =
useSetAnswerState(id!);
- const debugEnabled = (() => {
- try {
- const envDev = Boolean(
- (import.meta as unknown as { env?: Record }).env?.DEV
- );
- const qs =
- typeof window !== "undefined"
- ? new URLSearchParams(window.location.search)
- : undefined;
- const fromQuery = qs?.get("debugQuestionPage") === "1";
- const fromLocal =
- typeof window !== "undefined" &&
- !!window.localStorage &&
- window.localStorage.getItem("debugQuestionPage") === "1";
- return envDev || !!fromQuery || !!fromLocal;
- } catch {
- return false;
- }
- })();
+ // debug-флаг и временные логи удалены
const {
register,
handleSubmit,
@@ -60,51 +41,7 @@ export default function QuestionPage() {
reset({ content: "" });
};
- // В режиме отладки: если пользователь ещё не загружен, форсируем refresh(), чтобы увидеть логи AuthProvider
- useEffect(() => {
- if (debugEnabled && !user) {
- try {
- void refresh();
- console.log("[QuestionPage] forced refresh() to trigger /auth logs");
- } catch {
- // no-op for debug
- }
- }
- }, [debugEnabled, user, refresh]);
-
- // Dev/log-flag логирование для диагностики
- if (debugEnabled) {
- // Статус и базовые данные
- console.log("[QuestionPage] DEBUG ENABLED");
- console.log("[QuestionPage] status:", status, "questionId:", id);
- if (user) {
- console.log("[QuestionPage] auth user:", {
- id: (user as unknown as Record)["id"],
- username: (user as unknown as Record)["username"],
- role: (user as unknown as Record)["role"],
- });
- } else {
- console.log("[QuestionPage] auth user: null");
- }
- if (question) {
- const qAny = question as unknown as Record;
- const qUser =
- (qAny?.["user"] as Record | undefined) ?? undefined;
- console.log("[QuestionPage] question owner raw fields:", {
- questionId: qAny?.["id"],
- user: qUser
- ? { id: qUser["id"], username: qUser["username"] }
- : undefined,
- userId: qAny?.["userId"],
- authorId: qAny?.["authorId"],
- ownerId: qAny?.["ownerId"],
- createdById: qAny?.["createdById"],
- answersLen: Array.isArray(qAny?.["answers"] as unknown[])
- ? (qAny?.["answers"] as unknown[]).length
- : undefined,
- });
- }
- }
+ // удалены dev-логирование и принудительный refresh
if (status === "pending")
return (
@@ -132,7 +69,6 @@ export default function QuestionPage() {
);
if (!question) return Вопрос не найден
;
-
// Только автор вопроса может помечать ответы правильными/неправильными
// Поддерживаем разные возможные формы данных от API: user.id | userId | user.userId
const qAny = question as unknown as Record;
@@ -177,15 +113,6 @@ export default function QuestionPage() {
((ownerId != null && String(user.id) === String(ownerId)) ||
(ownerName != null && user.username === ownerName));
- if (debugEnabled) {
- console.log("[QuestionPage] derived owner:", {
- ownerId,
- ownerName,
- isOwner,
- currentUserId: user?.id,
- currentUserName: user?.username,
- });
- }
return (
@@ -219,27 +146,11 @@ export default function QuestionPage() {
canMark={isOwner}
pending={markPending}
onMarkCorrect={() => {
- if (!isOwner) {
- alert("Только автор вопроса может помечать ответы.");
- return;
- }
- if (debugEnabled) {
- console.log("[QuestionPage] action: mark correct", {
- answerId: a.id,
- });
- }
+ if (!isOwner) return;
setAnswerState({ answerId: a.id, state: "correct" });
}}
onMarkIncorrect={() => {
- if (!isOwner) {
- alert("Только автор вопроса может помечать ответы.");
- return;
- }
- if (debugEnabled) {
- console.log("[QuestionPage] action: unmark (incorrect)", {
- answerId: a.id,
- });
- }
+ if (!isOwner) return;
setAnswerState({ answerId: a.id, state: "incorrect" });
}}
/>
diff --git a/src/shared/api/http.ts b/src/shared/api/http.ts
index c7c8d6e..2120228 100644
--- a/src/shared/api/http.ts
+++ b/src/shared/api/http.ts
@@ -11,82 +11,10 @@ export const http = axios.create({
withCredentials: true,
timeout: 10000,
});
-// Стартовый лог окружения HTTP при включённом дебаге
-if (getDebugEnabled()) {
- try {
- console.log("[HTTP] init", {
- baseURL: API_BASE_URL,
- location: typeof window !== "undefined" ? window.location.href : "n/a",
- withCredentials: true,
- timeout: http.defaults.timeout,
- });
- } catch {
- /* noop */
- }
-}
-
-function getDebugEnabled(): boolean {
- try {
- const envDev = Boolean(
- (import.meta as unknown as { env?: Record
}).env?.DEV
- );
- const qs =
- typeof window !== "undefined"
- ? new URLSearchParams(window.location.search)
- : undefined;
- const fromQuery = qs?.get("debugQuestionPage") === "1";
- const fromLocal =
- typeof window !== "undefined" &&
- !!window.localStorage &&
- window.localStorage.getItem("debugQuestionPage") === "1";
- return envDev || !!fromQuery || !!fromLocal;
- } catch {
- return false;
- }
-}
-
-http.interceptors.request.use((config) => {
- if (getDebugEnabled()) {
- try {
- console.log("[HTTP] →", config.method?.toUpperCase(), config.url, {
- baseURL: config.baseURL,
- withCredentials: config.withCredentials,
- });
- } catch {
- /* noop */
- }
- }
- return config;
-});
http.interceptors.response.use(
- (r) => {
- if (getDebugEnabled()) {
- try {
- console.log("[HTTP] ←", r.config.method?.toUpperCase(), r.config.url, {
- status: r.status,
- });
- } catch {
- /* noop */
- }
- }
- return r;
- },
- (error) => {
- if (getDebugEnabled()) {
- try {
- const cfg = (error?.config ?? {}) as { method?: string; url?: string };
- const status = error?.response?.status;
- console.log("[HTTP] ×", cfg.method?.toUpperCase(), cfg.url, {
- status,
- message: error?.message,
- });
- } catch {
- /* noop */
- }
- }
- return Promise.reject(error);
- }
+ (r) => r,
+ (error) => Promise.reject(error)
);
export type HttpError = {
From c29a22064794881d2f19887b5c32447c73f92040 Mon Sep 17 00:00:00 2001
From: kotru21
Date: Sun, 17 Aug 2025 22:52:39 +0300
Subject: [PATCH 21/40] Improve AnswerItemView styling and correct label
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Refactored AnswerItemView to use conditional class names for correct and normal answers, enhancing visual distinction. Updated the correct answer label to 'Верный ответ' with improved styling. Removed minor extraneous whitespace in auth and question page files.
---
src/app/providers/auth.tsx | 1 -
src/pages/question/QuestionPage.tsx | 1 -
src/pages/question/ui/AnswerItemView.tsx | 13 +++++++++++--
3 files changed, 11 insertions(+), 4 deletions(-)
diff --git a/src/app/providers/auth.tsx b/src/app/providers/auth.tsx
index d0eb99b..0a27256 100644
--- a/src/app/providers/auth.tsx
+++ b/src/app/providers/auth.tsx
@@ -6,7 +6,6 @@ import { AuthContext, type User } from "./auth-context";
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
-
const refresh = useCallback(async () => {
try {
diff --git a/src/pages/question/QuestionPage.tsx b/src/pages/question/QuestionPage.tsx
index 1c2c913..8e558b8 100644
--- a/src/pages/question/QuestionPage.tsx
+++ b/src/pages/question/QuestionPage.tsx
@@ -113,7 +113,6 @@ export default function QuestionPage() {
((ownerId != null && String(user.id) === String(ownerId)) ||
(ownerName != null && user.username === ownerName));
-
return (
diff --git a/src/pages/question/ui/AnswerItemView.tsx b/src/pages/question/ui/AnswerItemView.tsx
index 97cabcf..2256b40 100644
--- a/src/pages/question/ui/AnswerItemView.tsx
+++ b/src/pages/question/ui/AnswerItemView.tsx
@@ -18,8 +18,13 @@ export const AnswerItemView = memo(function AnswerItemView({
onMarkIncorrect,
pending,
}: AnswerItemViewProps) {
+ const itemBase = "border rounded p-2 text-sm transition-colors";
+ const itemNormal =
+ "bg-white dark:bg-neutral-800 border-neutral-200 dark:border-neutral-700";
+ const itemCorrect =
+ "bg-green-50 border-green-400 dark:bg-green-900/30 dark:border-green-500";
return (
-
+
- {isCorrect &&
correct }
+ {isCorrect && (
+
+ Верный ответ
+
+ )}
{canMark &&
(isCorrect ? (
Date: Sun, 17 Aug 2025 22:59:30 +0300
Subject: [PATCH 22/40] Improve header scroll behavior and UI links
Refactored Header to use requestAnimationFrame for scroll event handling and improved atTop state management with useRef. Updated HeaderView to use smoother transition timing and replaced username display with a profile link, removing redundant account link.
---
src/app/Header.tsx | 40 ++++++++++++++++++++++++++++++++++++---
src/app/ui/HeaderView.tsx | 10 ++++++----
2 files changed, 43 insertions(+), 7 deletions(-)
diff --git a/src/app/Header.tsx b/src/app/Header.tsx
index 2eeaf65..22f28a2 100644
--- a/src/app/Header.tsx
+++ b/src/app/Header.tsx
@@ -1,4 +1,4 @@
-import { memo, useEffect, useState } from "react";
+import { memo, useEffect, useRef, useState } from "react";
import HeaderView from "./ui/HeaderView";
import type { HeaderViewProps } from "./ui/HeaderView";
@@ -6,10 +6,44 @@ type Props = Omit;
function Header(props: Props) {
const [atTop, setAtTop] = useState(true);
+ const atTopRef = useRef(true);
+ // держим ref в синхронизации со стейтом
useEffect(() => {
- const onScroll = () => setAtTop(window.scrollY <= 8);
- onScroll();
+ atTopRef.current = atTop;
+ }, [atTop]);
+
+ useEffect(() => {
+ const COLLAPSE_Y = 24; // ~разница высоты между py-5 и py-2
+ const EXPAND_Y = 0;
+ let ticking = false;
+
+ const evalPos = () => {
+ const y =
+ window.scrollY ?? document.documentElement.scrollTop ?? (0 as number);
+ let next = atTopRef.current;
+ if (atTopRef.current) {
+ if (y > COLLAPSE_Y) next = false;
+ } else {
+ if (y <= EXPAND_Y) next = true;
+ }
+ if (next !== atTopRef.current) {
+ atTopRef.current = next;
+ setAtTop(next);
+ }
+ };
+
+ const onScroll = () => {
+ if (ticking) return;
+ ticking = true;
+ requestAnimationFrame(() => {
+ ticking = false;
+ evalPos();
+ });
+ };
+
+ // начальная инициализация
+ evalPos();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
diff --git a/src/app/ui/HeaderView.tsx b/src/app/ui/HeaderView.tsx
index e6f676b..dd29919 100644
--- a/src/app/ui/HeaderView.tsx
+++ b/src/app/ui/HeaderView.tsx
@@ -20,7 +20,7 @@ function HeaderView({
return (
@@ -36,14 +36,16 @@ function HeaderView({
{user ? (
<>
-
+
{user.username}
-
+
Создать
-
Account
Logout +{" "}
From e1032fd1cec291c6298100cb8e8fce26651cb738 Mon Sep 17 00:00:00 2001
From: kotru21
Date: Sat, 23 Aug 2025 02:14:33 +0300
Subject: [PATCH 23/40] websocket
---
src/app/providers/auth.tsx | 47 +-
src/pages/snippet/SnippetPage.tsx | 77 +-
src/shared/api/http.ts | 20 +-
src/shared/socket.ts | 34 +-
websocket-server/README.md | 33 +
websocket-server/package-lock.json | 736 ++++++++++++++++++
websocket-server/package.json | 19 +
websocket-server/src/index.ts | 11 +
.../src/modules/comments/commentsGateway.ts | 42 +
.../src/modules/comments/commentsService.ts | 4 +
websocket-server/src/modules/core/server.ts | 43 +
websocket-server/src/modules/core/types.ts | 13 +
websocket-server/tsconfig.json | 12 +
13 files changed, 1066 insertions(+), 25 deletions(-)
create mode 100644 websocket-server/README.md
create mode 100644 websocket-server/package-lock.json
create mode 100644 websocket-server/package.json
create mode 100644 websocket-server/src/index.ts
create mode 100644 websocket-server/src/modules/comments/commentsGateway.ts
create mode 100644 websocket-server/src/modules/comments/commentsService.ts
create mode 100644 websocket-server/src/modules/core/server.ts
create mode 100644 websocket-server/src/modules/core/types.ts
create mode 100644 websocket-server/tsconfig.json
diff --git a/src/app/providers/auth.tsx b/src/app/providers/auth.tsx
index 0a27256..ea550b7 100644
--- a/src/app/providers/auth.tsx
+++ b/src/app/providers/auth.tsx
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
-import { http, toHttpError } from "../../shared/api/http";
+import { http, toHttpError, setAuthToken } from "../../shared/api/http";
import { unwrapData } from "../../shared/api/normalize";
import { AuthContext, type User } from "./auth-context";
@@ -7,10 +7,28 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
+ const storeTokenFromRaw = (raw: unknown) => {
+ if (raw && typeof raw === "object") {
+ const obj = raw as Record;
+ const tokenLike = (obj["accessToken"] || obj["token"] || obj["jwt"]) as
+ | string
+ | undefined;
+ if (typeof tokenLike === "string" && tokenLike.length > 10) {
+ setAuthToken(tokenLike);
+ try {
+ localStorage.setItem("authToken", tokenLike);
+ } catch {
+ // ignore localStorage write errors
+ }
+ }
+ }
+ };
+
const refresh = useCallback(async () => {
try {
const res = await http.get("/auth");
const raw = unwrapData(res.data);
+ storeTokenFromRaw(raw);
const normalized: User = {
id: Number((raw as Record)?.["id"] ?? 0),
username: String((raw as Record)?.["username"] ?? ""),
@@ -30,12 +48,21 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
if (didInit.current) return;
didInit.current = true;
+ // Восстанавливаем токен из localStorage (если API не использует httpOnly cookie)
+ try {
+ const saved = localStorage.getItem("authToken");
+ if (saved) setAuthToken(saved);
+ } catch {
+ // ignore localStorage errors
+ }
void refresh();
}, [refresh]);
const login = async (username: string, password: string) => {
try {
- await http.post("/auth/login", { username, password });
+ const res = await http.post("/auth/login", { username, password });
+ const raw = unwrapData(res.data);
+ storeTokenFromRaw(raw);
await refresh();
} catch (e) {
const err = toHttpError(e);
@@ -46,13 +73,25 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
};
const logout = async () => {
- await http.post("/auth/logout");
+ try {
+ await http.post("/auth/logout");
+ } catch {
+ // ignore network errors on logout
+ }
setUser(null);
+ setAuthToken(null);
+ try {
+ localStorage.removeItem("authToken");
+ } catch {
+ // ignore localStorage errors
+ }
};
const register = async (username: string, password: string) => {
try {
- await http.post("/register", { username, password });
+ const res = await http.post("/register", { username, password });
+ const raw = unwrapData(res.data);
+ storeTokenFromRaw(raw);
} catch (e) {
const err = toHttpError(e);
const error = new Error(err.message || "Register failed");
diff --git a/src/pages/snippet/SnippetPage.tsx b/src/pages/snippet/SnippetPage.tsx
index 3b4c548..f4004cf 100644
--- a/src/pages/snippet/SnippetPage.tsx
+++ b/src/pages/snippet/SnippetPage.tsx
@@ -2,6 +2,10 @@ import { useParams } from "react-router-dom";
import { useSnippet } from "../../entities/snippet/api";
import { useAuth } from "../../app/providers/useAuth";
import { useEffect, useState } from "react";
+import type {
+ Snippet as SnippetType,
+ Comment as SnippetComment,
+} from "../../entities/snippet/types";
import { http, toHttpError } from "../../shared/api/http";
import { useQueryClient } from "@tanstack/react-query";
import { BackLink } from "../../shared/ui/BackLink";
@@ -28,23 +32,49 @@ export default function SnippetPage() {
if (!snippetId) return;
const socket = getSocket();
const channel = `snippet:${snippetId}`;
- const onCreated = (payload: { snippetId: number }) => {
- if (payload?.snippetId === snippetId) {
- qc.invalidateQueries({ queryKey: ["snippet", snippetId] });
- }
- };
- const onDeleted = (payload: { snippetId: number }) => {
+
+ const onCreated = (payload: {
+ snippetId?: number;
+ id?: number;
+ content?: string;
+ user?: { username: string };
+ }) => {
if (payload?.snippetId === snippetId) {
+ // Мгновенно оптимистично добавляем комментарий, если он ещё не в списке
+ qc.setQueryData(["snippet", snippetId], (prev) => {
+ const prevTyped = prev as SnippetType | undefined;
+ if (!prevTyped) return prevTyped;
+ const exists = prevTyped.comments?.some(
+ (c: SnippetComment) => c.id === payload.id
+ );
+ if (exists) return prevTyped;
+ if (!payload.id) return prevTyped;
+ const newComment: SnippetComment = {
+ id: Number(payload.id),
+ content: String(payload.content ?? ""),
+ user: {
+ id: 0,
+ username: payload.user?.username || "unknown",
+ role: "user",
+ },
+ };
+ return {
+ ...prevTyped,
+ comments: [...(prevTyped.comments || []), newComment],
+ commentsCount: (prevTyped.commentsCount || 0) + 1,
+ } as SnippetType;
+ });
+ // Триггерим актуализацию из API на всякий случай
qc.invalidateQueries({ queryKey: ["snippet", snippetId] });
}
};
+
socket.emit("join", channel);
socket.on("comment:created", onCreated);
- socket.on("comment:deleted", onDeleted);
+
return () => {
socket.emit("leave", channel);
socket.off("comment:created", onCreated);
- socket.off("comment:deleted", onDeleted);
};
}, [snippetId, qc]);
@@ -53,7 +83,36 @@ export default function SnippetPage() {
setOk(null);
setPending(true);
try {
- await http.post("/comments", { content, snippetId });
+ const res = await http.post("/comments", { content, snippetId });
+ // Пытаемся вытащить id из API ответа (обёртка data может отличаться). Используем несколько fallback.
+ let createdId: number | undefined;
+ const rawUnknown: unknown = res.data;
+ if (rawUnknown && typeof rawUnknown === "object") {
+ const rawObj = rawUnknown as Record;
+ if (typeof rawObj.id !== "undefined") createdId = Number(rawObj.id);
+ else if (
+ rawObj.data &&
+ typeof rawObj.data === "object" &&
+ rawObj.data !== null &&
+ typeof (rawObj.data as Record).id !== "undefined"
+ ) {
+ createdId = Number(
+ (rawObj.data as Record).id as unknown as number
+ );
+ }
+ }
+ // Локально инициируем создание через вебсокет (сервер всё равно сохранит и ретранслирует)
+ try {
+ const socket = getSocket();
+ socket.emit("comment:create", {
+ content,
+ snippetId,
+ id: createdId,
+ user: { username: user?.username },
+ });
+ } catch {
+ // ignore socket emit errors
+ }
setContent("");
setOk("Комментарий отправлен");
// При socket.io обновление прилетит и так; оставим инвалидацию на случай деградации
diff --git a/src/shared/api/http.ts b/src/shared/api/http.ts
index 2120228..fff0d2a 100644
--- a/src/shared/api/http.ts
+++ b/src/shared/api/http.ts
@@ -1,4 +1,4 @@
-import axios from "axios";
+import axios, { AxiosHeaders } from "axios";
// Use relative '/api' by default to leverage Vite proxy (avoids CORS with credentials).
// Can be overridden via VITE_API_BASE_URL if your API supports proper CORS for credentials.
@@ -12,6 +12,24 @@ export const http = axios.create({
timeout: 10000,
});
+// In-memory bearer token (optional) – fallback when API doesn't use httpOnly cookies
+let authToken: string | null = null;
+export function setAuthToken(token: string | null) {
+ authToken = token;
+}
+
+http.interceptors.request.use((config) => {
+ if (authToken) {
+ if (!config.headers) config.headers = new AxiosHeaders();
+ // Приводим для установки значения без конфликтов типов
+ const h = config.headers as Record;
+ if (h["Authorization"] == null) {
+ (h as Record)["Authorization"] = `Bearer ${authToken}`;
+ }
+ }
+ return config;
+});
+
http.interceptors.response.use(
(r) => r,
(error) => Promise.reject(error)
diff --git a/src/shared/socket.ts b/src/shared/socket.ts
index 23da1a8..0e8c295 100644
--- a/src/shared/socket.ts
+++ b/src/shared/socket.ts
@@ -1,29 +1,41 @@
import { io, type Socket } from "socket.io-client";
-// Автоконфигурация пути под Vite proxy: если API ходит на /api, сокеты ждём на /api/socket.io
-const API_BASE = (import.meta as unknown as { env?: Record })
- .env?.VITE_API_BASE_URL;
-
+// Правильный доступ к Vite env
+const API_BASE = import.meta.env.VITE_API_BASE_URL as string | undefined;
+const isDev = import.meta.env.DEV;
const DEFAULT_PATH =
API_BASE && API_BASE.startsWith("/api") ? "/api/socket.io" : "/socket.io";
-
const SOCKET_URL =
- (import.meta as unknown as { env?: Record }).env
- ?.VITE_SOCKET_URL || undefined; // undefined => текущий origin
+ (import.meta.env.VITE_SOCKET_URL as string | undefined) ||
+ (isDev ? "http://localhost:4000" : undefined);
const SOCKET_PATH =
- (import.meta as unknown as { env?: Record }).env
- ?.VITE_SOCKET_PATH || DEFAULT_PATH;
+ (import.meta.env.VITE_SOCKET_PATH as string | undefined) || DEFAULT_PATH;
let socketRef: Socket | null = null;
export function getSocket(): Socket {
if (socketRef) return socketRef;
- socketRef = io(SOCKET_URL, {
+ socketRef = io(SOCKET_URL ?? window.location.origin, {
path: SOCKET_PATH,
withCredentials: true,
autoConnect: true,
- transports: ["websocket", "polling"],
+ transports: ["websocket"],
});
+ if (isDev) {
+ socketRef.on("connect", () => {
+ console.log("[socket] connected", {
+ id: socketRef?.id,
+ url: SOCKET_URL,
+ path: SOCKET_PATH,
+ });
+ });
+ socketRef.io.on("error", (err) => {
+ console.error("[socket] error", err);
+ });
+ socketRef.on("connect_error", (err) => {
+ console.error("[socket] connect_error", err.message);
+ });
+ }
return socketRef;
}
diff --git a/websocket-server/README.md b/websocket-server/README.md
new file mode 100644
index 0000000..b8415ab
--- /dev/null
+++ b/websocket-server/README.md
@@ -0,0 +1,33 @@
+# WebSocket (Socket.io) сервер комментариев
+
+Запуск dev:
+
+```bash
+cd websocket-server
+npm run dev
+```
+
+Сборка и запуск prod:
+
+```bash
+npm run build
+npm start
+```
+
+Эндпоинт: ws://localhost:4000 (socket.io протокол)
+
+События:
+
+- join (room: string) – подписка на `snippet:{id}` или `question:{id}`
+- leave (room: string)
+- comment:create { content, snippetId? , questionId? }
+- comment:created (payload комментария)
+- comment:error { message }
+
+Поток:
+
+1. Клиент делает HTTP POST /comments (persist) -> 201
+2. Клиент (или сервер после повторного подтверждения) вызывает socket emit comment:create
+3. Сервер сохраняет (saveComment) и рассылает comment:created в соответствующую комнату
+
+Отладка: в dev включены логи join/leave и connect.
diff --git a/websocket-server/package-lock.json b/websocket-server/package-lock.json
new file mode 100644
index 0000000..591ebc1
--- /dev/null
+++ b/websocket-server/package-lock.json
@@ -0,0 +1,736 @@
+{
+ "name": "websocket-server",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "websocket-server",
+ "version": "1.0.0",
+ "dependencies": {
+ "axios": "^1.6.0",
+ "socket.io": "^4.8.1"
+ },
+ "devDependencies": {
+ "ts-node": "^10.0.0",
+ "typescript": "^5.0.0"
+ }
+ },
+ "node_modules/@cspotcode/source-map-support": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
+ "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "0.3.9"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.9",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
+ "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.0.3",
+ "@jridgewell/sourcemap-codec": "^1.4.10"
+ }
+ },
+ "node_modules/@socket.io/component-emitter": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
+ "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
+ "license": "MIT"
+ },
+ "node_modules/@tsconfig/node10": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz",
+ "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@tsconfig/node12": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
+ "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@tsconfig/node14": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
+ "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@tsconfig/node16": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
+ "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/cors": {
+ "version": "2.8.19",
+ "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
+ "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "24.3.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz",
+ "integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.10.0"
+ }
+ },
+ "node_modules/accepts": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "~2.1.34",
+ "negotiator": "0.6.3"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.15.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
+ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-walk": {
+ "version": "8.3.4",
+ "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz",
+ "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "acorn": "^8.11.0"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/arg": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
+ "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "license": "MIT"
+ },
+ "node_modules/axios": {
+ "version": "1.11.0",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz",
+ "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==",
+ "license": "MIT",
+ "dependencies": {
+ "follow-redirects": "^1.15.6",
+ "form-data": "^4.0.4",
+ "proxy-from-env": "^1.1.0"
+ }
+ },
+ "node_modules/base64id": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
+ "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==",
+ "license": "MIT",
+ "engines": {
+ "node": "^4.5.0 || >= 5.9"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "license": "MIT",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cors": {
+ "version": "2.8.5",
+ "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
+ "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
+ "license": "MIT",
+ "dependencies": {
+ "object-assign": "^4",
+ "vary": "^1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/create-require": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
+ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.3.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
+ "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/diff": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
+ "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.3.1"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/engine.io": {
+ "version": "6.6.4",
+ "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.4.tgz",
+ "integrity": "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/cors": "^2.8.12",
+ "@types/node": ">=10.0.0",
+ "accepts": "~1.3.4",
+ "base64id": "2.0.0",
+ "cookie": "~0.7.2",
+ "cors": "~2.8.5",
+ "debug": "~4.3.1",
+ "engine.io-parser": "~5.2.1",
+ "ws": "~8.17.1"
+ },
+ "engines": {
+ "node": ">=10.2.0"
+ }
+ },
+ "node_modules/engine.io-parser": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
+ "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/engine.io/node_modules/ws": {
+ "version": "8.17.1",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
+ "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/follow-redirects": {
+ "version": "1.15.11",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
+ "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/RubenVerborgh"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependenciesMeta": {
+ "debug": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/form-data": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
+ "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.2",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/make-error": {
+ "version": "1.3.6",
+ "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
+ "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/proxy-from-env": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
+ "license": "MIT"
+ },
+ "node_modules/socket.io": {
+ "version": "4.8.1",
+ "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz",
+ "integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "~1.3.4",
+ "base64id": "~2.0.0",
+ "cors": "~2.8.5",
+ "debug": "~4.3.2",
+ "engine.io": "~6.6.0",
+ "socket.io-adapter": "~2.5.2",
+ "socket.io-parser": "~4.2.4"
+ },
+ "engines": {
+ "node": ">=10.2.0"
+ }
+ },
+ "node_modules/socket.io-adapter": {
+ "version": "2.5.5",
+ "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz",
+ "integrity": "sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "~4.3.4",
+ "ws": "~8.17.1"
+ }
+ },
+ "node_modules/socket.io-adapter/node_modules/ws": {
+ "version": "8.17.1",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
+ "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/socket.io-parser": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
+ "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
+ "license": "MIT",
+ "dependencies": {
+ "@socket.io/component-emitter": "~3.1.0",
+ "debug": "~4.3.1"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/ts-node": {
+ "version": "10.9.2",
+ "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
+ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@cspotcode/source-map-support": "^0.8.0",
+ "@tsconfig/node10": "^1.0.7",
+ "@tsconfig/node12": "^1.0.7",
+ "@tsconfig/node14": "^1.0.0",
+ "@tsconfig/node16": "^1.0.2",
+ "acorn": "^8.4.1",
+ "acorn-walk": "^8.1.1",
+ "arg": "^4.1.0",
+ "create-require": "^1.1.0",
+ "diff": "^4.0.1",
+ "make-error": "^1.1.1",
+ "v8-compile-cache-lib": "^3.0.1",
+ "yn": "3.1.1"
+ },
+ "bin": {
+ "ts-node": "dist/bin.js",
+ "ts-node-cwd": "dist/bin-cwd.js",
+ "ts-node-esm": "dist/bin-esm.js",
+ "ts-node-script": "dist/bin-script.js",
+ "ts-node-transpile-only": "dist/bin-transpile.js",
+ "ts-script": "dist/bin-script-deprecated.js"
+ },
+ "peerDependencies": {
+ "@swc/core": ">=1.2.50",
+ "@swc/wasm": ">=1.2.50",
+ "@types/node": "*",
+ "typescript": ">=2.7"
+ },
+ "peerDependenciesMeta": {
+ "@swc/core": {
+ "optional": true
+ },
+ "@swc/wasm": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.2",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
+ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.10.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz",
+ "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==",
+ "license": "MIT"
+ },
+ "node_modules/v8-compile-cache-lib": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
+ "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/yn": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
+ "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ }
+ }
+}
diff --git a/websocket-server/package.json b/websocket-server/package.json
new file mode 100644
index 0000000..bf07d9b
--- /dev/null
+++ b/websocket-server/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "websocket-server",
+ "version": "1.0.0",
+ "private": true,
+ "main": "dist/index.js",
+ "scripts": {
+ "dev": "ts-node src/index.ts",
+ "build": "tsc -p tsconfig.json",
+ "start": "node dist/index.js"
+ },
+ "dependencies": {
+ "axios": "^1.6.0",
+ "socket.io": "^4.8.1"
+ },
+ "devDependencies": {
+ "ts-node": "^10.0.0",
+ "typescript": "^5.0.0"
+ }
+}
diff --git a/websocket-server/src/index.ts b/websocket-server/src/index.ts
new file mode 100644
index 0000000..77e31a6
--- /dev/null
+++ b/websocket-server/src/index.ts
@@ -0,0 +1,11 @@
+import { WSServer } from "./modules/core/server";
+import { registerCommentsGateway } from "./modules/comments/commentsGateway";
+
+const PORT = Number(process.env.PORT) || 4000;
+const wsServer = new WSServer({
+ port: PORT,
+ corsOrigin: ["http://localhost:5173"],
+});
+registerCommentsGateway(wsServer);
+
+console.log(`Socket.io server started on ws://localhost:${PORT}`);
diff --git a/websocket-server/src/modules/comments/commentsGateway.ts b/websocket-server/src/modules/comments/commentsGateway.ts
new file mode 100644
index 0000000..d412335
--- /dev/null
+++ b/websocket-server/src/modules/comments/commentsGateway.ts
@@ -0,0 +1,42 @@
+import { WSServer } from "../core/server";
+
+// Шлюз теперь не создаёт комментарии в API (нет токена), а лишь ретранслирует событие после того как клиент сам сохранил через HTTP.
+export function registerCommentsGateway(server: WSServer) {
+ server.io.on("connection", (socket) => {
+ socket.on(
+ "comment:create",
+ (data: {
+ snippetId?: number;
+ questionId?: number;
+ id?: number;
+ content?: string;
+ user?: { username?: string };
+ }) => {
+ try {
+ const room = data.snippetId
+ ? `snippet:${data.snippetId}`
+ : data.questionId
+ ? `question:${data.questionId}`
+ : undefined;
+ const payload = {
+ snippetId: data.snippetId,
+ questionId: data.questionId,
+ id: data.id,
+ content: data.content,
+ user: { username: data.user?.username || "unknown" },
+ };
+ if (room) {
+ server.io.to(room).emit("comment:created", payload);
+ } else {
+ server.io.emit("comment:created", payload);
+ }
+ } catch (err) {
+ if (process.env.NODE_ENV !== "production") {
+ console.error("comment:create relay failed", err);
+ }
+ socket.emit("comment:error", { message: "Relay failed" });
+ }
+ }
+ );
+ });
+}
diff --git a/websocket-server/src/modules/comments/commentsService.ts b/websocket-server/src/modules/comments/commentsService.ts
new file mode 100644
index 0000000..099d3ad
--- /dev/null
+++ b/websocket-server/src/modules/comments/commentsService.ts
@@ -0,0 +1,4 @@
+// Legacy: изначально сервер пытался сам создавать комментарий через REST API.
+// Теперь логика перенесена на клиент (HTTP -> после успешного ответа -> socket emit).
+// Файл оставлен как напоминание; можно удалить при желании.
+export {};
diff --git a/websocket-server/src/modules/core/server.ts b/websocket-server/src/modules/core/server.ts
new file mode 100644
index 0000000..2dc0126
--- /dev/null
+++ b/websocket-server/src/modules/core/server.ts
@@ -0,0 +1,43 @@
+import { Server, Socket } from "socket.io";
+import { createServer } from "http";
+
+export interface WSServerOptions {
+ port: number;
+ corsOrigin?: string | string[];
+}
+
+export class WSServer {
+ public io: Server;
+
+ constructor(opts: WSServerOptions) {
+ const httpServer = createServer();
+ this.io = new Server(httpServer, {
+ cors: {
+ origin: opts.corsOrigin || true,
+ credentials: true,
+ },
+ });
+
+ this.io.on("connection", (socket) => this.onConnection(socket));
+ httpServer.listen(opts.port);
+ }
+
+ private onConnection(socket: Socket) {
+ socket.on("join", (room: string) => {
+ if (typeof room === "string") {
+ socket.join(room);
+ if (process.env.NODE_ENV !== "production") {
+ console.log(`[ws] ${socket.id} joined ${room}`);
+ }
+ }
+ });
+ socket.on("leave", (room: string) => {
+ if (typeof room === "string") {
+ socket.leave(room);
+ if (process.env.NODE_ENV !== "production") {
+ console.log(`[ws] ${socket.id} left ${room}`);
+ }
+ }
+ });
+ }
+}
diff --git a/websocket-server/src/modules/core/types.ts b/websocket-server/src/modules/core/types.ts
new file mode 100644
index 0000000..72c2c6b
--- /dev/null
+++ b/websocket-server/src/modules/core/types.ts
@@ -0,0 +1,13 @@
+export interface CommentDto {
+ id: number;
+ content: string;
+ user: { id?: number; username: string };
+ snippetId?: number;
+ questionId?: number;
+ createdAt?: string;
+}
+
+export type WSMessage = {
+ type: T;
+ payload: P;
+};
diff --git a/websocket-server/tsconfig.json b/websocket-server/tsconfig.json
new file mode 100644
index 0000000..2d43166
--- /dev/null
+++ b/websocket-server/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "commonjs",
+ "outDir": "dist",
+ "rootDir": "src",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true
+ },
+ "include": ["src"]
+}
From 89e5196d3dbd79b6f967e7a7a3b0d773e07ab3ac Mon Sep 17 00:00:00 2001
From: kotru21
Date: Sun, 24 Aug 2025 20:30:16 +0300
Subject: [PATCH 24/40] Add real-time updates for question answers via socket
Implemented socket-based real-time updates for question answers and answer state changes. Added hooks and emitters for subscribing to and broadcasting answer creation and state changes in both frontend and backend. Refactored related logic in QuestionPage and SnippetPage to use new socket utilities.
---
src/pages/question/QuestionPage.tsx | 86 +++++--
src/pages/snippet/SnippetPage.tsx | 79 +------
src/shared/socket.ts | 213 ++++++++++++++++++
.../src/modules/comments/commentsGateway.ts | 55 +++++
4 files changed, 348 insertions(+), 85 deletions(-)
diff --git a/src/pages/question/QuestionPage.tsx b/src/pages/question/QuestionPage.tsx
index 8e558b8..a649e89 100644
--- a/src/pages/question/QuestionPage.tsx
+++ b/src/pages/question/QuestionPage.tsx
@@ -14,6 +14,11 @@ import AnswerItemView from "./ui/AnswerItemView";
import AnswerFormView from "./ui/AnswerFormView";
import { BackLink } from "../../shared/ui/BackLink";
import { Skeleton } from "../../shared/ui/Skeleton";
+import {
+ useQuestionAnswers,
+ emitQuestionAnswer,
+ emitAnswerStateChange,
+} from "../../shared/socket";
const schema = z.object({
content: z.string().min(1, "Ответ не может быть пустым"),
@@ -28,7 +33,9 @@ export default function QuestionPage() {
const { mutateAsync: createAnswer, isPending } = useCreateAnswer(id!);
const { mutateAsync: setAnswerState, isPending: markPending } =
useSetAnswerState(id!);
- // debug-флаг и временные логи удалены
+
+ useQuestionAnswers(id);
+
const {
register,
handleSubmit,
@@ -37,11 +44,40 @@ export default function QuestionPage() {
} = useForm({ resolver: zodResolver(schema) });
const onSubmit = async (data: FormData) => {
- await createAnswer(data.content);
- reset({ content: "" });
- };
+ try {
+ const res = await createAnswer(data.content);
+
+ let createdId: number | string | undefined;
+ const rawUnknown: unknown = res;
+ if (rawUnknown && typeof rawUnknown === "object") {
+ const rawObj = rawUnknown as Record;
+ if (typeof rawObj.id !== "undefined")
+ createdId = rawObj.id as number | string;
+ else if (
+ rawObj.data &&
+ typeof rawObj.data === "object" &&
+ rawObj.data !== null &&
+ typeof (rawObj.data as Record).id !== "undefined"
+ ) {
+ createdId = (rawObj.data as Record).id as
+ | number
+ | string;
+ }
+ }
- // удалены dev-логирование и принудительный refresh
+ emitQuestionAnswer({
+ content: data.content,
+ questionId: id!,
+ id: createdId,
+ user: { username: user?.username || "unknown" },
+ isCorrect: false,
+ });
+
+ reset({ content: "" });
+ } catch (error) {
+ console.error("Ошибка при создании ответа:", error);
+ }
+ };
if (status === "pending")
return (
@@ -69,8 +105,6 @@ export default function QuestionPage() {
);
if (!question) return
Вопрос не найден
;
- // Только автор вопроса может помечать ответы правильными/неправильными
- // Поддерживаем разные возможные формы данных от API: user.id | userId | user.userId
const qAny = question as unknown as Record
;
const qUser =
(qAny?.["user"] as Record | undefined) ?? undefined;
@@ -113,6 +147,34 @@ export default function QuestionPage() {
((ownerId != null && String(user.id) === String(ownerId)) ||
(ownerName != null && user.username === ownerName));
+ const handleMarkCorrect = async (answerId: string | number) => {
+ if (!isOwner) return;
+ try {
+ await setAnswerState({ answerId, state: "correct" });
+ emitAnswerStateChange({
+ questionId: id!,
+ answerId,
+ isCorrect: true,
+ });
+ } catch (error) {
+ console.error("Ошибка при изменении статуса ответа:", error);
+ }
+ };
+
+ const handleMarkIncorrect = async (answerId: string | number) => {
+ if (!isOwner) return;
+ try {
+ await setAnswerState({ answerId, state: "incorrect" });
+ emitAnswerStateChange({
+ questionId: id!,
+ answerId,
+ isCorrect: false,
+ });
+ } catch (error) {
+ console.error("Ошибка при изменении статуса ответа:", error);
+ }
+ };
+
return (
@@ -144,14 +206,8 @@ export default function QuestionPage() {
isCorrect={a.isCorrect}
canMark={isOwner}
pending={markPending}
- onMarkCorrect={() => {
- if (!isOwner) return;
- setAnswerState({ answerId: a.id, state: "correct" });
- }}
- onMarkIncorrect={() => {
- if (!isOwner) return;
- setAnswerState({ answerId: a.id, state: "incorrect" });
- }}
+ onMarkCorrect={() => handleMarkCorrect(a.id)}
+ onMarkIncorrect={() => handleMarkIncorrect(a.id)}
/>
))}
diff --git a/src/pages/snippet/SnippetPage.tsx b/src/pages/snippet/SnippetPage.tsx
index f4004cf..1fe777e 100644
--- a/src/pages/snippet/SnippetPage.tsx
+++ b/src/pages/snippet/SnippetPage.tsx
@@ -1,11 +1,7 @@
import { useParams } from "react-router-dom";
import { useSnippet } from "../../entities/snippet/api";
import { useAuth } from "../../app/providers/useAuth";
-import { useEffect, useState } from "react";
-import type {
- Snippet as SnippetType,
- Comment as SnippetComment,
-} from "../../entities/snippet/types";
+import { useState } from "react";
import { http, toHttpError } from "../../shared/api/http";
import { useQueryClient } from "@tanstack/react-query";
import { BackLink } from "../../shared/ui/BackLink";
@@ -13,7 +9,7 @@ import SnippetDetailsView from "./ui/SnippetDetailsView";
import { Skeleton } from "../../shared/ui/Skeleton";
import CommentFormView from "./ui/CommentFormView";
import CommentsListView from "./ui/CommentsListView";
-import { getSocket } from "../../shared/socket";
+import { useSnippetComments, emitSnippetComment } from "../../shared/socket";
export default function SnippetPage() {
const { id } = useParams();
@@ -27,56 +23,8 @@ export default function SnippetPage() {
const [ok, setOk] = useState(null);
const [pending, setPending] = useState(false);
const qc = useQueryClient();
- // Подписываемся на сокет-события комментариев
- useEffect(() => {
- if (!snippetId) return;
- const socket = getSocket();
- const channel = `snippet:${snippetId}`;
- const onCreated = (payload: {
- snippetId?: number;
- id?: number;
- content?: string;
- user?: { username: string };
- }) => {
- if (payload?.snippetId === snippetId) {
- // Мгновенно оптимистично добавляем комментарий, если он ещё не в списке
- qc.setQueryData(["snippet", snippetId], (prev) => {
- const prevTyped = prev as SnippetType | undefined;
- if (!prevTyped) return prevTyped;
- const exists = prevTyped.comments?.some(
- (c: SnippetComment) => c.id === payload.id
- );
- if (exists) return prevTyped;
- if (!payload.id) return prevTyped;
- const newComment: SnippetComment = {
- id: Number(payload.id),
- content: String(payload.content ?? ""),
- user: {
- id: 0,
- username: payload.user?.username || "unknown",
- role: "user",
- },
- };
- return {
- ...prevTyped,
- comments: [...(prevTyped.comments || []), newComment],
- commentsCount: (prevTyped.commentsCount || 0) + 1,
- } as SnippetType;
- });
- // Триггерим актуализацию из API на всякий случай
- qc.invalidateQueries({ queryKey: ["snippet", snippetId] });
- }
- };
-
- socket.emit("join", channel);
- socket.on("comment:created", onCreated);
-
- return () => {
- socket.emit("leave", channel);
- socket.off("comment:created", onCreated);
- };
- }, [snippetId, qc]);
+ useSnippetComments(Number.isFinite(snippetId) ? snippetId : undefined);
const submit = async () => {
setError(null);
@@ -84,7 +32,6 @@ export default function SnippetPage() {
setPending(true);
try {
const res = await http.post("/comments", { content, snippetId });
- // Пытаемся вытащить id из API ответа (обёртка data может отличаться). Используем несколько fallback.
let createdId: number | undefined;
const rawUnknown: unknown = res.data;
if (rawUnknown && typeof rawUnknown === "object") {
@@ -101,21 +48,14 @@ export default function SnippetPage() {
);
}
}
- // Локально инициируем создание через вебсокет (сервер всё равно сохранит и ретранслирует)
- try {
- const socket = getSocket();
- socket.emit("comment:create", {
- content,
- snippetId,
- id: createdId,
- user: { username: user?.username },
- });
- } catch {
- // ignore socket emit errors
- }
+ emitSnippetComment({
+ content,
+ snippetId,
+ id: createdId,
+ user: { username: user?.username || "unknown" },
+ });
setContent("");
setOk("Комментарий отправлен");
- // При socket.io обновление прилетит и так; оставим инвалидацию на случай деградации
qc.invalidateQueries({ queryKey: ["snippet", snippetId] });
} catch (e) {
const err = toHttpError(e);
@@ -142,7 +82,6 @@ export default function SnippetPage() {
- {/* Форма комментария (новый лейаут: сразу под деталями) */}
diff --git a/src/shared/socket.ts b/src/shared/socket.ts
index 0e8c295..8b8c9e4 100644
--- a/src/shared/socket.ts
+++ b/src/shared/socket.ts
@@ -1,4 +1,6 @@
import { io, type Socket } from "socket.io-client";
+import { useEffect } from "react";
+import { useQueryClient } from "@tanstack/react-query";
// Правильный доступ к Vite env
const API_BASE = import.meta.env.VITE_API_BASE_URL as string | undefined;
@@ -45,3 +47,214 @@ export function closeSocket() {
socketRef = null;
}
}
+
+// Хук для подписки на комментарии к сниппету
+export function useSnippetComments(snippetId: number | undefined) {
+ const qc = useQueryClient();
+
+ useEffect(() => {
+ if (!snippetId) return;
+ const socket = getSocket();
+ const channel = `snippet:${snippetId}`;
+
+ const onCreated = (payload: {
+ snippetId?: number;
+ id?: number;
+ content?: string;
+ user?: { username: string };
+ }) => {
+ if (payload?.snippetId === snippetId) {
+ // Мгновенно оптимистично добавляем комментарий, если он ещё не в списке
+ qc.setQueryData(["snippet", snippetId], (prev: unknown) => {
+ if (!prev || typeof prev !== "object") return prev;
+ const prevSnippet = prev as Record
;
+ const comments = prevSnippet.comments as
+ | Array>
+ | undefined;
+ const exists = comments?.some((c) => c.id === payload.id);
+ if (exists) return prev;
+ if (!payload.id) return prev;
+ const newComment = {
+ id: Number(payload.id),
+ content: String(payload.content ?? ""),
+ user: {
+ id: 0,
+ username: payload.user?.username || "unknown",
+ role: "user",
+ },
+ };
+ return {
+ ...prevSnippet,
+ comments: [...(comments || []), newComment],
+ commentsCount: ((prevSnippet.commentsCount as number) || 0) + 1,
+ };
+ });
+ // Триггерим актуализацию из API на всякий случай
+ qc.invalidateQueries({ queryKey: ["snippet", snippetId] });
+ }
+ };
+
+ socket.emit("join", channel);
+ socket.on("comment:created", onCreated);
+
+ return () => {
+ socket.emit("leave", channel);
+ socket.off("comment:created", onCreated);
+ };
+ }, [snippetId, qc]);
+}
+
+// Хук для подписки на ответы к вопросу
+export function useQuestionAnswers(questionId: number | string | undefined) {
+ const qc = useQueryClient();
+
+ useEffect(() => {
+ if (!questionId) return;
+ const socket = getSocket();
+ const channel = `question:${questionId}`;
+
+ const onAnswerCreated = (payload: {
+ questionId?: number | string;
+ id?: number | string;
+ content?: string;
+ user?: { username: string };
+ isCorrect?: boolean;
+ }) => {
+ if (
+ payload?.questionId &&
+ String(payload.questionId) === String(questionId)
+ ) {
+ // Мгновенно оптимистично добавляем ответ, если он ещё не в списке
+ qc.setQueryData(["question", questionId], (prev: unknown) => {
+ if (!prev || typeof prev !== "object") return prev;
+ const prevQuestion = prev as Record;
+ const answers = prevQuestion.answers as
+ | Array>
+ | undefined;
+ const exists = answers?.some(
+ (a) => String(a.id) === String(payload.id)
+ );
+ if (exists) return prev;
+ if (!payload.id) return prev;
+ const newAnswer = {
+ id: payload.id,
+ content: String(payload.content ?? ""),
+ isCorrect: Boolean(payload.isCorrect ?? false),
+ user: {
+ id: 0,
+ username: payload.user?.username || "unknown",
+ role: "user",
+ },
+ };
+ return {
+ ...prevQuestion,
+ answers: [...(answers || []), newAnswer],
+ };
+ });
+ // Триггерим актуализацию из API на всякий случай
+ qc.invalidateQueries({ queryKey: ["question", questionId] });
+ }
+ };
+
+ const onAnswerStateChanged = (payload: {
+ questionId?: number | string;
+ answerId?: number | string;
+ isCorrect?: boolean;
+ }) => {
+ if (
+ payload?.questionId &&
+ String(payload.questionId) === String(questionId)
+ ) {
+ // Обновляем статус ответа
+ qc.setQueryData(["question", questionId], (prev: unknown) => {
+ if (!prev || typeof prev !== "object") return prev;
+ const prevQuestion = prev as Record;
+ const answers = prevQuestion.answers as
+ | Array>
+ | undefined;
+ if (!answers) return prev;
+ return {
+ ...prevQuestion,
+ answers: answers.map((answer) =>
+ String(answer.id) === String(payload.answerId)
+ ? { ...answer, isCorrect: Boolean(payload.isCorrect) }
+ : answer
+ ),
+ };
+ });
+ // Триггерим актуализацию из API на всякий случай
+ qc.invalidateQueries({ queryKey: ["question", questionId] });
+ }
+ };
+
+ socket.emit("join", channel);
+ socket.on("answer:created", onAnswerCreated);
+ socket.on("answer:state_changed", onAnswerStateChanged);
+
+ return () => {
+ socket.emit("leave", channel);
+ socket.off("answer:created", onAnswerCreated);
+ socket.off("answer:state_changed", onAnswerStateChanged);
+ };
+ }, [questionId, qc]);
+}
+
+// Функция для отправки комментария к сниппету
+export function emitSnippetComment(data: {
+ content: string;
+ snippetId: number;
+ id?: number;
+ user?: { username: string };
+}) {
+ try {
+ const socket = getSocket();
+ socket.emit("comment:create", {
+ content: data.content,
+ snippetId: data.snippetId,
+ id: data.id,
+ user: data.user,
+ });
+ } catch {
+ // ignore socket emit errors
+ }
+}
+
+// Функция для отправки ответа на вопрос
+export function emitQuestionAnswer(data: {
+ content: string;
+ questionId: number | string;
+ id?: number | string;
+ user?: { username: string };
+ isCorrect?: boolean;
+}) {
+ try {
+ const socket = getSocket();
+ socket.emit("answer:create", {
+ content: data.content,
+ questionId: data.questionId,
+ id: data.id,
+ user: data.user,
+ isCorrect: data.isCorrect ?? false,
+ });
+ } catch {
+ // ignore socket emit errors
+ }
+}
+
+// Функция для отправки изменения статуса ответа
+export function emitAnswerStateChange(data: {
+ questionId: number | string;
+ answerId: number | string;
+ isCorrect: boolean;
+}) {
+ try {
+ const socket = getSocket();
+ socket.emit("answer:state_change", {
+ questionId: data.questionId,
+ answerId: data.answerId,
+ isCorrect: data.isCorrect,
+ });
+ } catch {
+ // ignore socket emit errors
+ }
+}
diff --git a/websocket-server/src/modules/comments/commentsGateway.ts b/websocket-server/src/modules/comments/commentsGateway.ts
index d412335..6047636 100644
--- a/websocket-server/src/modules/comments/commentsGateway.ts
+++ b/websocket-server/src/modules/comments/commentsGateway.ts
@@ -3,6 +3,7 @@ import { WSServer } from "../core/server";
// Шлюз теперь не создаёт комментарии в API (нет токена), а лишь ретранслирует событие после того как клиент сам сохранил через HTTP.
export function registerCommentsGateway(server: WSServer) {
server.io.on("connection", (socket) => {
+ // Обработка создания комментариев
socket.on(
"comment:create",
(data: {
@@ -38,5 +39,59 @@ export function registerCommentsGateway(server: WSServer) {
}
}
);
+
+ // Обработка создания ответов на вопросы
+ socket.on(
+ "answer:create",
+ (data: {
+ questionId?: number | string;
+ id?: number | string;
+ content?: string;
+ user?: { username?: string };
+ isCorrect?: boolean;
+ }) => {
+ try {
+ const room = `question:${data.questionId}`;
+ const payload = {
+ questionId: data.questionId,
+ id: data.id,
+ content: data.content,
+ user: { username: data.user?.username || "unknown" },
+ isCorrect: data.isCorrect ?? false,
+ };
+ server.io.to(room).emit("answer:created", payload);
+ } catch (err) {
+ if (process.env.NODE_ENV !== "production") {
+ console.error("answer:create relay failed", err);
+ }
+ socket.emit("answer:error", { message: "Relay failed" });
+ }
+ }
+ );
+
+ // Обработка изменения статуса ответа
+ socket.on(
+ "answer:state_change",
+ (data: {
+ questionId?: number | string;
+ answerId?: number | string;
+ isCorrect?: boolean;
+ }) => {
+ try {
+ const room = `question:${data.questionId}`;
+ const payload = {
+ questionId: data.questionId,
+ answerId: data.answerId,
+ isCorrect: data.isCorrect,
+ };
+ server.io.to(room).emit("answer:state_changed", payload);
+ } catch (err) {
+ if (process.env.NODE_ENV !== "production") {
+ console.error("answer:state_change relay failed", err);
+ }
+ socket.emit("answer:error", { message: "State change relay failed" });
+ }
+ }
+ );
});
}
From 0e1afbbad0fbec0dd2d3d211eb8edefc9bad3f8d Mon Sep 17 00:00:00 2001
From: kotru21
Date: Sun, 24 Aug 2025 20:35:34 +0300
Subject: [PATCH 25/40] Simplify question owner detection logic
Replaced complex owner ID and username extraction with direct access to the question's user property. This improves readability and maintainability by relying on the typed Question model.
---
src/pages/question/QuestionPage.tsx | 44 ++++-------------------------
1 file changed, 5 insertions(+), 39 deletions(-)
diff --git a/src/pages/question/QuestionPage.tsx b/src/pages/question/QuestionPage.tsx
index a649e89..8abf610 100644
--- a/src/pages/question/QuestionPage.tsx
+++ b/src/pages/question/QuestionPage.tsx
@@ -105,47 +105,13 @@ export default function QuestionPage() {
);
if (!question) return Вопрос не найден
;
- const qAny = question as unknown as Record;
- const qUser =
- (qAny?.["user"] as Record | undefined) ?? undefined;
- const ownerIdCandidates: Array = [
- qUser?.["id"] as number | string | undefined,
- qAny?.["userId"] as number | string | undefined,
- qUser?.["userId"] as number | string | undefined,
- (qAny?.["author"] as Record | undefined)?.["id"] as
- | number
- | string
- | undefined,
- (qAny?.["owner"] as Record | undefined)?.["id"] as
- | number
- | string
- | undefined,
- (qAny?.["createdBy"] as Record | undefined)?.["id"] as
- | number
- | string
- | undefined,
- qAny?.["createdById"] as number | string | undefined,
- qAny?.["authorId"] as number | string | undefined,
- qAny?.["ownerId"] as number | string | undefined,
- ];
- const ownerId = ownerIdCandidates.find((v) => v !== undefined);
- const ownerNameCandidates: Array = [
- (qUser?.["username"] as string | undefined) ?? undefined,
- (qAny?.["author"] as Record | undefined)?.["username"] as
- | string
- | undefined,
- (qAny?.["owner"] as Record | undefined)?.["username"] as
- | string
- | undefined,
- (qAny?.["createdBy"] as Record | undefined)?.[
- "username"
- ] as string | undefined,
- ];
- const ownerName = ownerNameCandidates.find((v) => !!v);
+
+ const questionUser = (question as Question).user;
const isOwner =
!!user &&
- ((ownerId != null && String(user.id) === String(ownerId)) ||
- (ownerName != null && user.username === ownerName));
+ !!questionUser &&
+ (String(user.id) === String(questionUser.id) ||
+ user.username === questionUser.username);
const handleMarkCorrect = async (answerId: string | number) => {
if (!isOwner) return;
From 45f9dc5ee9bd48f3ffde5cc4806f563745fd203b Mon Sep 17 00:00:00 2001
From: kotru21
Date: Sun, 24 Aug 2025 21:39:35 +0300
Subject: [PATCH 26/40] Refactor form logic into reusable hooks
Moved question, answer, snippet, and account form logic into dedicated hooks for better separation of concerns and reusability. Updated related pages to use these hooks, simplifying component code and improving maintainability. Added new index files for hooks and refactored socket event handling into hooks. Also introduced language normalization and validation service for snippet creation.
---
src/entities/question/hooks/index.ts | 3 +
.../question/hooks/useAnswerActions.ts | 41 ++
src/entities/question/hooks/useAnswerForm.ts | 66 ++++
.../question/hooks/useQuestionOwnership.ts | 19 +
src/entities/snippet/hooks/index.ts | 1 +
src/entities/snippet/hooks/useCommentForm.ts | 70 ++++
src/entities/user/hooks/index.ts | 1 +
src/entities/user/hooks/useAccountForms.ts | 67 ++++
src/pages/account/AccountPage.tsx | 101 ++---
src/pages/account/AccountPageNew.tsx | 73 ++++
src/pages/create/CreatePage.tsx | 349 +++++++-----------
src/pages/create/CreatePageNew.tsx | 168 +++++++++
src/pages/create/hooks/index.ts | 1 +
src/pages/create/hooks/useCreateForms.ts | 105 ++++++
src/pages/question/QuestionPage.tsx | 119 ++----
src/pages/question/QuestionPageNew.tsx | 108 ++++++
src/pages/snippet/SnippetPage.tsx | 62 +---
src/shared/hooks/useAccountForms.ts | 67 ++++
src/shared/hooks/useAnswerActions.ts | 41 ++
src/shared/hooks/useAnswerForm.ts | 66 ++++
src/shared/hooks/useCommentForm.ts | 70 ++++
src/shared/hooks/useCreateForms.ts | 99 +++++
src/shared/hooks/useQuestionOwnership.ts | 19 +
src/shared/services/languageService.ts | 52 +++
24 files changed, 1336 insertions(+), 432 deletions(-)
create mode 100644 src/entities/question/hooks/index.ts
create mode 100644 src/entities/question/hooks/useAnswerActions.ts
create mode 100644 src/entities/question/hooks/useAnswerForm.ts
create mode 100644 src/entities/question/hooks/useQuestionOwnership.ts
create mode 100644 src/entities/snippet/hooks/index.ts
create mode 100644 src/entities/snippet/hooks/useCommentForm.ts
create mode 100644 src/entities/user/hooks/index.ts
create mode 100644 src/entities/user/hooks/useAccountForms.ts
create mode 100644 src/pages/account/AccountPageNew.tsx
create mode 100644 src/pages/create/CreatePageNew.tsx
create mode 100644 src/pages/create/hooks/index.ts
create mode 100644 src/pages/create/hooks/useCreateForms.ts
create mode 100644 src/pages/question/QuestionPageNew.tsx
create mode 100644 src/shared/hooks/useAccountForms.ts
create mode 100644 src/shared/hooks/useAnswerActions.ts
create mode 100644 src/shared/hooks/useAnswerForm.ts
create mode 100644 src/shared/hooks/useCommentForm.ts
create mode 100644 src/shared/hooks/useCreateForms.ts
create mode 100644 src/shared/hooks/useQuestionOwnership.ts
create mode 100644 src/shared/services/languageService.ts
diff --git a/src/entities/question/hooks/index.ts b/src/entities/question/hooks/index.ts
new file mode 100644
index 0000000..f4f6678
--- /dev/null
+++ b/src/entities/question/hooks/index.ts
@@ -0,0 +1,3 @@
+export { useAnswerForm } from "./useAnswerForm";
+export { useAnswerActions } from "./useAnswerActions";
+export { useQuestionOwnership } from "./useQuestionOwnership";
diff --git a/src/entities/question/hooks/useAnswerActions.ts b/src/entities/question/hooks/useAnswerActions.ts
new file mode 100644
index 0000000..f41df7b
--- /dev/null
+++ b/src/entities/question/hooks/useAnswerActions.ts
@@ -0,0 +1,41 @@
+import { useSetAnswerState } from "../api";
+import { emitAnswerStateChange } from "../../../shared/socket";
+
+export function useAnswerActions(questionId: string | number) {
+ const { mutateAsync: setAnswerState, isPending } =
+ useSetAnswerState(questionId);
+
+ const markCorrect = async (answerId: string | number) => {
+ try {
+ await setAnswerState({ answerId, state: "correct" });
+ emitAnswerStateChange({
+ questionId,
+ answerId,
+ isCorrect: true,
+ });
+ } catch (error) {
+ console.error("Ошибка при изменении статуса ответа:", error);
+ throw error;
+ }
+ };
+
+ const markIncorrect = async (answerId: string | number) => {
+ try {
+ await setAnswerState({ answerId, state: "incorrect" });
+ emitAnswerStateChange({
+ questionId,
+ answerId,
+ isCorrect: false,
+ });
+ } catch (error) {
+ console.error("Ошибка при изменении статуса ответа:", error);
+ throw error;
+ }
+ };
+
+ return {
+ markCorrect,
+ markIncorrect,
+ isPending,
+ };
+}
diff --git a/src/entities/question/hooks/useAnswerForm.ts b/src/entities/question/hooks/useAnswerForm.ts
new file mode 100644
index 0000000..08189cd
--- /dev/null
+++ b/src/entities/question/hooks/useAnswerForm.ts
@@ -0,0 +1,66 @@
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useCreateAnswer } from "../api";
+import { emitQuestionAnswer } from "../../../shared/socket";
+import { useAuth } from "../../../app/providers/useAuth";
+
+const schema = z.object({
+ content: z.string().min(1, "Ответ не может быть пустым"),
+});
+
+type FormData = z.infer;
+
+export function useAnswerForm(questionId: string | number) {
+ const { user } = useAuth();
+ const { mutateAsync: createAnswer, isPending } = useCreateAnswer(questionId);
+
+ const form = useForm({
+ resolver: zodResolver(schema),
+ });
+
+ const onSubmit = async (data: FormData) => {
+ try {
+ const res = await createAnswer(data.content);
+
+ // Пытаемся получить id созданного ответа
+ let createdId: number | string | undefined;
+ const rawUnknown: unknown = res;
+ if (rawUnknown && typeof rawUnknown === "object") {
+ const rawObj = rawUnknown as Record;
+ if (typeof rawObj.id !== "undefined")
+ createdId = rawObj.id as number | string;
+ else if (
+ rawObj.data &&
+ typeof rawObj.data === "object" &&
+ rawObj.data !== null &&
+ typeof (rawObj.data as Record).id !== "undefined"
+ ) {
+ createdId = (rawObj.data as Record).id as
+ | number
+ | string;
+ }
+ }
+
+ // Отправляем через вебсокет
+ emitQuestionAnswer({
+ content: data.content,
+ questionId,
+ id: createdId,
+ user: { username: user?.username || "unknown" },
+ isCorrect: false,
+ });
+
+ form.reset({ content: "" });
+ } catch (error) {
+ console.error("Ошибка при создании ответа:", error);
+ throw error;
+ }
+ };
+
+ return {
+ ...form,
+ onSubmit: form.handleSubmit(onSubmit),
+ isPending,
+ };
+}
diff --git a/src/entities/question/hooks/useQuestionOwnership.ts b/src/entities/question/hooks/useQuestionOwnership.ts
new file mode 100644
index 0000000..cc97315
--- /dev/null
+++ b/src/entities/question/hooks/useQuestionOwnership.ts
@@ -0,0 +1,19 @@
+import { useMemo } from "react";
+import { useAuth } from "../../../app/providers/useAuth";
+import type { Question } from "../types";
+
+export function useQuestionOwnership(question: Question | undefined) {
+ const { user } = useAuth();
+
+ return useMemo(() => {
+ if (!user || !question) return false;
+
+ const questionUser = question.user;
+ if (!questionUser) return false;
+
+ return (
+ String(user.id) === String(questionUser.id) ||
+ user.username === questionUser.username
+ );
+ }, [user, question]);
+}
diff --git a/src/entities/snippet/hooks/index.ts b/src/entities/snippet/hooks/index.ts
new file mode 100644
index 0000000..b150c84
--- /dev/null
+++ b/src/entities/snippet/hooks/index.ts
@@ -0,0 +1 @@
+export { useCommentForm } from "./useCommentForm";
diff --git a/src/entities/snippet/hooks/useCommentForm.ts b/src/entities/snippet/hooks/useCommentForm.ts
new file mode 100644
index 0000000..ec18cb1
--- /dev/null
+++ b/src/entities/snippet/hooks/useCommentForm.ts
@@ -0,0 +1,70 @@
+import { useState } from "react";
+import { http, toHttpError } from "../../../shared/api/http";
+import { useQueryClient } from "@tanstack/react-query";
+import { emitSnippetComment } from "../../../shared/socket";
+import { useAuth } from "../../../app/providers/useAuth";
+
+export function useCommentForm(snippetId: number) {
+ const { user } = useAuth();
+ const [content, setContent] = useState("");
+ const [error, setError] = useState(null);
+ const [ok, setOk] = useState(null);
+ const [pending, setPending] = useState(false);
+ const qc = useQueryClient();
+
+ const submit = async () => {
+ setError(null);
+ setOk(null);
+ setPending(true);
+
+ try {
+ const res = await http.post("/comments", { content, snippetId });
+
+ // Пытаемся вытащить id из API ответа
+ let createdId: number | undefined;
+ const rawUnknown: unknown = res.data;
+ if (rawUnknown && typeof rawUnknown === "object") {
+ const rawObj = rawUnknown as Record;
+ if (typeof rawObj.id !== "undefined") createdId = Number(rawObj.id);
+ else if (
+ rawObj.data &&
+ typeof rawObj.data === "object" &&
+ rawObj.data !== null &&
+ typeof (rawObj.data as Record).id !== "undefined"
+ ) {
+ createdId = Number(
+ (rawObj.data as Record).id as unknown as number
+ );
+ }
+ }
+
+ // Отправляем через вебсокет
+ emitSnippetComment({
+ content,
+ snippetId,
+ id: createdId,
+ user: { username: user?.username || "unknown" },
+ });
+
+ setContent("");
+ setOk("Комментарий отправлен");
+
+ // При socket.io обновление прилетит и так; оставим инвалидацию на случай деградации
+ qc.invalidateQueries({ queryKey: ["snippet", snippetId] });
+ } catch (e) {
+ const err = toHttpError(e);
+ setError(err.message || "Не удалось отправить комментарий");
+ } finally {
+ setPending(false);
+ }
+ };
+
+ return {
+ content,
+ setContent,
+ error,
+ ok,
+ pending,
+ submit,
+ };
+}
diff --git a/src/entities/user/hooks/index.ts b/src/entities/user/hooks/index.ts
new file mode 100644
index 0000000..46b3bbf
--- /dev/null
+++ b/src/entities/user/hooks/index.ts
@@ -0,0 +1 @@
+export { useAccountForms } from "./useAccountForms";
diff --git a/src/entities/user/hooks/useAccountForms.ts b/src/entities/user/hooks/useAccountForms.ts
new file mode 100644
index 0000000..c1344ec
--- /dev/null
+++ b/src/entities/user/hooks/useAccountForms.ts
@@ -0,0 +1,67 @@
+import { useState, useEffect } from "react";
+import { useUpdateMe, useUpdatePassword, useMe } from "../api";
+
+export function useAccountForms() {
+ const { data: me } = useMe();
+ const { mutateAsync: updateMe, isPending: isUpdatingMe } = useUpdateMe();
+ const { mutateAsync: updatePassword, isPending: isUpdatingPassword } =
+ useUpdatePassword();
+
+ const [username, setUsername] = useState(me?.username ?? "");
+ const [oldPassword, setOldPassword] = useState("");
+ const [newPassword, setNewPassword] = useState("");
+ const [message, setMessage] = useState(null);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (me?.username) setUsername(me.username);
+ }, [me?.username]);
+
+ const saveProfile = async () => {
+ setMessage(null);
+ setError(null);
+ try {
+ await updateMe({ username });
+ setMessage("Профиль обновлён");
+ } catch {
+ setError("Не удалось обновить профиль");
+ }
+ };
+
+ const changePassword = async () => {
+ setMessage(null);
+ setError(null);
+ if (!oldPassword || !newPassword) {
+ setError("Заполните все поля");
+ return;
+ }
+ try {
+ await updatePassword({ oldPassword, newPassword });
+ setMessage("Пароль изменён");
+ setOldPassword("");
+ setNewPassword("");
+ } catch {
+ setError("Не удалось изменить пароль");
+ }
+ };
+
+ return {
+ // Profile
+ username,
+ setUsername,
+ saveProfile,
+ isUpdatingMe,
+
+ // Password
+ oldPassword,
+ setOldPassword,
+ newPassword,
+ setNewPassword,
+ changePassword,
+ isUpdatingPassword,
+
+ // Messages
+ message,
+ error,
+ };
+}
diff --git a/src/pages/account/AccountPage.tsx b/src/pages/account/AccountPage.tsx
index ccc4816..ea2e854 100644
--- a/src/pages/account/AccountPage.tsx
+++ b/src/pages/account/AccountPage.tsx
@@ -1,16 +1,11 @@
-import { useEffect, useState } from "react";
import { useAuth } from "../../app/providers/useAuth";
import { BackLink } from "../../shared/ui/BackLink";
-import {
- useMe,
- useUpdateMe,
- useUpdatePassword,
- useUserStatistic,
-} from "../../entities/user/api";
+import { useMe, useUserStatistic } from "../../entities/user/api";
import AccountInfoView from "./ui/AccountInfoView";
import AccountStatsView from "./ui/AccountStatsView";
import ProfileFormView from "./ui/ProfileFormView";
import PasswordFormView from "./ui/PasswordFormView";
+import { useAccountForms } from "../../entities/user/hooks";
export default function AccountPage() {
const { user: authUser } = useAuth();
@@ -18,86 +13,58 @@ export default function AccountPage() {
const { data: me, status: meStatus } = useMe();
const idForStat = me?.id ?? userId;
const { data: stat, status: statStatus } = useUserStatistic(idForStat);
- const { mutateAsync: updateMe, isPending: isUpdatingMe } = useUpdateMe();
- const { mutateAsync: updatePassword, isPending: isUpdatingPassword } =
- useUpdatePassword();
-
- const [username, setUsername] = useState(me?.username ?? "");
- const [oldPassword, setOldPassword] = useState("");
- const [newPassword, setNewPassword] = useState("");
- const [message, setMessage] = useState(null);
- const [error, setError] = useState(null);
-
- useEffect(() => {
- if (me?.username) setUsername(me.username);
- }, [me?.username]);
+ const accountForms = useAccountForms();
const canEdit = !!authUser;
- const onSaveProfile = async () => {
- setMessage(null);
- setError(null);
- try {
- await updateMe({ username });
- setMessage("Профиль обновлён");
- } catch {
- setError("Не удалось обновить профиль");
- }
- };
-
- const onChangePassword = async () => {
- setMessage(null);
- setError(null);
- try {
- await updatePassword({ oldPassword, newPassword });
- setMessage("Пароль обновлён");
- setOldPassword("");
- setNewPassword("");
- } catch {
- setError("Не удалось обновить пароль");
- }
- };
-
return (
-
+
-
+ {me && (
+
+ )}
-
+ {stat && (
+
+ )}
{canEdit && (
)}
- {(message || error) && (
+ {(accountForms.message || accountForms.error) && (
- {message &&
{message}
}
- {error &&
{error}
}
+ {accountForms.message && (
+
{accountForms.message}
+ )}
+ {accountForms.error && (
+
{accountForms.error}
+ )}
)}
diff --git a/src/pages/account/AccountPageNew.tsx b/src/pages/account/AccountPageNew.tsx
new file mode 100644
index 0000000..f446a1c
--- /dev/null
+++ b/src/pages/account/AccountPageNew.tsx
@@ -0,0 +1,73 @@
+import { useAuth } from "../../app/providers/useAuth";
+import { BackLink } from "../../shared/ui/BackLink";
+import { useMe, useUserStatistic } from "../../entities/user/api";
+import AccountInfoView from "./ui/AccountInfoView";
+import AccountStatsView from "./ui/AccountStatsView";
+import ProfileFormView from "./ui/ProfileFormView";
+import PasswordFormView from "./ui/PasswordFormView";
+import { useAccountForms } from "../../entities/user/hooks";
+
+export default function AccountPage() {
+ const { user: authUser } = useAuth();
+ const userId = authUser?.id;
+ const { data: me, status: meStatus } = useMe();
+ const idForStat = me?.id ?? userId;
+ const { data: stat, status: statStatus } = useUserStatistic(idForStat);
+
+ // Используем хук для форм аккаунта
+ const accountForms = useAccountForms();
+ const canEdit = !!authUser;
+
+ return (
+
+
+
+ {me && (
+
+ )}
+
+ {stat && (
+
+ )}
+
+ {canEdit && (
+
+ Настройки профиля
+
+
+
+
+ )}
+
+ {(accountForms.message || accountForms.error) && (
+
+ {accountForms.message && (
+
{accountForms.message}
+ )}
+ {accountForms.error && (
+
{accountForms.error}
+ )}
+
+ )}
+
+ );
+}
diff --git a/src/pages/create/CreatePage.tsx b/src/pages/create/CreatePage.tsx
index 2476f2f..e5088b9 100644
--- a/src/pages/create/CreatePage.tsx
+++ b/src/pages/create/CreatePage.tsx
@@ -1,247 +1,164 @@
import { useState } from "react";
-import { z } from "zod";
-import { useForm } from "react-hook-form";
-import { zodResolver } from "@hookform/resolvers/zod";
import { useNavigate } from "react-router-dom";
-import { useCreateQuestion } from "../../entities/question/api";
-import { useCreateSnippet } from "../../entities/snippet/api";
import CodeEditor from "../../shared/ui/CodeEditor";
-import { toHttpError } from "../../shared/api/http";
-
-const questionSchema = z.object({
- title: z.string().min(3, "Минимум 3 символа"),
- description: z.string().min(1, "Описание обязательно"),
- attachedCode: z.string().optional(),
-});
-type QuestionFormData = z.infer
;
-
-const SUPPORTED_LANG_HINT =
- "Допустимые: JavaScript, Python, Java, C/C++, C#, Go, Kotlin, Ruby";
-
-function normalizeLanguageInput(input: string): string | undefined {
- const raw = (input || "").trim().toLowerCase();
- if (!raw) return undefined;
- const cleaned = raw.replace(/\s+/g, ""); // убираем пробелы: "c sharp" -> "csharp"
- const map: Record = {
- javascript: "JavaScript",
- js: "JavaScript",
- node: "JavaScript",
- python: "Python",
- py: "Python",
- java: "Java",
- c: "C",
- "c++": "C++",
- cpp: "C++",
- "c#": "C#",
- csharp: "C#",
- "c-sharp": "C#",
- golang: "Go",
- go: "Go",
- kotlin: "Kotlin",
- kt: "Kotlin",
- ruby: "Ruby",
- rb: "Ruby",
- };
- // сначала пробуем как есть
- if (map[raw]) return map[raw];
- // затем по "очищенному" ключу
- if (map[cleaned]) return map[cleaned];
- return undefined;
-}
-
-const snippetSchema = z.object({
- language: z
- .string()
- .min(1, "Укажите язык")
- .transform((v) => normalizeLanguageInput(v) ?? "__invalid__")
- .refine((v) => v !== "__invalid__", {
- message: `Неподдерживаемый язык. ${SUPPORTED_LANG_HINT}`,
- }),
- code: z.string().min(1, "Код обязателен"),
-});
-type SnippetFormData = z.infer;
+import { useQuestionForm, useSnippetForm } from "./hooks";
export default function CreatePage() {
+ const navigate = useNavigate();
const [mode, setMode] = useState<"question" | "snippet">("question");
- const nav = useNavigate();
- const [qError, setQError] = useState(null);
- const [sError, setSError] = useState(null);
-
- const {
- register: qReg,
- handleSubmit: qSubmit,
- formState: { errors: qErr, isSubmitting: qSub },
- setValue: qSetValue,
- watch: qWatch,
- } = useForm({ resolver: zodResolver(questionSchema) });
- const codeQ = qWatch("attachedCode") || "";
- const {
- register: sReg,
- handleSubmit: sSubmit,
- formState: { errors: sErr, isSubmitting: sSub },
- setValue: sSetValue,
- watch: sWatch,
- } = useForm({ resolver: zodResolver(snippetSchema) });
- const codeS = sWatch("code") || "";
-
- const { mutateAsync: createQuestion, isPending: qPending } =
- useCreateQuestion();
- const { mutateAsync: createSnippet, isPending: sPending } =
- useCreateSnippet();
-
- const onCreateQuestion = async (data: QuestionFormData) => {
- setQError(null);
- try {
- const created = await createQuestion(data);
- const id = (created as { id?: string | number } | undefined)?.id;
- if (id != null) nav(`/questions/${id}`);
- else nav("/");
- } catch (e) {
- const err = toHttpError(e);
- setQError(err.message || "Не удалось создать вопрос. Попробуйте позже.");
- }
- };
- const onCreateSnippet = async (data: SnippetFormData) => {
- setSError(null);
- try {
- const created = await createSnippet(data);
- const id = (created as { id?: string | number } | undefined)?.id;
- if (id != null) nav(`/snippets/${id}`);
- else nav("/");
- } catch (e) {
- const err = toHttpError(e);
- setSError(
- err.message ||
- "Не удалось создать сниппет. Проверьте корректность данных."
- );
- }
- };
+ const questionForm = useQuestionForm();
+ const snippetForm = useSnippetForm();
return (
-
-
+
+ {/* Tabs */}
+
setMode("question")}>
+ onClick={() => setMode("question")}
+ className={`pb-2 px-1 border-b-2 transition ${
+ mode === "question"
+ ? "border-blue-500 text-blue-600"
+ : "border-transparent text-gray-600 hover:text-gray-800"
+ }`}>
Вопрос
setMode("snippet")}>
+ onClick={() => setMode("snippet")}
+ className={`pb-2 px-1 border-b-2 transition ${
+ mode === "snippet"
+ ? "border-blue-500 text-blue-600"
+ : "border-transparent text-gray-600 hover:text-gray-800"
+ }`}>
Сниппет
- {mode === "question" ? (
-
-
-
-
-
Заголовок
-
- {qErr.title && (
-
{qErr.title.message}
- )}
-
-
-
Описание
-
- {qErr.description && (
-
- {qErr.description.message}
-
- )}
-
-
-
- Код (необязательно)
- qSetValue("attachedCode", v)}
- language="tsx"
- height="60vh"
- placeholder="Вставьте минимальный воспроизводимый пример"
- />
-
+ {/* Question Form */}
+ {mode === "question" && (
+
+
+
+ Заголовок *
+
+
+ {questionForm.errors.title && (
+
+ {questionForm.errors.title.message}
+
+ )}
+
+
+
+
+ Описание *
+
+
+ {questionForm.errors.description && (
+
+ {questionForm.errors.description.message}
+
+ )}
+
+
+
+
+ Прикрепленный код (опционально)
+
+ questionForm.setValue("attachedCode", value)}
+ placeholder="Введите код для демонстрации проблемы..."
+ />
- {qError && (
-
- {qError}
-
+
+ {questionForm.error && (
+ {questionForm.error}
)}
-
+
+
- Создать вопрос
+ disabled={questionForm.isPending}
+ className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed">
+ {questionForm.isPending ? "Создание..." : "Создать вопрос"}
+
+ navigate("/")}
+ className="bg-gray-600 text-white px-4 py-2 rounded-lg hover:bg-gray-700">
+ Отмена
- ) : (
-
-
-
-
-
Язык
-
- {sErr.language && (
-
- {sErr.language.message}
-
- )}
- {!sErr.language && (
-
- {SUPPORTED_LANG_HINT}
-
- )}
-
-
-
-
Код
-
sSetValue("code", v)}
- language={sWatch("language")}
- height="60vh"
- placeholder="Вставьте код сниппета"
- />
- {sErr.code && (
- {sErr.code.message}
- )}
-
+ )}
+
+ {/* Snippet Form */}
+ {mode === "snippet" && (
+
+
+
+ Язык программирования *
+
+
+ {snippetForm.errors.language && (
+
+ {snippetForm.errors.language.message}
+
+ )}
+
+ Допустимые: JavaScript, Python, Java, C/C++, C#, Go, Kotlin, Ruby
+
- {sError && (
-
- {sError}
-
+
+
+
+ Код *
+
+
snippetForm.setValue("code", value)}
+ language={snippetForm.watch("language")}
+ placeholder="Введите ваш код..."
+ />
+ {snippetForm.errors.code && (
+
+ {snippetForm.errors.code.message}
+
+ )}
+
+
+ {snippetForm.error && (
+ {snippetForm.error}
)}
-
+
+
- Создать сниппет
+ disabled={snippetForm.isPending}
+ className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed">
+ {snippetForm.isPending ? "Создание..." : "Создать сниппет"}
+
+ navigate("/")}
+ className="bg-gray-600 text-white px-4 py-2 rounded-lg hover:bg-gray-700">
+ Отмена
diff --git a/src/pages/create/CreatePageNew.tsx b/src/pages/create/CreatePageNew.tsx
new file mode 100644
index 0000000..e5088b9
--- /dev/null
+++ b/src/pages/create/CreatePageNew.tsx
@@ -0,0 +1,168 @@
+import { useState } from "react";
+import { useNavigate } from "react-router-dom";
+import CodeEditor from "../../shared/ui/CodeEditor";
+import { useQuestionForm, useSnippetForm } from "./hooks";
+
+export default function CreatePage() {
+ const navigate = useNavigate();
+ const [mode, setMode] = useState<"question" | "snippet">("question");
+
+ const questionForm = useQuestionForm();
+ const snippetForm = useSnippetForm();
+
+ return (
+
+ {/* Tabs */}
+
+ setMode("question")}
+ className={`pb-2 px-1 border-b-2 transition ${
+ mode === "question"
+ ? "border-blue-500 text-blue-600"
+ : "border-transparent text-gray-600 hover:text-gray-800"
+ }`}>
+ Вопрос
+
+ setMode("snippet")}
+ className={`pb-2 px-1 border-b-2 transition ${
+ mode === "snippet"
+ ? "border-blue-500 text-blue-600"
+ : "border-transparent text-gray-600 hover:text-gray-800"
+ }`}>
+ Сниппет
+
+
+
+ {/* Question Form */}
+ {mode === "question" && (
+
+
+
+ Заголовок *
+
+
+ {questionForm.errors.title && (
+
+ {questionForm.errors.title.message}
+
+ )}
+
+
+
+
+ Описание *
+
+
+ {questionForm.errors.description && (
+
+ {questionForm.errors.description.message}
+
+ )}
+
+
+
+
+ Прикрепленный код (опционально)
+
+ questionForm.setValue("attachedCode", value)}
+ placeholder="Введите код для демонстрации проблемы..."
+ />
+
+
+ {questionForm.error && (
+ {questionForm.error}
+ )}
+
+
+
+ {questionForm.isPending ? "Создание..." : "Создать вопрос"}
+
+ navigate("/")}
+ className="bg-gray-600 text-white px-4 py-2 rounded-lg hover:bg-gray-700">
+ Отмена
+
+
+
+ )}
+
+ {/* Snippet Form */}
+ {mode === "snippet" && (
+
+
+
+ Язык программирования *
+
+
+ {snippetForm.errors.language && (
+
+ {snippetForm.errors.language.message}
+
+ )}
+
+ Допустимые: JavaScript, Python, Java, C/C++, C#, Go, Kotlin, Ruby
+
+
+
+
+
+ Код *
+
+
snippetForm.setValue("code", value)}
+ language={snippetForm.watch("language")}
+ placeholder="Введите ваш код..."
+ />
+ {snippetForm.errors.code && (
+
+ {snippetForm.errors.code.message}
+
+ )}
+
+
+ {snippetForm.error && (
+ {snippetForm.error}
+ )}
+
+
+
+ {snippetForm.isPending ? "Создание..." : "Создать сниппет"}
+
+ navigate("/")}
+ className="bg-gray-600 text-white px-4 py-2 rounded-lg hover:bg-gray-700">
+ Отмена
+
+
+
+ )}
+
+ );
+}
diff --git a/src/pages/create/hooks/index.ts b/src/pages/create/hooks/index.ts
new file mode 100644
index 0000000..7880202
--- /dev/null
+++ b/src/pages/create/hooks/index.ts
@@ -0,0 +1 @@
+export { useQuestionForm, useSnippetForm } from "./useCreateForms";
diff --git a/src/pages/create/hooks/useCreateForms.ts b/src/pages/create/hooks/useCreateForms.ts
new file mode 100644
index 0000000..e2d37da
--- /dev/null
+++ b/src/pages/create/hooks/useCreateForms.ts
@@ -0,0 +1,105 @@
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useNavigate } from "react-router-dom";
+import { useCreateQuestion } from "../../../entities/question/api";
+import { useCreateSnippet } from "../../../entities/snippet/api";
+import {
+ normalizeLanguageInput,
+ SUPPORTED_LANG_HINT,
+} from "../../../shared/services/languageService";
+import { toHttpError } from "../../../shared/api/http";
+
+// Question Form
+const questionSchema = z.object({
+ title: z.string().min(3, "Минимум 3 символа"),
+ description: z.string().min(1, "Описание обязательно"),
+ attachedCode: z.string().optional(),
+});
+
+type QuestionFormData = z.infer
;
+
+export function useQuestionForm() {
+ const navigate = useNavigate();
+ const { mutateAsync: createQuestion, isPending } = useCreateQuestion();
+
+ const form = useForm({
+ resolver: zodResolver(questionSchema),
+ });
+
+ const onSubmit = async (data: QuestionFormData) => {
+ try {
+ const created = await createQuestion(data);
+ if (created?.id) {
+ navigate(`/questions/${created.id}`);
+ } else {
+ navigate("/");
+ }
+ } catch (e) {
+ const err = toHttpError(e);
+ form.setError("root", { message: err.message });
+ throw e;
+ }
+ };
+
+ return {
+ ...form,
+ onSubmit: form.handleSubmit(onSubmit),
+ isPending,
+ errors: form.formState.errors,
+ error: form.formState.errors.root?.message,
+ };
+}
+
+// Snippet Form
+const snippetSchema = z.object({
+ language: z.string().min(1, "Укажите язык"),
+ code: z.string().min(1, "Код обязателен"),
+});
+
+type SnippetFormData = z.infer;
+
+export function useSnippetForm() {
+ const navigate = useNavigate();
+ const { mutateAsync: createSnippet, isPending } = useCreateSnippet();
+
+ const form = useForm({
+ resolver: zodResolver(snippetSchema),
+ });
+
+ const onSubmit = async (data: SnippetFormData) => {
+ try {
+ // Валидируем язык отдельно
+ const normalizedLanguage = normalizeLanguageInput(data.language);
+ if (!normalizedLanguage) {
+ form.setError("language", {
+ message: `Неподдерживаемый язык. ${SUPPORTED_LANG_HINT}`,
+ });
+ return;
+ }
+
+ const created = await createSnippet({
+ language: normalizedLanguage,
+ code: data.code,
+ });
+
+ if (created?.id) {
+ navigate(`/snippets/${created.id}`);
+ } else {
+ navigate("/");
+ }
+ } catch (e) {
+ const err = toHttpError(e);
+ form.setError("root", { message: err.message });
+ throw e;
+ }
+ };
+
+ return {
+ ...form,
+ onSubmit: form.handleSubmit(onSubmit),
+ isPending,
+ errors: form.formState.errors,
+ error: form.formState.errors.root?.message,
+ };
+}
diff --git a/src/pages/question/QuestionPage.tsx b/src/pages/question/QuestionPage.tsx
index 8abf610..5e40c3d 100644
--- a/src/pages/question/QuestionPage.tsx
+++ b/src/pages/question/QuestionPage.tsx
@@ -1,12 +1,5 @@
import { useParams } from "react-router-dom";
-import { useForm } from "react-hook-form";
-import { z } from "zod";
-import { zodResolver } from "@hookform/resolvers/zod";
-import {
- useQuestion,
- useCreateAnswer,
- useSetAnswerState,
-} from "../../entities/question/api";
+import { useQuestion } from "../../entities/question/api";
import type { Question, Answer } from "../../entities/question/types";
import { useAuth } from "../../app/providers/useAuth";
import QuestionDetailsView from "./ui/QuestionDetailsView";
@@ -14,70 +7,29 @@ import AnswerItemView from "./ui/AnswerItemView";
import AnswerFormView from "./ui/AnswerFormView";
import { BackLink } from "../../shared/ui/BackLink";
import { Skeleton } from "../../shared/ui/Skeleton";
+import { useQuestionAnswers } from "../../shared/socket";
import {
- useQuestionAnswers,
- emitQuestionAnswer,
- emitAnswerStateChange,
-} from "../../shared/socket";
-
-const schema = z.object({
- content: z.string().min(1, "Ответ не может быть пустым"),
-});
-
-type FormData = z.infer;
+ useAnswerForm,
+ useQuestionOwnership,
+ useAnswerActions,
+} from "../../entities/question/hooks";
export default function QuestionPage() {
const { id } = useParams<{ id: string }>();
const { user } = useAuth();
const { data: question, status } = useQuestion(id);
- const { mutateAsync: createAnswer, isPending } = useCreateAnswer(id!);
- const { mutateAsync: setAnswerState, isPending: markPending } =
- useSetAnswerState(id!);
+ // Подписываемся на обновления ответов через вебсокеты
useQuestionAnswers(id);
+ // Используем новые хуки для бизнес-логики
+ const answerForm = useAnswerForm(id!);
+ const isOwner = useQuestionOwnership(question);
const {
- register,
- handleSubmit,
- reset,
- formState: { errors, isSubmitting },
- } = useForm({ resolver: zodResolver(schema) });
-
- const onSubmit = async (data: FormData) => {
- try {
- const res = await createAnswer(data.content);
-
- let createdId: number | string | undefined;
- const rawUnknown: unknown = res;
- if (rawUnknown && typeof rawUnknown === "object") {
- const rawObj = rawUnknown as Record;
- if (typeof rawObj.id !== "undefined")
- createdId = rawObj.id as number | string;
- else if (
- rawObj.data &&
- typeof rawObj.data === "object" &&
- rawObj.data !== null &&
- typeof (rawObj.data as Record).id !== "undefined"
- ) {
- createdId = (rawObj.data as Record).id as
- | number
- | string;
- }
- }
-
- emitQuestionAnswer({
- content: data.content,
- questionId: id!,
- id: createdId,
- user: { username: user?.username || "unknown" },
- isCorrect: false,
- });
-
- reset({ content: "" });
- } catch (error) {
- console.error("Ошибка при создании ответа:", error);
- }
- };
+ markCorrect,
+ markIncorrect,
+ isPending: markPending,
+ } = useAnswerActions(id!);
if (status === "pending")
return (
@@ -89,7 +41,7 @@ export default function QuestionPage() {
- {/* Форма ответа (новый лейаут: сразу под деталями) */}
+ {/* Форма ответа */}
@@ -106,39 +58,14 @@ export default function QuestionPage() {
);
if (!question) return
Вопрос не найден
;
- const questionUser = (question as Question).user;
- const isOwner =
- !!user &&
- !!questionUser &&
- (String(user.id) === String(questionUser.id) ||
- user.username === questionUser.username);
-
- const handleMarkCorrect = async (answerId: string | number) => {
+ const handleMarkCorrect = (answerId: string | number) => {
if (!isOwner) return;
- try {
- await setAnswerState({ answerId, state: "correct" });
- emitAnswerStateChange({
- questionId: id!,
- answerId,
- isCorrect: true,
- });
- } catch (error) {
- console.error("Ошибка при изменении статуса ответа:", error);
- }
+ markCorrect(answerId);
};
- const handleMarkIncorrect = async (answerId: string | number) => {
+ const handleMarkIncorrect = (answerId: string | number) => {
if (!isOwner) return;
- try {
- await setAnswerState({ answerId, state: "incorrect" });
- emitAnswerStateChange({
- questionId: id!,
- answerId,
- isCorrect: false,
- });
- } catch (error) {
- console.error("Ошибка при изменении статуса ответа:", error);
- }
+ markIncorrect(answerId);
};
return (
@@ -151,10 +78,10 @@ export default function QuestionPage() {
/>
{user ? (
) : (
diff --git a/src/pages/question/QuestionPageNew.tsx b/src/pages/question/QuestionPageNew.tsx
new file mode 100644
index 0000000..239087f
--- /dev/null
+++ b/src/pages/question/QuestionPageNew.tsx
@@ -0,0 +1,108 @@
+import { useParams } from "react-router-dom";
+import { useQuestion } from "../../entities/question/api";
+import type { Question, Answer } from "../../entities/question/types";
+import { useAuth } from "../../app/providers/useAuth";
+import QuestionDetailsView from "./ui/QuestionDetailsView";
+import AnswerItemView from "./ui/AnswerItemView";
+import AnswerFormView from "./ui/AnswerFormView";
+import { BackLink } from "../../shared/ui/BackLink";
+import { Skeleton } from "../../shared/ui/Skeleton";
+import { useQuestionAnswers } from "../../shared/socket";
+import {
+ useAnswerForm,
+ useQuestionOwnership,
+ useAnswerActions,
+} from "../../entities/question/hooks";
+
+export default function QuestionPage() {
+ const { id } = useParams<{ id: string }>();
+ const { user } = useAuth();
+ const { data: question, status } = useQuestion(id);
+
+ useQuestionAnswers(id);
+
+ const answerForm = useAnswerForm(id!);
+ const isOwner = useQuestionOwnership(question);
+ const {
+ markCorrect,
+ markIncorrect,
+ isPending: markPending,
+ } = useAnswerActions(id!);
+
+ if (status === "pending")
+ return (
+
+
+
+ {/* Заголовок и детали вопроса */}
+
+
+
+
+ {/* Форма ответа */}
+
+
+
+
+
+ {/* Секция ответов */}
+
+
+ {[...Array(3)].map((_, i) => (
+
+ ))}
+
+
+ );
+ if (!question) return
Вопрос не найден
;
+
+ const handleMarkCorrect = (answerId: string | number) => {
+ if (!isOwner) return;
+ markCorrect(answerId);
+ };
+
+ const handleMarkIncorrect = (answerId: string | number) => {
+ if (!isOwner) return;
+ markIncorrect(answerId);
+ };
+
+ return (
+
+
+
+ {user ? (
+
+ ) : (
+
+ Войдите, чтобы оставить ответ.
+
+ )}
+
+
Ответы
+
+ {Array.isArray((question as Question).answers) &&
+ (question as Question).answers!.map((a: Answer) => (
+ handleMarkCorrect(a.id)}
+ onMarkIncorrect={() => handleMarkIncorrect(a.id)}
+ />
+ ))}
+
+
+
+ );
+}
diff --git a/src/pages/snippet/SnippetPage.tsx b/src/pages/snippet/SnippetPage.tsx
index 1fe777e..1c34381 100644
--- a/src/pages/snippet/SnippetPage.tsx
+++ b/src/pages/snippet/SnippetPage.tsx
@@ -1,15 +1,13 @@
import { useParams } from "react-router-dom";
import { useSnippet } from "../../entities/snippet/api";
import { useAuth } from "../../app/providers/useAuth";
-import { useState } from "react";
-import { http, toHttpError } from "../../shared/api/http";
-import { useQueryClient } from "@tanstack/react-query";
import { BackLink } from "../../shared/ui/BackLink";
import SnippetDetailsView from "./ui/SnippetDetailsView";
import { Skeleton } from "../../shared/ui/Skeleton";
import CommentFormView from "./ui/CommentFormView";
import CommentsListView from "./ui/CommentsListView";
-import { useSnippetComments, emitSnippetComment } from "../../shared/socket";
+import { useSnippetComments } from "../../shared/socket";
+import { useCommentForm } from "../../entities/snippet/hooks";
export default function SnippetPage() {
const { id } = useParams();
@@ -18,52 +16,10 @@ export default function SnippetPage() {
Number.isFinite(snippetId) ? snippetId : undefined
);
const { user } = useAuth();
- const [content, setContent] = useState("");
- const [error, setError] = useState
(null);
- const [ok, setOk] = useState(null);
- const [pending, setPending] = useState(false);
- const qc = useQueryClient();
useSnippetComments(Number.isFinite(snippetId) ? snippetId : undefined);
- const submit = async () => {
- setError(null);
- setOk(null);
- setPending(true);
- try {
- const res = await http.post("/comments", { content, snippetId });
- let createdId: number | undefined;
- const rawUnknown: unknown = res.data;
- if (rawUnknown && typeof rawUnknown === "object") {
- const rawObj = rawUnknown as Record;
- if (typeof rawObj.id !== "undefined") createdId = Number(rawObj.id);
- else if (
- rawObj.data &&
- typeof rawObj.data === "object" &&
- rawObj.data !== null &&
- typeof (rawObj.data as Record).id !== "undefined"
- ) {
- createdId = Number(
- (rawObj.data as Record).id as unknown as number
- );
- }
- }
- emitSnippetComment({
- content,
- snippetId,
- id: createdId,
- user: { username: user?.username || "unknown" },
- });
- setContent("");
- setOk("Комментарий отправлен");
- qc.invalidateQueries({ queryKey: ["snippet", snippetId] });
- } catch (e) {
- const err = toHttpError(e);
- setError(err.message || "Не удалось отправить комментарий");
- } finally {
- setPending(false);
- }
- };
+ const commentForm = useCommentForm(snippetId);
if (status === "pending") {
return (
@@ -128,12 +84,12 @@ export default function SnippetPage() {
{user ? (
) : (
diff --git a/src/shared/hooks/useAccountForms.ts b/src/shared/hooks/useAccountForms.ts
new file mode 100644
index 0000000..96e1e91
--- /dev/null
+++ b/src/shared/hooks/useAccountForms.ts
@@ -0,0 +1,67 @@
+import { useState, useEffect } from "react";
+import { useUpdateMe, useUpdatePassword, useMe } from "../../entities/user/api";
+
+export function useAccountForms() {
+ const { data: me } = useMe();
+ const { mutateAsync: updateMe, isPending: isUpdatingMe } = useUpdateMe();
+ const { mutateAsync: updatePassword, isPending: isUpdatingPassword } =
+ useUpdatePassword();
+
+ const [username, setUsername] = useState(me?.username ?? "");
+ const [oldPassword, setOldPassword] = useState("");
+ const [newPassword, setNewPassword] = useState("");
+ const [message, setMessage] = useState(null);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (me?.username) setUsername(me.username);
+ }, [me?.username]);
+
+ const saveProfile = async () => {
+ setMessage(null);
+ setError(null);
+ try {
+ await updateMe({ username });
+ setMessage("Профиль обновлён");
+ } catch {
+ setError("Не удалось обновить профиль");
+ }
+ };
+
+ const changePassword = async () => {
+ setMessage(null);
+ setError(null);
+ if (!oldPassword || !newPassword) {
+ setError("Заполните все поля");
+ return;
+ }
+ try {
+ await updatePassword({ oldPassword, newPassword });
+ setMessage("Пароль изменён");
+ setOldPassword("");
+ setNewPassword("");
+ } catch {
+ setError("Не удалось изменить пароль");
+ }
+ };
+
+ return {
+ // Profile
+ username,
+ setUsername,
+ saveProfile,
+ isUpdatingMe,
+
+ // Password
+ oldPassword,
+ setOldPassword,
+ newPassword,
+ setNewPassword,
+ changePassword,
+ isUpdatingPassword,
+
+ // Messages
+ message,
+ error,
+ };
+}
diff --git a/src/shared/hooks/useAnswerActions.ts b/src/shared/hooks/useAnswerActions.ts
new file mode 100644
index 0000000..9172f1e
--- /dev/null
+++ b/src/shared/hooks/useAnswerActions.ts
@@ -0,0 +1,41 @@
+import { useSetAnswerState } from "../../entities/question/api";
+import { emitAnswerStateChange } from "../socket";
+
+export function useAnswerActions(questionId: string | number) {
+ const { mutateAsync: setAnswerState, isPending } =
+ useSetAnswerState(questionId);
+
+ const markCorrect = async (answerId: string | number) => {
+ try {
+ await setAnswerState({ answerId, state: "correct" });
+ emitAnswerStateChange({
+ questionId,
+ answerId,
+ isCorrect: true,
+ });
+ } catch (error) {
+ console.error("Ошибка при изменении статуса ответа:", error);
+ throw error;
+ }
+ };
+
+ const markIncorrect = async (answerId: string | number) => {
+ try {
+ await setAnswerState({ answerId, state: "incorrect" });
+ emitAnswerStateChange({
+ questionId,
+ answerId,
+ isCorrect: false,
+ });
+ } catch (error) {
+ console.error("Ошибка при изменении статуса ответа:", error);
+ throw error;
+ }
+ };
+
+ return {
+ markCorrect,
+ markIncorrect,
+ isPending,
+ };
+}
diff --git a/src/shared/hooks/useAnswerForm.ts b/src/shared/hooks/useAnswerForm.ts
new file mode 100644
index 0000000..fc61f6e
--- /dev/null
+++ b/src/shared/hooks/useAnswerForm.ts
@@ -0,0 +1,66 @@
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useCreateAnswer } from "../../entities/question/api";
+import { emitQuestionAnswer } from "../socket";
+import { useAuth } from "../../app/providers/useAuth";
+
+const schema = z.object({
+ content: z.string().min(1, "Ответ не может быть пустым"),
+});
+
+type FormData = z.infer;
+
+export function useAnswerForm(questionId: string | number) {
+ const { user } = useAuth();
+ const { mutateAsync: createAnswer, isPending } = useCreateAnswer(questionId);
+
+ const form = useForm({
+ resolver: zodResolver(schema),
+ });
+
+ const onSubmit = async (data: FormData) => {
+ try {
+ const res = await createAnswer(data.content);
+
+ // Пытаемся получить id созданного ответа
+ let createdId: number | string | undefined;
+ const rawUnknown: unknown = res;
+ if (rawUnknown && typeof rawUnknown === "object") {
+ const rawObj = rawUnknown as Record;
+ if (typeof rawObj.id !== "undefined")
+ createdId = rawObj.id as number | string;
+ else if (
+ rawObj.data &&
+ typeof rawObj.data === "object" &&
+ rawObj.data !== null &&
+ typeof (rawObj.data as Record).id !== "undefined"
+ ) {
+ createdId = (rawObj.data as Record).id as
+ | number
+ | string;
+ }
+ }
+
+ // Отправляем через вебсокет
+ emitQuestionAnswer({
+ content: data.content,
+ questionId,
+ id: createdId,
+ user: { username: user?.username || "unknown" },
+ isCorrect: false,
+ });
+
+ form.reset({ content: "" });
+ } catch (error) {
+ console.error("Ошибка при создании ответа:", error);
+ throw error;
+ }
+ };
+
+ return {
+ ...form,
+ onSubmit: form.handleSubmit(onSubmit),
+ isPending,
+ };
+}
diff --git a/src/shared/hooks/useCommentForm.ts b/src/shared/hooks/useCommentForm.ts
new file mode 100644
index 0000000..489495a
--- /dev/null
+++ b/src/shared/hooks/useCommentForm.ts
@@ -0,0 +1,70 @@
+import { useState } from "react";
+import { http, toHttpError } from "../api/http";
+import { useQueryClient } from "@tanstack/react-query";
+import { emitSnippetComment } from "../socket";
+import { useAuth } from "../../app/providers/useAuth";
+
+export function useCommentForm(snippetId: number) {
+ const { user } = useAuth();
+ const [content, setContent] = useState("");
+ const [error, setError] = useState(null);
+ const [ok, setOk] = useState(null);
+ const [pending, setPending] = useState(false);
+ const qc = useQueryClient();
+
+ const submit = async () => {
+ setError(null);
+ setOk(null);
+ setPending(true);
+
+ try {
+ const res = await http.post("/comments", { content, snippetId });
+
+ // Пытаемся вытащить id из API ответа
+ let createdId: number | undefined;
+ const rawUnknown: unknown = res.data;
+ if (rawUnknown && typeof rawUnknown === "object") {
+ const rawObj = rawUnknown as Record;
+ if (typeof rawObj.id !== "undefined") createdId = Number(rawObj.id);
+ else if (
+ rawObj.data &&
+ typeof rawObj.data === "object" &&
+ rawObj.data !== null &&
+ typeof (rawObj.data as Record).id !== "undefined"
+ ) {
+ createdId = Number(
+ (rawObj.data as Record).id as unknown as number
+ );
+ }
+ }
+
+ // Отправляем через вебсокет
+ emitSnippetComment({
+ content,
+ snippetId,
+ id: createdId,
+ user: { username: user?.username || "unknown" },
+ });
+
+ setContent("");
+ setOk("Комментарий отправлен");
+
+ // При socket.io обновление прилетит и так; оставим инвалидацию на случай деградации
+ qc.invalidateQueries({ queryKey: ["snippet", snippetId] });
+ } catch (e) {
+ const err = toHttpError(e);
+ setError(err.message || "Не удалось отправить комментарий");
+ } finally {
+ setPending(false);
+ }
+ };
+
+ return {
+ content,
+ setContent,
+ error,
+ ok,
+ pending,
+ submit,
+ };
+}
diff --git a/src/shared/hooks/useCreateForms.ts b/src/shared/hooks/useCreateForms.ts
new file mode 100644
index 0000000..10064b5
--- /dev/null
+++ b/src/shared/hooks/useCreateForms.ts
@@ -0,0 +1,99 @@
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useNavigate } from "react-router-dom";
+import { useCreateQuestion } from "../../entities/question/api";
+import { useCreateSnippet } from "../../entities/snippet/api";
+import {
+ normalizeLanguageInput,
+ SUPPORTED_LANG_HINT,
+} from "../services/languageService";
+import { toHttpError } from "../api/http";
+
+// Question Form
+const questionSchema = z.object({
+ title: z.string().min(3, "Минимум 3 символа"),
+ description: z.string().min(1, "Описание обязательно"),
+ attachedCode: z.string().optional(),
+});
+
+type QuestionFormData = z.infer;
+
+export function useQuestionForm() {
+ const navigate = useNavigate();
+ const { mutateAsync: createQuestion } = useCreateQuestion();
+
+ const form = useForm({
+ resolver: zodResolver(questionSchema),
+ });
+
+ const onSubmit = async (data: QuestionFormData) => {
+ try {
+ const created = await createQuestion(data);
+ if (created?.id) {
+ navigate(`/questions/${created.id}`);
+ } else {
+ navigate("/");
+ }
+ } catch (e) {
+ const err = toHttpError(e);
+ form.setError("root", { message: err.message });
+ throw e;
+ }
+ };
+
+ return {
+ ...form,
+ onSubmit: form.handleSubmit(onSubmit),
+ };
+}
+
+// Snippet Form
+const snippetSchema = z.object({
+ language: z.string().min(1, "Укажите язык"),
+ code: z.string().min(1, "Код обязателен"),
+});
+
+type SnippetFormData = z.infer;
+
+export function useSnippetForm() {
+ const navigate = useNavigate();
+ const { mutateAsync: createSnippet } = useCreateSnippet();
+
+ const form = useForm({
+ resolver: zodResolver(snippetSchema),
+ });
+
+ const onSubmit = async (data: SnippetFormData) => {
+ try {
+ // Валидируем язык отдельно
+ const normalizedLanguage = normalizeLanguageInput(data.language);
+ if (!normalizedLanguage) {
+ form.setError("language", {
+ message: `Неподдерживаемый язык. ${SUPPORTED_LANG_HINT}`,
+ });
+ return;
+ }
+
+ const created = await createSnippet({
+ language: normalizedLanguage,
+ code: data.code,
+ });
+
+ if (created?.id) {
+ navigate(`/snippets/${created.id}`);
+ } else {
+ navigate("/");
+ }
+ } catch (e) {
+ const err = toHttpError(e);
+ form.setError("root", { message: err.message });
+ throw e;
+ }
+ };
+
+ return {
+ ...form,
+ onSubmit: form.handleSubmit(onSubmit),
+ };
+}
diff --git a/src/shared/hooks/useQuestionOwnership.ts b/src/shared/hooks/useQuestionOwnership.ts
new file mode 100644
index 0000000..fcec817
--- /dev/null
+++ b/src/shared/hooks/useQuestionOwnership.ts
@@ -0,0 +1,19 @@
+import { useMemo } from "react";
+import { useAuth } from "../../app/providers/useAuth";
+import type { Question } from "../../entities/question/types";
+
+export function useQuestionOwnership(question: Question | undefined) {
+ const { user } = useAuth();
+
+ return useMemo(() => {
+ if (!user || !question) return false;
+
+ const questionUser = question.user;
+ if (!questionUser) return false;
+
+ return (
+ String(user.id) === String(questionUser.id) ||
+ user.username === questionUser.username
+ );
+ }, [user, question]);
+}
diff --git a/src/shared/services/languageService.ts b/src/shared/services/languageService.ts
new file mode 100644
index 0000000..abba454
--- /dev/null
+++ b/src/shared/services/languageService.ts
@@ -0,0 +1,52 @@
+export const SUPPORTED_LANGUAGES = [
+ "JavaScript",
+ "Python",
+ "Java",
+ "C",
+ "C++",
+ "C#",
+ "Go",
+ "Kotlin",
+ "Ruby",
+] as const;
+
+export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];
+
+export const SUPPORTED_LANG_HINT = `Допустимые: ${SUPPORTED_LANGUAGES.join(
+ ", "
+)}`;
+
+export function normalizeLanguageInput(
+ input: string
+): SupportedLanguage | undefined {
+ const raw = (input || "").trim().toLowerCase();
+ if (!raw) return undefined;
+
+ const cleaned = raw.replace(/\s+/g, "");
+
+ const map: Record = {
+ javascript: "JavaScript",
+ js: "JavaScript",
+ node: "JavaScript",
+ python: "Python",
+ py: "Python",
+ java: "Java",
+ c: "C",
+ "c++": "C++",
+ cpp: "C++",
+ "c#": "C#",
+ csharp: "C#",
+ "c-sharp": "C#",
+ golang: "Go",
+ go: "Go",
+ kotlin: "Kotlin",
+ kt: "Kotlin",
+ ruby: "Ruby",
+ rb: "Ruby",
+ };
+
+ if (map[raw]) return map[raw];
+ if (map[cleaned]) return map[cleaned];
+
+ return undefined;
+}
From 46c2ba2d44ad47927629d2b215c4180bb1936a9e Mon Sep 17 00:00:00 2001
From: kotru21
Date: Sun, 24 Aug 2025 21:53:41 +0300
Subject: [PATCH 27/40] Implement path aliasing and refactor imports
Added path aliases for src directories in tsconfig and Vite config. Refactored all relative imports to use the new alias paths for improved code maintainability and readability. Also removed the unused AccountPageNew.tsx file and updated PasswordFormView UI.
---
src/App.tsx | 6 +-
src/main.tsx | 20 ++---
src/pages/account/AccountPage.tsx | 8 +-
src/pages/account/AccountPageNew.tsx | 73 -------------------
src/pages/account/ui/AccountInfoView.tsx | 2 +-
src/pages/account/ui/AccountStatsView.tsx | 4 +-
src/pages/account/ui/PasswordFormView.tsx | 63 +++++++++-------
src/pages/auth/LoginPage.tsx | 2 +-
src/pages/auth/RegisterPage.tsx | 2 +-
src/pages/create/CreatePage.tsx | 2 +-
src/pages/create/CreatePageNew.tsx | 2 +-
src/pages/create/hooks/useCreateForms.ts | 8 +-
src/pages/home/HomePage.tsx | 8 +-
src/pages/home/ItemCard.tsx | 8 +-
src/pages/home/ui/HomePageView.tsx | 2 +-
src/pages/home/ui/ItemCommonCardView.tsx | 8 +-
src/pages/question/QuestionPage.tsx | 14 ++--
src/pages/question/ui/AnswerItemView.tsx | 2 +-
src/pages/question/ui/QuestionDetailsView.tsx | 2 +-
src/pages/snippet/SnippetPage.tsx | 12 +--
src/pages/snippet/ui/CommentsListView.tsx | 2 +-
src/pages/snippet/ui/SnippetDetailsView.tsx | 4 +-
src/shared/ui/CodeBlock.tsx | 2 +-
src/shared/ui/CodeEditor.tsx | 3 +-
tsconfig.app.json | 10 +++
vite.config.ts | 9 +++
26 files changed, 117 insertions(+), 161 deletions(-)
delete mode 100644 src/pages/account/AccountPageNew.tsx
diff --git a/src/App.tsx b/src/App.tsx
index e31edb3..8e2339f 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,8 +1,8 @@
import { Outlet } from "react-router-dom";
import { useCallback } from "react";
-import { useAuth } from "./app/providers/useAuth";
-import { useTheme } from "./app/providers/useTheme";
-import Header from "./app/Header";
+import { useAuth } from "@/app/providers/useAuth";
+import { useTheme } from "@/app/providers/useTheme";
+import Header from "@/app/Header";
export default function App() {
const { user, logout } = useAuth();
diff --git a/src/main.tsx b/src/main.tsx
index 880d9a5..626a7b8 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -2,18 +2,18 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import { createBrowserRouter, RouterProvider } from "react-router-dom";
-import HomePage from "./pages/home/HomePage";
-import QuestionPage from "./pages/question/QuestionPage";
-import SnippetPage from "./pages/snippet/SnippetPage";
-import AccountPage from "./pages/account/AccountPage";
-import LoginPage from "./pages/auth/LoginPage.tsx";
-import RegisterPage from "./pages/auth/RegisterPage.tsx";
-import { RequireAuth, RequireGuest } from "./app/providers/route-guards";
-import CreatePage from "./pages/create/CreatePage";
+import HomePage from "@/pages/home/HomePage";
+import QuestionPage from "@/pages/question/QuestionPage";
+import SnippetPage from "@/pages/snippet/SnippetPage";
+import AccountPage from "@/pages/account/AccountPage";
+import LoginPage from "@/pages/auth/LoginPage.tsx";
+import RegisterPage from "@/pages/auth/RegisterPage.tsx";
+import { RequireAuth, RequireGuest } from "@/app/providers/route-guards";
+import CreatePage from "@/pages/create/CreatePage";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App.tsx";
-import { AuthProvider } from "./app/providers/auth";
-import { ThemeProvider } from "./app/providers/theme";
+import { AuthProvider } from "@/app/providers/auth";
+import { ThemeProvider } from "@/app/providers/theme";
const router = createBrowserRouter([
{
diff --git a/src/pages/account/AccountPage.tsx b/src/pages/account/AccountPage.tsx
index ea2e854..36df205 100644
--- a/src/pages/account/AccountPage.tsx
+++ b/src/pages/account/AccountPage.tsx
@@ -1,11 +1,11 @@
-import { useAuth } from "../../app/providers/useAuth";
-import { BackLink } from "../../shared/ui/BackLink";
-import { useMe, useUserStatistic } from "../../entities/user/api";
+import { useAuth } from "@/app/providers/useAuth";
+import { BackLink } from "@/shared/ui/BackLink";
+import { useMe, useUserStatistic } from "@/entities/user/api";
import AccountInfoView from "./ui/AccountInfoView";
import AccountStatsView from "./ui/AccountStatsView";
import ProfileFormView from "./ui/ProfileFormView";
import PasswordFormView from "./ui/PasswordFormView";
-import { useAccountForms } from "../../entities/user/hooks";
+import { useAccountForms } from "@/entities/user/hooks";
export default function AccountPage() {
const { user: authUser } = useAuth();
diff --git a/src/pages/account/AccountPageNew.tsx b/src/pages/account/AccountPageNew.tsx
deleted file mode 100644
index f446a1c..0000000
--- a/src/pages/account/AccountPageNew.tsx
+++ /dev/null
@@ -1,73 +0,0 @@
-import { useAuth } from "../../app/providers/useAuth";
-import { BackLink } from "../../shared/ui/BackLink";
-import { useMe, useUserStatistic } from "../../entities/user/api";
-import AccountInfoView from "./ui/AccountInfoView";
-import AccountStatsView from "./ui/AccountStatsView";
-import ProfileFormView from "./ui/ProfileFormView";
-import PasswordFormView from "./ui/PasswordFormView";
-import { useAccountForms } from "../../entities/user/hooks";
-
-export default function AccountPage() {
- const { user: authUser } = useAuth();
- const userId = authUser?.id;
- const { data: me, status: meStatus } = useMe();
- const idForStat = me?.id ?? userId;
- const { data: stat, status: statStatus } = useUserStatistic(idForStat);
-
- // Используем хук для форм аккаунта
- const accountForms = useAccountForms();
- const canEdit = !!authUser;
-
- return (
-
-
-
- {me && (
-
- )}
-
- {stat && (
-
- )}
-
- {canEdit && (
-
- Настройки профиля
-
-
-
-
- )}
-
- {(accountForms.message || accountForms.error) && (
-
- {accountForms.message && (
-
{accountForms.message}
- )}
- {accountForms.error && (
-
{accountForms.error}
- )}
-
- )}
-
- );
-}
diff --git a/src/pages/account/ui/AccountInfoView.tsx b/src/pages/account/ui/AccountInfoView.tsx
index ba55315..34f66c7 100644
--- a/src/pages/account/ui/AccountInfoView.tsx
+++ b/src/pages/account/ui/AccountInfoView.tsx
@@ -7,7 +7,7 @@ export type AccountInfoViewProps = {
loading?: boolean;
};
-import { Skeleton } from "../../../shared/ui/Skeleton";
+import { Skeleton } from "@/shared/ui/Skeleton";
export const AccountInfoView = memo(function AccountInfoView({
id,
diff --git a/src/pages/account/ui/AccountStatsView.tsx b/src/pages/account/ui/AccountStatsView.tsx
index 2801cd7..6aa22f7 100644
--- a/src/pages/account/ui/AccountStatsView.tsx
+++ b/src/pages/account/ui/AccountStatsView.tsx
@@ -1,6 +1,6 @@
import { memo } from "react";
-import { Skeleton } from "../../../shared/ui/Skeleton";
-import type { UserStatistic } from "../../../entities/user/types";
+import { Skeleton } from "@/shared/ui/Skeleton";
+import type { UserStatistic } from "@/entities/user/types";
export type AccountStatsViewProps = {
statistic?: UserStatistic;
diff --git a/src/pages/account/ui/PasswordFormView.tsx b/src/pages/account/ui/PasswordFormView.tsx
index 5f492fb..41a897d 100644
--- a/src/pages/account/ui/PasswordFormView.tsx
+++ b/src/pages/account/ui/PasswordFormView.tsx
@@ -1,17 +1,16 @@
import { memo } from "react";
+import { Skeleton } from "@/shared/ui/Skeleton";
export type PasswordFormViewProps = {
oldPassword: string;
newPassword: string;
- onOldPasswordChange: (v: string) => void;
- onNewPasswordChange: (v: string) => void;
+ onOldPasswordChange: (value: string) => void;
+ onNewPasswordChange: (value: string) => void;
onSubmit: () => void;
isPending?: boolean;
loading?: boolean;
};
-import { Skeleton } from "../../../shared/ui/Skeleton";
-
export const PasswordFormView = memo(function PasswordFormView({
oldPassword,
newPassword,
@@ -21,40 +20,50 @@ export const PasswordFormView = memo(function PasswordFormView({
isPending,
loading,
}: PasswordFormViewProps) {
+ if (loading) {
+ return (
+
+
+
+
+
+
+ );
+ }
+
return (
-
-
Старый пароль
- {loading ? (
- <>
-
-
Новый пароль
-
-
- >
- ) : (
- <>
+
+
Смена пароля
+
+
+
+ Старый пароль
+
onOldPasswordChange(e.target.value)}
+ className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
+ disabled={isPending}
/>
- Новый пароль
+
+
+ Новый пароль
onNewPasswordChange(e.target.value)}
+ className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
+ disabled={isPending}
/>
-
- Обновить пароль
-
- >
- )}
+
+
+ {isPending ? "Сохранение..." : "Сменить пароль"}
+
+
);
});
diff --git a/src/pages/auth/LoginPage.tsx b/src/pages/auth/LoginPage.tsx
index 15b5f54..0bbc9e1 100644
--- a/src/pages/auth/LoginPage.tsx
+++ b/src/pages/auth/LoginPage.tsx
@@ -1,7 +1,7 @@
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
-import { useAuth } from "../../app/providers/useAuth";
+import { useAuth } from "@/app/providers/useAuth";
import { useNavigate, useLocation } from "react-router-dom";
import LoginFormView from "./ui/LoginFormView";
diff --git a/src/pages/auth/RegisterPage.tsx b/src/pages/auth/RegisterPage.tsx
index a280c8e..75824be 100644
--- a/src/pages/auth/RegisterPage.tsx
+++ b/src/pages/auth/RegisterPage.tsx
@@ -1,7 +1,7 @@
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
-import { useAuth } from "../../app/providers/useAuth";
+import { useAuth } from "@/app/providers/useAuth";
import { useNavigate } from "react-router-dom";
import RegisterFormView from "./ui/RegisterFormView";
diff --git a/src/pages/create/CreatePage.tsx b/src/pages/create/CreatePage.tsx
index e5088b9..49f8eef 100644
--- a/src/pages/create/CreatePage.tsx
+++ b/src/pages/create/CreatePage.tsx
@@ -1,6 +1,6 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
-import CodeEditor from "../../shared/ui/CodeEditor";
+import CodeEditor from "@/shared/ui/CodeEditor";
import { useQuestionForm, useSnippetForm } from "./hooks";
export default function CreatePage() {
diff --git a/src/pages/create/CreatePageNew.tsx b/src/pages/create/CreatePageNew.tsx
index e5088b9..49f8eef 100644
--- a/src/pages/create/CreatePageNew.tsx
+++ b/src/pages/create/CreatePageNew.tsx
@@ -1,6 +1,6 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
-import CodeEditor from "../../shared/ui/CodeEditor";
+import CodeEditor from "@/shared/ui/CodeEditor";
import { useQuestionForm, useSnippetForm } from "./hooks";
export default function CreatePage() {
diff --git a/src/pages/create/hooks/useCreateForms.ts b/src/pages/create/hooks/useCreateForms.ts
index e2d37da..3093b42 100644
--- a/src/pages/create/hooks/useCreateForms.ts
+++ b/src/pages/create/hooks/useCreateForms.ts
@@ -2,13 +2,13 @@ import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useNavigate } from "react-router-dom";
-import { useCreateQuestion } from "../../../entities/question/api";
-import { useCreateSnippet } from "../../../entities/snippet/api";
+import { useCreateQuestion } from "@/entities/question/api";
+import { useCreateSnippet } from "@/entities/snippet/api";
import {
normalizeLanguageInput,
SUPPORTED_LANG_HINT,
-} from "../../../shared/services/languageService";
-import { toHttpError } from "../../../shared/api/http";
+} from "@/shared/services/languageService";
+import { toHttpError } from "@/shared/api/http";
// Question Form
const questionSchema = z.object({
diff --git a/src/pages/home/HomePage.tsx b/src/pages/home/HomePage.tsx
index 29107cd..83d77ca 100644
--- a/src/pages/home/HomePage.tsx
+++ b/src/pages/home/HomePage.tsx
@@ -1,10 +1,10 @@
import { useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useWindowVirtualizer } from "@tanstack/react-virtual";
-import { useQuestions } from "../../entities/question/api";
-import type { Question } from "../../entities/question/types";
-import { useSnippets } from "../../entities/snippet/api";
-import type { Snippet } from "../../entities/snippet/types";
+import { useQuestions } from "@/entities/question/api";
+import type { Question } from "@/entities/question/types";
+import { useSnippets } from "@/entities/snippet/api";
+import type { Snippet } from "@/entities/snippet/types";
import { ItemCard } from "./ItemCard";
import HomePageView from "./ui/HomePageView";
diff --git a/src/pages/home/ItemCard.tsx b/src/pages/home/ItemCard.tsx
index c27e090..4e1f9c8 100644
--- a/src/pages/home/ItemCard.tsx
+++ b/src/pages/home/ItemCard.tsx
@@ -1,8 +1,8 @@
import { memo } from "react";
-import type { Question } from "../../entities/question/types";
-import type { Snippet } from "../../entities/snippet/types";
-import { useAuth } from "../../app/providers/useAuth";
-import { useMarkSnippet } from "../../entities/snippet/api";
+import type { Question } from "@/entities/question/types";
+import type { Snippet } from "@/entities/snippet/types";
+import { useAuth } from "@/app/providers/useAuth";
+import { useMarkSnippet } from "@/entities/snippet/api";
import { ItemCommonCardView } from "./ui/ItemCommonCardView";
type BaseProps = {
diff --git a/src/pages/home/ui/HomePageView.tsx b/src/pages/home/ui/HomePageView.tsx
index fe5ef4c..c598ae7 100644
--- a/src/pages/home/ui/HomePageView.tsx
+++ b/src/pages/home/ui/HomePageView.tsx
@@ -1,5 +1,5 @@
import { memo } from "react";
-import { Skeleton } from "../../../shared/ui/Skeleton";
+import { Skeleton } from "@/shared/ui/Skeleton";
import type { ReactNode, RefCallback } from "react";
export type HomeRow = {
diff --git a/src/pages/home/ui/ItemCommonCardView.tsx b/src/pages/home/ui/ItemCommonCardView.tsx
index cc0ff5b..d1e97b5 100644
--- a/src/pages/home/ui/ItemCommonCardView.tsx
+++ b/src/pages/home/ui/ItemCommonCardView.tsx
@@ -1,8 +1,8 @@
import { memo } from "react";
-import Avatar from "../../../shared/ui/Avatar";
-import { ExpandableText } from "../../../shared/ui/ExpandableText";
-import { Clamp } from "../../../shared/ui/Clamp";
-import { CodeBlock } from "../../../shared/ui/CodeBlock";
+import Avatar from "@/shared/ui/Avatar";
+import { ExpandableText } from "@/shared/ui/ExpandableText";
+import { Clamp } from "@/shared/ui/Clamp";
+import { CodeBlock } from "@/shared/ui/CodeBlock";
export type ItemCommonCardViewProps = {
mode: "question" | "snippet";
diff --git a/src/pages/question/QuestionPage.tsx b/src/pages/question/QuestionPage.tsx
index 5e40c3d..645bc94 100644
--- a/src/pages/question/QuestionPage.tsx
+++ b/src/pages/question/QuestionPage.tsx
@@ -1,18 +1,18 @@
import { useParams } from "react-router-dom";
-import { useQuestion } from "../../entities/question/api";
-import type { Question, Answer } from "../../entities/question/types";
-import { useAuth } from "../../app/providers/useAuth";
+import { useQuestion } from "@/entities/question/api";
+import type { Question, Answer } from "@/entities/question/types";
+import { useAuth } from "@/app/providers/useAuth";
import QuestionDetailsView from "./ui/QuestionDetailsView";
import AnswerItemView from "./ui/AnswerItemView";
import AnswerFormView from "./ui/AnswerFormView";
-import { BackLink } from "../../shared/ui/BackLink";
-import { Skeleton } from "../../shared/ui/Skeleton";
-import { useQuestionAnswers } from "../../shared/socket";
+import { BackLink } from "@/shared/ui/BackLink";
+import { Skeleton } from "@/shared/ui/Skeleton";
+import { useQuestionAnswers } from "@/shared/socket";
import {
useAnswerForm,
useQuestionOwnership,
useAnswerActions,
-} from "../../entities/question/hooks";
+} from "@/entities/question/hooks";
export default function QuestionPage() {
const { id } = useParams<{ id: string }>();
diff --git a/src/pages/question/ui/AnswerItemView.tsx b/src/pages/question/ui/AnswerItemView.tsx
index 2256b40..34dc0b4 100644
--- a/src/pages/question/ui/AnswerItemView.tsx
+++ b/src/pages/question/ui/AnswerItemView.tsx
@@ -1,5 +1,5 @@
import { memo } from "react";
-import { ExpandableText } from "../../../shared/ui/ExpandableText";
+import { ExpandableText } from "@/shared/ui/ExpandableText";
export type AnswerItemViewProps = {
content: string;
diff --git a/src/pages/question/ui/QuestionDetailsView.tsx b/src/pages/question/ui/QuestionDetailsView.tsx
index 80fed8c..08eea06 100644
--- a/src/pages/question/ui/QuestionDetailsView.tsx
+++ b/src/pages/question/ui/QuestionDetailsView.tsx
@@ -1,5 +1,5 @@
import { memo } from "react";
-import { CodeBlock } from "../../../shared/ui/CodeBlock";
+import { CodeBlock } from "@/shared/ui/CodeBlock";
export type QuestionDetailsViewProps = {
title: string;
diff --git a/src/pages/snippet/SnippetPage.tsx b/src/pages/snippet/SnippetPage.tsx
index 1c34381..7848b4a 100644
--- a/src/pages/snippet/SnippetPage.tsx
+++ b/src/pages/snippet/SnippetPage.tsx
@@ -1,13 +1,13 @@
import { useParams } from "react-router-dom";
-import { useSnippet } from "../../entities/snippet/api";
-import { useAuth } from "../../app/providers/useAuth";
-import { BackLink } from "../../shared/ui/BackLink";
+import { useSnippet } from "@/entities/snippet/api";
+import { useAuth } from "@/app/providers/useAuth";
+import { BackLink } from "@/shared/ui/BackLink";
import SnippetDetailsView from "./ui/SnippetDetailsView";
-import { Skeleton } from "../../shared/ui/Skeleton";
+import { Skeleton } from "@/shared/ui/Skeleton";
import CommentFormView from "./ui/CommentFormView";
import CommentsListView from "./ui/CommentsListView";
-import { useSnippetComments } from "../../shared/socket";
-import { useCommentForm } from "../../entities/snippet/hooks";
+import { useSnippetComments } from "@/shared/socket";
+import { useCommentForm } from "@/entities/snippet/hooks";
export default function SnippetPage() {
const { id } = useParams();
diff --git a/src/pages/snippet/ui/CommentsListView.tsx b/src/pages/snippet/ui/CommentsListView.tsx
index b6cbfb6..34e27a5 100644
--- a/src/pages/snippet/ui/CommentsListView.tsx
+++ b/src/pages/snippet/ui/CommentsListView.tsx
@@ -1,5 +1,5 @@
import { memo } from "react";
-import Avatar from "../../../shared/ui/Avatar";
+import Avatar from "@/shared/ui/Avatar";
export type Comment = {
id: number;
diff --git a/src/pages/snippet/ui/SnippetDetailsView.tsx b/src/pages/snippet/ui/SnippetDetailsView.tsx
index 9bddb9c..9ddb67f 100644
--- a/src/pages/snippet/ui/SnippetDetailsView.tsx
+++ b/src/pages/snippet/ui/SnippetDetailsView.tsx
@@ -1,6 +1,6 @@
import { memo } from "react";
-import { CodeBlock } from "../../../shared/ui/CodeBlock";
-import Avatar from "../../../shared/ui/Avatar";
+import { CodeBlock } from "@/shared/ui/CodeBlock";
+import Avatar from "@/shared/ui/Avatar";
export type SnippetDetailsViewProps = {
id: number;
diff --git a/src/shared/ui/CodeBlock.tsx b/src/shared/ui/CodeBlock.tsx
index 907e7da..7285eba 100644
--- a/src/shared/ui/CodeBlock.tsx
+++ b/src/shared/ui/CodeBlock.tsx
@@ -5,7 +5,7 @@ import type {
Token as PrismToken,
} from "prism-react-renderer";
import { memo, useMemo } from "react";
-import { useTheme } from "../../app/providers/useTheme";
+import { useTheme } from "@/app/providers/useTheme";
export type CodeBlockProps = {
code: string;
diff --git a/src/shared/ui/CodeEditor.tsx b/src/shared/ui/CodeEditor.tsx
index f5c7727..a658c6f 100644
--- a/src/shared/ui/CodeEditor.tsx
+++ b/src/shared/ui/CodeEditor.tsx
@@ -5,7 +5,8 @@ import type {
RenderProps,
Token as PrismToken,
} from "prism-react-renderer";
-import { useTheme } from "../../app/providers/useTheme";
+
+import { useTheme } from "@/app/providers/useTheme";
type Props = {
value: string;
diff --git a/tsconfig.app.json b/tsconfig.app.json
index 227a6c6..5f22fa7 100644
--- a/tsconfig.app.json
+++ b/tsconfig.app.json
@@ -15,6 +15,16 @@
"noEmit": true,
"jsx": "react-jsx",
+ /* Path mapping */
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/*"],
+ "@/app/*": ["src/app/*"],
+ "@/entities/*": ["src/entities/*"],
+ "@/pages/*": ["src/pages/*"],
+ "@/shared/*": ["src/shared/*"]
+ },
+
/* Linting */
"strict": true,
"noUnusedLocals": true,
diff --git a/vite.config.ts b/vite.config.ts
index 44349a0..e3ec9cd 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -5,6 +5,15 @@ import mkcert from "vite-plugin-mkcert";
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), mkcert()],
+ resolve: {
+ alias: {
+ "@": "/src",
+ "@/app": "/src/app",
+ "@/entities": "/src/entities",
+ "@/pages": "/src/pages",
+ "@/shared": "/src/shared",
+ },
+ },
server: {
https: true,
proxy: {
From c8940f565ffcbcae67e7f21c0e657977167f9790 Mon Sep 17 00:00:00 2001
From: kotru21
Date: Sun, 24 Aug 2025 22:02:47 +0300
Subject: [PATCH 28/40] Add MyItemsPage and navigation link
Introduces a new MyItemsPage for displaying user's questions and snippets, adds routing for '/my', and updates the header to include a navigation link to the new page.
---
src/app/ui/HeaderView.tsx | 5 +-
src/main.tsx | 6 +-
src/pages/my/MyItemsPage.tsx | 140 +++++++++++++++++++++++++++++++++++
3 files changed, 149 insertions(+), 2 deletions(-)
create mode 100644 src/pages/my/MyItemsPage.tsx
diff --git a/src/app/ui/HeaderView.tsx b/src/app/ui/HeaderView.tsx
index dd29919..a60d6ce 100644
--- a/src/app/ui/HeaderView.tsx
+++ b/src/app/ui/HeaderView.tsx
@@ -20,7 +20,7 @@ function HeaderView({
return (
@@ -46,6 +46,9 @@ function HeaderView({
Создать
+
+ Мои
+
Logout +{" "}
diff --git a/src/main.tsx b/src/main.tsx
index 626a7b8..84ec5b1 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -10,6 +10,7 @@ import LoginPage from "@/pages/auth/LoginPage.tsx";
import RegisterPage from "@/pages/auth/RegisterPage.tsx";
import { RequireAuth, RequireGuest } from "@/app/providers/route-guards";
import CreatePage from "@/pages/create/CreatePage";
+import MyItemsPage from "@/pages/my/MyItemsPage";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App.tsx";
import { AuthProvider } from "@/app/providers/auth";
@@ -26,7 +27,10 @@ const router = createBrowserRouter([
{ path: "account", element: },
{
element: ,
- children: [{ path: "create", element: }],
+ children: [
+ { path: "create", element: },
+ { path: "my", element: },
+ ],
},
{
element: ,
diff --git a/src/pages/my/MyItemsPage.tsx b/src/pages/my/MyItemsPage.tsx
new file mode 100644
index 0000000..12dfc7f
--- /dev/null
+++ b/src/pages/my/MyItemsPage.tsx
@@ -0,0 +1,140 @@
+import { useEffect, useMemo, useState } from "react";
+import { useSearchParams, useNavigate } from "react-router-dom";
+import { useWindowVirtualizer } from "@tanstack/react-virtual";
+import { useAuth } from "@/app/providers/useAuth";
+import { useQuestions } from "@/entities/question/api";
+import { useSnippets } from "@/entities/snippet/api";
+import type { Question } from "@/entities/question/types";
+import type { Snippet } from "@/entities/snippet/types";
+import { ItemCard } from "@/pages/home/ItemCard";
+import HomePageView from "@/pages/home/ui/HomePageView";
+
+// Объединённая страница: Мои вопросы / Мои сниппеты
+export default function MyItemsPage() {
+ const { user } = useAuth();
+ const userId = user?.id;
+ const navigate = useNavigate();
+ const [searchParams, setSearchParams] = useSearchParams();
+ const initialMode = (
+ searchParams.get("mode") === "snippets" ? "snippets" : "questions"
+ ) as "questions" | "snippets";
+ const [mode, setMode] = useState<"questions" | "snippets">(initialMode);
+
+ // Queries
+ const qQuestions = useQuestions({
+ enabled: mode === "questions" && !!userId,
+ });
+ const qSnippets = useSnippets({
+ userId: userId as number | undefined,
+ enabled: mode === "snippets" && !!userId,
+ });
+
+ const myQuestions = useMemo(() => {
+ const all = qQuestions.data?.pages.flatMap((p) => p.data) ?? [];
+ return all.filter((qu) => String(qu.user.id) === String(userId));
+ }, [qQuestions.data?.pages, userId]) as Question[];
+ const mySnippets = (qSnippets.data?.pages.flatMap((p) => p.data) ??
+ []) as Snippet[];
+
+ const isQuestions = mode === "questions";
+ const items = (isQuestions ? myQuestions : mySnippets) as (
+ | Question
+ | Snippet
+ )[];
+
+ const fetchNextPage = isQuestions
+ ? qQuestions.fetchNextPage
+ : qSnippets.fetchNextPage;
+ const hasNextPage = isQuestions
+ ? qQuestions.hasNextPage
+ : qSnippets.hasNextPage;
+ const isFetchingNextPage = isQuestions
+ ? qQuestions.isFetchingNextPage
+ : qSnippets.isFetchingNextPage;
+ const status = isQuestions ? qQuestions.status : qSnippets.status;
+
+ const rowVirtualizer = useWindowVirtualizer({
+ count: items.length,
+ estimateSize: () => 220,
+ overscan: 6,
+ });
+ const virtualItems = rowVirtualizer.getVirtualItems();
+
+ // sync URL with mode
+ useEffect(() => {
+ const current = searchParams.get("mode");
+ if (current !== mode) {
+ setSearchParams(
+ (prev) => {
+ const next = new URLSearchParams(prev);
+ next.set("mode", mode);
+ return next;
+ },
+ { replace: true }
+ );
+ }
+ }, [mode, searchParams, setSearchParams]);
+
+ // infinite scroll
+ useEffect(() => {
+ if (!hasNextPage || isFetchingNextPage || items.length === 0) return;
+ const last = virtualItems[virtualItems.length - 1];
+ if (last && last.index >= items.length - 1) {
+ void fetchNextPage();
+ }
+ }, [
+ virtualItems,
+ hasNextPage,
+ isFetchingNextPage,
+ items.length,
+ fetchNextPage,
+ ]);
+
+ const rows = virtualItems.map((v) => {
+ const item = items[v.index];
+ const key = (item && (item as Question | Snippet).id) ?? v.index;
+ const content = isQuestions ? (
+ (item as Question) ? (
+
+ navigate(`/questions/${(item as Question).id}`, {
+ state: { fromMode: "my-questions" },
+ })
+ }
+ />
+ ) : null
+ ) : (item as Snippet) ? (
+
+ navigate(`/snippets/${(item as Snippet).id}`, {
+ state: { fromMode: "my-snippets" },
+ })
+ }
+ />
+ ) : null;
+ return {
+ key,
+ start: v.start,
+ index: v.index,
+ ref: rowVirtualizer.measureElement,
+ content,
+ };
+ });
+
+ return (
+ 0}
+ containerHeight={rowVirtualizer.getTotalSize()}
+ rows={rows}
+ isFetchingNextPage={isFetchingNextPage}
+ />
+ );
+}
From efcbfebfe3e298aaa960768fcca6b8e811e337b7 Mon Sep 17 00:00:00 2001
From: kotru21
Date: Sun, 24 Aug 2025 22:11:32 +0300
Subject: [PATCH 29/40] Add edit and delete functionality for questions and
snippets
Introduces hooks and UI for updating and deleting questions and snippets. Adds ownership checks, edit forms, and action buttons to QuestionPage and SnippetPage. Refactors details views to support custom action buttons for owners.
---
src/entities/question/api.ts | 28 ++++
.../question/hooks/useQuestionOwnership.ts | 2 +-
src/entities/snippet/api.ts | 28 ++++
src/entities/snippet/hooks/index.ts | 1 +
.../snippet/hooks/useSnippetOwnership.ts | 13 ++
src/pages/question/QuestionPage.tsx | 122 +++++++++++++++---
src/pages/question/ui/QuestionDetailsView.tsx | 7 +-
src/pages/snippet/SnippetPage.tsx | 118 +++++++++++++++--
src/pages/snippet/ui/SnippetDetailsView.tsx | 3 +
9 files changed, 289 insertions(+), 33 deletions(-)
create mode 100644 src/entities/snippet/hooks/useSnippetOwnership.ts
diff --git a/src/entities/question/api.ts b/src/entities/question/api.ts
index c0f2aa9..1ee2997 100644
--- a/src/entities/question/api.ts
+++ b/src/entities/question/api.ts
@@ -114,3 +114,31 @@ export function useCreateQuestion() {
},
});
}
+
+export function useUpdateQuestion(id: string | number) {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: async (dto: CreateQuestionDto) => {
+ // API: PATCH /questions/{id}
+ const res = await http.patch(`/questions/${id}`, dto);
+ return unwrapData(res.data as unknown);
+ },
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ["question", id] });
+ qc.invalidateQueries({ queryKey: ["questions"] });
+ },
+ });
+}
+
+export function useDeleteQuestion(id: string | number) {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: async () => {
+ const res = await http.delete(`/questions/${id}`);
+ return res.data as unknown;
+ },
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ["questions"] });
+ },
+ });
+}
diff --git a/src/entities/question/hooks/useQuestionOwnership.ts b/src/entities/question/hooks/useQuestionOwnership.ts
index cc97315..e3dab81 100644
--- a/src/entities/question/hooks/useQuestionOwnership.ts
+++ b/src/entities/question/hooks/useQuestionOwnership.ts
@@ -1,5 +1,5 @@
import { useMemo } from "react";
-import { useAuth } from "../../../app/providers/useAuth";
+import { useAuth } from "@/app/providers/useAuth";
import type { Question } from "../types";
export function useQuestionOwnership(question: Question | undefined) {
diff --git a/src/entities/snippet/api.ts b/src/entities/snippet/api.ts
index 70d95dc..bba477c 100644
--- a/src/entities/snippet/api.ts
+++ b/src/entities/snippet/api.ts
@@ -139,3 +139,31 @@ export function useCreateSnippet() {
},
});
}
+
+export function useUpdateSnippet(id: number) {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: async (dto: { code?: string; language?: string }) => {
+ // API PATCH /snippets/{id}
+ const res = await http.patch(`/snippets/${id}`, dto);
+ return res.data as unknown;
+ },
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ["snippet", id] });
+ qc.invalidateQueries({ queryKey: ["snippets"] });
+ },
+ });
+}
+
+export function useDeleteSnippet(id: number) {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: async () => {
+ const res = await http.delete(`/snippets/${id}`);
+ return res.data as unknown;
+ },
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ["snippets"] });
+ },
+ });
+}
diff --git a/src/entities/snippet/hooks/index.ts b/src/entities/snippet/hooks/index.ts
index b150c84..b3b6f50 100644
--- a/src/entities/snippet/hooks/index.ts
+++ b/src/entities/snippet/hooks/index.ts
@@ -1 +1,2 @@
export { useCommentForm } from "./useCommentForm";
+export { useSnippetOwnership } from "./useSnippetOwnership";
diff --git a/src/entities/snippet/hooks/useSnippetOwnership.ts b/src/entities/snippet/hooks/useSnippetOwnership.ts
new file mode 100644
index 0000000..65db119
--- /dev/null
+++ b/src/entities/snippet/hooks/useSnippetOwnership.ts
@@ -0,0 +1,13 @@
+import { useMemo } from "react";
+import { useAuth } from "@/app/providers/useAuth";
+import type { Snippet } from "../types";
+
+export function useSnippetOwnership(snippet: Snippet | undefined) {
+ const { user } = useAuth();
+ return useMemo(() => {
+ if (!user || !snippet) return false;
+ const sUser = snippet.user;
+ if (!sUser) return false;
+ return String(user.id) === String(sUser.id) || user.username === sUser.username;
+ }, [user, snippet]);
+}
diff --git a/src/pages/question/QuestionPage.tsx b/src/pages/question/QuestionPage.tsx
index 645bc94..4b5b067 100644
--- a/src/pages/question/QuestionPage.tsx
+++ b/src/pages/question/QuestionPage.tsx
@@ -1,5 +1,5 @@
import { useParams } from "react-router-dom";
-import { useQuestion } from "@/entities/question/api";
+import { useQuestion, useUpdateQuestion, useDeleteQuestion } from "@/entities/question/api";
import type { Question, Answer } from "@/entities/question/types";
import { useAuth } from "@/app/providers/useAuth";
import QuestionDetailsView from "./ui/QuestionDetailsView";
@@ -8,28 +8,29 @@ import AnswerFormView from "./ui/AnswerFormView";
import { BackLink } from "@/shared/ui/BackLink";
import { Skeleton } from "@/shared/ui/Skeleton";
import { useQuestionAnswers } from "@/shared/socket";
-import {
- useAnswerForm,
- useQuestionOwnership,
- useAnswerActions,
-} from "@/entities/question/hooks";
+import { useAnswerForm, useQuestionOwnership, useAnswerActions } from "@/entities/question/hooks";
+import { useState } from "react";
+import CodeEditor from "@/shared/ui/CodeEditor";
+import { useNavigate } from "react-router-dom";
export default function QuestionPage() {
const { id } = useParams<{ id: string }>();
const { user } = useAuth();
const { data: question, status } = useQuestion(id);
- // Подписываемся на обновления ответов через вебсокеты
useQuestionAnswers(id);
- // Используем новые хуки для бизнес-логики
const answerForm = useAnswerForm(id!);
const isOwner = useQuestionOwnership(question);
- const {
- markCorrect,
- markIncorrect,
- isPending: markPending,
- } = useAnswerActions(id!);
+ const { markCorrect, markIncorrect, isPending: markPending } = useAnswerActions(id!);
+
+ const updateMutation = useUpdateQuestion(id!);
+ const deleteMutation = useDeleteQuestion(id!);
+ const [isEditing, setIsEditing] = useState(false);
+ const [editTitle, setEditTitle] = useState("");
+ const [editDescription, setEditDescription] = useState("");
+ const [editCode, setEditCode] = useState("");
+ const navigate = useNavigate();
if (status === "pending")
return (
@@ -68,14 +69,99 @@ export default function QuestionPage() {
markIncorrect(answerId);
};
+ const onStartEdit = () => {
+ if (!question) return;
+ setEditTitle(question.title);
+ setEditDescription(question.description);
+ setEditCode(question.attachedCode || "");
+ setIsEditing(true);
+ };
+
+ const onCancelEdit = () => setIsEditing(false);
+
+ const onSave = async () => {
+ try {
+ await updateMutation.mutateAsync({ title: editTitle, description: editDescription, attachedCode: editCode });
+ setIsEditing(false);
+ } catch {
+ alert("Не удалось сохранить изменения");
+ }
+ };
+
+ const onDelete = async () => {
+ if (!confirm("Удалить вопрос?")) return;
+ try {
+ await deleteMutation.mutateAsync();
+ navigate("/my?mode=questions");
+ } catch {
+ alert("Не удалось удалить вопрос");
+ }
+ };
+
+ const actionButtons = isOwner && !isEditing && (
+ <>
+
+ Редактировать
+
+
+ {deleteMutation.isPending ? "Удаление..." : "Удалить"}
+
+ >
+ );
+
return (
-
+ {!isEditing && (
+
+ )}
+ {isEditing && (
+
+
Редактирование вопроса
+
+ Заголовок
+ setEditTitle(e.target.value)}
+ className="w-full border rounded px-2 py-1 text-sm"
+ />
+
+
+ Описание
+ setEditDescription(e.target.value)}
+ rows={5}
+ className="w-full border rounded px-2 py-1 text-sm"
+ />
+
+
+ Код
+
+
+
+
+ {updateMutation.isPending ? "Сохранение..." : "Сохранить"}
+
+
+ Отмена
+
+
+
+ )}
{user ? (
- {title}
+
+
{title}
+ {actions &&
{actions}
}
+
{description}
diff --git a/src/pages/snippet/SnippetPage.tsx b/src/pages/snippet/SnippetPage.tsx
index 7848b4a..93fd91c 100644
--- a/src/pages/snippet/SnippetPage.tsx
+++ b/src/pages/snippet/SnippetPage.tsx
@@ -1,5 +1,5 @@
import { useParams } from "react-router-dom";
-import { useSnippet } from "@/entities/snippet/api";
+import { useSnippet, useUpdateSnippet, useDeleteSnippet } from "@/entities/snippet/api";
import { useAuth } from "@/app/providers/useAuth";
import { BackLink } from "@/shared/ui/BackLink";
import SnippetDetailsView from "./ui/SnippetDetailsView";
@@ -7,7 +7,10 @@ import { Skeleton } from "@/shared/ui/Skeleton";
import CommentFormView from "./ui/CommentFormView";
import CommentsListView from "./ui/CommentsListView";
import { useSnippetComments } from "@/shared/socket";
-import { useCommentForm } from "@/entities/snippet/hooks";
+import { useCommentForm, useSnippetOwnership } from "@/entities/snippet/hooks";
+import CodeEditor from "@/shared/ui/CodeEditor";
+import { useState } from "react";
+import { useNavigate } from "react-router-dom";
export default function SnippetPage() {
const { id } = useParams();
@@ -16,6 +19,15 @@ export default function SnippetPage() {
Number.isFinite(snippetId) ? snippetId : undefined
);
const { user } = useAuth();
+ const isOwner = useSnippetOwnership(snippet);
+ const navigate = useNavigate();
+
+ const [isEditing, setIsEditing] = useState(false);
+ const [editLanguage, setEditLanguage] = useState("");
+ const [editCode, setEditCode] = useState("");
+
+ const updateMutation = useUpdateSnippet(snippetId);
+ const deleteMutation = useDeleteSnippet(snippetId);
useSnippetComments(Number.isFinite(snippetId) ? snippetId : undefined);
@@ -69,20 +81,100 @@ export default function SnippetPage() {
return Сниппет не найден.
;
}
+ const onStartEdit = () => {
+ if (!snippet) return;
+ setEditLanguage(snippet.language);
+ setEditCode(snippet.code);
+ setIsEditing(true);
+ };
+
+ const onCancelEdit = () => {
+ setIsEditing(false);
+ };
+
+ const onSave = async () => {
+ try {
+ await updateMutation.mutateAsync({ language: editLanguage, code: editCode });
+ setIsEditing(false);
+ } catch {
+ alert("Не удалось сохранить изменения");
+ }
+ };
+
+ const onDelete = async () => {
+ if (!confirm("Удалить сниппет? Действие необратимо.")) return;
+ try {
+ await deleteMutation.mutateAsync();
+ navigate("/my?mode=snippets");
+ } catch {
+ alert("Не удалось удалить сниппет");
+ }
+ };
+
+ const actionButtons = isOwner && !isEditing && (
+ <>
+
+ Редактировать
+
+
+ {deleteMutation.isPending ? "Удаление..." : "Удалить"}
+
+ >
+ );
+
return (
-
-
- {user ? (
+ {!isEditing && (
+
+ )}
+ {isEditing && (
+
+
Редактирование сниппета #{snippet.id}
+
+ Язык
+ setEditLanguage(e.target.value)}
+ className="w-full border rounded px-2 py-1 text-sm"
+ placeholder="Language"
+ />
+
+
+ Код
+
+
+
+
+ {updateMutation.isPending ? "Сохранение..." : "Сохранить"}
+
+
+ Отмена
+
+
+
+ )}
+
+ {user ? (
Сниппет #{id}
+ {actions &&
{actions}
}
{language && (
{language}
From 6d93e818b7fb7ce8df7e37f2061daa4eec0d8dee Mon Sep 17 00:00:00 2001
From: kotru21
Date: Sun, 24 Aug 2025 22:18:36 +0300
Subject: [PATCH 30/40] Add edit and delete functionality for answers and
comments
Introduced mutations and UI for editing and deleting answers on questions and comments on snippets. Updated relevant API hooks, page components, and list views to support inline editing and removal by content owners.
---
src/entities/question/api.ts | 30 +++
src/entities/snippet/api.ts | 27 +++
.../snippet/hooks/useSnippetOwnership.ts | 4 +-
src/pages/question/QuestionPage.tsx | 175 ++++++++++++++++--
src/pages/question/ui/AnswerItemView.tsx | 24 ++-
src/pages/snippet/SnippetPage.tsx | 38 +++-
src/pages/snippet/ui/CommentsListView.tsx | 98 +++++++++-
7 files changed, 360 insertions(+), 36 deletions(-)
diff --git a/src/entities/question/api.ts b/src/entities/question/api.ts
index 1ee2997..9af974a 100644
--- a/src/entities/question/api.ts
+++ b/src/entities/question/api.ts
@@ -99,6 +99,36 @@ export function useSetAnswerState(questionId: string | number) {
});
}
+export function useUpdateAnswer(questionId: string | number) {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: async (params: {
+ answerId: string | number;
+ content: string;
+ }) => {
+ const { answerId, content } = params;
+ const res = await http.patch(`/answers/${Number(answerId)}`, { content });
+ return res.data as unknown;
+ },
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ["question", questionId] });
+ },
+ });
+}
+
+export function useDeleteAnswer(questionId: string | number) {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: async (answerId: string | number) => {
+ const res = await http.delete(`/answers/${Number(answerId)}`);
+ return res.data as unknown;
+ },
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ["question", questionId] });
+ },
+ });
+}
+
export function useCreateQuestion() {
const qc = useQueryClient();
return useMutation({
diff --git a/src/entities/snippet/api.ts b/src/entities/snippet/api.ts
index bba477c..9e28770 100644
--- a/src/entities/snippet/api.ts
+++ b/src/entities/snippet/api.ts
@@ -167,3 +167,30 @@ export function useDeleteSnippet(id: number) {
},
});
}
+
+export function useUpdateComment(snippetId: number) {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: async (params: { id: number; content: string }) => {
+ const { id, content } = params;
+ const res = await http.patch(`/comments/${id}`, { content });
+ return res.data as unknown;
+ },
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ["snippet", snippetId] });
+ },
+ });
+}
+
+export function useDeleteComment(snippetId: number) {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: async (id: number) => {
+ const res = await http.delete(`/comments/${id}`);
+ return res.data as unknown;
+ },
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ["snippet", snippetId] });
+ },
+ });
+}
diff --git a/src/entities/snippet/hooks/useSnippetOwnership.ts b/src/entities/snippet/hooks/useSnippetOwnership.ts
index 65db119..a81e6c4 100644
--- a/src/entities/snippet/hooks/useSnippetOwnership.ts
+++ b/src/entities/snippet/hooks/useSnippetOwnership.ts
@@ -8,6 +8,8 @@ export function useSnippetOwnership(snippet: Snippet | undefined) {
if (!user || !snippet) return false;
const sUser = snippet.user;
if (!sUser) return false;
- return String(user.id) === String(sUser.id) || user.username === sUser.username;
+ return (
+ String(user.id) === String(sUser.id) || user.username === sUser.username
+ );
}, [user, snippet]);
}
diff --git a/src/pages/question/QuestionPage.tsx b/src/pages/question/QuestionPage.tsx
index 4b5b067..e3018dd 100644
--- a/src/pages/question/QuestionPage.tsx
+++ b/src/pages/question/QuestionPage.tsx
@@ -1,5 +1,11 @@
import { useParams } from "react-router-dom";
-import { useQuestion, useUpdateQuestion, useDeleteQuestion } from "@/entities/question/api";
+import {
+ useQuestion,
+ useUpdateQuestion,
+ useDeleteQuestion,
+ useUpdateAnswer,
+ useDeleteAnswer,
+} from "@/entities/question/api";
import type { Question, Answer } from "@/entities/question/types";
import { useAuth } from "@/app/providers/useAuth";
import QuestionDetailsView from "./ui/QuestionDetailsView";
@@ -8,7 +14,11 @@ import AnswerFormView from "./ui/AnswerFormView";
import { BackLink } from "@/shared/ui/BackLink";
import { Skeleton } from "@/shared/ui/Skeleton";
import { useQuestionAnswers } from "@/shared/socket";
-import { useAnswerForm, useQuestionOwnership, useAnswerActions } from "@/entities/question/hooks";
+import {
+ useAnswerForm,
+ useQuestionOwnership,
+ useAnswerActions,
+} from "@/entities/question/hooks";
import { useState } from "react";
import CodeEditor from "@/shared/ui/CodeEditor";
import { useNavigate } from "react-router-dom";
@@ -22,10 +32,16 @@ export default function QuestionPage() {
const answerForm = useAnswerForm(id!);
const isOwner = useQuestionOwnership(question);
- const { markCorrect, markIncorrect, isPending: markPending } = useAnswerActions(id!);
+ const {
+ markCorrect,
+ markIncorrect,
+ isPending: markPending,
+ } = useAnswerActions(id!);
const updateMutation = useUpdateQuestion(id!);
const deleteMutation = useDeleteQuestion(id!);
+ const updateAnswerMutation = useUpdateAnswer(id!);
+ const deleteAnswerMutation = useDeleteAnswer(id!);
const [isEditing, setIsEditing] = useState(false);
const [editTitle, setEditTitle] = useState("");
const [editDescription, setEditDescription] = useState("");
@@ -81,7 +97,11 @@ export default function QuestionPage() {
const onSave = async () => {
try {
- await updateMutation.mutateAsync({ title: editTitle, description: editDescription, attachedCode: editCode });
+ await updateMutation.mutateAsync({
+ title: editTitle,
+ description: editDescription,
+ attachedCode: editCode,
+ });
setIsEditing(false);
} catch {
alert("Не удалось сохранить изменения");
@@ -136,7 +156,7 @@ export default function QuestionPage() {
className="w-full border rounded px-2 py-1 text-sm"
/>
-
+
Описание
{updateMutation.isPending ? "Сохранение..." : "Сохранить"}
-
+
Отмена
@@ -176,21 +198,134 @@ export default function QuestionPage() {
)}
Ответы
-
- {Array.isArray((question as Question).answers) &&
- (question as Question).answers!.map((a: Answer) => (
- handleMarkCorrect(a.id)}
- onMarkIncorrect={() => handleMarkIncorrect(a.id)}
- />
- ))}
-
+
+ updateAnswerMutation.mutate({ answerId, content })
+ }
+ onDelete={(answerId) => deleteAnswerMutation.mutate(answerId)}
+ />
);
}
+
+function AnswerList({
+ answers,
+ currentUser,
+ ownerCanMark,
+ onMarkCorrect,
+ onMarkIncorrect,
+ markPending,
+ onUpdate,
+ onDelete,
+}: {
+ answers: Answer[];
+ currentUser?: string;
+ ownerCanMark: boolean;
+ onMarkCorrect: (id: string | number) => void;
+ onMarkIncorrect: (id: string | number) => void;
+ markPending: boolean;
+ onUpdate: (id: string | number, content: string) => void;
+ onDelete: (id: string | number) => void;
+}) {
+ return (
+
+ {answers.map((a) => (
+ onMarkCorrect(a.id)}
+ onMarkIncorrect={() => onMarkIncorrect(a.id)}
+ markPending={markPending}
+ onUpdate={onUpdate}
+ onDelete={onDelete}
+ />
+ ))}
+
+ );
+}
+
+import { useState as useStateReact } from "react";
+
+function EditableAnswerItem({
+ answer,
+ canMark,
+ currentUser,
+ onMarkCorrect,
+ onMarkIncorrect,
+ markPending,
+ onUpdate,
+ onDelete,
+}: {
+ answer: Answer;
+ canMark: boolean;
+ currentUser?: string;
+ onMarkCorrect: () => void;
+ onMarkIncorrect: () => void;
+ markPending: boolean;
+ onUpdate: (id: string | number, content: string) => void;
+ onDelete: (id: string | number) => void;
+}) {
+ const [editing, setEditing] = useStateReact(false);
+ const [value, setValue] = useStateReact(answer.content);
+ // Допускаем что объект ответа может содержать user (не описан в исходном типе)
+ const answerUser = (answer as unknown as { user?: { username?: string } })
+ .user;
+ const isOwner = !!currentUser && answerUser?.username === currentUser;
+
+ const save = () => {
+ onUpdate(answer.id, value);
+ setEditing(false);
+ };
+ const del = () => {
+ if (confirm("Удалить ответ?")) onDelete(answer.id);
+ };
+ return (
+
+
setEditing(true)}
+ onDelete={del}
+ />
+ {editing && (
+
+
setValue(e.target.value)}
+ rows={4}
+ className="w-full border rounded px-2 py-1 text-sm"
+ />
+
+
+ Сохранить
+
+ {
+ setValue(answer.content);
+ setEditing(false);
+ }}
+ className="text-xs px-2 py-1 rounded border">
+ Отмена
+
+
+
+ )}
+
+ );
+}
diff --git a/src/pages/question/ui/AnswerItemView.tsx b/src/pages/question/ui/AnswerItemView.tsx
index 34dc0b4..453e6aa 100644
--- a/src/pages/question/ui/AnswerItemView.tsx
+++ b/src/pages/question/ui/AnswerItemView.tsx
@@ -8,6 +8,9 @@ export type AnswerItemViewProps = {
onMarkCorrect?: () => void;
onMarkIncorrect?: () => void;
pending?: boolean;
+ canEdit?: boolean;
+ onEdit?: () => void;
+ onDelete?: () => void;
};
export const AnswerItemView = memo(function AnswerItemView({
@@ -17,6 +20,9 @@ export const AnswerItemView = memo(function AnswerItemView({
onMarkCorrect,
onMarkIncorrect,
pending,
+ canEdit,
+ onEdit,
+ onDelete,
}: AnswerItemViewProps) {
const itemBase = "border rounded p-2 text-sm transition-colors";
const itemNormal =
@@ -34,7 +40,7 @@ export const AnswerItemView = memo(function AnswerItemView({
className="flex-1"
maxHeight={120}
/>
-
+
{isCorrect && (
Верный ответ
@@ -58,6 +64,22 @@ export const AnswerItemView = memo(function AnswerItemView({
Пометить как верный
))}
+ {canEdit && (
+ <>
+
+ Изм.
+
+
+ Удал.
+
+ >
+ )}
diff --git a/src/pages/snippet/SnippetPage.tsx b/src/pages/snippet/SnippetPage.tsx
index 93fd91c..c648547 100644
--- a/src/pages/snippet/SnippetPage.tsx
+++ b/src/pages/snippet/SnippetPage.tsx
@@ -1,5 +1,11 @@
import { useParams } from "react-router-dom";
-import { useSnippet, useUpdateSnippet, useDeleteSnippet } from "@/entities/snippet/api";
+import {
+ useSnippet,
+ useUpdateSnippet,
+ useDeleteSnippet,
+ useUpdateComment,
+ useDeleteComment,
+} from "@/entities/snippet/api";
import { useAuth } from "@/app/providers/useAuth";
import { BackLink } from "@/shared/ui/BackLink";
import SnippetDetailsView from "./ui/SnippetDetailsView";
@@ -28,6 +34,8 @@ export default function SnippetPage() {
const updateMutation = useUpdateSnippet(snippetId);
const deleteMutation = useDeleteSnippet(snippetId);
+ const updateCommentMutation = useUpdateComment(snippetId);
+ const deleteCommentMutation = useDeleteComment(snippetId);
useSnippetComments(Number.isFinite(snippetId) ? snippetId : undefined);
@@ -94,7 +102,10 @@ export default function SnippetPage() {
const onSave = async () => {
try {
- await updateMutation.mutateAsync({ language: editLanguage, code: editCode });
+ await updateMutation.mutateAsync({
+ language: editLanguage,
+ code: editCode,
+ });
setIsEditing(false);
} catch {
alert("Не удалось сохранить изменения");
@@ -144,7 +155,9 @@ export default function SnippetPage() {
)}
{isEditing && (
-
Редактирование сниппета #{snippet.id}
+
+ Редактирование сниппета #{snippet.id}
+
Язык
Код
-
+
)}
- {user ? (
+ {user ? (
0 && (
-
+ {
+ updateCommentMutation.mutate({ id: cid, content });
+ }}
+ onDelete={(cid) => {
+ deleteCommentMutation.mutate(cid);
+ }}
+ />
)}
);
diff --git a/src/pages/snippet/ui/CommentsListView.tsx b/src/pages/snippet/ui/CommentsListView.tsx
index 34e27a5..6f9f456 100644
--- a/src/pages/snippet/ui/CommentsListView.tsx
+++ b/src/pages/snippet/ui/CommentsListView.tsx
@@ -1,4 +1,4 @@
-import { memo } from "react";
+import { memo, useState } from "react";
import Avatar from "@/shared/ui/Avatar";
export type Comment = {
@@ -9,10 +9,16 @@ export type Comment = {
export type CommentsListViewProps = {
comments: Comment[];
+ currentUser?: { username: string } | null;
+ onUpdate?: (id: number, content: string) => void;
+ onDelete?: (id: number) => void;
};
export const CommentsListView = memo(function CommentsListView({
comments,
+ currentUser,
+ onUpdate,
+ onDelete,
}: CommentsListViewProps) {
if (!comments.length) return null;
return (
@@ -20,18 +26,94 @@ export const CommentsListView = memo(function CommentsListView({
Комментарии
{comments.map((c) => (
-
-
- {c.content}
-
+ comment={c}
+ isOwner={currentUser?.username === c.user.username}
+ onUpdate={onUpdate}
+ onDelete={onDelete}
+ />
))}
);
});
+function CommentItem({
+ comment,
+ isOwner,
+ onUpdate,
+ onDelete,
+}: {
+ comment: Comment;
+ isOwner: boolean;
+ onUpdate?: (id: number, content: string) => void;
+ onDelete?: (id: number) => void;
+}) {
+ const [editing, setEditing] = useState(false);
+ const [value, setValue] = useState(comment.content);
+
+ const save = () => {
+ if (!onUpdate) return;
+ onUpdate(comment.id, value);
+ setEditing(false);
+ };
+ const del = () => {
+ if (!onDelete) return;
+ if (confirm("Удалить комментарий?")) onDelete(comment.id);
+ };
+ return (
+
+
+
+
@
+ {comment.user.username}
+
+ {isOwner && !editing && (
+
+ setEditing(true)}
+ className="text-xs px-2 py-0.5 rounded border">
+ Изм.
+
+
+ Удал.
+
+
+ )}
+
+ {!editing && (
+ {comment.content}
+ )}
+ {editing && (
+
+
setValue(e.target.value)}
+ rows={3}
+ className="w-full border rounded px-2 py-1 text-sm"
+ />
+
+
+ Сохранить
+
+ {
+ setValue(comment.content);
+ setEditing(false);
+ }}
+ className="px-2 py-0.5 text-xs rounded border">
+ Отмена
+
+
+
+ )}
+
+ );
+}
+
export default CommentsListView;
From 19e39e97b351774e6d10f337d82d3dda2fab5933 Mon Sep 17 00:00:00 2001
From: kotru21
Date: Sun, 24 Aug 2025 23:49:09 +0300
Subject: [PATCH 31/40] Refactor item details and unify UI buttons
Introduces a new item details module consolidating question and snippet pages under src/pages/item/. Replaces legacy question/snippet detail pages and related UI components with unified, reusable views and hooks. Refactors all button usages to use the shared Button component for consistent styling and behavior across the app. Updates routing and props to support the new structure.
---
src/app/ui/HeaderView.tsx | 28 +-
src/main.tsx | 8 +-
src/pages/account/AccountPage.tsx | 18 +-
src/pages/account/ui/AccountStatsView.tsx | 5 +-
src/pages/account/ui/PasswordFormView.tsx | 10 +-
src/pages/account/ui/ProfileFormView.tsx | 11 +-
src/pages/auth/ui/LoginFormView.tsx | 12 +-
src/pages/auth/ui/RegisterFormView.tsx | 12 +-
src/pages/create/CreatePage.tsx | 55 ++-
src/pages/create/CreatePageNew.tsx | 55 ++-
src/pages/home/ui/HomePageView.tsx | 29 +-
src/pages/home/ui/ItemCommonCardView.tsx | 28 +-
src/pages/item/ItemDetailsPage.tsx | 11 +
src/pages/item/ItemDetailsView.tsx | 233 ++++++++++++
src/pages/item/QuestionDetailsPage.tsx | 13 +
src/pages/item/SnippetDetailsPage.tsx | 13 +
src/pages/item/hooks/itemTypes.ts | 73 ++++
src/pages/item/hooks/useQuestionDetails.ts | 115 ++++++
src/pages/item/hooks/useSnippetDetails.ts | 107 ++++++
src/pages/item/ui/ActionButtons.tsx | 26 ++
.../{question => item}/ui/AnswerFormView.tsx | 0
.../{question => item}/ui/AnswerItemView.tsx | 27 +-
.../{snippet => item}/ui/CommentFormView.tsx | 0
.../{snippet => item}/ui/CommentsListView.tsx | 26 +-
src/pages/item/ui/EditPanel.tsx | 43 +++
src/pages/item/ui/EditableAnswerItem.tsx | 79 +++++
src/pages/item/ui/GenericTextForm.tsx | 103 ++++++
src/pages/item/ui/LoadingSkeleton.tsx | 30 ++
.../ui/QuestionDetailsView.tsx | 0
.../ui/SnippetDetailsView.tsx | 2 +-
src/pages/item/useItemDetails.ts | 21 ++
src/pages/question/QuestionPage.tsx | 331 ------------------
src/pages/question/QuestionPageNew.tsx | 108 ------
src/pages/snippet/SnippetPage.tsx | 223 ------------
src/shared/ui/Button.tsx | 93 +++++
src/shared/ui/Clamp.tsx | 26 +-
src/shared/ui/ExpandableText.tsx | 26 +-
37 files changed, 1152 insertions(+), 848 deletions(-)
create mode 100644 src/pages/item/ItemDetailsPage.tsx
create mode 100644 src/pages/item/ItemDetailsView.tsx
create mode 100644 src/pages/item/QuestionDetailsPage.tsx
create mode 100644 src/pages/item/SnippetDetailsPage.tsx
create mode 100644 src/pages/item/hooks/itemTypes.ts
create mode 100644 src/pages/item/hooks/useQuestionDetails.ts
create mode 100644 src/pages/item/hooks/useSnippetDetails.ts
create mode 100644 src/pages/item/ui/ActionButtons.tsx
rename src/pages/{question => item}/ui/AnswerFormView.tsx (100%)
rename src/pages/{question => item}/ui/AnswerItemView.tsx (78%)
rename src/pages/{snippet => item}/ui/CommentFormView.tsx (100%)
rename src/pages/{snippet => item}/ui/CommentsListView.tsx (82%)
create mode 100644 src/pages/item/ui/EditPanel.tsx
create mode 100644 src/pages/item/ui/EditableAnswerItem.tsx
create mode 100644 src/pages/item/ui/GenericTextForm.tsx
create mode 100644 src/pages/item/ui/LoadingSkeleton.tsx
rename src/pages/{question => item}/ui/QuestionDetailsView.tsx (100%)
rename src/pages/{snippet => item}/ui/SnippetDetailsView.tsx (93%)
create mode 100644 src/pages/item/useItemDetails.ts
delete mode 100644 src/pages/question/QuestionPage.tsx
delete mode 100644 src/pages/question/QuestionPageNew.tsx
delete mode 100644 src/pages/snippet/SnippetPage.tsx
create mode 100644 src/shared/ui/Button.tsx
diff --git a/src/app/ui/HeaderView.tsx b/src/app/ui/HeaderView.tsx
index a60d6ce..8fd1670 100644
--- a/src/app/ui/HeaderView.tsx
+++ b/src/app/ui/HeaderView.tsx
@@ -1,5 +1,6 @@
import { Link } from "react-router-dom";
import { memo } from "react";
+import { Button } from "@/shared/ui/Button";
import Avatar from "../../shared/ui/Avatar";
export type HeaderViewProps = {
@@ -28,12 +29,13 @@ function HeaderView({
kinda StackOverflow
-
{theme === "dark" ? "Light" : "Dark"}
-
+
{user ? (
<>
{user.username}
-
- Создать
+
+
Создать
-
- Мои
+
+
Мои
-
- Logout +{" "}
-
+
+ Logout
+
>
) : (
<>
Login
-
- Sign up
+
+
+ Sign up
+
>
)}
diff --git a/src/main.tsx b/src/main.tsx
index 84ec5b1..ed45135 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -3,8 +3,8 @@ import { createRoot } from "react-dom/client";
import "./index.css";
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import HomePage from "@/pages/home/HomePage";
-import QuestionPage from "@/pages/question/QuestionPage";
-import SnippetPage from "@/pages/snippet/SnippetPage";
+import QuestionDetailsPage from "@/pages/item/QuestionDetailsPage";
+import SnippetDetailsPage from "@/pages/item/SnippetDetailsPage";
import AccountPage from "@/pages/account/AccountPage";
import LoginPage from "@/pages/auth/LoginPage.tsx";
import RegisterPage from "@/pages/auth/RegisterPage.tsx";
@@ -22,8 +22,8 @@ const router = createBrowserRouter([
element:
,
children: [
{ index: true, element:
},
- { path: "questions/:id", element:
},
- { path: "snippets/:id", element:
},
+ { path: "questions/:id", element:
},
+ { path: "snippets/:id", element:
},
{ path: "account", element:
},
{
element:
,
diff --git a/src/pages/account/AccountPage.tsx b/src/pages/account/AccountPage.tsx
index 36df205..7e5128d 100644
--- a/src/pages/account/AccountPage.tsx
+++ b/src/pages/account/AccountPage.tsx
@@ -21,18 +21,14 @@ export default function AccountPage() {
- {me && (
-
- )}
+
- {stat && (
-
- )}
+
{canEdit && (
diff --git a/src/pages/account/ui/AccountStatsView.tsx b/src/pages/account/ui/AccountStatsView.tsx
index 6aa22f7..4e1e8de 100644
--- a/src/pages/account/ui/AccountStatsView.tsx
+++ b/src/pages/account/ui/AccountStatsView.tsx
@@ -26,7 +26,7 @@ export const AccountStatsView = memo(function AccountStatsView({
{status === "error" && (
Не удалось загрузить статистику.
)}
- {statistic ? (
+ {status === "success" && statistic && (
Сниппеты: {statistic.snippetsCount}
@@ -51,7 +51,8 @@ export const AccountStatsView = memo(function AccountStatsView({
Обычных ответов: {statistic.regularAnswersCount}
- ) : (
+ )}
+ {status === "success" && !statistic && (
Нет данных статистики.
)}
diff --git a/src/pages/account/ui/PasswordFormView.tsx b/src/pages/account/ui/PasswordFormView.tsx
index 41a897d..0da2077 100644
--- a/src/pages/account/ui/PasswordFormView.tsx
+++ b/src/pages/account/ui/PasswordFormView.tsx
@@ -1,5 +1,6 @@
import { memo } from "react";
import { Skeleton } from "@/shared/ui/Skeleton";
+import { Button } from "@/shared/ui/Button";
export type PasswordFormViewProps = {
oldPassword: string;
@@ -57,12 +58,13 @@ export const PasswordFormView = memo(function PasswordFormView({
disabled={isPending}
/>
-
- {isPending ? "Сохранение..." : "Сменить пароль"}
-
+ loading={isPending}
+ variant="primary">
+ Сменить пароль
+
);
diff --git a/src/pages/account/ui/ProfileFormView.tsx b/src/pages/account/ui/ProfileFormView.tsx
index 0bcf606..95165e6 100644
--- a/src/pages/account/ui/ProfileFormView.tsx
+++ b/src/pages/account/ui/ProfileFormView.tsx
@@ -1,4 +1,5 @@
import { memo } from "react";
+import { Button } from "@/shared/ui/Button";
export type ProfileFormViewProps = {
username: string;
@@ -8,7 +9,7 @@ export type ProfileFormViewProps = {
loading?: boolean;
};
-import { Skeleton } from "../../../shared/ui/Skeleton";
+import { Skeleton } from "@/shared/ui/Skeleton";
export const ProfileFormView = memo(function ProfileFormView({
username,
@@ -33,13 +34,15 @@ export const ProfileFormView = memo(function ProfileFormView({
onChange={(e) => onUsernameChange(e.target.value)}
placeholder="username"
/>
-
+ size="sm"
+ variant="primary">
Сохранить профиль
-
+
>
)}
diff --git a/src/pages/auth/ui/LoginFormView.tsx b/src/pages/auth/ui/LoginFormView.tsx
index 36e7deb..b46b498 100644
--- a/src/pages/auth/ui/LoginFormView.tsx
+++ b/src/pages/auth/ui/LoginFormView.tsx
@@ -1,5 +1,6 @@
import { Link } from "react-router-dom";
import { memo } from "react";
+import { Button } from "@/shared/ui/Button";
import type { FormEvent, InputHTMLAttributes } from "react";
export type LoginFormViewProps = {
@@ -47,11 +48,14 @@ function LoginFormView({
)}
{errors?.root && {errors.root}
}
-
- {isSubmitting ? "Вход..." : "Войти"}
-
+ loading={isSubmitting}
+ variant="primary"
+ className="w-full">
+ Войти
+
Нет аккаунта?{" "}
diff --git a/src/pages/auth/ui/RegisterFormView.tsx b/src/pages/auth/ui/RegisterFormView.tsx
index bd6ad19..58d2c92 100644
--- a/src/pages/auth/ui/RegisterFormView.tsx
+++ b/src/pages/auth/ui/RegisterFormView.tsx
@@ -1,5 +1,6 @@
import { Link } from "react-router-dom";
import { memo } from "react";
+import { Button } from "@/shared/ui/Button";
import type { FormEvent, InputHTMLAttributes } from "react";
export type RegisterFormViewProps = {
@@ -61,11 +62,14 @@ function RegisterFormView({
)}
{errors?.root && {errors.root}
}
-
- {isSubmitting ? "Создание..." : "Создать аккаунт"}
-
+ loading={isSubmitting}
+ variant="primary"
+ className="w-full">
+ Создать аккаунт
+
Уже есть аккаунт?{" "}
diff --git a/src/pages/create/CreatePage.tsx b/src/pages/create/CreatePage.tsx
index 49f8eef..3433a0f 100644
--- a/src/pages/create/CreatePage.tsx
+++ b/src/pages/create/CreatePage.tsx
@@ -1,4 +1,5 @@
import { useState } from "react";
+import { Button } from "@/shared/ui/Button";
import { useNavigate } from "react-router-dom";
import CodeEditor from "@/shared/ui/CodeEditor";
import { useQuestionForm, useSnippetForm } from "./hooks";
@@ -14,24 +15,20 @@ export default function CreatePage() {
{/* Tabs */}
- setMode("question")}
- className={`pb-2 px-1 border-b-2 transition ${
- mode === "question"
- ? "border-blue-500 text-blue-600"
- : "border-transparent text-gray-600 hover:text-gray-800"
- }`}>
+ variant={mode === "question" ? "outline" : "ghost"}
+ size="sm"
+ className="rounded-none border-b-2 !border-0">
Вопрос
-
-
+ setMode("snippet")}
- className={`pb-2 px-1 border-b-2 transition ${
- mode === "snippet"
- ? "border-blue-500 text-blue-600"
- : "border-transparent text-gray-600 hover:text-gray-800"
- }`}>
+ variant={mode === "snippet" ? "outline" : "ghost"}
+ size="sm"
+ className="rounded-none border-b-2 !border-0">
Сниппет
-
+
{/* Question Form */}
@@ -87,18 +84,19 @@ export default function CreatePage() {
)}
-
- {questionForm.isPending ? "Создание..." : "Создать вопрос"}
-
-
+ Создать вопрос
+
+ navigate("/")}
- className="bg-gray-600 text-white px-4 py-2 rounded-lg hover:bg-gray-700">
+ variant="secondary">
Отмена
-
+
)}
@@ -148,18 +146,19 @@ export default function CreatePage() {
)}
-
- {snippetForm.isPending ? "Создание..." : "Создать сниппет"}
-
-
+ Создать сниппет
+
+ navigate("/")}
- className="bg-gray-600 text-white px-4 py-2 rounded-lg hover:bg-gray-700">
+ variant="secondary">
Отмена
-
+
)}
diff --git a/src/pages/create/CreatePageNew.tsx b/src/pages/create/CreatePageNew.tsx
index 49f8eef..3433a0f 100644
--- a/src/pages/create/CreatePageNew.tsx
+++ b/src/pages/create/CreatePageNew.tsx
@@ -1,4 +1,5 @@
import { useState } from "react";
+import { Button } from "@/shared/ui/Button";
import { useNavigate } from "react-router-dom";
import CodeEditor from "@/shared/ui/CodeEditor";
import { useQuestionForm, useSnippetForm } from "./hooks";
@@ -14,24 +15,20 @@ export default function CreatePage() {
{/* Tabs */}
- setMode("question")}
- className={`pb-2 px-1 border-b-2 transition ${
- mode === "question"
- ? "border-blue-500 text-blue-600"
- : "border-transparent text-gray-600 hover:text-gray-800"
- }`}>
+ variant={mode === "question" ? "outline" : "ghost"}
+ size="sm"
+ className="rounded-none border-b-2 !border-0">
Вопрос
-
-
+ setMode("snippet")}
- className={`pb-2 px-1 border-b-2 transition ${
- mode === "snippet"
- ? "border-blue-500 text-blue-600"
- : "border-transparent text-gray-600 hover:text-gray-800"
- }`}>
+ variant={mode === "snippet" ? "outline" : "ghost"}
+ size="sm"
+ className="rounded-none border-b-2 !border-0">
Сниппет
-
+
{/* Question Form */}
@@ -87,18 +84,19 @@ export default function CreatePage() {
)}
-
- {questionForm.isPending ? "Создание..." : "Создать вопрос"}
-
-
+ Создать вопрос
+
+ navigate("/")}
- className="bg-gray-600 text-white px-4 py-2 rounded-lg hover:bg-gray-700">
+ variant="secondary">
Отмена
-
+
)}
@@ -148,18 +146,19 @@ export default function CreatePage() {
)}
-
- {snippetForm.isPending ? "Создание..." : "Создать сниппет"}
-
-
+ Создать сниппет
+
+ navigate("/")}
- className="bg-gray-600 text-white px-4 py-2 rounded-lg hover:bg-gray-700">
+ variant="secondary">
Отмена
-
+
)}
diff --git a/src/pages/home/ui/HomePageView.tsx b/src/pages/home/ui/HomePageView.tsx
index c598ae7..2bea00a 100644
--- a/src/pages/home/ui/HomePageView.tsx
+++ b/src/pages/home/ui/HomePageView.tsx
@@ -1,4 +1,5 @@
import { memo } from "react";
+import { Button } from "@/shared/ui/Button";
import { Skeleton } from "@/shared/ui/Skeleton";
import type { ReactNode, RefCallback } from "react";
@@ -37,26 +38,22 @@ function HomePageView({
{title}
{mode && onModeChange && (
- onModeChange("questions")}>
+ size="sm"
+ variant={mode === "questions" ? "outline" : "ghost"}
+ onClick={() => onModeChange("questions")}
+ className="rounded-none">
Вопросы
-
-
+ onModeChange("snippets")}>
+ size="sm"
+ variant={mode === "snippets" ? "outline" : "ghost"}
+ onClick={() => onModeChange("snippets")}
+ className="rounded-none border-l">
Сниппеты
-
+
)}
diff --git a/src/pages/home/ui/ItemCommonCardView.tsx b/src/pages/home/ui/ItemCommonCardView.tsx
index d1e97b5..a16ba3a 100644
--- a/src/pages/home/ui/ItemCommonCardView.tsx
+++ b/src/pages/home/ui/ItemCommonCardView.tsx
@@ -1,4 +1,5 @@
import { memo } from "react";
+import { Button } from "@/shared/ui/Button";
import Avatar from "@/shared/ui/Avatar";
import { ExpandableText } from "@/shared/ui/ExpandableText";
import { Clamp } from "@/shared/ui/Clamp";
@@ -87,34 +88,35 @@ export const ItemCommonCardView = memo(function ItemCommonCardView({
Answers: {answersCount}
)}
{onMoreClick && (
-
+ size="xs"
+ className="ml-auto">
Answers →
-
+
)}
) : (
-
Like
-
-
+
Dislike
-
+
{typeof likesCount !== "undefined" && (
Likes: {likesCount}
@@ -123,14 +125,16 @@ export const ItemCommonCardView = memo(function ItemCommonCardView({
Dislikes: {dislikesCount}
)}
{(onCommentsClick || onMoreClick) && (
-
{typeof commentsCount !== "undefined"
? `Comments: ${commentsCount}`
: "Comments →"}
-
+
)}
{!canInteract && (
diff --git a/src/pages/item/ItemDetailsPage.tsx b/src/pages/item/ItemDetailsPage.tsx
new file mode 100644
index 0000000..f3c6c23
--- /dev/null
+++ b/src/pages/item/ItemDetailsPage.tsx
@@ -0,0 +1,11 @@
+import { useItemDetails } from "./useItemDetails";
+import { ItemDetailsView } from "./ItemDetailsView";
+
+export default function ItemDetailsPage({
+ mode,
+}: {
+ mode: "question" | "snippet";
+}) {
+ const state = useItemDetails(mode);
+ return
;
+}
diff --git a/src/pages/item/ItemDetailsView.tsx b/src/pages/item/ItemDetailsView.tsx
new file mode 100644
index 0000000..210984b
--- /dev/null
+++ b/src/pages/item/ItemDetailsView.tsx
@@ -0,0 +1,233 @@
+import { BackLink } from "@/shared/ui/BackLink";
+import CodeEditor from "@/shared/ui/CodeEditor";
+import QuestionDetailsView from "./ui/QuestionDetailsView";
+import SnippetDetailsView from "./ui/SnippetDetailsView";
+import GenericTextForm from "./ui/GenericTextForm";
+import CommentsListView from "./ui/CommentsListView";
+import { ActionButtons } from "./ui/ActionButtons";
+import { EditPanel } from "./ui/EditPanel";
+import { LoadingSkeleton } from "./ui/LoadingSkeleton";
+import type { ItemState, QuestionState, SnippetState } from "./hooks/itemTypes";
+import type { Answer } from "@/entities/question/types";
+import { EditableAnswerItem } from "./ui/EditableAnswerItem";
+import { useAuth } from "@/app/providers/useAuth";
+
+export function ItemDetailsView(props: ItemState) {
+ if (props.loading) return
;
+ if (props.notFound) return
Не найдено
;
+ return props.mode === "question" ? (
+
+ ) : (
+
+ );
+}
+
+function QuestionSection({ state }: { state: QuestionState }) {
+ const { user } = useAuth();
+ const {
+ question,
+ isOwner,
+ isEditing,
+ edit,
+ startEdit,
+ cancelEdit,
+ saveEdit,
+ saving,
+ deleteItem,
+ deleting,
+ answerForm,
+ markCorrect,
+ markIncorrect,
+ markPending,
+ updateAnswer,
+ deleteAnswer,
+ } = state;
+ return (
+
+
+ {!isEditing && question && (
+
+ )
+ }
+ />
+ )}
+ {isEditing && (
+
+
+ Заголовок
+ edit.setTitle(e.target.value)}
+ className="w-full border rounded px-2 py-1 text-sm"
+ />
+
+
+ Описание
+ edit.setDescription(e.target.value)}
+ rows={5}
+ className="w-full border rounded px-2 py-1 text-sm"
+ />
+
+
+ Код
+
+
+
+ )}
+ {user ? (
+ answerForm && (
+
+ )
+ ) : (
+
+ Войдите, чтобы оставить ответ.
+
+ )}
+ {question && (
+
+
Ответы
+
+ {Array.isArray(question.answers) &&
+ question.answers!.map((a: Answer) => (
+ markCorrect(a.id)}
+ onMarkIncorrect={() => markIncorrect(a.id)}
+ markPending={markPending}
+ onUpdate={(id, content) => updateAnswer(id, content)}
+ onDelete={(id) => deleteAnswer(id)}
+ />
+ ))}
+
+
+ )}
+
+ );
+}
+
+function SnippetSection({ state }: { state: SnippetState }) {
+ const { user } = useAuth();
+ const {
+ snippet,
+ isOwner,
+ isEditing,
+ edit,
+ startEdit,
+ cancelEdit,
+ saveEdit,
+ saving,
+ deleteItem,
+ deleting,
+ commentForm,
+ updateComment,
+ deleteComment,
+ } = state;
+ return (
+
+
+ {!isEditing && snippet && (
+
+ )
+ }
+ />
+ )}
+ {isEditing && (
+
+
+ Язык
+ edit.setLanguage(e.target.value)}
+ className="w-full border rounded px-2 py-1 text-sm"
+ />
+
+
+ Код
+
+
+
+ )}
+ {user ? (
+ commentForm && (
+
Оставить комментарий
+ }
+ />
+ )
+ ) : (
+
+ Войдите, чтобы оставлять комментарии.
+
+ )}
+ {snippet?.comments && snippet.comments.length > 0 && (
+ ({
+ id: Number(c.id),
+ content: c.content,
+ user: { username: c.user?.username || "unknown" },
+ }))}
+ currentUser={user}
+ onUpdate={(cid, content) => updateComment(cid, content)}
+ onDelete={(cid) => deleteComment(cid)}
+ />
+ )}
+
+ );
+}
diff --git a/src/pages/item/QuestionDetailsPage.tsx b/src/pages/item/QuestionDetailsPage.tsx
new file mode 100644
index 0000000..60edb1c
--- /dev/null
+++ b/src/pages/item/QuestionDetailsPage.tsx
@@ -0,0 +1,13 @@
+import { useParams } from "react-router-dom";
+import { useAuth } from "@/app/providers/useAuth";
+import { useQuestionDetails } from "./hooks/useQuestionDetails";
+import { ItemDetailsView } from "./ItemDetailsView";
+
+export function QuestionDetailsPage() {
+ const { id } = useParams<{ id: string }>();
+ useAuth();
+ const state = useQuestionDetails(id);
+ return
;
+}
+
+export default QuestionDetailsPage;
diff --git a/src/pages/item/SnippetDetailsPage.tsx b/src/pages/item/SnippetDetailsPage.tsx
new file mode 100644
index 0000000..e7476e0
--- /dev/null
+++ b/src/pages/item/SnippetDetailsPage.tsx
@@ -0,0 +1,13 @@
+import { useParams } from "react-router-dom";
+import { useAuth } from "@/app/providers/useAuth";
+import { useSnippetDetails } from "./hooks/useSnippetDetails";
+import { ItemDetailsView } from "./ItemDetailsView";
+
+export function SnippetDetailsPage() {
+ const { id } = useParams<{ id: string }>();
+ useAuth();
+ const state = useSnippetDetails(id);
+ return
;
+}
+
+export default SnippetDetailsPage;
diff --git a/src/pages/item/hooks/itemTypes.ts b/src/pages/item/hooks/itemTypes.ts
new file mode 100644
index 0000000..b91d10d
--- /dev/null
+++ b/src/pages/item/hooks/itemTypes.ts
@@ -0,0 +1,73 @@
+import type { Answer } from "@/entities/question/types";
+import type { useAnswerForm } from "@/entities/question/hooks";
+import type { useCommentForm } from "@/entities/snippet/hooks";
+
+export type Mode = "question" | "snippet";
+
+export interface BaseState {
+ mode: Mode;
+ loading: boolean;
+ notFound: boolean;
+ error?: string;
+ isOwner: boolean;
+ isEditing: boolean;
+ startEdit(): void;
+ cancelEdit(): void;
+ saveEdit(): Promise
| void;
+ saving: boolean;
+ deleteItem(): Promise | void;
+ deleting: boolean;
+}
+
+export interface QuestionState extends BaseState {
+ mode: "question";
+ question?: {
+ title: string;
+ description: string;
+ attachedCode?: string;
+ answers?: Answer[];
+ };
+ edit: {
+ title: string;
+ description: string;
+ code: string;
+ setTitle(v: string): void;
+ setDescription(v: string): void;
+ setCode(v: string): void;
+ };
+ answerForm?: ReturnType;
+ markPending: boolean;
+ markCorrect(id: string | number): void;
+ markIncorrect(id: string | number): void;
+ updateAnswer(id: string | number, content: string): void;
+ deleteAnswer(id: string | number): void;
+}
+
+export interface SnippetState extends BaseState {
+ mode: "snippet";
+ snippet?: {
+ id: number;
+ language: string;
+ code: string;
+ likesCount?: number;
+ dislikesCount?: number;
+ commentsCount?: number;
+ user?: { username?: string };
+ comments?: {
+ id: number | string;
+ content: string;
+ user?: { username?: string };
+ }[];
+ };
+ edit: {
+ language: string;
+ code: string;
+ setLanguage(v: string): void;
+ setCode(v: string): void;
+ };
+ commentForm?: ReturnType;
+ updateComment(id: number | string, content: string): void;
+ deleteComment(id: number | string): void;
+}
+
+export type ItemState = QuestionState | SnippetState;
diff --git a/src/pages/item/hooks/useQuestionDetails.ts b/src/pages/item/hooks/useQuestionDetails.ts
new file mode 100644
index 0000000..596aa62
--- /dev/null
+++ b/src/pages/item/hooks/useQuestionDetails.ts
@@ -0,0 +1,115 @@
+import { useState, useMemo } from "react";
+import { useNavigate } from "react-router-dom";
+import {
+ useQuestion,
+ useUpdateQuestion,
+ useDeleteQuestion,
+ useSetAnswerState,
+ useUpdateAnswer,
+ useDeleteAnswer,
+} from "@/entities/question/api";
+import { useAnswerForm, useQuestionOwnership } from "@/entities/question/hooks";
+import { useQuestionAnswers } from "@/shared/socket";
+import type { QuestionState } from "./itemTypes";
+
+export function useQuestionDetails(id?: string): QuestionState {
+ const navigate = useNavigate();
+ const { data: questionData, status } = useQuestion(id);
+ useQuestionAnswers(id);
+ const answerForm = useAnswerForm(id || "0");
+ const isOwner = useQuestionOwnership(questionData);
+ const setAnswerStateMut = useSetAnswerState(id || "0");
+ const updateAnswerMut = useUpdateAnswer(id || "0");
+ const deleteAnswerMut = useDeleteAnswer(id || "0");
+ const updateQuestionMut = useUpdateQuestion(id || "0");
+ const deleteQuestionMut = useDeleteQuestion(id || "0");
+
+ const [isEditing, setIsEditing] = useState(false);
+ const [editTitle, setEditTitle] = useState("");
+ const [editDescription, setEditDescription] = useState("");
+ const [editQCode, setEditQCode] = useState("");
+
+ const startEdit = () => {
+ if (questionData) {
+ setEditTitle(questionData.title);
+ setEditDescription(questionData.description);
+ setEditQCode(questionData.attachedCode || "");
+ }
+ setIsEditing(true);
+ };
+
+ const cancelEdit = () => setIsEditing(false);
+
+ const loading = status === "pending";
+ const notFound = status === "success" && !questionData;
+
+ const mappedQuestion = useMemo(
+ () =>
+ questionData
+ ? {
+ title: questionData.title,
+ description: questionData.description,
+ attachedCode: questionData.attachedCode,
+ answers: questionData.answers, // уже готовый массив; при необходимости можно клонировать
+ }
+ : undefined,
+ [questionData]
+ );
+
+ return {
+ mode: "question",
+ loading,
+ notFound,
+ error: undefined,
+ isOwner,
+ isEditing,
+ startEdit,
+ cancelEdit,
+ saveEdit: async () => {
+ try {
+ await updateQuestionMut.mutateAsync({
+ title: editTitle,
+ description: editDescription,
+ attachedCode: editQCode,
+ });
+ setIsEditing(false);
+ } catch {
+ alert("Не удалось сохранить изменения");
+ }
+ },
+ saving: updateQuestionMut.isPending,
+ deleteItem: async () => {
+ if (!confirm("Удалить вопрос?")) return;
+ try {
+ await deleteQuestionMut.mutateAsync();
+ navigate("/my?mode=questions");
+ } catch {
+ alert("Не удалось удалить вопрос");
+ }
+ },
+ deleting: deleteQuestionMut.isPending,
+ question: mappedQuestion,
+ edit: {
+ title: editTitle,
+ description: editDescription,
+ code: editQCode,
+ setTitle: setEditTitle,
+ setDescription: setEditDescription,
+ setCode: setEditQCode,
+ },
+ answerForm,
+ markPending: setAnswerStateMut.isPending,
+ markCorrect: (answerId: string | number) => {
+ if (!isOwner) return;
+ setAnswerStateMut.mutate({ answerId, state: "correct" });
+ },
+ markIncorrect: (answerId: string | number) => {
+ if (!isOwner) return;
+ setAnswerStateMut.mutate({ answerId, state: "incorrect" });
+ },
+ updateAnswer: (answerId: string | number, content: string) =>
+ updateAnswerMut.mutate({ answerId, content }),
+ deleteAnswer: (answerId: string | number) =>
+ deleteAnswerMut.mutate(answerId),
+ } as const;
+}
diff --git a/src/pages/item/hooks/useSnippetDetails.ts b/src/pages/item/hooks/useSnippetDetails.ts
new file mode 100644
index 0000000..d08f7d5
--- /dev/null
+++ b/src/pages/item/hooks/useSnippetDetails.ts
@@ -0,0 +1,107 @@
+import { useState, useMemo } from "react";
+import { useNavigate } from "react-router-dom";
+import {
+ useSnippet,
+ useUpdateSnippet,
+ useDeleteSnippet,
+ useUpdateComment,
+ useDeleteComment,
+} from "@/entities/snippet/api";
+import { useCommentForm, useSnippetOwnership } from "@/entities/snippet/hooks";
+import { useSnippetComments } from "@/shared/socket";
+import type { SnippetState } from "./itemTypes";
+
+export function useSnippetDetails(rawId?: string): SnippetState {
+ const navigate = useNavigate();
+ const numericId = Number(rawId);
+ const validId = Number.isFinite(numericId) ? numericId : undefined;
+ const { data: snippetData, status } = useSnippet(validId);
+ useSnippetComments(validId);
+ const isOwner = useSnippetOwnership(snippetData);
+ const updateSnippetMut = useUpdateSnippet(numericId || 0);
+ const deleteSnippetMut = useDeleteSnippet(numericId || 0);
+ const updateCommentMut = useUpdateComment(numericId || 0);
+ const deleteCommentMut = useDeleteComment(numericId || 0);
+ const commentForm = useCommentForm(numericId || 0);
+
+ const [isEditing, setIsEditing] = useState(false);
+ const [editLanguage, setEditLanguage] = useState("");
+ const [editCode, setEditCode] = useState("");
+
+ const startEdit = () => {
+ if (snippetData) {
+ setEditLanguage(snippetData.language);
+ setEditCode(snippetData.code);
+ }
+ setIsEditing(true);
+ };
+ const cancelEdit = () => setIsEditing(false);
+
+ const loading = status === "pending";
+ const notFound = status === "success" && !snippetData;
+
+ const mappedSnippet = useMemo(
+ () =>
+ snippetData
+ ? {
+ id: snippetData.id,
+ language: snippetData.language,
+ code: snippetData.code,
+ user: { username: snippetData.user?.username },
+ likesCount: snippetData.likesCount,
+ dislikesCount: snippetData.dislikesCount,
+ commentsCount: snippetData.commentsCount,
+ comments: snippetData.comments?.map((c) => ({
+ id: Number(c.id),
+ content: c.content,
+ user: { username: c.user.username },
+ })),
+ }
+ : undefined,
+ [snippetData]
+ );
+
+ return {
+ mode: "snippet",
+ loading,
+ notFound,
+ error: undefined,
+ isOwner,
+ isEditing,
+ startEdit,
+ cancelEdit,
+ saveEdit: async () => {
+ try {
+ await updateSnippetMut.mutateAsync({
+ language: editLanguage,
+ code: editCode,
+ });
+ setIsEditing(false);
+ } catch {
+ alert("Не удалось сохранить изменения");
+ }
+ },
+ saving: updateSnippetMut.isPending,
+ deleteItem: async () => {
+ if (!confirm("Удалить сниппет?")) return;
+ try {
+ await deleteSnippetMut.mutateAsync();
+ navigate("/my?mode=snippets");
+ } catch {
+ alert("Не удалось удалить сниппет");
+ }
+ },
+ deleting: deleteSnippetMut.isPending,
+ snippet: mappedSnippet,
+ edit: {
+ language: editLanguage,
+ code: editCode,
+ setLanguage: setEditLanguage,
+ setCode: setEditCode,
+ },
+ commentForm,
+ updateComment: (id: number | string, content: string) =>
+ updateCommentMut.mutate({ id: Number(id), content }),
+ deleteComment: (id: number | string) => deleteCommentMut.mutate(Number(id)),
+ } as const;
+}
diff --git a/src/pages/item/ui/ActionButtons.tsx b/src/pages/item/ui/ActionButtons.tsx
new file mode 100644
index 0000000..31415eb
--- /dev/null
+++ b/src/pages/item/ui/ActionButtons.tsx
@@ -0,0 +1,26 @@
+import { Button } from "@/shared/ui/Button";
+
+interface ActionButtonsProps {
+ onEdit(): void;
+ onDelete(): void;
+ deleting?: boolean;
+}
+
+export function ActionButtons({
+ onEdit,
+ onDelete,
+ deleting,
+}: ActionButtonsProps) {
+ return (
+
+
+ Редактировать
+
+
+ {deleting ? "Удаление..." : "Удалить"}
+
+
+ );
+}
+
+export default ActionButtons;
diff --git a/src/pages/question/ui/AnswerFormView.tsx b/src/pages/item/ui/AnswerFormView.tsx
similarity index 100%
rename from src/pages/question/ui/AnswerFormView.tsx
rename to src/pages/item/ui/AnswerFormView.tsx
diff --git a/src/pages/question/ui/AnswerItemView.tsx b/src/pages/item/ui/AnswerItemView.tsx
similarity index 78%
rename from src/pages/question/ui/AnswerItemView.tsx
rename to src/pages/item/ui/AnswerItemView.tsx
index 453e6aa..13ee8eb 100644
--- a/src/pages/question/ui/AnswerItemView.tsx
+++ b/src/pages/item/ui/AnswerItemView.tsx
@@ -1,5 +1,6 @@
import { memo } from "react";
import { ExpandableText } from "@/shared/ui/ExpandableText";
+import { Button } from "@/shared/ui/Button";
export type AnswerItemViewProps = {
content: string;
@@ -48,36 +49,34 @@ export const AnswerItemView = memo(function AnswerItemView({
)}
{canMark &&
(isCorrect ? (
-
+ size="xs">
Снять метку
-
+
) : (
-
+ size="xs">
Пометить как верный
-
+
))}
{canEdit && (
<>
-
+
Изм.
-
-
+
+ size="xs"
+ variant="danger">
Удал.
-
+
>
)}
diff --git a/src/pages/snippet/ui/CommentFormView.tsx b/src/pages/item/ui/CommentFormView.tsx
similarity index 100%
rename from src/pages/snippet/ui/CommentFormView.tsx
rename to src/pages/item/ui/CommentFormView.tsx
diff --git a/src/pages/snippet/ui/CommentsListView.tsx b/src/pages/item/ui/CommentsListView.tsx
similarity index 82%
rename from src/pages/snippet/ui/CommentsListView.tsx
rename to src/pages/item/ui/CommentsListView.tsx
index 6f9f456..0742e37 100644
--- a/src/pages/snippet/ui/CommentsListView.tsx
+++ b/src/pages/item/ui/CommentsListView.tsx
@@ -1,5 +1,6 @@
import { memo, useState } from "react";
import Avatar from "@/shared/ui/Avatar";
+import { Button } from "@/shared/ui/Button";
export type Comment = {
id: number;
@@ -52,7 +53,6 @@ function CommentItem({
}) {
const [editing, setEditing] = useState(false);
const [value, setValue] = useState(comment.content);
-
const save = () => {
if (!onUpdate) return;
onUpdate(comment.id, value);
@@ -71,16 +71,12 @@ function CommentItem({
{isOwner && !editing && (
- setEditing(true)}
- className="text-xs px-2 py-0.5 rounded border">
+ setEditing(true)} size="xs">
Изм.
-
-
+
+
Удал.
-
+
)}
@@ -96,19 +92,17 @@ function CommentItem({
className="w-full border rounded px-2 py-1 text-sm"
/>
-
+
Сохранить
-
-
+ {
setValue(comment.content);
setEditing(false);
}}
- className="px-2 py-0.5 text-xs rounded border">
+ size="xs">
Отмена
-
+
)}
diff --git a/src/pages/item/ui/EditPanel.tsx b/src/pages/item/ui/EditPanel.tsx
new file mode 100644
index 0000000..dc0bac0
--- /dev/null
+++ b/src/pages/item/ui/EditPanel.tsx
@@ -0,0 +1,43 @@
+import type { ReactNode } from "react";
+import { Button } from "@/shared/ui/Button";
+
+interface EditPanelProps {
+ title: string;
+ saving?: boolean;
+ onSave(): void | Promise;
+ onCancel(): void;
+ children: ReactNode;
+ saveText?: string;
+ savingText?: string;
+}
+
+export function EditPanel({
+ title,
+ saving,
+ onSave,
+ onCancel,
+ children,
+ saveText = "Сохранить",
+ savingText = "Сохранение...",
+}: EditPanelProps) {
+ return (
+
+
{title}
+ {children}
+
+ onSave()}
+ variant="primary"
+ size="sm"
+ loading={saving}>
+ {saving ? savingText : saveText}
+
+
+ Отмена
+
+
+
+ );
+}
+
+export default EditPanel;
diff --git a/src/pages/item/ui/EditableAnswerItem.tsx b/src/pages/item/ui/EditableAnswerItem.tsx
new file mode 100644
index 0000000..1d02910
--- /dev/null
+++ b/src/pages/item/ui/EditableAnswerItem.tsx
@@ -0,0 +1,79 @@
+import { useState } from "react";
+import type { Answer } from "@/entities/question/types";
+import AnswerItemView from "./AnswerItemView";
+
+interface EditableAnswerItemProps {
+ answer: Answer;
+ canMark: boolean;
+ currentUser?: string;
+ onMarkCorrect: () => void;
+ onMarkIncorrect: () => void;
+ markPending: boolean;
+ onUpdate: (id: string | number, content: string) => void;
+ onDelete: (id: string | number) => void;
+}
+
+export function EditableAnswerItem({
+ answer,
+ canMark,
+ currentUser,
+ onMarkCorrect,
+ onMarkIncorrect,
+ markPending,
+ onUpdate,
+ onDelete,
+}: EditableAnswerItemProps) {
+ const [editing, setEditing] = useState(false);
+ const [value, setValue] = useState(answer.content);
+ const answerUser = ((): string | undefined => {
+ const anyAnswer = answer as unknown as { user?: { username?: string } };
+ return anyAnswer.user?.username;
+ })();
+ const isOwner = !!currentUser && answerUser === currentUser;
+ const save = () => {
+ onUpdate(answer.id, value);
+ setEditing(false);
+ };
+ return (
+
+
setEditing(true)}
+ onDelete={() => onDelete(answer.id)}
+ />
+ {editing && (
+
+
setValue(e.target.value)}
+ rows={4}
+ className="w-full border rounded px-2 py-1 text-sm"
+ />
+
+
+ Сохранить
+
+ {
+ setValue(answer.content);
+ setEditing(false);
+ }}
+ className="text-xs px-2 py-1 rounded border">
+ Отмена
+
+
+
+ )}
+
+ );
+}
+
+export default EditableAnswerItem;
diff --git a/src/pages/item/ui/GenericTextForm.tsx b/src/pages/item/ui/GenericTextForm.tsx
new file mode 100644
index 0000000..d580981
--- /dev/null
+++ b/src/pages/item/ui/GenericTextForm.tsx
@@ -0,0 +1,103 @@
+import type {
+ ReactNode,
+ TextareaHTMLAttributes,
+ FormEventHandler,
+} from "react";
+import { memo } from "react";
+
+type UncontrolledProps = {
+ textareaProps: TextareaHTMLAttributes;
+ value?: never;
+ onChange?: never;
+};
+
+type ControlledProps = {
+ value: string;
+ onChange(v: string): void;
+ textareaProps?: never;
+};
+
+export type GenericTextFormProps = (UncontrolledProps | ControlledProps) & {
+ placeholder?: string;
+ submitLabel?: string;
+ pending?: boolean;
+ error?: string | null;
+ success?: string | null;
+ onSubmit?: () => void; // для контролируемого варианта
+ formSubmit?: FormEventHandler; // для варианта с form + react-hook-form
+ header?: ReactNode;
+ disableIfEmpty?: boolean;
+ rows?: number;
+ className?: string;
+};
+
+export const GenericTextForm = memo(function GenericTextForm(
+ props: GenericTextFormProps
+) {
+ const {
+ placeholder = "",
+ submitLabel = "Отправить",
+ pending,
+ error,
+ success,
+ header,
+ disableIfEmpty = true,
+ rows = 4,
+ className = "space-y-2",
+ } = props;
+
+ const contentValue =
+ "value" in props
+ ? props.value
+ : props.textareaProps && "value" in props.textareaProps
+ ? (props.textareaProps as TextareaHTMLAttributes)
+ .value
+ : undefined;
+ const isEmpty =
+ disableIfEmpty && (!contentValue || !String(contentValue).trim());
+ const buttonDisabled = !!pending || (disableIfEmpty && isEmpty);
+
+ const textarea = (
+ ) =>
+ props.onChange && props.onChange(e.target.value),
+ }
+ : props.textareaProps)}
+ />
+ );
+
+ const body = (
+ <>
+ {header}
+ {textarea}
+
+
+ {pending ? "..." : submitLabel}
+
+ {error && {error} }
+ {success && {success} }
+
+ >
+ );
+
+ if (props.formSubmit) {
+ return (
+
+ {body}
+
+ );
+ }
+ return {body}
;
+});
+
+export default GenericTextForm;
diff --git a/src/pages/item/ui/LoadingSkeleton.tsx b/src/pages/item/ui/LoadingSkeleton.tsx
new file mode 100644
index 0000000..3b4faf3
--- /dev/null
+++ b/src/pages/item/ui/LoadingSkeleton.tsx
@@ -0,0 +1,30 @@
+import { BackLink } from "@/shared/ui/BackLink";
+import { Skeleton } from "@/shared/ui/Skeleton";
+
+interface LoadingSkeletonProps {
+ mode: string;
+}
+
+export function LoadingSkeleton({ mode }: LoadingSkeletonProps) {
+ const config =
+ mode === "question"
+ ? [{ w: 240, h: 28 }, { h: 80 }, { h: 180 }]
+ : [{ w: 180, h: 28 }, { h: 80 }, { h: 220 }];
+ return (
+
+
+
+ {config.map((b, i) => (
+ 30 ? "rounded" : undefined}
+ />
+ ))}
+
+
+ );
+}
+
+export default LoadingSkeleton;
diff --git a/src/pages/question/ui/QuestionDetailsView.tsx b/src/pages/item/ui/QuestionDetailsView.tsx
similarity index 100%
rename from src/pages/question/ui/QuestionDetailsView.tsx
rename to src/pages/item/ui/QuestionDetailsView.tsx
diff --git a/src/pages/snippet/ui/SnippetDetailsView.tsx b/src/pages/item/ui/SnippetDetailsView.tsx
similarity index 93%
rename from src/pages/snippet/ui/SnippetDetailsView.tsx
rename to src/pages/item/ui/SnippetDetailsView.tsx
index 9d22763..cf65dd8 100644
--- a/src/pages/snippet/ui/SnippetDetailsView.tsx
+++ b/src/pages/item/ui/SnippetDetailsView.tsx
@@ -10,7 +10,7 @@ export type SnippetDetailsViewProps = {
likesCount?: number;
dislikesCount?: number;
commentsCount?: number;
- actions?: React.ReactNode; // зона для кнопок (редактирование / удаление)
+ actions?: React.ReactNode;
};
export const SnippetDetailsView = memo(function SnippetDetailsView({
diff --git a/src/pages/item/useItemDetails.ts b/src/pages/item/useItemDetails.ts
new file mode 100644
index 0000000..bb452b2
--- /dev/null
+++ b/src/pages/item/useItemDetails.ts
@@ -0,0 +1,21 @@
+import { useParams } from "react-router-dom";
+import { useAuth } from "@/app/providers/useAuth";
+import { useQuestionDetails } from "./hooks/useQuestionDetails";
+import { useSnippetDetails } from "./hooks/useSnippetDetails";
+export {
+ type Mode,
+ type BaseState,
+ type QuestionState,
+ type SnippetState,
+ type ItemState,
+} from "./hooks/itemTypes";
+import type { ItemState, Mode } from "./hooks/itemTypes";
+
+export function useItemDetails(mode: Mode): ItemState {
+ const { id } = useParams<{ id: string }>();
+ useAuth();
+ // Вызвать оба хука (один будет лишним) чтобы соблюсти порядок хуков.
+ const questionState = useQuestionDetails(id);
+ const snippetState = useSnippetDetails(id);
+ return (mode === "question" ? questionState : snippetState) as ItemState;
+}
diff --git a/src/pages/question/QuestionPage.tsx b/src/pages/question/QuestionPage.tsx
deleted file mode 100644
index e3018dd..0000000
--- a/src/pages/question/QuestionPage.tsx
+++ /dev/null
@@ -1,331 +0,0 @@
-import { useParams } from "react-router-dom";
-import {
- useQuestion,
- useUpdateQuestion,
- useDeleteQuestion,
- useUpdateAnswer,
- useDeleteAnswer,
-} from "@/entities/question/api";
-import type { Question, Answer } from "@/entities/question/types";
-import { useAuth } from "@/app/providers/useAuth";
-import QuestionDetailsView from "./ui/QuestionDetailsView";
-import AnswerItemView from "./ui/AnswerItemView";
-import AnswerFormView from "./ui/AnswerFormView";
-import { BackLink } from "@/shared/ui/BackLink";
-import { Skeleton } from "@/shared/ui/Skeleton";
-import { useQuestionAnswers } from "@/shared/socket";
-import {
- useAnswerForm,
- useQuestionOwnership,
- useAnswerActions,
-} from "@/entities/question/hooks";
-import { useState } from "react";
-import CodeEditor from "@/shared/ui/CodeEditor";
-import { useNavigate } from "react-router-dom";
-
-export default function QuestionPage() {
- const { id } = useParams<{ id: string }>();
- const { user } = useAuth();
- const { data: question, status } = useQuestion(id);
-
- useQuestionAnswers(id);
-
- const answerForm = useAnswerForm(id!);
- const isOwner = useQuestionOwnership(question);
- const {
- markCorrect,
- markIncorrect,
- isPending: markPending,
- } = useAnswerActions(id!);
-
- const updateMutation = useUpdateQuestion(id!);
- const deleteMutation = useDeleteQuestion(id!);
- const updateAnswerMutation = useUpdateAnswer(id!);
- const deleteAnswerMutation = useDeleteAnswer(id!);
- const [isEditing, setIsEditing] = useState(false);
- const [editTitle, setEditTitle] = useState("");
- const [editDescription, setEditDescription] = useState("");
- const [editCode, setEditCode] = useState("");
- const navigate = useNavigate();
-
- if (status === "pending")
- return (
-
-
-
- {/* Заголовок и детали вопроса */}
-
-
-
-
- {/* Форма ответа */}
-
-
-
-
-
- {/* Секция ответов */}
-
-
- {[...Array(3)].map((_, i) => (
-
- ))}
-
-
- );
- if (!question) return Вопрос не найден
;
-
- const handleMarkCorrect = (answerId: string | number) => {
- if (!isOwner) return;
- markCorrect(answerId);
- };
-
- const handleMarkIncorrect = (answerId: string | number) => {
- if (!isOwner) return;
- markIncorrect(answerId);
- };
-
- const onStartEdit = () => {
- if (!question) return;
- setEditTitle(question.title);
- setEditDescription(question.description);
- setEditCode(question.attachedCode || "");
- setIsEditing(true);
- };
-
- const onCancelEdit = () => setIsEditing(false);
-
- const onSave = async () => {
- try {
- await updateMutation.mutateAsync({
- title: editTitle,
- description: editDescription,
- attachedCode: editCode,
- });
- setIsEditing(false);
- } catch {
- alert("Не удалось сохранить изменения");
- }
- };
-
- const onDelete = async () => {
- if (!confirm("Удалить вопрос?")) return;
- try {
- await deleteMutation.mutateAsync();
- navigate("/my?mode=questions");
- } catch {
- alert("Не удалось удалить вопрос");
- }
- };
-
- const actionButtons = isOwner && !isEditing && (
- <>
-
- Редактировать
-
-
- {deleteMutation.isPending ? "Удаление..." : "Удалить"}
-
- >
- );
-
- return (
-
-
- {!isEditing && (
-
- )}
- {isEditing && (
-
-
Редактирование вопроса
-
- Заголовок
- setEditTitle(e.target.value)}
- className="w-full border rounded px-2 py-1 text-sm"
- />
-
-
- Описание
- setEditDescription(e.target.value)}
- rows={5}
- className="w-full border rounded px-2 py-1 text-sm"
- />
-
-
- Код
-
-
-
-
- {updateMutation.isPending ? "Сохранение..." : "Сохранить"}
-
-
- Отмена
-
-
-
- )}
- {user ? (
-
- ) : (
-
- Войдите, чтобы оставить ответ.
-
- )}
-
-
Ответы
-
- updateAnswerMutation.mutate({ answerId, content })
- }
- onDelete={(answerId) => deleteAnswerMutation.mutate(answerId)}
- />
-
-
- );
-}
-
-function AnswerList({
- answers,
- currentUser,
- ownerCanMark,
- onMarkCorrect,
- onMarkIncorrect,
- markPending,
- onUpdate,
- onDelete,
-}: {
- answers: Answer[];
- currentUser?: string;
- ownerCanMark: boolean;
- onMarkCorrect: (id: string | number) => void;
- onMarkIncorrect: (id: string | number) => void;
- markPending: boolean;
- onUpdate: (id: string | number, content: string) => void;
- onDelete: (id: string | number) => void;
-}) {
- return (
-
- {answers.map((a) => (
- onMarkCorrect(a.id)}
- onMarkIncorrect={() => onMarkIncorrect(a.id)}
- markPending={markPending}
- onUpdate={onUpdate}
- onDelete={onDelete}
- />
- ))}
-
- );
-}
-
-import { useState as useStateReact } from "react";
-
-function EditableAnswerItem({
- answer,
- canMark,
- currentUser,
- onMarkCorrect,
- onMarkIncorrect,
- markPending,
- onUpdate,
- onDelete,
-}: {
- answer: Answer;
- canMark: boolean;
- currentUser?: string;
- onMarkCorrect: () => void;
- onMarkIncorrect: () => void;
- markPending: boolean;
- onUpdate: (id: string | number, content: string) => void;
- onDelete: (id: string | number) => void;
-}) {
- const [editing, setEditing] = useStateReact(false);
- const [value, setValue] = useStateReact(answer.content);
- // Допускаем что объект ответа может содержать user (не описан в исходном типе)
- const answerUser = (answer as unknown as { user?: { username?: string } })
- .user;
- const isOwner = !!currentUser && answerUser?.username === currentUser;
-
- const save = () => {
- onUpdate(answer.id, value);
- setEditing(false);
- };
- const del = () => {
- if (confirm("Удалить ответ?")) onDelete(answer.id);
- };
- return (
-
-
setEditing(true)}
- onDelete={del}
- />
- {editing && (
-
-
setValue(e.target.value)}
- rows={4}
- className="w-full border rounded px-2 py-1 text-sm"
- />
-
-
- Сохранить
-
- {
- setValue(answer.content);
- setEditing(false);
- }}
- className="text-xs px-2 py-1 rounded border">
- Отмена
-
-
-
- )}
-
- );
-}
diff --git a/src/pages/question/QuestionPageNew.tsx b/src/pages/question/QuestionPageNew.tsx
deleted file mode 100644
index 239087f..0000000
--- a/src/pages/question/QuestionPageNew.tsx
+++ /dev/null
@@ -1,108 +0,0 @@
-import { useParams } from "react-router-dom";
-import { useQuestion } from "../../entities/question/api";
-import type { Question, Answer } from "../../entities/question/types";
-import { useAuth } from "../../app/providers/useAuth";
-import QuestionDetailsView from "./ui/QuestionDetailsView";
-import AnswerItemView from "./ui/AnswerItemView";
-import AnswerFormView from "./ui/AnswerFormView";
-import { BackLink } from "../../shared/ui/BackLink";
-import { Skeleton } from "../../shared/ui/Skeleton";
-import { useQuestionAnswers } from "../../shared/socket";
-import {
- useAnswerForm,
- useQuestionOwnership,
- useAnswerActions,
-} from "../../entities/question/hooks";
-
-export default function QuestionPage() {
- const { id } = useParams<{ id: string }>();
- const { user } = useAuth();
- const { data: question, status } = useQuestion(id);
-
- useQuestionAnswers(id);
-
- const answerForm = useAnswerForm(id!);
- const isOwner = useQuestionOwnership(question);
- const {
- markCorrect,
- markIncorrect,
- isPending: markPending,
- } = useAnswerActions(id!);
-
- if (status === "pending")
- return (
-
-
-
- {/* Заголовок и детали вопроса */}
-
-
-
-
- {/* Форма ответа */}
-
-
-
-
-
- {/* Секция ответов */}
-
-
- {[...Array(3)].map((_, i) => (
-
- ))}
-
-
- );
- if (!question) return Вопрос не найден
;
-
- const handleMarkCorrect = (answerId: string | number) => {
- if (!isOwner) return;
- markCorrect(answerId);
- };
-
- const handleMarkIncorrect = (answerId: string | number) => {
- if (!isOwner) return;
- markIncorrect(answerId);
- };
-
- return (
-
-
-
- {user ? (
-
- ) : (
-
- Войдите, чтобы оставить ответ.
-
- )}
-
-
Ответы
-
- {Array.isArray((question as Question).answers) &&
- (question as Question).answers!.map((a: Answer) => (
- handleMarkCorrect(a.id)}
- onMarkIncorrect={() => handleMarkIncorrect(a.id)}
- />
- ))}
-
-
-
- );
-}
diff --git a/src/pages/snippet/SnippetPage.tsx b/src/pages/snippet/SnippetPage.tsx
deleted file mode 100644
index c648547..0000000
--- a/src/pages/snippet/SnippetPage.tsx
+++ /dev/null
@@ -1,223 +0,0 @@
-import { useParams } from "react-router-dom";
-import {
- useSnippet,
- useUpdateSnippet,
- useDeleteSnippet,
- useUpdateComment,
- useDeleteComment,
-} from "@/entities/snippet/api";
-import { useAuth } from "@/app/providers/useAuth";
-import { BackLink } from "@/shared/ui/BackLink";
-import SnippetDetailsView from "./ui/SnippetDetailsView";
-import { Skeleton } from "@/shared/ui/Skeleton";
-import CommentFormView from "./ui/CommentFormView";
-import CommentsListView from "./ui/CommentsListView";
-import { useSnippetComments } from "@/shared/socket";
-import { useCommentForm, useSnippetOwnership } from "@/entities/snippet/hooks";
-import CodeEditor from "@/shared/ui/CodeEditor";
-import { useState } from "react";
-import { useNavigate } from "react-router-dom";
-
-export default function SnippetPage() {
- const { id } = useParams();
- const snippetId = Number(id);
- const { data: snippet, status } = useSnippet(
- Number.isFinite(snippetId) ? snippetId : undefined
- );
- const { user } = useAuth();
- const isOwner = useSnippetOwnership(snippet);
- const navigate = useNavigate();
-
- const [isEditing, setIsEditing] = useState(false);
- const [editLanguage, setEditLanguage] = useState("");
- const [editCode, setEditCode] = useState("");
-
- const updateMutation = useUpdateSnippet(snippetId);
- const deleteMutation = useDeleteSnippet(snippetId);
- const updateCommentMutation = useUpdateComment(snippetId);
- const deleteCommentMutation = useDeleteComment(snippetId);
-
- useSnippetComments(Number.isFinite(snippetId) ? snippetId : undefined);
-
- const commentForm = useCommentForm(snippetId);
-
- if (status === "pending") {
- return (
-
-
-
-
-
-
- {[...Array(2)].map((_, i) => (
-
-
-
-
- ))}
-
-
- );
- }
-
- if (status === "error") {
- return Не удалось загрузить сниппет.
;
- }
-
- if (!snippet) {
- return Сниппет не найден.
;
- }
-
- const onStartEdit = () => {
- if (!snippet) return;
- setEditLanguage(snippet.language);
- setEditCode(snippet.code);
- setIsEditing(true);
- };
-
- const onCancelEdit = () => {
- setIsEditing(false);
- };
-
- const onSave = async () => {
- try {
- await updateMutation.mutateAsync({
- language: editLanguage,
- code: editCode,
- });
- setIsEditing(false);
- } catch {
- alert("Не удалось сохранить изменения");
- }
- };
-
- const onDelete = async () => {
- if (!confirm("Удалить сниппет? Действие необратимо.")) return;
- try {
- await deleteMutation.mutateAsync();
- navigate("/my?mode=snippets");
- } catch {
- alert("Не удалось удалить сниппет");
- }
- };
-
- const actionButtons = isOwner && !isEditing && (
- <>
-
- Редактировать
-
-
- {deleteMutation.isPending ? "Удаление..." : "Удалить"}
-
- >
- );
-
- return (
-
-
- {!isEditing && (
-
- )}
- {isEditing && (
-
-
- Редактирование сниппета #{snippet.id}
-
-
- Язык
- setEditLanguage(e.target.value)}
- className="w-full border rounded px-2 py-1 text-sm"
- placeholder="Language"
- />
-
-
- Код
-
-
-
-
- {updateMutation.isPending ? "Сохранение..." : "Сохранить"}
-
-
- Отмена
-
-
-
- )}
-
- {user ? (
-
- ) : (
-
- Войдите, чтобы оставлять комментарии.
-
- )}
-
- {Array.isArray(snippet.comments) && snippet.comments.length > 0 && (
-
{
- updateCommentMutation.mutate({ id: cid, content });
- }}
- onDelete={(cid) => {
- deleteCommentMutation.mutate(cid);
- }}
- />
- )}
-
- );
-}
diff --git a/src/shared/ui/Button.tsx b/src/shared/ui/Button.tsx
new file mode 100644
index 0000000..c0dfa61
--- /dev/null
+++ b/src/shared/ui/Button.tsx
@@ -0,0 +1,93 @@
+import type { ButtonHTMLAttributes, ReactNode } from "react";
+import { memo } from "react";
+import clsx from "clsx";
+
+type Variant =
+ | "primary"
+ | "secondary"
+ | "danger"
+ | "ghost"
+ | "outline"
+ | "link";
+type Size = "xs" | "sm" | "md";
+
+export interface ButtonProps extends ButtonHTMLAttributes {
+ variant?: Variant;
+ size?: Size;
+ loading?: boolean;
+ iconLeft?: ReactNode;
+ iconRight?: ReactNode;
+ fullWidth?: boolean;
+}
+
+const base =
+ "inline-flex items-center justify-center rounded whitespace-nowrap font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed";
+
+const variantClasses: Record = {
+ primary:
+ "bg-blue-600 text-white hover:bg-blue-500 dark:bg-blue-500 dark:hover:bg-blue-400",
+ secondary:
+ "border border-gray-300 bg-white text-gray-900 hover:bg-gray-50 dark:border-neutral-600 dark:bg-neutral-800 dark:text-white dark:hover:bg-neutral-700",
+ danger:
+ "border border-red-600 text-red-600 hover:bg-red-50 dark:hover:bg-red-950",
+ ghost:
+ "text-gray-600 hover:bg-gray-100 dark:text-neutral-300 dark:hover:bg-neutral-700",
+ outline:
+ "border border-gray-300 text-gray-800 hover:bg-gray-50 dark:border-neutral-600 dark:text-neutral-200 dark:hover:bg-neutral-700 bg-transparent",
+ link: "text-blue-600 hover:underline dark:text-blue-400",
+};
+
+const sizeClasses: Record = {
+ xs: "text-xs px-2 py-1",
+ sm: "text-sm px-3 py-1.5",
+ md: "text-sm px-4 py-2",
+};
+
+function Spinner() {
+ return (
+
+
+
+
+ );
+}
+
+export const Button = memo(function Button({
+ variant = "secondary",
+ size = "sm",
+ loading,
+ iconLeft,
+ iconRight,
+ className,
+ children,
+ fullWidth,
+ disabled,
+ ...rest
+}: ButtonProps) {
+ return (
+
+ {iconLeft && {iconLeft} }
+ {loading ? : children}
+ {iconRight && {iconRight} }
+
+ );
+});
+
+export default Button;
diff --git a/src/shared/ui/Clamp.tsx b/src/shared/ui/Clamp.tsx
index 282ffd5..16d96c5 100644
--- a/src/shared/ui/Clamp.tsx
+++ b/src/shared/ui/Clamp.tsx
@@ -1,4 +1,5 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { Button } from "@/shared/ui/Button";
type ClampProps = {
children: React.ReactNode;
@@ -80,27 +81,26 @@ export const Clamp = memo(function Clamp({
{mode === "toggle" ? (
expanded ? (
-
+ variant="link"
+ size="xs"
+ onClick={handleLess}>
{lessLabel}
-
+
) : (
-
+ variant="link"
+ size="xs"
+ onClick={handleMore}>
{moreLabel}
-
+
)
) : (
-
+
{moreLabel}
-
+
)}
)}
diff --git a/src/shared/ui/ExpandableText.tsx b/src/shared/ui/ExpandableText.tsx
index 82d82a9..92a012d 100644
--- a/src/shared/ui/ExpandableText.tsx
+++ b/src/shared/ui/ExpandableText.tsx
@@ -1,4 +1,5 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { Button } from "@/shared/ui/Button";
type ExpandableTextProps = {
text: string;
@@ -91,27 +92,26 @@ export const ExpandableText = memo(function ExpandableText({
{mode === "toggle" ? (
expanded ? (
-
+ variant="link"
+ size="xs"
+ onClick={handleLess}>
{lessLabel}
-
+
) : (
-
+ variant="link"
+ size="xs"
+ onClick={handleMore}>
{moreLabel}
-
+
)
) : (
-
+
{moreLabel}
-
+
)}
)}
From ce45dba9281e0d6039c229415cc9281aab63db30 Mon Sep 17 00:00:00 2001
From: kotru21
Date: Mon, 25 Aug 2025 00:01:36 +0300
Subject: [PATCH 32/40] Refactor auth token handling and improve answer form UX
Removed in-memory and localStorage auth token management in favor of relying on httpOnly cookies. Updated answer form to use 'onChange' mode and improved controlled textarea handling. Enhanced account info view with avatar display. Simplified HTTP error normalization logic.
---
src/app/providers/auth.tsx | 41 +-----
src/entities/question/hooks/useAnswerForm.ts | 1 +
src/pages/account/ui/AccountInfoView.tsx | 11 +-
src/pages/item/ItemDetailsView.tsx | 5 +-
src/shared/api/http.ts | 128 ++++---------------
5 files changed, 40 insertions(+), 146 deletions(-)
diff --git a/src/app/providers/auth.tsx b/src/app/providers/auth.tsx
index ea550b7..eaf94e3 100644
--- a/src/app/providers/auth.tsx
+++ b/src/app/providers/auth.tsx
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
-import { http, toHttpError, setAuthToken } from "../../shared/api/http";
+import { http, toHttpError } from "../../shared/api/http";
import { unwrapData } from "../../shared/api/normalize";
import { AuthContext, type User } from "./auth-context";
@@ -7,28 +7,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
- const storeTokenFromRaw = (raw: unknown) => {
- if (raw && typeof raw === "object") {
- const obj = raw as Record;
- const tokenLike = (obj["accessToken"] || obj["token"] || obj["jwt"]) as
- | string
- | undefined;
- if (typeof tokenLike === "string" && tokenLike.length > 10) {
- setAuthToken(tokenLike);
- try {
- localStorage.setItem("authToken", tokenLike);
- } catch {
- // ignore localStorage write errors
- }
- }
- }
- };
-
const refresh = useCallback(async () => {
try {
const res = await http.get("/auth");
const raw = unwrapData(res.data);
- storeTokenFromRaw(raw);
const normalized: User = {
id: Number((raw as Record)?.["id"] ?? 0),
username: String((raw as Record)?.["username"] ?? ""),
@@ -48,21 +30,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
if (didInit.current) return;
didInit.current = true;
- // Восстанавливаем токен из localStorage (если API не использует httpOnly cookie)
- try {
- const saved = localStorage.getItem("authToken");
- if (saved) setAuthToken(saved);
- } catch {
- // ignore localStorage errors
- }
void refresh();
}, [refresh]);
const login = async (username: string, password: string) => {
try {
- const res = await http.post("/auth/login", { username, password });
- const raw = unwrapData(res.data);
- storeTokenFromRaw(raw);
+ await http.post("/auth/login", { username, password });
await refresh();
} catch (e) {
const err = toHttpError(e);
@@ -79,19 +52,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
// ignore network errors on logout
}
setUser(null);
- setAuthToken(null);
- try {
- localStorage.removeItem("authToken");
- } catch {
- // ignore localStorage errors
- }
};
const register = async (username: string, password: string) => {
try {
- const res = await http.post("/register", { username, password });
- const raw = unwrapData(res.data);
- storeTokenFromRaw(raw);
+ await http.post("/register", { username, password });
} catch (e) {
const err = toHttpError(e);
const error = new Error(err.message || "Register failed");
diff --git a/src/entities/question/hooks/useAnswerForm.ts b/src/entities/question/hooks/useAnswerForm.ts
index 08189cd..2e18570 100644
--- a/src/entities/question/hooks/useAnswerForm.ts
+++ b/src/entities/question/hooks/useAnswerForm.ts
@@ -17,6 +17,7 @@ export function useAnswerForm(questionId: string | number) {
const form = useForm({
resolver: zodResolver(schema),
+ mode: "onChange",
});
const onSubmit = async (data: FormData) => {
diff --git a/src/pages/account/ui/AccountInfoView.tsx b/src/pages/account/ui/AccountInfoView.tsx
index 34f66c7..bc13805 100644
--- a/src/pages/account/ui/AccountInfoView.tsx
+++ b/src/pages/account/ui/AccountInfoView.tsx
@@ -1,4 +1,5 @@
import { memo } from "react";
+import Avatar from "@/shared/ui/Avatar";
export type AccountInfoViewProps = {
id?: number;
@@ -17,7 +18,10 @@ export const AccountInfoView = memo(function AccountInfoView({
}: AccountInfoViewProps) {
return (
- Аккаунт
+
+ {username &&
}
+
@{username}
+
{loading ? (
@@ -25,10 +29,7 @@ export const AccountInfoView = memo(function AccountInfoView({
) : typeof id === "number" && username && role ? (
-
-
- Имя пользователя: @{username}
-
+
Роль: {role}
diff --git a/src/pages/item/ItemDetailsView.tsx b/src/pages/item/ItemDetailsView.tsx
index 210984b..a0f9b72 100644
--- a/src/pages/item/ItemDetailsView.tsx
+++ b/src/pages/item/ItemDetailsView.tsx
@@ -94,7 +94,10 @@ function QuestionSection({ state }: { state: QuestionState }) {
answerForm && (
+ answerForm.setValue("content", v, { shouldValidate: true })
+ }
error={answerForm.formState.errors.content?.message}
pending={answerForm.formState.isSubmitting || answerForm.isPending}
placeholder="Ваш ответ..."
diff --git a/src/shared/api/http.ts b/src/shared/api/http.ts
index fff0d2a..69774ab 100644
--- a/src/shared/api/http.ts
+++ b/src/shared/api/http.ts
@@ -1,7 +1,6 @@
-import axios, { AxiosHeaders } from "axios";
+import axios from "axios";
-// Use relative '/api' by default to leverage Vite proxy (avoids CORS with credentials).
-// Can be overridden via VITE_API_BASE_URL if your API supports proper CORS for credentials.
+// /api (Vite proxy) или переопределение через VITE_API_BASE_URL
const API_BASE_URL =
(import.meta as unknown as { env?: Record }).env
?.VITE_API_BASE_URL || "/api";
@@ -12,111 +11,36 @@ export const http = axios.create({
timeout: 10000,
});
-// In-memory bearer token (optional) – fallback when API doesn't use httpOnly cookies
-let authToken: string | null = null;
-export function setAuthToken(token: string | null) {
- authToken = token;
-}
-
-http.interceptors.request.use((config) => {
- if (authToken) {
- if (!config.headers) config.headers = new AxiosHeaders();
- // Приводим для установки значения без конфликтов типов
- const h = config.headers as Record;
- if (h["Authorization"] == null) {
- (h as Record)["Authorization"] = `Bearer ${authToken}`;
- }
- }
- return config;
-});
-
-http.interceptors.response.use(
- (r) => r,
- (error) => Promise.reject(error)
-);
-
-export type HttpError = {
+export interface HttpError {
status?: number;
message?: string;
-};
+}
-export function toHttpError(e: unknown): HttpError {
- if (typeof e === "object" && e !== null) {
- const anyE = e as Record;
- const hasResponse =
- "response" in anyE &&
- typeof anyE.response === "object" &&
- anyE.response !== null;
- const status =
- hasResponse && "status" in (anyE.response as Record)
- ? ((anyE.response as Record).status as
- | number
- | undefined)
- : undefined;
- const data =
- hasResponse && "data" in (anyE.response as Record)
- ? ((anyE.response as Record).data as Record<
- string,
- unknown
- >)
+export function toHttpError(err: unknown): HttpError {
+ if (typeof err === "object" && err !== null) {
+ const obj = err as Record;
+ const response = (obj as { response?: unknown }).response;
+ const resObj =
+ typeof response === "object" && response !== null
+ ? (response as Record)
: undefined;
- // Try to pick a useful message from common API error shapes
+ const status = resObj?.status as number | undefined;
+ const data = resObj?.data as
+ | { message?: unknown; error?: unknown; detail?: unknown }
+ | undefined;
let message: string | undefined;
- if (data && typeof data === "object") {
- const rec = data as Record;
- const msg = rec["message"];
- if (typeof msg === "string") message = msg;
- // NestJS/class-validator often returns { message: string[] }
- else if (Array.isArray(msg)) {
- const arr = msg as unknown[];
- const firstStr = arr.find((it) => typeof it === "string");
- if (typeof firstStr === "string") message = firstStr;
- else if (arr.length) {
- const first = arr[0];
- if (typeof first === "object" && first !== null) {
- const obj = first as Record;
- const constraints = obj["constraints"];
- if (constraints && typeof constraints === "object") {
- const values = Object.values(
- constraints as Record
- );
- const cFirst = values.find((v) => typeof v === "string");
- if (typeof cFirst === "string") message = cFirst;
- }
- }
- }
- } else {
- const errorMsg = rec["error"];
- const detail = rec["detail"];
- if (typeof errorMsg === "string") message = errorMsg;
- else if (typeof detail === "string") message = detail;
- else if (Array.isArray(rec["errors"])) {
- const arr = rec["errors"] as unknown[];
- // If API returns array of strings
- const firstString = arr.find((it) => typeof it === "string");
- if (typeof firstString === "string") message = firstString;
- else {
- // Try documented shape: { field: string; failures: string[]; receivedValue?: unknown }
- const firstObj = arr.find(
- (it) => typeof it === "object" && it !== null
- );
- if (firstObj) {
- const obj = firstObj as Record;
- const failures = obj["failures"];
- if (Array.isArray(failures)) {
- const firstFailure = (failures as unknown[]).find(
- (f) => typeof f === "string"
- );
- if (typeof firstFailure === "string") message = firstFailure;
- }
- }
- }
- }
- }
+ const pick = (v: unknown) =>
+ typeof v === "string" && v.trim() ? v : undefined;
+ if (data) {
+ message =
+ pick(data.message) ||
+ pick(data.error) ||
+ pick(data.detail) ||
+ undefined;
}
- if (!message && typeof anyE.message === "string")
- message = anyE.message as string;
+ if (!message && typeof (obj as { message?: unknown }).message === "string")
+ message = (obj as { message?: string }).message;
return { status, message };
}
- return { message: String(e) };
+ return { message: String(err) };
}
From b990184a40f2f6a9dab619181ad344f8ba67b626 Mon Sep 17 00:00:00 2001
From: kotru21
Date: Mon, 25 Aug 2025 00:49:15 +0300
Subject: [PATCH 33/40] Refactor item details structure and imports
Removed generic ItemDetailsPage and useItemDetails in favor of dedicated question and snippet modules. Moved question and snippet related components and hooks into their respective subfolders for better separation. Updated imports and exports to reflect new structure, improving maintainability and clarity.
---
src/pages/item/ItemDetailsPage.tsx | 11 --
src/pages/item/ItemDetailsView.tsx | 6 +-
src/pages/item/QuestionDetailsPage.tsx | 2 +-
src/pages/item/SnippetDetailsPage.tsx | 2 +-
src/pages/item/question/index.ts | 1 +
.../item/{ => question}/ui/AnswerItemView.tsx | 0
.../{ => question}/ui/EditableAnswerItem.tsx | 0
.../{ => question}/ui/QuestionDetailsView.tsx | 6 +-
src/pages/item/question/useQuestionDetails.ts | 118 ++++++++++++++++++
.../{ => snippet}/ui/SnippetDetailsView.tsx | 0
.../{hooks => snippet}/useSnippetDetails.ts | 4 +-
src/pages/item/useItemDetails.ts | 21 ----
12 files changed, 130 insertions(+), 41 deletions(-)
delete mode 100644 src/pages/item/ItemDetailsPage.tsx
create mode 100644 src/pages/item/question/index.ts
rename src/pages/item/{ => question}/ui/AnswerItemView.tsx (100%)
rename src/pages/item/{ => question}/ui/EditableAnswerItem.tsx (100%)
rename src/pages/item/{ => question}/ui/QuestionDetailsView.tsx (87%)
create mode 100644 src/pages/item/question/useQuestionDetails.ts
rename src/pages/item/{ => snippet}/ui/SnippetDetailsView.tsx (100%)
rename src/pages/item/{hooks => snippet}/useSnippetDetails.ts (97%)
delete mode 100644 src/pages/item/useItemDetails.ts
diff --git a/src/pages/item/ItemDetailsPage.tsx b/src/pages/item/ItemDetailsPage.tsx
deleted file mode 100644
index f3c6c23..0000000
--- a/src/pages/item/ItemDetailsPage.tsx
+++ /dev/null
@@ -1,11 +0,0 @@
-import { useItemDetails } from "./useItemDetails";
-import { ItemDetailsView } from "./ItemDetailsView";
-
-export default function ItemDetailsPage({
- mode,
-}: {
- mode: "question" | "snippet";
-}) {
- const state = useItemDetails(mode);
- return ;
-}
diff --git a/src/pages/item/ItemDetailsView.tsx b/src/pages/item/ItemDetailsView.tsx
index a0f9b72..7f4e2a2 100644
--- a/src/pages/item/ItemDetailsView.tsx
+++ b/src/pages/item/ItemDetailsView.tsx
@@ -1,7 +1,7 @@
import { BackLink } from "@/shared/ui/BackLink";
import CodeEditor from "@/shared/ui/CodeEditor";
-import QuestionDetailsView from "./ui/QuestionDetailsView";
-import SnippetDetailsView from "./ui/SnippetDetailsView";
+import QuestionDetailsView from "./question/ui/QuestionDetailsView";
+import SnippetDetailsView from "./snippet/ui/SnippetDetailsView";
import GenericTextForm from "./ui/GenericTextForm";
import CommentsListView from "./ui/CommentsListView";
import { ActionButtons } from "./ui/ActionButtons";
@@ -9,7 +9,7 @@ import { EditPanel } from "./ui/EditPanel";
import { LoadingSkeleton } from "./ui/LoadingSkeleton";
import type { ItemState, QuestionState, SnippetState } from "./hooks/itemTypes";
import type { Answer } from "@/entities/question/types";
-import { EditableAnswerItem } from "./ui/EditableAnswerItem";
+import { EditableAnswerItem } from "./question/ui/EditableAnswerItem";
import { useAuth } from "@/app/providers/useAuth";
export function ItemDetailsView(props: ItemState) {
diff --git a/src/pages/item/QuestionDetailsPage.tsx b/src/pages/item/QuestionDetailsPage.tsx
index 60edb1c..1757183 100644
--- a/src/pages/item/QuestionDetailsPage.tsx
+++ b/src/pages/item/QuestionDetailsPage.tsx
@@ -1,6 +1,6 @@
import { useParams } from "react-router-dom";
import { useAuth } from "@/app/providers/useAuth";
-import { useQuestionDetails } from "./hooks/useQuestionDetails";
+import { useQuestionDetails } from "./question";
import { ItemDetailsView } from "./ItemDetailsView";
export function QuestionDetailsPage() {
diff --git a/src/pages/item/SnippetDetailsPage.tsx b/src/pages/item/SnippetDetailsPage.tsx
index e7476e0..fa5b8ae 100644
--- a/src/pages/item/SnippetDetailsPage.tsx
+++ b/src/pages/item/SnippetDetailsPage.tsx
@@ -1,6 +1,6 @@
import { useParams } from "react-router-dom";
import { useAuth } from "@/app/providers/useAuth";
-import { useSnippetDetails } from "./hooks/useSnippetDetails";
+import { useSnippetDetails } from "./snippet/useSnippetDetails";
import { ItemDetailsView } from "./ItemDetailsView";
export function SnippetDetailsPage() {
diff --git a/src/pages/item/question/index.ts b/src/pages/item/question/index.ts
new file mode 100644
index 0000000..7038310
--- /dev/null
+++ b/src/pages/item/question/index.ts
@@ -0,0 +1 @@
+export * from "./useQuestionDetails";
diff --git a/src/pages/item/ui/AnswerItemView.tsx b/src/pages/item/question/ui/AnswerItemView.tsx
similarity index 100%
rename from src/pages/item/ui/AnswerItemView.tsx
rename to src/pages/item/question/ui/AnswerItemView.tsx
diff --git a/src/pages/item/ui/EditableAnswerItem.tsx b/src/pages/item/question/ui/EditableAnswerItem.tsx
similarity index 100%
rename from src/pages/item/ui/EditableAnswerItem.tsx
rename to src/pages/item/question/ui/EditableAnswerItem.tsx
diff --git a/src/pages/item/ui/QuestionDetailsView.tsx b/src/pages/item/question/ui/QuestionDetailsView.tsx
similarity index 87%
rename from src/pages/item/ui/QuestionDetailsView.tsx
rename to src/pages/item/question/ui/QuestionDetailsView.tsx
index 99f6b5c..5eb773b 100644
--- a/src/pages/item/ui/QuestionDetailsView.tsx
+++ b/src/pages/item/question/ui/QuestionDetailsView.tsx
@@ -8,7 +8,7 @@ export type QuestionDetailsViewProps = {
actions?: React.ReactNode;
};
-function QuestionDetailsView({
+export const QuestionDetailsView = memo(function QuestionDetailsView({
title,
description,
attachedCode,
@@ -26,6 +26,6 @@ function QuestionDetailsView({
{attachedCode && }
);
-}
+});
-export default memo(QuestionDetailsView);
+export default QuestionDetailsView;
diff --git a/src/pages/item/question/useQuestionDetails.ts b/src/pages/item/question/useQuestionDetails.ts
new file mode 100644
index 0000000..f4f4b6a
--- /dev/null
+++ b/src/pages/item/question/useQuestionDetails.ts
@@ -0,0 +1,118 @@
+import { useState, useMemo } from "react";
+import { useNavigate } from "react-router-dom";
+import {
+ useQuestion,
+ useUpdateQuestion,
+ useDeleteQuestion,
+ useSetAnswerState,
+ useUpdateAnswer,
+ useDeleteAnswer,
+} from "@/entities/question/api";
+import { useAnswerForm, useQuestionOwnership } from "@/entities/question/hooks";
+import { useQuestionAnswers } from "@/shared/socket";
+import type { QuestionState } from "../hooks/itemTypes";
+
+export function useQuestionDetails(id?: string): QuestionState {
+ const navigate = useNavigate();
+ const { data: questionData, status } = useQuestion(id);
+ useQuestionAnswers(id);
+ const answerForm = useAnswerForm(id || "0");
+ const isOwner = useQuestionOwnership(questionData);
+ const setAnswerStateMut = useSetAnswerState(id || "0");
+ const updateAnswerMut = useUpdateAnswer(id || "0");
+ const deleteAnswerMut = useDeleteAnswer(id || "0");
+ const updateQuestionMut = useUpdateQuestion(id || "0");
+ const deleteQuestionMut = useDeleteQuestion(id || "0");
+
+ const [isEditing, setIsEditing] = useState(false);
+ const [editTitle, setEditTitle] = useState("");
+ const [editDescription, setEditDescription] = useState("");
+ const [editQCode, setEditQCode] = useState("");
+
+ const startEdit = () => {
+ if (questionData) {
+ setEditTitle(questionData.title);
+ setEditDescription(questionData.description);
+ setEditQCode(questionData.attachedCode || "");
+ }
+ setIsEditing(true);
+ };
+
+ const cancelEdit = () => setIsEditing(false);
+
+ const loading = status === "pending";
+ const notFound = status === "success" && !questionData;
+
+ const mappedQuestion = useMemo(
+ () =>
+ questionData
+ ? {
+ title: questionData.title,
+ description: questionData.description,
+ attachedCode: questionData.attachedCode,
+ answers: questionData.answers,
+ }
+ : undefined,
+ [questionData]
+ );
+
+ return {
+ mode: "question",
+ loading,
+ notFound,
+ error: undefined,
+ isOwner,
+ isEditing,
+ startEdit,
+ cancelEdit,
+ saveEdit: async () => {
+ try {
+ await updateQuestionMut.mutateAsync({
+ title: editTitle,
+ description: editDescription,
+ attachedCode: editQCode,
+ });
+ setIsEditing(false);
+ } catch {
+ alert("Не удалось сохранить изменения");
+ }
+ },
+ saving: updateQuestionMut.isPending,
+ deleteItem: async () => {
+ if (!confirm("Удалить вопрос?")) return;
+ try {
+ await deleteQuestionMut.mutateAsync();
+ navigate("/my?mode=questions");
+ } catch {
+ alert("Не удалось удалить вопрос");
+ }
+ },
+ deleting: deleteQuestionMut.isPending,
+ question: mappedQuestion,
+ edit: {
+ title: editTitle,
+ description: editDescription,
+ code: editQCode,
+ setTitle: setEditTitle,
+ setDescription: setEditDescription,
+ setCode: setEditQCode,
+ },
+ answerForm,
+ markPending: setAnswerStateMut.isPending,
+ markCorrect: (answerId: string | number) => {
+ if (!isOwner) return;
+ setAnswerStateMut.mutate({ answerId, state: "correct" });
+ },
+ markIncorrect: (answerId: string | number) => {
+ if (!isOwner) return;
+ setAnswerStateMut.mutate({ answerId, state: "incorrect" });
+ },
+ updateAnswer: (answerId: string | number, content: string) =>
+ updateAnswerMut.mutate({ answerId, content }),
+ deleteAnswer: (answerId: string | number) =>
+ deleteAnswerMut.mutate(answerId),
+ } as const;
+}
+
+// default экспорт не обязателен, но оставим для совместимости
+export default useQuestionDetails;
diff --git a/src/pages/item/ui/SnippetDetailsView.tsx b/src/pages/item/snippet/ui/SnippetDetailsView.tsx
similarity index 100%
rename from src/pages/item/ui/SnippetDetailsView.tsx
rename to src/pages/item/snippet/ui/SnippetDetailsView.tsx
diff --git a/src/pages/item/hooks/useSnippetDetails.ts b/src/pages/item/snippet/useSnippetDetails.ts
similarity index 97%
rename from src/pages/item/hooks/useSnippetDetails.ts
rename to src/pages/item/snippet/useSnippetDetails.ts
index d08f7d5..59095c9 100644
--- a/src/pages/item/hooks/useSnippetDetails.ts
+++ b/src/pages/item/snippet/useSnippetDetails.ts
@@ -9,7 +9,7 @@ import {
} from "@/entities/snippet/api";
import { useCommentForm, useSnippetOwnership } from "@/entities/snippet/hooks";
import { useSnippetComments } from "@/shared/socket";
-import type { SnippetState } from "./itemTypes";
+import type { SnippetState } from "../hooks/itemTypes";
export function useSnippetDetails(rawId?: string): SnippetState {
const navigate = useNavigate();
@@ -105,3 +105,5 @@ export function useSnippetDetails(rawId?: string): SnippetState {
deleteComment: (id: number | string) => deleteCommentMut.mutate(Number(id)),
} as const;
}
+
+export default useSnippetDetails;
diff --git a/src/pages/item/useItemDetails.ts b/src/pages/item/useItemDetails.ts
deleted file mode 100644
index bb452b2..0000000
--- a/src/pages/item/useItemDetails.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { useParams } from "react-router-dom";
-import { useAuth } from "@/app/providers/useAuth";
-import { useQuestionDetails } from "./hooks/useQuestionDetails";
-import { useSnippetDetails } from "./hooks/useSnippetDetails";
-export {
- type Mode,
- type BaseState,
- type QuestionState,
- type SnippetState,
- type ItemState,
-} from "./hooks/itemTypes";
-import type { ItemState, Mode } from "./hooks/itemTypes";
-
-export function useItemDetails(mode: Mode): ItemState {
- const { id } = useParams<{ id: string }>();
- useAuth();
- // Вызвать оба хука (один будет лишним) чтобы соблюсти порядок хуков.
- const questionState = useQuestionDetails(id);
- const snippetState = useSnippetDetails(id);
- return (mode === "question" ? questionState : snippetState) as ItemState;
-}
From f3b9f21bc517a0d7170f4b4b4edf1bfde9ceccca Mon Sep 17 00:00:00 2001
From: kotru21
Date: Mon, 25 Aug 2025 01:22:11 +0300
Subject: [PATCH 34/40] Add real-time update/delete for answers and comments
Implemented socket event handling and emission for updating and deleting answers and comments in both client and server code. This enables real-time synchronization of answer and comment changes across clients. Also improved development logging and CORS configuration for local HTTPS.
---
src/pages/item/question/useQuestionDetails.ts | 22 +-
src/pages/item/snippet/useSnippetDetails.ts | 24 +-
src/shared/socket.ts | 234 ++++++++++++++++++
websocket-server/package.json | 3 +-
websocket-server/src/index.ts | 3 +-
.../src/modules/comments/commentsGateway.ts | 168 +++++++++++++
websocket-server/src/modules/core/server.ts | 6 +
7 files changed, 451 insertions(+), 9 deletions(-)
diff --git a/src/pages/item/question/useQuestionDetails.ts b/src/pages/item/question/useQuestionDetails.ts
index f4f4b6a..1c1c023 100644
--- a/src/pages/item/question/useQuestionDetails.ts
+++ b/src/pages/item/question/useQuestionDetails.ts
@@ -9,7 +9,11 @@ import {
useDeleteAnswer,
} from "@/entities/question/api";
import { useAnswerForm, useQuestionOwnership } from "@/entities/question/hooks";
-import { useQuestionAnswers } from "@/shared/socket";
+import {
+ useQuestionAnswers,
+ emitAnswerUpdate,
+ emitAnswerDelete,
+} from "@/shared/socket";
import type { QuestionState } from "../hooks/itemTypes";
export function useQuestionDetails(id?: string): QuestionState {
@@ -108,9 +112,21 @@ export function useQuestionDetails(id?: string): QuestionState {
setAnswerStateMut.mutate({ answerId, state: "incorrect" });
},
updateAnswer: (answerId: string | number, content: string) =>
- updateAnswerMut.mutate({ answerId, content }),
+ updateAnswerMut.mutate(
+ { answerId, content },
+ {
+ onSuccess: () =>
+ emitAnswerUpdate({
+ questionId: id || "0",
+ answerId,
+ content,
+ }),
+ }
+ ),
deleteAnswer: (answerId: string | number) =>
- deleteAnswerMut.mutate(answerId),
+ deleteAnswerMut.mutate(answerId, {
+ onSuccess: () => emitAnswerDelete({ questionId: id || "0", answerId }),
+ }),
} as const;
}
diff --git a/src/pages/item/snippet/useSnippetDetails.ts b/src/pages/item/snippet/useSnippetDetails.ts
index 59095c9..60a3efd 100644
--- a/src/pages/item/snippet/useSnippetDetails.ts
+++ b/src/pages/item/snippet/useSnippetDetails.ts
@@ -8,7 +8,11 @@ import {
useDeleteComment,
} from "@/entities/snippet/api";
import { useCommentForm, useSnippetOwnership } from "@/entities/snippet/hooks";
-import { useSnippetComments } from "@/shared/socket";
+import {
+ useSnippetComments,
+ emitSnippetCommentUpdate,
+ emitSnippetCommentDelete,
+} from "@/shared/socket";
import type { SnippetState } from "../hooks/itemTypes";
export function useSnippetDetails(rawId?: string): SnippetState {
@@ -101,8 +105,22 @@ export function useSnippetDetails(rawId?: string): SnippetState {
},
commentForm,
updateComment: (id: number | string, content: string) =>
- updateCommentMut.mutate({ id: Number(id), content }),
- deleteComment: (id: number | string) => deleteCommentMut.mutate(Number(id)),
+ updateCommentMut.mutate(
+ { id: Number(id), content },
+ {
+ onSuccess: () =>
+ emitSnippetCommentUpdate({
+ snippetId: numericId || 0,
+ id,
+ content,
+ }),
+ }
+ ),
+ deleteComment: (id: number | string) =>
+ deleteCommentMut.mutate(Number(id), {
+ onSuccess: () =>
+ emitSnippetCommentDelete({ snippetId: numericId || 0, id }),
+ }),
} as const;
}
diff --git a/src/shared/socket.ts b/src/shared/socket.ts
index 8b8c9e4..ebcfc1a 100644
--- a/src/shared/socket.ts
+++ b/src/shared/socket.ts
@@ -94,12 +94,95 @@ export function useSnippetComments(snippetId: number | undefined) {
}
};
+ const onUpdated = (payload: {
+ snippetId?: number;
+ id?: number | string;
+ content?: string;
+ }) => {
+ if (payload?.snippetId === snippetId && payload.id) {
+ if (isDev) {
+ console.debug(
+ "[socket] snippet comment:updated (client handler)",
+ payload
+ );
+ }
+ qc.setQueryData(["snippet", snippetId], (prev: unknown) => {
+ if (!prev || typeof prev !== "object") return prev;
+ const prevSnippet = prev as {
+ comments?: Array<{
+ id: number;
+ content: string;
+ user?: { username?: string };
+ }>;
+ commentsCount?: number;
+ [k: string]: unknown;
+ };
+ const comments = Array.isArray(prevSnippet.comments)
+ ? [...prevSnippet.comments]
+ : [];
+ let changed = false;
+ const next = comments.map((c) => {
+ if (Number(c.id) === Number(payload.id)) {
+ changed = true;
+ return { ...c, content: String(payload.content ?? c.content) };
+ }
+ return c;
+ });
+ if (!changed) return prev;
+ return { ...prevSnippet, comments: next };
+ });
+ }
+ };
+
+ const onDeleted = (payload: {
+ snippetId?: number;
+ id?: number | string;
+ }) => {
+ if (payload?.snippetId === snippetId && payload.id) {
+ if (isDev) {
+ console.debug(
+ "[socket] snippet comment:deleted (client handler)",
+ payload
+ );
+ }
+ qc.setQueryData(["snippet", snippetId], (prev: unknown) => {
+ if (!prev || typeof prev !== "object") return prev;
+ const prevSnippet = prev as {
+ comments?: Array<{
+ id: number;
+ content: string;
+ user?: { username?: string };
+ }>;
+ commentsCount?: number;
+ [k: string]: unknown;
+ };
+ const comments = Array.isArray(prevSnippet.comments)
+ ? prevSnippet.comments.filter(
+ (c) => Number(c.id) !== Number(payload.id)
+ )
+ : [];
+ return {
+ ...prevSnippet,
+ comments,
+ commentsCount: Math.max(
+ 0,
+ (prevSnippet.commentsCount as number) - 1
+ ),
+ };
+ });
+ }
+ };
+
socket.emit("join", channel);
socket.on("comment:created", onCreated);
+ socket.on("comment:updated", onUpdated);
+ socket.on("comment:deleted", onDeleted);
return () => {
socket.emit("leave", channel);
socket.off("comment:created", onCreated);
+ socket.off("comment:updated", onUpdated);
+ socket.off("comment:deleted", onDeleted);
};
}, [snippetId, qc]);
}
@@ -112,6 +195,12 @@ export function useQuestionAnswers(questionId: number | string | undefined) {
if (!questionId) return;
const socket = getSocket();
const channel = `question:${questionId}`;
+ if (isDev) {
+ console.debug("[socket] useQuestionAnswers subscribe", {
+ channel,
+ questionId,
+ });
+ }
const onAnswerCreated = (payload: {
questionId?: number | string;
@@ -187,14 +276,85 @@ export function useQuestionAnswers(questionId: number | string | undefined) {
}
};
+ const onAnswerUpdated = (payload: {
+ questionId?: number | string;
+ answerId?: number | string;
+ content?: string;
+ }) => {
+ if (
+ payload?.questionId &&
+ String(payload.questionId) === String(questionId)
+ ) {
+ if (isDev) {
+ console.debug("[socket] answer:updated (client handler)", payload);
+ }
+ qc.setQueryData(["question", questionId], (prev: unknown) => {
+ if (!prev || typeof prev !== "object") return prev;
+ const prevQuestion = prev as {
+ answers?: Array<{
+ id: string | number;
+ content: string;
+ isCorrect?: boolean;
+ user?: { username?: string };
+ }>;
+ [k: string]: unknown;
+ };
+ const answers = Array.isArray(prevQuestion.answers)
+ ? prevQuestion.answers.map((a) =>
+ String(a.id) === String(payload.answerId)
+ ? { ...a, content: String(payload.content ?? a.content) }
+ : a
+ )
+ : [];
+ return { ...prevQuestion, answers };
+ });
+ }
+ };
+
+ const onAnswerDeleted = (payload: {
+ questionId?: number | string;
+ answerId?: number | string;
+ }) => {
+ if (
+ payload?.questionId &&
+ String(payload.questionId) === String(questionId)
+ ) {
+ if (isDev) {
+ console.debug("[socket] answer:deleted (client handler)", payload);
+ }
+ qc.setQueryData(["question", questionId], (prev: unknown) => {
+ if (!prev || typeof prev !== "object") return prev;
+ const prevQuestion = prev as {
+ answers?: Array<{
+ id: string | number;
+ content: string;
+ isCorrect?: boolean;
+ user?: { username?: string };
+ }>;
+ [k: string]: unknown;
+ };
+ const answers = Array.isArray(prevQuestion.answers)
+ ? prevQuestion.answers.filter(
+ (a) => String(a.id) !== String(payload.answerId)
+ )
+ : [];
+ return { ...prevQuestion, answers };
+ });
+ }
+ };
+
socket.emit("join", channel);
socket.on("answer:created", onAnswerCreated);
socket.on("answer:state_changed", onAnswerStateChanged);
+ socket.on("answer:updated", onAnswerUpdated);
+ socket.on("answer:deleted", onAnswerDeleted);
return () => {
socket.emit("leave", channel);
socket.off("answer:created", onAnswerCreated);
socket.off("answer:state_changed", onAnswerStateChanged);
+ socket.off("answer:updated", onAnswerUpdated);
+ socket.off("answer:deleted", onAnswerDeleted);
};
}, [questionId, qc]);
}
@@ -208,6 +368,9 @@ export function emitSnippetComment(data: {
}) {
try {
const socket = getSocket();
+ if (isDev) {
+ console.debug("[socket] emit comment:create", data);
+ }
socket.emit("comment:create", {
content: data.content,
snippetId: data.snippetId,
@@ -219,6 +382,37 @@ export function emitSnippetComment(data: {
}
}
+export function emitSnippetCommentUpdate(data: {
+ snippetId: number;
+ id: number | string;
+ content: string;
+}) {
+ try {
+ const socket = getSocket();
+ if (isDev) {
+ console.debug("[socket] emit comment:update", data);
+ }
+ socket.emit("comment:update", data);
+ } catch {
+ // ignore
+ }
+}
+
+export function emitSnippetCommentDelete(data: {
+ snippetId: number;
+ id: number | string;
+}) {
+ try {
+ const socket = getSocket();
+ if (isDev) {
+ console.debug("[socket] emit comment:delete", data);
+ }
+ socket.emit("comment:delete", data);
+ } catch {
+ // ignore
+ }
+}
+
// Функция для отправки ответа на вопрос
export function emitQuestionAnswer(data: {
content: string;
@@ -229,6 +423,9 @@ export function emitQuestionAnswer(data: {
}) {
try {
const socket = getSocket();
+ if (isDev) {
+ console.debug("[socket] emit answer:create", data);
+ }
socket.emit("answer:create", {
content: data.content,
questionId: data.questionId,
@@ -248,7 +445,11 @@ export function emitAnswerStateChange(data: {
isCorrect: boolean;
}) {
try {
+ if (!data.questionId || data.questionId === "0") return;
const socket = getSocket();
+ if (isDev) {
+ console.debug("[socket] emit answer:state_change", data);
+ }
socket.emit("answer:state_change", {
questionId: data.questionId,
answerId: data.answerId,
@@ -258,3 +459,36 @@ export function emitAnswerStateChange(data: {
// ignore socket emit errors
}
}
+
+export function emitAnswerUpdate(data: {
+ questionId: number | string;
+ answerId: number | string;
+ content: string;
+}) {
+ try {
+ if (!data.questionId || data.questionId === "0") return;
+ const socket = getSocket();
+ if (isDev) {
+ console.debug("[socket] emit answer:update", data);
+ }
+ socket.emit("answer:update", data);
+ } catch {
+ // ignore
+ }
+}
+
+export function emitAnswerDelete(data: {
+ questionId: number | string;
+ answerId: number | string;
+}) {
+ try {
+ if (!data.questionId || data.questionId === "0") return;
+ const socket = getSocket();
+ if (isDev) {
+ console.debug("[socket] emit answer:delete", data);
+ }
+ socket.emit("answer:delete", data);
+ } catch {
+ // ignore
+ }
+}
diff --git a/websocket-server/package.json b/websocket-server/package.json
index bf07d9b..088497c 100644
--- a/websocket-server/package.json
+++ b/websocket-server/package.json
@@ -5,8 +5,7 @@
"main": "dist/index.js",
"scripts": {
"dev": "ts-node src/index.ts",
- "build": "tsc -p tsconfig.json",
- "start": "node dist/index.js"
+ "build": "tsc -p tsconfig.json"
},
"dependencies": {
"axios": "^1.6.0",
diff --git a/websocket-server/src/index.ts b/websocket-server/src/index.ts
index 77e31a6..a481755 100644
--- a/websocket-server/src/index.ts
+++ b/websocket-server/src/index.ts
@@ -4,7 +4,8 @@ import { registerCommentsGateway } from "./modules/comments/commentsGateway";
const PORT = Number(process.env.PORT) || 4000;
const wsServer = new WSServer({
port: PORT,
- corsOrigin: ["http://localhost:5173"],
+ // Разрешаем и http, и https (локальные самоподписанные сертификаты)
+ corsOrigin: ["http://localhost:5173", "https://localhost:5173"],
});
registerCommentsGateway(wsServer);
diff --git a/websocket-server/src/modules/comments/commentsGateway.ts b/websocket-server/src/modules/comments/commentsGateway.ts
index 6047636..ee261ba 100644
--- a/websocket-server/src/modules/comments/commentsGateway.ts
+++ b/websocket-server/src/modules/comments/commentsGateway.ts
@@ -14,6 +14,9 @@ export function registerCommentsGateway(server: WSServer) {
user?: { username?: string };
}) => {
try {
+ if (process.env.NODE_ENV !== "production") {
+ console.log("[ws] recv comment:create", data);
+ }
const room = data.snippetId
? `snippet:${data.snippetId}`
: data.questionId
@@ -27,8 +30,14 @@ export function registerCommentsGateway(server: WSServer) {
user: { username: data.user?.username || "unknown" },
};
if (room) {
+ if (process.env.NODE_ENV !== "production") {
+ console.log("[ws] broadcast comment:created ->", room, payload);
+ }
server.io.to(room).emit("comment:created", payload);
} else {
+ if (process.env.NODE_ENV !== "production") {
+ console.log("[ws] broadcast comment:created (global)", payload);
+ }
server.io.emit("comment:created", payload);
}
} catch (err) {
@@ -40,6 +49,92 @@ export function registerCommentsGateway(server: WSServer) {
}
);
+ // Обработка обновления комментария
+ socket.on(
+ "comment:update",
+ (data: {
+ snippetId?: number;
+ questionId?: number;
+ id?: number | string;
+ content?: string;
+ }) => {
+ try {
+ if (process.env.NODE_ENV !== "production") {
+ console.log("[ws] recv comment:update", data);
+ }
+ const room = data.snippetId
+ ? `snippet:${data.snippetId}`
+ : data.questionId
+ ? `question:${data.questionId}`
+ : undefined;
+ const payload = {
+ snippetId: data.snippetId,
+ questionId: data.questionId,
+ id: data.id,
+ content: data.content,
+ };
+ if (room) {
+ if (process.env.NODE_ENV !== "production") {
+ console.log("[ws] broadcast comment:updated ->", room, payload);
+ }
+ server.io.to(room).emit("comment:updated", payload);
+ } else {
+ if (process.env.NODE_ENV !== "production") {
+ console.log("[ws] broadcast comment:updated (global)", payload);
+ }
+ server.io.emit("comment:updated", payload);
+ }
+ } catch (err) {
+ if (process.env.NODE_ENV !== "production") {
+ console.error("comment:update relay failed", err);
+ }
+ socket.emit("comment:error", { message: "Update relay failed" });
+ }
+ }
+ );
+
+ // Обработка удаления комментария
+ socket.on(
+ "comment:delete",
+ (data: {
+ snippetId?: number;
+ questionId?: number;
+ id?: number | string;
+ }) => {
+ try {
+ if (process.env.NODE_ENV !== "production") {
+ console.log("[ws] recv comment:delete", data);
+ }
+ const room = data.snippetId
+ ? `snippet:${data.snippetId}`
+ : data.questionId
+ ? `question:${data.questionId}`
+ : undefined;
+ const payload = {
+ snippetId: data.snippetId,
+ questionId: data.questionId,
+ id: data.id,
+ };
+ if (room) {
+ if (process.env.NODE_ENV !== "production") {
+ console.log("[ws] broadcast comment:deleted ->", room, payload);
+ }
+ server.io.to(room).emit("comment:deleted", payload);
+ } else {
+ if (process.env.NODE_ENV !== "production") {
+ console.log("[ws] broadcast comment:deleted (global)", payload);
+ }
+ server.io.emit("comment:deleted", payload);
+ }
+ } catch (err) {
+ if (process.env.NODE_ENV !== "production") {
+ console.error("comment:delete relay failed", err);
+ }
+ socket.emit("comment:error", { message: "Delete relay failed" });
+ }
+ }
+ );
+
// Обработка создания ответов на вопросы
socket.on(
"answer:create",
@@ -51,6 +146,9 @@ export function registerCommentsGateway(server: WSServer) {
isCorrect?: boolean;
}) => {
try {
+ if (process.env.NODE_ENV !== "production") {
+ console.log("[ws] recv answer:create", data);
+ }
const room = `question:${data.questionId}`;
const payload = {
questionId: data.questionId,
@@ -59,6 +157,9 @@ export function registerCommentsGateway(server: WSServer) {
user: { username: data.user?.username || "unknown" },
isCorrect: data.isCorrect ?? false,
};
+ if (process.env.NODE_ENV !== "production") {
+ console.log("[ws] broadcast answer:created ->", room, payload);
+ }
server.io.to(room).emit("answer:created", payload);
} catch (err) {
if (process.env.NODE_ENV !== "production") {
@@ -78,12 +179,22 @@ export function registerCommentsGateway(server: WSServer) {
isCorrect?: boolean;
}) => {
try {
+ if (process.env.NODE_ENV !== "production") {
+ console.log("[ws] recv answer:state_change", data);
+ }
const room = `question:${data.questionId}`;
const payload = {
questionId: data.questionId,
answerId: data.answerId,
isCorrect: data.isCorrect,
};
+ if (process.env.NODE_ENV !== "production") {
+ console.log(
+ "[ws] broadcast answer:state_changed ->",
+ room,
+ payload
+ );
+ }
server.io.to(room).emit("answer:state_changed", payload);
} catch (err) {
if (process.env.NODE_ENV !== "production") {
@@ -93,5 +204,62 @@ export function registerCommentsGateway(server: WSServer) {
}
}
);
+
+ // Обработка обновления ответа
+ socket.on(
+ "answer:update",
+ (data: {
+ questionId?: number | string;
+ answerId?: number | string;
+ content?: string;
+ }) => {
+ try {
+ if (process.env.NODE_ENV !== "production") {
+ console.log("[ws] recv answer:update", data);
+ }
+ const room = `question:${data.questionId}`;
+ const payload = {
+ questionId: data.questionId,
+ answerId: data.answerId,
+ content: data.content,
+ };
+ if (process.env.NODE_ENV !== "production") {
+ console.log("[ws] broadcast answer:updated ->", room, payload);
+ }
+ server.io.to(room).emit("answer:updated", payload);
+ } catch (err) {
+ if (process.env.NODE_ENV !== "production") {
+ console.error("answer:update relay failed", err);
+ }
+ socket.emit("answer:error", { message: "Update relay failed" });
+ }
+ }
+ );
+
+ // Обработка удаления ответа
+ socket.on(
+ "answer:delete",
+ (data: { questionId?: number | string; answerId?: number | string }) => {
+ try {
+ if (process.env.NODE_ENV !== "production") {
+ console.log("[ws] recv answer:delete", data);
+ }
+ const room = `question:${data.questionId}`;
+ const payload = {
+ questionId: data.questionId,
+ answerId: data.answerId,
+ };
+ if (process.env.NODE_ENV !== "production") {
+ console.log("[ws] broadcast answer:deleted ->", room, payload);
+ }
+ server.io.to(room).emit("answer:deleted", payload);
+ } catch (err) {
+ if (process.env.NODE_ENV !== "production") {
+ console.error("answer:delete relay failed", err);
+ }
+ socket.emit("answer:error", { message: "Delete relay failed" });
+ }
+ }
+ );
});
}
diff --git a/websocket-server/src/modules/core/server.ts b/websocket-server/src/modules/core/server.ts
index 2dc0126..38d022d 100644
--- a/websocket-server/src/modules/core/server.ts
+++ b/websocket-server/src/modules/core/server.ts
@@ -23,6 +23,12 @@ export class WSServer {
}
private onConnection(socket: Socket) {
+ if (process.env.NODE_ENV !== "production") {
+ const origin = socket.handshake.headers.origin;
+ console.log(
+ `[ws] connection established id=${socket.id} origin=${origin}`
+ );
+ }
socket.on("join", (room: string) => {
if (typeof room === "string") {
socket.join(room);
From 32d556d77d22cfa2ab5eaa06cd64b470d670cffc Mon Sep 17 00:00:00 2001
From: kotru21
Date: Wed, 27 Aug 2025 19:31:30 +0300
Subject: [PATCH 35/40] Add centralized notifications and error handling
Introduces a notifications context and hooks for global app notifications. Refactors error handling to use a new AppError model, replacing legacy error conversion. Updates mutation hooks to emit notifications on success/error, and improves form error mapping. Removes unused CreatePageNew and legacy question details hook, consolidating logic. Enhances dark mode styles for create page forms.
---
src/app/providers/auth.tsx | 7 +-
src/app/providers/notifications-context.tsx | 91 ++++++++++
src/app/providers/notifications-core.ts | 11 ++
src/app/providers/useNotifications.ts | 12 ++
src/entities/question/api.ts | 63 +++----
src/entities/snippet/api.ts | 39 ++--
src/entities/snippet/hooks/useCommentForm.ts | 5 +-
src/entities/user/api.ts | 18 +-
src/main.tsx | 5 +-
src/pages/auth/LoginPage.tsx | 11 +-
src/pages/auth/RegisterPage.tsx | 47 ++---
src/pages/auth/ui/LoginFormView.tsx | 2 -
src/pages/auth/ui/RegisterFormView.tsx | 2 -
src/pages/create/CreatePage.tsx | 20 +--
src/pages/create/CreatePageNew.tsx | 167 ------------------
src/pages/create/hooks/useCreateForms.ts | 16 +-
src/pages/item/hooks/useQuestionDetails.ts | 115 ------------
src/pages/item/question/useQuestionDetails.ts | 26 ++-
src/pages/item/snippet/useSnippetDetails.ts | 25 ++-
src/pages/item/utils/queryErrorState.ts | 14 ++
src/shared/api/app-error.ts | 141 +++++++++++++++
src/shared/api/http.ts | 59 +++----
src/shared/forms/applyAppError.ts | 38 ++++
src/shared/hooks/useApiMutation.ts | 51 ++++++
src/shared/hooks/useCommentForm.ts | 5 +-
src/shared/hooks/useCreateForms.ts | 6 +-
src/shared/notifications.ts | 40 +++++
27 files changed, 591 insertions(+), 445 deletions(-)
create mode 100644 src/app/providers/notifications-context.tsx
create mode 100644 src/app/providers/notifications-core.ts
create mode 100644 src/app/providers/useNotifications.ts
delete mode 100644 src/pages/create/CreatePageNew.tsx
create mode 100644 src/pages/item/utils/queryErrorState.ts
create mode 100644 src/shared/api/app-error.ts
create mode 100644 src/shared/forms/applyAppError.ts
create mode 100644 src/shared/hooks/useApiMutation.ts
create mode 100644 src/shared/notifications.ts
diff --git a/src/app/providers/auth.tsx b/src/app/providers/auth.tsx
index eaf94e3..d1fe99b 100644
--- a/src/app/providers/auth.tsx
+++ b/src/app/providers/auth.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
-import { http, toHttpError } from "../../shared/api/http";
+import { http } from "../../shared/api/http";
+import { toAppError } from "../../shared/api/app-error";
import { unwrapData } from "../../shared/api/normalize";
import { AuthContext, type User } from "./auth-context";
@@ -38,7 +39,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
await http.post("/auth/login", { username, password });
await refresh();
} catch (e) {
- const err = toHttpError(e);
+ const err = toAppError(e);
const error = new Error(err.message || "Login failed");
(error as Error & { status?: number }).status = err.status;
throw error;
@@ -58,7 +59,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
try {
await http.post("/register", { username, password });
} catch (e) {
- const err = toHttpError(e);
+ const err = toAppError(e);
const error = new Error(err.message || "Register failed");
(error as Error & { status?: number }).status = err.status;
throw error;
diff --git a/src/app/providers/notifications-context.tsx b/src/app/providers/notifications-context.tsx
new file mode 100644
index 0000000..f615805
--- /dev/null
+++ b/src/app/providers/notifications-context.tsx
@@ -0,0 +1,91 @@
+import React, { useEffect, useRef, useState, useCallback } from "react";
+import {
+ type NotificationPayload,
+ subscribeNotifications,
+ emitNotification,
+} from "@/shared/notifications";
+
+import {
+ NotificationsContext,
+ type NotificationsContextValue,
+} from "./notifications-core.ts";
+
+export function NotificationsProvider({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ const [items, setItems] = useState([]);
+ const timeouts = useRef>({});
+
+ const remove = (id: NotificationPayload["id"]) => {
+ if (id == null) return;
+ setItems((prev) => prev.filter((n) => n.id !== id));
+ const tid = timeouts.current[id];
+ if (tid) {
+ clearTimeout(tid);
+ delete timeouts.current[id];
+ }
+ };
+
+ const add = useCallback((p: NotificationPayload) => {
+ setItems((prev) => [...prev, p]);
+ if (p.ttlMs) {
+ const tid = window.setTimeout(() => remove(p.id), p.ttlMs);
+ timeouts.current[p.id!] = tid;
+ }
+ }, []);
+
+ useEffect(() => subscribeNotifications(add), [add]);
+
+ useEffect(
+ () => () => {
+ Object.values(timeouts.current).forEach(clearTimeout);
+ },
+ []
+ );
+
+ const notify: NotificationsContextValue["notify"] = (
+ p: Omit
+ ) => emitNotification(p);
+
+ return (
+
+ {children}
+
+
+ );
+}
+
+function NotificationsPortal() {
+ // ленивый импорт через require чтобы избежать цикла типов (не обяз.) – оставим прямой доступ
+ const ctx = React.useContext(NotificationsContext);
+ if (!ctx) return null;
+ const { items, remove } = ctx;
+ return (
+
+ {items.map((n: NotificationPayload) => (
+
+
{n.message}
+
remove(n.id)}
+ className="ml-2 opacity-70 hover:opacity-100 transition text-xs"
+ aria-label="Close notification">
+ ×
+
+
+ ))}
+
+ );
+}
diff --git a/src/app/providers/notifications-core.ts b/src/app/providers/notifications-core.ts
new file mode 100644
index 0000000..3b1955d
--- /dev/null
+++ b/src/app/providers/notifications-core.ts
@@ -0,0 +1,11 @@
+import { createContext } from "react";
+import type { NotificationPayload } from "@/shared/notifications";
+
+export interface NotificationsContextValue {
+ items: NotificationPayload[];
+ notify: (p: Omit) => void;
+ remove: (id: NotificationPayload["id"]) => void;
+}
+
+export const NotificationsContext =
+ createContext(null);
diff --git a/src/app/providers/useNotifications.ts b/src/app/providers/useNotifications.ts
new file mode 100644
index 0000000..1a0b67d
--- /dev/null
+++ b/src/app/providers/useNotifications.ts
@@ -0,0 +1,12 @@
+import { useContext } from "react";
+import { NotificationsContext } from "./notifications-core";
+
+export function useNotifications() {
+ const ctx = useContext(NotificationsContext);
+ if (!ctx) throw new Error("NotificationsProvider is missing");
+ return ctx;
+}
+
+export function useNotify() {
+ return useNotifications().notify;
+}
diff --git a/src/entities/question/api.ts b/src/entities/question/api.ts
index 9af974a..65e79c8 100644
--- a/src/entities/question/api.ts
+++ b/src/entities/question/api.ts
@@ -1,13 +1,14 @@
import {
useInfiniteQuery,
- useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { http } from "../../shared/api/http";
+import { emitNotification } from "@/shared/notifications";
import type { Question, CreateQuestionDto } from "./types";
import type { Paginated } from "../../shared/types/pagination";
import { normalizePaginated, unwrapData } from "../../shared/api/normalize";
+import { useApiMutation } from "@/shared/hooks/useApiMutation";
export function useQuestions(params: {
page?: number;
@@ -59,7 +60,7 @@ export function useQuestion(id?: string | number) {
export function useCreateAnswer(questionId: string | number) {
const qc = useQueryClient();
type CreateAnswerResult = unknown;
- return useMutation({
+ return useApiMutation({
mutationFn: async (content: string) => {
const res = await http.post("/answers", {
content,
@@ -70,18 +71,17 @@ export function useCreateAnswer(questionId: string | number) {
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["question", questionId] });
},
+ notifySuccessMessage: "Ответ добавлен",
});
}
export function useSetAnswerState(questionId: string | number) {
const qc = useQueryClient();
- return useMutation({
- mutationFn: async (params: {
- answerId: string | number;
- state: "correct" | "incorrect";
- }) => {
- const { answerId, state } = params;
- // Убедимся, что путь содержит числовой id (бэкенд ожидает number)
+ return useApiMutation<
+ unknown,
+ { answerId: string | number; state: "correct" | "incorrect" }
+ >({
+ mutationFn: async ({ answerId, state }) => {
const idNum = Number(answerId);
const res = await http.put(`/answers/${idNum}/state/${state}`);
return res.data as unknown;
@@ -90,66 +90,67 @@ export function useSetAnswerState(questionId: string | number) {
qc.invalidateQueries({ queryKey: ["question", questionId] });
qc.invalidateQueries({ queryKey: ["questions"] });
},
+ notifySuccessMessage: () => undefined,
+ suppressErrorToast: true,
onError: () => {
- // Перестраховка: мягко обновим вопрос, чтобы снять возможные рассинхроны
qc.invalidateQueries({ queryKey: ["question", questionId] });
- // Простое уведомление пользователю (минимально инвазивно)
- alert("Не удалось обновить статус ответа. Попробуйте ещё раз.");
+ emitNotification({
+ type: "error",
+ message: "Не удалось обновить статус ответа. Попробуйте ещё раз.",
+ });
},
});
}
export function useUpdateAnswer(questionId: string | number) {
const qc = useQueryClient();
- return useMutation({
- mutationFn: async (params: {
- answerId: string | number;
- content: string;
- }) => {
- const { answerId, content } = params;
+ return useApiMutation<
+ unknown,
+ { answerId: string | number; content: string }
+ >({
+ mutationFn: async ({ answerId, content }) => {
const res = await http.patch(`/answers/${Number(answerId)}`, { content });
return res.data as unknown;
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["question", questionId] });
},
+ notifySuccessMessage: "Ответ обновлён",
});
}
export function useDeleteAnswer(questionId: string | number) {
const qc = useQueryClient();
- return useMutation({
- mutationFn: async (answerId: string | number) => {
+ return useApiMutation({
+ mutationFn: async (answerId) => {
const res = await http.delete(`/answers/${Number(answerId)}`);
return res.data as unknown;
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["question", questionId] });
},
+ notifySuccessMessage: "Ответ удалён",
});
}
export function useCreateQuestion() {
const qc = useQueryClient();
- return useMutation({
- mutationFn: async (dto: CreateQuestionDto) => {
+ return useApiMutation({
+ mutationFn: async (dto) => {
const res = await http.post("/questions", dto);
return unwrapData(res.data as unknown);
},
- onSuccess: (created) => {
+ onSuccess: () => {
qc.invalidateQueries({ queryKey: ["questions"] });
- if (created?.id) {
- // no-op here; navigation will be performed by page-level logic
- }
},
+ notifySuccessMessage: "Вопрос создан",
});
}
export function useUpdateQuestion(id: string | number) {
const qc = useQueryClient();
- return useMutation({
- mutationFn: async (dto: CreateQuestionDto) => {
- // API: PATCH /questions/{id}
+ return useApiMutation({
+ mutationFn: async (dto) => {
const res = await http.patch(`/questions/${id}`, dto);
return unwrapData(res.data as unknown);
},
@@ -157,12 +158,13 @@ export function useUpdateQuestion(id: string | number) {
qc.invalidateQueries({ queryKey: ["question", id] });
qc.invalidateQueries({ queryKey: ["questions"] });
},
+ notifySuccessMessage: "Вопрос обновлён",
});
}
export function useDeleteQuestion(id: string | number) {
const qc = useQueryClient();
- return useMutation({
+ return useApiMutation({
mutationFn: async () => {
const res = await http.delete(`/questions/${id}`);
return res.data as unknown;
@@ -170,5 +172,6 @@ export function useDeleteQuestion(id: string | number) {
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["questions"] });
},
+ notifySuccessMessage: "Вопрос удалён",
});
}
diff --git a/src/entities/snippet/api.ts b/src/entities/snippet/api.ts
index 9e28770..dab06c9 100644
--- a/src/entities/snippet/api.ts
+++ b/src/entities/snippet/api.ts
@@ -1,6 +1,5 @@
import {
useInfiniteQuery,
- useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
@@ -8,6 +7,7 @@ import { http } from "../../shared/api/http";
import type { Snippet, SnippetMark, Comment as SnippetComment } from "./types";
import type { Paginated } from "../../shared/types/pagination";
import { normalizePaginated, unwrapData } from "../../shared/api/normalize";
+import { useApiMutation } from "@/shared/hooks/useApiMutation";
export function useSnippets(params: {
page?: number;
@@ -114,8 +114,8 @@ export function useSnippet(id?: number) {
export function useMarkSnippet(id: number) {
const qc = useQueryClient();
- return useMutation({
- mutationFn: async (mark: SnippetMark) => {
+ return useApiMutation({
+ mutationFn: async (mark) => {
await http.post(`/snippets/${id}/mark`, { mark });
return mark;
},
@@ -123,28 +123,32 @@ export function useMarkSnippet(id: number) {
qc.invalidateQueries({ queryKey: ["snippets"] });
qc.invalidateQueries({ queryKey: ["snippet", id] });
},
+ notifySuccessMessage: () => undefined,
+ suppressErrorToast: true,
});
}
export function useCreateSnippet() {
const qc = useQueryClient();
- return useMutation({
- mutationFn: async (dto: { code: string; language: string }) => {
+ return useApiMutation<
+ Snippet & { id: number },
+ { code: string; language: string }
+ >({
+ mutationFn: async (dto) => {
const res = await http.post("/snippets", dto);
- const created = unwrapData(res.data as unknown);
- return created;
+ return unwrapData(res.data as unknown);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["snippets"] });
},
+ notifySuccessMessage: "Сниппет создан",
});
}
export function useUpdateSnippet(id: number) {
const qc = useQueryClient();
- return useMutation({
- mutationFn: async (dto: { code?: string; language?: string }) => {
- // API PATCH /snippets/{id}
+ return useApiMutation({
+ mutationFn: async (dto) => {
const res = await http.patch(`/snippets/${id}`, dto);
return res.data as unknown;
},
@@ -152,12 +156,13 @@ export function useUpdateSnippet(id: number) {
qc.invalidateQueries({ queryKey: ["snippet", id] });
qc.invalidateQueries({ queryKey: ["snippets"] });
},
+ notifySuccessMessage: "Сниппет обновлён",
});
}
export function useDeleteSnippet(id: number) {
const qc = useQueryClient();
- return useMutation({
+ return useApiMutation({
mutationFn: async () => {
const res = await http.delete(`/snippets/${id}`);
return res.data as unknown;
@@ -165,32 +170,34 @@ export function useDeleteSnippet(id: number) {
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["snippets"] });
},
+ notifySuccessMessage: "Сниппет удалён",
});
}
export function useUpdateComment(snippetId: number) {
const qc = useQueryClient();
- return useMutation({
- mutationFn: async (params: { id: number; content: string }) => {
- const { id, content } = params;
+ return useApiMutation({
+ mutationFn: async ({ id, content }) => {
const res = await http.patch(`/comments/${id}`, { content });
return res.data as unknown;
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["snippet", snippetId] });
},
+ notifySuccessMessage: "Комментарий обновлён",
});
}
export function useDeleteComment(snippetId: number) {
const qc = useQueryClient();
- return useMutation({
- mutationFn: async (id: number) => {
+ return useApiMutation({
+ mutationFn: async (id) => {
const res = await http.delete(`/comments/${id}`);
return res.data as unknown;
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["snippet", snippetId] });
},
+ notifySuccessMessage: "Комментарий удалён",
});
}
diff --git a/src/entities/snippet/hooks/useCommentForm.ts b/src/entities/snippet/hooks/useCommentForm.ts
index ec18cb1..bc7a101 100644
--- a/src/entities/snippet/hooks/useCommentForm.ts
+++ b/src/entities/snippet/hooks/useCommentForm.ts
@@ -1,5 +1,6 @@
import { useState } from "react";
-import { http, toHttpError } from "../../../shared/api/http";
+import { http } from "../../../shared/api/http";
+import { toAppError } from "../../../shared/api/app-error";
import { useQueryClient } from "@tanstack/react-query";
import { emitSnippetComment } from "../../../shared/socket";
import { useAuth } from "../../../app/providers/useAuth";
@@ -52,7 +53,7 @@ export function useCommentForm(snippetId: number) {
// При socket.io обновление прилетит и так; оставим инвалидацию на случай деградации
qc.invalidateQueries({ queryKey: ["snippet", snippetId] });
} catch (e) {
- const err = toHttpError(e);
+ const err = toAppError(e);
setError(err.message || "Не удалось отправить комментарий");
} finally {
setPending(false);
diff --git a/src/entities/user/api.ts b/src/entities/user/api.ts
index f82acb0..6f09581 100644
--- a/src/entities/user/api.ts
+++ b/src/entities/user/api.ts
@@ -1,11 +1,11 @@
import {
- useMutation,
useQuery,
useInfiniteQuery,
useQueryClient,
} from "@tanstack/react-query";
import { http } from "../../shared/api/http";
import { normalizePaginated, unwrapData } from "../../shared/api/normalize";
+import { useApiMutation } from "@/shared/hooks/useApiMutation";
import type { Paginated } from "../../shared/types/pagination";
import type { User, UserStatistic } from "./types";
@@ -109,32 +109,31 @@ export function useMe() {
export function useUpdateMe() {
const qc = useQueryClient();
- return useMutation({
- mutationFn: async (payload: { username: string }) => {
+ return useApiMutation({
+ mutationFn: async (payload) => {
const res = await http.patch(`/me`, payload);
return res.data;
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["me"] });
},
+ notifySuccessMessage: "Профиль обновлён",
});
}
export function useUpdatePassword() {
- return useMutation({
- mutationFn: async (payload: {
- oldPassword: string;
- newPassword: string;
- }) => {
+ return useApiMutation({
+ mutationFn: async (payload) => {
const res = await http.patch(`/me/password`, payload);
return res.data;
},
+ notifySuccessMessage: "Пароль обновлён",
});
}
export function useDeleteMe() {
const qc = useQueryClient();
- return useMutation({
+ return useApiMutation({
mutationFn: async () => {
const res = await http.delete(`/me`);
return res.data;
@@ -143,5 +142,6 @@ export function useDeleteMe() {
qc.invalidateQueries({ queryKey: ["me"] });
qc.invalidateQueries({ queryKey: ["users"] });
},
+ notifySuccessMessage: "Аккаунт удалён",
});
}
diff --git a/src/main.tsx b/src/main.tsx
index ed45135..0ab4453 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -15,6 +15,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App.tsx";
import { AuthProvider } from "@/app/providers/auth";
import { ThemeProvider } from "@/app/providers/theme";
+import { NotificationsProvider } from "@/app/providers/notifications-context";
const router = createBrowserRouter([
{
@@ -50,7 +51,9 @@ createRoot(document.getElementById("root")!).render(
-
+
+
+
diff --git a/src/pages/auth/LoginPage.tsx b/src/pages/auth/LoginPage.tsx
index 0bbc9e1..d666368 100644
--- a/src/pages/auth/LoginPage.tsx
+++ b/src/pages/auth/LoginPage.tsx
@@ -2,6 +2,8 @@ import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useAuth } from "@/app/providers/useAuth";
+import { toAppError } from "@/shared/api/app-error";
+import { emitNotification } from "@/shared/notifications";
import { useNavigate, useLocation } from "react-router-dom";
import LoginFormView from "./ui/LoginFormView";
@@ -25,7 +27,6 @@ export default function LoginPage() {
register,
handleSubmit,
formState: { errors, isSubmitting },
- setError,
} = useForm({
resolver: zodResolver(schema),
});
@@ -35,8 +36,11 @@ export default function LoginPage() {
await login(data.username, data.password);
navigate(from, { replace: true });
} catch (e) {
- const message = e instanceof Error ? e.message : "Ошибка входа";
- setError("root", { message });
+ const appErr = toAppError(e);
+ emitNotification({
+ type: "error",
+ message: appErr.message || "Ошибка входа",
+ });
}
};
@@ -48,7 +52,6 @@ export default function LoginPage() {
errors={{
username: errors.username?.message,
password: errors.password?.message,
- root: errors.root?.message,
}}
isSubmitting={isSubmitting}
/>
diff --git a/src/pages/auth/RegisterPage.tsx b/src/pages/auth/RegisterPage.tsx
index 75824be..7357ac8 100644
--- a/src/pages/auth/RegisterPage.tsx
+++ b/src/pages/auth/RegisterPage.tsx
@@ -2,6 +2,9 @@ import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useAuth } from "@/app/providers/useAuth";
+import { toAppError } from "@/shared/api/app-error";
+import { applyAppErrorToForm } from "@/shared/forms/applyAppError";
+import { emitNotification } from "@/shared/notifications";
import { useNavigate } from "react-router-dom";
import RegisterFormView from "./ui/RegisterFormView";
@@ -28,14 +31,13 @@ type FormData = z.infer;
export default function RegisterPage() {
const { register: doRegister } = useAuth();
const navigate = useNavigate();
+ const form = useForm({ resolver: zodResolver(schema) });
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
setError,
- } = useForm({
- resolver: zodResolver(schema),
- });
+ } = form;
const onSubmit = async (data: FormData) => {
const username = data.username.trim();
@@ -44,34 +46,20 @@ export default function RegisterPage() {
await doRegister(username, password);
navigate("/login", { replace: true });
} catch (e: unknown) {
- let message = e instanceof Error ? e.message : "Ошибка регистрации";
- // Provide friendlier messages for common validation cases
- const hasStatus =
- typeof e === "object" &&
- e !== null &&
- "status" in (e as Record);
- if (hasStatus) {
- const status = (e as Record).status as
- | number
- | undefined;
- if (status === 409)
- message = "Пользователь с таким именем уже существует";
- if (status === 422) message = message || "Некорректные данные";
- }
- // Translate server's password policy message if it comes in English
- if (
- /Password must contain at least one lowercase letter, one uppercase letter, one number and one symbol!?/i.test(
- message
- )
- ) {
- message =
- "Пароль должен содержать минимум 1 строчную, 1 заглавную букву, 1 цифру и 1 символ";
+ const appErr = toAppError(e);
+ // 409 — конфликт имени
+ if (appErr.status === 409) {
+ setError("username", {
+ message: "Пользователь с таким именем уже существует",
+ });
+ return;
}
- if (message && /^validation failed!?$/i.test(message)) {
- message =
- "Проверьте имя пользователя (не короче 5) и пароль (не короче 6)";
+ if (appErr.kind === "validation") {
+ applyAppErrorToForm(form, appErr);
+ return;
}
- setError("root", { message });
+ const msg = appErr.message || "Ошибка регистрации";
+ emitNotification({ type: "error", message: msg });
}
};
@@ -85,7 +73,6 @@ export default function RegisterPage() {
username: errors.username?.message,
password: errors.password?.message,
confirm: errors.confirm?.message,
- root: errors.root?.message,
}}
isSubmitting={isSubmitting}
/>
diff --git a/src/pages/auth/ui/LoginFormView.tsx b/src/pages/auth/ui/LoginFormView.tsx
index b46b498..c054039 100644
--- a/src/pages/auth/ui/LoginFormView.tsx
+++ b/src/pages/auth/ui/LoginFormView.tsx
@@ -10,7 +10,6 @@ export type LoginFormViewProps = {
errors?: {
username?: string;
password?: string;
- root?: string;
};
isSubmitting?: boolean;
};
@@ -47,7 +46,6 @@ function LoginFormView({
{errors.password}
)}
- {errors?.root && {errors.root}
}
{errors.confirm}
)}
- {errors?.root && {errors.root}
}
+
{/* Tabs */}
-
+
Заголовок *
{questionForm.errors.title && (
@@ -52,13 +52,13 @@ export default function CreatePage() {
-
+
Описание *
{questionForm.errors.description && (
@@ -69,7 +69,7 @@ export default function CreatePage() {
-
+
Прикрепленный код (опционально)
-
+
Язык программирования *
{snippetForm.errors.language && (
@@ -119,13 +119,13 @@ export default function CreatePage() {
{snippetForm.errors.language.message}
)}
-
+
Допустимые: JavaScript, Python, Java, C/C++, C#, Go, Kotlin, Ruby
-
+
Код *
("question");
-
- const questionForm = useQuestionForm();
- const snippetForm = useSnippetForm();
-
- return (
-
- {/* Tabs */}
-
- setMode("question")}
- variant={mode === "question" ? "outline" : "ghost"}
- size="sm"
- className="rounded-none border-b-2 !border-0">
- Вопрос
-
- setMode("snippet")}
- variant={mode === "snippet" ? "outline" : "ghost"}
- size="sm"
- className="rounded-none border-b-2 !border-0">
- Сниппет
-
-
-
- {/* Question Form */}
- {mode === "question" && (
-
-
-
- Заголовок *
-
-
- {questionForm.errors.title && (
-
- {questionForm.errors.title.message}
-
- )}
-
-
-
-
- Описание *
-
-
- {questionForm.errors.description && (
-
- {questionForm.errors.description.message}
-
- )}
-
-
-
-
- Прикрепленный код (опционально)
-
- questionForm.setValue("attachedCode", value)}
- placeholder="Введите код для демонстрации проблемы..."
- />
-
-
- {questionForm.error && (
- {questionForm.error}
- )}
-
-
-
- Создать вопрос
-
- navigate("/")}
- variant="secondary">
- Отмена
-
-
-
- )}
-
- {/* Snippet Form */}
- {mode === "snippet" && (
-
-
-
- Язык программирования *
-
-
- {snippetForm.errors.language && (
-
- {snippetForm.errors.language.message}
-
- )}
-
- Допустимые: JavaScript, Python, Java, C/C++, C#, Go, Kotlin, Ruby
-
-
-
-
-
- Код *
-
-
snippetForm.setValue("code", value)}
- language={snippetForm.watch("language")}
- placeholder="Введите ваш код..."
- />
- {snippetForm.errors.code && (
-
- {snippetForm.errors.code.message}
-
- )}
-
-
- {snippetForm.error && (
- {snippetForm.error}
- )}
-
-
-
- Создать сниппет
-
- navigate("/")}
- variant="secondary">
- Отмена
-
-
-
- )}
-
- );
-}
diff --git a/src/pages/create/hooks/useCreateForms.ts b/src/pages/create/hooks/useCreateForms.ts
index 3093b42..b8ea147 100644
--- a/src/pages/create/hooks/useCreateForms.ts
+++ b/src/pages/create/hooks/useCreateForms.ts
@@ -8,7 +8,8 @@ import {
normalizeLanguageInput,
SUPPORTED_LANG_HINT,
} from "@/shared/services/languageService";
-import { toHttpError } from "@/shared/api/http";
+import { toAppError } from "@/shared/api/app-error";
+import { applyAppErrorToForm } from "@/shared/forms/applyAppError";
// Question Form
const questionSchema = z.object({
@@ -36,9 +37,9 @@ export function useQuestionForm() {
navigate("/");
}
} catch (e) {
- const err = toHttpError(e);
- form.setError("root", { message: err.message });
- throw e;
+ const appErr = toAppError(e);
+ applyAppErrorToForm(form, appErr);
+ throw appErr;
}
};
@@ -69,7 +70,6 @@ export function useSnippetForm() {
const onSubmit = async (data: SnippetFormData) => {
try {
- // Валидируем язык отдельно
const normalizedLanguage = normalizeLanguageInput(data.language);
if (!normalizedLanguage) {
form.setError("language", {
@@ -89,9 +89,9 @@ export function useSnippetForm() {
navigate("/");
}
} catch (e) {
- const err = toHttpError(e);
- form.setError("root", { message: err.message });
- throw e;
+ const appErr = toAppError(e);
+ applyAppErrorToForm(form, appErr);
+ throw appErr;
}
};
diff --git a/src/pages/item/hooks/useQuestionDetails.ts b/src/pages/item/hooks/useQuestionDetails.ts
index 596aa62..e69de29 100644
--- a/src/pages/item/hooks/useQuestionDetails.ts
+++ b/src/pages/item/hooks/useQuestionDetails.ts
@@ -1,115 +0,0 @@
-import { useState, useMemo } from "react";
-import { useNavigate } from "react-router-dom";
-import {
- useQuestion,
- useUpdateQuestion,
- useDeleteQuestion,
- useSetAnswerState,
- useUpdateAnswer,
- useDeleteAnswer,
-} from "@/entities/question/api";
-import { useAnswerForm, useQuestionOwnership } from "@/entities/question/hooks";
-import { useQuestionAnswers } from "@/shared/socket";
-import type { QuestionState } from "./itemTypes";
-
-export function useQuestionDetails(id?: string): QuestionState {
- const navigate = useNavigate();
- const { data: questionData, status } = useQuestion(id);
- useQuestionAnswers(id);
- const answerForm = useAnswerForm(id || "0");
- const isOwner = useQuestionOwnership(questionData);
- const setAnswerStateMut = useSetAnswerState(id || "0");
- const updateAnswerMut = useUpdateAnswer(id || "0");
- const deleteAnswerMut = useDeleteAnswer(id || "0");
- const updateQuestionMut = useUpdateQuestion(id || "0");
- const deleteQuestionMut = useDeleteQuestion(id || "0");
-
- const [isEditing, setIsEditing] = useState(false);
- const [editTitle, setEditTitle] = useState("");
- const [editDescription, setEditDescription] = useState("");
- const [editQCode, setEditQCode] = useState("");
-
- const startEdit = () => {
- if (questionData) {
- setEditTitle(questionData.title);
- setEditDescription(questionData.description);
- setEditQCode(questionData.attachedCode || "");
- }
- setIsEditing(true);
- };
-
- const cancelEdit = () => setIsEditing(false);
-
- const loading = status === "pending";
- const notFound = status === "success" && !questionData;
-
- const mappedQuestion = useMemo(
- () =>
- questionData
- ? {
- title: questionData.title,
- description: questionData.description,
- attachedCode: questionData.attachedCode,
- answers: questionData.answers, // уже готовый массив; при необходимости можно клонировать
- }
- : undefined,
- [questionData]
- );
-
- return {
- mode: "question",
- loading,
- notFound,
- error: undefined,
- isOwner,
- isEditing,
- startEdit,
- cancelEdit,
- saveEdit: async () => {
- try {
- await updateQuestionMut.mutateAsync({
- title: editTitle,
- description: editDescription,
- attachedCode: editQCode,
- });
- setIsEditing(false);
- } catch {
- alert("Не удалось сохранить изменения");
- }
- },
- saving: updateQuestionMut.isPending,
- deleteItem: async () => {
- if (!confirm("Удалить вопрос?")) return;
- try {
- await deleteQuestionMut.mutateAsync();
- navigate("/my?mode=questions");
- } catch {
- alert("Не удалось удалить вопрос");
- }
- },
- deleting: deleteQuestionMut.isPending,
- question: mappedQuestion,
- edit: {
- title: editTitle,
- description: editDescription,
- code: editQCode,
- setTitle: setEditTitle,
- setDescription: setEditDescription,
- setCode: setEditQCode,
- },
- answerForm,
- markPending: setAnswerStateMut.isPending,
- markCorrect: (answerId: string | number) => {
- if (!isOwner) return;
- setAnswerStateMut.mutate({ answerId, state: "correct" });
- },
- markIncorrect: (answerId: string | number) => {
- if (!isOwner) return;
- setAnswerStateMut.mutate({ answerId, state: "incorrect" });
- },
- updateAnswer: (answerId: string | number, content: string) =>
- updateAnswerMut.mutate({ answerId, content }),
- deleteAnswer: (answerId: string | number) =>
- deleteAnswerMut.mutate(answerId),
- } as const;
-}
diff --git a/src/pages/item/question/useQuestionDetails.ts b/src/pages/item/question/useQuestionDetails.ts
index 1c1c023..4c1be97 100644
--- a/src/pages/item/question/useQuestionDetails.ts
+++ b/src/pages/item/question/useQuestionDetails.ts
@@ -15,10 +15,13 @@ import {
emitAnswerDelete,
} from "@/shared/socket";
import type { QuestionState } from "../hooks/itemTypes";
+import { emitNotification } from "@/shared/notifications";
+import { deriveEntityAccessState } from "../utils/queryErrorState";
export function useQuestionDetails(id?: string): QuestionState {
const navigate = useNavigate();
- const { data: questionData, status } = useQuestion(id);
+ const query = useQuestion(id);
+ const { data: questionData, status } = query;
useQuestionAnswers(id);
const answerForm = useAnswerForm(id || "0");
const isOwner = useQuestionOwnership(questionData);
@@ -45,7 +48,13 @@ export function useQuestionDetails(id?: string): QuestionState {
const cancelEdit = () => setIsEditing(false);
const loading = status === "pending";
- const notFound = status === "success" && !questionData;
+ const { notFound, forbidden } = deriveEntityAccessState(query);
+ if (forbidden) {
+ emitNotification({
+ type: "error",
+ message: "Нет прав для просмотра вопроса",
+ });
+ }
const mappedQuestion = useMemo(
() =>
@@ -64,7 +73,7 @@ export function useQuestionDetails(id?: string): QuestionState {
mode: "question",
loading,
notFound,
- error: undefined,
+ error: forbidden ? "Недостаточно прав" : undefined,
isOwner,
isEditing,
startEdit,
@@ -78,7 +87,10 @@ export function useQuestionDetails(id?: string): QuestionState {
});
setIsEditing(false);
} catch {
- alert("Не удалось сохранить изменения");
+ emitNotification({
+ type: "error",
+ message: "Не удалось сохранить изменения",
+ });
}
},
saving: updateQuestionMut.isPending,
@@ -88,7 +100,10 @@ export function useQuestionDetails(id?: string): QuestionState {
await deleteQuestionMut.mutateAsync();
navigate("/my?mode=questions");
} catch {
- alert("Не удалось удалить вопрос");
+ emitNotification({
+ type: "error",
+ message: "Не удалось удалить вопрос",
+ });
}
},
deleting: deleteQuestionMut.isPending,
@@ -130,5 +145,4 @@ export function useQuestionDetails(id?: string): QuestionState {
} as const;
}
-// default экспорт не обязателен, но оставим для совместимости
export default useQuestionDetails;
diff --git a/src/pages/item/snippet/useSnippetDetails.ts b/src/pages/item/snippet/useSnippetDetails.ts
index 60a3efd..24953be 100644
--- a/src/pages/item/snippet/useSnippetDetails.ts
+++ b/src/pages/item/snippet/useSnippetDetails.ts
@@ -14,12 +14,15 @@ import {
emitSnippetCommentDelete,
} from "@/shared/socket";
import type { SnippetState } from "../hooks/itemTypes";
+import { emitNotification } from "@/shared/notifications";
+import { deriveEntityAccessState } from "../utils/queryErrorState";
export function useSnippetDetails(rawId?: string): SnippetState {
const navigate = useNavigate();
const numericId = Number(rawId);
const validId = Number.isFinite(numericId) ? numericId : undefined;
- const { data: snippetData, status } = useSnippet(validId);
+ const query = useSnippet(validId);
+ const { data: snippetData, status } = query;
useSnippetComments(validId);
const isOwner = useSnippetOwnership(snippetData);
const updateSnippetMut = useUpdateSnippet(numericId || 0);
@@ -42,7 +45,13 @@ export function useSnippetDetails(rawId?: string): SnippetState {
const cancelEdit = () => setIsEditing(false);
const loading = status === "pending";
- const notFound = status === "success" && !snippetData;
+ const { notFound, forbidden } = deriveEntityAccessState(query);
+ if (forbidden) {
+ emitNotification({
+ type: "error",
+ message: "Нет прав для просмотра сниппета",
+ });
+ }
const mappedSnippet = useMemo(
() =>
@@ -69,7 +78,7 @@ export function useSnippetDetails(rawId?: string): SnippetState {
mode: "snippet",
loading,
notFound,
- error: undefined,
+ error: forbidden ? "Недостаточно прав" : undefined,
isOwner,
isEditing,
startEdit,
@@ -82,7 +91,10 @@ export function useSnippetDetails(rawId?: string): SnippetState {
});
setIsEditing(false);
} catch {
- alert("Не удалось сохранить изменения");
+ emitNotification({
+ type: "error",
+ message: "Не удалось сохранить изменения",
+ });
}
},
saving: updateSnippetMut.isPending,
@@ -92,7 +104,10 @@ export function useSnippetDetails(rawId?: string): SnippetState {
await deleteSnippetMut.mutateAsync();
navigate("/my?mode=snippets");
} catch {
- alert("Не удалось удалить сниппет");
+ emitNotification({
+ type: "error",
+ message: "Не удалось удалить сниппет",
+ });
}
},
deleting: deleteSnippetMut.isPending,
diff --git a/src/pages/item/utils/queryErrorState.ts b/src/pages/item/utils/queryErrorState.ts
new file mode 100644
index 0000000..73008cc
--- /dev/null
+++ b/src/pages/item/utils/queryErrorState.ts
@@ -0,0 +1,14 @@
+import type { QueryObserverResult } from "@tanstack/react-query";
+
+// Универсальный helper для извлечения признаков notFound / forbidden из query result
+// Основан на нашем AppError (error?.kind) + эвристике: success без data => notFound
+export function deriveEntityAccessState(
+ result: Pick, "status" | "data" | "error">
+) {
+ const { status, data, error } = result;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const kind = (error as any)?.kind as string | undefined;
+ const notFound = (status === "success" && !data) || kind === "not_found";
+ const forbidden = kind === "forbidden";
+ return { notFound, forbidden } as const;
+}
diff --git a/src/shared/api/app-error.ts b/src/shared/api/app-error.ts
new file mode 100644
index 0000000..4691fd6
--- /dev/null
+++ b/src/shared/api/app-error.ts
@@ -0,0 +1,141 @@
+// Централизованная модель ошибок приложения
+// Позволяет единообразно классифицировать и отображать ошибки
+
+export type ErrorKind =
+ | "auth"
+ | "forbidden"
+ | "not_found"
+ | "conflict"
+ | "validation"
+ | "rate_limit"
+ | "timeout"
+ | "network"
+ | "server"
+ | "unknown";
+
+export interface AppError {
+ kind: ErrorKind;
+ status?: number;
+ message: string;
+ details?: Record | Array<{ field: string; message: string }>;
+ raw?: unknown;
+}
+
+// Человекочитаемые дефолтные сообщения
+const DEFAULT_MESSAGES: Record = {
+ auth: "Нужна авторизация",
+ forbidden: "Недостаточно прав",
+ not_found: "Не найдено",
+ conflict: "Уже существует",
+ validation: "Исправьте ошибки в форме",
+ rate_limit: "Слишком много запросов, попробуйте позже",
+ timeout: "Сервер не ответил вовремя",
+ network: "Нет соединения с сетью",
+ server: "Внутренняя ошибка сервера",
+ unknown: "Непредвиденная ошибка",
+};
+
+export function classifyStatus(status?: number): ErrorKind {
+ if (!status) return "unknown";
+ if (status === 401) return "auth";
+ if (status === 403) return "forbidden";
+ if (status === 404) return "not_found";
+ if (status === 409) return "conflict";
+ if (status === 422) return "validation";
+ if (status === 429) return "rate_limit";
+ if (status >= 500) return "server";
+ return "unknown";
+}
+
+export function toAppError(err: unknown): AppError {
+ if (isAppError(err)) return err;
+
+ if (typeof navigator !== "undefined" && !navigator.onLine) {
+ return { kind: "network", message: DEFAULT_MESSAGES.network, raw: err };
+ }
+
+ type PossibleAxios = {
+ isAxiosError?: boolean;
+ response?: { status?: number; data?: unknown };
+ code?: string;
+ message?: string;
+ };
+ const ax = err as PossibleAxios;
+ const isAxios = !!ax?.isAxiosError;
+ const status = ax?.response?.status;
+ const data = ax?.response?.data;
+
+ const extractMsg = (src: unknown): string | undefined => {
+ if (src && typeof src === "object") {
+ const r = src as Record;
+ const c = r.message || r.error || r.detail;
+ if (typeof c === "string" && c.trim()) return c.trim();
+ }
+ return undefined;
+ };
+ const message =
+ extractMsg(data) ||
+ (typeof ax?.message === "string" ? ax.message : undefined);
+ const kindBase = status ? classifyStatus(status) : undefined;
+
+ if (ax?.code === "ECONNABORTED") {
+ return {
+ kind: "timeout",
+ status,
+ message: message || DEFAULT_MESSAGES.timeout,
+ raw: err,
+ };
+ }
+
+ if (isAxios && !ax?.response) {
+ return {
+ kind: "network",
+ message: message || DEFAULT_MESSAGES.network,
+ raw: err,
+ };
+ }
+
+ let details: AppError["details"] | undefined;
+ if (data && typeof data === "object") {
+ const d = data as Record;
+ const errors = d.errors as unknown;
+ if (errors) {
+ if (Array.isArray(errors)) {
+ details = errors
+ .filter(
+ (e): e is Record => !!e && typeof e === "object"
+ )
+ .map((e) => ({
+ field: String(e.field || e.path || ""),
+ message: String(e.message || ""),
+ }));
+ } else if (typeof errors === "object") {
+ details = Object.entries(errors as Record).reduce(
+ (acc, [k, v]) => {
+ if (typeof v === "string") acc[k] = v;
+ return acc;
+ },
+ {} as Record
+ );
+ }
+ }
+ }
+
+ const kind = kindBase || "unknown";
+ return {
+ kind,
+ status,
+ message: message || DEFAULT_MESSAGES[kind],
+ details,
+ raw: err,
+ };
+}
+
+export function isAppError(e: unknown): e is AppError {
+ if (!e || typeof e !== "object") return false;
+ return "kind" in e && "message" in e;
+}
+
+export function appErrorToLegacyHttpError(e: AppError) {
+ return { status: e.status, message: e.message };
+}
diff --git a/src/shared/api/http.ts b/src/shared/api/http.ts
index 69774ab..76afa38 100644
--- a/src/shared/api/http.ts
+++ b/src/shared/api/http.ts
@@ -1,4 +1,5 @@
import axios from "axios";
+import { toAppError, isAppError } from "./app-error";
// /api (Vite proxy) или переопределение через VITE_API_BASE_URL
const API_BASE_URL =
@@ -11,36 +12,34 @@ export const http = axios.create({
timeout: 10000,
});
-export interface HttpError {
- status?: number;
- message?: string;
-}
-
-export function toHttpError(err: unknown): HttpError {
- if (typeof err === "object" && err !== null) {
- const obj = err as Record;
- const response = (obj as { response?: unknown }).response;
- const resObj =
- typeof response === "object" && response !== null
- ? (response as Record)
- : undefined;
- const status = resObj?.status as number | undefined;
- const data = resObj?.data as
- | { message?: unknown; error?: unknown; detail?: unknown }
- | undefined;
- let message: string | undefined;
- const pick = (v: unknown) =>
- typeof v === "string" && v.trim() ? v : undefined;
- if (data) {
- message =
- pick(data.message) ||
- pick(data.error) ||
- pick(data.detail) ||
- undefined;
+// Axios interceptor
+http.interceptors.response.use(
+ (res) => res,
+ (error) => {
+ const appErr = toAppError(error);
+ if (appErr.status === 401) {
+ try {
+ if (typeof window !== "undefined") {
+ const path = window.location.pathname;
+ const isPublic =
+ path === "/" ||
+ /^\/questions\//.test(path) ||
+ /^\/snippets\//.test(path) ||
+ /\/login|\/register/.test(path);
+ if (!isPublic) {
+ const search = window.location.search || "";
+ const from = encodeURIComponent(path + search);
+ window.location.assign(`/login?from=${from}`);
+ }
+ }
+ } catch {
+ // ignore redirect failures
+ }
}
- if (!message && typeof (obj as { message?: unknown }).message === "string")
- message = (obj as { message?: string }).message;
- return { status, message };
+ return Promise.reject(appErr);
}
- return { message: String(err) };
+);
+
+export function asAppError(e: unknown) {
+ return isAppError(e) ? e : toAppError(e);
}
diff --git a/src/shared/forms/applyAppError.ts b/src/shared/forms/applyAppError.ts
new file mode 100644
index 0000000..1c5d28c
--- /dev/null
+++ b/src/shared/forms/applyAppError.ts
@@ -0,0 +1,38 @@
+import type { FieldValues, UseFormReturn } from "react-hook-form";
+import { isAppError, toAppError } from "@/shared/api/app-error";
+
+// Раскладывает AppError.kind === 'validation' details по полям формы
+export function applyAppErrorToForm(
+ form: UseFormReturn,
+ error: unknown
+) {
+ const appErr = isAppError(error) ? error : toAppError(error);
+ if (appErr.kind !== "validation") {
+ form.setError("root", { message: appErr.message });
+ return;
+ }
+ let assigned = false;
+ if (Array.isArray(appErr.details)) {
+ for (const d of appErr.details) {
+ if (!d.field) continue;
+ if (
+ (form.getValues() as Record)[d.field] !== undefined
+ ) {
+ // @ts-expect-error динамическое имя поля вне Path ограничений
+ form.setError(d.field, { message: d.message || appErr.message });
+ assigned = true;
+ }
+ }
+ } else if (appErr.details && typeof appErr.details === "object") {
+ for (const [field, msg] of Object.entries(appErr.details)) {
+ if ((form.getValues() as Record)[field] !== undefined) {
+ // @ts-expect-error динамическое имя поля
+ form.setError(field, { message: (msg as string) || appErr.message });
+ assigned = true;
+ }
+ }
+ }
+ if (!assigned) {
+ form.setError("root", { message: appErr.message });
+ }
+}
diff --git a/src/shared/hooks/useApiMutation.ts b/src/shared/hooks/useApiMutation.ts
new file mode 100644
index 0000000..06dac62
--- /dev/null
+++ b/src/shared/hooks/useApiMutation.ts
@@ -0,0 +1,51 @@
+import { useMutation } from "@tanstack/react-query";
+import type {
+ UseMutationOptions,
+ UseMutationResult,
+} from "@tanstack/react-query";
+import { toAppError, isAppError } from "@/shared/api/app-error";
+import { emitNotification } from "@/shared/notifications";
+
+// Обёртка над useMutation: единообразный onError (toast + возврат AppError)
+export function useApiMutation<
+ TData = unknown,
+ TVariables = void,
+ TContext = unknown
+>(
+ options: UseMutationOptions & {
+ notifySuccessMessage?: string | ((data: TData) => string | undefined);
+ suppressErrorToast?: boolean;
+ }
+): UseMutationResult {
+ const {
+ onError,
+ onSuccess,
+ notifySuccessMessage,
+ suppressErrorToast,
+ ...rest
+ } = options;
+ return useMutation({
+ ...rest,
+ onError: (error, vars, ctx) => {
+ const appErr = toAppError(error);
+ if (!suppressErrorToast) {
+ emitNotification({ type: "error", message: appErr.message });
+ }
+ onError?.(appErr, vars, ctx);
+ },
+ onSuccess: (data, vars, ctx) => {
+ let msg: string | undefined;
+ if (typeof notifySuccessMessage === "string") msg = notifySuccessMessage;
+ else if (typeof notifySuccessMessage === "function")
+ msg = notifySuccessMessage(data);
+ if (msg) emitNotification({ type: "success", message: msg });
+ onSuccess?.(data, vars, ctx);
+ },
+ } as UseMutationOptions);
+}
+
+export function getErrorMessage(e: unknown) {
+ if (isAppError(e)) return e.message;
+ if (e instanceof Error) return e.message;
+ return String(e);
+}
diff --git a/src/shared/hooks/useCommentForm.ts b/src/shared/hooks/useCommentForm.ts
index 489495a..58a3f16 100644
--- a/src/shared/hooks/useCommentForm.ts
+++ b/src/shared/hooks/useCommentForm.ts
@@ -1,5 +1,6 @@
import { useState } from "react";
-import { http, toHttpError } from "../api/http";
+import { http } from "../api/http";
+import { toAppError } from "../api/app-error";
import { useQueryClient } from "@tanstack/react-query";
import { emitSnippetComment } from "../socket";
import { useAuth } from "../../app/providers/useAuth";
@@ -52,7 +53,7 @@ export function useCommentForm(snippetId: number) {
// При socket.io обновление прилетит и так; оставим инвалидацию на случай деградации
qc.invalidateQueries({ queryKey: ["snippet", snippetId] });
} catch (e) {
- const err = toHttpError(e);
+ const err = toAppError(e);
setError(err.message || "Не удалось отправить комментарий");
} finally {
setPending(false);
diff --git a/src/shared/hooks/useCreateForms.ts b/src/shared/hooks/useCreateForms.ts
index 10064b5..3c5cb41 100644
--- a/src/shared/hooks/useCreateForms.ts
+++ b/src/shared/hooks/useCreateForms.ts
@@ -8,7 +8,7 @@ import {
normalizeLanguageInput,
SUPPORTED_LANG_HINT,
} from "../services/languageService";
-import { toHttpError } from "../api/http";
+import { toAppError } from "../api/app-error";
// Question Form
const questionSchema = z.object({
@@ -36,7 +36,7 @@ export function useQuestionForm() {
navigate("/");
}
} catch (e) {
- const err = toHttpError(e);
+ const err = toAppError(e);
form.setError("root", { message: err.message });
throw e;
}
@@ -86,7 +86,7 @@ export function useSnippetForm() {
navigate("/");
}
} catch (e) {
- const err = toHttpError(e);
+ const err = toAppError(e);
form.setError("root", { message: err.message });
throw e;
}
diff --git a/src/shared/notifications.ts b/src/shared/notifications.ts
new file mode 100644
index 0000000..ff135a8
--- /dev/null
+++ b/src/shared/notifications.ts
@@ -0,0 +1,40 @@
+// Простой emitter уведомлений через window events + типы
+export type NotificationType = "info" | "success" | "error" | "warning";
+export interface NotificationPayload {
+ id?: string | number;
+ type?: NotificationType;
+ message: string;
+ ttlMs?: number; // время жизни
+}
+
+const EVENT_NAME = "app:notify";
+
+export function emitNotification(payload: NotificationPayload) {
+ if (typeof window === "undefined") return;
+ const detail: NotificationPayload = {
+ ttlMs: 4000,
+ type: "info",
+ ...payload,
+ id: payload.id || Date.now() + Math.random(),
+ };
+ window.dispatchEvent(new CustomEvent(EVENT_NAME, { detail }));
+}
+
+export function subscribeNotifications(cb: (p: NotificationPayload) => void) {
+ const handler = (e: Event) => {
+ const ce = e as CustomEvent;
+ cb(ce.detail);
+ };
+ window.addEventListener(EVENT_NAME, handler);
+ return () => window.removeEventListener(EVENT_NAME, handler);
+}
+
+export function notifyError(message: string) {
+ emitNotification({ type: "error", message });
+}
+export function notifySuccess(message: string) {
+ emitNotification({ type: "success", message });
+}
+export function notifyInfo(message: string) {
+ emitNotification({ type: "info", message });
+}
From 3eec5cfe6d4488bd380f2f767f1a637e19eb3a0e Mon Sep 17 00:00:00 2001
From: kotru21
Date: Wed, 27 Aug 2025 19:40:13 +0300
Subject: [PATCH 36/40] Add resolved status indicator to question cards
Introduces a 'resolved' prop to ItemCommonCardView and displays a status badge on question cards based on whether the question is resolved or has a correct answer. Enhances user clarity on question status.
---
src/pages/home/ItemCard.tsx | 5 +++++
src/pages/home/ui/ItemCommonCardView.tsx | 22 +++++++++++++++++++---
2 files changed, 24 insertions(+), 3 deletions(-)
diff --git a/src/pages/home/ItemCard.tsx b/src/pages/home/ItemCard.tsx
index 4e1f9c8..22d4657 100644
--- a/src/pages/home/ItemCard.tsx
+++ b/src/pages/home/ItemCard.tsx
@@ -29,6 +29,10 @@ function QuestionView({
item: Question;
onMoreClick?: () => void;
}) {
+ const resolved = Boolean(
+ item.isResolved ||
+ (Array.isArray(item.answers) && item.answers.some((a) => a.isCorrect))
+ );
return (
);
diff --git a/src/pages/home/ui/ItemCommonCardView.tsx b/src/pages/home/ui/ItemCommonCardView.tsx
index a16ba3a..d9bd70a 100644
--- a/src/pages/home/ui/ItemCommonCardView.tsx
+++ b/src/pages/home/ui/ItemCommonCardView.tsx
@@ -12,6 +12,7 @@ export type ItemCommonCardViewProps = {
title?: string;
description?: string;
answersCount?: number;
+ resolved?: boolean;
// snippet
language?: string;
likesCount?: number;
@@ -33,6 +34,7 @@ export const ItemCommonCardView = memo(function ItemCommonCardView({
title,
description,
answersCount,
+ resolved,
language,
likesCount,
dislikesCount,
@@ -50,9 +52,23 @@ export const ItemCommonCardView = memo(function ItemCommonCardView({
{mode === "question" ? (
-
- {title}
-
+
+
+ {title}
+
+ {typeof resolved !== "undefined" && (
+
+ {resolved ? "Solved" : "Open"}
+
+ )}
+
) : (
{language}
From eb773bdab64b13390c417387e9e5e6aae28cadf3 Mon Sep 17 00:00:00 2001
From: kotru21
Date: Wed, 27 Aug 2025 19:46:09 +0300
Subject: [PATCH 37/40] Optimize rendering with memoization in item components
Refactored several components (ItemCard, ItemDetailsView, EditableAnswerItem, ActionButtons, CommentsListView) to use React.memo and useCallback/useMemo hooks for improved rendering performance and reduced unnecessary re-renders. This change enhances efficiency, especially for lists of answers and comments, and standardizes memoization usage across item-related UI components.
---
src/pages/home/ItemCard.tsx | 8 +-
src/pages/item/ItemDetailsView.tsx | 93 +++++++++++++------
.../item/question/ui/EditableAnswerItem.tsx | 6 +-
src/pages/item/ui/ActionButtons.tsx | 5 +-
src/pages/item/ui/CommentsListView.tsx | 4 +-
5 files changed, 77 insertions(+), 39 deletions(-)
diff --git a/src/pages/home/ItemCard.tsx b/src/pages/home/ItemCard.tsx
index 22d4657..923a277 100644
--- a/src/pages/home/ItemCard.tsx
+++ b/src/pages/home/ItemCard.tsx
@@ -1,4 +1,4 @@
-import { memo } from "react";
+import { memo, useCallback } from "react";
import type { Question } from "@/entities/question/types";
import type { Snippet } from "@/entities/snippet/types";
import { useAuth } from "@/app/providers/useAuth";
@@ -59,6 +59,8 @@ function SnippetView({
const { user: authUser } = useAuth();
const { mutate: mark, isPending } = useMarkSnippet(item.id);
const canInteract = !!authUser;
+ const like = useCallback(() => mark("like"), [mark]);
+ const dislike = useCallback(() => mark("dislike"), [mark]);
return (
mark("like")}
- onDislike={() => mark("dislike")}
+ onLike={like}
+ onDislike={dislike}
canInteract={canInteract}
isPending={isPending}
/>
diff --git a/src/pages/item/ItemDetailsView.tsx b/src/pages/item/ItemDetailsView.tsx
index 7f4e2a2..deba6d6 100644
--- a/src/pages/item/ItemDetailsView.tsx
+++ b/src/pages/item/ItemDetailsView.tsx
@@ -10,6 +10,7 @@ import { LoadingSkeleton } from "./ui/LoadingSkeleton";
import type { ItemState, QuestionState, SnippetState } from "./hooks/itemTypes";
import type { Answer } from "@/entities/question/types";
import { EditableAnswerItem } from "./question/ui/EditableAnswerItem";
+import { memo, useMemo, useCallback } from "react";
import { useAuth } from "@/app/providers/useAuth";
export function ItemDetailsView(props: ItemState) {
@@ -22,7 +23,11 @@ export function ItemDetailsView(props: ItemState) {
);
}
-function QuestionSection({ state }: { state: QuestionState }) {
+const QuestionSection = memo(function QuestionSection({
+ state,
+}: {
+ state: QuestionState;
+}) {
const { user } = useAuth();
const {
question,
@@ -42,6 +47,32 @@ function QuestionSection({ state }: { state: QuestionState }) {
updateAnswer,
deleteAnswer,
} = state;
+ const answersList = useMemo(() => {
+ if (!question || !Array.isArray(question.answers)) return null;
+ return question.answers.map((a: Answer) => (
+ markCorrect(a.id)}
+ onMarkIncorrect={() => markIncorrect(a.id)}
+ markPending={markPending}
+ onUpdate={(id, content) => updateAnswer(id, content)}
+ onDelete={(id) => deleteAnswer(id)}
+ />
+ ));
+ }, [
+ question,
+ isOwner,
+ user?.username,
+ markCorrect,
+ markIncorrect,
+ markPending,
+ updateAnswer,
+ deleteAnswer,
+ ]);
+
return (
@@ -109,32 +140,21 @@ function QuestionSection({ state }: { state: QuestionState }) {
Войдите, чтобы оставить ответ.
)}
- {question && (
+ {question && answersList && (
Ответы
-
- {Array.isArray(question.answers) &&
- question.answers!.map((a: Answer) => (
- markCorrect(a.id)}
- onMarkIncorrect={() => markIncorrect(a.id)}
- markPending={markPending}
- onUpdate={(id, content) => updateAnswer(id, content)}
- onDelete={(id) => deleteAnswer(id)}
- />
- ))}
-
+
)}
);
-}
+});
-function SnippetSection({ state }: { state: SnippetState }) {
+const SnippetSection = memo(function SnippetSection({
+ state,
+}: {
+ state: SnippetState;
+}) {
const { user } = useAuth();
const {
snippet,
@@ -151,6 +171,25 @@ function SnippetSection({ state }: { state: SnippetState }) {
updateComment,
deleteComment,
} = state;
+ const commentsData = useMemo(
+ () =>
+ snippet?.comments?.map((c) => ({
+ id: Number(c.id),
+ content: c.content,
+ user: { username: c.user?.username || "unknown" },
+ })) || [],
+ [snippet?.comments]
+ );
+
+ const handleUpdateComment = useCallback(
+ (cid: number, content: string) => updateComment(cid, content),
+ [updateComment]
+ );
+ const handleDeleteComment = useCallback(
+ (cid: number) => deleteComment(cid),
+ [deleteComment]
+ );
+
return (
@@ -219,18 +258,14 @@ function SnippetSection({ state }: { state: SnippetState }) {
Войдите, чтобы оставлять комментарии.
)}
- {snippet?.comments && snippet.comments.length > 0 && (
+ {commentsData.length > 0 && (
({
- id: Number(c.id),
- content: c.content,
- user: { username: c.user?.username || "unknown" },
- }))}
+ comments={commentsData}
currentUser={user}
- onUpdate={(cid, content) => updateComment(cid, content)}
- onDelete={(cid) => deleteComment(cid)}
+ onUpdate={handleUpdateComment}
+ onDelete={handleDeleteComment}
/>
)}
);
-}
+});
diff --git a/src/pages/item/question/ui/EditableAnswerItem.tsx b/src/pages/item/question/ui/EditableAnswerItem.tsx
index 1d02910..fc0b663 100644
--- a/src/pages/item/question/ui/EditableAnswerItem.tsx
+++ b/src/pages/item/question/ui/EditableAnswerItem.tsx
@@ -1,4 +1,4 @@
-import { useState } from "react";
+import { memo, useState } from "react";
import type { Answer } from "@/entities/question/types";
import AnswerItemView from "./AnswerItemView";
@@ -13,7 +13,7 @@ interface EditableAnswerItemProps {
onDelete: (id: string | number) => void;
}
-export function EditableAnswerItem({
+export const EditableAnswerItem = memo(function EditableAnswerItem({
answer,
canMark,
currentUser,
@@ -74,6 +74,6 @@ export function EditableAnswerItem({
)}
);
-}
+});
export default EditableAnswerItem;
diff --git a/src/pages/item/ui/ActionButtons.tsx b/src/pages/item/ui/ActionButtons.tsx
index 31415eb..745f3a3 100644
--- a/src/pages/item/ui/ActionButtons.tsx
+++ b/src/pages/item/ui/ActionButtons.tsx
@@ -1,3 +1,4 @@
+import { memo } from "react";
import { Button } from "@/shared/ui/Button";
interface ActionButtonsProps {
@@ -6,7 +7,7 @@ interface ActionButtonsProps {
deleting?: boolean;
}
-export function ActionButtons({
+export const ActionButtons = memo(function ActionButtons({
onEdit,
onDelete,
deleting,
@@ -21,6 +22,6 @@ export function ActionButtons({
);
-}
+});
export default ActionButtons;
diff --git a/src/pages/item/ui/CommentsListView.tsx b/src/pages/item/ui/CommentsListView.tsx
index 0742e37..dcd2e6c 100644
--- a/src/pages/item/ui/CommentsListView.tsx
+++ b/src/pages/item/ui/CommentsListView.tsx
@@ -40,7 +40,7 @@ export const CommentsListView = memo(function CommentsListView({
);
});
-function CommentItem({
+const CommentItem = memo(function CommentItem({
comment,
isOwner,
onUpdate,
@@ -108,6 +108,6 @@ function CommentItem({
)}
);
-}
+});
export default CommentsListView;
From d07acea2acd827fb0660eb8cae5d9acbe5173357 Mon Sep 17 00:00:00 2001
From: kotru21
Date: Wed, 27 Aug 2025 19:57:15 +0300
Subject: [PATCH 38/40] Readme
---
README.en.md | 193 ++++++++++++++++++++++++++++++++++++++
README.md | 258 ++++++++++++++++++++++++++++++++++++++-------------
2 files changed, 385 insertions(+), 66 deletions(-)
create mode 100644 README.en.md
diff --git a/README.en.md b/README.en.md
new file mode 100644
index 0000000..0bcf59b
--- /dev/null
+++ b/README.en.md
@@ -0,0 +1,193 @@
+# StackOverflow Clone (Frontend + WebSocket Relay)
+
+[Русский](./README.md) | English
+
+Modern educational single page application (StackOverflow‑like domain):
+questions with answers, code snippets with likes/dislikes and comments, user account management, real‑time updates via Socket.IO.
+
+> NOTE: For real‑time features you MUST run the separate WebSocket relay server located in `websocket-server` (see "Running the WebSocket server"). Without it the UI works, but no live events (comments / answers / status changes) will arrive.
+
+## Backend / API
+
+Production REST API base URL:
+
+API documentation (Swagger / OpenAPI):
+
+Locally the frontend uses a Vite proxy mapping `/api` → `https://codelang.vercel.app` (see `vite.config.ts`). Override `VITE_API_BASE_URL` if you need a different backend.
+
+## Features
+
+- Authentication (login / logout / register) with session persistence (cookies, `withCredentials`).
+- Questions: create, edit, delete, answers, mark/unmark answer correctness, live updates of answers and their states.
+- Snippets: create, edit, delete, like / dislike, comments, live comment updates.
+- Infinite pagination (`useInfiniteQuery`).
+- Optimistic updates + subsequent cache validation (React Query invalidation).
+- Toast‑like notifications via a lightweight window event emitter.
+- Theming (light / dark) with user preference persistence and system preference fallback.
+- Code editor / viewer (CodeMirror + Prism highlight).
+- Type‑safe forms (`react-hook-form` + `zod`).
+- Path alias imports (`@`, `@/shared`, etc.) configured in Vite.
+
+## Tech Stack
+
+Frontend:
+
+- React 19 + TypeScript
+- Vite 7 (fast build, HMR, mkcert for local https)
+- React Router v7
+- TanStack React Query (data / cache / synchronization)
+- Socket.IO client (real‑time)
+- Tailwind CSS 4
+- Axios (HTTP)
+- React Hook Form + Zod (validation)
+- CodeMirror / Prism (editor & syntax highlighting)
+
+WebSocket relay server (`/websocket-server`):
+
+- Node.js + TypeScript
+- socket.io (server)
+- Simple event relay (comments & answers) without direct REST API access.
+
+## Architectural Overview
+
+Feature / slice inspired layout:
+
+- `src/app` — context providers (auth, theme, notifications), top‑level layout pieces.
+- `src/entities` — domain entities (question, snippet, user): types, API hooks, business hooks.
+- `src/pages` — route pages & view compositions.
+- `src/shared` — shared utilities: HTTP client, normalization, socket, notifications, UI components, forms, language service.
+- `websocket-server` — standalone Socket.IO relay.
+
+Data fetching & mutations are encapsulated in hooks (`useQuestion`, `useCreateAnswer`, `useSnippets`, etc.). Real‑time subscriptions are separate hooks (`useSnippetComments`, `useQuestionAnswers`). Socket events update the React Query cache (`setQueryData`) and then optionally trigger `invalidateQueries` to reconcile with backend truth.
+
+## Real‑time Events
+
+Comment (snippet room `snippet:{id}`):
+
+- `comment:created`, `comment:updated`, `comment:deleted`.
+
+Answer (question room `question:{id}`):
+
+- `answer:created`, `answer:state_changed`, `answer:updated`, `answer:deleted`.
+
+Client first persists via REST, then (for responsiveness) may emit an event (optimistic relay) which is later confirmed by cache invalidation.
+
+## Environment Variables (Vite)
+
+Use `.env.local` (do not commit) if you need overrides:
+
+```bash
+VITE_API_BASE_URL=/api # Default /api (proxied via vite.config)
+VITE_SOCKET_URL=http://localhost:4000 # WebSocket relay URL (dev)
+VITE_SOCKET_PATH=/socket.io # Optional, auto‑derived when omitted
+```
+
+If the API backend is separate (no Vite proxy), set full origin:
+
+```bash
+VITE_API_BASE_URL=https://api.example.com
+```
+
+## Quick Start (Frontend)
+
+1. Install dependencies:
+
+ ```powershell
+ npm install
+ ```
+
+1. (Optional) create `.env.local` to override API / Socket.
+
+1. Run dev server:
+
+ ```powershell
+ npm run dev
+ ```
+
+1. Open . (mkcert produces a local cert; add a browser exception if warned.)
+
+## Running the WebSocket Server
+
+Enter the subfolder and install separately:
+
+```powershell
+cd .\websocket-server
+npm install
+npm run dev # starts on port 4000
+```
+
+You should see: `Socket.io server started on ws://localhost:4000`.
+
+Ensure the frontend can reach it (default `VITE_SOCKET_URL=http://localhost:4000`).
+
+## Build & Preview
+
+```powershell
+npm run build # tsc -b + vite build
+npm run preview # local static preview
+```
+
+Production bundle is emitted to `dist`.
+
+## Scripts
+
+- `npm run dev` — Vite dev server (https + HMR)
+- `npm run build` — type check (project refs) + build
+- `npm run preview` — preview built SPA
+- `npm run lint` — ESLint
+
+## HTTP Client & Auth
+
+`src/shared/api/http.ts` configures Axios with `baseURL = VITE_API_BASE_URL || /api`, `withCredentials: true`. A 401 interceptor redirects to `/login?from=...` for protected pages.
+
+## Caching Strategy
+
+React Query:
+
+- Lists (`useInfiniteQuery`) key pattern: `['questions', { search, sortBy }]`.
+- Details: `['question', id]`, `['snippet', id]`.
+- Post-mutation targeted invalidation.
+- Real‑time events first patch cache (`setQueryData`), then optionally reconcile using `invalidateQueries`.
+
+## Theming
+
+`ThemeProvider` sets `data-theme` and toggles `dark` class on `html` & `body`, persisting selection in `localStorage` (values: `light | dark`).
+
+## Notifications
+
+`src/shared/notifications.ts` provides a tiny CustomEvent bus. Hooks dispatch success / error messages (auto TTL).
+
+## Adding a New Entity (Pattern)
+
+1. Create `src/entities/`: `types.ts`, `api.ts`, `hooks/`.
+2. Define types + normalization adapter.
+3. Implement hooks: list (`uses`), detail (`use`), mutations via `useApiMutation`.
+4. Add socket subscription hook if needed (model after `useSnippetComments` / `useQuestionAnswers`).
+
+## Real‑time Integration Details
+
+File `src/shared/socket.ts`:
+
+- Lazy client initialization.
+- Env driven config (`VITE_SOCKET_URL`, `VITE_SOCKET_PATH`).
+- Forced WebSocket transport.
+- Verbose dev logging of connect / errors.
+
+## Codebase Style
+
+- Type‑first: domain types in `entities/*/types.ts`.
+- Manual lightweight normalization (coercion, counts for likes/dislikes, etc.).
+- Avoid unnecessary rerenders: structured query keys, limited retries, disabled `refetchOnWindowFocus` for list views.
+
+## Quality Gate
+
+Before committing:
+
+```powershell
+npm run lint
+npm run build
+```
+
+---
+
+See Russian version in `README.md` for original description or open an issue for questions.
diff --git a/README.md b/README.md
index 7959ce4..60d4e6a 100644
--- a/README.md
+++ b/README.md
@@ -1,69 +1,195 @@
-# React + TypeScript + Vite
-
-This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
-
-Currently, two official plugins are available:
-
-- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
-- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
-
-## Expanding the ESLint configuration
-
-If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
-
-```js
-export default tseslint.config([
- globalIgnores(['dist']),
- {
- files: ['**/*.{ts,tsx}'],
- extends: [
- // Other configs...
-
- // Remove tseslint.configs.recommended and replace with this
- ...tseslint.configs.recommendedTypeChecked,
- // Alternatively, use this for stricter rules
- ...tseslint.configs.strictTypeChecked,
- // Optionally, add this for stylistic rules
- ...tseslint.configs.stylisticTypeChecked,
-
- // Other configs...
- ],
- languageOptions: {
- parserOptions: {
- project: ['./tsconfig.node.json', './tsconfig.app.json'],
- tsconfigRootDir: import.meta.dirname,
- },
- // other options...
- },
- },
-])
+# StackOverflow Clone (Frontend + WebSocket Relay)
+
+Русский | [English](./README.en.md)
+
+Современное учебное SPA-приложение (аналог StackOverflow по набору сущностей):
+вопросы с ответами, сниппеты кода с лайками/дизлайками и комментариями, учётная запись пользователя, real‑time обновления через Socket.IO.
+
+## Основные возможности
+
+- Аутентификация (login / logout / register) с сохранением сессии (cookies, `withCredentials`).
+- Вопросы: создание, редактирование, удаление, ответы, пометка ответов как корректных, live‑обновление ответов и их статусов.
+- Сниппеты: создание, редактирование, удаление, лайк / дизлайк, комментарии, live‑обновление комментариев.
+- Пагинация и бесконечная прокрутка (`useInfiniteQuery`).
+- Оптимистические обновления + последующая валидация через инвалидацию кэша React Query.
+- Уведомления (toast-like) через простой EventEmitter на window.
+- Темизация (light / dark) с сохранением предпочтений и уважением системной темы.
+- Code editor / viewer (CodeMirror + Prism highlight).
+- Type‑safe формы (`react-hook-form` + `zod`).
+- Alias импорты (`@`, `@/shared`, и т.д.) настроены в Vite.
+
+## Технологический стек
+
+Frontend:
+
+- React 19 + TypeScript
+- Vite 7 (быстрая сборка, HMR, mkcert для https локально)
+- React Router v7
+- TanStack React Query (данные / кэш / синхронизация)
+- Socket.IO client (real‑time)
+- Tailwind CSS 4
+- Axios (HTTP)
+- React Hook Form + Zod (валидация)
+- CodeMirror / Prism (редактор и подсветка кода)
+
+WebSocket relay сервер (`/websocket-server`):
+
+- Node.js + TypeScript
+- socket.io (сервер)
+- Простая ретрансляция событий (комментарии и ответы) без прямого доступа к REST API.
+
+## Важное примечание
+
+Для работы live‑обновлений необходимо запустить отдельный WebSocket сервер из папки `websocket-server` (см. раздел Запуск). Без него интерфейс будет работать, но real‑time события (комментарии, ответы, изменения статусов) не будут приходить.
+
+## Бэкенд / API
+
+Продакшен REST API развёрнут по адресу:
+
+Документация (Swagger / OpenAPI):
+
+Локально фронтенд по умолчанию использует Vite proxy для пути `/api` → `https://codelang.vercel.app` (см. `vite.config.ts`). Если требуется направить запросы на иной бекэнд, переопределите `VITE_API_BASE_URL`.
+
+## Архитектурный обзор
+
+Структура организована в стиле feature / slices:
+
+- `src/app` — провайдеры контекстов (auth, theme, notifications), верхнеуровневые layout компоненты.
+- `src/entities` — доменные сущности (question, snippet, user): типы, API hooks, бизнес-хуки.
+- `src/pages` — страницы роутера (маршруты и их представления).
+- `src/shared` — общие утилиты: HTTP клиент, нормализация API, сокет, уведомления, UI компоненты, формы, сервис языка.
+- `websocket-server` — независимый Socket.IO relay.
+
+Логика получения и мутации данных инкапсулирована в hooks (`useQuestion`, `useCreateAnswer`, `useSnippets`, и т.д.). Real‑time подписки оформлены отдельными hooks (`useSnippetComments`, `useQuestionAnswers`). Сокет событийно синхронизирует клиентский кэш (через `queryClient.setQueryData` + `invalidateQueries`).
+
+## Реалтайм события
+
+Комментарий (snippet room `snippet:{id}`):
+
+- `comment:created`, `comment:updated`, `comment:deleted`.
+
+Ответ (question room `question:{id}`):
+
+- `answer:created`, `answer:state_changed`, `answer:updated`, `answer:deleted`.
+
+Клиент сначала сохраняет сущность через HTTP (REST), затем (для отзывчивости) часть событий может отправляться клиентом самим (optimistic relay) и подтверждаться через инвалидацию кэша.
+
+## Переменные окружения (Vite)
+
+Используйте файл `.env.local` (не коммитить) при необходимости:
+
+```bash
+VITE_API_BASE_URL=/api # По умолчанию /api (proxy на внешний бекенд через vite.config)
+VITE_SOCKET_URL=http://localhost:4000 # URL websocket-сервера (dev)
+VITE_SOCKET_PATH=/socket.io # Не обязательно, вычисляется автоматически
+```
+
+Если API размещён отдельно (не через proxy), установите полный origin, напр.:
+
+```bash
+VITE_API_BASE_URL=https://api.example.com
+```
+
+## Быстрый старт (Frontend)
+
+1. Установить зависимости:
+
+```powershell
+npm install
+```
+
+1. (Опционально) создать `.env.local` для переопределения API / Socket.
+
+1. Запустить дев‑сервер:
+
+```powershell
+npm run dev
+```
+
+1. Открыть (mkcert генерирует локальный сертификат). Если браузер предупреждает о самоподписанном сертификате — добавить исключение.
+
+## Запуск WebSocket сервера
+
+Перейдите в подпапку и установите его зависимости отдельно:
+
+```powershell
+cd .\websocket-server
+npm install
+npm run dev # запустит на порту 4000
+```
+
+Сообщение в консоли: `Socket.io server started on ws://localhost:4000`.
+
+Убедитесь, что фронтенд видит его (по умолчанию `VITE_SOCKET_URL=http://localhost:4000`).
+
+## Сборка и предпросмотр
+
+```powershell
+npm run build # tsc -b + vite build
+npm run preview # локальный статический preview
```
-You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
-
-```js
-// eslint.config.js
-import reactX from 'eslint-plugin-react-x'
-import reactDom from 'eslint-plugin-react-dom'
-
-export default tseslint.config([
- globalIgnores(['dist']),
- {
- files: ['**/*.{ts,tsx}'],
- extends: [
- // Other configs...
- // Enable lint rules for React
- reactX.configs['recommended-typescript'],
- // Enable lint rules for React DOM
- reactDom.configs.recommended,
- ],
- languageOptions: {
- parserOptions: {
- project: ['./tsconfig.node.json', './tsconfig.app.json'],
- tsconfigRootDir: import.meta.dirname,
- },
- // other options...
- },
- },
-])
+Готовый production билд будет в папке `dist`.
+
+## Скрипты
+
+- `npm run dev` — старт дев-сервера Vite (https + HMR)
+- `npm run build` — типизация (project refs) + сборка
+- `npm run preview` — предпросмотр собранного SPA
+- `npm run lint` — ESLint проверка
+
+## HTTP клиент и авторизация
+
+`src/shared/api/http.ts` создаёт экземпляр Axios с `baseURL = VITE_API_BASE_URL || /api` и `withCredentials: true`. Интерцептор 401 делает редирект на `/login?from=...` если страница частная.
+
+## Работа с кэшем
+
+React Query:
+
+- Списки (`useInfiniteQuery`) с ключами вида `['questions', { search, sortBy }]`.
+- Детали: `['question', id]`, `['snippet', id]`.
+- После мутаций — точечная инвалидация.
+- Live‑события сначала обновляют кэш локально (`setQueryData`), затем вызывают `invalidateQueries` для сверки с сервером.
+
+## Темизация
+
+`ThemeProvider` добавляет атрибуты `data-theme` и класс `dark` на `html` и `body`, сохраняет выбор в `localStorage`. Возможные значения: `light | dark`.
+
+## Уведомления
+
+Файл `src/shared/notifications.ts` — простая шина событий (CustomEvent) с типами. Используется хуками мутаций для показа успеха / ошибок.
+
+## Добавление новой сущности (рекомендованный паттерн)
+
+1. Создать директорию в `src/entities/`: `types.ts`, `api.ts`, `hooks/`.
+2. Определить типы и адаптер нормализации.
+3. Hooks `uses`, `use` (деталь), мутации через `useApiMutation`.
+4. Подписки на сокет (если нужны) аналогично `useSnippetComments` или `useQuestionAnswers`.
+
+## Реалтайм интеграция (деталь)
+
+Файл `src/shared/socket.ts`:
+
+- Автоленивая инициализация клиента.
+- Конфиг через env (`VITE_SOCKET_URL`, `VITE_SOCKET_PATH`).
+- Принудительный транспорт WebSocket.
+- Dev‑логи подключений и ошибок.
+
+## Стиль кодовой базы
+
+- Type-first: типы домена в `entities/*/types.ts`.
+- Минимальная «ручная» нормализация ответов (приведение чисел и строк, сбор статистик likes/dislikes).
+- Избежание лишних перерендеров: составные ключи, ограниченный retry, отключение `refetchOnWindowFocus` на списках.
+
+## Проверка качества
+
+Перед коммитом рекомендуется:
+
+```powershell
+npm run lint
+npm run build
```
+
+---
+
+При возникновении вопросов смотрите исходный код в соответствующих директориях или добавляйте issue.
From 7babd26c3008fad50c3e119505f450cbf456324e Mon Sep 17 00:00:00 2001
From: kotru21
Date: Wed, 27 Aug 2025 20:05:33 +0300
Subject: [PATCH 39/40] Add feature screenshots to README files
Inserted relevant UI screenshots into both English and Russian README files to visually illustrate key features. Added four new images showing questions, snippets, and code editor modes for improved documentation clarity.
---
README.en.md | 4 ++++
README.md | 4 ++++
readme/localhost_5173__code_editor.png | Bin 0 -> 225877 bytes
readme/localhost_5173__mode=questions (2).png | Bin 0 -> 206387 bytes
readme/localhost_5173__mode=questions.png | Bin 0 -> 242972 bytes
readme/localhost_5173__mode=snippets.png | Bin 0 -> 241748 bytes
6 files changed, 8 insertions(+)
create mode 100644 readme/localhost_5173__code_editor.png
create mode 100644 readme/localhost_5173__mode=questions (2).png
create mode 100644 readme/localhost_5173__mode=questions.png
create mode 100644 readme/localhost_5173__mode=snippets.png
diff --git a/README.en.md b/README.en.md
index 0bcf59b..4fdf0e0 100644
--- a/README.en.md
+++ b/README.en.md
@@ -19,12 +19,16 @@ Locally the frontend uses a Vite proxy mapping `/api` → `https://codelang.verc
- Authentication (login / logout / register) with session persistence (cookies, `withCredentials`).
- Questions: create, edit, delete, answers, mark/unmark answer correctness, live updates of answers and their states.
+
- Snippets: create, edit, delete, like / dislike, comments, live comment updates.
+
- Infinite pagination (`useInfiniteQuery`).
- Optimistic updates + subsequent cache validation (React Query invalidation).
- Toast‑like notifications via a lightweight window event emitter.
- Theming (light / dark) with user preference persistence and system preference fallback.
+
- Code editor / viewer (CodeMirror + Prism highlight).
+
- Type‑safe forms (`react-hook-form` + `zod`).
- Path alias imports (`@`, `@/shared`, etc.) configured in Vite.
diff --git a/README.md b/README.md
index 60d4e6a..88848e6 100644
--- a/README.md
+++ b/README.md
@@ -9,12 +9,16 @@
- Аутентификация (login / logout / register) с сохранением сессии (cookies, `withCredentials`).
- Вопросы: создание, редактирование, удаление, ответы, пометка ответов как корректных, live‑обновление ответов и их статусов.
+
- Сниппеты: создание, редактирование, удаление, лайк / дизлайк, комментарии, live‑обновление комментариев.
+
- Пагинация и бесконечная прокрутка (`useInfiniteQuery`).
- Оптимистические обновления + последующая валидация через инвалидацию кэша React Query.
- Уведомления (toast-like) через простой EventEmitter на window.
- Темизация (light / dark) с сохранением предпочтений и уважением системной темы.
+
- Code editor / viewer (CodeMirror + Prism highlight).
+
- Type‑safe формы (`react-hook-form` + `zod`).
- Alias импорты (`@`, `@/shared`, и т.д.) настроены в Vite.
diff --git a/readme/localhost_5173__code_editor.png b/readme/localhost_5173__code_editor.png
new file mode 100644
index 0000000000000000000000000000000000000000..1f7ee3bba2f30eb4d5e1452187a964d173b3d833
GIT binary patch
literal 225877
zcmbTd2UL^U);|m?78FGR89?lG0i{Yu5s}^rJtCa|p(7=ss30Jq(xe5XLnzXvB?F2`
z?}>yG1nDKT5J(952OZ}+^WJ;EZ|+*GtgP@n`|R`EeV=_IbhK0$=-KIMXlNKzAKcTU
zp*f{ULvs{${3!L8<7w9*G&GDfs`u^~Jf>Y9Kk@MVa1&u28~5x;%Q@yNx;od4B8<3>
zJ7sxXF?XP=Gg9Q&S-Z;2dLCqAN|)pGPOt@}T%dNRR3}sUIlkgc3zxBhj$$ZBGo9`o
zrt_LM-7w!CS#{Xc>(^}pM0UgM?RvV>q~`V%=44N(&f0`V?kLjG9%1~;NB&91ONWkM
z4yO6_<2Eq(_}RZaMt$q|-9ypxEtl6r{_^PM;NWSxPMeDPdwf?rhBaChc
znzL8`x<4w@n{>~qOtaV0FkNQ%r7+3;xwhi;*~@zWhzO&@(7~35#(eCZ;evyZpeK}+N`tNx32u5cYhjG-~5r|zy1HU?2v)8BD7gm$4NhJ
z;3pX|s?q=5m(bA8on*Z7r_KF*^uXTLPf&%;e7xarWV9GBAN>bFg}kN$%)8Kh@h@vk
zo}$hA2dtb`%zR3fwwl|!CqFqna7$WeFQ5JgtWW`5qdKdn$fJ(Gyp?uM@%rDkc>KG6
zy!U@x^iQaQGpNk$-F)ybVc%8_HvXH4zx(NX&wu6j`!YSOm+$^qK9cSYjjr<@`k#Vz
zOQh-loB#jQKmLvQFHU`uiMplO&?A4~ds{R3@!uZ)|K^1Hguw4dj+Y%V`@vZnkK(<5
zh{^YT2CD?0V7z!-;`_T=%$K=;!TASM|1Adm$@W1k7&~2j5SHxe8Cm|KIR_s3KS!fq
z(XOFVaXxB96{Z30)ZPCm%wJ#mH9|7>s&Ho{Re-pJIXau_vc`wWVZ=n7`SAjNE{{H_829*Xi
z`LK_A$)lL2`F!*5DslutBlx$SQ=$54HU9_b-mBC&P)Xy?{#RTxuN?2Y{}1*U^4*Tg
z&)EN(#?XX>T~QSH%P$A3P`Uq)CRw0ERbG}2w{yR;!yZYq@(;E|T`(k)Ds0_Xr{{i=
zsKf0;Rm{JV{r`v{0QqYycLF(_^?+#(c_dnrU6Jo
z?A=#Ebn01ISv_HA%4@RA`mivV?>+VLM1Yj{Vtg0W$ET(-YDBWF$*&9Q>gxI=s`ife
z>1gFl7+nmfLSfmNi+UJ);QB5yt(OwAUwx|&ArHy$HYXvq&@gR;^K9*pxo7T-Px
zTuR-#ChaZV08?Q4?E1tp$}!H-EHgGBzqWTPWZ~o>PsmOAMM&@f&B3NZf*GlavZB}F
z=dO+i^b1ytO-<`@QEDT%-M+{E%B8+Cw
zv>k>TzfnD7clSek9M6jC>Dd!!W*1JEW(CsCaOywUwtALEy#JQx4DS#oEKNRVa_mMO
zy{GL)@}88Gln6StRU_QY$A~fPe4)=5c`CIt;vzp$!xvUG3ozev#lwF49iHE3aAmoVEN{4zy2rV$?
zPQJX%xikAN21-#m9q)1l|lCq?bWh1Q1lAD#8ch&wGpP3m}xVO#8fU8gaJnrXCd=!7vKIG_HA?b3{<(7+x
z3mlyB$r8D6c#t(uTLb$>UIb$J#6ny5hbuNPp&F?n_w=D7jK^(gF=2mkxd*Amzmksw
zcQAMSP3kt1w113LCX&iw*$s5?(ekRkqzXTOmZ+-{pezO|0*l5herzAC20C(qZfZN<
z)DFDKbgAnp-DZ6FyW-ekSu%xrpX?eSqNKCmu9h&%|JmsCg4Z)!h>iC1n{n4dD`Pt}
z0;#4tOw~+VZc~Gzn<7_&6%|ih*x7zgMa)Z;#H(B_r`_D#S}Q_lpya5LaAa|5NgoOg
zk>k55cKr+DEoBc?QZ%&s2_{vnTF}+K+P~71l>9(1DQE#ZWV;?RcX~EZy(4UO*nqMP0
zp~SkN15d7n7KL994Gpz{y*bPLZQ$)SEL!PgPw{|k!q=^ZK@si1;~g@kYdCCe#s;JR
zMzDc_+bo@->^TqO%M@s>zGkc|z%7O<)MN*%MX|W6MaCCK&K@_QZL96vV_A>O*XkV8
z7%j4WIz8>;Op9tm=PRj*CIEFZw^?fp2iM+xx^HQ{Zs`Wi!KpIlYuTfX>t9hWbK)_nW7L;+NGv7vK%4s>
zP^0VDsu=D+BgemY5Z}dkn(?;ZMpdY}&^iDK$`kEo$jH0y8CN)h!s{6u&!+}5#s}$X
z#vwdZ!ZW?UKhXqkgAT!3k~f2
zuOIzDx!`s1G`FGwpTEf8i@adPC(N1sKlSeq(`5i&mJ|kRw%p}_UZy>F=dm$U16N(0
z-*$}!!}O|j=xAGO@6l_BqKV-;TAr8o
z5Qdx4^xzgW?eyfg+){*mG6nEko7+Cv>Nh7F$45;HO{L;`52c>Hk=C%7Nt<;Grhg+Z
z3SrIc&NlhZ5`#u&QMc`(dxou_e`)l5=Teb;vXAO2+S
zg$_xbE+z)K@S=>7t?Xpyhc#D5!k1N#m$uYrJpCGd{l=2@GuRg}F2#q1nfZ$&HEU-y
zKBVjsRO3-MmeFts?p>9~O=m9Td#NFyky~BJ(CAge(WFibU}5Dl>`8Fv%26Jyw`9qftQV#O$p=UJS2D%5{@yIoqzZMjVmyh@T`-)cL2(4)Q
z@1$?xYdF2bS?yK|fmwZVz!8`An%?iQ{wgcR;6q|3LW&M*W+%&Uf6ZMEDFy#9$X~0w
z?|FX+4eRIj_E&x_cXjqvp9N8%iq#S~1T?AGtRT%@Fl;)}*|&aOB{KLoHqlUQ?rG
z(MM&wUr|5KOpEz@OYo=1KW-kfw--1=wOxi&ox}&FEwVD458&@K;1+Zv8(`4o>e!mU
z@Mab)P6bJah3Y>Hl%pDdVc|#&jo^RGTNvLRnrJ_B;2kE@x`Lha;Wq(R3OOS#qS_(6
zH*Qo77XP9o$8XZ6e6Qwp=nht)jVk`vDk0Yu&3<|Ghf@8SyE8sN{4H@O$oQfG`_`bJbSB
zdNVMZBP|o&I8lCrolrE|@M!|={o7f?e=Yk1PAVW`Kgz}s8s;^v>kb}!Q8dou$(l|c
z&KZzCjj>5SMhQBBKg4)kj282=!I5yDnl0uQe*W&a84C`P054F6Z#9R-w)hzWg$zeR
zFnu%*0MrEDbnDA)hUIDB>H4~mCM8z<;-GqVeiwRU+=RAophNuast1)_Uyo!TKx@F*
zM7zTUYuvq*^W&q+{Snh*lmr*GO`nzt7d3^i<5p3BIGvw@Q0zXTbM6mgIg%Lcd+v_T
z#4T(bOHcJP6(wj6SgCBVf7T+Nbu6pvk;n%bzHAdoP8W=F{#{Ib@|^RrCmY(lTSIqb
zQXP@$52;@0J6T#TMeCoU4e2;&n^ow2Q#x?s$2CHVZMxa&KYKFyq`cfyybc$}tnzS?MiU0*s~KL@8I1n^m_
z(+oYnUWFdr7v+auRJ*C{8?+VR4tNst7&Oqg;7t%?}f2lCw3WiAgca8UB#X-un6SgFn}$%B28AyZC+812bf=`TZ5(
z)6#4Bxn=mwwD+!MyO`hI2;ZHx^L%w6jSAl_LWB?bI*quLJE5xk1!wlRs
zK(&xB4?j43X29+?+TZ;&xl!Aj2V?vC)?0Lp$>5?%nUXzBi14``!hv6&Mh>LIh^rUk
za_l7`Z{ubxtYGKtArUUx^+RHLQAcBvvz2i+q!<~=S0ygm+lFN-`UZgwNakU`0&XGW63s#&uc)X+
zK#j{lJ(s?CRV5{w!lvdYb|*qx8hq{^D!kAXx3qOzGv%gHc_TLP>h+)2av%zETKa#5
zmV)@-OGi#@Uq6hqc{1g42H!M|^u~Uh+agd%dTsRlh8L1M7~k-ZnRC&KpNPi+*(IBc1!A
zr*{GKm|krg&|0}0?n*a+$D_Rg)9pj7Uf6484j&Lki~gew^<-*2!$3CWu-tpHYPR#%
zqti*PnUzS&!0Oq#@sOQ9GDPwUXe-J2IA_!p`y0izjg?a7&%sgstGW8PQSY`7gVkQ7
z_1|B-Frtf5BsxW}z2G41uw~_v1O^*@#oEAK)iJPK&V;%bvf|e!vEbfDeNkII`k$^T
zCm28YG}xIn`k>3l&!xzH=$tO3|&S->LPIK|D_q
zeF7^xH^BILw8t@
zS@PjMo7p4|Ji-3;R#a<4hpVGom21W=;F3`n>M%MiF+Dun`*Pp?>{+3{`UG3yGp)ml
zOclP4UmGntWRX!9gZAAuIi*a`yl4b!cSFpxrPY!-jOK?A+oL?^GlW6yl6tkbvJ)6D
zS6=FIwl$2sfX?sap3f5iqdBL6eJ$dotq^Qk?P*Arw%LA-8q>u7`y#qHWdF%!R7we{
zr|4y+ZT9|@WEgNIO$q-ygFT_soi(FiF4q?mc0j`yHN
zSfF$HTJ{bOuO7iJE~fixD*JB8vP%kOC=kh`aL;l6b%`m#t+Asv%3yiT&$Py-Aad@A
z^I(1E;*PSPGz-Q+C@Go#tDdga+l1j8s85^xy!`Qg8&6C;
zu9egpr-=WK~4s&dCeIZ-u*|
z3EUZPP8K0@T+m_DdTpaP9RMZbl-;?mAn%F}nW7cP)pi9`49S*!+8qTP%+*pDJS=Bc
zA1l6tzf+PdaZgeUQ=lzdZte#GM+ex{Cl;F)qm8UIW7ZZuIQem6ZD$L3LO6v3xSzT3;;S=Z7^UhTDZtRdsf*LyS0anL
z!9_o+=K_LULYhZ&bvVGB~O>TPa4pcB>UWO=7yL6k<=)2V(IdyOv-R
zbLCz74h2H~&Bp@tCwtKCH{qh@LL?oi)hnB(Wln6-gJ*Z{@ZaDyxKnw&uJ}|peUa~`
zB{eZ@@WR=g4hT04K7PB9&96i7=K9kj)psMP36J?vuZFc4*88Jo6vMdr(@yrBx81%_
zAf6f==P643*<%JjdjSC_v&0vh5!-$O>Q90o?A!VOf|F|fy+b+jVak<-{V_oDXQANK{dW|8Jn3KBs%Aco)73s
zq@ybI4>ioJIS4~$72bwI_yawSR(wbqu!^jK?yPv!eec)~uKiLNY}3oSKPcLjEO?oCPv202%qdo#wrj)pqd>Ge7&D$hJH2|$DTt*6D_X}g;U0jlb+pB{QYhXMuJres$r
zw4}D}ra$Pf0Mzh};vPIxRj7U1w&$s(AIehJIpZ$SIab6qoj#MIU|Z$S<+Hwogva;U
zSLF$g=e-D|h;(Y(r$UM3+YC3d(^P}X1GVCVT$1gzoCmwiwgCf|h;ara~82HIEmwO%OM#&=+eT
zTC%vP&X*r1){UD+_Z*8eOw8Ht0SJRlN-a~6fIl1ywN$P8+0_0i)%!<;-h02WOq00>
z7|oq=YF{2EenF^?$p2n+h<0=6h$ki?sKdFaN$jMw;8K2V#~BbmU7dk}gZI;=7i^6D
z9wvq+bM&dT2Jh75ue3<(&m{S7ge7cjUqnd`)Koj@_ve
z>*7=2YM%L{h^EHIc7^i35N9p&L;oLI-E2!o3(=r#MyqN9>-6GjC+bXKgH&R
znZ1tRn|oU{hMg8xc={qbF23)|oewbc9G3}>^fT1td|iW~k^4?IU|Em?rrv8QUrI()PDS8LAgs9m*CfByR+UX{Jgs<_iMUV!((s!Z
zZ@0X&iz<8%`@jr#X)1GxWza#YpaK3HA%yxO=gNuX?2vLz7ET3?X_#eBe0QOd*u=Sk
zDwpZ2-4|YE&cmkT_i_o3*+!WhP4|~9BX5zn<^#EsbbHcnk*NtX8#*z7SG`^Ts6&eH
zxffYtUhN{9O$UhF>e>FPBrKIy*xFcq>U2Vk0&ipgE+&2{uP0Q|wl<6&j
zjymo4=ox=Ku}FF+18;)L4fl4+Pgy1t@b`TOEET}`@^s$14mEZCNP-R;p*H{RkGhVL
z_fXa0KfTV6u31RRx#Q};6_C!!=0Ym(m^CTP6D~+sfTCwLFP2@A3z$Dh^K;SKZ6KmL
zZs9=)Pn_@Os^_*}91a0MB*Y-{kz;6}MafdV)|bp{6M-|%3Q%YPjwoY~%un;!plh+h
zJq^hW)l(>wYx`7vT%J#~_6pB+4Uf8b;L9=`eeYm?>C%>;TiWTT`^I$2dUteQ8W`lX
zoEOY#8Q{$14|*@VB7EaU@kX^_{p2?xInwXH;W?ZIW1F%dJe}{QWh`)I0e51xb`n;_
zcDqDuidP|fQe^T;Clp?Lulv_Gs9JQ&O^F
zG@QRqx=N^Bf3|FEt0vplJR!eJSQ6BZv!9>PU6s~I^NmS?vBF!o>Vq(3GXH!<#AL>#Jb1bxV^Z|zQotrA^F((HEewAS;v%kQd;?i5!YH0
z>owVxnkHahSZ_>1S4y>Rtm#bZd;aatL6rTNEdzA-!<0zOg<-^0lc{F4d|86UzI<=o
z#^#c$1Em(&^fUe)fLbDUl-(n5(Cu%_-!K|Pt
z(%TER20=RTPwzMDmw1yemM=~wqc4o+M|b9dTE?5aQ7cZfZ-=bZ7L{q9=S5p^v+gg5cLw_*{nkRE)C1(zQl>z?GmbAVFp-*aCTLZZl+=
zB${<@GtWnMx-{F{YlLiu^23$OS2pH0W&??z+Hb6^U9|hr<4}}4ZcwYX#6veM7`pUF
zV#5`}G%Dt~cim<4JQiAzD41TfGdpp3u12oOI_BtB^0ZK`e&acCq@bZr%6ZM?>~9rN
z7ziU4NNp}`H|UO9O`RP6Z*)5u`
z*w^F{BQ6|%HL;5WJrOX5L5Kqn=kI8`mY?3W{IpvG7h7h7M=*R|SCUA!&>J%{ZlE-l5^$ME#M*DI6AMY+*7aiwzzSI~hhgB<
zq)16nWuum+VMm7naH61HX}C5T3?O>ch2gg@g_lPnLB)CU;=0-XZLGB;FVbaH9CwqT
z=){CB(~_Kkl3PZoIydW(Eq6D6{Am>7YHfTP3U84iEEWOS;vLX@VFs7D-u1pG%<)$szXd?
z1d(BhT^Gwnxr_TlL|xSgHZuF*?!AS_Tur;_F~;FBX&ViWnKlL2?iZMmu2-&laH)(e2la
z0uIXh0QV$v&S9j08rP=RLyOm|7aICpeeG(Ptt@cc6S?;JS_2=4fnVb2mQP*_nBRUZ
zlgnUM1K+6(A-w^o|XUMx|Y>1+C>6ni6qOh#v!#iMlSkqmQC*B8A6RUCId8M=^}=ztvc=yRtUw
za$w~*?}w%dg2ZqT0i`V+GJp_?Oqpw>gdPnb4fA*Ir`cOLed--hP$-nCA1gRf=HFJu
zzy2y)u#(%svR%6&;eE`F*LeA|4H9-Gu@aI<^s3X21430~WN`$gODm|x^}}9hQ8R-;
zK&VX6Ix!Qa(O?0Xkk?d&qSe}}th-IQqS-Pzj>*9}_cMB$UwJ*aw(-o;ID9J2y6*@u%)uRp{B<3xU0%PXwkaQ=nfa*qucc&nkR(S~$S
z)BK&`LjGX2fNh+Fi<_|7;6Sxw#aw>TEGB)hY8ihcu4w99>CQqsX6r%LURK=cJcs_L
zmXWUh2}kAAvtCp{fqmY^WpbWjM1YKjr|e4AsEn^-dxa)S>#$e=`J(OYO1}}Zv0*?>
z3MpdN+4G*Q$qTElOOC4&64akfI(yJOD?O~tFTMY)M(9n!XGZ<6BT37(c8QNu9t^G7
zno7u7e%(*H*riiJfiC57^^!6KB*zUjQ%M;b`eAc(`mf00PFXmEu*9ag#ZiG*Uy`$I
zWtvo`#2nqSfHHdG_B%HP3Gr;=fv|1kY2T#|KQ(`wn)9w-isX)=9H#_fOJ{`8?|5D(
zG>~myJ%Ko9yakv$PEK@XB(ToH!U1e@h49NpiPy1)ca^a
zOHeKW@oKO55e(jL{o_$zguzl%JvM!-L_dL8@5`fhczU`~#`&z`<;)`vr+(=={iwHZ
z12b2zF1z)!AUZrYJOdp~7CM>299DsVg%3j|M5oy5MzTT*3AhT>xiBKDQnm|)ngu4S
zHWB8ihY8Ws)%z|>?4IK-`}M!4@q%{kqPb%&-ddpQPD+z&&&!SjIYUd*kFI&JuoyKY
zj70YFj3iMj@(#xV1F5ruC0N?e7~zUe3FMh~Py@lbV)9F!8Q|mc&?irrFZP>1YyMg^
z5pHOQr!Ux94E5rotkac9^NeFjm^Ub$9gVOf3cGmda|-&fRtSaVd@Kp)lx&9*fzHa?N0g#Hr8yuA-8O$>avD5?CkXsH|h=IK^X115PzE78&(K`8B#>#eWJCwi^P
z1niith&Hfsh&OLuKd~tm(QIu$?<*hy0w&g8j4Mo0x6zhci;Bv{`8a+$EGp)?epe2;
zBc++P7Ag^l)_~-_&Eye4x!a;a7YMDe03wh^0{Pe+fs$!rU^Y~0l@9W7V1lc@7G_yv
zlo1Ha>+%BaZfKH8XU2!MP%1>3l)C*Fz)({jkNBBn;akMqmDMFn4o6O5ob6G|;xL}>
z;RYPeXHFYiqHQ!@HnBbGneqC3X#yENlMP0D=8eN_XO4iAm8u6kyFTL0DcciF&hV>l
zmS6_|n_Y-U3q=gX4lO@GTeTJ0BjA*UOnq#<-3}1|V6uHfHHfqWojzEW~-h9teF5N%?eRjfCtP5xx?xGMR>@V8KIGBvw$kg_cV6
zHh%iP%Nt087^wU_W=^flqiQ;tB>w0XndL5E8wnJ^2I|i??apkoq{wii_AhwMuds^k
zwMP@*Zp|2u;Uqp0&F!Mgrh1P_E-ZAfS_7dQI<3~#k>0O34;q~HG=R%Gav_9ZSl)%acFQcGG8hOXJ*J4xVtP@t{OhbnS$2oEG3>AHVHyp7zUUrjOC0VU!aqNWEIpR
zw68m6H5dfQQL@DX%xtFU&nhc6=*;{wbfIVBHjoJd5pJbIZbtDfBKky%L3vFNYT4NINPeRCu3-evqU%LUOib#HOjRt>-E
z!mFe)LUmh)+DYO
zh1${daJW>4AV-;rDtH6dW75@zYXHkn&s~D?f>qmP^kx8&TQfo~<9xksH(N=50jDeO
zT)uh4f$raU+~DI&TA69>M+F1+wU$_8(3}=R2I>SzjNX!fSKH1NV&Qnbc~cXbJajYw
z4fejL&_9+RKEH^99_v(t`D<^klA0`KY||T^pP%3Jeo-%0IlwS8LtkX1?irf0)VUXx
zP{zn&!o@BzNtuP+h3GQH9Q|&K);>+v@r|ba$+bRy`qgJ@}SN
zAmC+bwW_LV6}rP8$|@%hs;a+l>_`DLTasOxg=|gRObPYCUd=_g)~xCg6oM?G9s1tq
zIV=S)N&wDdNoNH8gnQ);Q{i!iC&i(O4HoIE$$9z2FTQ}J0uj&7&eboW!l(k1*rgDT
ztl^K8CT726qU_q!i&VSb;&U;s&^i{#WnL^_3v|YDe%UC+x|3c8;G0L)oh
zKudlr5bp?yH|=LWZAJ8}J*UT~l}j`W4i{p}1lxon*EE^SW2i%zh}>sU$eP&evU~AR
zbXgqvBRwg29pTqFJ7zs4ms?FCg^f$jn#U~#I4GVJu;sSf;l6K7LICO|C>i-!WMm+bzLBk1{4AT2p3w-3b&Jg
z8$MOM&%1Lsh_Y$cacVUO82$cEZupFp0o(1QG+Ay@zh+mR=R-qU1|A8O$|o~0S4l*U
z`%Vj;+D<$8mEj`Wlhsb6x=okNi@Ad4mxY{3m8QgcwoJR)rlWH5fNUn2q7qio;^A;D=+epRsFic(GJ*Tg;hCfM_X%WAB)+k;47`X
zkDX-`wj;dYQZd_~Q!P=GZsR5x`XSiiY1ymLuzWvOj@QDWJ#WS4ZAV9Ylj
zPln^SiXq$mK2Kh65$Mho5@uX%kP0$hsI@kLL-U)Pq`kLE_Kpi}J{x0zv#2={P#{Z?
z{L8Cmb+OeOWEH|#)SMdCHRjPIQIEu{kAxO^i?jZioYFaYh+5pUMQlXt>b
z)n@K->(d>dUJWm5rB3M&q`a@F+8>tcSc8ETbSv!{c+n%NB`qrvE-6xh#_J4(^!tt@
z1BH=b%ddvRx07?dC?VNyCdd!#@vNm!Cq6LGj0`sWsrhv_OpjALr@3I_6JiWo=4~9G
ztpiygDli~|a9aP7l>1X(^*%*lkB!T??o?tBmQAMLyCyIC23TxV%E>LE$VDJf?nZP*
zUIdGVYb2Y95z{Cu0O{Zv8F?*2x5Xu51JVd9dL*?pzglAIqP_QAAA9yg{ABudK{&&=U{s`Fs<~bRY@74@NEsA
z-iCh2#s!q}oz?9I^HR_E^;zm#j&m&vi@UL#*FCfMi%Gp{dk=*ulMGy`SC|8%FE-|@
z?@DH=;D?2S*quuII8rADRAmg+;V$l;Ff=lWXC2)yUm{dUL2Wa~BR
zi=WJ5l4KjE@Ep9C;1C7LY!Ip&(n_-(zUwloCKXO_ROxUMOp{TME5UM3xRavmr`d41
z$Gmp}z{If{Ro{=L7V%SYzOc2B`hygk3IXPp{gi}pyk}49f8pxcV|0in&hOZ
z9kr5x>z3kv3Le-V_Zcyn`+(gHOmfLoJIx3tZm~hxbP)qMp&*q18_Dg(Bek&^@TD8R
z3F+0q!c_hEy`{BSRz~BSd+Y4?T
z?rdsC-UiSW?#UTx92V=AY0o6<2nImjh9wdXx6Xcz+!Pkttcq+4rbI*zBzJ6`Q3ddE
z7u^nejI&A^8g9sVJ3Ok_B(u8G!z{Qd7yU3eYkMpi
zzrofYx^%&$TwS{1?>}ZXzpRDo`
z3e*slNPpdfyssa(3P5}8zb7Gt<$ZwxN@?{*JBK|A2dmNVH=*yeVem1McwLWinM(ei
zQozY#B6QzOt{*p5q24=@FXmZ^G%d0>m-1@_+}}60SZP1*hpD6#5DJ(_lxnfgKCufx
zTpf@88@YgXC?FLpQ5YqjnGf7$3&11fuDp$NMbr#kKO8q+dv9q<9U=pbkX<1SLBa#t
zD2>Y7V~3HI2%9J)Cl}m>K3XJp(@SmnE5dHn#{?-|<@p#uszJgJqqIJ12k>4QQ%hT6
z8Pnh_U4ge(UPSYfe7*9keAX3~MS6T3t9#5KvP)%&ck(k9LSS}xKk}^vuIr9dmVI)?
z7c%8*$1_K#M|sgi&YsFx*`k!Rc-;~o!1mO9l-Dy!Tn~iUky`q3du@MQ`dhoNYJ&vK
zps?G|$l9M(gq7inC9R&tT3uY+!3>(r0(6Y=ZrR*-kUpCy_MWv?>=9N`C11=
zvgb<85A-Z5G*)u<9y^J4h;pek10~i6t>12>1QnD(YSIje@=f3-mX%7@THTRB77Xv=
zBQpj6yq~=Qneb}0K`Ss6RJOGgj;0i}3!6nhUoFkyvmMs}66v?ebUkD;r4C^_!`DuH
zG)LN-l=?OH(gn)Os}1J{
zd=E)EZ(QPdMte{KT<$A0RIwl~v)jx(UX_&*opQ=n$fRS9G9kikYEKcrpG>#Q*`lKbweP|&-P1Pq=uSrYv=Q}{Vybq7X~hx
zcs0`n3O?o)t=ha#&Gr|rivM%@GsKiB^C5KrBlGkTdiep=H}E)%I7h+LrEyhA<^4Dn
zx%?nIHCC@(aMOE_1o=P$1EhM6p#&&)f5I6yHdnRl9{bq4yll@13?}PH`P5q#hlY;T
zvNDH>$>bls#eqVaaf=%d{J+(ln_
z_{-^IP{9EK@bq?KH;+H%x#|$gG9WKi(Q*u}T6vhlxv
zhy~3oBT$cJ3?<`4oW#bq7l*L=4~h3(Ml3TjOgy)m;ho|EtLm63HeXnxNh$=h;h$2S
zP^#Ieicsa)epO*4g}Ftb&gQ(dYa&QHZ7sL@6UZwaw_oOUNlH4+23M3y)mjf)R(5C*
zghuhM1DdO$HOR~azP52kRL$>PwG4x9acaQQXx!|BQAvjh5-Mc=E&EV@bV5Nkn!u4D
z=3OE#(IPhAzra59HfAxG{fLu6aLbR7blaDfpv~Ju_AmW$M>O*&-6LiW4ny+^cvRJ%
z;1W-1tICD=jy~cEi1+YwDN`J2!8Lx$pPS$P+dVG1gq&yXaPjI|C&IPsY7qNehrtif
z)nju4Gu_3Wa7h`%&0`-6(eX3@De+TwoO+3fjK``J+Ke$;r`XiNAPc1IH9dfgt)yjybyi?=SCD7YaxeQa
z{VF00;cG-K*XMheMg;FnWYF>89wf+hoXK;9*T$B(v(Ke!T#^5K;4ZjC>-7DqXn}V~
zFGU)lihU1X5Gg{QlMTut{f=VrUx}B3KI4|Xbzgx@)$)<4;@X1Lll)Cp4S0FYOP+=4
zL1=X*JT}N*TWZ^Kvdmm4iTW2Ano_{~WpAk3$wl7ux02AkLs(@a$L)JzSC<}i5Q7#*
z^7kL+Mgr|xlr}aJ{H=O2WSOo-F7It$k7&8OWE`R;SwhTE6h7sxkn)zBdfVM%R5d+R
zb9Au!D@A@tHO`r_vvm!;~0)0h8(=w#3CuP60zsA)Z}E67-lsdb{v#+kz6
zt%h~8%eEKCv80oTCu%9X#XbfbnqMTOQRvrYr#bPt>epj9lL#gKt8JXaD2mONqTssb
zg$IL4l%$+~^vwJd0I-iUj@o$}i=jak
zQ04OW9i#8+px>%XcbVCIB%-!cCg0}kL@Wpq8D>7^Cs{}sU!|EX0m2O_rb41p#p7!e
zi>teorzB@K5G6k$5MrPZ|4O;yQz~CyTGVQ>dX{F^mWKhzP7~GQRXCoxkRtIM=$3fC
zsB6CtNipN=BA+mq_R-kJr5P@Mz-@+6l56^5?aI>gu(Y|LfzEb<(n)on^gGuJb@%k{xLAn!fmA&k+m5
z{&8GutDz`M>BoJp{OiRNOZ!XL319!>3=W7H)T0i8+xO@RGD+So1Zx|TgCXD*b*#*}UB?d!gTKiAAhz7@QUQJ`T=~uz-vlqi
zCL44^`AiZhCU?SKQ$$oQ(yF?%qeOc!iT>V;@V65g?EHqE5@rg15+*)*4#@<&y>05u
zUM(^#;jQ2My`WDlg8}ij($#vM1*wZRH%+2tJ3Awrf!8;^cALHm@tNh5!|zbdH0Jth
zp4A5AF%0vn+8X|P<%xAdU-$GEd98@1l_vu!V#&I&^qS5%ZM8;G-E!EV^WV&PQ9C&0
zk4XvfkWeNJe($$&a$D?}p}EApK^)&c$CwJjzZYUI$ZWL8eP2{+Dg#zIJSwJ}v|cG*
zYtICKWA0*cQ#TH{i&B$(0dRWuS@rwMlkSRtVLd;CtHnz%q6{>1b
z0@H#ef5l}Xs?p}{d
zzfrf3u1q0U7=pcgxir}d)YV`vIstgh7f&b!mouIp5Zy%YG|r)IRgV(B1C2rzug}{2
zG3qmYaPNM4-ahLfMzhB#Za&tusMk;$4M+)es|y>KzCpu%=N=z-4=z8r%vIm15XUDc
z=mU<)a~g%O+L$brfP455s{0|TYU~&DKffH18ceXgCzh?A1S2tj{nz%%>Frjkc(9hx5Y@Sv4i?X=J;uWDO{o
zp2j6+mQRTImZDKj)HCGSK*FnrbMcG|S{?NwUu$j?dYXqZ^`jL*ZKTGS+xTc}ufZHO^ZzK$MCw;dRA~aud>*TZ9?Fx3`
zpBsxX&K7v)z&&04!p?Pd?}a;YqZ_-L-J4o_M{-W!1qL`=`C
zY^9!5M<*whWYx40qPbf$14tW{i%nprt@|sbJx>s-=B0KLb%W4Gb7jQG?i2&{lwfYp
zZUd*4e2@y`y$x`{W2%%`h3G-859;LpTC=N9;46SD7dDJ)r?OV8%)a4rXSIYSbJ
zTj$>5n%WE7Sr^HID5Ie-({CBq_stuQq>*rJe;X`Ltp|yTt(9)5t^%v^{PAXpvt)cz
z4>KL-Az*G>b-_!6$$l=BsraxwaO@KzlbyC5dw9$`JTp<6rQ82xO|Wj173`Vy`bd@P
z`;M~Y@BYOJnY>XsWA`N0blWIaMnz7F3*7s3iN)kq`GanU;Z``Y$+sHO+2UvCXSzz^
zgITxdLu_OvFY!W|6+yOEHmeV*IAwK37_VeFx%ibd<@QRn<>Z6zr@_k16B(WJWRTx*
zYqxKk#v}z?ic3%^SqZ*qUgJO29%R6&Gsgo=mUK3EG#WcM&^KyT+x?YjbN
z9SR*z@3Y;(bwGB(+b+s+HZK)T{g^Nb=FkuCpYIvo+DW|uZ@)6wiiAq%^leV2lV@6v
z#`tjT?H}TZQ>Q%U4C&fka^%2v6ZbT3^}br=mLwSclvfgtom2{`p7sW2B3h0DZGfM_eh{l2^N^6-CxAk&2
z{gk2S<}a^wmeQXjOwAUIGb2xu9h6&@M5KzT7$pgZx`Q}Zr36n~ebF2i+hmezn`s;?
zs4KtPqWDcCh`YRy^=5Q*(rxo#s?R%BIh>7L9`PJANjKGL2!grW$PK;Q+8*W360L!C
znz@O(X{&6=zxPOYcHzq9PD@G$C<=DYIQls);14|RUQ3LoM5JgUk~XWlY|mW2eA$+0Lz3TarPs+oE9$z}YwwJZ
zywFS)i8dY`t^Ot0jvUNEzt}s&{V};++L%W(;j{0#r~Jt>YON?0z?yE)tQLbKqOu~}
z9qI&v(`RNbjg3t((0yU(V;P%C-?FePDYJ~+_D&n~D%=4#7ES0N;>uW?TPknT3Ud^d
zd!EXDpH1kTA_yxL*K6c_-=qqQR?dQLu{Vp|YIiM1O;pnIlzvo^{fIzxPB^D~pbmv3
zWYY?xwq;9ndEY;K^_E{yvjBR1g(oq}MJsu&v9)k;s{P|tF=h&I99p+(jXx~W1@z-(
zzL|jWtg`9%Nfvl7JvgsW^GtX60N*uMtt=5tTF`j5`GDZ^{o4~fjYF+IHO2hB=a6BI_Nq(=#+}#=kx=%&fUM?M=64slNck!%Zns6DXLb^EpzENA5L(=?*TuDbiw?HnX9~Rucl9*@$3VQ)Q^u;GkNFrh_|6
z*_Modm_T{NOYwq4Wy!GJYz
zXu6VNY9ihG3)C(q37O0VGjOWWLHMd~R{4)y
z@FX;@qu6g#S}U{2u7qxUcD;`Yr+h`RC&nscZS@7F(dvY^S^W4ED*;bEqNjo7sy?Ke
z|9iPq*Tjp-f)YU`4TtF`+nkQxNrR>n3|2O?jVC)Bt@Imn(QMVsoo^`2r4mOI>`y8T
zwb6xfGRinefQZ7dAKmZz<`1R<&V?6jL%iavkN3vc_O<66cFj}B#M1Fzc(N9O+CgavgVqmDsdhsl<
z$B8ag$`619<=ODNPa-b3
zyjnRB(e7doc9>w{I%
zdyCgA!HIlas!Q+>Z3dt!!|ySH!PY)px*q(gkM(bJ4c7QZX6jwhEep$>z12u%8LV?b
z)MUJEr8l4J^J0D5&H%mGPW@FJAD>PIU1Ivix=HXo;HZxSnaPgoTSP^eNqz48$-&R`
zy_3SkD>4DmK*x|3HP;8%mM;yNtW7C}7;)15Sck&u_uU{qyy|GE@Q-G6U
zLkHYwdz>qmCKbkFE_0qmA}THVeb!uOdG_0#D4yteIlk7HOR%{mBqqURZO5X-pT8z68=C>CK-wmO`OS3Z9ALdy^KU86Zf*ld8EA#CPPXDq
zUQ0hye%~%&IT?fO0Ibh4UHAb7xxfiuGDR!>hOA
zo~x~MC;JLarY-HVRj0jJ91Xj^4?o0{BaU9U>ea=6-zaEfK=fm`DuQh-r)lH4P}K67R#Q%r=-8mF2qko0-N2Y=w2a>1mFivk>jwpM~bn#QS=NE`Q(
z$TDP0Wt(QtWN+~gSxMzii6|aN7W%Tu7U_7WFfN;7V*ag^Xcxp;z0CqO2Ofvfvt=KK
zaa9+ECw=%4g5K@Y4HJGKD#OWlWnCX{k%ij(}vC~b#JE~(Rmiq)NkDd))S?rWggNlvTD&6Q>0?&MDns%)05k{
zR=dSS&LFcmF-7#}!pyhhH*
zF;|RTExkKmy}mg<(`C@yI(}&onbdK^CPEuDsLXjU9+CS7;c~aDJHJHKxOSYV3jv=G
z{60of8*Qi0!Y+PS6vt&8nd=m0V~X@I?phjC??OOJY__$!%2RP3meqfU->``(m&&>}
z0394`HtpgJ-i*|y9n2_(ty{fX$;;~-pb*cm&-L58H|lokDqe?cub3QaK}NWx5JSg<
zwJm$nvK+$x?tJ@ux&rz>y99rNZ?D2{*=58zcj_B%$CnWfbR>Q+)SMzBVOE;1e?oh&
zlJA4W(%hmRQ!tiNh}w4D?Lzet=quTfyXn3HT{^+-Jm9ysD-vxTyy6;;QY9C~G}uJr
zPhb6>_Pxv4G_QNiIISx)-bP{gMx%4$dOnC=^C9EgAX3fjxUVJ`#4F3T2J1(sTJDu?
zD(jusWLdH@qE|dqP(`A(-gRlkyIkxHEv?BzFVnR2Q_UgK58A#Cs~aQ%Vdc|woT_Vj
z*T=#hPhA)QZdjL;vQVf$%PW*9qh(@Psz$P(&GY97=P)$&miN@&w@hh=
z;Sp#)GUd9o$5?=*Ts9s56tuj2BwC~@qF&S}8aYaB+P;O~#VU4Z;8q!vNe*dz(mNLz
zeSNU$HhOBI>xl<8WNJ4(-08!0Ylhks7UtGm(~WW+B_F;sw;p_(Fe-@4+cD?#H%R&(`6Aq(mSdUgU;ac41A*)`lZ?ns~3T6M+dfa<+4!J(KoC}AEA(k
zYAc=5H{V5>T?b+G@hI
zPZrFwG^>eZzWDumq1M#%lIW$ei+1twGmIDYHIQiq{dq9nkyJv(cdkw^J&Nax8%?^t
zWtf_@FDICl8^Toc4C7i_ZoGWyNne_pclSlAiq8#9Od=9l;DTQ1bXJ?)+F;yg!IB4)
zu=n8Sn$$!@VlOdSh{j7gN>#+8x;LGp;NyHzaF$1=y?JfHB>H_e>YX~$mXGxk79;Ws
z0>Xg$!jD#var5L&A2qLLImf8o0KsL6GiV1uH+?l#cdDY
z&ay$%HvO^~=Dg|Kcbfg?;=C>>mDu$T^otB+Oxrkz==+n!^VUeT4P9oJ-wC|f->pz+
zX6a5DzQAsCbBwYFdNF{xbubXDgaJ(I&8w#;C^Fezo0=`g#PWLDPgBN&b$1{JTvUOd
zSi8Iuv`kz(UxJlF9^~I5pv-WTnj1_n!|rsA>4(r>dqz0VQDmDt?pyv-HjG>HXWBhO3XnL4|2l5+vqXQj7#
zpiV+MC!~zLdn(T$y5mQszcJA>{l@Wdqvoq7pCjhB!b%1^OwLAk83rPKXJ$J=pW0Oy
z3;Y~w%{*(BR
zy{fbGtM8MeCVewkAjP5*
z%?`-