Merge pull request #7 from Nekiiinkognito/feature/loginRegisterPage

december 2024 version
This commit is contained in:
Maxim 2024-12-15 14:31:32 +03:00 committed by GitHub
commit bbf33b8c7e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
85 changed files with 3700 additions and 162 deletions

View File

@ -1,5 +1,7 @@
{
"cSpell.words": [
"godotenv"
"downvotes",
"godotenv",
"upvotes"
]
}

524
enshi/package-lock.json generated
View File

@ -8,18 +8,24 @@
"name": "enshi",
"version": "0.0.0",
"dependencies": {
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-form": "^0.1.0",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-navigation-menu": "^1.2.1",
"@radix-ui/react-scroll-area": "^1.2.1",
"@radix-ui/react-toast": "^1.2.2",
"@radix-ui/react-tooltip": "^1.1.2",
"@radix-ui/themes": "^3.1.3",
"@tanstack/react-query": "^5.55.0",
"@tanstack/react-query-devtools": "^5.61.0",
"axios": "^1.7.7",
"html-react-parser": "^5.1.16",
"i18n": "^0.15.1",
"i18next": "^23.14.0",
"i18next-browser-languagedetector": "^8.0.0",
"immer": "^10.1.1",
"jotai": "^2.9.3",
"jotai-immer": "^0.4.1",
"primereact": "^10.8.2",
"quill": "^2.0.2",
"react": "^18.3.1",
@ -1227,6 +1233,42 @@
}
}
},
"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-dialog": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.1.tgz",
"integrity": "sha512-zysS+iU4YP3STKNS6USvFVqI4qqx8EpiwmT5TuCApVEBca+eRCbONi4EgzfNSuVnOXvC5UPHHMjs8RXO6DH9Bg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.0",
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-context": "1.1.0",
"@radix-ui/react-dismissable-layer": "1.1.0",
"@radix-ui/react-focus-guards": "1.1.0",
"@radix-ui/react-focus-scope": "1.1.0",
"@radix-ui/react-id": "1.1.0",
"@radix-ui/react-portal": "1.1.1",
"@radix-ui/react-presence": "1.1.0",
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-slot": "1.1.0",
"@radix-ui/react-use-controllable-state": "1.1.0",
"aria-hidden": "^1.1.1",
"react-remove-scroll": "2.5.7"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-arrow": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.0.tgz",
@ -1414,25 +1456,25 @@
}
},
"node_modules/@radix-ui/react-dialog": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.1.tgz",
"integrity": "sha512-zysS+iU4YP3STKNS6USvFVqI4qqx8EpiwmT5TuCApVEBca+eRCbONi4EgzfNSuVnOXvC5UPHHMjs8RXO6DH9Bg==",
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.2.tgz",
"integrity": "sha512-Yj4dZtqa2o+kG61fzB0H2qUvmwBA2oyQroGLyNtBj1beo1khoQ3q1a2AO8rrQYjd8256CO9+N8L9tvsS+bnIyA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.0",
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-context": "1.1.0",
"@radix-ui/react-dismissable-layer": "1.1.0",
"@radix-ui/react-focus-guards": "1.1.0",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-dismissable-layer": "1.1.1",
"@radix-ui/react-focus-guards": "1.1.1",
"@radix-ui/react-focus-scope": "1.1.0",
"@radix-ui/react-id": "1.1.0",
"@radix-ui/react-portal": "1.1.1",
"@radix-ui/react-presence": "1.1.0",
"@radix-ui/react-portal": "1.1.2",
"@radix-ui/react-presence": "1.1.1",
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-slot": "1.1.0",
"@radix-ui/react-use-controllable-state": "1.1.0",
"aria-hidden": "^1.1.1",
"react-remove-scroll": "2.5.7"
"react-remove-scroll": "2.6.0"
},
"peerDependencies": {
"@types/react": "*",
@ -1449,6 +1491,158 @@
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-context": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz",
"integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.1.tgz",
"integrity": "sha512-QSxg29lfr/xcev6kSz7MAlmDnzbP1eI/Dwn3Tp1ip0KT5CUELsxkekFEMVBEoykI3oV39hKT4TKZzBNMbcTZYQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.0",
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-use-callback-ref": "1.1.0",
"@radix-ui/react-use-escape-keydown": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-guards": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz",
"integrity": "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-portal": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.2.tgz",
"integrity": "sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-use-layout-effect": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-presence": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.1.tgz",
"integrity": "sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-use-layout-effect": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/react-remove-scroll": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.0.tgz",
"integrity": "sha512-I2U4JVEsQenxDAKaVa3VZ/JeJZe0/2DxPWL8Tj8yLKctQJQiZM52pn/GWFpSp8dftjM3pSAHVJZscAnC/y+ySQ==",
"license": "MIT",
"dependencies": {
"react-remove-scroll-bar": "^2.3.6",
"react-style-singleton": "^2.2.1",
"tslib": "^2.1.0",
"use-callback-ref": "^1.3.0",
"use-sidecar": "^1.1.2"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/react-remove-scroll-bar": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.6.tgz",
"integrity": "sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g==",
"license": "MIT",
"dependencies": {
"react-style-singleton": "^2.2.1",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-direction": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz",
@ -1619,6 +1813,15 @@
}
}
},
"node_modules/@radix-ui/react-icons": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.3.2.tgz",
"integrity": "sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g==",
"license": "MIT",
"peerDependencies": {
"react": "^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/@radix-ui/react-id": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz",
@ -2030,17 +2233,17 @@
}
},
"node_modules/@radix-ui/react-scroll-area": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.1.0.tgz",
"integrity": "sha512-9ArIZ9HWhsrfqS765h+GZuLoxaRHD/j0ZWOWilsCvYTpYJp8XwCqNG7Dt9Nu/TItKOdgLGkOPCodQvDc+UMwYg==",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.1.tgz",
"integrity": "sha512-FnM1fHfCtEZ1JkyfH/1oMiTcFBQvHKl4vD9WnpwkLgtF+UmnXMCad6ECPTaAjcDjam+ndOEJWgHyKDGNteWSHw==",
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.0",
"@radix-ui/primitive": "1.1.0",
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-context": "1.1.0",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-direction": "1.1.0",
"@radix-ui/react-presence": "1.1.0",
"@radix-ui/react-presence": "1.1.1",
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-use-callback-ref": "1.1.0",
"@radix-ui/react-use-layout-effect": "1.1.0"
@ -2060,6 +2263,45 @@
}
}
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-context": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz",
"integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-presence": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.1.tgz",
"integrity": "sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-use-layout-effect": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-select": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.1.tgz",
@ -2213,6 +2455,130 @@
}
}
},
"node_modules/@radix-ui/react-toast": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.2.tgz",
"integrity": "sha512-Z6pqSzmAP/bFJoqMAston4eSNa+ud44NSZTiZUmUen+IOZ5nBY8kzuU5WDBVyFXPtcW6yUalOHsxM/BP6Sv8ww==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.0",
"@radix-ui/react-collection": "1.1.0",
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-dismissable-layer": "1.1.1",
"@radix-ui/react-portal": "1.1.2",
"@radix-ui/react-presence": "1.1.1",
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-use-callback-ref": "1.1.0",
"@radix-ui/react-use-controllable-state": "1.1.0",
"@radix-ui/react-use-layout-effect": "1.1.0",
"@radix-ui/react-visually-hidden": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-context": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz",
"integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.1.tgz",
"integrity": "sha512-QSxg29lfr/xcev6kSz7MAlmDnzbP1eI/Dwn3Tp1ip0KT5CUELsxkekFEMVBEoykI3oV39hKT4TKZzBNMbcTZYQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.0",
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-use-callback-ref": "1.1.0",
"@radix-ui/react-use-escape-keydown": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-portal": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.2.tgz",
"integrity": "sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-use-layout-effect": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-presence": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.1.tgz",
"integrity": "sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-use-layout-effect": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toggle": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.0.tgz",
@ -2504,6 +2870,42 @@
}
}
},
"node_modules/@radix-ui/themes/node_modules/@radix-ui/react-dialog": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.1.tgz",
"integrity": "sha512-zysS+iU4YP3STKNS6USvFVqI4qqx8EpiwmT5TuCApVEBca+eRCbONi4EgzfNSuVnOXvC5UPHHMjs8RXO6DH9Bg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.0",
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-context": "1.1.0",
"@radix-ui/react-dismissable-layer": "1.1.0",
"@radix-ui/react-focus-guards": "1.1.0",
"@radix-ui/react-focus-scope": "1.1.0",
"@radix-ui/react-id": "1.1.0",
"@radix-ui/react-portal": "1.1.1",
"@radix-ui/react-presence": "1.1.0",
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-slot": "1.1.0",
"@radix-ui/react-use-controllable-state": "1.1.0",
"aria-hidden": "^1.1.1",
"react-remove-scroll": "2.5.7"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/themes/node_modules/@radix-ui/react-navigation-menu": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.0.tgz",
@ -2540,6 +2942,37 @@
}
}
},
"node_modules/@radix-ui/themes/node_modules/@radix-ui/react-scroll-area": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.1.0.tgz",
"integrity": "sha512-9ArIZ9HWhsrfqS765h+GZuLoxaRHD/j0ZWOWilsCvYTpYJp8XwCqNG7Dt9Nu/TItKOdgLGkOPCodQvDc+UMwYg==",
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.0",
"@radix-ui/primitive": "1.1.0",
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-context": "1.1.0",
"@radix-ui/react-direction": "1.1.0",
"@radix-ui/react-presence": "1.1.0",
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-use-callback-ref": "1.1.0",
"@radix-ui/react-use-layout-effect": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/themes/node_modules/@radix-ui/react-tooltip": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.1.tgz",
@ -2808,9 +3241,19 @@
]
},
"node_modules/@tanstack/query-core": {
"version": "5.54.1",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.54.1.tgz",
"integrity": "sha512-hKS+WRpT5zBFip21pB6Jx1C0hranWQrbv5EJ7qPoiV5MYI3C8rTCqWC9DdBseiPT1JgQWh8Y55YthuYZNiw3Xw==",
"version": "5.60.6",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.60.6.tgz",
"integrity": "sha512-tI+k0KyCo1EBJ54vxK1kY24LWj673ujTydCZmzEZKAew4NqZzTaVQJEuaG1qKj2M03kUHN46rchLRd+TxVq/zQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/query-devtools": {
"version": "5.59.20",
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.59.20.tgz",
"integrity": "sha512-vxhuQ+8VV4YWQSFxQLsuM+dnEKRY7VeRzpNabFXdhEwsBYLrjXlF1pM38A8WyKNLqZy8JjyRO8oP4Wd/oKHwuQ==",
"license": "MIT",
"funding": {
"type": "github",
@ -2818,12 +3261,12 @@
}
},
"node_modules/@tanstack/react-query": {
"version": "5.55.0",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.55.0.tgz",
"integrity": "sha512-2uYuxEbRQD8TORUiTUacEOwt1e8aoSqUOJFGY5TUrh6rQ3U85zrMS2wvbNhBhXGh6Vj69QDCP2yv8tIY7joo6Q==",
"version": "5.61.0",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.61.0.tgz",
"integrity": "sha512-SBzV27XAeCRBOQ8QcC94w2H1Md0+LI0gTWwc3qRJoaGuewKn5FNW4LSqwPFJZVEItfhMfGT7RpZuSFXjTi12pQ==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.54.1"
"@tanstack/query-core": "5.60.6"
},
"funding": {
"type": "github",
@ -2833,6 +3276,23 @@
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-query-devtools": {
"version": "5.61.0",
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.61.0.tgz",
"integrity": "sha512-hd3yXl+KV+OGQmAw946qHAFp6DygcXcYN+1ai9idYddx6uEQyCwYk3jyIBOQEUw9uzN5DOGJLBsgd/QcimDQsA==",
"license": "MIT",
"dependencies": {
"@tanstack/query-devtools": "5.59.20"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"@tanstack/react-query": "^5.61.0",
"react": "^18 || ^19"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@ -4818,6 +5278,16 @@
"node": ">= 4"
}
},
"node_modules/immer": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz",
"integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
@ -5043,6 +5513,16 @@
}
}
},
"node_modules/jotai-immer": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/jotai-immer/-/jotai-immer-0.4.1.tgz",
"integrity": "sha512-nQTt1HBKie/5OJDck1qLpV1PeBA6bjJLAczEYAx70PD8R4Mbu7gtexfBUCzJh6W6ecsOfwHksAYAesVth6SN9A==",
"license": "MIT",
"peerDependencies": {
"immer": ">=9.0.0",
"jotai": ">=2.0.0"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",

View File

@ -10,18 +10,24 @@
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-form": "^0.1.0",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-navigation-menu": "^1.2.1",
"@radix-ui/react-scroll-area": "^1.2.1",
"@radix-ui/react-toast": "^1.2.2",
"@radix-ui/react-tooltip": "^1.1.2",
"@radix-ui/themes": "^3.1.3",
"@tanstack/react-query": "^5.55.0",
"@tanstack/react-query-devtools": "^5.61.0",
"axios": "^1.7.7",
"html-react-parser": "^5.1.16",
"i18n": "^0.15.1",
"i18next": "^23.14.0",
"i18next-browser-languagedetector": "^8.0.0",
"immer": "^10.1.1",
"jotai": "^2.9.3",
"jotai-immer": "^0.4.1",
"primereact": "^10.8.2",
"quill": "^2.0.2",
"react": "^18.3.1",

View File

@ -0,0 +1,8 @@
export type GetRandomPostsRow = {
post_id: string;
// blog_id: number;
user_id: string;
title: string;
// created_at: Date;
}

View File

@ -1,4 +1,5 @@
export type TUser = {
username: string;
isAdmin: boolean;
id?: string | number;
}

11
enshi/src/@types/index.d.ts vendored Normal file
View File

@ -0,0 +1,11 @@
type TToast = {
title: string;
description?: string;
action?: React.Component;
};
type TExistingToast = TToast & {
id: number;
resetFunc: (arg0: boolean) => void;
open: boolean;
};

View File

@ -19,6 +19,10 @@
text-transform: uppercase;
}
* {
font-family: "Times New Roman";
}
/*!
* Quill Editor v1.3.6
* https://quilljs.com/

View File

@ -1,21 +1,26 @@
import "./App.css";
import "@radix-ui/themes/styles.css";
import { Theme, ThemePanel } from "@radix-ui/themes";
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import "@radix-ui/themes/styles.css";
import { QueryClientProvider } from "@tanstack/react-query";
import queryClient from "./api/QueryClient/QueryClient";
import { routes } from "./routes/routes";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import "axios";
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import queryClient from "./api/QueryClient/QueryClient";
import "./App.css";
import ToastProvider from "./Components/ToastProvider/ToastProvider";
import { routes } from "./routes/routes";
const router = createBrowserRouter(routes);
export default function App() {
return (
<Theme className="h-fit" accentColor="indigo" grayColor="slate">
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<ThemePanel />
</QueryClientProvider>
<ToastProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<ThemePanel />
<ReactQueryDevtools/>
</QueryClientProvider>
</ToastProvider>
</Theme>
);
}

View File

@ -1,4 +1,50 @@
import { atom } from "jotai";
import { TUser } from "../types/UserType";
import { atomWithStorage } from "jotai/utils";
import { TUser } from "../@types/UserType";
export const userAtom = atom<TUser>()
export const userAtom = atom<TUser>();
export const postCreationAtom = atom<string>();
export const postCreationTitleAtom = atom<string>();
type TPostData = {
title: string;
content: string;
};
export const storagePostAtom = atomWithStorage<TPostData>(
"draft-post",
{ title: "", content: "" },
{
getItem: (key) => sessionStorage.getItem(key) as any,
setItem: (key, value) => sessionStorage.setItem(key, value as any),
removeItem: (key) => sessionStorage.removeItem(key),
},
{ getOnInit: true }
);
export const toastAtom = atom<TExistingToast[]>([]);
export const setToastAtom = atom(null, (get, set, value: TToast) => {
let maxToastId = Math.max(...get(toastAtom).map((toast) => toast.id));
maxToastId = maxToastId >= 0 ? maxToastId : 1;
let atomValueWithNewToast = get(toastAtom);
atomValueWithNewToast = [
...atomValueWithNewToast,
{
id: maxToastId + 1,
resetFunc: (_) => {
let currentToasts = get(toastAtom);
let afterRemoval = currentToasts.filter(
(toast) => toast.id != maxToastId + 1
);
set(toastAtom, afterRemoval);
},
title: value.title,
action: value.action,
description: value.description,
open: true,
},
];
set(toastAtom, atomValueWithNewToast);
});

View File

@ -1,13 +1,146 @@
import { Container } from "@radix-ui/themes";
import React from "react";
import * as Dialog from "@radix-ui/react-dialog";
import { Cross2Icon } from "@radix-ui/react-icons";
import {
Box,
Button,
Container,
Flex,
Select,
Separator,
Text,
} from "@radix-ui/themes";
import { useQuery } from "@tanstack/react-query";
import { Interweave } from "interweave";
import { useAtomValue } from "jotai";
import { useParams } from "react-router-dom";
import { axiosLocalhost } from "../../api/axios/axios";
import { userAtom } from "../../AtomStore/AtomStore";
import ChangePostButton from "./ChangePostButton/ChangePostButton";
import SkeletonPostLoader from "./SkeletonLoader/SkeletonLoader";
import VoteButton, { DOWNVOTE, UPVOTE } from "./VoteButton/VoteButton";
import VoteCounter from "./VoteCounter/VoteCounter";
type TArticleViewer = {
htmlToParse?: string;
};
export default function ArticleViewer(props: TArticleViewer) {
let queryParams = useParams();
const user = useAtomValue(userAtom);
const { data, isPending } = useQuery({
queryKey: [`post_${queryParams["postId"]}`],
queryFn: async () => {
const response = await axiosLocalhost.get(
`posts/${queryParams["postId"]}`
);
return response.data;
},
gcTime: 0,
refetchOnMount: true,
});
if (isPending) return <SkeletonPostLoader />;
export default function ArticleViewer() {
return (
<>
<div className="ql-snow">
<Container className="mt-4 ql-editor">
</Container>
</div>
<Container size={"3"}>
<div className="ql-snow ql-editor">
<Container size={"2"} className="mt-4">
<Flex direction={"column"}>
<Text className="mb-2" as="div" size={"9"}>
{data.title}
</Text>
<Flex
gap={"3"}
className="items-center mt-4 mb-2 align-baseline"
>
<Flex gap={"1"}>
<VoteButton
vote={UPVOTE}
postId={queryParams["postId"] || ""}
/>
<VoteCounter
postId={queryParams["postId"] || ""}
/>
<VoteButton
vote={DOWNVOTE}
postId={queryParams["postId"] || ""}
/>
</Flex>
<Box hidden={data.user_id != user?.id}>
<ChangePostButton
postId={queryParams["postId"] || ""}
/>
</Box>
<Dialog.Root>
<Dialog.Trigger asChild>
<Button
variant="surface"
className="h-5"
>
Add to blog
</Button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-blackA6 data-[state=open]:animate-overlayShow" />
<Dialog.Content className="fixed left-1/2 top-1/2 max-h-[85vh] w-[90vw] max-w-[450px] -translate-x-1/2 -translate-y-1/2 rounded-md bg-white p-[25px] shadow-[hsl(206_22%_7%_/_35%)_0px_10px_38px_-10px,_hsl(206_22%_7%_/_20%)_0px_10px_20px_-15px] focus:outline-none data-[state=open]:animate-contentShow">
<Dialog.Title className="m-0 text-[17px] font-medium text-mauve12">
Add this post to blog
</Dialog.Title>
<Dialog.Description className="mb-5 mt-2.5 text-[15px] leading-normal text-mauve11">
<Flex>
<Text>
{`Add "${data.title}" to blog...`}
</Text>
<Select.Root defaultValue="apple">
<Select.Trigger />
<Select.Content>
<Select.Group>
<Select.Item value="orange">
This
</Select.Item>
<Select.Item value="apple">
This is
updated blog
</Select.Item>
<Select.Item value="grape">
This another
</Select.Item>
</Select.Group>
</Select.Content>
</Select.Root>
</Flex>
</Dialog.Description>
<div className="mt-[25px] flex justify-end">
<Dialog.Close asChild>
<Button>Confirm</Button>
</Dialog.Close>
</div>
<Dialog.Close asChild>
<button
className="absolute right-2.5 top-2.5 inline-flex size-[25px] appearance-none items-center justify-center rounded-full text-violet11 hover:bg-violet4 focus:shadow-[0_0_0_2px] focus:shadow-violet7 focus:outline-none"
aria-label="Close"
>
<Cross2Icon />
</button>
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
</Flex>
</Flex>
<Separator size={"4"} className="mb-2" />
<Interweave content={data.content} />
</Container>
</div>
</Container>
</>
);
}

View File

@ -0,0 +1,21 @@
import { Button } from "@radix-ui/themes";
import { useNavigate } from "react-router-dom";
type TChangePostButton = {
postId: number | string;
};
export default function ChangePostButton(props: TChangePostButton) {
const navigate = useNavigate();
return (
<Button
size={"1"}
className="h-5"
variant="surface"
onClick={() => navigate("/posts/change/" + props.postId)}
>
{"Change article"}
</Button>
);
}

View File

@ -0,0 +1,27 @@
import { Container, Skeleton, Text } from "@radix-ui/themes";
import {
headerLong,
headerShort,
pText,
} from "../../../constants/textForSkeleton";
export default function SkeletonPostLoader() {
return (
<Container size={"2"} className="mt-4">
<Skeleton>
<Text size={"6"}>{headerLong}</Text>
<br />
<Text size={"6"}>{headerShort}</Text>
<br />
<br />
<Text>{pText}</Text>
<br />
<br />
<Text wrap={"pretty"}>{pText}</Text>
<br />
<br />
<Text>{pText}</Text>
</Skeleton>
</Container>
);
}

View File

@ -0,0 +1,65 @@
import { DoubleArrowDownIcon, DoubleArrowUpIcon } from "@radix-ui/react-icons";
import { IconButton } from "@radix-ui/themes";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { axiosLocalhost } from "../../../api/axios/axios";
export const UPVOTE = true;
export const DOWNVOTE = false;
type TVoteButton = {
postId: string;
vote: boolean;
};
export default function VoteButton(props: TVoteButton) {
const queryClient = useQueryClient();
const { data } = useQuery({
queryKey: [props.vote + "voteCheck"],
queryFn: async () => {
const response = await axiosLocalhost.get(
`post-vote/${props.postId}`
);
return (response.data?.vote as boolean) === props.vote || false;
},
gcTime: 0,
});
const voteMutation = useMutation({
mutationKey: [`voteMutation${props.vote}`],
onMutate: async () => {
queryClient.cancelQueries({ queryKey: [props.vote + "voteCheck"] });
queryClient.setQueryData([props.vote + "voteCheck"], true);
queryClient.setQueryData([!props.vote + "voteCheck"], false);
},
mutationFn: async () => {
await axiosLocalhost.post(`post-votes/${props.postId}`, {
vote: props.vote,
});
},
onSuccess: () => {},
onError: () => {
queryClient.setQueryData([props.vote + "voteCheck"], false);
},
onSettled: () => {
queryClient.invalidateQueries({
queryKey: [props.vote + "voteCheck"],
});
queryClient.invalidateQueries({
queryKey: ["post_vote_counter"],
});
},
});
return (
<IconButton
variant={data ? "solid" : "outline"}
size={"1"}
onClick={() => voteMutation.mutate()}
>
{props.vote ? <DoubleArrowUpIcon /> : <DoubleArrowDownIcon />}
</IconButton>
);
}

View File

@ -0,0 +1,33 @@
import { Box, Skeleton } from "@radix-ui/themes";
import { useQuery } from "@tanstack/react-query";
import { axiosLocalhost } from "../../../api/axios/axios";
type TVoteCounter = {
postId: string;
};
export default function VoteCounter(props: TVoteCounter) {
const { data, isLoading } = useQuery({
queryKey: ["post_vote_counter"],
queryFn: async () => {
const response = await axiosLocalhost.get(
`post-votes/${props.postId}`
);
return response.data as { upvotes: number; downvotes: number };
},
});
const calculateRating = (upvotes: number, downvotes: number) => {
return upvotes + (-downvotes)
}
if (isLoading) {
return <Skeleton>
{calculateRating(0, 0)}
</Skeleton>
}
return <Box>
{calculateRating(data?.upvotes || 0, data?.downvotes || 0)}
</Box>;
}

View File

@ -0,0 +1,25 @@
import { Avatar, Card, Flex, Heading } from "@radix-ui/themes";
import { useNavigate } from "react-router-dom";
import UserNicknameLink from "../UserNicknameLink/UserNicknameLink";
type TBlogBox = {
title?: string;
blogId?: string;
userId: string;
};
export default function BlogBox(props: TBlogBox) {
const navigate = useNavigate();
return (
<Card className="w-full h-20" onClick={() => navigate(``)}>
<Flex direction={"column"}>
<Heading size={"4"}>{props?.title || "...No title..."}</Heading>
<Flex align={"center"} gap={"2"} mt={"1"}>
<Avatar size={"2"} className="rounded-full" fallback={"SI"} />
<UserNicknameLink userId={props.userId} />
</Flex>
</Flex>
</Card>
);
}

View File

@ -1,25 +1,19 @@
import Quill, { Delta, } from "quill/core";
import ReactQuill from "react-quill";
import React, {
forwardRef,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import Sources from "quill";
import Quill, { Delta } from "quill/core";
import { forwardRef, useEffect, useRef, useState } from "react";
import ReactQuill from "react-quill";
type TEditor = {
readOnly?: boolean;
defaultValue?: string | Delta;
onChange: (d: string) => void; // TODO: make type
onSelectionChange?: any; // TODO same as before
onChange?: (d: string) => void;
onSelectionChange?: any;
};
const modules = {
toolbar: [
[{ header: [1, 2, 3, false] }],
["bold", "italic", "underline", "strike", "blockquote"],
[{ header: [1, 2, 3, 4, 5, false] }],
["bold", "italic", "underline", "strike", "blockquote", "span-wrapper"],
[
{ list: "ordered" },
{ list: "bullet" },
@ -38,7 +32,9 @@ const modules = {
const Editor = forwardRef((props: TEditor) => {
const editor = useRef(null);
const [quill, setQuill] = useState<Quill | null>(null);
const [value, setValue] = useState(new Delta())
const [value, setValue] = useState(new Delta());
const [loaded, setLoaded] = useState(false);
useEffect(() => {
if (editor.current) {
@ -51,13 +47,33 @@ const Editor = forwardRef((props: TEditor) => {
};
}, [editor.current]);
const changeHandler = (val: string, _changeDelta: Delta, _source: Sources, _editor: ReactQuill.UnprivilegedEditor) => {
useEffect(() => {
const quill = new Quill(document.createElement("div"));
const t = quill.clipboard.convert({
html: props.defaultValue as string,
}) as Delta;
if (!loaded) {
setValue(t);
console.log(t);
}
setLoaded(true);
}, [props.defaultValue]);
const changeHandler = (
val: string,
_changeDelta: Delta,
_source: Sources,
_editor: ReactQuill.UnprivilegedEditor
) => {
console.log(val);
console.log(JSON.stringify(quill?.getContents().ops, null, 2))
let fullDelta = quill?.getContents()
props.onChange(val || "")
setValue(fullDelta || new Delta())
}
console.log(JSON.stringify(quill?.getContents().ops, null, 2));
let fullDelta = quill?.getContents();
if (props.onChange) props.onChange(val || "");
if (loaded) setValue(fullDelta || new Delta());
};
return (
<div className="text-editor">
@ -65,11 +81,7 @@ const Editor = forwardRef((props: TEditor) => {
value={value}
ref={editor}
modules={modules}
onChange={changeHandler}
theme="snow"
placeholder="Type your thoughts here..."
/>

View File

@ -1,20 +1,15 @@
import {
Container,
} from "@radix-ui/themes";
import SearchField from "./SearchField/SearchField";
import UserButton from "./UserButton/UserButton";
import CustomNavigationMenu from "./NavigationMenu/NavigationMenu";
import RightButtonBar from "./RightButtonBar/RightButtonBar";
import SearchField from "./SearchField/SearchField";
export default function NavBar() {
return (
<Container size={"4"}>
<nav className="flex justify-center pt-2 pb-2 ml-4 mr-4">
<CustomNavigationMenu />
<nav className="flex justify-center pt-2 pb-2 ml-4 mr-4 flex-[1] max-h-fit">
<CustomNavigationMenu />
<SearchField />
<SearchField />
<UserButton />
</nav>
</Container>
<RightButtonBar />
</nav>
);
}
}

View File

@ -1,15 +1,19 @@
import * as NavigationMenu from "@radix-ui/react-navigation-menu";
import { useThemeContext, Button, Heading } from "@radix-ui/themes";
import { useNavigate, useLocation } from "react-router-dom";
import { Button, Heading, useThemeContext } from "@radix-ui/themes";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";
export default function CustomNavigationMenu() {
const {t} = useTranslation()
return (
<div className="flex-1">
<NavigationMenu.Root orientation="horizontal">
<NavigationMenu.List className="flex items-center justify-start gap-8">
<NavItem text="Home" to="/" />
<NavItem text={t("home")} to="/" />
<NavItem text="Following" to="/c" />
<NavItem text={t("following")} to="/c" />
</NavigationMenu.List>
</NavigationMenu.Root>
</div>

View File

@ -0,0 +1,17 @@
import { PlusIcon } from "@radix-ui/react-icons";
import { Button, Text } from "@radix-ui/themes";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
export default function CreatePostButton() {
const { t } = useTranslation();
return (
<Link to={"/create"}>
<Button variant="ghost" className="h-full">
<PlusIcon />
<Text>{t("createPost")}</Text>
</Button>
</Link>
);
}

View File

@ -0,0 +1,12 @@
import CreatePostButton from "./CreatePostButton/CreatePostButton";
import UserButton from "./UserButton/UserButton";
export default function RightButtonBar() {
return (
<div className='flex flex-row justify-end flex-1 gap-4'>
<CreatePostButton />
<UserButton />
</div>
)
}

View File

@ -7,14 +7,16 @@ import {
import { DropdownMenu, Flex, IconButton, Text } from "@radix-ui/themes";
import { Icon } from "@radix-ui/themes/dist/esm/components/callout.js";
import { useAtomValue } from "jotai";
import { userAtom } from "../../../AtomStore/AtomStore";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { userAtom } from "../../../../AtomStore/AtomStore";
export default function UserButton() {
const user = useAtomValue(userAtom);
const { t } = useTranslation();
return (
<div className="flex justify-end flex-1">
<div className="">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<IconButton>
@ -24,24 +26,26 @@ export default function UserButton() {
<DropdownMenu.Content className="w-fit">
<DropdownMenu.Item>
<Link to={"/profile"}>
<Link to={"/user/:user-id/profile"}>
<Flex className="justify-between gap-2">
<Icon>
<PersonIcon />
</Icon>
<Text>Profile</Text>
<Text>{t("profile")}</Text>
</Flex>
</Link>
</DropdownMenu.Item>
<DropdownMenu.Item>
<Flex className="justify-between gap-2">
<Icon>
<LaptopIcon />
</Icon>
<Text>Your blogs</Text>
</Flex>
<Link to={"/user/blogs"}>
<Flex className="justify-between gap-2">
<Icon>
<LaptopIcon />
</Icon>
<Text>{t("yourBlogs")}</Text>
</Flex>
</Link>
</DropdownMenu.Item>
<DropdownMenu.Separator />
@ -52,15 +56,17 @@ export default function UserButton() {
<Icon>
<ExitIcon />
</Icon>
<Text>Log out</Text>
<Text>{t("signOut")}</Text>
</Flex>
) : (
<Flex className="justify-between gap-2">
<Icon>
<EnterIcon />
</Icon>
<Text>Log in</Text>
</Flex>
<Link to={"/login"}>
<Flex className="justify-between gap-2">
<Icon>
<EnterIcon />
</Icon>
<Text>{t("signIn")}</Text>
</Flex>
</Link>
)}
</DropdownMenu.Item>
</DropdownMenu.Content>

View File

@ -6,9 +6,9 @@ export default function SearchField() {
const {t} = useTranslation()
return (
<div className="flex-1">
<div className="flex justify-center flex-1">
<TextField.Root
className="w-full rounded-lg"
className="w-2/3 rounded-lg"
placeholder={t("search")}
>
<TextField.Slot>

View File

@ -0,0 +1,44 @@
import { Cross1Icon } from "@radix-ui/react-icons";
import * as Toast from "@radix-ui/react-toast";
import { Card, Text } from "@radix-ui/themes";
import { useAtomValue } from "jotai";
import React from "react";
import { toastAtom } from "../../AtomStore/AtomStore";
export default function ToastProvider(props: React.PropsWithChildren) {
const toastsToRender = useAtomValue(toastAtom);
return (
<Toast.Provider swipeDirection="right">
{props.children}
{toastsToRender.map((toast) => {
return (
<Toast.Root
key={toast.id}
className="mt-2 mr-2 data-[state=open]:animate-slideFromRight data-[state=closed]:animate-fadeOut"
open={toast.open}
onOpenChange={toast.resetFunc}
color={"red"}
>
<Card className="relative w-60">
<Toast.Title>
<Text size={"4"} weight={"bold"}>
{toast.title}
</Text>
</Toast.Title>
<Toast.Description className="overflow-hidden w-50 text-ellipsis">
<Text className="text-pretty line-clamp-2">{toast.description}</Text>
</Toast.Description>
<Toast.Close className="absolute top-2 right-2">
<Cross1Icon color="red" />
</Toast.Close>
</Card>
</Toast.Root>
);
})}
<Toast.Viewport className="fixed right-0 bottom-2" />
</Toast.Provider>
);
}

View File

@ -0,0 +1,33 @@
import { Skeleton, Text } from "@radix-ui/themes";
import { useQuery } from "@tanstack/react-query";
import { Link } from "react-router-dom";
import { axiosLocalhost } from "../../api/axios/axios";
type TUserNicknameLink = {
userId: string;
};
export default function UserNicknameLink(props: TUserNicknameLink) {
const { data, isPending } = useQuery({
queryKey: [`userLink${props.userId}`],
queryFn: async () => {
const response = await axiosLocalhost.get(
`/user/${props.userId || 0}`
);
return response.data as string;
},
});
if (isPending)
return (
<Skeleton>
<Text>@Nickname</Text>
</Skeleton>
);
return (
<Link to={`/users/${data}`}>
<Text>@{data}</Text>
</Link>
);
}

View File

@ -0,0 +1,22 @@
import { Container, Text } from "@radix-ui/themes";
import { t } from "i18next";
import { useAtomValue } from "jotai";
import React from "react";
import { useNavigate } from "react-router-dom";
import { userAtom } from "../../AtomStore/AtomStore";
export default function AuthPageWrapper(props: React.PropsWithChildren) {
const user = useAtomValue(userAtom);
const navigate = useNavigate();
if (!user) {
navigate("/login");
return (
<Container size={"4"} className="mt-4">
<Text size={"7"}>{t("errors.unauthorized")}</Text>
</Container>
);
}
return props.children;
}

View File

@ -0,0 +1,13 @@
import { Box } from '@radix-ui/themes'
import { useParams } from 'react-router-dom'
export default function BlogPage() {
const queryParams = useParams()
return (
<Box>
</Box>
)
}

View File

@ -0,0 +1,182 @@
import * as Form from "@radix-ui/react-form";
import { CrossCircledIcon } from "@radix-ui/react-icons";
import { Button, Card, Heading, Text, TextField } from "@radix-ui/themes";
import { useMutation } from "@tanstack/react-query";
import { t } from "i18next";
import { useAtom } from "jotai";
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { axiosLocalhost } from "../../../api/axios/axios";
import { userAtom } from "../../../AtomStore/AtomStore";
import UseCapsLock from "../../../hooks/useCapsLock";
import ShowPasswordButton from "../ShowPasswordButton/ShowPasswordButton";
type TLoginData = {
username: string;
password: string;
};
export default function LoginPage() {
const [userAtomValue, setUserAtom] = useAtom(userAtom);
const [showPassword, setShowPassword] = useState(false);
const { isCapsLockOn } = UseCapsLock();
const [isError, setIsError] = useState(false);
const navigate = useNavigate();
const logInMutation = useMutation({
mutationFn: async (data: TLoginData) => {
let response = await axiosLocalhost.post(
"/login",
JSON.stringify(data)
);
setUserAtom({
username: response.data.username,
isAdmin: false,
id: response.data.id,
});
},
onError: (error, _variables, _context) => {
console.log(error);
setIsError(true);
},
onSuccess: () => {
let isAdminFunc = async () => {
let response = await axiosLocalhost.get("/admin/check");
if (response.status === 200) {
setUserAtom({
username: userAtomValue?.username || "",
isAdmin: true,
id: userAtomValue?.id,
});
}
};
isAdminFunc();
navigate("/");
},
});
return (
<Card
size={"2"}
className="absolute w-1/4
left-[50%] top-[50%]
translate-x-[-50%] translate-y-[-50%]"
>
<Heading weight={"medium"} className="mb-4 text-center">
{t("loginForm")}
</Heading>
<Form.Root
className="flex flex-col gap-2"
onSubmit={(e) => {
e.preventDefault();
let formData = new FormData(
document.querySelector("form") as HTMLFormElement
);
let loginData: TLoginData = {
password: (formData.get("password") as string) || "",
username: (formData.get("username") as string) || "",
};
logInMutation.mutate(loginData);
}}
>
<Form.Field className="gap-0.5 grid" name="username">
<div className="flex items-baseline justify-between gap-2">
<Form.Label>
<Text size={"3"}>{t("username")}</Text>
</Form.Label>
<Form.Message match="valueMissing">
<Text color="red">{t("errors.enterUsername")}</Text>
</Form.Message>
</div>
<Form.Control asChild>
<TextField.Root type="text" required>
<Form.ValidityState>
{(validity) => (
<TextField.Slot
side="right"
color="red"
className={
validity
? validity.valid
? "hidden"
: "mr-0.5"
: "hidden"
}
>
<CrossCircledIcon />
</TextField.Slot>
)}
</Form.ValidityState>
</TextField.Root>
</Form.Control>
</Form.Field>
<Form.Field className="mb-2.5 gap-0.5 grid" name="password">
<div className="flex items-baseline justify-between gap-2">
<Form.Label>
<Text size={"3"}>{t("password")}</Text>
</Form.Label>
<Form.Message match="valueMissing">
<Text color="red">{t("errors.enterPassword")}</Text>
</Form.Message>
</div>
<Form.Control asChild>
<TextField.Root
type={showPassword ? "text" : "password"}
required
autoComplete="on"
>
<Form.ValidityState>
{(validity) => (
<TextField.Slot
side="right"
color={
validity
? validity.valid
? undefined
: "red"
: undefined
}
>
<ShowPasswordButton
isShown={showPassword}
setIsShown={setShowPassword}
/>
</TextField.Slot>
)}
</Form.ValidityState>
</TextField.Root>
</Form.Control>
<Text size={"1"} hidden={!isCapsLockOn}>
{t("capsLogWarning")}
</Text>
</Form.Field>
<Text color="red" hidden={!isError}>
{t("errors.invalidLoginData")}
</Text>
<Form.Submit className="flex justify-center" asChild>
<Button type="submit" className="w-full p-4 m-auto">
<Text size={"3"}>{t("submit")}</Text>
</Button>
</Form.Submit>
<Text size={"1"} color="gray" className="block w-full text-center">
{t("suggestRegister")}{" "}
<Link to="/register">
<Text className="underline" weight={"bold"}>{t("register")}</Text>
</Link>{" "}
{t("now")}
</Text>
</Form.Root>
</Card>
);
}

View File

@ -0,0 +1,76 @@
import { Box, Container, Flex, Spinner } from "@radix-ui/themes";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useParams } from "react-router-dom";
import { axiosLocalhost } from "../../../api/axios/axios";
import Editor from "../../../Components/Editor/Editor";
import SubmitChangesButton from "./SubmitChangesButton/SubmitChangesButton";
export default function PostRedactor() {
const [contentValue, setContentValue] = useState("");
const [titleValue, setTitleValue] = useState("");
const queryParams = useParams();
const { isPending } = useQuery({
queryKey: ["changePostKey", queryParams.postId],
queryFn: async () => {
try {
const response = await axiosLocalhost.get(
`/posts/${queryParams.postId}`
);
setTitleValue(response.data["title"]);
setContentValue(response.data["content"]);
return response.data;
} catch (error) {
console.log(error);
return error;
}
},
gcTime: 0,
refetchOnMount: true
});
return (
<>
<Box className="flex flex-col flex-1">
<Flex gap={"4"} direction={"column"} className="flex-[1]">
<Container className="flex-[1]">
<input
disabled={isPending}
placeholder={"Post title"}
className="mb-2 border-0 border-b-[1px]
outline-none w-full border-b-gray-400
text-[60px] pl-4 pr-4 font-times"
onChange={(e) => {
setTitleValue(e.target.value);
}}
value={titleValue}
/>
</Container>
<Container className="overflow-y-auto flex-grow-[100]">
{isPending ? (
<Spinner />
) : (
<Editor
defaultValue={contentValue}
onChange={setContentValue}
/>
)}
</Container>
<Box className="flex justify-center flex-[1] mb-4">
<SubmitChangesButton
contentValue={contentValue}
titleValue={titleValue}
className="text-2xl rounded-full w-52" />
</Box>
</Flex>
</Box>
</>
);
}

View File

@ -0,0 +1,60 @@
import { Button } from "@radix-ui/themes";
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom";
import { axiosLocalhost } from "../../../../api/axios/axios";
import useToast from "../../../../hooks/useToast";
type TSubmitChangesButton = {
className: string;
titleValue: string;
contentValue: string;
};
export default function SubmitChangesButton(props: TSubmitChangesButton) {
const { t } = useTranslation();
const [isDisabled, setIsDisabled] = useState(false);
const createToast = useToast();
const navigate = useNavigate();
const queryParams = useParams();
const postMutation = useMutation({
mutationFn: async () => {
if (!props.titleValue) throw new Error("no title provided");
if (!props.contentValue || props.contentValue === "<p><br></p>")
throw new Error("no content provided");
axiosLocalhost.put(`/posts/${queryParams["postId"]}`, {
title: props.titleValue,
content: props.contentValue,
});
},
onMutate: () => {
setIsDisabled(true);
},
onError: () => {
setIsDisabled(false);
},
onSuccess: () => {
createToast({title: "Post has been changed!"})
navigate("/");
},
});
return (
<Button
onClick={() => {
postMutation.mutate();
}}
className={props.className}
variant="soft"
size={"4"}
disabled={isDisabled}
>
{t("updatePost")}
</Button>
);
}

View File

@ -0,0 +1,265 @@
import * as Form from "@radix-ui/react-form";
import { CrossCircledIcon } from "@radix-ui/react-icons";
import { Button, Card, Heading, Text, TextField } from "@radix-ui/themes";
import { useMutation } from "@tanstack/react-query";
import { useSetAtom } from "jotai";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { axiosLocalhost } from "../../../api/axios/axios";
import { userAtom } from "../../../AtomStore/AtomStore";
import UseCapsLock from "../../../hooks/useCapsLock";
import ShowPasswordButton from "../ShowPasswordButton/ShowPasswordButton";
type TRegisterData = {
username: string;
password: string;
email: string;
};
export default function RegisterPage() {
const setUserAtom = useSetAtom(userAtom)
const [showPassword, setShowPassword] = useState(false);
const [showConfPassword, setShowConfPassword] = useState(false);
const { isCapsLockOn } = UseCapsLock();
const { t } = useTranslation();
const [isError, setIsError] = useState(false);
const navigate = useNavigate();
const registerMutation = useMutation({
mutationFn: async (data: TRegisterData) => {
let response = await axiosLocalhost.post("/users", JSON.stringify(data));
setUserAtom({
username: response.data.username,
isAdmin: false,
id: response.data.id,
})
},
onError: (error, _variables, _context) => {
console.log(error);
setIsError(true);
},
onSuccess: () => {
navigate("/");
},
});
return (
<Card
size={"2"}
className="absolute w-[25rem] min-w-[20rem]
left-[50%] top-[50%]
translate-x-[-50%] translate-y-[-50%]"
>
<Heading weight={"medium"} className="mb-4 text-center">
{t("registerForm")}
</Heading>
<Form.Root
className="flex flex-col gap-2"
onSubmit={(e) => {
e.preventDefault();
let formData = new FormData(
document.querySelector("form") as HTMLFormElement
);
let registerData: TRegisterData = {
password: (formData.get("password") as string) || "",
username: (formData.get("username") as string) || "",
email: (formData.get("email") as string) || "",
};
registerMutation.mutate(registerData);
}}
>
<Form.Field className="gap-0.5 grid" name="username">
<div className="flex items-baseline justify-between gap-2">
<Form.Label>
<Text size={"4"}>{t("username")}</Text>
</Form.Label>
<Form.Message match="valueMissing">
<Text color="red">{t("errors.enterUsername")}</Text>
</Form.Message>
</div>
<Form.Control asChild>
<TextField.Root type="text" required>
<Form.ValidityState>
{(validity) => (
<TextField.Slot
side="right"
color="red"
className={
validity
? validity.valid
? "hidden"
: "mr-0.5"
: "hidden"
}
>
<CrossCircledIcon />
</TextField.Slot>
)}
</Form.ValidityState>
</TextField.Root>
</Form.Control>
</Form.Field>
<Form.Field className="gap-0.5 grid" name="email">
<div className="flex items-baseline justify-between gap-2">
<Form.Label>
<Text size={"4"}>{t("email")}</Text>
</Form.Label>
<Form.Message match="valueMissing">
<Text color="red">{t("errors.enterEmail")}</Text>
</Form.Message>
<Form.Message match="typeMismatch">
<Text color="red">{t("errors.invalidEmail")}</Text>
</Form.Message>
</div>
<Form.Control asChild>
<TextField.Root type="email" required>
<Form.ValidityState>
{(validity) => (
<TextField.Slot
side="right"
color="red"
className={
validity
? validity.valid
? "hidden"
: "mr-0.5"
: "hidden"
}
>
<CrossCircledIcon />
</TextField.Slot>
)}
</Form.ValidityState>
</TextField.Root>
</Form.Control>
</Form.Field>
<Form.Field className="gap-0.5 grid" name="password">
<div className="flex items-baseline justify-between gap-2">
<Form.Label>
<Text size={"4"}>{t("password")}</Text>
</Form.Label>
<Form.Message match="valueMissing">
<Text color="red">{t("errors.enterPassword")}</Text>
</Form.Message>
</div>
<Form.Control asChild>
<TextField.Root
type={showPassword ? "text" : "password"}
required
>
<Form.ValidityState>
{(validity) => (
<TextField.Slot
side="right"
color={
validity
? validity.valid
? undefined
: "red"
: undefined
}
>
<ShowPasswordButton
isShown={showPassword}
setIsShown={setShowPassword}
/>
</TextField.Slot>
)}
</Form.ValidityState>
</TextField.Root>
</Form.Control>
<Text size={"1"} hidden={!isCapsLockOn}>
{t("capsLogWarning")}
</Text>
</Form.Field>
<Form.Field
className="gap-0.5 grid"
name="conf-password"
>
<div className="flex items-baseline justify-between">
<Form.Label>
<Text size={"4"}>{t("confirmPassword")}</Text>
</Form.Label>
<Form.Message match="valueMissing">
<Text color="red">{t("errors.enterPassword")}</Text>
</Form.Message>
<Form.Message
match={(value, formData) =>
value !== formData.get("password")
}
>
<Text color="red">
{t("errors.passwordsMismatch")}
</Text>
</Form.Message>
</div>
<Form.Control asChild>
<TextField.Root
type={showConfPassword ? "text" : "password"}
required
>
<Form.ValidityState>
{(validity) => (
<TextField.Slot
side="right"
color={
validity
? validity.valid
? undefined
: "red"
: undefined
}
>
<ShowPasswordButton
isShown={showConfPassword}
setIsShown={setShowConfPassword}
/>
</TextField.Slot>
)}
</Form.ValidityState>
</TextField.Root>
</Form.Control>
<Text size={"1"} hidden={!isCapsLockOn}>
{t("capsLogWarning")}
</Text>
</Form.Field>
<Text color="red" hidden={!isError}>
{t("errors.invalidRegisterData")}
</Text>
<Form.Submit className="flex justify-center mt-2" asChild>
<Button type="submit" className="w-full m-auto">
<Text size={"3"}>{t("submit")}</Text>
</Button>
</Form.Submit>
<Text size={"1"} color="gray" className="block w-full text-center">
{t("alreadyRegistered")}{" "}
<Link to="/login">
<Text className="underline" weight={"bold"}>{t("logIn")}</Text>
</Link>{" "}
{t("now")}
</Text>
<Text size={"1"} color="gray" className="block w-full text-center">
{t("byPressingTheButton")}{" "}
<Link to="/register">
<Text className="underline" weight={"bold"}>{t("termsOfService")}</Text>.
</Link>
</Text>
</Form.Root>
</Card>
);
}

View File

@ -0,0 +1,40 @@
import { EyeClosedIcon, EyeOpenIcon } from "@radix-ui/react-icons";
import { IconButton, Tooltip } from "@radix-ui/themes";
import { Dispatch, SetStateAction } from "react";
type TShowPasswordButton = {
isShown: boolean;
setIsShown: Dispatch<SetStateAction<boolean>>;
};
export default function ShowPasswordButton({ isShown, setIsShown }: TShowPasswordButton) {
return (
<div>
<Tooltip content="Show password">
{isShown ? (
<IconButton
type="button"
onClick={() => {
setIsShown(!isShown);
}}
size={"1"}
className="rounded-full"
variant="soft"
>
<EyeClosedIcon />
</IconButton>
) : (
<IconButton
type="button"
onClick={() => setIsShown(!isShown)}
size={"1"}
className="rounded-full"
variant="soft"
>
<EyeOpenIcon />
</IconButton>
)}
</Tooltip>
</div>
);
}

View File

@ -1,22 +0,0 @@
import React from "react";
import { Outlet } from "react-router-dom";
import NavBar from "../../Components/NavBar/NavBar";
import { axiosLocalhost } from "../../api/axios/axios";
import { Container } from "@radix-ui/themes";
export default function MainPage() {
return (
<>
<NavBar />
<Outlet />
<button
onClick={async () => {
let d = await axiosLocalhost.get("getCookie");
console.log(d.data);
}}
>
Click for cookie test
</button>
</>
);
}

View File

@ -0,0 +1,43 @@
import { Box, Container, Flex } from "@radix-ui/themes";
import { useAtom, useSetAtom } from "jotai";
import {
postCreationAtom,
postCreationTitleAtom
} from "../../AtomStore/AtomStore";
import Editor from "../../Components/Editor/Editor";
import SubmitPostButton from "./SubmitPostButton/SubmitPostButton";
export default function PostCreatorPage() {
const [titleValue, setTitleValue] = useAtom(postCreationTitleAtom);
const setContentValue = useSetAtom(postCreationAtom);
return (
<>
<Box className="flex flex-col flex-1">
<Flex gap={"4"} direction={"column"} className="flex-[1]">
<Container className="flex-[1]">
<input
placeholder={"Post title"}
className="mb-2 border-0 border-b-[1px]
outline-none w-full border-b-gray-400
text-[60px] pl-4 pr-4 font-times"
onChange={(e) => {
setTitleValue(e.target.value);
}}
value={titleValue}
/>
</Container>
<Container className="overflow-y-auto flex-grow-[100]">
<Editor onChange={setContentValue} />
</Container>
<Box className="flex justify-center flex-[1] mb-4">
<SubmitPostButton className="text-2xl rounded-full w-52" />
</Box>
</Flex>
</Box>
</>
);
}

View File

@ -0,0 +1,64 @@
import { Button } from "@radix-ui/themes";
import { useMutation } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { axiosLocalhost } from "../../../api/axios/axios";
import {
postCreationAtom,
postCreationTitleAtom,
} from "../../../AtomStore/AtomStore";
type TSubmitPostButton = {
className: string;
};
export default function SubmitPostButton(props: TSubmitPostButton) {
const { t } = useTranslation();
const [isDisabled, setIsDisabled] = useState(false);
const [contentValue, setContentValue] = useAtom(postCreationAtom);
const [titleValue, setTitleValue] = useAtom(postCreationTitleAtom);
const navigate = useNavigate();
const postMutation = useMutation({
mutationFn: async () => {
if (!titleValue) throw new Error("no title provided");
if (!contentValue || contentValue === "<p><br></p>")
throw new Error("no content provided");
axiosLocalhost.post("/posts", {
title: titleValue,
content: contentValue,
});
},
onMutate: () => {
setIsDisabled(true);
},
onError: () => {
setIsDisabled(false);
},
onSuccess: () => {
setContentValue("");
setTitleValue("");
navigate("/");
},
});
return (
<Button
onClick={() => {
postMutation.mutate();
}}
className={props.className}
variant="soft"
size={"4"}
disabled={isDisabled}
>
{t("submit")}
</Button>
);
}

View File

@ -0,0 +1,30 @@
import { ImageIcon } from "@radix-ui/react-icons";
import { Box, Card, Heading } from "@radix-ui/themes";
import { useNavigate } from "react-router-dom";
import { GetRandomPostsRow } from "../../../@types/PostTypes";
type TPostCard = {
post: GetRandomPostsRow;
};
export default function PostCard({ post }: TPostCard) {
const navigate = useNavigate()
const clickHandler = () => {
navigate(`/posts/${post.post_id.toString()}`)
}
return (
<Card className="h-32 mb-4" onClick={clickHandler}>
<Box className="flex size-full">
<Box>
<ImageIcon className="w-full h-full" />
</Box>
<Box className="px-4 pt-2">
<Heading>{post.title}</Heading>
</Box>
</Box>
</Card>
);
}

View File

@ -0,0 +1,67 @@
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { Container, Flex, Heading, Separator } from "@radix-ui/themes";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { GetRandomPostsRow } from "../../@types/PostTypes";
import { axiosLocalhost } from "../../api/axios/axios";
import PostCard from "./PostCard/PostCard";
const LIMIT = 10;
export default function RandomPostsPage() {
const {t} = useTranslation()
const { data, refetch } = useQuery({
queryKey: ["random_posts_key"],
queryFn: async () => {
try {
const response = await axiosLocalhost.get(
`/posts/random?limit=${LIMIT}`
);
return response.data as GetRandomPostsRow[];
} catch (error) {
console.log(`Something went wrong`);
}
return [];
},
});
return (
<>
<Flex direction={"column"} className="mx-auto">
<Heading size={"9"} weight={"regular"} className="text-center">
{t("discover")}
</Heading>
<Separator size={"4"} className="my-8" />
<ScrollArea.Root className="w-full h-full overflow-hidden">
<ScrollArea.Viewport className="overflow-scroll rounded size-full">
{data?.map((post, i) => {
return (
<Container size={"3"} key={`post${i}`}>
<PostCard post={post} />
</Container>
);
})}
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
className="z-50 flex touch-none select-none p-0.5 w-2"
orientation="vertical"
>
<ScrollArea.Thumb className="relative flex-1 rounded-[10px] bg-slate-200"/>
</ScrollArea.Scrollbar>
{/* <ScrollArea.Scrollbar
className="flex touch-none select-none bg-blackA3 p-0.5 transition-colors duration-[160ms] ease-out hover:bg-blackA5 data-[orientation=horizontal]:h-2.5 data-[orientation=vertical]:w-2.5 data-[orientation=horizontal]:flex-col"
orientation="horizontal"
>
<ScrollArea.Thumb className="relative flex-1 rounded-[10px] bg-mauve10 before:absolute before:left-1/2 before:top-1/2 before:size-full before:min-h-[44px] before:min-w-[44px] before:-translate-x-1/2 before:-translate-y-1/2" />
</ScrollArea.Scrollbar> */}
{/* <ScrollArea.Corner className="bg-blackA5" /> */}
</ScrollArea.Root>
</Flex>
</>
);
}

View File

@ -0,0 +1,17 @@
import { Box, Skeleton } from "@radix-ui/themes";
export default function SkeletonBoxes() {
return (
<>
<Skeleton>
<Box className="w-full h-20 mb-2 rounded-lg"></Box>
</Skeleton>
<Skeleton>
<Box className="w-full h-20 mb-2 rounded-lg"></Box>
</Skeleton>
<Skeleton>
<Box className="w-full h-20 mb-2 rounded-lg"></Box>
</Skeleton>
</>
);
}

View File

@ -0,0 +1,126 @@
import * as Dialog from "@radix-ui/react-dialog";
import { Cross2Icon, PlusIcon } from "@radix-ui/react-icons";
import {
Box,
Button,
Container,
Flex,
Separator,
Text,
} from "@radix-ui/themes";
import { useQuery } from "@tanstack/react-query";
import { axiosLocalhost } from "../../api/axios/axios";
import BlogBox from "../../Components/BlogBox/BlogBox";
import { JSONWithInt64 } from "../../utils/idnex";
import SkeletonBoxes from "./SkeletonBoxes/SkeletonBoxes";
export default function UserBlogsPage() {
const { data, isPending, isFetching } = useQuery({
queryKey: ["userBlogs"],
queryFn: async () => {
const response = await axiosLocalhost.get("/user/blogs", {
transformResponse: [(data) => data],
});
let temp = JSONWithInt64(response.data);
return temp as any[];
},
});
if (isPending)
return (
<Container size={"1"}>
<SkeletonBoxes />
</Container>
);
return (
<Box className="size-full">
<Container size={"1"}>
<Flex direction={"column"} gap={"2"}>
<Text size={"9"} className="text-center">
Your blogs
</Text>
<Separator size={"4"} className="my-2" />
{data
? data?.map((blog: any, b) => {
return (
<>
<BlogBox
key={b}
title={blog.title}
blogId={blog.blog_id}
userId={blog.user_id}
/>
</>
);
})
: null}
<Dialog.Root>
<Dialog.Trigger asChild>
<Button onClick={() => {}}>
<PlusIcon />
</Button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-blackA6 data-[state=open]:animate-overlayShow" />
<Dialog.Content className="fixed left-1/2 top-1/2 max-h-[85vh] w-[90vw] max-w-[450px] -translate-x-1/2 -translate-y-1/2 rounded-md bg-white p-[25px] shadow-[hsl(206_22%_7%_/_35%)_0px_10px_38px_-10px,_hsl(206_22%_7%_/_20%)_0px_10px_20px_-15px] focus:outline-none data-[state=open]:animate-contentShow">
<Dialog.Title className="m-0 text-[17px] font-medium text-mauve12">
Create blog
</Dialog.Title>
<Dialog.Description className="mb-5 mt-2.5 text-[15px] leading-normal text-mauve11">
Create your new blog.
</Dialog.Description>
<fieldset className="mb-[15px] flex items-center gap-5">
<label
className="w-[90px] text-right text-[15px] text-violet11"
htmlFor="title"
>
Blog title
</label>
<input
className="inline-flex h-[35px] w-full flex-1 items-center justify-center rounded px-2.5 text-[15px] leading-none text-violet11 shadow-[0_0_0_1px] shadow-violet7 outline-none focus:shadow-[0_0_0_2px] focus:shadow-violet8"
id="title"
defaultValue="My blog"
/>
</fieldset>
<fieldset className="mb-[15px] flex items-center gap-5">
<label
className="w-[90px] text-right text-[15px] text-violet11"
htmlFor="Description"
>
Description
</label>
<textarea
className="pt-2 inline-flex h-[35px] w-full flex-1 items-center justify-center rounded px-2.5 text-[15px] leading-none text-violet11 shadow-[0_0_0_1px] shadow-violet7 outline-none focus:shadow-[0_0_0_2px] focus:shadow-violet8"
id="Description"
placeholder="Your description..."
/>
</fieldset>
<div className="mt-[25px] flex justify-end">
<Dialog.Close asChild>
<Button>
Create blog
</Button>
</Dialog.Close>
</div>
<Dialog.Close asChild>
<button
className="absolute right-2.5 top-2.5 inline-flex size-[25px] appearance-none items-center justify-center rounded-full text-violet11 hover:bg-violet4 focus:shadow-[0_0_0_2px] focus:shadow-violet7 focus:outline-none"
aria-label="Close"
>
<Cross2Icon />
</button>
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
</Flex>
</Container>
</Box>
);
}

View File

@ -2,7 +2,7 @@ import axios from "axios";
export const axiosLocalhost = axios.create(
{
baseURL: `http://localhost:9876/`,
baseURL: `http://127.0.0.1:9876/`,
withCredentials: true,
headers: {

View File

@ -0,0 +1,12 @@
export const pText = `The goal of typography is to relate font size, line
height, and line width in a proportional way that
maximizes beauty and makes reading easier and more
pleasant. The question is: What proportion(s) will give
us the best results? The golden ratio is often observed
in nature where beauty and utility intersect; perhaps we
can use this divine proportion to enhance these
attributes in our typography.`;
export const headerLong = `THUS SHU SHU HDFQIUWKHFQWHF KJQWHqwfiqfquwdhqwjdk`;
export const headerShort = `THUS SHU SHU HDFQIUWKHFQWHF`;

View File

@ -0,0 +1,27 @@
import { useEffect, useState } from "react";
export default function UseCapsLock() {
const [isCapsLockOn, setIsCapsLockOn] = useState(false);
useEffect(() => {
const f = (e: KeyboardEvent) => {
if (e.getModifierState("CapsLock")) {
setIsCapsLockOn(true);
} else {
setIsCapsLockOn(false);
}
};
document.addEventListener("keydown", f);
return () => {
document.removeEventListener("keydown", f);
};
}, []);
return {
isCapsLockOn
}
}

View File

@ -0,0 +1,7 @@
import { useSetAtom } from "jotai";
import { setToastAtom } from "../AtomStore/AtomStore";
export default function useToast() {
const createToast = useSetAtom(setToastAtom);
return createToast;
}

View File

@ -4,6 +4,11 @@
@tailwind components;
@tailwind utilities;
@layer components {
.center-of-parent {
@apply absolute top-1/2 left-1/2 translate-x-[-50%] translate-y-[-50%];
}
}
.radix-themes {
--default-font-family: "Times New Roman"; ;

View File

@ -0,0 +1,74 @@
import { Box, Flex, Spinner } from "@radix-ui/themes";
import { useQuery } from "@tanstack/react-query";
import { useSetAtom } from "jotai";
import { Outlet } from "react-router-dom";
import { axiosLocalhost } from "../../api/axios/axios";
import { userAtom } from "../../AtomStore/AtomStore";
import NavBar from "../../Components/NavBar/NavBar";
const REFETCH_INTERVAL_IN_MINUTES = 5;
const RETRY_INTERVAL_IN_SECONDS = 1;
const SECONDS_IN_MINUTE = 60;
const MILLS_IN_SECOND = 1000;
const TAGS = Array.from({ length: 50 }).map(
(_, i, a) => `v1.2.0-beta.${a.length - i}`
);
export default function MainPage() {
const setUserData = useSetAtom(userAtom);
const { isPending } = useQuery({
queryKey: ["authKey"],
queryFn: async () => {
try {
const response = await axiosLocalhost.get("/auth/check");
setUserData({
isAdmin: response.data["is_admin"],
username: response.data["username"],
id: response.data["id"],
});
return true;
} catch (error) {
setUserData(undefined);
return false;
}
},
refetchInterval:
REFETCH_INTERVAL_IN_MINUTES * SECONDS_IN_MINUTE * MILLS_IN_SECOND,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
gcTime: 10,
retry: 3,
retryDelay: (attempt) =>
attempt * RETRY_INTERVAL_IN_SECONDS * MILLS_IN_SECOND,
});
return (
<>
{isPending ? (
<div
className="absolute top-1/2 left-1/2
translate-x-[-50%] translate-y-[-50%]"
>
<Spinner size={"3"} />
</div>
) : (
<Flex
direction={"column"}
className="min-h-[100vh] max-h-[100vh] overflow-hidden"
>
<Box flexGrow={"1"} className="flex-[1]">
<NavBar />
</Box>
<Box flexGrow={"100"} className="flex overflow-hidden flex-">
<Outlet />
</Box>
</Flex>
)}
</>
);
}

View File

@ -1,6 +1,53 @@
const en = {
hello: "hello!",
search: "Search...",
}
username: "Username",
email: "Email",
password: "Password",
confirmPassword: "Confirm password",
submit: "Submit",
export default en;
createPost: "Write post",
profile: "Profile",
yourBlogs: "Your blogs",
signIn: "Log in",
signOut: "Sign out",
capsLogWarning: "CapsLock is on",
registerForm: "Register",
loginForm: "Log in",
alreadyRegistered: "Already registered?",
suggestRegister: "Don't have an account?",
register: "Register",
now: "now!",
logIn: "Log in",
updatePost: "Update",
byPressingTheButton: "By pressing the submit button you agree with our",
termsOfService: "Terms Of Service",
discover: "Discover something new...",
home: "Home",
following: "Following",
errors: {
enterUsername: "Please enter your username",
enterEmail: "Please enter your email",
invalidEmail: "Please enter correct email",
enterPassword: "Please enter your password",
passwordsMismatch: "Passwords must be the same",
invalidLoginData: "Invalid username or password",
invalidRegisterData: "Invalid register data",
unauthorized: "You need to be authorized to do that",
},
};
export default en;

View File

@ -1,6 +1,56 @@
const ru = {
hello: "Привет!",
search: "Поиск...",
}
username: "Имя пользователя",
email: "Электронная почта",
password: "Пароль",
confirmPassword: "Подтвердите пароль",
submit: "Подтвердить",
export default ru;
createPost: "Написать пост",
profile: "Профиль",
yourBlogs: "Ваши блоги",
signIn: "Войти",
signOut: "Выйти",
capsLogWarning: "Включён CapsLock",
registerForm: "Регистрация",
loginForm: "Вход",
alreadyRegistered: "Уже есть аккаунт?",
suggestRegister: "Не зарегистрированы?",
register: "Создайте аккаунт",
now: "сейчас!",
logIn: "Войдите",
byPressingTheButton: "Нажимая `Подтвердить`, вы соглашаетесь с нашими",
termsOfService: "Условиями предоставления услуг.",
updatePost: "Изменить",
discover: "Найдите что-то новое",
home: "Главная",
following: "Отслеживаемые",
errors: {
enterUsername: "Это обязательное поле",
enterEmail: "Это обязательное поле",
invalidEmail: "Некорректный адрес электронной почты",
enterPassword: "Пожалуйста, введите пароль",
passwordsMismatch: "Пароли должны быть одинаковыми",
invalidLoginData: "Неверное имя пользователя или пароль",
invalidRegisterData:
"Пользователь с таким адресом электронной почты или именем пользователя уже существует",
unauthorized: "Вы должны быть авторизованы, чтобы сделать это",
},
};
export default ru;

View File

@ -1,7 +1,20 @@
import { createRoutesFromElements, Route, useRouteError } from "react-router-dom"
import MainPage from "../Pages/MainPage/MainPage"
import {Text} from "@radix-ui/themes";
import { Text } from "@radix-ui/themes";
import {
createRoutesFromElements,
Outlet,
Route,
useRouteError,
} from "react-router-dom";
import ArticleViewer from "../Components/ArticleViewer/ArticleViewer";
import MainPage from "../layout/MainPage/MainPage";
import AuthPageWrapper from "../Pages/AuthPageWrapper/AuthPageWrapper";
import BlogPage from "../Pages/BlogPage/BlogPage";
import LoginPage from "../Pages/LoginRegisterPage/LoginPage/LoginPage";
import PostRedactor from "../Pages/LoginRegisterPage/PostRedactor/PostRedactor";
import RegisterPage from "../Pages/LoginRegisterPage/RegisterPage/RegisterPage";
import PostCreatorPage from "../Pages/PostCreatorPage/PostCreatorPage";
import RandomPostsPage from "../Pages/RandomPostsPage/RandomPostsPage";
import UserBlogsPage from "../Pages/UserBlogsPage/UserBlogsPage";
function ErrorBoundary() {
let error = useRouteError();
@ -12,16 +25,54 @@ function ErrorBoundary() {
export const routes = createRoutesFromElements(
<>
<Route
path="/"
errorElement={<ErrorBoundary />}
element={<MainPage />}
>
<Route index element={<Text size={"5"}>Cringer path</Text>} />
<Route path="/" errorElement={<ErrorBoundary />} element={<MainPage />}>
<Route index element={<RandomPostsPage />} />
<Route
path="/a?/c"
element={<Text weight={"regular"}>Cringer path, but this a</Text>}
></Route>
path="a?/c"
element={
<Text weight={"regular"}>
This page is yet to be created
</Text>
}
/>
<Route
path="create"
element={
<AuthPageWrapper>
<PostCreatorPage />
</AuthPageWrapper>
}
/>
<Route path="blogs/:blogId" element={<BlogPage />} />
<Route path="user" element={<Outlet />}>
<Route
path="blogs"
element={
<AuthPageWrapper>
<UserBlogsPage />
</AuthPageWrapper>
}
/>
</Route>
<Route path="posts/:postId" element={<ArticleViewer />} />
<Route path="posts/change/:postId" element={<PostRedactor />} />
</Route>
<Route
path="/login"
errorElement={<ErrorBoundary />}
element={<LoginPage />}
/>
<Route
path="/register"
errorElement={<ErrorBoundary />}
element={<RegisterPage />}
/>
</>
)
);

19
enshi/src/utils/idnex.ts Normal file
View File

@ -0,0 +1,19 @@
const isBigNumber = (num: any) => !Number.isSafeInteger(+num);
const enquoteBigNumber = (jsonString: any, bigNumChecker: any) =>
jsonString.replaceAll(
/([:\s\[,]*)(\d+)([\s,\]]*)/g,
(matchingSubstr: any, prefix: any, bigNum: any, suffix: any) =>
bigNumChecker(bigNum)
? `${prefix}"${bigNum}"${suffix}`
: matchingSubstr
);
const parseWithBigInt = (jsonString: any, bigNumChecker: any) =>
JSON.parse(enquoteBigNumber(jsonString, bigNumChecker), (_key, value) =>
!isNaN(value) && bigNumChecker(value) ? BigInt(value).toString() : value
);
export const JSONWithInt64 = (jsonString: any) => {
return parseWithBigInt(jsonString, isBigNumber);
};

View File

@ -7,11 +7,32 @@ export default {
"primary-color": "var(--primary-color)",
"secondary-color": "var(--secondary-color)",
},
fontFamily: {
'times': "Times New Roman"
},
animation: {
appear: "appear 0.25s",
widthOut: "widthOut cubic-bezier(0.4, 0, 0.6, 1) 0.4s",
slideFromRight: "slideFromRight cubic-bezier(0.4, 0, 0.6, 1) 0.2s",
fadeOut: "fadeOut 0.2s ease-in",
},
keyframes: {
fadeOut: {
from: {
opacity: "1",
},
to: {
opacity: "0",
}
},
slideFromRight: {
"0%": {
transform: "translateX(110%)"
},
"100%": {
transform: "translateX(0%)"
}
},
appear: {
"100%": { opacity: "1" },
},

View File

@ -0,0 +1,36 @@
package bookmarkspolicies
import (
bookmarksrules "enshi/ABAC/BookmarkPolicies/bookmarkRules"
"enshi/ABAC/rules"
"github.com/gin-gonic/gin"
)
const (
DELETE_BOOKMARK = "delete_bookmark"
CREATE_BOOKMARK = "create_bookmark"
READ_BOOKMARK = "read_bookmark"
)
func BlogPolicies(c *gin.Context) (bool, []error) {
target, exists := c.Get("target")
if !exists {
return false, nil
}
// Permit if one permit
switch target {
case DELETE_BOOKMARK:
return rules.CheckRule(c, bookmarksrules.BookmarkDeleteRule)
case CREATE_BOOKMARK:
return rules.CheckRule(c, bookmarksrules.BookmarkCreateRule)
case READ_BOOKMARK:
return rules.CheckRule(c, bookmarksrules.BookmarkReadRule)
}
return false, nil
}

View File

@ -0,0 +1,22 @@
package bookmarksrules
import (
globalrules "enshi/ABAC/GlobalRules"
"enshi/ABAC/rules"
"github.com/gin-gonic/gin"
)
func BookmarkCreateRule(c *gin.Context) (bool, []error) {
rulesToCheck := []rules.RuleFunction{
globalrules.AuthorizedRule,
}
isAllowed, errors := rules.CheckRules(
c,
rulesToCheck,
rules.ALL_RULES_MUST_BE_COMPLETED,
)
return isAllowed, errors
}

View File

@ -0,0 +1,22 @@
package bookmarksrules
import (
globalrules "enshi/ABAC/GlobalRules"
"enshi/ABAC/rules"
"github.com/gin-gonic/gin"
)
func BookmarkDeleteRule(c *gin.Context) (bool, []error) {
rulesToCheck := []rules.RuleFunction{
globalrules.AuthorizedRule,
}
isAllowed, errors := rules.CheckRules(
c,
rulesToCheck,
rules.ALL_RULES_MUST_BE_COMPLETED,
)
return isAllowed, errors
}

View File

@ -0,0 +1,22 @@
package bookmarksrules
import (
globalrules "enshi/ABAC/GlobalRules"
"enshi/ABAC/rules"
"github.com/gin-gonic/gin"
)
func BookmarkReadRule(c *gin.Context) (bool, []error) {
rulesToCheck := []rules.RuleFunction{
globalrules.AuthorizedRule,
}
isAllowed, errors := rules.CheckRules(
c,
rulesToCheck,
rules.ALL_RULES_MUST_BE_COMPLETED,
)
return isAllowed, errors
}

View File

@ -0,0 +1,38 @@
package postvotespolicies
import (
postvoterules "enshi/ABAC/PostVotesPolicies/PostVoteRules"
"enshi/ABAC/rules"
"github.com/gin-gonic/gin"
)
const (
DELETE_VOTE = "delete_vote"
CREATE_VOTE = "create_vote"
READ_VOTE = "read_vote"
)
func PostVotePolicies(c *gin.Context) (bool, []error) {
target, exists := c.Get("target")
if !exists {
return false, nil
}
// Permit if one permit
switch target {
case DELETE_VOTE:
return rules.CheckRule(c, postvoterules.PostVoteDeleteRule)
case CREATE_VOTE:
return rules.CheckRule(c, postvoterules.PostVoteCreateRule)
case READ_VOTE:
return rules.CheckRule(c, postvoterules.PostVoteReadRule)
default:
return rules.CheckRule(c, postvoterules.PostVotesReadRule)
}
return false, nil
}

View File

@ -0,0 +1,22 @@
package postvoterules
import (
globalrules "enshi/ABAC/GlobalRules"
"enshi/ABAC/rules"
"github.com/gin-gonic/gin"
)
func PostVoteCreateRule(c *gin.Context) (bool, []error) {
rulesToCheck := []rules.RuleFunction{
globalrules.AuthorizedRule,
}
isAllowed, errors := rules.CheckRules(
c,
rulesToCheck,
rules.ALL_RULES_MUST_BE_COMPLETED,
)
return isAllowed, errors
}

View File

@ -0,0 +1,22 @@
package postvoterules
import (
globalrules "enshi/ABAC/GlobalRules"
"enshi/ABAC/rules"
"github.com/gin-gonic/gin"
)
func PostVoteDeleteRule(c *gin.Context) (bool, []error) {
rulesToCheck := []rules.RuleFunction{
globalrules.AuthorizedRule,
}
isAllowed, errors := rules.CheckRules(
c,
rulesToCheck,
rules.ALL_RULES_MUST_BE_COMPLETED,
)
return isAllowed, errors
}

View File

@ -0,0 +1,22 @@
package postvoterules
import (
globalrules "enshi/ABAC/GlobalRules"
"enshi/ABAC/rules"
"github.com/gin-gonic/gin"
)
func PostVoteReadRule(c *gin.Context) (bool, []error) {
rulesToCheck := []rules.RuleFunction{
globalrules.AuthorizedRule,
}
isAllowed, errors := rules.CheckRules(
c,
rulesToCheck,
rules.ALL_RULES_MUST_BE_COMPLETED,
)
return isAllowed, errors
}

View File

@ -0,0 +1,17 @@
package postvoterules
import (
"github.com/gin-gonic/gin"
)
func PostVotesReadRule(c *gin.Context) (bool, []error) {
// rulesToCheck := []rules.RuleFunction{}
// isAllowed, errors := rules.CheckRules(
// c,
// rulesToCheck,
// rules.ALL_RULES_MUST_BE_COMPLETED,
// )
return true, nil
}

View File

@ -13,6 +13,9 @@ const createPostVote = `-- name: CreatePostVote :one
INSERT INTO public.post_votes
(post_id, user_id, vote)
VALUES($1, $2, $3)
ON CONFLICT (user_id, post_id)
DO UPDATE SET
vote = $3
RETURNING post_id, user_id, vote
`
@ -62,6 +65,25 @@ func (q *Queries) GetPostVote(ctx context.Context, arg GetPostVoteParams) (bool,
return vote, err
}
const getPostVotes = `-- name: GetPostVotes :one
SELECT count (*) FILTER (WHERE vote = TRUE) as upvotes,
count (*) FILTER (WHERE vote = FALSE) as downvotes
FROM public.post_votes
WHERE post_id = $1
`
type GetPostVotesRow struct {
Upvotes int64 `json:"upvotes"`
Downvotes int64 `json:"downvotes"`
}
func (q *Queries) GetPostVotes(ctx context.Context, postID int64) (GetPostVotesRow, error) {
row := q.db.QueryRow(ctx, getPostVotes, postID)
var i GetPostVotesRow
err := row.Scan(&i.Upvotes, &i.Downvotes)
return i, err
}
const updateVote = `-- name: UpdateVote :one
UPDATE public.post_votes
SET vote=$1

View File

@ -146,6 +146,47 @@ func (q *Queries) GetPostsByUserId(ctx context.Context, userID int64) ([]Post, e
return items, nil
}
const getRandomPosts = `-- name: GetRandomPosts :many
SELECT post_id, blog_id, user_id, title, created_at
FROM public.posts
ORDER BY RANDOM()
LIMIT $1
`
type GetRandomPostsRow struct {
PostID int64 `json:"post_id"`
BlogID pgtype.Int8 `json:"blog_id"`
UserID int64 `json:"user_id"`
Title pgtype.Text `json:"title"`
CreatedAt pgtype.Timestamp `json:"created_at"`
}
func (q *Queries) GetRandomPosts(ctx context.Context, limit int32) ([]GetRandomPostsRow, error) {
rows, err := q.db.Query(ctx, getRandomPosts, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetRandomPostsRow
for rows.Next() {
var i GetRandomPostsRow
if err := rows.Scan(
&i.PostID,
&i.BlogID,
&i.UserID,
&i.Title,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updatePostBlogId = `-- name: UpdatePostBlogId :exec
UPDATE public.posts
SET blog_id=$2, updated_at=CURRENT_TIMESTAMP

View File

@ -152,6 +152,17 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User,
return i, err
}
const getUserUsernameById = `-- name: GetUserUsernameById :one
SELECT username FROM users WHERE user_id = $1
`
func (q *Queries) GetUserUsernameById(ctx context.Context, userID int64) (string, error) {
row := q.db.QueryRow(ctx, getUserUsernameById, userID)
var username string
err := row.Scan(&username)
return username, err
}
const updateUserPasswordHash = `-- name: UpdateUserPasswordHash :one
UPDATE public.users
SET "password"=$1

View File

@ -2,6 +2,9 @@
INSERT INTO public.post_votes
(post_id, user_id, vote)
VALUES($1, $2, $3)
ON CONFLICT (user_id, post_id)
DO UPDATE SET
vote = $3
RETURNING *;
-- name: DeletePostVote :exec
@ -17,4 +20,10 @@ RETURNING *;
-- name: GetPostVote :one
SELECT vote
FROM public.post_votes p_v
WHERE p_v.user_id = $1 and p_v.post_id = $2;
WHERE p_v.user_id = $1 and p_v.post_id = $2;
-- name: GetPostVotes :one
SELECT count (*) FILTER (WHERE vote = TRUE) as upvotes,
count (*) FILTER (WHERE vote = FALSE) as downvotes
FROM public.post_votes
WHERE post_id = $1;

View File

@ -33,4 +33,10 @@ WHERE post_id=$1;
UPDATE public.posts
SET blog_id=$2, updated_at=CURRENT_TIMESTAMP
WHERE post_id = $1
RETURNING *;
RETURNING *;
-- name: GetRandomPosts :many
SELECT post_id, blog_id, user_id, title, created_at
FROM public.posts
ORDER BY RANDOM()
LIMIT $1;

View File

@ -4,6 +4,9 @@ SELECT * FROM users;
-- name: GetUserById :one
SELECT * FROM users WHERE user_id = $1;
-- name: GetUserUsernameById :one
SELECT username FROM users WHERE user_id = $1;
-- name: GetUserByUsername :one
SELECT * FROM users WHERE username = $1;

View File

@ -1,7 +1,7 @@
package global
const PathForCookies = "/"
const DomainForCookies = "localhost"
const DomainForCookies = "127.0.0.1"
const SecureForCookies = false
const HttpOnlyForCookies = false

View File

@ -1,8 +1,10 @@
package middleware
import (
rest_api_stuff "enshi/REST_API_stuff"
"enshi/auth"
"enshi/global"
"fmt"
"net/http"
"github.com/gin-gonic/gin"
@ -11,9 +13,14 @@ import (
func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// token := c.GetHeader("Authorization")
cookies := c.Request.CookiesNamed("auth_cookie")
if len(cookies) == 0 {
rest_api_stuff.UnauthorizedAnswer(c, fmt.Errorf("no token provided"))
c.Abort()
return
}
tokenFromCookies := c.Request.CookiesNamed("auth_cookie")[0].Value
tokenFromCookies := cookies[0].Value
cookieClimes, err := auth.ValidateToken(tokenFromCookies)
if err != nil {
c.IndentedJSON(http.StatusUnauthorized, gin.H{"error auth": err.Error()})
@ -21,14 +28,6 @@ func AuthMiddleware() gin.HandlerFunc {
return
}
// claims, err := auth.ValidateToken(token)
// if err != nil {
// c.IndentedJSON(http.StatusUnauthorized, gin.H{"error auth": err.Error()})
// c.Abort()
// return
// }
// Claims -> data stored in token
c.Set(global.ContextUserId, cookieClimes["id"])
c.Set(global.ContextTokenData, cookieClimes)
c.Next()

View File

@ -0,0 +1,33 @@
package middleware
import (
bookmarkspolicies "enshi/ABAC/BookmarkPolicies"
"enshi/ABAC/rules"
"github.com/gin-gonic/gin"
)
func BookmarksMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
switch c.Request.Method {
case "DELETE":
c.Set("target", bookmarkspolicies.DELETE_BOOKMARK)
case "POST":
c.Set("target", bookmarkspolicies.CREATE_BOOKMARK)
case "GET":
c.Set("target", bookmarkspolicies.READ_BOOKMARK)
}
isAllowed, errors := bookmarkspolicies.BlogPolicies(c)
if rules.ShouldAbortRequest(c, isAllowed, errors) {
c.Abort()
return
}
c.Next()
}
}

View File

@ -4,7 +4,7 @@ import "github.com/gin-gonic/gin"
func CORSMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", "http://localhost:5173")
c.Writer.Header().Set("Access-Control-Allow-Origin", "http://127.0.0.1:5173")
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
c.Writer.Header().Set(
"Access-Control-Allow-Headers",

View File

@ -0,0 +1,38 @@
package middleware
import (
postvotespolicies "enshi/ABAC/PostVotesPolicies"
"enshi/ABAC/rules"
"strings"
"github.com/gin-gonic/gin"
)
func PostVotesMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
a := strings.Split(c.Request.URL.Path, "/")[1]
switch c.Request.Method {
case "DELETE":
c.Set("target", postvotespolicies.DELETE_VOTE)
case "POST":
c.Set("target", postvotespolicies.CREATE_VOTE)
case "GET":
if a != "post-votes" {
c.Set("target", postvotespolicies.READ_VOTE)
} else {
c.Set("target", "")
}
}
isAllowed, errors := postvotespolicies.PostVotePolicies(c)
if rules.ShouldAbortRequest(c, isAllowed, errors) {
c.Abort()
return
}
c.Next()
}
}

View File

@ -68,6 +68,6 @@ func Login(c *gin.Context) {
c.Header("Authorization", token)
c.SetCookie(cookieName, cookieValue, maxAge, path, domain, secure, httpOnly)
c.IndentedJSON(http.StatusOK, gin.H{"token": token})
c.IndentedJSON(http.StatusOK, gin.H{"token": token, "username": user.Username, "id": user.UserID})
}

View File

@ -10,6 +10,7 @@ import (
"enshi/global"
"enshi/hasher"
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
@ -118,5 +119,5 @@ func RegisterUser(c *gin.Context) {
transaction.Commit(context.Background())
rest_api_stuff.SetCookie(c, cookieParams)
rest_api_stuff.OkAnswer(c, "User has been created!")
c.IndentedJSON(http.StatusOK, gin.H{"status": "All good", "username": userParams.Username, "id": userParams.UserID})
}

View File

@ -0,0 +1,29 @@
package blogRoutes
import (
"context"
rest_api_stuff "enshi/REST_API_stuff"
db_repo "enshi/db/go_queries"
"enshi/db_connection"
"enshi/middleware/getters"
"net/http"
"github.com/gin-gonic/gin"
)
func GetUserBlogs(c *gin.Context) {
userId, err := getters.GetUserIdFromContext(c)
if err != nil {
rest_api_stuff.BadRequestAnswer(c, err)
return
}
blogData, err := db_repo.New(db_connection.Dbx).
GetBlogsByUserId(context.Background(), userId)
if err != nil {
rest_api_stuff.InternalErrorAnswer(c, err)
return
}
c.IndentedJSON(http.StatusOK, blogData)
}

View File

@ -0,0 +1,35 @@
package bookmarksroutes
import (
"context"
rest_api_stuff "enshi/REST_API_stuff"
db_repo "enshi/db/go_queries"
"enshi/db_connection"
"enshi/middleware/getters"
"github.com/gin-gonic/gin"
)
func CreateBookmark(c *gin.Context) {
var bookmarkParams db_repo.CreateBookmarkParams
if err := c.BindJSON(&bookmarkParams); err != nil {
rest_api_stuff.BadRequestAnswer(c, err)
return
}
userId, err := getters.GetUserIdFromContext(c)
if err != nil {
rest_api_stuff.BadRequestAnswer(c, err)
return
}
bookmarkParams.UserID = userId
query := db_repo.New(db_connection.Dbx)
if _, err := query.CreateBookmark(context.Background(), bookmarkParams); err != nil {
rest_api_stuff.InternalErrorAnswer(c, err)
return
}
rest_api_stuff.OkAnswer(c, "Bookmark has been created!")
}

View File

@ -0,0 +1,35 @@
package bookmarksroutes
import (
"context"
rest_api_stuff "enshi/REST_API_stuff"
db_repo "enshi/db/go_queries"
"enshi/db_connection"
"enshi/middleware/getters"
"github.com/gin-gonic/gin"
)
func DeleteBookmark(c *gin.Context) {
var bookmarkParams db_repo.DeleteBookmarkParams
if err := c.BindJSON(&bookmarkParams); err != nil {
rest_api_stuff.BadRequestAnswer(c, err)
return
}
userId, err := getters.GetUserIdFromContext(c)
if err != nil {
rest_api_stuff.BadRequestAnswer(c, err)
return
}
bookmarkParams.UserID = userId
query := db_repo.New(db_connection.Dbx)
if err := query.DeleteBookmark(context.Background(), bookmarkParams); err != nil {
rest_api_stuff.InternalErrorAnswer(c, err)
return
}
rest_api_stuff.OkAnswer(c, "Bookmark has been deleted!")
}

View File

@ -0,0 +1,48 @@
package bookmarksroutes
import (
"context"
rest_api_stuff "enshi/REST_API_stuff"
db_repo "enshi/db/go_queries"
"enshi/db_connection"
"enshi/middleware/getters"
"net/http"
"time"
"github.com/gin-gonic/gin"
)
func GetBookmark(c *gin.Context) {
var bookmarkParams db_repo.GetBookmarkTimestampParams
if err := c.BindJSON(&bookmarkParams); err != nil {
rest_api_stuff.BadRequestAnswer(c, err)
return
}
userId, err := getters.GetUserIdFromContext(c)
if err != nil {
rest_api_stuff.BadRequestAnswer(c, err)
return
}
bookmarkParams.UserID = userId
query := db_repo.New(db_connection.Dbx)
if timestamp, err := query.GetBookmarkTimestamp(context.Background(), bookmarkParams); err != nil {
rest_api_stuff.InternalErrorAnswer(c, err)
return
} else {
if timestamp.Valid {
c.IndentedJSON(http.StatusOK, gin.H{
"isBookmarked": timestamp.Valid,
"bookmarkedAt": timestamp.Time,
})
return
} else {
c.IndentedJSON(http.StatusOK, gin.H{
"isBookmarked": timestamp.Valid,
"bookmarkedAt": time.Unix(1<<63-1, 0).UTC(),
})
}
}
}

View File

@ -0,0 +1,43 @@
package postsRoutes
import (
"context"
rest_api_stuff "enshi/REST_API_stuff"
db_repo "enshi/db/go_queries"
"enshi/db_connection"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
func GetRandomPost(c *gin.Context) {
limit, err := strconv.Atoi(c.DefaultQuery("limit", "10"))
if err != nil {
rest_api_stuff.InternalErrorAnswer(c, err)
return
}
postsData, err :=
db_repo.New(db_connection.Dbx).
GetRandomPosts(context.Background(), int32(limit))
if err != nil {
rest_api_stuff.InternalErrorAnswer(c, err)
return
}
result := make([]any, 0)
for _, post := range postsData {
result = append(result, gin.H{
"post_id": strconv.Itoa(int(post.PostID)),
"title": post.Title,
"user_id": strconv.Itoa(int(post.UserID)),
})
}
c.IndentedJSON(http.StatusOK, result)
}

View File

@ -2,10 +2,14 @@ package routes
import (
"enshi/middleware"
"enshi/middleware/getters"
"enshi/routes/authRoutes"
"enshi/routes/blogRoutes"
bookmarksroutes "enshi/routes/bookmarksRoutes"
"enshi/routes/postsRoutes"
"enshi/routes/userProfileRoutes"
userroutes "enshi/routes/userRoutes"
voteroutes "enshi/routes/voteRoutes"
"net/http"
"strings"
@ -21,6 +25,23 @@ func testAdmin(c *gin.Context) {
c.IndentedJSON(http.StatusOK, gin.H{"message": "you are an admin, congrats!"})
}
func testAuth(c *gin.Context) {
userInfo, err := getters.GetClaimsFromContext(c)
if err != nil {
c.IndentedJSON(http.StatusUnauthorized, gin.H{"message": "you are not logged in"})
}
c.IndentedJSON(
http.StatusOK,
gin.H{
"message": "you are logged in, congrats!",
"username": userInfo.Username,
"is_admin": userInfo.IsAdmin,
"id": userInfo.Id,
},
)
}
func SetupRotes(g *gin.Engine) error {
g.Use(middleware.CORSMiddleware())
@ -45,6 +66,12 @@ func SetupRotes(g *gin.Engine) error {
"posts/:post-id",
postsRoutes.GetPost,
)
postsGroup.GET(
"posts/random",
postsRoutes.GetRandomPost,
)
postsGroup.PUT(
"posts/:post-id",
postsRoutes.UpdatePost,
@ -97,11 +124,69 @@ func SetupRotes(g *gin.Engine) error {
userProfileRoutes.UpdateUserProfile,
)
bookmarksGroup := g.Group("/")
bookmarksGroup.Use(middleware.BookmarksMiddleware())
bookmarksGroup.POST(
"bookmarks/:post-id",
bookmarksroutes.CreateBookmark,
)
bookmarksGroup.DELETE(
"bookmarks/:post-id",
bookmarksroutes.DeleteBookmark,
)
bookmarksGroup.GET(
"bookmarks/:post-id",
bookmarksroutes.GetBookmark,
)
postVoteGroup := g.Group("/")
postVoteGroup.Use(middleware.PostVotesMiddleware())
postVoteGroup.POST(
"post-votes/:post-id",
voteroutes.CreateVote,
)
postVoteGroup.DELETE(
"post-votes/:post-id",
voteroutes.DeleteVote,
)
postVoteGroup.GET(
"post-vote/:post-id",
voteroutes.GetVote,
)
postVoteGroup.GET(
"post-votes/:post-id",
voteroutes.GetVotes,
)
// Admin group routes
adminGroup := g.Group("/admin/")
adminGroup.Use(middleware.AdminMiddleware())
adminGroup.GET("testAdmin", testAdmin)
adminGroup.GET("check", testAdmin)
authGroup := g.Group("/auth/")
authGroup.Use(middleware.AuthMiddleware())
authGroup.GET("check", testAuth)
temporal := g.Group("/")
temporal.Use(middleware.AuthMiddleware())
temporal.GET(
"/user/blogs",
blogRoutes.GetUserBlogs,
)
freeGroup.GET(
"/user/:user-id",
userroutes.GetUserUsername,
)
return nil
}

View File

@ -0,0 +1,30 @@
package userroutes
import (
"context"
rest_api_stuff "enshi/REST_API_stuff"
db_repo "enshi/db/go_queries"
"enshi/db_connection"
"enshi/middleware/getters"
"net/http"
"github.com/gin-gonic/gin"
)
func GetUserUsername(c *gin.Context) {
userId, err := getters.GetInt64Param(c, "user-id")
if err != nil {
rest_api_stuff.BadRequestAnswer(c, err)
}
userInfo, err := db_repo.New(db_connection.Dbx).GetUserUsernameById(
context.Background(),
userId,
)
if err != nil {
rest_api_stuff.InternalErrorAnswer(c, err)
}
c.IndentedJSON(http.StatusOK, userInfo)
}

View File

@ -0,0 +1,42 @@
package voteroutes
import (
"context"
rest_api_stuff "enshi/REST_API_stuff"
db_repo "enshi/db/go_queries"
"enshi/db_connection"
"enshi/middleware/getters"
"github.com/gin-gonic/gin"
)
func CreateVote(c *gin.Context) {
var postVoteParams db_repo.CreatePostVoteParams
if err := c.BindJSON(&postVoteParams); err != nil {
rest_api_stuff.BadRequestAnswer(c, err)
return
}
userId, err := getters.GetUserIdFromContext(c)
if err != nil {
rest_api_stuff.BadRequestAnswer(c, err)
return
}
postVoteParams.UserID = userId
postId, err := getters.GetInt64Param(c, "post-id")
if err != nil {
rest_api_stuff.BadRequestAnswer(c, err)
return
}
postVoteParams.PostID = postId
query := db_repo.New(db_connection.Dbx)
if _, err := query.CreatePostVote(context.Background(), postVoteParams); err != nil {
rest_api_stuff.InternalErrorAnswer(c, err)
return
}
rest_api_stuff.OkAnswer(c, "Vote has been created!")
}

View File

@ -0,0 +1,35 @@
package voteroutes
import (
"context"
rest_api_stuff "enshi/REST_API_stuff"
db_repo "enshi/db/go_queries"
"enshi/db_connection"
"enshi/middleware/getters"
"github.com/gin-gonic/gin"
)
func DeleteVote(c *gin.Context) {
var postVoteParams db_repo.DeletePostVoteParams
if err := c.BindJSON(&postVoteParams); err != nil {
rest_api_stuff.BadRequestAnswer(c, err)
return
}
userId, err := getters.GetUserIdFromContext(c)
if err != nil {
rest_api_stuff.BadRequestAnswer(c, err)
return
}
postVoteParams.UserID = userId
query := db_repo.New(db_connection.Dbx)
if err := query.DeletePostVote(context.Background(), postVoteParams); err != nil {
rest_api_stuff.InternalErrorAnswer(c, err)
return
}
rest_api_stuff.OkAnswer(c, "Vote has been deleted!")
}

View File

@ -0,0 +1,40 @@
package voteroutes
import (
"context"
rest_api_stuff "enshi/REST_API_stuff"
db_repo "enshi/db/go_queries"
"enshi/db_connection"
"enshi/middleware/getters"
"net/http"
"github.com/gin-gonic/gin"
)
func GetVote(c *gin.Context) {
var postVoteParams db_repo.GetPostVoteParams
userId, err := getters.GetUserIdFromContext(c)
if err != nil {
rest_api_stuff.BadRequestAnswer(c, err)
return
}
postVoteParams.UserID = userId
postId, err := getters.GetInt64Param(c, "post-id")
if err != nil {
rest_api_stuff.BadRequestAnswer(c, err)
return
}
postVoteParams.PostID = postId
query := db_repo.New(db_connection.Dbx)
if voteData, err := query.GetPostVote(context.Background(), postVoteParams); err != nil {
rest_api_stuff.InternalErrorAnswer(c, err)
return
} else {
c.IndentedJSON(http.StatusOK, gin.H{
"vote": voteData,
})
}
}

View File

@ -0,0 +1,29 @@
package voteroutes
import (
"context"
rest_api_stuff "enshi/REST_API_stuff"
db_repo "enshi/db/go_queries"
"enshi/db_connection"
"enshi/middleware/getters"
"net/http"
"github.com/gin-gonic/gin"
)
func GetVotes(c *gin.Context) {
postId, err := getters.GetInt64Param(c, "post-id")
if err != nil {
rest_api_stuff.BadRequestAnswer(c, err)
return
}
query := db_repo.New(db_connection.Dbx)
if voteData, err := query.GetPostVotes(context.Background(), postId); err != nil {
rest_api_stuff.InternalErrorAnswer(c, err)
return
} else {
c.IndentedJSON(http.StatusOK, voteData)
}
}

View File

@ -0,0 +1,49 @@
package utils
import (
"fmt"
"reflect"
)
func ConvertInt64ToStringInStruct(input any) (any, error) {
origVal := reflect.ValueOf(input)
// Ensure input is a struct or pointer to a struct
if origVal.Kind() == reflect.Ptr {
origVal = origVal.Elem()
}
if origVal.Kind() != reflect.Struct {
return nil, fmt.Errorf("input must be a struct or a pointer to a struct")
}
// Create a new instance of the same type
newStruct := reflect.New(origVal.Type()).Elem()
// Iterate through fields
for i := 0; i < origVal.NumField(); i++ {
field := origVal.Field(i)
newField := newStruct.Field(i)
if !newField.CanSet() {
// Skip unexported fields
continue
}
switch field.Kind() {
case reflect.Int64:
// Convert int64 fields to string if the target is compatible
// if newField.Kind() == reflect.Int64 {
// fmt.Print("aqwrqfwq", field)
// newField.Set(strconv.FormatInt(field.Int(), 10))
// }
newField.SetString("asd")
default:
// Copy other fields directly
if newField.Type() == field.Type() {
newField.Set(field)
}
}
}
return newStruct.Interface(), nil
}

366
package-lock.json generated
View File

@ -5,7 +5,113 @@
"packages": {
"": {
"dependencies": {
"@radix-ui/react-icons": "^1.3.2"
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-toast": "^1.2.2",
"interweave": "^13.1.0"
}
},
"node_modules/@radix-ui/primitive": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz",
"integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==",
"license": "MIT"
},
"node_modules/@radix-ui/react-collection": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.0.tgz",
"integrity": "sha512-GZsZslMJEyo1VKm5L1ZJY8tGDxZNPAoUeQUIbKeJfoi7Q4kmig5AsgLMYYuyYbfjd8fBmFORAIwYAkXMnXZgZw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-context": "1.1.0",
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-slot": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-context": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz",
"integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz",
"integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-context": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz",
"integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.1.tgz",
"integrity": "sha512-QSxg29lfr/xcev6kSz7MAlmDnzbP1eI/Dwn3Tp1ip0KT5CUELsxkekFEMVBEoykI3oV39hKT4TKZzBNMbcTZYQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.0",
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-use-callback-ref": "1.1.0",
"@radix-ui/react-use-escape-keydown": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-icons": {
@ -17,6 +123,240 @@
"react": "^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/@radix-ui/react-portal": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.2.tgz",
"integrity": "sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-use-layout-effect": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-presence": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.1.tgz",
"integrity": "sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-use-layout-effect": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-primitive": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz",
"integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz",
"integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toast": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.2.tgz",
"integrity": "sha512-Z6pqSzmAP/bFJoqMAston4eSNa+ud44NSZTiZUmUen+IOZ5nBY8kzuU5WDBVyFXPtcW6yUalOHsxM/BP6Sv8ww==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.0",
"@radix-ui/react-collection": "1.1.0",
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-dismissable-layer": "1.1.1",
"@radix-ui/react-portal": "1.1.2",
"@radix-ui/react-presence": "1.1.1",
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-use-callback-ref": "1.1.0",
"@radix-ui/react-use-controllable-state": "1.1.0",
"@radix-ui/react-use-layout-effect": "1.1.0",
"@radix-ui/react-visually-hidden": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz",
"integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz",
"integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-callback-ref": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-escape-keydown": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz",
"integrity": "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-callback-ref": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz",
"integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-visually-hidden": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.0.tgz",
"integrity": "sha512-N8MDZqtgCgG5S3aV60INAB475osJousYpZ4cTJ2cFbMpdHS5Y6loLTH8LPtkj2QN0x93J30HT/M3qJXM0+lyeQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.0.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/interweave": {
"version": "13.1.0",
"resolved": "https://registry.npmjs.org/interweave/-/interweave-13.1.0.tgz",
"integrity": "sha512-JIDq0+2NYg0cgL7AB26fBcV0yZdiJvPDBp+aF6k8gq6Cr1kH5Gd2/Xqn7j8z+TGb8jCWZn739jzalCz+nPYwcA==",
"license": "MIT",
"dependencies": {
"escape-html": "^1.0.3"
},
"funding": {
"type": "ko-fi",
"url": "https://ko-fi.com/milesjohnson"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -49,6 +389,30 @@
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
},
"peerDependencies": {
"react": "^18.3.1"
}
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
}
}
}
}

View File

@ -1,5 +1,7 @@
{
"dependencies": {
"@radix-ui/react-icons": "^1.3.2"
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-toast": "^1.2.2",
"interweave": "^13.1.0"
}
}