Merge pull request #9 from Nekiiinkognito/develop

v 0.1.6
This commit is contained in:
Maxim 2025-01-29 13:39:07 +03:00 committed by GitHub
commit 81a3cc5971
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
154 changed files with 5581 additions and 650 deletions

4
.gitignore vendored
View File

@ -13,6 +13,10 @@ dist-ssr
*.local
secret.env
privkey.pem
fullchain.pem
gin.log
# Editor directories and files
.vscode/*
!.vscode/extensions.json

View File

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

25
compose.yml Normal file
View File

@ -0,0 +1,25 @@
services:
nginx:
build: ./enshi
ports:
- 127.0.0.1:80:80
- 127.0.0.1:443:443
networks:
- app-network
restart: unless-stopped
enshi_back:
build: ./enshi_back
ports:
- 127.0.0.1:9876:9876
networks:
- app-network
environment:
- ENV=docker
- DOMAIN=localhost
restart: unless-stopped
networks:
app-network:
driver: bridge

1
enshi/.dockerignore Normal file
View File

@ -0,0 +1 @@
node_modules

22
enshi/Dockerfile Normal file
View File

@ -0,0 +1,22 @@
FROM node:18-alpine as builder
WORKDIR /app
ENV VITE_ENV=docker
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY ./nginx/nginx.conf /etc/nginx/conf.d/default.conf
# Certificates
COPY ./nginx/fullchain.pem /etc/nginx/ssl/
COPY ./nginx/privkey.pem /etc/nginx/ssl/
EXPOSE 80
EXPOSE 443

View File

@ -1,50 +1 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
- Configure the top-level `parserOptions` property like this:
```js
export default tseslint.config({
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
})
```
- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked`
- Optionally add `...tseslint.configs.stylisticTypeChecked`
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config:
```js
// eslint.config.js
import react from 'eslint-plugin-react'
export default tseslint.config({
// Set the react version
settings: { react: { version: '18.3' } },
plugins: {
// Add the react plugin
react,
},
rules: {
// other rules...
// Enable its recommended rules
...react.configs.recommended.rules,
...react.configs['jsx-runtime'].rules,
},
})
```
# README

View File

@ -1,28 +0,0 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)

37
enshi/nginx/nginx.conf Normal file
View File

@ -0,0 +1,37 @@
server {
listen 80;
server_name localhost;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
# server_name nekiiinkognito.ru www.nekiiinkognito.ru;
server_name localhost;
ssl_certificate /etc/nginx/ssl/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_ciphers EECDH+AESGCM:EDH+AESGCM;
ssl_ecdh_curve secp384r1;
ssl_session_timeout 10m;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
ssl_stapling on;
ssl_stapling_verify on;
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
}
location /api/v1/ {
proxy_pass http://enshi_back:9876/;
}
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
}

647
enshi/package-lock.json generated
View File

@ -8,17 +8,25 @@
"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",
"interweave": "^13.1.0",
"jotai": "^2.9.3",
"jotai-immer": "^0.4.1",
"primereact": "^10.8.2",
"quill": "^2.0.2",
"react": "^18.3.1",
@ -1226,6 +1234,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",
@ -1413,25 +1457,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": "*",
@ -1448,6 +1492,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",
@ -1618,6 +1814,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",
@ -2029,17 +2234,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"
@ -2059,6 +2264,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",
@ -2212,6 +2456,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",
@ -2503,6 +2871,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",
@ -2539,6 +2943,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",
@ -2807,9 +3242,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",
@ -2817,12 +3262,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",
@ -2832,6 +3277,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",
@ -3336,6 +3798,12 @@
"node": ">=10"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/autoprefixer": {
"version": "10.4.20",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz",
@ -3374,6 +3842,17 @@
"postcss": "^8.1.0"
}
},
"node_modules/axios": {
"version": "1.7.7",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz",
"integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@ -3602,6 +4081,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/commander": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
@ -3738,6 +4229,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/detect-node-es": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
@ -3926,6 +4426,12 @@
"node": ">=6"
}
},
"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/escape-string-regexp": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
@ -4357,6 +4863,26 @@
"dev": true,
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/foreground-child": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz",
@ -4374,6 +4900,20 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/form-data": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz",
"integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fraction.js": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
@ -4745,6 +5285,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",
@ -4778,6 +5328,22 @@
"integrity": "sha512-qlD8YNDqyTKTyuITrDOffsl6Tdhv+UC4hcdAVuQsK4IMQ99nSgd1MIA/Q+jQYoh9r3hVUXhYh7urSRmXPkW04g==",
"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/invariant": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
@ -4970,6 +5536,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",
@ -5185,6 +5761,27 @@
"node": ">=8.6"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@ -5712,6 +6309,12 @@
"react-is": "^16.13.1"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",

View File

@ -1,26 +1,35 @@
{
"name": "enshi",
"private": true,
"version": "0.0.0",
"version": "0.1.6",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint-build": "tsc -b && vite build",
"build": "vite build",
"lint": "eslint .",
"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",
"interweave": "^13.1.0",
"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

@ -0,0 +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,42 +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 { useEffect } from "react";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import "axios";
import { axiosLocalhost } from "./api/axios/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() {
useEffect(() => {
let f = async () => {
let c = await axiosLocalhost.post(
"/login",
{
nickname: "StasikChess",
password: "123456",
}
);
console.log(c.headers);
console.log(document.cookie);
};
f();
}, []);
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

@ -0,0 +1,50 @@
import { atom } from "jotai";
import { atomWithStorage } from "jotai/utils";
import { TUser } from "../@types/UserType";
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 +0,0 @@
import { Container } from "@radix-ui/themes";
import React from "react";
export default function ArcticleViewer() {
return (
<>
<div className="ql-snow">
<Container className="mt-4 ql-editor">
</Container>
</div>
</>
);
}

View File

@ -0,0 +1,146 @@
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 />;
return (
<>
<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,67 +1,15 @@
import { Button, Card, ChevronDownIcon, Text } from "@radix-ui/themes";
import * as NavigationMenu from "@radix-ui/react-navigation-menu";
import { useLocation, useNavigate } from "react-router-dom";
import CustomNavigationMenu from "./NavigationMenu/NavigationMenu";
import RightButtonBar from "./RightButtonBar/RightButtonBar";
import SearchField from "./SearchField/SearchField";
export default function NavBar() {
return (
<nav className="pt-2">
<NavigationMenu.Root
orientation="horizontal"
className="flex justify-center"
>
<NavigationMenu.List className="flex justify-center gap-2">
<NavItem text="Cringer" to="/" />
<nav className="flex justify-center pt-2 pb-2 ml-4 mr-4 flex-[1] max-h-fit">
<CustomNavigationMenu />
<NavItem text="C-Cringer" to="/c" />
<SearchField />
<NavigationMenu.Item className="text-center">
<NavigationMenu.Trigger className="flex items-center">
<Button
asChild
className="w-fit pr-2 h-fit rounded-full m-0 p-0 pl-2 mt-2 mb-2 duration-[50ms]"
variant="ghost"
highContrast
>
<Text
size={"3"}
className="flex items-center gap-1"
>
Cringer 123 <ChevronDownIcon />
</Text>
</Button>
</NavigationMenu.Trigger>
<NavigationMenu.Content className="absolute data-[motion=from-start]:scale-150">
<Card>asd</Card>
</NavigationMenu.Content>
</NavigationMenu.Item>
</NavigationMenu.List>
</NavigationMenu.Root>
<RightButtonBar />
</nav>
);
}
type TNavItem = {
text: string;
to: string;
};
function NavItem(props: TNavItem) {
const navigate = useNavigate();
const location = useLocation();
return (
<NavigationMenu.Item>
<NavigationMenu.Link>
<Button
className="w-fit h-fit rounded-full m-0 p-0 pr-2 pl-2 mt-2 mb-2 duration-[50ms]"
highContrast
variant={location.pathname === props.to ? "solid" : "ghost"}
onClick={() => navigate(props.to)}
>
<Text size={"3"}>{props.text}</Text>
</Button>
</NavigationMenu.Link>
</NavigationMenu.Item>
);
}

View File

@ -0,0 +1,62 @@
import * as NavigationMenu from "@radix-ui/react-navigation-menu";
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={t("home")} to="/" />
<NavItem text={t("following")} to="/c" />
</NavigationMenu.List>
</NavigationMenu.Root>
</div>
);
}
type TNavItem = {
text: string;
to: string;
};
function NavItem(props: TNavItem) {
const navigate = useNavigate();
const location = useLocation();
const theme = useThemeContext();
return (
<div className="relative flex flex-col">
<NavigationMenu.Item>
<NavigationMenu.Link>
<div>
<Button
className="w-fit border-0 border-b-[0px] border-solid"
highContrast
variant="ghost"
onClick={() => navigate(props.to)}
>
<Heading weight={"medium"} size={"3"}>
{props.text}
</Heading>
</Button>
</div>
</NavigationMenu.Link>
</NavigationMenu.Item>
{location.pathname == props.to ? (
<div
className={`absolute animate-widthOut bottom-[-0.35rem]
w-full h-[2px] z-[999] rounded-full`}
style={{
background: `var(--${theme.accentColor}-10)`,
}}
></div>
) : null}
</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

@ -0,0 +1,76 @@
import {
EnterIcon,
ExitIcon,
LaptopIcon,
PersonIcon,
} from "@radix-ui/react-icons";
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 { 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="">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<IconButton>
<PersonIcon />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content className="w-fit">
<DropdownMenu.Item>
<Link to={"/user/:user-id/profile"}>
<Flex className="justify-between gap-2">
<Icon>
<PersonIcon />
</Icon>
<Text>{t("profile")}</Text>
</Flex>
</Link>
</DropdownMenu.Item>
<DropdownMenu.Item>
<Link to={"/user/blogs"}>
<Flex className="justify-between gap-2">
<Icon>
<LaptopIcon />
</Icon>
<Text>{t("yourBlogs")}</Text>
</Flex>
</Link>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item color={user ? "red" : "green"}>
{user ? (
<Flex className="justify-between gap-2">
<Icon>
<ExitIcon />
</Icon>
<Text>{t("signOut")}</Text>
</Flex>
) : (
<Link to={"/login"}>
<Flex className="justify-between gap-2">
<Icon>
<EnterIcon />
</Icon>
<Text>{t("signIn")}</Text>
</Flex>
</Link>
)}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
);
}

View File

@ -0,0 +1,20 @@
import { MagnifyingGlassIcon } from "@radix-ui/react-icons";
import { TextField } from "@radix-ui/themes";
import { useTranslation } from "react-i18next";
export default function SearchField() {
const {t} = useTranslation()
return (
<div className="flex justify-center flex-1">
<TextField.Root
className="w-2/3 rounded-lg"
placeholder={t("search")}
>
<TextField.Slot>
<MagnifyingGlassIcon />
</TextField.Slot>
</TextField.Root>
</div>
);
}

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,23 +0,0 @@
import React from 'react'
import { Outlet } from 'react-router-dom'
import NavBar from '../../Components/NavBar/NavBar'
import { axiosLocalhost } from '../../api/axios/axios'
export default function MainPage() {
return (
<>
<NavBar />
<Outlet />
<button
onClick={
async () => {
let d = await axiosLocalhost.get("getCookie")
console.log(d.data);
}
}>
qwpofjqwifhqwuif
</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

@ -1,8 +1,12 @@
import axios from "axios";
const environment = import.meta.env.VITE_ENV || 'development';
// const environment = "docker"
const baseURL = environment === "docker" ? "https://localhost/api/v1/" : "http://127.0.0.1:9876/";
export const axiosLocalhost = axios.create(
{
baseURL: `http://localhost:9876/`,
baseURL: baseURL,
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

@ -1,10 +1,19 @@
@import url('https://fonts.googleapis.com/css2?family=Edu+AU+VIC+WA+NT+Pre:wght@400..700&family=Playfair+Display:ital,wght@0,400..900;1,400..900&display=swap');
@tailwind base;
@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:
--heading-font-family:
--default-font-family: "Times New Roman"; ;
--heading-font-family: "Edu AU VIC WA NT Pre", cursive;
/* Your custom font for <Heading> components */
--code-font-family:
/* Your custom font for <Code> components */

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,5 +1,53 @@
const en = {
hello: "hello!"
}
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,5 +1,56 @@
const ru = {
hello: "Привет!"
}
export default ru;
const ru = {
hello: "Привет!",
search: "Поиск...",
username: "Имя пользователя",
email: "Электронная почта",
password: "Пароль",
confirmPassword: "Подтвердите пароль",
submit: "Подтвердить",
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>Cringer path</Text>} />
<Route path="/" errorElement={<ErrorBoundary />} element={<MainPage />}>
<Route index element={<RandomPostsPage />} />
<Route
path="/a?/c"
element={<Text>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

@ -1,21 +1,53 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
animation: {
'appear': 'appear 0.25s'
},
keyframes: {
appear: {
'100%': {opacity: '1'}
}
}
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {
colors: {
"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" },
},
widthOut: {
"0%": {
width: "0%",
left: "50%",
},
"100%": {
width: "100%",
left: "0%",
},
},
},
},
},
},
plugins: [],
}
plugins: [],
};

View File

View File

@ -1,5 +1,5 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
// https://vitejs.dev/config/
export default defineConfig({

8
enshi_back/.dockerignore Normal file
View File

@ -0,0 +1,8 @@
main
*.log
*.swp
*.tmp
*.out
node_modules
.idea
.vscode

View File

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

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,30 @@
package globalrules
import (
"enshi/auth"
"enshi/global"
"fmt"
"net/http"
"github.com/gin-gonic/gin"
)
func AuthorizedRule(c *gin.Context) (bool, []error) {
cookies := c.Request.CookiesNamed("auth_cookie")
if len(cookies) == 0 {
return false, []error{fmt.Errorf("no cookies provided")}
}
tokenFromCookies := cookies[0].Value
cookieClimes, err := auth.ValidateToken(tokenFromCookies)
if err != nil {
c.IndentedJSON(http.StatusUnauthorized, gin.H{"error auth": err.Error()})
c.Abort()
return false, []error{err}
} else {
c.Set(global.ContextUserId, cookieClimes["id"])
c.Set(global.ContextTokenData, cookieClimes)
}
return true, nil
}

View File

@ -0,0 +1,33 @@
package globalrules
import (
"context"
db_repo "enshi/db/go_queries"
"enshi/db_connection"
"enshi/middleware/getters"
"fmt"
"github.com/gin-gonic/gin"
)
func IsAdminRule(c *gin.Context) (bool, []error) {
contextUserId, err := getters.GetUserIdFromContext(c)
if err != nil {
return false, []error{err}
}
user, err :=
db_repo.New(db_connection.Dbx).
GetUserById(context.Background(), contextUserId)
if err != nil || user.UserID == 0 {
return false, []error{err}
}
if !user.IsAdmin {
return false, []error{fmt.Errorf("not admin")}
}
return true, nil
}

View File

@ -0,0 +1,39 @@
package globalrules
import (
"context"
db_repo "enshi/db/go_queries"
"enshi/db_connection"
"enshi/middleware/getters"
"fmt"
"github.com/gin-gonic/gin"
)
func IsOwnerOfTheBlogRule(c *gin.Context) (bool, []error) {
blogId, err := getters.GetInt64Param(c, "blog-id")
if err != nil {
return false, []error{err}
}
contextUserId, err := getters.GetUserIdFromContext(c)
if err != nil {
return false, []error{err}
}
blog, err :=
db_repo.New(db_connection.Dbx).
GetBlogByBlogId(context.Background(), blogId)
if err != nil {
return false, []error{err}
}
if blog.UserID != contextUserId {
return false, []error{fmt.Errorf("now owner of the blog")}
}
return true, nil
}

View File

@ -0,0 +1,39 @@
package globalrules
import (
"context"
db_repo "enshi/db/go_queries"
"enshi/db_connection"
"enshi/middleware/getters"
"fmt"
"github.com/gin-gonic/gin"
)
func IsOwnerOfThePostRule(c *gin.Context) (bool, []error) {
postId, err := getters.GetInt64Param(c, "post-id")
if err != nil {
return false, []error{err}
}
contextUserId, err := getters.GetUserIdFromContext(c)
if err != nil {
return false, []error{err}
}
post, err :=
db_repo.New(db_connection.Dbx).
GetPostsByPostId(context.Background(), postId)
if err != nil {
return false, []error{err}
}
if post.UserID != contextUserId {
return false, []error{fmt.Errorf("now owner of the post")}
}
return true, nil
}

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

@ -0,0 +1,48 @@
package postspolicies
import (
"enshi/ABAC/PostsPolicies/postRules"
"enshi/ABAC/rules"
"github.com/gin-gonic/gin"
)
const (
DELETE_POST = "delete_post"
DELETE_POST_BLOG = "delete_post_blog"
UPDATE_POST = "update_post"
UPDATE_POST_BLOG = "update_post_blog"
CREATE_POST = "create_post"
GET_POST = "get_post"
)
func PostsPolicies(c *gin.Context) (bool, []error) {
target, exists := c.Get("target")
if !exists {
return false, nil
}
// Permit if one permit
switch target {
case DELETE_POST:
return rules.CheckRule(c, postRules.DeleteRule)
case DELETE_POST_BLOG:
return rules.CheckRule(c, postRules.DeletePostFromBlogRule)
case UPDATE_POST:
return rules.CheckRule(c, postRules.PostUpdateRule)
case UPDATE_POST_BLOG:
return rules.CheckRule(c, postRules.UpdatePostBlogRule)
case GET_POST:
return rules.CheckRule(c, postRules.PostReadRule)
case CREATE_POST:
return rules.CheckRule(c, postRules.PostCreateRule)
}
return false, nil
}

View File

@ -0,0 +1,23 @@
package postRules
import (
globalrules "enshi/ABAC/GlobalRules"
"enshi/ABAC/rules"
"github.com/gin-gonic/gin"
)
// Only owner of the post can change it
func PostCreateRule(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,24 @@
package postRules
import (
globalrules "enshi/ABAC/GlobalRules"
"enshi/ABAC/rules"
"github.com/gin-gonic/gin"
)
func DeletePostFromBlogRule(c *gin.Context) (bool, []error) {
rulesToCheck := []rules.RuleFunction{
globalrules.AuthorizedRule,
globalrules.IsOwnerOfThePostRule,
globalrules.IsOwnerOfTheBlogRule,
}
isAllowed, errors := rules.CheckRules(
c,
rulesToCheck,
RULES_NUMBER_TO_COMPLETE,
)
return isAllowed, errors
}

View File

@ -0,0 +1,27 @@
package postRules
import (
globalrules "enshi/ABAC/GlobalRules"
"enshi/ABAC/rules"
"github.com/gin-gonic/gin"
)
const RULES_NUMBER_TO_COMPLETE = 2
// Only owner or admin can delete post
func DeleteRule(c *gin.Context) (bool, []error) {
rulesToCheck := []rules.RuleFunction{
globalrules.AuthorizedRule,
globalrules.IsOwnerOfThePostRule,
globalrules.IsAdminRule,
}
isAllowed, errors := rules.CheckRules(
c,
rulesToCheck,
RULES_NUMBER_TO_COMPLETE,
)
return isAllowed, errors
}

View File

@ -0,0 +1,10 @@
package postRules
import (
"github.com/gin-gonic/gin"
)
// Only owner of the post can change it
func PostReadRule(c *gin.Context) (bool, []error) {
return true, nil
}

View File

@ -0,0 +1,25 @@
package postRules
import (
globalrules "enshi/ABAC/GlobalRules"
"enshi/ABAC/rules"
"github.com/gin-gonic/gin"
)
// Only user that own target post and blog can do that
func UpdatePostBlogRule(c *gin.Context) (bool, []error) {
rulesToCheck := []rules.RuleFunction{
globalrules.AuthorizedRule,
globalrules.IsOwnerOfThePostRule,
globalrules.IsOwnerOfTheBlogRule,
}
isAllowed, errors := rules.CheckRules(
c,
rulesToCheck,
rules.ALL_RULES_MUST_BE_COMPLETED,
)
return isAllowed, errors
}

View File

@ -0,0 +1,24 @@
package postRules
import (
globalrules "enshi/ABAC/GlobalRules"
"enshi/ABAC/rules"
"github.com/gin-gonic/gin"
)
// Only owner of the post can change it
func PostUpdateRule(c *gin.Context) (bool, []error) {
rulesToCheck := []rules.RuleFunction{
globalrules.AuthorizedRule,
globalrules.IsOwnerOfThePostRule,
}
isAllowed, errors := rules.CheckRules(
c,
rulesToCheck,
rules.ALL_RULES_MUST_BE_COMPLETED,
)
return isAllowed, errors
}

View File

@ -0,0 +1,31 @@
package profilepolicies
import (
profilesrules "enshi/ABAC/ProfilePolicies/ProfilesRules"
"enshi/ABAC/rules"
"github.com/gin-gonic/gin"
)
const (
RESET_PROFILE = "reset_profile"
UPDATE_PROFILE = "update_profile"
CREATE_PROFILE = "create_profile"
GET_PROFILE = "get_profile"
)
func ProfilePolicies(c *gin.Context) (bool, []error) {
target, exists := c.Get("target")
if !exists {
return false, nil
}
// Permit if one permit
switch target {
case UPDATE_PROFILE:
return rules.CheckRule(c, profilesrules.UpdateProfileRule)
}
return false, nil
}

View File

@ -0,0 +1,22 @@
package profilesrules
import (
globalrules "enshi/ABAC/GlobalRules"
"enshi/ABAC/rules"
"github.com/gin-gonic/gin"
)
func UpdateProfileRule(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,40 @@
package blogspolicies
import (
blogrules "enshi/ABAC/blogsPolicies/blogRules"
"enshi/ABAC/rules"
"github.com/gin-gonic/gin"
)
const (
DELETE_BLOG = "delete_blog"
UPDATE_BLOG = "update_blog"
CREATE_BLOG = "create_blog"
GET_BLOG = "get_blog"
)
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_BLOG:
return rules.CheckRule(c, blogrules.BlogDeleteRule)
case UPDATE_BLOG:
return rules.CheckRule(c, blogrules.BlogUpdateRule)
case GET_BLOG:
return rules.CheckRule(c, blogrules.BlogReadRule)
case CREATE_BLOG:
return rules.CheckRule(c, blogrules.BlogCreateRule)
}
return false, nil
}

View File

@ -0,0 +1,22 @@
package blogrules
import (
globalrules "enshi/ABAC/GlobalRules"
"enshi/ABAC/rules"
"github.com/gin-gonic/gin"
)
func BlogCreateRule(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,24 @@
package blogrules
import (
globalrules "enshi/ABAC/GlobalRules"
"enshi/ABAC/rules"
"github.com/gin-gonic/gin"
)
func BlogDeleteRule(c *gin.Context) (bool, []error) {
rulesToCheck := []rules.RuleFunction{
globalrules.AuthorizedRule,
globalrules.IsOwnerOfTheBlogRule,
globalrules.IsAdminRule,
}
isAllowed, errors := rules.CheckRules(
c,
rulesToCheck,
2,
)
return isAllowed, errors
}

View File

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

View File

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

View File

@ -0,0 +1,72 @@
package rules
import (
"fmt"
"strconv"
"github.com/gin-gonic/gin"
)
type RuleFunction func(*gin.Context) (bool, []error)
const (
ALL_RULES_MUST_BE_COMPLETED = iota
)
func CheckRule(
c *gin.Context,
ruleChecker RuleFunction,
) (bool, []error) {
IsAllowed, err := ruleChecker(c)
if err != nil {
return false, err
}
return IsAllowed, nil
}
func CheckRules(
c *gin.Context,
rules []RuleFunction,
completedRulesCount int,
) (bool, []error) {
var allowancesIndexes []int
var errors []error
if len(rules) < completedRulesCount {
return false, []error{fmt.Errorf("there is less rules, that should be completed")}
}
for i, rule := range rules {
if isAllowed, err := CheckRule(c, rule); err != nil {
errors = append(
errors,
err...,
)
} else if !isAllowed {
errors = append(
errors,
fmt.Errorf("rule "+
strconv.Itoa(i)+
" was rejected"),
)
} else {
allowancesIndexes = append(allowancesIndexes, i)
}
}
switch completedRulesCount {
case ALL_RULES_MUST_BE_COMPLETED:
if len(allowancesIndexes) == len(rules) {
return true, nil
} else {
return false, errors
}
default:
if len(allowancesIndexes) >= completedRulesCount {
return true, nil
} else {
return false, errors
}
}
}

View File

@ -0,0 +1,28 @@
package rules
import (
rest_api_stuff "enshi/REST_API_stuff"
"fmt"
"net/http"
"github.com/gin-gonic/gin"
)
func ShouldAbortRequest(c *gin.Context, isAllowed bool, errors []error) bool {
var errorsMap = map[int]string{}
for i, error := range errors {
errorsMap[i] = error.Error()
}
if errors != nil {
c.IndentedJSON(http.StatusUnauthorized, errorsMap)
return true
}
if !isAllowed {
rest_api_stuff.UnauthorizedAnswer(c, fmt.Errorf("you have no permission"))
return true
}
return false
}

View File

@ -21,7 +21,7 @@ RETURNING blog_id, user_id, title, description, category_id, created_at
type CreateBlogByUserIdParams struct {
BlogID int64 `json:"blog_id"`
UserID int64 `json:"user_id"`
Title pgtype.Text `json:"title"`
Title pgtype.Text `json:"title" validate:"required"`
Description pgtype.Text `json:"description"`
CategoryID pgtype.Int4 `json:"category_id"`
}
@ -56,6 +56,26 @@ func (q *Queries) DeleteBlogByBlogId(ctx context.Context, blogID int64) error {
return err
}
const getBlogByBlogId = `-- name: GetBlogByBlogId :one
SELECT blog_id, user_id, title, description, category_id, created_at
FROM public.blogs
WHERE blog_id = $1
`
func (q *Queries) GetBlogByBlogId(ctx context.Context, blogID int64) (Blog, error) {
row := q.db.QueryRow(ctx, getBlogByBlogId, blogID)
var i Blog
err := row.Scan(
&i.BlogID,
&i.UserID,
&i.Title,
&i.Description,
&i.CategoryID,
&i.CreatedAt,
)
return i, err
}
const getBlogsByUserId = `-- name: GetBlogsByUserId :many
SELECT blog_id, user_id, title, description, category_id, created_at
FROM public.blogs
@ -97,7 +117,7 @@ RETURNING blog_id, user_id, title, description, category_id, created_at
`
type UpdateBlogInfoByBlogIdParams struct {
Title pgtype.Text `json:"title"`
Title pgtype.Text `json:"title" validate:"required"`
Description pgtype.Text `json:"description"`
CategoryID pgtype.Int4 `json:"category_id"`
BlogID int64 `json:"blog_id"`

View File

@ -11,7 +11,7 @@ import (
type Blog struct {
BlogID int64 `json:"blog_id"`
UserID int64 `json:"user_id"`
Title pgtype.Text `json:"title"`
Title pgtype.Text `json:"title" validate:"required"`
Description pgtype.Text `json:"description"`
CategoryID pgtype.Int4 `json:"category_id"`
CreatedAt pgtype.Timestamp `json:"created_at"`

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,29 +146,79 @@ 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
WHERE post_id = $1
RETURNING post_id, blog_id, user_id, title, content, created_at, updated_at
`
type UpdatePostBlogIdParams struct {
PostID int64 `json:"post_id"`
BlogID pgtype.Int8 `json:"blog_id"`
}
func (q *Queries) UpdatePostBlogId(ctx context.Context, arg UpdatePostBlogIdParams) error {
_, err := q.db.Exec(ctx, updatePostBlogId, arg.PostID, arg.BlogID)
return err
}
const updatePostByPostId = `-- name: UpdatePostByPostId :one
UPDATE public.posts
SET blog_id=$1, user_id=$2, title=$3, "content"=$4, updated_at=CURRENT_TIMESTAMP
WHERE post_id = $5
SET title=$1, "content"=$2, updated_at=CURRENT_TIMESTAMP
WHERE post_id = $3
RETURNING post_id, blog_id, user_id, title, content, created_at, updated_at
`
type UpdatePostByPostIdParams struct {
BlogID pgtype.Int8 `json:"blog_id"`
UserID int64 `json:"user_id"`
Title pgtype.Text `json:"title"`
Content pgtype.Text `json:"content"`
PostID int64 `json:"post_id"`
}
func (q *Queries) UpdatePostByPostId(ctx context.Context, arg UpdatePostByPostIdParams) (Post, error) {
row := q.db.QueryRow(ctx, updatePostByPostId,
arg.BlogID,
arg.UserID,
arg.Title,
arg.Content,
arg.PostID,
)
row := q.db.QueryRow(ctx, updatePostByPostId, arg.Title, arg.Content, arg.PostID)
var i Post
err := row.Scan(
&i.PostID,

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

@ -15,6 +15,11 @@ SELECT *
FROM public.blogs
WHERE user_id = $1;
-- name: GetBlogByBlogId :one
SELECT *
FROM public.blogs
WHERE blog_id = $1;
-- name: DeleteBlogByBlogId :exec
DELETE FROM public.blogs
WHERE blog_id=$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

@ -21,10 +21,22 @@ RETURNING *;
-- name: UpdatePostByPostId :one
UPDATE public.posts
SET blog_id=$1, user_id=$2, title=$3, "content"=$4, updated_at=CURRENT_TIMESTAMP
WHERE post_id = $5
SET title=$1, "content"=$2, updated_at=CURRENT_TIMESTAMP
WHERE post_id = $3
RETURNING *;
-- name: DeletePostByPostId :exec
DELETE FROM public.posts
WHERE post_id=$1;
WHERE post_id=$1;
-- name: UpdatePostBlogId :exec
UPDATE public.posts
SET blog_id=$2, updated_at=CURRENT_TIMESTAMP
WHERE post_id = $1
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

@ -18,6 +18,9 @@ sql:
- column: users.email
go_struct_tag: validate:"required,email"
- column: blogs.title
go_struct_tag: validate:"required"
- db_type: "uuid"
go_type:
import: 'github.com/google/uuid'

Some files were not shown because too many files have changed in this diff Show More