diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..48adce1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,64 @@ +# Dependencies +node_modules +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Testing +coverage +tests + +# IDE +.vscode +.idea +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Git +.git +.gitignore +.gitattributes + +# CI/CD +.github + +# Documentation +*.md +LICENSE.txt + +# Build artifacts (будут созданы внутри контейнера) +dist +build +*.log + +# Config files (не нужны в образе) +.prettierrc.js +.prettierignore +.eslintignore +.lintstagedrc.json +eslint.config.js +tsconfig*.json +vite.config.ts + +# Native app (не нужно в web образе) +native + +# Storybook +.storybook +*.stories.tsx + +# Environment (будет встроено в build) +.env.local +.env.development +.env.production + +# Husky +.husky + +# Misc +*.iml diff --git a/.env b/.env index 1630994..0ace094 100644 --- a/.env +++ b/.env @@ -1,14 +1,11 @@ IJO42_URL = ijo42.ru -VITE_MAPTILES_STYLE_KEY = 1XfSivF5uaaJV0EiuRS1 - VITE_URL_IJO42_TILES = martin://tiles2.$IJO42_URL/ VITE_URL_MAP_ASSETS = https://res.$IJO42_URL/ VITE_URL_IJO42_MAPI = https://mapi.$IJO42_URL/v2 VITE_URL_PSU_TOOLS_API = https://events.$IJO42_URL VITE_URL_ICAL_ENDPOINT = https://ical.psu.ru/calendars/ -VITE_URL_MAPTILER_STYLE = https://api.maptiler.com/maps/streets/style.json VITE_URL_BIND_ETIS = https://student.psu.ru/ VITE_URL_TG_GROUP = https://t.me/psumaps VITE_URL_SUPPORT = https://t.me/psumaps_sbot?start=vkmapp diff --git a/.github/workflows/docker-build.yaml b/.github/workflows/docker-build.yaml new file mode 100644 index 0000000..265640a --- /dev/null +++ b/.github/workflows/docker-build.yaml @@ -0,0 +1,86 @@ +name: Build and Push Docker Image + +on: + push: + branches: + - master + tags: + - 'v*' + pull_request: + branches: + - master + +env: + REGISTRY: ghcr.io + IMAGE_NAME: psumaps/mini-app + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Load environment variables from .env + id: dotenv + run: | + # Parse .env file and create build-args + echo "build_args<> $GITHUB_OUTPUT + + # First, extract IJO42_URL for variable expansion + IJO42_URL=$(grep "^IJO42_URL" .env | cut -d= -f2 | xargs) + export IJO42_URL + + # Process all VITE_* variables + grep "^VITE_" .env | while IFS= read -r line; do + key=$(echo "$line" | cut -d= -f1 | xargs) + value=$(echo "$line" | cut -d= -f2- | xargs) + + # Expand variables (like $IJO42_URL) + expanded_value=$(eval echo "$value") + echo "$key=$expanded_value" >> $GITHUB_OUTPUT + done + + echo "EOF" >> $GITHUB_OUTPUT + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha,prefix=sha- + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: ${{ steps.dotenv.outputs.build_args }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Image digest + run: echo ${{ steps.build.outputs.digest }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8da50a1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,56 @@ +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package*.json ./ +COPY shared/package*.json ./shared/ +COPY web/package*.json ./web/ + +# Install dependencies +RUN npm ci && \ + npm ci --prefix shared && \ + npm ci --prefix web + +# Copy source code +COPY . . + +# Build arguments for environment variables +ARG VITE_URL_IJO42_TILES +ARG VITE_URL_MAP_ASSETS +ARG VITE_URL_IJO42_MAPI +ARG VITE_URL_PSU_TOOLS_API +ARG VITE_URL_ICAL_ENDPOINT +ARG VITE_URL_BIND_ETIS +ARG VITE_URL_TG_GROUP +ARG VITE_URL_SUPPORT +ARG VITE_URL_VK_APP + +# Set environment variables for build +ENV VITE_URL_IJO42_TILES=${VITE_URL_IJO42_TILES} +ENV VITE_URL_MAP_ASSETS=${VITE_URL_MAP_ASSETS} +ENV VITE_URL_IJO42_MAPI=${VITE_URL_IJO42_MAPI} +ENV VITE_URL_PSU_TOOLS_API=${VITE_URL_PSU_TOOLS_API} +ENV VITE_URL_ICAL_ENDPOINT=${VITE_URL_ICAL_ENDPOINT} +ENV VITE_URL_BIND_ETIS=${VITE_URL_BIND_ETIS} +ENV VITE_URL_TG_GROUP=${VITE_URL_TG_GROUP} +ENV VITE_URL_SUPPORT=${VITE_URL_SUPPORT} +ENV VITE_URL_VK_APP=${VITE_URL_VK_APP} + +# Build the web application +RUN npm run build --prefix web + +FROM nginxinc/nginx-unprivileged:alpine + +# Copy custom nginx configuration +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Copy built assets from builder stage +COPY --from=builder /app/web/dist /usr/share/nginx/html + +# nginx-unprivileged runs on port 8080 by default +EXPOSE 8080 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1 diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..677d8ac --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,23 @@ +services: + psumaps-web: + build: + context: . + dockerfile: Dockerfile + args: + VITE_URL_IJO42_TILES: martin://tiles2.ijo42.ru/ + VITE_URL_MAP_ASSETS: https://res.ijo42.ru/ + VITE_URL_IJO42_MAPI: https://mapi.ijo42.ru/v2 + VITE_URL_ICAL_ENDPOINT: https://ical.psu.ru/calendars/ + VITE_URL_BIND_ETIS: https://student.psu.ru/ + VITE_URL_TG_GROUP: https://t.me/psumaps + VITE_URL_SUPPORT: https://t.me/psumaps_sbot?start=vkmapp + VITE_URL_VK_APP: https://m.vk.com/app51764300 + ports: + - "8080:8080" + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"] + interval: 30s + timeout: 3s + retries: 3 + start_period: 5s diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..b8346f8 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,53 @@ +server { + listen 8080; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # SPA routing - always serve index.html for client-side routing + location / { + try_files $uri $uri/ /index.html; + } + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Disable caching for index.html to ensure users get latest version + location = /index.html { + add_header Cache-Control "no-cache, no-store, must-revalidate"; + expires 0; + } + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_comp_level 6; + gzip_types + text/plain + text/css + text/xml + text/javascript + application/json + application/javascript + application/xml + application/xml+rss + application/x-javascript + image/svg+xml; + + # Health check endpoint for k8s probes + location /health { + access_log off; + return 200 "OK\n"; + add_header Content-Type text/plain; + } +} diff --git a/package-lock.json b/package-lock.json index f39f8ec..a9d643a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "psumaps-frontend", - "version": "1.1.1", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "psumaps-frontend", - "version": "1.1.1", + "version": "1.2.0", "license": "MPL-2.0", "dependencies": { "@tanstack/react-query": "^5.69.0", diff --git a/package.json b/package.json index f45e207..8b492e9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "psumaps-frontend", - "version": "1.1.1", + "version": "1.2.0", "description": "Client applications of PSU Maps", "type": "module", "scripts": { diff --git a/shared/.storybook/preview.js b/shared/.storybook/preview.js index 50a53ce..cb11a65 100644 --- a/shared/.storybook/preview.js +++ b/shared/.storybook/preview.js @@ -1,4 +1,4 @@ -import {withThemeByClassName} from '@storybook/addon-themes'; +import { withThemeByClassName } from '@storybook/addon-themes'; import '../../web/src/tw.css'; /** @type { import('@storybook/react').Preview } */ diff --git a/shared/src/components/map/searchPopUp/search/index.tsx b/shared/src/components/map/searchPopUp/search/index.tsx index ce7c961..facaee0 100644 --- a/shared/src/components/map/searchPopUp/search/index.tsx +++ b/shared/src/components/map/searchPopUp/search/index.tsx @@ -23,7 +23,7 @@ const SEARCH_DEBOUNCE_MS = 500; const Search = () => { const { data: animEnabled } = useAnimEnabled(); const queryClient = useQueryClient(); - const { token } = useIcalToken(); + const { jwtToken } = useIcalToken(); const storage = useContext(StorageContext); const { search, popupState, setPopupState, selectedPoi } = @@ -45,7 +45,7 @@ const Search = () => { const searchQuery = useQuery( { queryKey: ['search', debouncedSearch], - queryFn: async () => httpClient.mapi.search(debouncedSearch, token!), + queryFn: async () => httpClient.mapi.search(debouncedSearch, jwtToken!), enabled: !!debouncedSearch && popupState === 'opened', ...queryOptions, }, @@ -53,14 +53,14 @@ const Search = () => { ); const amenities = useQuery({ queryKey: ['amenities'], - queryFn: async () => httpClient.mapi.getAmenityList(token!), + queryFn: async () => httpClient.mapi.getAmenityList(jwtToken!), ...queryOptions, enabled: popupState === 'opened', }); const amenityPois = useQuery({ queryKey: ['amenity-pois', selectedAmenity], queryFn: async () => - httpClient.mapi.getPoiByAmenity(selectedAmenity!, token!), + httpClient.mapi.getPoiByAmenity(selectedAmenity!, jwtToken!), enabled: !!selectedAmenity && popupState === 'opened', ...queryOptions, }); diff --git a/shared/src/components/settings/IcalTokenInput.tsx b/shared/src/components/settings/IcalTokenInput.tsx index 85b0857..3d22063 100644 --- a/shared/src/components/settings/IcalTokenInput.tsx +++ b/shared/src/components/settings/IcalTokenInput.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useState, useEffect } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import CheckSvg from '../../assets/check-circle.svg?react'; import MinusSvg from '../../assets/minus-circle.svg?react'; import CrossSvg from '../../assets/x-circle.svg?react'; @@ -56,7 +56,7 @@ const IcalTokenInput = ({ className }: Props) => { const [isManuallyValidating, setIsManuallyValidating] = useState(false); const [lastProcessedToken, setLastProcessedToken] = useState(''); - const { token, isValid, isLoading, error, setToken, clearToken } = + const { icalToken, isValid, isLoading, error, setToken, clearToken } = useIcalToken(); // Эффект для отображения анимации тряски при ошибке @@ -153,8 +153,8 @@ const IcalTokenInput = ({ className }: Props) => { }; // Маскирование токена для отображения - const tokenMasked = token - ? `${token.substring(0, token.length / 2)}${'*'.repeat(Math.ceil(token.length / 2.0))}` + const tokenMasked = icalToken + ? `${icalToken.substring(0, icalToken.length / 2)}${'*'.repeat(Math.ceil(icalToken.length / 2.0))}` : ''; // Определяем класс для статуса @@ -197,7 +197,7 @@ const IcalTokenInput = ({ className }: Props) => { } ${animEnabled ? 'transition-all duration-200 ease-in-out' : ''} `} >
- {isValid && token &&

Ваш токен: {tokenMasked}

} + {isValid && icalToken &&

Ваш токен: {tokenMasked}

}
, ) => { - const { token, isValid } = useIcalToken(); + const { icalToken, isValid } = useIcalToken(); const navigator = useContext(NavigatorContext); const { classesQuery, dateFrom, ...rest } = props; @@ -23,7 +23,7 @@ const FeedClasses = ( return (
- {!(isValid && token) ? ( + {!(isValid && icalToken) ? ( <>

Авторизация не пройдена.

@@ -51,7 +51,7 @@ const FeedClasses = ( key={`${lesson.classId}`} classData={lesson} navigate={(s) => navigator?.navigate(s)} - icalToken={token} + icalToken={icalToken} /> ))} diff --git a/shared/src/components/timetable/index.tsx b/shared/src/components/timetable/index.tsx index c89a15b..83c35c2 100644 --- a/shared/src/components/timetable/index.tsx +++ b/shared/src/components/timetable/index.tsx @@ -21,7 +21,7 @@ const EVENTS_FEED_ID = 'feed-events'; const Timetable = () => { const storageContext = useContext(StorageContext); - const { token, isValid } = useIcalToken(); + const { icalToken, isValid } = useIcalToken(); const { data: animEnabled } = useAnimEnabled(); const queryClient = useQueryClient(); const [searchValue, setSearchValue] = useState(''); @@ -58,12 +58,12 @@ const Timetable = () => { { queryKey: ['classes'], queryFn: async () => { - if (!token || !isValid) { + if (!icalToken || !isValid) { throw new Error('Токен не валиден или отсутствует'); } - return httpClient.ical.getTimetable({ token }); + return httpClient.ical.getTimetable({ token: icalToken }); }, - enabled: isValid && !!token, + enabled: isValid && !!icalToken, retry: false, refetchOnWindowFocus: false, staleTime: 10 * 60 * 1000, diff --git a/shared/src/contexts/IcalTokenContext.tsx b/shared/src/contexts/IcalTokenContext.tsx index c31481c..4d8ffda 100644 --- a/shared/src/contexts/IcalTokenContext.tsx +++ b/shared/src/contexts/IcalTokenContext.tsx @@ -1,21 +1,22 @@ import React, { createContext, + useCallback, useContext, - useState, useEffect, useMemo, - useCallback, + useState, } from 'react'; import type IStorage from '../models/storage'; import httpClient from '../network/httpClient'; interface IcalTokenContextType { - token: string | null; + icalToken: string | null; + jwtToken: string | null; isValid: boolean; isLoading: boolean; error: string | null; isServiceAvailable: boolean; - setToken: (token: string) => Promise; + setToken: (icalToken: string) => Promise; validateToken: (token: string) => Promise; clearToken: () => Promise; } @@ -41,17 +42,45 @@ export const IcalTokenProvider: React.FC = ({ storage, children, }) => { - const [token, setTokenState] = useState(null); + const [icalToken, setIcalTokenState] = useState(null); + const [jwtToken, setJwtTokenState] = useState(null); const [isValid, setIsValid] = useState(false); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [isServiceAvailable, setIsServiceAvailable] = useState(false); + // Обмен iCal токена на JWT + const exchangeToken = useCallback( + async (icalTokenToExchange: string): Promise => { + try { + const jwt = + await httpClient.auth.exchangeIcalForJwt(icalTokenToExchange); + setIsServiceAvailable(true); + setError(null); + return jwt; + } catch (err: unknown) { + setIsServiceAvailable(true); + if (err && typeof err === 'object' && 'response' in err) { + const axiosErr = err as { response?: { status?: number } }; + if (axiosErr.response?.status === 401) { + setError('Токен не прошел проверку'); + return null; + } + } + // Сетевые ошибки или таймаут + setError('Сервер временно недоступен'); + setIsServiceAvailable(false); + return null; + } + }, + [], + ); + // Валидация токена const validateToken = useCallback( async (tokenToValidate: string): Promise => { try { - const result = await httpClient.mapi.validateIcal(tokenToValidate); + const result = await httpClient.auth.validateJwt(tokenToValidate); setIsServiceAvailable(true); // Обработка всех возможных результатов от mapiClient.validateIcal @@ -82,9 +111,9 @@ export const IcalTokenProvider: React.FC = ({ // Установка нового токена const setToken = useCallback( - async (newToken: string): Promise => { + async (newIcalToken: string): Promise => { // Проверка формата токена - if (!newToken.match(/^\w{16}$/)) { + if (!newIcalToken.match(/^\w{16}$/)) { setError('Токен должен состоять из 16 латинских букв и цифр'); return; } @@ -93,13 +122,18 @@ export const IcalTokenProvider: React.FC = ({ setError(null); try { - const isTokenValid = await validateToken(newToken); - if (isTokenValid) { - await storage.set('ical_token', newToken); - setTokenState(newToken); + // Пытаемся обменять токен на JWT + const jwt = await exchangeToken(newIcalToken); + + if (jwt) { + // Сохраняем оба токена + await storage.set('ical_token', newIcalToken); + await storage.set('jwt_token', jwt); + setIcalTokenState(newIcalToken); + setJwtTokenState(jwt); setIsValid(true); } else { - // Не очищаем токен из хранилища при ошибке валидации + // Обмен не удался, но токен сохраняем для повторных попыток setIsValid(false); } } catch (_err) { @@ -108,14 +142,16 @@ export const IcalTokenProvider: React.FC = ({ setIsLoading(false); } }, - [storage, validateToken], + [storage, exchangeToken], ); // Очистка токена const clearToken = useCallback(async (): Promise => { try { await storage.set('ical_token', ''); - setTokenState(null); + await storage.set('jwt_token', ''); + setIcalTokenState(null); + setJwtTokenState(null); setIsValid(false); setError(null); } catch (_err) { @@ -127,11 +163,29 @@ export const IcalTokenProvider: React.FC = ({ useEffect(() => { const initializeToken = async () => { try { - const storedToken = await storage.get('ical_token'); - if (storedToken) { - setTokenState(storedToken); - const valid = await validateToken(storedToken); - setIsValid(valid); + const storedIcalToken = await storage.get('ical_token'); + const storedJwtToken = await storage.get('jwt_token'); + + if (storedIcalToken) { + setIcalTokenState(storedIcalToken); + + // Если есть JWT - используем его + if (storedJwtToken) { + setJwtTokenState(storedJwtToken); + const valid = await validateToken(storedJwtToken); + setIsValid(valid); + } else { + // Автоматическая миграция: есть iCal, но нет JWT + const jwt = await exchangeToken(storedIcalToken); + if (jwt) { + await storage.set('jwt_token', jwt); + setJwtTokenState(jwt); + setIsValid(true); + } else { + // Обмен не удался, попытка будет при следующей загрузке + setIsValid(false); + } + } } } catch (_err) { setError('Ошибка при инициализации токена'); @@ -141,11 +195,12 @@ export const IcalTokenProvider: React.FC = ({ }; void initializeToken(); - }, [storage, validateToken]); + }, [storage, exchangeToken]); const value = useMemo( () => ({ - token, + icalToken, + jwtToken, isValid, isLoading, error, @@ -155,7 +210,8 @@ export const IcalTokenProvider: React.FC = ({ clearToken, }), [ - token, + icalToken, + jwtToken, isValid, isLoading, error, diff --git a/shared/src/hooks/useLocationHash.ts b/shared/src/hooks/useLocationHash.ts index a925123..30d9b14 100644 --- a/shared/src/hooks/useLocationHash.ts +++ b/shared/src/hooks/useLocationHash.ts @@ -18,7 +18,7 @@ import { useSharedMapContext } from '../contexts/SharedMapContext'; * - `e=(\d+)` - переходит к событию с id */ const useLocationHash = () => { - const { token, isValid, setToken } = useIcalToken(); + const { jwtToken, isValid, setToken } = useIcalToken(); const { showNotification } = useNotification(); const { handlePoiSelect } = useMapContext(); const { setSearch } = useSharedMapContext(); @@ -43,7 +43,7 @@ const useLocationHash = () => { if (hashParams.has('q')) { result = await PoiHandlerModule.handleIndoorByName( hashParams.get('q') as string, - token ?? undefined, + jwtToken ?? undefined, isValid, handlePoiSelect, setSearch, @@ -51,7 +51,7 @@ const useLocationHash = () => { } else if (hashParams.has('i')) { result = await PoiHandlerModule.handleIndoorById( hashParams.get('i') as string, - token ?? undefined, + jwtToken ?? undefined, isValid, handlePoiSelect, ); @@ -69,7 +69,7 @@ const useLocationHash = () => { return result; }, - [setToken, token, isValid, handlePoiSelect, setSearch], + [setToken, jwtToken, isValid, handlePoiSelect, setSearch], ); /** diff --git a/shared/src/network/httpClient/authClient.ts b/shared/src/network/httpClient/authClient.ts new file mode 100644 index 0000000..31f8e96 --- /dev/null +++ b/shared/src/network/httpClient/authClient.ts @@ -0,0 +1,57 @@ +import axios, { AxiosError } from 'axios'; +import api from '../api'; + +interface JwtResponse { + jwt: string; +} + +const client = { + /** + * Обмен iCal токена на JWT токен + * @param icalToken - 16-значный iCal токен + * @returns JWT токен + * @throws AxiosError с кодом 401 если токен невалиден + */ + exchangeIcalForJwt: async (icalToken: string): Promise => { + try { + const response = await axios.post( + `${api.mapi}/auth`, + { token: icalToken }, + { + headers: { 'Content-Type': 'application/json' }, + signal: AbortSignal.timeout(5000), + }, + ); + return response.data.jwt; + } catch (e) { + if (e instanceof AxiosError) { + throw e; + } + throw new Error('Unexpected error during token exchange'); + } + }, + + /** + * Валидация JWT токена через /v2/ping + * @param jwtToken - JWT токен + * @returns true если токен валиден + */ + validateJwt: async (jwtToken: string): Promise => { + try { + await axios.get(`${api.mapi}/ping`, { + headers: { Authorization: jwtToken }, + signal: AbortSignal.timeout(3000), + }); + return true; + } catch (e) { + if (e instanceof AxiosError) { + if (e.response?.status === 401) { + return false; + } + } + return null; + } + }, +}; + +export default client; diff --git a/shared/src/network/httpClient/index.ts b/shared/src/network/httpClient/index.ts index b5b6a87..83a1211 100644 --- a/shared/src/network/httpClient/index.ts +++ b/shared/src/network/httpClient/index.ts @@ -10,12 +10,14 @@ import mapiClient from './mapiClient'; import psuToolsClient from './psuToolsClient'; import icalClient from './icalClient'; import tileClient from './tileClient'; +import authClient from './authClient'; const httpClient = { mapi: mapiClient, psuTools: psuToolsClient, ical: icalClient, tile: tileClient, + auth: authClient, }; export default httpClient; diff --git a/shared/src/network/httpClient/mapiClient.ts b/shared/src/network/httpClient/mapiClient.ts index 00cb58a..f9cc233 100644 --- a/shared/src/network/httpClient/mapiClient.ts +++ b/shared/src/network/httpClient/mapiClient.ts @@ -1,4 +1,4 @@ -import axios, { AxiosError } from 'axios'; +import axios from 'axios'; import Poi from '../models/mapi/poi'; import api from '../api'; @@ -9,17 +9,6 @@ const tokenHeader = (token: string) => ({ const badAmenities = ['community_centre', 'yes', 'main']; const client = { - validateIcal: async (token: string) => { - try { - await axios.get(`${api.mapi}/ping`, tokenHeader(token)); - } catch (e) { - if (e instanceof AxiosError) { - if (e.response?.status === 401) return false; - } - return null; - } - return true; - }, getIndoorById: async (id: string, token: string) => { const response = await axios.get( `${api.mapi}/indoor?query_id=${id}`, @@ -33,31 +22,31 @@ const client = { ); return response.data; }, - getAmenityList: async (token: string) => { + getAmenityList: async (jwtToken: string) => { const response = await axios.get<{ collection: string[] }>( `${api.mapi}/amenitys`, - tokenHeader(token), + tokenHeader(jwtToken), ); return response.data.collection.filter( - (item) => !!item && !badAmenities.includes(item), + (item) => !!item && !badAmenities.includes(item) && /[\w_]+/.test(item), ); }, - getPoiByAmenity: async (amenity: string, token: string) => { + getPoiByAmenity: async (amenity: string, jwtToken: string) => { const response = await axios.get<{ collection: Poi[] }>( `${api.mapi}/amenity?query_name=${amenity}`, - tokenHeader(token), + tokenHeader(jwtToken), ); return response.data.collection; }, search: async ( query: string, - token: string, + jwtToken: string, limit: number = 10, offset: number = 0, ) => { const response = await axios.get<{ collection: Poi[] }>( `${api.mapi}/search?query_name=${query}&limit=${limit}&offset=${offset}`, - tokenHeader(token), + tokenHeader(jwtToken), ); return response.data.collection; }, diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 87b3c32..43b321c 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -1,4 +1,4 @@ -import React, {Suspense} from 'react'; +import React, { Suspense } from 'react'; import { createBrowserRouter } from 'react-router-dom'; import PageBase from '~/pages/pageBase'; @@ -7,14 +7,14 @@ const SettingsPage = React.lazy(() => import('../pages/settings')); const MapPage = React.lazy(() => import('~/pages/map')); const TimetablePage = React.lazy(() => import('~/pages/timetable')); const EventDescription = React.lazy( - () => import('~/pages/timetable/eventDescription'), + () => import('~/pages/timetable/eventDescription'), ); // Suspense fallback component const LoadingFallback = () => ( -

-
Loading...
-
+
+
Loading...
+
); const router = createBrowserRouter([ @@ -22,9 +22,9 @@ const router = createBrowserRouter([ path: '/', element: ( - }> - - + }> + + ), }, @@ -32,9 +32,9 @@ const router = createBrowserRouter([ path: '/settings', element: ( - }> - - + }> + + ), }, @@ -42,9 +42,9 @@ const router = createBrowserRouter([ path: '/timetable', element: ( - }> - - + }> + + ), }, @@ -52,9 +52,9 @@ const router = createBrowserRouter([ path: '/event/:eventId', element: ( - }> - - + }> + + ), }, diff --git a/web/src/mapEngine/layers.ts b/web/src/mapEngine/layers.ts index 73a0c4e..d19ed1e 100644 --- a/web/src/mapEngine/layers.ts +++ b/web/src/mapEngine/layers.ts @@ -21,7 +21,7 @@ const commonPoi = { 'text-offset': [0, 0.6], 'text-padding': 2, 'text-size': 12, - 'text-font': ['DIN Offc Pro Medium'], + 'text-font': ['DINPro Medium'], }, paint: { 'text-color': '#666', @@ -226,7 +226,7 @@ const layers: LayerSpecification[] = [ ], 'text-max-width': 5, 'text-size': 14, - 'text-font': ['DIN Offc Pro Medium'], + 'text-font': ['DINPro Medium'], }, paint: { 'text-color': '#666', @@ -278,7 +278,7 @@ const layers: LayerSpecification[] = [ 'text-field': ['get', 'name'], 'text-max-width': 7, 'text-size': 16, - 'text-font': ['DIN Offc Pro Medium'], + 'text-font': ['DINPro Medium'], }, paint: { 'text-color': '#575656', diff --git a/web/src/mapEngine/mapConfig.ts b/web/src/mapEngine/mapConfig.ts index 38063d8..34b7f16 100644 --- a/web/src/mapEngine/mapConfig.ts +++ b/web/src/mapEngine/mapConfig.ts @@ -34,8 +34,8 @@ const mapStyle: StyleSpecification = { }, ...mapLayers, ], - sprite: `${import.meta.env.VITE_URL_MAP_ASSETS}assets/sprite/indoorequal`, - glyphs: `${import.meta.env.VITE_URL_MAP_ASSETS}assets/font/{fontstack}/{range}`, + sprite: `${import.meta.env.VITE_URL_MAP_ASSETS}sprite/indoorequal`, + glyphs: `${import.meta.env.VITE_URL_MAP_ASSETS}font/{fontstack}/{range}`, }; export const initialView: ViewState = { diff --git a/web/src/pages/map/components/PoiHandler.tsx b/web/src/pages/map/components/PoiHandler.tsx index 706be8b..9f09cd4 100644 --- a/web/src/pages/map/components/PoiHandler.tsx +++ b/web/src/pages/map/components/PoiHandler.tsx @@ -6,7 +6,7 @@ import { useNotification } from 'psumaps-shared/src/components/common/notificati import { useMapContext } from '~/pages/map/contexts/MapContext'; const PoiHandler: React.FC = () => { - const { token } = useIcalToken(); + const { jwtToken } = useIcalToken(); const { showNotification } = useNotification(); const { handlePoiSelect, mapRef, isMapLoaded } = useMapContext(); @@ -18,7 +18,7 @@ const PoiHandler: React.FC = () => { ) => { if (!(e.features![0].properties.class === 'entrance')) { httpClient.mapi - .getIndoorById(String(e.features![0].id!).slice(0, -1), token!) + .getIndoorById(String(e.features![0].id!).slice(0, -1), jwtToken!) .then((data) => { if (data) { handlePoiSelect(data); @@ -31,12 +31,12 @@ const PoiHandler: React.FC = () => { }); } }, - [token, handlePoiSelect, showNotification], + [jwtToken, handlePoiSelect, showNotification], ); // Регистрируем обработчики событий после загрузки карты useEffect(() => { - if (!isMapLoaded || !token) return undefined; + if (!isMapLoaded || !jwtToken) return undefined; const map = mapRef.current; if (!map) return undefined; @@ -62,7 +62,7 @@ const PoiHandler: React.FC = () => { map.off('click', 'indoor-poi-rank2', handlePoiClick); } }; - }, [isMapLoaded, mapRef, token, handlePoiClick]); + }, [isMapLoaded, mapRef, jwtToken, handlePoiClick]); return null; // Этот компонент не рендерит UI, только добавляет обработчики событий }; diff --git a/web/src/pages/map/contexts/MapContext.tsx b/web/src/pages/map/contexts/MapContext.tsx index 448963f..497985e 100644 --- a/web/src/pages/map/contexts/MapContext.tsx +++ b/web/src/pages/map/contexts/MapContext.tsx @@ -51,7 +51,7 @@ const MapProviderInner: React.FC<{ children: ReactNode }> = ({ children }) => { const [viewState, setViewState] = useState(initialView); const [isBannerVisible, setIsBannerVisible] = useState(true); const [isMapLoaded, setIsMapLoaded] = useState(false); - const { token, isValid, isServiceAvailable } = useIcalToken(); + const { jwtToken, isValid, isServiceAvailable } = useIcalToken(); const queryClient = useQueryClient(); const { @@ -80,7 +80,7 @@ const MapProviderInner: React.FC<{ children: ReactNode }> = ({ children }) => { useEffect(() => { registerProtocol({ queryClient, - token: token ?? undefined, + token: jwtToken ?? undefined, isValid, isServiceAvailable, onAuthError: handleAuthError, @@ -88,7 +88,7 @@ const MapProviderInner: React.FC<{ children: ReactNode }> = ({ children }) => { }); return () => removeProtocol('martin'); }, [ - token, + jwtToken, isValid, isServiceAvailable, queryClient, diff --git a/web/src/pages/map/mapUtils.ts b/web/src/pages/map/mapUtils.ts index 48013f1..bfeea2f 100644 --- a/web/src/pages/map/mapUtils.ts +++ b/web/src/pages/map/mapUtils.ts @@ -37,10 +37,7 @@ const registerProtocol = ({ const url = params.url.replace(/pub/, ''); try { tilesResponse = await queryClient.fetchQuery({ - queryFn: async () => - httpClient.tile.getTile(url, { - Authorization: `Bearer ${token}`, - }), + queryFn: async () => httpClient.tile.getTile(url), queryKey: ['tiles', params.url.split('tiles')[2]], staleTime: 12 * 60 * 60 * 1000, }); diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts index d1c83bd..2f28c3f 100644 --- a/web/src/vite-env.d.ts +++ b/web/src/vite-env.d.ts @@ -7,9 +7,7 @@ interface ImportMetaEnv { readonly VITE_PSU_TOOLS_KEY: string; readonly VITE_URL_PSU_TOOLS_API: string; readonly VITE_URL_ICAL_ENDPOINT: string; - readonly VITE_URL_INDOOREQUAL_TILES: string; readonly VITE_URL_MAPTILER_STYLE: string; - readonly VITE_MAPTILES_STYLE_KEY: string; readonly VITE_URL_BIND_ETIS: string; readonly VITE_URL_SUPPORT: string; readonly VITE_URL_TG_GROUP: string; diff --git a/web/src/widgets/navigationBar.tsx b/web/src/widgets/navigationBar.tsx index 7e92b0c..038484f 100644 --- a/web/src/widgets/navigationBar.tsx +++ b/web/src/widgets/navigationBar.tsx @@ -4,7 +4,6 @@ import React, { useContext, useEffect } from 'react'; import { useLocation } from 'react-router-dom'; import getStoredTheme from 'psumaps-shared/src/utils/readTheme'; import MapIcon from 'psumaps-shared/src/assets/map.svg?react'; -import TimetableIcon from 'psumaps-shared/src/assets/timetable.svg?react'; import SettingsIcon from 'psumaps-shared/src/assets/settings.svg?react'; import { StorageContext } from 'psumaps-shared/src/models/storage'; import { NavigatorContext } from 'psumaps-shared/src/models/navigator'; @@ -46,13 +45,14 @@ const NavigationBar = ({ className }: { className?: string }) => { > - + {/*Недоступны...*/} + {/* navigator?.navigate('/timetable')}*/} + {/* aria-label="Расписание"*/} + {/*>*/} + {/* */} + {/**/}
); };