diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index f454300..6060120 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -4,8 +4,6 @@ stages: variables: DOCKER_TLS_CERTDIR: /certs - FRONTEND_IMAGE: $CI_REGISTRY_IMAGE/frontend - BACKEND_IMAGE: $CI_REGISTRY_IMAGE/backend .build-template: &build-template image: @@ -26,9 +24,12 @@ variables: --destination "${IMAGE_NAME}:${CI_COMMIT_SHA}" --destination "${IMAGE_NAME}:latest" --cache=true + --registry-mirror=dockerhub.timeweb.cloud build_frontend: <<: *build-template + rules: + - if: '$CI_COMMIT_REF_NAME == "master"' variables: CONTEXT: "${CI_PROJECT_DIR}/services/frontend" DOCKERFILE_PATH: "Dockerfile" @@ -36,6 +37,8 @@ build_frontend: build_backend: <<: *build-template + rules: + - if: '$CI_COMMIT_REF_NAME == "master"' variables: CONTEXT: "${CI_PROJECT_DIR}/services/backend" DOCKERFILE_PATH: "Dockerfile" @@ -43,13 +46,15 @@ build_backend: build_backend-staticfiles: <<: *build-template + rules: + - if: '$CI_COMMIT_REF_NAME == "master"' variables: CONTEXT: "${CI_PROJECT_DIR}/services/backend" DOCKERFILE_PATH: "Dockerfile.staticfiles" IMAGE_NAME: "$CI_REGISTRY_IMAGE/backend-staticfiles" deploy: - image: alpine:latest + image: kroniak/ssh-client:3.19 stage: deploy rules: - if: '$CI_COMMIT_REF_NAME == "master"' @@ -57,22 +62,25 @@ deploy: SSH_HOST: "158.160.172.23" SSH_USER: "ubuntu" SSH_ADDRESS: "$SSH_USER@$SSH_HOST" - SSH_PRIVATE_KEY_BASE64: "$ENV_PRIVATE_KEY_BASE64" - before_script: - - apk add --no-cache openssh-client + SSH_PRIVATE_KEY: SSH_PRIVATE_KEY script: - - echo $SSH_PRIVATE_KEY_BASE64 - - mkdir -p ~/.ssh && chmod 700 ~/.ssh - - echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config && chmod 600 ~/.ssh/config - - echo "$SSH_PRIVATE_KEY_BASE64" | base64 -d > ~/.ssh/id_rsa && chmod 400 ~/.ssh/id_rsa - - cat ~/.ssh/id_rsa - - ssh-agent sh -c "ssh-add ~/.ssh/id_rsa" - - ssh-keyscan -H "$SSH_HOST" - - scp -C -r infrastructure/ compose.yaml "$SSH_ADDRESS":~/deploy/ - - ssh "$SSH_ADDRESS" << 'EOF' - set -e - cd ~/deploy - docker compose pull - docker compose down - docker compose up -d --remove-orphans + - mkdir -p ~/.ssh + - chmod 700 ~/.ssh + - echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config + - echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa + - chmod 600 ~/.ssh/id_rsa + - ssh-keyscan -H "$SSH_HOST" > /dev/null 2>&1 + + - AUTH_COMMAND="echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY --username $CI_REGISTRY_USER --password-stdin"; + - ssh $SSH_ADDRESS "$AUTH_COMMAND" + - scp -C -r infrastructure/ compose.yaml $SSH_ADDRESS:~/deploy/ + - ssh $SSH_ADDRESS "docker -v" + - | + ssh $SSH_ADDRESS <<'EOF' + cd ~/deploy + docker compose pull > deploy.log 2>&1 + docker compose down >> deploy.log 2>&1 + docker compose up -d --remove-orphans >> deploy.log 2>&1 + docker compose ps >> deploy.log 2>&1 EOF + - ssh $SSH_ADDRESS "docker system prune -a --force" diff --git a/README.md b/README.md index bedd1d8..6c44ca1 100644 --- a/README.md +++ b/README.md @@ -1 +1 @@ -# project_name +# DataRush diff --git a/compose.yaml b/compose.yaml index 91b6a69..b2f0751 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,4 +1,4 @@ -name: project_name +name: datarush services: backend: @@ -131,7 +131,7 @@ services: - name: web target: 80 published: 8002 - host_ip: 127.0.0.1 + host_ip: 0.0.0.0 protocol: tcp restart: unless-stopped @@ -372,7 +372,7 @@ services: protocol: tcp volumes: - type: bind - source: /ssl + source: ./infrastructure/ssl target: /etc/nginx/certs restart: unless-stopped diff --git a/infrastructure/.gitignore b/infrastructure/.gitignore index dad66cc..8e9f998 100644 --- a/infrastructure/.gitignore +++ b/infrastructure/.gitignore @@ -1,5 +1,2 @@ # Custom environment files .env - -# Password files -password diff --git a/infrastructure/nginx/nginx.conf b/infrastructure/nginx/nginx.conf index 08545c6..0286b05 100644 --- a/infrastructure/nginx/nginx.conf +++ b/infrastructure/nginx/nginx.conf @@ -5,48 +5,65 @@ error_log /var/log/nginx/error.log warn; pid /var/run/nginx.pid; events { - worker_connections 1024; + worker_connections 4096; } http { include /etc/nginx/mime.types; default_type application/octet-stream; - ssl_session_cache shared:SSL:10m; - ssl_session_timeout 10m; + ssl_session_cache shared:SSL:50m; + ssl_session_timeout 1d; + ssl_session_tickets off; ssl_protocols TLSv1.2 TLSv1.3; - ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256; + ssl_ecdh_curve X25519:secp521r1:secp384r1; ssl_prefer_server_ciphers on; - ssl_ecdh_curve secp384r1; ssl_stapling on; ssl_stapling_verify on; - resolver 1.1.1.1 1.0.0.1 valid=300s; + resolver 1.1.1.1 1.0.0.1 8.8.8.8 valid=300s; + resolver_timeout 5s; add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "SAMEORIGIN" always; - add_header X-XSS-Protection "1; mode=block" always; + add_header X-XSS-Protection "0"; add_header Referrer-Policy "strict-origin-when-cross-origin" always; - add_header Content-Security-Policy "default-src 'self'; frame-ancestors 'none'; form-action 'self'; object-src 'none'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; img-src 'self' data:; style-src 'self' 'unsafe-inline';" always; + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; + add_header Permissions-Policy "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https:; img-src 'self' data: https:; style-src 'self' 'unsafe-inline' https:; font-src 'self' https:; frame-ancestors 'none'; form-action 'self'; object-src 'none'; base-uri 'self';" always; sendfile on; tcp_nopush on; tcp_nodelay on; - keepalive_timeout 65; - client_body_timeout 12; - client_header_timeout 12; + keepalive_timeout 75; + client_body_timeout 15; + client_header_timeout 15; reset_timedout_connection on; - send_timeout 10; + send_timeout 15; - client_body_buffer_size 16K; - client_header_buffer_size 1K; - client_max_body_size 8M; - large_client_header_buffers 4 8K; + client_body_buffer_size 128k; + client_header_buffer_size 4k; + client_max_body_size 100M; + large_client_header_buffers 4 16k; gzip on; gzip_vary on; gzip_proxied any; gzip_comp_level 6; - gzip_types text/plain text/css text/xml application/json application/javascript application/xml+rss; + gzip_min_length 1024; + gzip_types + text/plain + text/css + text/xml + text/javascript + application/json + application/javascript + application/x-javascript + application/xml + application/xml+rss + font/woff + font/woff2 + image/svg+xml; log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' @@ -55,38 +72,36 @@ http { access_log /var/log/nginx/access.log main; server_tokens off; - more_clear_headers 'Server'; server { listen 80 default_server; + listen [::]:80 default_server; server_name _; return 301 https://$host$request_uri; } server { listen 443 ssl http2 default_server; - server_name _; - return 204; - } - - server { - listen 443 ssl http2; - server_name _; + listen [::]:443 ssl http2 default_server; + server_name prod-team-15-2pc0i3lc.final.prodcontest.ru; ssl_certificate /etc/nginx/certs/fullchain.pem; ssl_certificate_key /etc/nginx/certs/privkey.pem; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + location / { proxy_pass http://frontend:80; - - add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; - add_header Permissions-Policy "geolocation=(),midi=(),sync-xhr=(),microphone=(),camera=(),magnetometer=(),gyroscope=(),fullscreen=(self),payment=()"; - + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; proxy_hide_header X-Powered-By; proxy_connect_timeout 75s; @@ -96,36 +111,35 @@ http { location /api { proxy_pass http://backend:8080; - + proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; - add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; - add_header Access-Control-Allow-Origin "$http_origin" always; - add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always; - add_header Access-Control-Allow-Headers "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization" always; - add_header Access-Control-Expose-Headers "Content-Length,Content-Range" always; - - proxy_connect_timeout 75s; - proxy_send_timeout 600s; - proxy_read_timeout 600s; - - proxy_buffering off; - proxy_request_buffering off; - } - - location /api { if ($request_method = 'OPTIONS') { add_header 'Access-Control-Allow-Origin' "$http_origin"; add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS'; - add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization'; + add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, X-Requested-With'; add_header 'Access-Control-Max-Age' 1728000; - add_header 'Content-Type' 'text/plain; charset=utf-8'; + add_header 'Content-Type' 'text/plain; charset=UTF-8'; add_header 'Content-Length' 0; return 204; } + + add_header 'Access-Control-Allow-Origin' "$http_origin" always; + add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always; + add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, X-Requested-With' always; + add_header 'Access-Control-Allow-Credentials' 'true' always; + + proxy_buffering off; + proxy_request_buffering off; + client_max_body_size 100M; + } + + error_page 404 = @notfound; + location @notfound { + return 444; } } } diff --git a/infrastructure/pgadmin/password b/infrastructure/pgadmin/password new file mode 100644 index 0000000..f77b004 --- /dev/null +++ b/infrastructure/pgadmin/password @@ -0,0 +1 @@ +admin \ No newline at end of file diff --git a/infrastructure/postgres/password b/infrastructure/postgres/password new file mode 100644 index 0000000..7d72bd7 --- /dev/null +++ b/infrastructure/postgres/password @@ -0,0 +1 @@ +postgres \ No newline at end of file diff --git a/infrastructure/ssl/cert.pem b/infrastructure/ssl/cert.pem new file mode 100644 index 0000000..c485fa3 --- /dev/null +++ b/infrastructure/ssl/cert.pem @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDjTCCAxSgAwIBAgISA5ep7QO5wGgN93zJfQTGlkUfMAoGCCqGSM49BAMDMDIx +CzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQswCQYDVQQDEwJF +NjAeFw0yNTAyMjgwNjA2NTlaFw0yNTA1MjkwNjA2NThaMCExHzAdBgNVBAMMFiou +ZmluYWwucHJvZGNvbnRlc3QucnUwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQK +fXgGLgeNzruo5GW12+DHilbhxha9/MPT8zh7mh7BTWldFRktTTQ64C7yArJUZqCg +9lzvE/+a2tAMgLXlMUr6o4ICGTCCAhUwDgYDVR0PAQH/BAQDAgeAMB0GA1UdJQQW +MBQGCCsGAQUFBwMBBggrBgEFBQcDAjAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBS+ +BpaBU+CxXvOyjQTWxujc8lDZtDAfBgNVHSMEGDAWgBSTJ0aYA6lRaI6Y1sRCSNsj +v1iU0jBVBggrBgEFBQcBAQRJMEcwIQYIKwYBBQUHMAGGFWh0dHA6Ly9lNi5vLmxl +bmNyLm9yZzAiBggrBgEFBQcwAoYWaHR0cDovL2U2LmkubGVuY3Iub3JnLzAhBgNV +HREEGjAYghYqLmZpbmFsLnByb2Rjb250ZXN0LnJ1MBMGA1UdIAQMMAowCAYGZ4EM +AQIBMIIBBQYKKwYBBAHWeQIEAgSB9gSB8wDxAHcAouMK5EXvva2bfjjtR2d3U9eC +W4SU1yteGyzEuVCkR+cAAAGVS1/fqAAABAMASDBGAiEAh++tCc2QKpORe/zE33hI +WZudKHCGgXTSRFagyCBahi4CIQCmtzQXIiPC2YGGR7GkFeEhHbvSSugxz+/8EKsp +nuFqsgB2ABNK3xq1mEIJeAxv70x6kaQWtyNJzlhXat+u2qfCq+AiAAABlUtf4UEA +AAQDAEcwRQIgASgk8NgunqM1H9cuY4oiP6PRuH71nv4FChbbnMTb48oCIQCj4vxo +bdbH5382p/gZh1FibeYB9xd7Dr85HjntYjkrMTAKBggqhkjOPQQDAwNnADBkAjAz +cQn3S6VPQJ8nUvNEZYvzZrKgP716Xzhjukju77glV9RfS9B3sqiYXU9k59zlKwcC +MGMy7s814/t7R+rXaYGu7lDRXpzkq356cRxtrtu6WofIyQ6GGaA/jBSDjVlENWYD +Gw== +-----END CERTIFICATE----- diff --git a/infrastructure/ssl/chain.pem b/infrastructure/ssl/chain.pem new file mode 100644 index 0000000..65797c8 --- /dev/null +++ b/infrastructure/ssl/chain.pem @@ -0,0 +1,26 @@ +-----BEGIN CERTIFICATE----- +MIIEVzCCAj+gAwIBAgIRALBXPpFzlydw27SHyzpFKzgwDQYJKoZIhvcNAQELBQAw +TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh +cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMjQwMzEzMDAwMDAw +WhcNMjcwMzEyMjM1OTU5WjAyMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNTGV0J3Mg +RW5jcnlwdDELMAkGA1UEAxMCRTYwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAATZ8Z5G +h/ghcWCoJuuj+rnq2h25EqfUJtlRFLFhfHWWvyILOR/VvtEKRqotPEoJhC6+QJVV +6RlAN2Z17TJOdwRJ+HB7wxjnzvdxEP6sdNgA1O1tHHMWMxCcOrLqbGL0vbijgfgw +gfUwDgYDVR0PAQH/BAQDAgGGMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcD +ATASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBSTJ0aYA6lRaI6Y1sRCSNsj +v1iU0jAfBgNVHSMEGDAWgBR5tFnme7bl5AFzgAiIyBpY9umbbjAyBggrBgEFBQcB +AQQmMCQwIgYIKwYBBQUHMAKGFmh0dHA6Ly94MS5pLmxlbmNyLm9yZy8wEwYDVR0g +BAwwCjAIBgZngQwBAgEwJwYDVR0fBCAwHjAcoBqgGIYWaHR0cDovL3gxLmMubGVu +Y3Iub3JnLzANBgkqhkiG9w0BAQsFAAOCAgEAfYt7SiA1sgWGCIpunk46r4AExIRc +MxkKgUhNlrrv1B21hOaXN/5miE+LOTbrcmU/M9yvC6MVY730GNFoL8IhJ8j8vrOL +pMY22OP6baS1k9YMrtDTlwJHoGby04ThTUeBDksS9RiuHvicZqBedQdIF65pZuhp +eDcGBcLiYasQr/EO5gxxtLyTmgsHSOVSBcFOn9lgv7LECPq9i7mfH3mpxgrRKSxH +pOoZ0KXMcB+hHuvlklHntvcI0mMMQ0mhYj6qtMFStkF1RpCG3IPdIwpVCQqu8GV7 +s8ubknRzs+3C/Bm19RFOoiPpDkwvyNfvmQ14XkyqqKK5oZ8zhD32kFRQkxa8uZSu +h4aTImFxknu39waBxIRXE4jKxlAmQc4QjFZoq1KmQqQg0J/1JF8RlFvJas1VcjLv +YlvUB2t6npO6oQjB3l+PNf0DpQH7iUx3Wz5AjQCi6L25FjyE06q6BZ/QlmtYdl/8 +ZYao4SRqPEs/6cAiF+Qf5zg2UkaWtDphl1LKMuTNLotvsX99HP69V2faNyegodQ0 +LyTApr/vT01YPE46vNsDLgK+4cL6TrzC/a4WcmF5SRJ938zrv/duJHLXQIku5v0+ +EwOy59Hdm0PT/Er/84dDV0CSjdR/2XuZM3kpysSKLgD1cKiDA+IRguODCxfO9cyY +Ig46v9mFmBvyH04= +-----END CERTIFICATE----- diff --git a/infrastructure/ssl/fullchain.pem b/infrastructure/ssl/fullchain.pem new file mode 100644 index 0000000..110e015 --- /dev/null +++ b/infrastructure/ssl/fullchain.pem @@ -0,0 +1,48 @@ +-----BEGIN CERTIFICATE----- +MIIDjTCCAxSgAwIBAgISA5ep7QO5wGgN93zJfQTGlkUfMAoGCCqGSM49BAMDMDIx +CzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQswCQYDVQQDEwJF +NjAeFw0yNTAyMjgwNjA2NTlaFw0yNTA1MjkwNjA2NThaMCExHzAdBgNVBAMMFiou +ZmluYWwucHJvZGNvbnRlc3QucnUwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQK +fXgGLgeNzruo5GW12+DHilbhxha9/MPT8zh7mh7BTWldFRktTTQ64C7yArJUZqCg +9lzvE/+a2tAMgLXlMUr6o4ICGTCCAhUwDgYDVR0PAQH/BAQDAgeAMB0GA1UdJQQW +MBQGCCsGAQUFBwMBBggrBgEFBQcDAjAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBS+ +BpaBU+CxXvOyjQTWxujc8lDZtDAfBgNVHSMEGDAWgBSTJ0aYA6lRaI6Y1sRCSNsj +v1iU0jBVBggrBgEFBQcBAQRJMEcwIQYIKwYBBQUHMAGGFWh0dHA6Ly9lNi5vLmxl +bmNyLm9yZzAiBggrBgEFBQcwAoYWaHR0cDovL2U2LmkubGVuY3Iub3JnLzAhBgNV +HREEGjAYghYqLmZpbmFsLnByb2Rjb250ZXN0LnJ1MBMGA1UdIAQMMAowCAYGZ4EM +AQIBMIIBBQYKKwYBBAHWeQIEAgSB9gSB8wDxAHcAouMK5EXvva2bfjjtR2d3U9eC +W4SU1yteGyzEuVCkR+cAAAGVS1/fqAAABAMASDBGAiEAh++tCc2QKpORe/zE33hI +WZudKHCGgXTSRFagyCBahi4CIQCmtzQXIiPC2YGGR7GkFeEhHbvSSugxz+/8EKsp +nuFqsgB2ABNK3xq1mEIJeAxv70x6kaQWtyNJzlhXat+u2qfCq+AiAAABlUtf4UEA +AAQDAEcwRQIgASgk8NgunqM1H9cuY4oiP6PRuH71nv4FChbbnMTb48oCIQCj4vxo +bdbH5382p/gZh1FibeYB9xd7Dr85HjntYjkrMTAKBggqhkjOPQQDAwNnADBkAjAz +cQn3S6VPQJ8nUvNEZYvzZrKgP716Xzhjukju77glV9RfS9B3sqiYXU9k59zlKwcC +MGMy7s814/t7R+rXaYGu7lDRXpzkq356cRxtrtu6WofIyQ6GGaA/jBSDjVlENWYD +Gw== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIEVzCCAj+gAwIBAgIRALBXPpFzlydw27SHyzpFKzgwDQYJKoZIhvcNAQELBQAw +TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh +cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMjQwMzEzMDAwMDAw +WhcNMjcwMzEyMjM1OTU5WjAyMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNTGV0J3Mg +RW5jcnlwdDELMAkGA1UEAxMCRTYwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAATZ8Z5G +h/ghcWCoJuuj+rnq2h25EqfUJtlRFLFhfHWWvyILOR/VvtEKRqotPEoJhC6+QJVV +6RlAN2Z17TJOdwRJ+HB7wxjnzvdxEP6sdNgA1O1tHHMWMxCcOrLqbGL0vbijgfgw +gfUwDgYDVR0PAQH/BAQDAgGGMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcD +ATASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBSTJ0aYA6lRaI6Y1sRCSNsj +v1iU0jAfBgNVHSMEGDAWgBR5tFnme7bl5AFzgAiIyBpY9umbbjAyBggrBgEFBQcB +AQQmMCQwIgYIKwYBBQUHMAKGFmh0dHA6Ly94MS5pLmxlbmNyLm9yZy8wEwYDVR0g +BAwwCjAIBgZngQwBAgEwJwYDVR0fBCAwHjAcoBqgGIYWaHR0cDovL3gxLmMubGVu +Y3Iub3JnLzANBgkqhkiG9w0BAQsFAAOCAgEAfYt7SiA1sgWGCIpunk46r4AExIRc +MxkKgUhNlrrv1B21hOaXN/5miE+LOTbrcmU/M9yvC6MVY730GNFoL8IhJ8j8vrOL +pMY22OP6baS1k9YMrtDTlwJHoGby04ThTUeBDksS9RiuHvicZqBedQdIF65pZuhp +eDcGBcLiYasQr/EO5gxxtLyTmgsHSOVSBcFOn9lgv7LECPq9i7mfH3mpxgrRKSxH +pOoZ0KXMcB+hHuvlklHntvcI0mMMQ0mhYj6qtMFStkF1RpCG3IPdIwpVCQqu8GV7 +s8ubknRzs+3C/Bm19RFOoiPpDkwvyNfvmQ14XkyqqKK5oZ8zhD32kFRQkxa8uZSu +h4aTImFxknu39waBxIRXE4jKxlAmQc4QjFZoq1KmQqQg0J/1JF8RlFvJas1VcjLv +YlvUB2t6npO6oQjB3l+PNf0DpQH7iUx3Wz5AjQCi6L25FjyE06q6BZ/QlmtYdl/8 +ZYao4SRqPEs/6cAiF+Qf5zg2UkaWtDphl1LKMuTNLotvsX99HP69V2faNyegodQ0 +LyTApr/vT01YPE46vNsDLgK+4cL6TrzC/a4WcmF5SRJ938zrv/duJHLXQIku5v0+ +EwOy59Hdm0PT/Er/84dDV0CSjdR/2XuZM3kpysSKLgD1cKiDA+IRguODCxfO9cyY +Ig46v9mFmBvyH04= +-----END CERTIFICATE----- diff --git a/infrastructure/ssl/privkey.pem b/infrastructure/ssl/privkey.pem new file mode 100644 index 0000000..a7d4bff --- /dev/null +++ b/infrastructure/ssl/privkey.pem @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgZ50qoTXE2cFD6dkD +Vwv6YCvVv9DSMDiNqOGDOg3yhAahRANCAAQKfXgGLgeNzruo5GW12+DHilbhxha9 +/MPT8zh7mh7BTWldFRktTTQ64C7yArJUZqCg9lzvE/+a2tAMgLXlMUr6 +-----END PRIVATE KEY----- diff --git a/services/backend/.env.template b/services/backend/.env.template index 75997b2..696f558 100644 --- a/services/backend/.env.template +++ b/services/backend/.env.template @@ -20,7 +20,7 @@ MINIO_CUSTOM_ENDPOINT_URL= MINIO_ACCESS_KEY= MINIO_SECRET_KEY= MINIO_USE_HTTPS=False -MINIO_MEDIA_BUCKET_NAME=projectname-media +MINIO_MEDIA_BUCKET_NAME=datarush-media # Applyable if you installing using docker compose diff --git a/services/backend/README.md b/services/backend/README.md index 7a06d20..5757dc6 100644 --- a/services/backend/README.md +++ b/services/backend/README.md @@ -1,4 +1,4 @@ -# project_name Backend +# DataRush Backend ## Prerequisites @@ -15,13 +15,13 @@ Ensure you have the following installed on your system: #### Clone the project ```bash -git clone project_name +git clone git@gitlab.prodcontest.ru:team-15/project.git ``` #### Go to the project directory ```bash -cd project_name/services/backend +cd project/services/backend ``` #### Customize environment @@ -79,19 +79,19 @@ uv run gunicorn config.wsgi ### Clone the project ```bash -git clone project_name +git clone git@gitlab.prodcontest.ru:team-15/project.git ``` ### Go to the project directory ```bash -cd project_name/services/backend +cd project/services/backend ``` ### Build docker image ```bash -docker build -t project_name-backend . +docker build -t datarush-backend . ``` ### Customize environment @@ -103,13 +103,13 @@ Customize environment with `docker run` command (or bind .env file to container) #### Backend ```bash -docker run -p 8080:8080 --name project_name-backend project_name-backend +docker run -p 8080:8080 --name datarush-backend datarush-backend ``` #### Celery worker ```bash -docker run --name project_name-celery-worker project_name-backend celery -A config worker -l INFO +docker run --name datarush-celery-worker datarush-backend celery -A config worker -l INFO ``` Backend will be available on [127.0.0.1:8080](http://127.0.0.1:8080). @@ -119,13 +119,13 @@ Backend will be available on [127.0.0.1:8080](http://127.0.0.1:8080). ### Clone the project ```bash -git clone project_name +git clone git@gitlab.prodcontest.ru:team-15/project.git ``` ### Go to the project directory ```bash -cd project_name/services/backend +cd project/services/backend ``` ### Install dependencies diff --git a/services/backend/api/urls.py b/services/backend/api/urls.py index ffeb361..c6400c9 100644 --- a/services/backend/api/urls.py +++ b/services/backend/api/urls.py @@ -4,7 +4,7 @@ from health_check.views import MainView from api.v1.router import router as api_v1_router urlpatterns = [ - path("", api_v1_router.urls), + path("api/v1/", api_v1_router.urls), # Health endpoint - path("health", MainView.as_view(), name="health_check_home"), + path("api/health", MainView.as_view(), name="health_check_home"), ] diff --git a/services/backend/api/v1/competition/schemas.py b/services/backend/api/v1/competition/schemas.py index 1c5aff1..079baab 100644 --- a/services/backend/api/v1/competition/schemas.py +++ b/services/backend/api/v1/competition/schemas.py @@ -11,7 +11,7 @@ class CompetitionOut(ModelSchema): class Meta: model = Competition - fields = "__all__" + exclude = ("participants",) class StateOut(ModelSchema): diff --git a/services/backend/api/v1/competition/views.py b/services/backend/api/v1/competition/views.py index 095549c..1f7eb77 100644 --- a/services/backend/api/v1/competition/views.py +++ b/services/backend/api/v1/competition/views.py @@ -18,6 +18,7 @@ router = Router(tags=["competition"]) status.OK: schemas.CompetitionOut, status.BAD_REQUEST: global_schemas.BadRequestError, status.UNAUTHORIZED: global_schemas.UnauthorizedError, + status.NOT_FOUND: global_schemas.NotFoundError, }, ) def get_competition( @@ -30,14 +31,14 @@ def get_competition( @router.get( "competitions", response={ - status.OK: list[schemas.CompetitionListInstanceOut], + status.OK: list[schemas.CompetitionOut], status.BAD_REQUEST: global_schemas.BadRequestError, status.UNAUTHORIZED: global_schemas.UnauthorizedError, }, ) def list_competitions( request: HttpRequest, is_participating: bool -) -> tuple[status, list[schemas.CompetitionListInstanceOut]]: +) -> tuple[status, list[schemas.CompetitionOut]]: user = request.auth if is_participating: competitions = Competition.objects.filter(participants=user) diff --git a/services/backend/api/v1/router.py b/services/backend/api/v1/router.py index 2c24b2b..431db00 100644 --- a/services/backend/api/v1/router.py +++ b/services/backend/api/v1/router.py @@ -14,7 +14,6 @@ router = NinjaAPI( version="1", description="API docs for DataRush", openapi_url="/docs/openapi.json", - auth=BearerAuth(), ) @@ -25,10 +24,12 @@ router.add_router( router.add_router( "", user_router, + auth=BearerAuth(), ) router.add_router( "", competition_router, + auth=BearerAuth(), ) router.add_router( "", diff --git a/services/backend/api/v1/task/views.py b/services/backend/api/v1/task/views.py index b805998..6710c95 100644 --- a/services/backend/api/v1/task/views.py +++ b/services/backend/api/v1/task/views.py @@ -1,5 +1,7 @@ from http import HTTPStatus as status +from uuid import UUID +from django.shortcuts import get_object_or_404 from ninja import Router from django.shortcuts import get_object_or_404 @@ -15,6 +17,7 @@ from apps.task.models import ( CompetitionTask, CompetetionTaskSumbission, ) +from apps.competition.models import State router = Router(tags=["competition"]) @@ -28,7 +31,12 @@ router = Router(tags=["competition"]) status.NOT_FOUND: NotFoundError, }, ) -def start_competition(request, competition_id: str) -> PingOut: ... +def start_competition(request, competition_id: UUID) -> PingOut: + competition = get_object_or_404(Competition, pk=competition_id) + state_obj, _ = State.objects.update_or_create( + user=request.auth, competition=competition, state="started" + ) + return status.OK, PingOut() @router.get( @@ -41,9 +49,15 @@ def start_competition(request, competition_id: str) -> PingOut: ... status.NOT_FOUND: NotFoundError, }, ) -def get_competition_tasks( - request, competition_id: str -) -> list[TaskOutSchema]: ... +def get_competition_tasks(request, competition_id: UUID) -> list[TaskOutSchema]: + competition = get_object_or_404(Competition, pk=competition_id) + state = State.objects.filter( + user=request.auth, competition=competition, state="started" + ).first() + if not state: + return 403, ForbiddenError() + + return status.OK, CompetitionTask.objects.filter(competition=competition).all() @router.get( diff --git a/services/backend/api/v1/user/schemas.py b/services/backend/api/v1/user/schemas.py index edde86f..6baa542 100644 --- a/services/backend/api/v1/user/schemas.py +++ b/services/backend/api/v1/user/schemas.py @@ -22,4 +22,4 @@ class LoginSchema(ModelSchema): class UserSchema(ModelSchema): class Meta: model = User - fields = ["email", "username"] + fields = ["id", "email", "username"] diff --git a/services/backend/api/v1/user/views.py b/services/backend/api/v1/user/views.py index 29a2ff7..f4629b7 100644 --- a/services/backend/api/v1/user/views.py +++ b/services/backend/api/v1/user/views.py @@ -54,6 +54,16 @@ def sign_in(request, data: LoginSchema): return status.OK, TokenSchema(token=token) +@router.get( + "/me", + response={ + status.OK: UserSchema, + status.UNAUTHORIZED: ForbiddenError, + }, +) +def get_me(request): + return 200, request.auth + @router.get( path="/user/{user_id}", response={ diff --git a/services/backend/apps/competition/migrations/0004_state_changed_at.py b/services/backend/apps/competition/migrations/0004_state_changed_at.py new file mode 100644 index 0000000..365f995 --- /dev/null +++ b/services/backend/apps/competition/migrations/0004_state_changed_at.py @@ -0,0 +1,19 @@ +# Generated by Django 5.1.6 on 2025-03-01 08:10 + +import datetime +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('competition', '0003_state'), + ] + + operations = [ + migrations.AddField( + model_name='state', + name='changed_at', + field=models.DateTimeField(default=datetime.datetime.now), + ), + ] diff --git a/services/backend/apps/competition/models.py b/services/backend/apps/competition/models.py index 644f733..589ce91 100644 --- a/services/backend/apps/competition/models.py +++ b/services/backend/apps/competition/models.py @@ -1,3 +1,5 @@ +from datetime import datetime + from django.db import models from apps.core.models import BaseModel @@ -49,3 +51,4 @@ class State(BaseModel): user = models.ForeignKey(User, on_delete=models.CASCADE) competition = models.ForeignKey(Competition, on_delete=models.CASCADE) state = models.CharField(choices=StateChoices.choices, max_length=11) + changed_at = models.DateTimeField(default=datetime.now) diff --git a/services/backend/config/asgi.py b/services/backend/config/asgi.py index 513bb2b..d908fe2 100644 --- a/services/backend/config/asgi.py +++ b/services/backend/config/asgi.py @@ -1,4 +1,4 @@ -"""ASGI config for project_name.""" +"""ASGI config for datarush.""" import os diff --git a/services/backend/config/celery.py b/services/backend/config/celery.py index ecad09a..ee77fe7 100644 --- a/services/backend/config/celery.py +++ b/services/backend/config/celery.py @@ -4,7 +4,7 @@ from celery import Celery os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") -app = Celery("project_name") +app = Celery("datarush") app.config_from_object("django.conf:settings", namespace="CELERY") app.autodiscover_tasks() diff --git a/services/backend/config/settings.py b/services/backend/config/settings.py index ffae770..a64cb83 100644 --- a/services/backend/config/settings.py +++ b/services/backend/config/settings.py @@ -1,4 +1,4 @@ -"""Django settings for project_name.""" +"""Django settings for datarush.""" import contextlib import logging @@ -141,7 +141,7 @@ MINIO_STORAGE_SECRET_KEY = env("MINIO_SECRET_KEY", default=None) MINIO_STORAGE_USE_HTTPS = env("MINIO_USE_HTTPS", default=False) MINIO_STORAGE_MEDIA_BUCKET_NAME = env( - "MINIO_MEDIA_BUCKET_NAME", default="projectname-media" + "MINIO_MEDIA_BUCKET_NAME", default="datarush-media" ) MINIO_STORAGE_AUTO_CREATE_MEDIA_BUCKET = True @@ -300,7 +300,7 @@ WSGI_APPLICATION = "config.wsgi.application" # Logging -LOGGER_NAME = "project_name" +LOGGER_NAME = "datarush" LOGGER = logging.getLogger(LOGGER_NAME) diff --git a/services/backend/config/urls.py b/services/backend/config/urls.py index 4dc0330..264fcc1 100644 --- a/services/backend/config/urls.py +++ b/services/backend/config/urls.py @@ -1,4 +1,4 @@ -"""URL configuration for project_name.""" +"""URL configuration for datarush.""" from django.conf import settings from django.contrib import admin @@ -6,9 +6,9 @@ from django.urls import include, path from config import handlers -admin.site.site_title = "project_name" -admin.site.site_header = "project_name" -admin.site.index_title = "project_name" +admin.site.site_title = "DataRush" +admin.site.site_header = "DataRush" +admin.site.index_title = "DataRush" urlpatterns = [ diff --git a/services/backend/config/wsgi.py b/services/backend/config/wsgi.py index 478b263..65dafc3 100644 --- a/services/backend/config/wsgi.py +++ b/services/backend/config/wsgi.py @@ -1,4 +1,4 @@ -"""WSGI config for project_name.""" +"""WSGI config for datarush.""" import os diff --git a/services/backend/pyproject.toml b/services/backend/pyproject.toml index fc82dc8..0aaa38b 100644 --- a/services/backend/pyproject.toml +++ b/services/backend/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "project_name-backend" +name = "datarush-backend" version = "0.1.0" readme = "README.md" requires-python = ">=3.10,<3.12" diff --git a/services/frontend/.gitignore b/services/frontend/.gitignore index a547bf3..7246199 100644 --- a/services/frontend/.gitignore +++ b/services/frontend/.gitignore @@ -11,7 +11,7 @@ node_modules dist dist-ssr *.local - +.VSCodeCounter # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/services/frontend/bun.lock b/services/frontend/bun.lock index 47cb214..1e65b63 100644 --- a/services/frontend/bun.lock +++ b/services/frontend/bun.lock @@ -4,18 +4,24 @@ "": { "name": "frontend", "dependencies": { + "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-tabs": "^1.1.3", "@tailwindcss/vite": "^4.0.9", + "autoprefixer": "^10.4.20", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.476.0", "ofetch": "^1.4.1", + "postcss": "^8.5.3", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router": "^7.2.0", + "react-router-dom": "^7.2.0", "tailwind-merge": "^3.0.2", "tailwindcss": "^4.0.9", "tailwindcss-animate": "^1.0.7", + "vaul": "^1.1.2", }, "devDependencies": { "@eslint/js": "^9.21.0", @@ -117,10 +123,46 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.1", "", {}, "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA=="], + + "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-slot": "1.1.2" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9z54IEKRxIa9VityapoEYMuByaG42iSy1ZXlY2KcuLSEtq8x4987/N6m15ppoMffgZX72gER2uHe1D9Y6Unlcw=="], + "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw=="], + "@radix-ui/react-context": ["@radix-ui/react-context@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q=="], + + "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.5", "@radix-ui/react-focus-guards": "1.1.1", "@radix-ui/react-focus-scope": "1.1.2", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-portal": "1.1.4", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-slot": "1.1.2", "@radix-ui/react-use-controllable-state": "1.1.0", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/IVhJV5AceX620DUJ4uYVMymzsipdKBzo3edo+omeskCKGm9FRHM0ebIdbPnlQVJqyuHbuBltQUOG2mOTq2IYw=="], + + "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg=="], + + "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.5", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-primitive": "2.0.2", "@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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg=="], + + "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg=="], + + "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA=="], + + "@radix-ui/react-id": ["@radix-ui/react-id@1.1.0", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA=="], + + "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.4", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.2", "@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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA=="], + + "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1", "@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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg=="], + + "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.0.2", "", { "dependencies": { "@radix-ui/react-slot": "1.1.2" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w=="], + + "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.2", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-collection": "1.1.2", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-controllable-state": "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zgMQWkNO169GtGqRvYrzb0Zf8NhMHS2DuEB/TiEmVnpr5OqPU3i8lfbxaAmC2J/KYuIQxyoQQ6DxepyXp61/xw=="], + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ=="], + "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-roving-focus": "1.1.2", "@radix-ui/react-use-controllable-state": "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9mFyI30cuRDImbmFF6O2KUJdgEOsGh9Vmx9x/Dh9tOhL7BngmQPQfwW4aejKm5OHpfWIdmeV6ySyuxoOGjtNng=="], + + "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw=="], + + "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.1.0", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw=="], + + "@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.0", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw=="], + + "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w=="], + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.34.8", "", { "os": "android", "cpu": "arm" }, "sha512-q217OSE8DTp8AFHuNHXo0Y86e1wtlfVrXiAlwkIvGRQv9zbc6mE3sjIVfwI8sYUyNxwOg0j/Vm1RKM04JcWLJw=="], "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.34.8", "", { "os": "android", "cpu": "arm64" }, "sha512-Gigjz7mNWaOL9wCggvoK3jEIUUbGul656opstjaUSGC3eT0BM7PofdAJaBfPFWWkXNVAXbaQtC99OCg4sJv70Q=="], @@ -253,14 +295,22 @@ "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "aria-hidden": ["aria-hidden@1.2.4", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A=="], + + "autoprefixer": ["autoprefixer@10.4.20", "", { "dependencies": { "browserslist": "^4.23.3", "caniuse-lite": "^1.0.30001646", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", "picocolors": "^1.0.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g=="], + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + "browserslist": ["browserslist@4.24.4", "", { "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.1" }, "bin": { "browserslist": "cli.js" } }, "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A=="], + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + "caniuse-lite": ["caniuse-lite@1.0.30001701", "", {}, "sha512-faRs/AW3jA9nTwmJBSO1PQ6L/EOgsB5HMQQq4iCu5zhPgVVgO/pZRHlmatwijZKetFw8/Pr4q6dEN8sJuq8qTw=="], + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], @@ -287,10 +337,16 @@ "detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="], + "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.109", "", {}, "sha512-AidaH9JETVRr9DIPGfp1kAarm/W6hRJTPuCnkF+2MqhF4KaAgRIcBc8nvjk+YMXZhwfISof/7WG29eS4iGxQLQ=="], + "enhanced-resolve": ["enhanced-resolve@5.18.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg=="], "esbuild": ["esbuild@0.25.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.0", "@esbuild/android-arm": "0.25.0", "@esbuild/android-arm64": "0.25.0", "@esbuild/android-x64": "0.25.0", "@esbuild/darwin-arm64": "0.25.0", "@esbuild/darwin-x64": "0.25.0", "@esbuild/freebsd-arm64": "0.25.0", "@esbuild/freebsd-x64": "0.25.0", "@esbuild/linux-arm": "0.25.0", "@esbuild/linux-arm64": "0.25.0", "@esbuild/linux-ia32": "0.25.0", "@esbuild/linux-loong64": "0.25.0", "@esbuild/linux-mips64el": "0.25.0", "@esbuild/linux-ppc64": "0.25.0", "@esbuild/linux-riscv64": "0.25.0", "@esbuild/linux-s390x": "0.25.0", "@esbuild/linux-x64": "0.25.0", "@esbuild/netbsd-arm64": "0.25.0", "@esbuild/netbsd-x64": "0.25.0", "@esbuild/openbsd-arm64": "0.25.0", "@esbuild/openbsd-x64": "0.25.0", "@esbuild/sunos-x64": "0.25.0", "@esbuild/win32-arm64": "0.25.0", "@esbuild/win32-ia32": "0.25.0", "@esbuild/win32-x64": "0.25.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], "eslint": ["eslint@9.21.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.19.2", "@eslint/core": "^0.12.0", "@eslint/eslintrc": "^3.3.0", "@eslint/js": "9.21.0", "@eslint/plugin-kit": "^0.2.7", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.2.0", "eslint-visitor-keys": "^4.2.0", "espree": "^10.3.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-KjeihdFqTPhOMXTt7StsDxriV4n66ueuF/jfPNC3j/lduHwr/ijDwJMsF+wyMJethgiKi5wniIE243vi07d3pg=="], @@ -333,8 +389,12 @@ "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], + "fraction.js": ["fraction.js@4.3.7", "", {}, "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], "globals": ["globals@15.15.0", "", {}, "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg=="], @@ -417,6 +477,10 @@ "node-fetch-native": ["node-fetch-native@1.6.6", "", {}, "sha512-8Mc2HhqPdlIfedsuZoc3yioPuzp6b+L5jRCRY1QzuWZh2EGJVQrGppC6V6cF0bLdbW0+O2YpqCA25aF/1lvipQ=="], + "node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="], + + "normalize-range": ["normalize-range@0.1.2", "", {}, "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA=="], + "ofetch": ["ofetch@1.4.1", "", { "dependencies": { "destr": "^2.0.3", "node-fetch-native": "^1.6.4", "ufo": "^1.5.4" } }, "sha512-QZj2DfGplQAr2oj9KzceK9Hwz6Whxazmn85yYeVuS3u9XTMOGMRx0kO95MQ+vLsj/S/NwBDMMLU5hpxvI6Tklw=="], "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], @@ -437,6 +501,8 @@ "postcss": ["postcss@8.5.3", "", { "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A=="], + "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "prettier": ["prettier@3.5.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-lc6npv5PH7hVqozBR7lkBNOGXV9vMwROAPlumdBkX0wTbbzPu/U1hk5yL8p2pt4Xoc+2mkT8t/sow2YrV/M5qg=="], @@ -451,8 +517,16 @@ "react-dom": ["react-dom@19.0.0", "", { "dependencies": { "scheduler": "^0.25.0" }, "peerDependencies": { "react": "^19.0.0" } }, "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ=="], + "react-remove-scroll": ["react-remove-scroll@2.6.3", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-pnAi91oOk8g8ABQKGF5/M9qxmmOPxaAnopyTHYfqYEwJhyFrbbBtHuSgtKEoH0jpcxx5o3hXqH1mNd9/Oi+8iQ=="], + + "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], + "react-router": ["react-router@7.2.0", "", { "dependencies": { "@types/cookie": "^0.6.0", "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0", "turbo-stream": "2.4.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-fXyqzPgCPZbqhrk7k3hPcCpYIlQ2ugIXDboHUzhJISFVy2DEPsmHgN588MyGmkIOv3jDgNfUE3kJi83L28s/LQ=="], + "react-router-dom": ["react-router-dom@7.2.0", "", { "dependencies": { "react-router": "7.2.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-cU7lTxETGtQRQbafJubvZKHEn5izNABxZhBY0Jlzdv0gqQhCPQt2J8aN5ZPjS6mQOXn5NnirWNh+FpE8TTYN0Q=="], + + "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], @@ -491,6 +565,8 @@ "tsconfck": ["tsconfck@3.1.5", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-CLDfGgUp7XPswWnezWwsCRxNmgQjhYq3VXHM0/XIRxhVrKw0M1if9agzryh1QS3nxjCROvV+xWxoJO1YctzzWg=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "turbo-stream": ["turbo-stream@2.4.0", "", {}, "sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g=="], "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], @@ -503,8 +579,16 @@ "undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], + "update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="], + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], + + "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], + + "vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="], + "vite": ["vite@6.2.0", "", { "dependencies": { "esbuild": "^0.25.0", "postcss": "^8.5.3", "rollup": "^4.30.1" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-7dPxoo+WsT/64rDcwoOjk76XHj+TqNTIvHKcuMQ1k4/SeHDaQt5GFAeLYzrimZrMpn/O6DtdI03WUjdxuPM0oQ=="], "vite-tsconfig-paths": ["vite-tsconfig-paths@5.1.4", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" }, "optionalPeers": ["vite"] }, "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w=="], diff --git a/services/frontend/package.json b/services/frontend/package.json index 8e5e061..680a54b 100644 --- a/services/frontend/package.json +++ b/services/frontend/package.json @@ -10,18 +10,24 @@ "preview": "vite preview" }, "dependencies": { + "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-tabs": "^1.1.3", "@tailwindcss/vite": "^4.0.9", + "autoprefixer": "^10.4.20", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.476.0", "ofetch": "^1.4.1", + "postcss": "^8.5.3", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router": "^7.2.0", + "react-router-dom": "^7.2.0", "tailwind-merge": "^3.0.2", "tailwindcss": "^4.0.9", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "vaul": "^1.1.2" }, "devDependencies": { "@eslint/js": "^9.21.0", diff --git a/services/frontend/public/DANO.png b/services/frontend/public/DANO.png new file mode 100644 index 0000000..686ef2b Binary files /dev/null and b/services/frontend/public/DANO.png differ diff --git a/services/frontend/public/fonts/HSESans-Black.otf b/services/frontend/public/fonts/HSESans-Black.otf new file mode 100644 index 0000000..1ab7d2a Binary files /dev/null and b/services/frontend/public/fonts/HSESans-Black.otf differ diff --git a/services/frontend/public/fonts/HSESans-Bold.otf b/services/frontend/public/fonts/HSESans-Bold.otf new file mode 100644 index 0000000..32ddef0 Binary files /dev/null and b/services/frontend/public/fonts/HSESans-Bold.otf differ diff --git a/services/frontend/public/fonts/HSESans-Italic.otf b/services/frontend/public/fonts/HSESans-Italic.otf new file mode 100644 index 0000000..804ed8f Binary files /dev/null and b/services/frontend/public/fonts/HSESans-Italic.otf differ diff --git a/services/frontend/public/fonts/HSESans-Regular.otf b/services/frontend/public/fonts/HSESans-Regular.otf new file mode 100644 index 0000000..8101ed8 Binary files /dev/null and b/services/frontend/public/fonts/HSESans-Regular.otf differ diff --git a/services/frontend/public/fonts/HSESans-SemiBold.otf b/services/frontend/public/fonts/HSESans-SemiBold.otf new file mode 100644 index 0000000..ca60925 Binary files /dev/null and b/services/frontend/public/fonts/HSESans-SemiBold.otf differ diff --git a/services/frontend/public/fonts/HSESans-Thin.otf b/services/frontend/public/fonts/HSESans-Thin.otf new file mode 100644 index 0000000..374525e Binary files /dev/null and b/services/frontend/public/fonts/HSESans-Thin.otf differ diff --git a/services/frontend/src/App.tsx b/services/frontend/src/App.tsx index daf09bc..e6af9eb 100644 --- a/services/frontend/src/App.tsx +++ b/services/frontend/src/App.tsx @@ -1,6 +1,16 @@ -import { Routes } from "react-router"; +import { Routes, Route } from "react-router"; import "./styles/globals.css"; +import CompetitionsPage from "./pages/CompetitionsPage"; +import CompetitionPreview from "./pages/CompetitionPreview"; -export default function App() { - return ; -} +const App = () => { + return ( + + } /> + } /> + + + ); +}; + +export default App; \ No newline at end of file diff --git a/services/frontend/src/components/ui/alert.tsx b/services/frontend/src/components/ui/alert.tsx new file mode 100644 index 0000000..dd7eaa4 --- /dev/null +++ b/services/frontend/src/components/ui/alert.tsx @@ -0,0 +1,66 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/shared/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "text-destructive-foreground [&>svg]:text-current *:data-[slot=alert-description]:text-destructive-foreground/80", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Alert, AlertTitle, AlertDescription } diff --git a/services/frontend/src/components/ui/badge.tsx b/services/frontend/src/components/ui/badge.tsx new file mode 100644 index 0000000..52ee562 --- /dev/null +++ b/services/frontend/src/components/ui/badge.tsx @@ -0,0 +1,46 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/shared/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40", + outline: + "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/services/frontend/src/components/ui/card.tsx b/services/frontend/src/components/ui/card.tsx new file mode 100644 index 0000000..4704955 --- /dev/null +++ b/services/frontend/src/components/ui/card.tsx @@ -0,0 +1,68 @@ +import * as React from "react" + +import { cn } from "@/shared/lib/utils" + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/services/frontend/src/components/ui/skeleton.tsx b/services/frontend/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..75be418 --- /dev/null +++ b/services/frontend/src/components/ui/skeleton.tsx @@ -0,0 +1,13 @@ +import { cn } from "@/shared/lib/utils" + +function Skeleton({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Skeleton } diff --git a/services/frontend/src/components/ui/tabs.tsx b/services/frontend/src/components/ui/tabs.tsx new file mode 100644 index 0000000..55c8eae --- /dev/null +++ b/services/frontend/src/components/ui/tabs.tsx @@ -0,0 +1,70 @@ +import * as React from "react" +import * as TabsPrimitive from "@radix-ui/react-tabs" + +import { cn } from "@/shared/lib/utils" + +function Tabs({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function TabsList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function TabsTrigger({ + className, + value, + ...props +}: React.ComponentProps & { value: string }) { + return ( + + ) +} + +function TabsContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Tabs, TabsList, TabsTrigger, TabsContent } \ No newline at end of file diff --git a/services/frontend/src/modules/Navbar/index.tsx b/services/frontend/src/modules/Navbar/index.tsx new file mode 100644 index 0000000..ba1062a --- /dev/null +++ b/services/frontend/src/modules/Navbar/index.tsx @@ -0,0 +1,24 @@ +import { ChevronDown } from "lucide-react"; + +const Navbar = () => { + return ( + + ); +}; + + +export default Navbar \ No newline at end of file diff --git a/services/frontend/src/pages/CompetitionPreview/index.tsx b/services/frontend/src/pages/CompetitionPreview/index.tsx new file mode 100644 index 0000000..670723c --- /dev/null +++ b/services/frontend/src/pages/CompetitionPreview/index.tsx @@ -0,0 +1,111 @@ +import { useEffect, useState } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import Navbar from "@/modules/Navbar"; +import { Button } from "@/components/ui/button"; +import { ArrowLeft } from "lucide-react"; +import { Competition, Status } from "@/shared/types/types"; + +const mockCompetitions: Competition[] = [ + { + id: '1', + name: 'Олимпиада DANO 2025. Индивидуальный этап', + imageUrl: '/DANO.png', + isOlympics: true, + status: Status.InProgress, + description: 'Проверка глубоких знаний и навыков в анализе данных. Будет несколько творческих заданий со свободным ответом. Задания выполняются индивидуально, вес тура в итоговом результате – 0,5. Этап пройдет онлайн в заданное время, с применением системы прокторинга. На работу дается 240 минут.' + }, + { + id: '2', + name: 'Олимпиада DANO 2025. Индивидуальный этап', + imageUrl: '/DANO.png', + isOlympics: false, + status: Status.NotParticipating, + description: 'Индивидуальный этап олимпиады DANO 2025 – это уникальная возможность для студентов продемонстрировать свои навыки анализа данных и решения сложных задач. Участники будут работать с реальными наборами данных и применять современные методы машинного обучения и статистического анализа.' + }, +]; + +const CompetitionPreview = () => { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const [competition, setCompetition] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const fetchCompetition = async () => { + try { + setTimeout(() => { + const found = mockCompetitions.find(comp => comp.id === id); + setCompetition(found || null); + setIsLoading(false); + }, 500); + } catch (error) { + console.error("Error fetching competition:", error); + setIsLoading(false); + } + }; + + fetchCompetition(); + }, [id]); + + const handleBack = () => { + navigate(-1); + }; + + const handleContinue = () => { + console.log("Continue to competition:", competition?.id); + }; + + return ( + <> + +
+ + + {isLoading ? ( +
+

Загрузка...

+
+ ) : competition ? ( +
+
+ {competition.name} +
+ +
+
+

{competition.name}

+ +
+ +
+

{competition.description}

+
+
+
+ ) : ( +
+

Соревнование не найдено

+

Запрошенное соревнование не существует или было удалено.

+
+ )} +
+ + ); +}; + +export default CompetitionPreview; \ No newline at end of file diff --git a/services/frontend/src/pages/CompetitionsPage/components/CompetitionCard/index.tsx b/services/frontend/src/pages/CompetitionsPage/components/CompetitionCard/index.tsx new file mode 100644 index 0000000..5c06328 --- /dev/null +++ b/services/frontend/src/pages/CompetitionsPage/components/CompetitionCard/index.tsx @@ -0,0 +1,55 @@ +import { Competition } from "@/shared/types/types"; +import { cn } from "@/shared/lib/utils"; +import { + Card, + CardContent, + CardFooter, +} from "@/components/ui/card"; +import { useNavigate } from "react-router"; + +interface CompetitionCardProps { + competition: Competition; + className?: string; +} + +export function CompetitionCard({ competition, className }: CompetitionCardProps) { + const { id, name, imageUrl, isOlympics, status } = competition; + const navigate = useNavigate(); + + const handleClick = () => { + navigate(`/competition/${id}`); + }; + + return ( + +
+ {name} +
+ + + + {isOlympics ? "Олимпиада" : "Тренировка"} + + + + {status.replace(/^\w/, c => c.toUpperCase())} + + + + +

{name}

+
+
+ ); +} \ No newline at end of file diff --git a/services/frontend/src/pages/CompetitionsPage/components/CompetitionSkeleton/index.tsx b/services/frontend/src/pages/CompetitionsPage/components/CompetitionSkeleton/index.tsx new file mode 100644 index 0000000..607d9b4 --- /dev/null +++ b/services/frontend/src/pages/CompetitionsPage/components/CompetitionSkeleton/index.tsx @@ -0,0 +1,20 @@ +import { Card, CardContent, CardFooter } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; + +const CompetitionSkeleton = () => { + return ( + + + + + + + + + + + + ); +} + +export default CompetitionSkeleton \ No newline at end of file diff --git a/services/frontend/src/pages/CompetitionsPage/components/CompetitionTag/index.tsx b/services/frontend/src/pages/CompetitionsPage/components/CompetitionTag/index.tsx new file mode 100644 index 0000000..445688f --- /dev/null +++ b/services/frontend/src/pages/CompetitionsPage/components/CompetitionTag/index.tsx @@ -0,0 +1,26 @@ +import { cn } from "@/shared/lib/utils"; +import { Badge } from "@/components/ui/badge"; + +interface CompetitionTagProps { + label: string; + variant: 'olympics' | 'status'; + className?: string; +} + +const CompetitionTag = ({ label, variant, className }: CompetitionTagProps) => { + return ( + + {label} + + ); +} + +export default CompetitionTag \ No newline at end of file diff --git a/services/frontend/src/pages/CompetitionsPage/index.tsx b/services/frontend/src/pages/CompetitionsPage/index.tsx new file mode 100644 index 0000000..0abcd4f --- /dev/null +++ b/services/frontend/src/pages/CompetitionsPage/index.tsx @@ -0,0 +1,143 @@ +import { useState, useEffect } from 'react'; +import { Competition, Status } from '@/shared/types/types'; +import { CompetitionGrid } from './modules/CompetitionGrid'; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { AlertCircle } from "lucide-react"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import Navbar from '@/modules/Navbar'; + +const mockCompetitions: Competition[] = [ + { + id: '1', + name: 'Олимпиада DANO 2025. Индивидуальный этап', + imageUrl: '/DANO.png', + isOlympics: true, + status: Status.InProgress + }, + { + id: '2', + name: 'Олимпиада DANO 2025. Индивидуальный этап', + imageUrl: '/DANO.png', + isOlympics: false, + status: Status.NotParticipating + }, + { + id: '3', + name: 'Олимпиада DANO 2025. Индивидуальный этап', + imageUrl: '/DANO.png', + isOlympics: false, + status: Status.InProgress + }, + { + id: '4', + name: 'Олимпиада DANO 2025. Индивидуальный этап', + imageUrl: '/DANO.png', + isOlympics: true, + status: Status.Completed + }, + { + id: '5', + name: 'Олимпиада DANO 2025. Индивидуальный этап', + imageUrl: '/DANO.png', + isOlympics: false, + status: Status.Completed + }, + { + id: '6', + name: 'Олимпиада DANO 2025. Индивидуальный этап', + imageUrl: '/DANO.png', + isOlympics: true, + status: Status.NotParticipating + } +]; + + +const CompetitionsPage = () => { + const [competitions, setCompetitions] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [activeTab, setActiveTab] = useState("ongoing"); + + useEffect(() => { + // ! симуляция фетча + const fetchCompetitions = async () => { + try { + setTimeout(() => { + setCompetitions(mockCompetitions); + setIsLoading(false); + }, 800); + } catch (error) { + setError('Соревнования не найдены, пожалуйста, попробуйте позже'); + setIsLoading(false); + } + }; + + fetchCompetitions(); + }, []); + + const myCompetitions = competitions.filter(comp => + comp.status === Status.InProgress || comp.status === Status.Completed + ); + + const filteredMyCompetitions = myCompetitions.filter(comp => + activeTab === "ongoing" ? comp.status === Status.InProgress : comp.status === Status.Completed + ); + + const availableCompetitions = competitions.filter(comp => + comp.status === 'Не участвую' + ); + + return ( + <> + +
+ {error && ( + + + {error} + + )} + +
+
+

Мои события

+ + + Текущие + Завершенные + + +
+ + {isLoading ? ( + + ) : filteredMyCompetitions.length > 0 ? ( + + ) : ( +
+

+ {activeTab === "ongoing" ? "У вас нет текущих соревнований" : "У вас нет завершенных соревнований"} +

+
+ )} +
+ +
+

Доступные события

+ + {isLoading ? ( + + ) : availableCompetitions.length > 0 ? ( + + ) : ( +
+

Нет доступных соревнований

+
+ )} +
+
+ + ); +} + +export default CompetitionsPage; \ No newline at end of file diff --git a/services/frontend/src/pages/CompetitionsPage/modules/CompetitionGrid/index.tsx b/services/frontend/src/pages/CompetitionsPage/modules/CompetitionGrid/index.tsx new file mode 100644 index 0000000..7bcc1fa --- /dev/null +++ b/services/frontend/src/pages/CompetitionsPage/modules/CompetitionGrid/index.tsx @@ -0,0 +1,46 @@ +import { Competition } from "@/shared/types/types"; +import { CompetitionCard } from "../../components/CompetitionCard"; +import CompetitionSkeleton from "../../components/CompetitionSkeleton"; +import { cn } from "@/shared/lib/utils"; + +interface CompetitionGridProps { + competitions: Competition[]; + isLoading?: boolean; + className?: string; + skeletonCount?: number; +} + +export function CompetitionGrid({ + competitions, + isLoading = false, + className, + skeletonCount +}: CompetitionGridProps) { + const gridClasses = cn( + "grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6", + className + ); + + const numberOfSkeletons = skeletonCount ?? (competitions.length > 0 ? competitions.length : 4); + + if (isLoading) { + return ( +
+ {Array.from({ length: numberOfSkeletons }).map((_, index) => ( + + ))} +
+ ); + } + + return ( +
+ {competitions.map((competition) => ( + + ))} +
+ ); +} \ No newline at end of file diff --git a/services/frontend/src/pages/CompetitionsPage/types.ts b/services/frontend/src/pages/CompetitionsPage/types.ts new file mode 100644 index 0000000..e69de29 diff --git a/services/frontend/src/shared/types/types.ts b/services/frontend/src/shared/types/types.ts new file mode 100644 index 0000000..6fd3a3e --- /dev/null +++ b/services/frontend/src/shared/types/types.ts @@ -0,0 +1,17 @@ +enum Status { + InProgress = 'В процессе', + NotParticipating = 'Не участвую', + Completed = 'Завершено' +} + +interface Competition { + id: string; + name: string; + imageUrl: string; + isOlympics: boolean; + status: Status; + description?: string; +} + +export {Status} +export type {Competition} \ No newline at end of file diff --git a/services/frontend/src/styles/fonts.css b/services/frontend/src/styles/fonts.css new file mode 100644 index 0000000..8f6986d --- /dev/null +++ b/services/frontend/src/styles/fonts.css @@ -0,0 +1,20 @@ +@font-face { + font-family: 'HSE Sans'; + src: url('/fonts/HSESans-Regular.otf') format('opentype'); + font-weight: normal; + font-style: normal; +} + +@font-face { + font-family: 'HSE Sans'; + src: url('/fonts/HSESans-Bold.otf') format('opentype'); + font-weight: bold; + font-style: normal; +} + +@font-face { + font-family: 'HSE Sans'; + src: url('/fonts/HSESans-SemiBold.otf') format('opentype'); + font-weight: 600; + font-style: normal; +} \ No newline at end of file diff --git a/services/frontend/src/styles/globals.css b/services/frontend/src/styles/globals.css index 1f613d0..9df6fec 100644 --- a/services/frontend/src/styles/globals.css +++ b/services/frontend/src/styles/globals.css @@ -1,5 +1,5 @@ @import "tailwindcss"; - +@import "./fonts.css"; @plugin "tailwindcss-animate"; @custom-variant dark (&:is(.dark *)); @@ -40,6 +40,9 @@ --sidebar-ring: oklch(0.87 0 0); } +@theme inline { + --font-hse-sans: "HSE Sans", system-ui, sans-serif +} .dark { --background: oklch(0.145 0 0); --foreground: oklch(0.985 0 0); diff --git a/services/frontend/tailwind.config.js b/services/frontend/tailwind.config.js new file mode 100644 index 0000000..50cd5f6 --- /dev/null +++ b/services/frontend/tailwind.config.js @@ -0,0 +1,11 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + theme: { + extend: { + fontFamily: { + 'hse-sans': ['"HSE Sans"', 'system-ui', 'sans-serif'], + }, + }, + }, + plugins: [], +} diff --git a/tests/README.md b/tests/README.md index ff758b6..4eb6984 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,4 +1,4 @@ -# project_name Tests +# DataRush Tests There is `unit` and `e2e` tests available, unit tests are placed all around `backend` serivce folder and `e2e` tests placed [here](./e2e/). diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 467ca99..7d32628 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -1,4 +1,4 @@ -# E2E tests for project_name +# E2E tests for DataRush ## Prerequisites diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 7750ce3..e7ecdcd 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -30,7 +30,7 @@ def docker_compose() -> Generator[None]: "docker", "compose", "--project-name", - "project_name", + "datarush-testing", "up", "-d", "--build", @@ -49,7 +49,7 @@ def docker_compose() -> Generator[None]: "docker", "compose", "--project-name", - "project_name", + "datarush-testing", "down", "-v", ], diff --git a/tests/e2e/pyproject.toml b/tests/e2e/pyproject.toml index 01813d7..0ba7a70 100644 --- a/tests/e2e/pyproject.toml +++ b/tests/e2e/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "project_name-e2e-tests" +name = "datarush-e2e-tests" version = "0.1.0" readme = "README.md" requires-python = ">=3.10,<3.12"