diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 543ff54..9ec6993 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -26,14 +26,14 @@ jobs: password: ${{ env.REGISTRY_PASSWORD }} - name: Build & Publish backend to Github Container registry run: | - docker build ./backend --tag $REGISTRY_URL/$REGISTRY_USERNAME/calendarit-backend:latest \ - --tag $REGISTRY_URL/$REGISTRY_USERNAME/calendarit-backend:$IMAGE_TAG - docker push $REGISTRY_URL/$REGISTRY_USERNAME/calendarit-backend:latest - docker push $REGISTRY_URL/$REGISTRY_USERNAME/calendarit-backend:$IMAGE_TAG + docker build ./backend --tag $REGISTRY_URL/$REGISTRY_USERNAME/yet_another_calendar_backend:latest \ + --tag $REGISTRY_URL/$REGISTRY_USERNAME/yet_another_calendar_backend:$IMAGE_TAG + docker push $REGISTRY_URL/$REGISTRY_USERNAME/yet_another_calendar_backend:latest + docker push $REGISTRY_URL/$REGISTRY_USERNAME/yet_another_calendar_backend:$IMAGE_TAG - name: Build & Publish frontend to Github Container registry run: | - docker build ./frontend --tag $REGISTRY_URL/$REGISTRY_USERNAME/calendarit-frontend:latest \ - --tag $REGISTRY_URL/$REGISTRY_USERNAME/calendarit-frontend:$IMAGE_TAG - docker push $REGISTRY_URL/$REGISTRY_USERNAME/calendarit-frontend:latest - docker push $REGISTRY_URL/$REGISTRY_USERNAME/calendarit-frontend:$IMAGE_TAG \ No newline at end of file + docker build ./frontend --tag $REGISTRY_URL/$REGISTRY_USERNAME/yet_another_calendar_frontend:latest \ + --tag $REGISTRY_URL/$REGISTRY_USERNAME/yet_another_calendar_frontend:$IMAGE_TAG + docker push $REGISTRY_URL/$REGISTRY_USERNAME/yet_another_calendar_frontend:latest + docker push $REGISTRY_URL/$REGISTRY_USERNAME/yet_another_calendar_frontend:$IMAGE_TAG \ No newline at end of file diff --git a/.gitignore b/.gitignore index 449688b..388c996 100644 --- a/.gitignore +++ b/.gitignore @@ -124,7 +124,7 @@ celerybeat.pid *.sage.py # Environments -.env +frontend/.env .venv env/ venv/ diff --git a/backend/.env.dist b/backend/.env.dist index 4c31759..78a6ac2 100644 --- a/backend/.env.dist +++ b/backend/.env.dist @@ -1,2 +1,4 @@ MODEUS_USERNAME=test_username -MODEUS_PASSWORD=test_password \ No newline at end of file +MODEUS_PASSWORD=test_password +YET_ANOTHER_CALENDAR_WORKERS_COUNT=10 +YET_ANOTHER_CALENDAR_DEBUG=False \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt deleted file mode 100644 index 1e98b42..0000000 --- a/backend/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -blacksheep[full]~=2.0.0 -uvicorn==0.22.0 -pydantic-settings -MarkupSafe==2.1.3 -pydantic diff --git a/backend/yet_another_calendar/settings.py b/backend/yet_another_calendar/settings.py index fb2fd35..01e0532 100644 --- a/backend/yet_another_calendar/settings.py +++ b/backend/yet_another_calendar/settings.py @@ -35,9 +35,7 @@ class Settings(BaseSettings): port: int = 8000 # quantity of workers for uvicorn workers_count: int = 1 - # Enable uvicorn reloading - reload: bool = env.bool("YET_ANOTHER_CALENDAR_RELOAD", False) - + # Enable uvicorn reloading, debug and docs debug: bool = env.bool("YET_ANOTHER_CALENDAR_DEBUG", False) log_level: LogLevel = LogLevel.INFO diff --git a/backend/yet_another_calendar/web/api/bulk/integration.py b/backend/yet_another_calendar/web/api/bulk/integration.py index 6261a22..e35d8fd 100644 --- a/backend/yet_another_calendar/web/api/bulk/integration.py +++ b/backend/yet_another_calendar/web/api/bulk/integration.py @@ -21,14 +21,14 @@ def create_ics_event(title: str, starts_at: datetime.datetime, ends_at: datetime.datetime, - lesson_id: Any, timezone: datetime.tzinfo, description: Optional[str] = None, + lesson_id: Any, description: Optional[str] = None, webinar_url: Optional[str] = None) -> icalendar.Event: event = icalendar.Event() - dt_now = datetime.datetime.now(tz=timezone) + dt_now = datetime.datetime.now() event.add('summary', title) event.add('location', webinar_url) - event.add('dtstart', starts_at.astimezone(timezone)) - event.add('dtend', ends_at.astimezone(timezone)) + event.add('dtstart', starts_at) + event.add('dtend', ends_at) event.add('dtstamp', dt_now) event.add('uid', lesson_id) if description: @@ -36,11 +36,7 @@ def create_ics_event(title: str, starts_at: datetime.datetime, ends_at: datetime return event -def export_to_ics(calendar: schema.CalendarResponse, timezone: str) -> Iterable[bytes]: - try: - tz = pytz.timezone(timezone) - except pytz.exceptions.UnknownTimeZoneError: - raise HTTPException(detail="Wrong timezone", status_code=status.HTTP_400_BAD_REQUEST) from None +def export_to_ics(calendar: schema.CalendarResponse) -> Iterable[bytes]: ics_calendar = icalendar.Calendar() ics_calendar.add('version', '2.0') ics_calendar.add('prodid', 'yet_another_calendar') @@ -50,12 +46,12 @@ def export_to_ics(calendar: schema.CalendarResponse, timezone: str) -> Iterable[ continue event = create_ics_event(title=f"Netology: {netology_lesson.title}", starts_at=netology_lesson.starts_at, ends_at=netology_lesson.ends_at, lesson_id=netology_lesson.id, - timezone=tz, webinar_url=netology_lesson.webinar_url) + webinar_url=netology_lesson.webinar_url) ics_calendar.add_component(event) for modeus_lesson in calendar.modeus: event = create_ics_event(title=f"Modeus: {modeus_lesson.name}", starts_at=modeus_lesson.start_time, ends_at=modeus_lesson.end_time, - lesson_id=modeus_lesson.id, timezone=tz, + lesson_id=modeus_lesson.id, description=modeus_lesson.description) ics_calendar.add_component(event) yield ics_calendar.to_ical() @@ -66,11 +62,12 @@ async def refresh_events( jwt_token: str, calendar_id: int, cookies: netology_schema.NetologyCookies, + timezone: str, ) -> schema.RefreshedCalendarResponse: """Clear events cache.""" - cached_json = await get_cached_calendar(body, jwt_token, calendar_id, cookies) + cached_json = await get_cached_calendar(body, jwt_token, calendar_id, cookies, timezone) cached_calendar = schema.CalendarResponse.model_validate(cached_json) - calendar = await get_calendar(body, jwt_token, calendar_id, cookies) + calendar = await get_calendar(body, jwt_token, calendar_id, cookies, timezone) changed = cached_calendar.get_hash() != calendar.get_hash() try: cache_key = default_key_builder(get_cached_calendar, args=(body, jwt_token, calendar_id, cookies), kwargs={}) @@ -93,13 +90,19 @@ async def get_calendar( jwt_token: str, calendar_id: int, cookies: netology_schema.NetologyCookies, + timezone: str, ) -> schema.CalendarResponse: + try: + tz = pytz.timezone(timezone) + except pytz.exceptions.UnknownTimeZoneError: + raise HTTPException(detail="Wrong timezone", status_code=status.HTTP_400_BAD_REQUEST) from None + async with asyncio.TaskGroup() as tg: netology_response = tg.create_task(netology_views.get_calendar(body, calendar_id, cookies)) modeus_response = tg.create_task(modeus_views.get_calendar(body, jwt_token)) return schema.CalendarResponse.model_validate( {"netology": netology_response.result(), "modeus": modeus_response.result()}, - ) + ).change_timezone(tz) @cache(expire=settings.redis_events_time_live) @@ -108,5 +111,6 @@ async def get_cached_calendar( jwt_token: str, calendar_id: int, cookies: netology_schema.NetologyCookies, + timezone: str, ) -> schema.CalendarResponse: - return await get_calendar(body, jwt_token, calendar_id, cookies) + return await get_calendar(body, jwt_token, calendar_id, cookies, timezone) diff --git a/backend/yet_another_calendar/web/api/bulk/schema.py b/backend/yet_another_calendar/web/api/bulk/schema.py index 93c31bd..3f16f32 100644 --- a/backend/yet_another_calendar/web/api/bulk/schema.py +++ b/backend/yet_another_calendar/web/api/bulk/schema.py @@ -1,5 +1,6 @@ import datetime import hashlib +from typing import Self from pydantic import BaseModel, Field @@ -11,6 +12,18 @@ class BulkResponse(BaseModel): netology: netology_schema.SerializedEvents modeus: list[modeus_schema.FullEvent] + def change_timezone(self, timezone: datetime.tzinfo) -> Self: + for homework in self.netology.homework: + if homework.deadline: + homework.deadline = homework.deadline.astimezone(timezone) + for webinar in self.netology.webinars: + webinar.starts_at = webinar.validate_starts_at(webinar.starts_at, timezone) + webinar.ends_at = webinar.validate_ends_at(webinar.ends_at, timezone) + + for event in self.modeus: + event.start_time = event.validate_starts_at(event.start_time, timezone) + event.end_time = event.validate_end_time(event.end_time, timezone) + return self class CalendarResponse(BulkResponse): cached_at: datetime.datetime = Field(default_factory=datetime.datetime.now, alias="cached_at") diff --git a/backend/yet_another_calendar/web/api/bulk/views.py b/backend/yet_another_calendar/web/api/bulk/views.py index c7507ac..035d0fb 100644 --- a/backend/yet_another_calendar/web/api/bulk/views.py +++ b/backend/yet_another_calendar/web/api/bulk/views.py @@ -21,12 +21,13 @@ async def get_calendar( cookies: Annotated[netology_schema.NetologyCookies, Depends(netology_schema.get_cookies_from_headers)], jwt_token: Annotated[str, Depends(modeus_schema.get_cookies_from_headers)], calendar_id: int = settings.netology_default_course_id, + time_zone: str = "Europe/Moscow", ) -> schema.CalendarResponse: """ Get events from Netology and Modeus, cached. """ - cached_calendar = await integration.get_cached_calendar(body, jwt_token, calendar_id, cookies) + cached_calendar = await integration.get_cached_calendar(body, jwt_token, calendar_id, cookies, time_zone) return schema.CalendarResponse.model_validate(cached_calendar) @@ -36,12 +37,13 @@ async def refresh_calendar( cookies: Annotated[netology_schema.NetologyCookies, Depends(netology_schema.get_cookies_from_headers)], jwt_token: Annotated[str, Depends(modeus_schema.get_cookies_from_headers)], calendar_id: int = settings.netology_default_course_id, + time_zone: str = "Europe/Moscow", ) -> schema.RefreshedCalendarResponse: """ Refresh events in redis. """ - return await integration.refresh_events(body, jwt_token, calendar_id, cookies) + return await integration.refresh_events(body, jwt_token, calendar_id, cookies, time_zone) @router.post("/export_ics/") @@ -55,5 +57,5 @@ async def export_ics( """ Export into .ics format """ - calendar = await integration.get_calendar(body, jwt_token, calendar_id, cookies) - return StreamingResponse(integration.export_to_ics(calendar, time_zone)) + calendar = await integration.get_calendar(body, jwt_token, calendar_id, cookies, time_zone) + return StreamingResponse(integration.export_to_ics(calendar)) diff --git a/backend/yet_another_calendar/web/api/docs/views.py b/backend/yet_another_calendar/web/api/docs/views.py index d75854f..33942ce 100644 --- a/backend/yet_another_calendar/web/api/docs/views.py +++ b/backend/yet_another_calendar/web/api/docs/views.py @@ -22,8 +22,8 @@ async def swagger_ui_html(request: Request) -> HTMLResponse: openapi_url=request.app.openapi_url, title=f"{title} - Swagger UI", oauth2_redirect_url=str(request.url_for("swagger_ui_redirect")), - swagger_js_url="/static/docs/swagger-ui-bundle.js", - swagger_css_url="/static/docs/swagger-ui.css", + swagger_js_url="/static_backend/docs/swagger-ui-bundle.js", + swagger_css_url="/static_backend/docs/swagger-ui.css", ) @@ -49,5 +49,5 @@ async def redoc_html(request: Request) -> HTMLResponse: return get_redoc_html( openapi_url=request.app.openapi_url, title=f"{title} - ReDoc", - redoc_js_url="/static/docs/redoc.standalone.js", + redoc_js_url="/static_backend/docs/redoc.standalone.js", ) diff --git a/backend/yet_another_calendar/web/api/modeus/schema.py b/backend/yet_another_calendar/web/api/modeus/schema.py index d7b67f3..1b8f5f4 100644 --- a/backend/yet_another_calendar/web/api/modeus/schema.py +++ b/backend/yet_another_calendar/web/api/modeus/schema.py @@ -19,10 +19,12 @@ class ModeusCreds(BaseModel): username: str password: str = Field(repr=False) + class ModeusTimeBody(BaseModel): time_min: datetime.datetime = Field(alias="timeMin", examples=["2024-09-23T00:00:00+03:00"]) time_max: datetime.datetime = Field(alias="timeMax", examples=["2024-09-29T23:59:59+03:00"]) + # noinspection PyNestedDecorators class ModeusEventsBody(ModeusTimeBody): """Modeus search events body.""" @@ -84,6 +86,18 @@ class Event(BaseModel): end_time: datetime.datetime = Field(alias="end") id: uuid.UUID + @field_validator("start_time") + @classmethod + def validate_starts_at(cls, start_time: datetime.datetime, + timezone: datetime.tzinfo = datetime.timezone.utc) -> datetime.datetime: + return start_time.astimezone(timezone) + + @field_validator("end_time") + @classmethod + def validate_end_time(cls, end_time: datetime.datetime, + timezone: datetime.tzinfo = datetime.timezone.utc) -> datetime.datetime: + return end_time.astimezone(timezone) + class Href(BaseModel): href: str @@ -157,6 +171,20 @@ class StudentsSpeciality(BaseModel): specialty_name: Optional[str] = Field(alias="specialtyName") specialty_profile: Optional[str] = Field(alias="specialtyProfile") + @field_validator("learning_start_date") + @classmethod + def validate_starts_at(cls, learning_start_date: Optional[datetime.datetime]) -> Optional[datetime.datetime]: + if not learning_start_date: + return learning_start_date + return learning_start_date.astimezone(datetime.timezone.utc) + + @field_validator("learning_end_date") + @classmethod + def validate_learning_end_date(cls, learning_end_date: Optional[datetime.datetime]) -> Optional[datetime.datetime]: + if not learning_end_date: + return learning_end_date + return learning_end_date.astimezone(datetime.timezone.utc) + class ExtendedPerson(StudentsSpeciality, ShortPerson): pass diff --git a/backend/yet_another_calendar/web/api/netology/schema.py b/backend/yet_another_calendar/web/api/netology/schema.py index 5539371..6f38d55 100644 --- a/backend/yet_another_calendar/web/api/netology/schema.py +++ b/backend/yet_another_calendar/web/api/netology/schema.py @@ -4,7 +4,7 @@ from urllib.parse import urljoin from fastapi import Header -from pydantic import BaseModel, Field, computed_field +from pydantic import BaseModel, Field, computed_field, field_validator, model_validator, ConfigDict from yet_another_calendar.settings import settings from yet_another_calendar.web.api.modeus.schema import ModeusTimeBody @@ -64,6 +64,22 @@ class LessonWebinar(BaseLesson): video_url: Optional[str] = None webinar_url: Optional[str] = None + @field_validator("starts_at") + @classmethod + def validate_starts_at(cls, starts_at: Optional[datetime.datetime], + timezone: datetime.tzinfo = datetime.timezone.utc) -> Optional[datetime.datetime]: + if not starts_at: + return starts_at + return starts_at.astimezone(timezone) + + @field_validator("ends_at") + @classmethod + def validate_ends_at(cls, ends_at: Optional[datetime.datetime], + timezone: datetime.tzinfo = datetime.timezone.utc) -> Optional[datetime.datetime]: + if not ends_at: + return ends_at + return ends_at.astimezone(timezone) + def is_suitable_time(self, time_min: datetime.datetime, time_max: datetime.datetime) -> bool: """Check if lesson have suitable time""" if not self.starts_at or time_min > self.starts_at: @@ -75,21 +91,27 @@ def is_suitable_time(self, time_min: datetime.datetime, time_max: datetime.datet # noinspection PyNestedDecorators class LessonTask(BaseLesson): + model_config = ConfigDict(arbitrary_types_allowed=True) + path: str + deadline: Optional[datetime.datetime] = Field(default=None) @computed_field # type: ignore @property def url(self) -> str: return urljoin(settings.netology_url, self.path) - @computed_field # type: ignore - @property - def deadline(self) -> Optional[datetime.datetime]: - match = re.search(_DATE_PATTERN, self.title) + @model_validator(mode='before') + @classmethod + def deadline_validation(cls, data: Any) -> Any: + if not isinstance(data, dict): + return data + match = re.search(_DATE_PATTERN, data.get('title', '')) if not match: - return None + return data date = match.group(0) - return datetime.datetime.strptime(date, "%d.%m.%y").replace(tzinfo=datetime.timezone.utc) + data['deadline'] = datetime.datetime.strptime(date, "%d.%m.%y").astimezone(datetime.timezone.utc) + return data def is_suitable_time(self, time_min: datetime.datetime, time_max: datetime.datetime) -> bool: """Check if lesson have suitable time""" @@ -158,6 +180,16 @@ class DetailedProgram(BaseModel): start_date: datetime.datetime finish_date: datetime.datetime + @field_validator("start_date") + @classmethod + def validate_start_date(cls, start_date: datetime.datetime) -> datetime.datetime: + return start_date.astimezone(datetime.timezone.utc) + + @field_validator("finish_date") + @classmethod + def validate_finish_date(cls, finish_date: datetime.datetime) -> datetime.datetime: + return finish_date.astimezone(datetime.timezone.utc) + class Program(BaseModel): detailed_program: DetailedProgram = Field(alias='program') @@ -173,6 +205,7 @@ def get_lesson_ids(self) -> set[int]: program_ids.add(program.detailed_program.id) return program_ids + class SerializedEvents(BaseModel): """Structure for displaying frontend.""" homework: list[LessonTask] diff --git a/backend/yet_another_calendar/web/api/router.py b/backend/yet_another_calendar/web/api/router.py index ec2dc19..94169ff 100644 --- a/backend/yet_another_calendar/web/api/router.py +++ b/backend/yet_another_calendar/web/api/router.py @@ -1,10 +1,12 @@ from fastapi.routing import APIRouter +from yet_another_calendar.settings import settings from yet_another_calendar.web.api import docs, monitoring, netology, modeus, bulk api_router = APIRouter() api_router.include_router(monitoring.router) -api_router.include_router(docs.router) +if settings.debug: + api_router.include_router(docs.router) api_router.include_router(netology.router, prefix="/netology", tags=["netology"]) api_router.include_router(modeus.router, prefix="/modeus", tags=["modeus"]) api_router.include_router(bulk.router, prefix="/bulk", tags=["bulk"]) diff --git a/backend/yet_another_calendar/web/application.py b/backend/yet_another_calendar/web/application.py index 589f486..711aa39 100644 --- a/backend/yet_another_calendar/web/application.py +++ b/backend/yet_another_calendar/web/application.py @@ -86,6 +86,6 @@ def get_app() -> FastAPI: app.include_router(router=api_router, prefix="/api") # Adds static directory. # This directory is used to access swagger files. - app.mount("/static", StaticFiles(directory=APP_ROOT / "static"), name="static") + app.mount("/static_backend", StaticFiles(directory=APP_ROOT / "static"), name="static") return app diff --git a/docker-compose.prod.yaml b/docker-compose.prod.yaml new file mode 100644 index 0000000..ec5b80b --- /dev/null +++ b/docker-compose.prod.yaml @@ -0,0 +1,49 @@ +services: + api: &main_app + ports: + - "127.0.0.1:8000:8000" + container_name: yet_another_calendar-api + image: ghcr.io/azamatkomaev/yet_another_calendar_backend:${YET_ANOTHER_CALENDAR_VERSION:-latest} + restart: always + volumes: + - ./backend:/app/src/ + env_file: + - backend/.env + depends_on: + redis: + condition: service_healthy + environment: + YET_ANOTHER_CALENDAR_HOST: 0.0.0.0 + YET_ANOTHER_CALENDAR_REDIS_HOST: yet_another_calendar-redis + LOGURU_DIAGNOSE: "False" + + redis: + image: redis:latest + hostname: "yet_another_calendar-redis" + restart: always + volumes: + - redis_data:/data + environment: + ALLOW_EMPTY_PASSWORD: "yes" + healthcheck: + test: redis-cli ping + interval: 1s + timeout: 3s + retries: 50 + + frontend: + image: ghcr.io/azamatkomaev/yet_another_calendar_frontend:${YET_ANOTHER_CALENDAR_VERSION:-latest} + build: + context: ./frontend + target: prod-run + container_name: yet_another_calendar-frontend + env_file: + - frontend/.env + restart: always + volumes: + - ./frontend/src:/app/src + ports: + - "127.0.0.1:3000:3000" + +volumes: + redis_data: diff --git a/docker-compose.yaml b/docker-compose.yaml index 9350fcb..b989878 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -5,7 +5,7 @@ services: build: context: backend/ dockerfile: Dockerfile - image: yet_another_calendar:${YET_ANOTHER_CALENDAR_VERSION:-latest} + image: ghcr.io/azamatkomaev/yet_another_calendar_backend:${YET_ANOTHER_CALENDAR_VERSION:-dev} restart: always volumes: - ./backend:/app/src/ @@ -17,7 +17,7 @@ services: environment: YET_ANOTHER_CALENDAR_HOST: 0.0.0.0 YET_ANOTHER_CALENDAR_REDIS_HOST: yet_another_calendar-redis - LOGURU_DIAGNOSE: "False" + YET_ANOTHER_CALENDAR_DEBUG: True redis: image: redis:latest @@ -37,12 +37,15 @@ services: build: context: ./frontend target: dev-run + image: ghcr.io/azamatkomaev/yet_another_calendar_frontend:${YET_ANOTHER_CALENDAR_VERSION:-dev} container_name: calendar-frontend + env_file: + - frontend/.env restart: always volumes: - ./frontend/src:/app/src ports: - - "3000:3000" + - "3000:80" volumes: redis_data: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 224de35..0ef13d3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -21,6 +21,10 @@ "react-router-dom": "^6.26.2", "react-scripts": "5.0.1", "web-vitals": "^2.1.4" + }, + "devDependencies": { + "sass": "^1.79.4", + "scss-reset": "^1.4.2" } }, "node_modules/@adobe/css-tools": { @@ -9579,6 +9583,13 @@ "url": "https://opencollective.com/immer" } }, + "node_modules/immutable": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz", + "integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==", + "devOptional": true, + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -15810,6 +15821,24 @@ "resolved": "https://registry.npmjs.org/sanitize.css/-/sanitize.css-13.0.0.tgz", "integrity": "sha512-ZRwKbh/eQ6w9vmTjkuG0Ioi3HBwPFce0O+v//ve+aOq1oeCy7jMV2qzzAlpsNuqpqCBjjriM1lbtZbF/Q8jVyA==" }, + "node_modules/sass": { + "version": "1.79.4", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.79.4.tgz", + "integrity": "sha512-K0QDSNPXgyqO4GZq2HO5Q70TLxTH6cIT59RdoCHMivrC8rqzaTw5ab9prjz9KUN1El4FLXrBXJhik61JR4HcGg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^4.0.0", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/sass-loader": { "version": "12.6.0", "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-12.6.0.tgz", @@ -15847,6 +15876,36 @@ } } }, + "node_modules/sass/node_modules/chokidar": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz", + "integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/sass/node_modules/readdirp": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz", + "integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/sax": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", @@ -15920,6 +15979,13 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" }, + "node_modules/scss-reset": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/scss-reset/-/scss-reset-1.4.2.tgz", + "integrity": "sha512-eXtSeI5APjD/TtaIlRdiMRapgsX5GCP4I1Ti3FiUzCSE4GEYnfT1hGISrJkKGZsZbCDhwZv1bUdOOZfPGs3R1A==", + "dev": true, + "license": "MIT" + }, "node_modules/select-hose": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index a0f8521..5af75bd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -40,5 +40,9 @@ "last 1 firefox version", "last 1 safari version" ] + }, + "devDependencies": { + "sass": "^1.79.4", + "scss-reset": "^1.4.2" } } diff --git a/frontend/src/App.css b/frontend/src/App.css deleted file mode 100644 index 74b5e05..0000000 --- a/frontend/src/App.css +++ /dev/null @@ -1,38 +0,0 @@ -.App { - text-align: center; -} - -.App-logo { - height: 40vmin; - pointer-events: none; -} - -@media (prefers-reduced-motion: no-preference) { - .App-logo { - animation: App-logo-spin infinite 20s linear; - } -} - -.App-header { - background-color: #282c34; - min-height: 100vh; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - font-size: calc(10px + 2vmin); - color: white; -} - -.App-link { - color: #61dafb; -} - -@keyframes App-logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} diff --git a/frontend/src/App.js b/frontend/src/App.js deleted file mode 100644 index 1fbb552..0000000 --- a/frontend/src/App.js +++ /dev/null @@ -1,11 +0,0 @@ -import DatePicker from "./components/DataPicker"; - -function App() { - return ( -
- -
- ); -} - -export default App; diff --git a/frontend/src/App.test.js b/frontend/src/App.test.js deleted file mode 100644 index 1f03afe..0000000 --- a/frontend/src/App.test.js +++ /dev/null @@ -1,8 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import App from './App'; - -test('renders learn react link', () => { - render(); - const linkElement = screen.getByText(/learn react/i); - expect(linkElement).toBeInTheDocument(); -}); diff --git a/frontend/src/components/Calendar.jsx b/frontend/src/components/Calendar.jsx deleted file mode 100644 index 23766e3..0000000 --- a/frontend/src/components/Calendar.jsx +++ /dev/null @@ -1,19 +0,0 @@ -import flatpickr from "flatpickr"; -import { useCallback, useRef } from "react"; - -function Calendar() { - const fp1 = useRef(); - - const inputRef = useCallback((node) => { - if (node !== null) { - fp1.current = flatpickr(node, { - enableTime: true, - dateFormat: "Y-m-d H:i", - }); - } - }, []); - - return (); -} - -export default Calendar; diff --git a/frontend/src/components/Calendar/Calendar.jsx b/frontend/src/components/Calendar/Calendar.jsx index a2c9be1..f5934ee 100644 --- a/frontend/src/components/Calendar/Calendar.jsx +++ b/frontend/src/components/Calendar/Calendar.jsx @@ -8,7 +8,7 @@ const Calendar = () => { if (node !== null) { fp1.current = flatpickr(node, { enableTime: true, - dateFormat: "Y-m-d H:i", + dateFormat: "j, F", }); } }, []); diff --git a/frontend/src/components/Calendar/left-week.png b/frontend/src/components/Calendar/left-week.png new file mode 100644 index 0000000..58b6e88 Binary files /dev/null and b/frontend/src/components/Calendar/left-week.png differ diff --git a/frontend/src/components/Calendar/right-week.png b/frontend/src/components/Calendar/right-week.png new file mode 100644 index 0000000..0ae2959 Binary files /dev/null and b/frontend/src/components/Calendar/right-week.png differ diff --git a/frontend/src/components/DataPicker.jsx b/frontend/src/components/DataPicker.jsx deleted file mode 100644 index 95b68bc..0000000 --- a/frontend/src/components/DataPicker.jsx +++ /dev/null @@ -1,25 +0,0 @@ -import React, { useRef, useEffect } from 'react'; -import Flatpickr from 'flatpickr'; -import weekSelect from 'flatpickr'; -import 'flatpickr/dist/flatpickr.css'; - -const DatePicker = ({ value, onChange, options }) => { - const datePickerRef = useRef(null); - - - useEffect(() => { - if (datePickerRef.current) { - const fp = new Flatpickr(datePickerRef.current, { - - }); - } - }, []); - - return ( -
- -
- ); -}; - -export default DatePicker; diff --git a/frontend/src/components/Header/Header.js b/frontend/src/components/Header/Header.js index ce488fe..1ce3a7f 100644 --- a/frontend/src/components/Header/Header.js +++ b/frontend/src/components/Header/Header.js @@ -1,103 +1,148 @@ import React from "react"; import { useNavigate } from "react-router-dom"; +import cross from "./cross.png"; +import arrow from "./arrow.png" +import camera from "./camera.png" + export default function Header() { const navigate = useNavigate(); return ( -
+
-
- Мое расписание +
+ Мое расписание +
-
- +
+
+ Дедлайн Нетология 23.09.2024 +
+
+ Программирование на Python +
+
+ Домашнее задание с самопроверкой(дедлайн 12.12.24) +
+
+
+
- - - - - - - + + + + + + + - - - - - - - - + + + + + + + + - - - - - - - - + + + + + + + + - - - - - - - - + + + + + + + + - - - - - - - - + + + + + + + + - - - - - - - - + + + + + + + + - - - - - - - - + + + + + + + + - - - - - - - - + + + + + + + +
ПнВтСрЧтПтСбВсПн 23.09Вт 24.09Ср 25.09Чт 26.09Пт 27.09Сб 28.09Вс 29.09
- дедлайны - - 1234567 + дедлайны + + + + +
2 пара 10:15 11:4512345672 пара
10:15 11:45
3 пара 12:00 13:3012345673 пара
12:00 13:30
4 пара 14:00 15:3012345674 пара
14:00 15:30
5 пара 15:45 17:1512345675 пара
15:45 17:15
6 пара 17:30 19:0012345676 пара
17:30 19:00
7 пара 19:10 20:4012345677 пара
19:10 20:40
diff --git a/frontend/src/components/Header/Shape-reverse.png b/frontend/src/components/Header/Shape-reverse.png new file mode 100644 index 0000000..0ae2959 Binary files /dev/null and b/frontend/src/components/Header/Shape-reverse.png differ diff --git a/frontend/src/components/Header/arrow.png b/frontend/src/components/Header/arrow.png new file mode 100644 index 0000000..66e4e16 Binary files /dev/null and b/frontend/src/components/Header/arrow.png differ diff --git a/frontend/src/components/Header/camera.png b/frontend/src/components/Header/camera.png new file mode 100644 index 0000000..a691948 Binary files /dev/null and b/frontend/src/components/Header/camera.png differ diff --git a/frontend/src/components/Header/cross.png b/frontend/src/components/Header/cross.png new file mode 100644 index 0000000..39835f3 Binary files /dev/null and b/frontend/src/components/Header/cross.png differ diff --git a/frontend/src/components/Header/left-week.png b/frontend/src/components/Header/left-week.png new file mode 100644 index 0000000..58b6e88 Binary files /dev/null and b/frontend/src/components/Header/left-week.png differ diff --git a/frontend/src/components/Login/ModeusLoginForm.jsx b/frontend/src/components/Login/ModeusLoginForm.jsx deleted file mode 100644 index 29160c6..0000000 --- a/frontend/src/components/Login/ModeusLoginForm.jsx +++ /dev/null @@ -1,124 +0,0 @@ -import { useState } from "react"; -import { loginModeus, searchModeus} from "../../services/api/login"; -import { useNavigate } from "react-router-dom"; // Импортируем useNavigate для навигации - -const ModeusLoginForm = () => { - const [email, setEmail] = useState(null); - const [password, setPassword] = useState(null); - const [fullName, setFullName] = useState(""); // Строка для поиска - const [searchResults, setSearchResults] = useState([]); // Результаты поиска - // const [selectedName, setSelectedName] = useState(""); // Выбранное имя - const [showSuggestions, setShowSuggestions] = useState(false); // Флаг показа списка - const [errorMessage, setErrorMessage] = useState(""); // Сообщение об ошибке - - const navigate = useNavigate(); // Инициализируем хук для навигации - - const onClickSearch = async (fullName) => { - console.log("Поиск ФИО:", fullName); - - let response = await searchModeus(fullName); - if (response.status !== 200) { - setErrorMessage("Неверное ФИО. Попробуйте еще раз."); - return; - } - console.log("Результаты поиска:", response.data); - setSearchResults(response.data); - setShowSuggestions(true); // Показываем список после поиска - setErrorMessage(""); // Очищаем ошибку при успешном поиске - }; - - /// Обработчик нажатия клавиши "Enter" - const handleKeyPress = (e) => { - if (e.key === "Enter") { - onClickSearch(fullName); // Выполнить поиск, если нажата клавиша Enter - } - }; - - // Обработчик выбора варианта из списка - const handleSelect = (name) => { - setFullName(name); // Устанавливаем выбранное имя - setShowSuggestions(false); // Скрываем список после выбора - }; - - const onClickLogin = async () => { - let response = await loginModeus(email, password); - - if (response.status !== 200) { - setErrorMessage("Неверный логин или пароль. Попробуйте еще раз."); // Устанавливаем текст ошибки - return; - } - console.log(response) - localStorage.setItem("token", response.data?.token); - setErrorMessage(""); // Очищаем ошибку при успешном логине - - // Перенаправление на страницу календаря - navigate("/calendar"); - window.location.reload(); // Обновляем страницу после навигации - }; - - return ( -
-

Мое расписание

- -
- - -
- setFullName(e.target.value)} // Обновляем строку поиска - onKeyPress={handleKeyPress} // Обработчик для нажатия клавиш - /> - - {/* Рендерим выпадающий список или сообщение об отсутствии результатов */} - {showSuggestions && ( -
    - {searchResults.length > 0 ? ( - searchResults.map((person, index) => ( -
  • handleSelect(person.fullName)}> - {person.fullName} {/* Отображаем имя */} -
  • - )) - ) : ( -
  • Нет такого имени
  • // Сообщение, если список пуст - )} -
- )} -
-
- - -
- -
- setEmail(e.target.value)} - /> - setPassword(e.target.value)} - /> -
- {/* Сообщение об ошибке */} - {errorMessage &&

{errorMessage}

} - - -
-
- ); -}; - -export default ModeusLoginForm; diff --git a/frontend/src/context/AuthContext.js b/frontend/src/context/AuthContext.js new file mode 100644 index 0000000..b2d980e --- /dev/null +++ b/frontend/src/context/AuthContext.js @@ -0,0 +1,19 @@ +// AuthContext.js +import React, { createContext, useState } from 'react'; + +// Создаем контекст +export const AuthContext = createContext(); + +// Создаем провайдер +export const AuthProvider = ({ children }) => { + const [authData, setAuthData] = useState({ + email: null, + password: null, + }); + + return ( + + {children} + + ); +}; \ No newline at end of file diff --git a/frontend/src/index.css b/frontend/src/index.css deleted file mode 100644 index e30857f..0000000 --- a/frontend/src/index.css +++ /dev/null @@ -1,218 +0,0 @@ -@import url("https://fonts.googleapis.com/css2?family=Unbounded:wght@200..900&display=swap"); -@import url("https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&family=Unbounded:wght@200..900&display=swap"); -@import url("https://fonts.googleapis.com/css2?family=Roboto+Mono:ital,wght@0,100..700;1,100..700&display=swap"); - -html { - height: 100%; -} - -body { - height: 100%; - background: #fff; - margin: 0; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", - "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", - "Helvetica Neue", sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -code { - font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", - monospace; -} - -.wrapper { - width: 100%; - -webkit-touch-callout: none; - -webkit-user-select: none; - -khtml-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - margin-top: 20px; -} - -.header-line { - display: flex; - justify-content: space-between; - align-items: center; -} - -.raspisanie-export { - display: flex; - align-items: center; -} - -header .raspisanie { - margin-left: 120px; - font-family: "Unbounded", sans-serif; - font-weight: 500; - font-size: 32px; -} -header .export-btn { - cursor: pointer; - width: 90px; - height: 28px; - background-color: #5856d6; - color: #fff; - text-decoration: none; - border: none; - border-radius: 6px; - padding: 4px 8px; - font-family: "Roboto", sans-serif; - font-weight: 400; - font-style: normal; - margin-left: 18px; - transition: color 0.2s linear; -} - -header .export-btn:hover { - background-color: #4745b5; -} - -header .login-btn { - margin-right: 120px; - cursor: pointer; - border: none; - background-color: #5856d6; - color: #fff; - text-decoration: none; - border-radius: 6px; - padding: 10px 20px; - font-family: "Roboto", sans-serif; - font-weight: 400; - transition: color 0.2s linear; -} - -header .login-btn:hover { - background-color: #4745b5; -} - -.input-name { - margin-top: 8px; - width: 320px; - height: 32px; - border-radius: 4px; - border-color: #d9d9d9; -} - -.input-email { - margin-top: 8px; - width: 320px; - height: 32px; - border-radius: 4px; - border-color: #d9d9d9; -} - -.login-btn-log { - width: 100px; - position: relative; - cursor: pointer; - border: none; - margin: 6px auto; - background-color: #5856d6; - color: #fff; - text-decoration: none; - border-radius: 6px; - padding: 10px 20px; - font-family: "Roboto", sans-serif; - font-weight: 400; - transition: color 0.2s linear; -} -.login-btn-log:hover { - background-color: #4745b5; -} - -.login-container { - display: flex; - align-items: center; - flex-direction: column; -} - -.login-netologiya, .login-fio { - max-width: 320px; - width: 100%; - margin: 24px auto; - display: flex; - align-items: flex-start; - justify-content: center; - flex-direction: column; - text-align: left; -} - -.calendar { - position: absolute; - top: 165px; - right: 248px; - background-color: #ecedf0; - width: 406px; - height: 32px; - border-radius: 6px; - border: 1px solid rgba(217, 217, 217, 0.5); - font-family: "Unbounded", sans-serif; - font-weight: 500; - font-size: 15px; - padding-left: 14px; - transition: color 0.2s linear; -} -.calendar:hover { - background-color: #e7e7ea; -} - -.raspisanie-table { - width: 100%; - margin-top: 170px; - border-spacing: 1px; -} -.days { - padding-left: 30px; - font-family: "Unbounded", sans-serif; - font-weight: 500; - font-size: 13px; - border: 1px; - border-bottom: 1px dotted #adadad; -} -.vertical { - text-align: center; - border-bottom: 1px dotted #adadad; -} -.vertical-zagolovok { - padding-left: 47px; - padding-top: 7px; - padding-bottom: 7px; - font-family: "Roboto Mono", monospace; - font-weight: 400; - font-size: 12px; - width: 0; - border-bottom: 1px dotted #adadad; -} - -.vertical-zagolovok::first-line { - font-family: "Roboto", sans-serif; - font-weight: 700; - font-size: 13px; -} - -.off-deadline { - margin-top: 7px; - font-family: "Roboto", sans-serif; - font-weight: 400; - font-size: 13px; - color: #7b61ff; - background: none; - padding: 4px 8px; - border-radius: 6px; - border: 1px solid #7b61ff; - transition: color 0.3s linear; -} -.off-deadline:hover { - cursor: pointer; - background-color: #5856d6; - color: #fff; -} - -.error-message { - color: red; - margin-top: 10px; -} diff --git a/frontend/src/index.js b/frontend/src/index.js index 05ae907..12f3815 100644 --- a/frontend/src/index.js +++ b/frontend/src/index.js @@ -3,10 +3,12 @@ import { createBrowserRouter, RouterProvider } from "react-router-dom"; import reportWebVitals from "./reportWebVitals"; import ReactDOM from "react-dom/client"; +import Header from "./components/Header/Header"; import LoginRoute from "./pages/LoginRoute"; import CalendarRoute from "./pages/CalendarRoute"; +import { AuthProvider } from './context/AuthContext'; -import "./index.css"; +import "./index.scss"; import PrivateRoute from "./components/Calendar/PrivateRoute"; const checkAuth = () => { @@ -29,16 +31,18 @@ const router = createBrowserRouter([ { path: "/calendar", element: ( - - - + + + ), // Защищаем страницу календаря }, ]); root.render( - + {/* Оборачиваем в AuthProvider для контекста */} + + ); diff --git a/frontend/src/index.scss b/frontend/src/index.scss new file mode 100644 index 0000000..372af5c --- /dev/null +++ b/frontend/src/index.scss @@ -0,0 +1,531 @@ +@import url("https://fonts.googleapis.com/css2?family=Unbounded:wght@200..900&display=swap"); +@import url("https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&family=Unbounded:wght@200..900&display=swap"); +@import url("https://fonts.googleapis.com/css2?family=Roboto+Mono:ital,wght@0,100..700;1,100..700&display=swap"); + +// app file start ======== +.App { + text-align: center; + &-logo { + height: 40vmin; + pointer-events: none; + } + &-header { + background-color: #282c34; + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + font-size: calc(10px + 2vmin); + color: white; + } + &-link { + color: #61dafb; + } +} +//TODO: ругается здесь +//media (prefers-reduced-motion: no-preference) { +// animation: App-logo-spin infinite 20s linear; +//} +//keyframes App-logo-spin { +// transform: rotate(0deg); +//} +//to { +// transform: rotate(360deg); +//} +// app file end ======== + + + +html { + height: 100%; +} + +body { + height: 100%; + background: #fff; + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", + "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", + "Helvetica Neue", sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +code { + font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", + monospace; +} + +.wrapper { + width: 100%; + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + margin-top: 20px; +} + +.header-line { + display: flex; + justify-content: space-between; + align-items: center; +} + +.shedule-export { + display: flex; + align-items: center; +} + +header .shedule { + margin-left: 120px; + font-family: "Unbounded", sans-serif; + font-weight: 500; + font-size: 32px; +} + +header .export-btn { + cursor: pointer; + height: 28px; + background-color: #5856d6; + color: #fff; + text-decoration: none; + border: none; + border-radius: 6px; + padding: 4px 8px; + font-family: "Roboto", sans-serif; + font-weight: 400; + font-style: normal; + margin-left: 18px; + transition: color 0.2s linear; + &:hover { + background-color: #4745b5; + } +} + +header .cache-btn { + margin-left: 18px; + height: 28px; + font-family: "Roboto", sans-serif; + font-weight: 400; + font-size: 13px; + color: #7b61ff; + background: none; + padding: 4px 8px; + border-radius: 6px; + border: 1px solid #7b61ff; + transition: color 0.3s linear; + &:hover { + cursor: pointer; + background-color: #5856d6; + color: #fff; + } +} + +header .exit-btn { + background: none; + margin-right: 120px; + cursor: pointer; + border: none; + color: #333333; + text-decoration: none; + font-family: "Roboto", sans-serif; + font-weight: 400; + font-size: 13px; + &-cross { + margin-left: 7px; + } +} + +header #rectangle { + padding: 0 12px; + margin: 22px 120px; + width: 534px; + height: 106px; + border-radius: 6px; + background: #f4638633; + border: 2px solid #f46386; +} + +.source { + margin-top: 7px; + color: #f46386; + font-family: "Roboto", sans-serif; + font-weight: 700; + font-size: 13px; + &-first-word { + font-family: "Roboto", sans-serif; + font-weight: 400; + } +} + +.date-event { + float: right; +} + +.name-event { + margin-top: 26px; + &-text { + color: #2c2d2e; + font-family: "Unbounded", sans-serif; + font-weight: 700; + font-size: 15px; + } +} + +.task-event { + margin-top: 7px; + &-text { + color: #2c2d2e; + font-family: "Roboto", sans-serif; + font-weight: 400; + font-size: 15px; + } +} + +.input-name { + margin-top: 8px; + width: 320px; + height: 32px; + border-radius: 4px; + border-color: #d9d9d9; +} + +.input-email { + margin-top: 8px; + width: 320px; + height: 32px; + border-radius: 4px; + border-color: #d9d9d9; +} + +.login-btn-log { + width: 100px; + position: relative; + cursor: pointer; + border: none; + margin: 6px auto; + background-color: #5856d6; + color: #fff; + text-decoration: none; + border-radius: 6px; + padding: 10px 20px; + font-family: "Roboto", sans-serif; + font-weight: 400; + transition: color 0.2s linear; + &:hover { + background-color: #4745b5; + } +} + +.login-container { + display: flex; + align-items: center; + flex-direction: column; +} + +.shedule-login { + font-family: "Unbounded", sans-serif; + font-weight: 500; + font-size: 32px; +} + +.login-netologiya, +.login-fio { + font-family: "Roboto", sans-serif; + font-weight: 400; + font-size: 15px; + max-width: 320px; + width: 100%; + margin: 24px auto; + display: flex; + align-items: flex-start; + justify-content: center; + flex-direction: column; + text-align: left; +} + +.calendar { + position: absolute; + top: 165px; + right: 248px; + background-color: #ecedf0; + width: 406px; + height: 32px; + border-radius: 6px; + border: 1px solid rgba(217, 217, 217, 0.5); + font-family: "Unbounded", sans-serif; + font-weight: 500; + font-size: 15px; + padding-left: 14px; + transition: color 0.2s linear; + &:hover { + background-color: #e7e7ea; + } +} + +.shedule-table { + width: 100%; + margin-top: 36px; + border-spacing: 1px; +} + +.days { + border-bottom: 1px dotted #adadad; + &-1 { + color: #adadad; + padding-left: 30px; + padding-bottom: 10px; + padding-top: 10px; + font-family: "Unbounded", sans-serif; + font-weight: 500; + font-size: 13px; + border: 1px; + border-bottom: 1px dotted #adadad; + } + &-2 { + color: #7b61ff; + padding-left: 30px; + font-family: "Unbounded", sans-serif; + font-weight: 500; + font-size: 13px; + border: 1px; + border-bottom: 1px dotted #adadad; + } + &-3 { + color: #333333; + padding-left: 30px; + font-family: "Unbounded", sans-serif; + font-weight: 500; + font-size: 13px; + border: 1px; + border-bottom: 1px dotted #adadad; + } + &-4 { + color: #333333; + padding-left: 30px; + font-family: "Unbounded", sans-serif; + font-weight: 500; + font-size: 13px; + border: 1px; + border-bottom: 1px dotted #adadad; + } + &-5 { + color: #333333; + padding-left: 30px; + font-family: "Unbounded", sans-serif; + font-weight: 500; + font-size: 13px; + border: 1px; + border-bottom: 1px dotted #adadad; + } + &-6 { + color: #333333; + padding-left: 30px; + font-family: "Unbounded", sans-serif; + font-weight: 500; + font-size: 13px; + border: 1px; + border-bottom: 1px dotted #adadad; + } + &-7 { + color: #333333; + padding-left: 30px; + font-family: "Unbounded", sans-serif; + font-weight: 500; + font-size: 13px; + border: 1px; + border-bottom: 1px dotted #adadad; + } +} + +.vertical-deadline { + vertical-align: top; + height: 72px; + width: 195px; + padding-left: 20px; + border-bottom: 1px dotted #adadad; + &-heading { + padding-left: 47px; + padding-top: 7px; + padding-bottom: 7px; + font-family: "Roboto Mono", monospace; + font-weight: 400; + font-size: 12px; + width: 0; + border-bottom: 1px dotted #adadad; + &::first-line { + font-family: "Roboto", sans-serif; + font-weight: 700; + font-size: 13px; + } + } +} + +.vertical { + height: 72px; + width: 195px; + padding-left: 20px; + border-bottom: 1px dotted #adadad; + &-heading { + padding-left: 47px; + padding-top: 7px; + padding-bottom: 7px; + font-family: "Roboto Mono", monospace; + font-weight: 400; + font-size: 12px; + width: 0; + border-bottom: 1px dotted #adadad; + &::first-line { + font-family: "Roboto", sans-serif; + font-weight: 700; + font-size: 13px; + } + } +} + +.off-deadline { + margin-top: 7px; + font-family: "Roboto", sans-serif; + font-weight: 400; + font-size: 13px; + color: #7b61ff; + background: none; + padding: 4px 8px; + border-radius: 6px; + border: 1px solid #7b61ff; + transition: color 0.3s linear; + &:hover { + cursor: pointer; + background-color: #5856d6; + color: #fff; + } +} + +.deadline { + &-info { + height: 28px; + margin-top: 6px; + width: 95%; + text-align: left; + padding: 5px 11px; + border: none; + background: #F46386; + border-radius: 6px; + color: #fff; + font-family: "Roboto", sans-serif; + font-weight: 700; + font-size: 13px; + transition: color 0.3s linear; + &:hover { + cursor: pointer; + color: #F46386; + background-color: #fff; + border: 2px solid #F46386; + } + } + &-info-on { + height: 28px; + margin-top: 4px; + width: 95%; + text-align: left; + padding: 5px 11px; + color: #F46386; + background-color: #fff; + border: 2px solid #F46386; + border-radius: 6px; + font-family: "Roboto", sans-serif; + font-weight: 700; + font-size: 13px; + transition: color 0.3s linear; + &:hover { + cursor: pointer; + color: #fff; + background-color: #F46386; + border: none; + } + } +} + +.past-lesson { + display: flex; + flex-direction: column; + justify-content: space-evenly; + padding-left: 12px; + text-align: left; + width: 95%; + height: 95%; + background: #ECEDF0; + border: none; + border-radius: 6px; + &:hover { + cursor: pointer; + } +} + +.company-name { + vertical-align: top; + font-family: "Roboto", sans-serif; + font-weight: 500; + font-size: 10px; + color: #7B61FF; +} + +.lesson-name { + vertical-align: top; + font-family: "Roboto", sans-serif; + font-weight: 400; + font-size: 13px; + color: #2C2D2E; +} + +.netology-lesson { + display: flex; + flex-direction: column; + justify-content: space-evenly; + padding-left: 12px; + text-align: left; + width: 95%; + height: 95%; + background: #00A8A833; + border: none; + border-radius: 6px; + &:hover { + cursor: pointer; + } +} + +.TyumGU-lesson { + display: flex; + flex-direction: column; + justify-content: space-evenly; + padding-left: 12px; + text-align: left; + width: 95%; + height: 95%; + background: #7B61FF33; + border: none; + border-radius: 6px; + &:hover { + cursor: pointer; + } +} + +.vertical-line { + margin-top: 17px; + margin-left: 365px; + position: absolute; + border-left: 2px solid #7B61FF; + height: 590px; +} + +.error-message { + font-family: "Roboto", sans-serif; + font-weight: 400; + font-size: 15px; + text-align: center; + color: red; + margin-top: 10px; +} diff --git a/frontend/src/pages/CalendarRoute.jsx b/frontend/src/pages/CalendarRoute.jsx index 1e2fa53..aca706a 100644 --- a/frontend/src/pages/CalendarRoute.jsx +++ b/frontend/src/pages/CalendarRoute.jsx @@ -1,13 +1,118 @@ -import DataPicker from "../components/Calendar/DataPicker" -import Header from "../components/Header/Header"; +import React, {useContext, useEffect, useState} from "react"; +import { getNetologyCourse, bulkEvents } from "../services/api"; +import {AuthContext} from "../context/AuthContext"; // Импортируем API функции const CalendarRoute = () => { + const { authData } = useContext(AuthContext); // Достаем данные из контекста + + const [calendarId, setCalendarId] = useState(null); // Хранение calendarId + const [courses, setCourses] = useState({ homework: [], webinars: [] }); // Для хранения курсов + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + console.log("Данные пользователя:", authData); + + const fetchCourseAndEvents = async () => { + const sessionToken = localStorage.getItem("token"); // Получаем токен из localStorage + + try { + // Получаем данные курса, чтобы извлечь calendarId + const courseData = await getNetologyCourse(sessionToken); + const fetchedCalendarId = courseData?.id; // Предполагаем, что calendarId есть в данных курса + setCalendarId(fetchedCalendarId); + + // Сохраняем курсы (домашние задания и вебинары) + if (courseData?.netology) { + setCourses(courseData.netology); + } + + + if (fetchedCalendarId) { + // дату получаем события для календаря + const eventsResponse = await bulkEvents( + authData.email, // username + authData.password, // password + sessionToken, // Токен сессии + fetchedCalendarId, // Извлеченный ID календаря + "2024-10-07T00:00:00+03:00", // Начало диапазона дат + "2024-10-13T23:59:59+03:00", // Конец диапазона дат + authData.personId // ID участника + ); + + setEvents(eventsResponse.data); // Записываем события в состояние + } + } catch (error) { + setError("Ошибка получения данных с сервера"); + } finally { + setLoading(false); + } + }; + + fetchCourseAndEvents(); + }, [authData]); + + if (loading) { + return
Загрузка...
; + } + + if (error) { + return
{error}
; + } + return (
-
- +

Календарь

+ {calendarId ?

ID Календаря: {calendarId}

:

Календарь не найден

} + +

События:

+ {events.length > 0 ? ( +
    + {events.map((event, index) => ( +
  • + {event.title} — {event.date} +
  • + ))} +
+ ) : ( +

Нет доступных событий

+ )} + +

Курсы

+

Домашние задания

+ {courses.homework.length > 0 ? ( + + ) : ( +

Нет домашних заданий

+ )} + +

Вебинары

+ {courses.webinars.length > 0 ? ( + + ) : ( +

Нет вебинаров

+ )}
- ) -} + ); +}; + +export default CalendarRoute; + -export default CalendarRoute diff --git a/frontend/src/pages/LoginRoute.jsx b/frontend/src/pages/LoginRoute.jsx index 6b0f062..b07cd50 100644 --- a/frontend/src/pages/LoginRoute.jsx +++ b/frontend/src/pages/LoginRoute.jsx @@ -1,11 +1,147 @@ -import ModeusLoginForm from "../components/Login/ModeusLoginForm" +import {useContext, useState} from "react"; +import {useNavigate} from "react-router-dom"; +import {AuthContext} from "../context/AuthContext"; +import {loginModeus, searchModeus} from "../services/api"; const LoginRoute = () => { + const {setAuthData} = useContext(AuthContext); // Достаем setAuthData из контекста + + const [email, setEmail] = useState(null); + const [password, setPassword] = useState(null); + const [fullName, setFullName] = useState(""); // Строка для поиска + const [searchResults, setSearchResults] = useState([]); // Результаты поиска + // const [selectedName, setSelectedName] = useState(""); // Выбранное имя + const [personId, setPersonId] = useState(null); // ID выбранного человека + const [showSuggestions, setShowSuggestions] = useState(false); // Флаг показа списка + const [errorMessage, setErrorMessage] = useState(""); // Сообщение об ошибке + + const navigate = useNavigate(); // Инициализируем хук для навигации + + const onClickSearch = async (fullName) => { + console.log("Поиск ФИО:", fullName); + + let response = await searchModeus(fullName); + if (response.status !== 200) { + setErrorMessage("Неверное ФИО. Попробуйте еще раз."); + return; + } + console.log("Результаты поиска:", response.data); + setSearchResults(response.data); + setShowSuggestions(true); // Показываем список после поиска + setErrorMessage(""); // Очищаем ошибку при успешном поиске + }; + + /// Обработчик нажатия клавиши "Enter" + const handleKeyPress = (e) => { + if (e.key === "Enter") { + onClickSearch(fullName); // Выполнить поиск, если нажата клавиша Enter + } + }; + + // Обработчик выбора варианта из списка + const handleSelect = (person) => { + console.log('person', person) + setFullName(person.fullName); // Устанавливаем выбранное имя + setPersonId(person.personId); + + // setAuthData((prev) => ({ + // person: person, + // personId: personId, // Сохраняем personId в контекст + // ...prev, + // })); + + // setAuthData({ person }); + setShowSuggestions(false); // Скрываем список после выбора + }; + + const onClickLogin = async () => { + let response = await loginModeus(email, password); + // console.log('email', email) + // console.log('password', password) + + if (response.status !== 200) { + setErrorMessage("Неверный логин или пароль. Попробуйте еще раз."); // Устанавливаем текст ошибки + return; + } + + console.log('setAuthData получил', setAuthData) + + // Set email, password, and personId in the AuthContext + setAuthData({ email, password }); + + console.log('setAuthData передал ', setAuthData) + + localStorage.setItem("token", response.data["_netology-on-rails_session"]); + setErrorMessage(""); // Очищаем ошибку при успешном логине + + // Перенаправление на страницу календаря + navigate("/calendar"); + window.location.reload(); // Обновляем страницу после навигации + }; + return ( -
- +
+

Мое расписание

+ +
+ + +
+ setFullName(e.target.value)} // Обновляем строку поиска + onKeyPress={handleKeyPress} // Обработчик для нажатия клавиш + /> + + {/* Рендерим выпадающий список или сообщение об отсутствии результатов */} + {showSuggestions && ( +
    + {searchResults.length > 0 ? ( + searchResults.map((person, index) => ( +
  • handleSelect(person)}> + {person.fullName} {/* Отображаем имя */} +
  • + )) + ) : ( +
  • Нет такого имени
  • // Сообщение, если список пуст + )} +
+ )} +
+
+ + +
+ +
+ setEmail(e.target.value)} + /> + setPassword(e.target.value)} + /> +
+ {/* Сообщение об ошибке */} + {errorMessage &&

{errorMessage}

} + + +
- ) + ); } export default LoginRoute diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js new file mode 100644 index 0000000..388f054 --- /dev/null +++ b/frontend/src/services/api.js @@ -0,0 +1,94 @@ +import axios from 'axios'; + +// env variable +const BACKEND_URL = process.env.REACT_APP_BACKEND_URL; + + +console.log("Backend URL:", BACKEND_URL); + +export function getTokenFromLocalStorage() { + return localStorage.getItem('token') +} + +// login +export async function loginModeus(username, password) { + try { + return await axios.post(`${BACKEND_URL}/api/netology/auth`, {username, password}); + } catch (e) { + return e.response; + } +} + +export async function searchModeus(fullName) { + try { + const params = new URLSearchParams(); + params.append('full_name', fullName); + return await axios.get(`${BACKEND_URL}/api/modeus/search/?full_name=${fullName}`); + + } catch (e) { + return e.response; + } +} +// calendar_id +export async function getNetologyCourse(sessionToken) { + console.log('sessionToken', sessionToken) + try { + const response = await axios.get(`${BACKEND_URL}/api/netology/course/`, { + headers: { + "_netology-on-rails_session": sessionToken, // Токен сессии передается в заголовке + "Content-Type": "application/json" + } + }); + return response.data; // Возвращаем данные + } catch (e) { + return e.response; + } +} +// calendar +export async function bulkEvents(username, password, sessionToken, calendarId, timeMin, timeMax, attendeePersonId) { + try { + const response = await axios.post( + `${BACKEND_URL}/api/bulk/events/?calendar_id=${calendarId}`, + { + timeMin, + timeMax, + size: 50, + attendeePersonId: [attendeePersonId], + }, + { + headers: { + "_netology-on-rails_session": sessionToken, // Токен сессии + "Content-Type": "application/json", + }, + } + ); + return response; + } catch (e) { + return e.response; + } +} +// Refresh calendar +export async function refreshBulkEvents(sessionToken, calendarId, timeMin, timeMax, attendeePersonId) { + try { + const response = await axios.post( + `${BACKEND_URL}/api/bulk/refresh_events/?calendar_id=${calendarId}`, + { + timeMin, + timeMax, + size: 50, + attendeePersonId: [attendeePersonId], + }, + { + headers: { + "_netology-on-rails_session": sessionToken, // Токен сессии + "Content-Type": "application/json", + }, + } + ); + return response; + } catch (e) { + return e.response; + } +} + + diff --git a/frontend/src/services/api/login.js b/frontend/src/services/api/login.js deleted file mode 100644 index b678ab8..0000000 --- a/frontend/src/services/api/login.js +++ /dev/null @@ -1,26 +0,0 @@ -import axios from 'axios'; -import { BACKEND_URL } from '../../variables'; - - -export function getTokenFromLocalStorage() { - return localStorage.getItem('token') -} - -export async function loginModeus(username, password) { - try { - return await axios.post(`${BACKEND_URL}/api/netology/auth`, {username, password}); - } catch (e) { - return e.response; - } -} - -export async function searchModeus(fullName) { - try { - const params = new URLSearchParams(); - params.append('full_name', fullName); - return await axios.get(`${BACKEND_URL}/api/modeus/search/?full_name=${fullName}`); - - } catch (e) { - return e.response; - } -} \ No newline at end of file diff --git a/frontend/src/variables.js b/frontend/src/variables.js deleted file mode 100644 index 5b61d0e..0000000 --- a/frontend/src/variables.js +++ /dev/null @@ -1 +0,0 @@ -export const BACKEND_URL="http://localhost:8000" \ No newline at end of file