Compare commits
187 Commits
Author | SHA1 | Date | |
---|---|---|---|
e2a7f73804 | |||
fc8f2b527b | |||
a2b0b25233 | |||
1f7f69e933 | |||
860ea43091 | |||
32d1b1b0e6 | |||
7ecfe6faa4 | |||
7a4b3978a7 | |||
18a7a0cbb9 | |||
2b001579c5 | |||
a60ff39c43 | |||
761f48c56f | |||
f74199b064 | |||
e6b34d684a | |||
60e5463028 | |||
558922dcf4 | |||
64a84d7c70 | |||
acd0a8fbf7 | |||
22dc21bda1 | |||
543b7b0c46 | |||
0df1d50612 | |||
c922c8611e | |||
f2de7c419e | |||
e9bf7eabaf | |||
74f89ae7cb | |||
7453a60eee | |||
37d219c516 | |||
f744cce713 | |||
2c870ee983 | |||
4326c70dbc | |||
93d2e2713e | |||
834b0f27bb | |||
ee79b9d4c5 | |||
c43814ccd4 | |||
30cce3608a | |||
1d2f786a8a | |||
a2da356912 | |||
e5d94959ec | |||
6742c963ab | |||
fb3763b910 | |||
f345ed6587 | |||
0468125b23 | |||
d7759adf26 | |||
48f6059bbd | |||
1a51c580d4 | |||
11abdb9147 | |||
85d437a0fb | |||
7798b0170d | |||
410931b475 | |||
326016be2a | |||
3fb5d8bb10 | |||
97e2ae5489 | |||
650d703f8f | |||
898acd0a5d | |||
79ff0a3813 | |||
6241fafbc6 | |||
1880f8abec | |||
480644c1e9 | |||
d5e19f45d2 | |||
0aaef69a5a | |||
a5798cf767 | |||
2cfe8512f4 | |||
ce97038f95 | |||
a4a6f620fb | |||
b12f19ac51 | |||
043a210324 | |||
4cf7bb8889 | |||
2708cc53a6 | |||
3bf00cea6a | |||
8fb75f8329 | |||
b3215596f1 | |||
07dfc1606c | |||
9a9ade6145 | |||
f25ac9aa0d | |||
73eaf00b96 | |||
53f91567e2 | |||
7731226864 | |||
8cfac08e8d | |||
f8235ca7f4 | |||
fa98b392a8 | |||
6478b45301 | |||
9937708da5 | |||
02db525e8b | |||
0c47da5543 | |||
6d215d4f66 | |||
3a3e036f0d | |||
864f5a040c | |||
d9925647c6 | |||
f432193508 | |||
60779ea489 | |||
ea18439140 | |||
4135c29160 | |||
6c5c7aa0c2 | |||
835dcf3979 | |||
15d61ecc4b | |||
bd863bc911 | |||
d3147b69ad | |||
5bdad31dae | |||
9a4226dc30 | |||
c52a623907 | |||
17dab2156d | |||
832a2ce985 | |||
e1e1244b3a | |||
e571b878bd | |||
d3d7760c5b | |||
f15c17a17e | |||
2b5a917107 | |||
d2a3393a11 | |||
9ae5824393 | |||
dfe1f90748 | |||
bf327dda28 | |||
dd88913abb | |||
d7f85bbbbb | |||
5dc90b625e | |||
5fcee1157e | |||
8bbdbce9f8 | |||
95e5c95cd4 | |||
cc414e38bd | |||
680b4ad7a2 | |||
26e42d874e | |||
b93ab9794d | |||
3e25550843 | |||
a41c684a74 | |||
2028dd9419 | |||
0145ed8f44 | |||
47fca02858 | |||
24bd39f689 | |||
6724a97352 | |||
e7327945e3 | |||
9b35a54ae9 | |||
29d46be492 | |||
9eb30d2066 | |||
466977d457 | |||
aaf0d20c65 | |||
517609ddbd | |||
86acf3e326 | |||
8b6010f453 | |||
cf81e3d817 | |||
e214ea53e7 | |||
ef94349341 | |||
6338e86a33 | |||
85472233a3 | |||
eb19113d78 | |||
9e98a224e3 | |||
8c81935004 | |||
5012642f7a | |||
e5da503ee5 | |||
b37443b862 | |||
bd7d4f3c5d | |||
48aad4ece7 | |||
0e5aeae491 | |||
50c2b0a615 | |||
6b32cc70b6 | |||
6bb7ab5ce9 | |||
dd719a20ec | |||
904b00059f | |||
d97ca1c43f | |||
b06306a20b | |||
d2c7ce453e | |||
8513e8610b | |||
ee823ff0c4 | |||
959596311b | |||
98139e2162 | |||
7c317805fb | |||
a234f95ace | |||
21970120bc | |||
91c99e0fd8 | |||
d93b2e131c | |||
9688f56c43 | |||
3bb6809454 | |||
2a229c96ba | |||
8220b43e9b | |||
40c5f08dfe | |||
e60d5d6732 | |||
abe3e64883 | |||
325898e76d | |||
30140f058f | |||
d5ba710885 | |||
6127dd8ba4 | |||
9e4bb1b99f | |||
d66b9004e0 | |||
626170964f | |||
1dd37a72b4 | |||
b39d9ada27 | |||
91842dcc51 | |||
09ba6a3478 | |||
808edad6b4 |
@ -29,4 +29,7 @@ dist-ssr
|
||||
uploads/
|
||||
.env
|
||||
|
||||
poems.txt
|
||||
poem_pic/
|
||||
|
||||
__pycache__
|
3
.gitignore
vendored
@ -28,5 +28,8 @@ dist-ssr
|
||||
*.db
|
||||
uploads/
|
||||
.env
|
||||
poem_pic/
|
||||
|
||||
poem_pic/
|
||||
|
||||
__pycache__
|
@ -11,4 +11,4 @@ COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir --upgrade -r requirements.txt
|
||||
COPY ./back ./back
|
||||
COPY --from=builder /src/dist ./front/dist
|
||||
CMD uvicorn back.main:app --host 0.0.0.0 --port 80
|
||||
CMD python -m back.main
|
||||
|
10
README.md
@ -4,9 +4,9 @@ Food and other stuff sharing platform. The service was developed during Digital
|
||||
|
||||
Members:
|
||||
|
||||
* Dmitry Gantimurov - Backend
|
||||
* Dmitriy Shishkov - Frontend
|
||||
* Vladimir Yakovlev - Backend & Design
|
||||
* Dmitry Gantimurov - Chief Backend
|
||||
* Dmitriy Shishkov - Frontend & Interface Design
|
||||
* Vladimir Yakovlev - Backend & Graphical Design
|
||||
|
||||
## Dev build instructions
|
||||
|
||||
@ -25,7 +25,7 @@ Backend:
|
||||
```sh
|
||||
pip install -r requirements.txt
|
||||
|
||||
uvicorn back.main:app --reload
|
||||
python -m back.main
|
||||
```
|
||||
|
||||
## Deploy instructions
|
||||
@ -35,5 +35,5 @@ Only docker/podman are required
|
||||
```sh
|
||||
docker build . -t porridger:build
|
||||
|
||||
docker run --name porridger -p 8080:80 -v ./sql_app.db:/srv/sql_app.db -v uploads:/srv/uploads porridger:build
|
||||
docker run --name porridger -p 8000:8000 -v ./sql_app.db:/srv/sql_app.db -v ./poems.txt:/srv/poems.txt -v ./poem_pic:/srv/poem_pic -v uploads:/srv/uploads porridger:build
|
||||
```
|
||||
|
114
alembic.ini
Normal file
@ -0,0 +1,114 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts
|
||||
script_location = migrations
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory.
|
||||
prepend_sys_path = .
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the python-dateutil library that can be
|
||||
# installed by adding `alembic[tz]` to the pip requirements
|
||||
# string value is passed to dateutil.tz.gettz()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the
|
||||
# "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to migrations/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "version_path_separator" below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions
|
||||
|
||||
# version path separator; As mentioned above, this is the character used to split
|
||||
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
||||
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
|
||||
# Valid values for version_path_separator are:
|
||||
#
|
||||
# version_path_separator = :
|
||||
# version_path_separator = ;
|
||||
# version_path_separator = space
|
||||
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
; sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = %(here)s/.venv/bin/ruff
|
||||
# ruff.options = --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
116
back/add_poems_and_filters.py
Normal file
@ -0,0 +1,116 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.sql import text, literal_column
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import Annotated
|
||||
from fastapi import Depends
|
||||
from sqlalchemy import select, or_, and_
|
||||
import datetime
|
||||
|
||||
from . import auth_utils, orm_models, pydantic_schemas
|
||||
|
||||
|
||||
# Загружаем стихи
|
||||
async def add_poems_to_db(async_db: AsyncSession):
|
||||
poems = []
|
||||
f1 = open('poems.txt', encoding='utf-8', mode='r')#открыть фаил для чтения на русском
|
||||
for a in range(1, 110):
|
||||
f1.seek(0)#перейти к началу
|
||||
i=0
|
||||
str1 = ""
|
||||
stixi = ""
|
||||
author = ""
|
||||
flag = False
|
||||
while str1 != f"стих {a}\n":
|
||||
str1=f1.readline()
|
||||
name=f1.readline()
|
||||
# Цикл для склеивания стихотворения
|
||||
while str1 != f"стих {a+1}\n":
|
||||
str1=f1.readline()
|
||||
if str1 != f"стих {a + 1}\n":
|
||||
if (str1 != f"Автор:\n" and flag == False):
|
||||
stixi += str1 # удаление /n и заключение в список
|
||||
else:
|
||||
if str1 == f"Автор:\n":#чтобы не записывать слово "автор"
|
||||
flag = True
|
||||
else:
|
||||
author += str1
|
||||
poem = orm_models.Poems(title=name, text=stixi, author=author)
|
||||
# В конце каждой итерации добавляем в базу данных
|
||||
poems.append(poem)
|
||||
|
||||
async_db.add_all(poems)
|
||||
|
||||
await async_db.commit()
|
||||
|
||||
# close the file
|
||||
f1.close()
|
||||
|
||||
async def filter_ann(schema: pydantic_schemas.SortAnnouncements, db: AsyncSession):
|
||||
"""Функция для последовательного применения различных фильтров (через схему SortAnnouncements)"""
|
||||
fields = schema.__dict__ # параметры передоваемой схемы SortAnnouncements (ключи и значения)
|
||||
# проходим по названиям фильтров и их значениям
|
||||
# выбираем все строки
|
||||
query = await db.execute(select(orm_models.Announcement))
|
||||
res = set(query.scalars().all())
|
||||
for name, filt_val in fields.items():
|
||||
# res = await db.execute(statement)
|
||||
# если фильтр задан
|
||||
if filt_val is not None:
|
||||
if name == "obsolete":
|
||||
filt_val = bool(filt_val)
|
||||
filter_query = await db.execute(select(orm_models.Announcement).where(literal_column(f"announcements.{name}") == filt_val))
|
||||
filtered = set(filter_query.scalars().all())
|
||||
res = res.intersection(filtered)
|
||||
# # отфильтровываем подходящие объявления
|
||||
# res = await db.execute(
|
||||
# select(orm_models.Announcement).where(
|
||||
# ((schema.obsolete == None) | ((schema.obsolete != None) & (orm_models.Announcement.obsolete == schema.obsolete)))
|
||||
# & ((schema.user_id == None) | ((schema.user_id != None) & (orm_models.Announcement.user_id == schema.user_id)))
|
||||
# & ((schema.metro == None) | ((schema.metro != None) & (orm_models.Announcement.metro == schema.metro)))
|
||||
# & ((schema.category == None) | ((schema.category != None) & (orm_models.Announcement.category == schema.category)))
|
||||
# )
|
||||
# )
|
||||
|
||||
|
||||
# .where(schema.user_id != None and orm_models.Announcement.user_id == schema.user_id)
|
||||
# .where(schema.metro != None and orm_models.Announcement.metro == schema.metro)
|
||||
# .where(schema.category != None and orm_models.Announcement.category == schema.category)
|
||||
# statement = text("SELECT * FROM announcements "
|
||||
# "WHERE announcements.obsolete = :obsolete "
|
||||
# "INTERSECT"
|
||||
# "SELECT * FROM announcements "
|
||||
# "WHERE announcements.user_id == :user_id "
|
||||
# "INTERSECT"
|
||||
# "SELECT * FROM announcements "
|
||||
# "WHERE announcements.metro == :metro "
|
||||
# "INTERSECT"
|
||||
# "SELECT * FROM announcements "
|
||||
# "WHERE announcements.category == :category")
|
||||
|
||||
# res = await db.execute(statement,
|
||||
# {"obsolete": schema.obsolete,
|
||||
# "user_id": schema.user_id,
|
||||
# "metro": schema.metro,
|
||||
# "category": schema.category}
|
||||
# )
|
||||
|
||||
# возвращаем все подходящие объявления
|
||||
return res
|
||||
|
||||
|
||||
async def check_obsolete(db: AsyncSession, current_date: datetime.date):
|
||||
"""
|
||||
Функция участвует в процессе обновления поля obsolete у всех объявлений раз в сутки
|
||||
"""
|
||||
# обращаемся ко всем объявлениям бд
|
||||
query_announcements = await db.execute(select(orm_models.Announcement))
|
||||
announcements = query_announcements.scalars().all()
|
||||
# для каждого объявления
|
||||
for ann in announcements:
|
||||
# если просрочено
|
||||
if ann.best_by < current_date:
|
||||
ann.obsolete = True
|
||||
await db.commit()
|
||||
await db.refresh(ann) # обновляем состояние объекта
|
||||
|
||||
|
333
back/api.py
Normal file
@ -0,0 +1,333 @@
|
||||
#подключение библиотек
|
||||
from fastapi import FastAPI, Depends, Form, status, HTTPException, APIRouter, UploadFile
|
||||
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.requests import Request
|
||||
|
||||
|
||||
from typing import Any, Annotated, List, Union
|
||||
from starlette.staticfiles import StaticFiles
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import select
|
||||
|
||||
import requests
|
||||
from uuid import uuid4
|
||||
import random
|
||||
import datetime
|
||||
import asyncio
|
||||
|
||||
import ast
|
||||
import pathlib
|
||||
import shutil
|
||||
import os
|
||||
|
||||
from . import add_poems_and_filters, auth_utils, orm_models, pydantic_schemas
|
||||
|
||||
from .config import TRASHBOXES_BASE_URL, TRASHBOXES_TOKEN
|
||||
|
||||
# создаем приложение Fastapi
|
||||
app = FastAPI()
|
||||
|
||||
# Jinja2 - шаблоны
|
||||
templates = Jinja2Templates(directory="./front/dist")
|
||||
|
||||
# хранение картинок для стихов
|
||||
app.mount("/poem_pic", StaticFiles(directory = "./poem_pic"))
|
||||
# создаем эндпоинт для хранения статических файлов
|
||||
app.mount("/static", StaticFiles(directory = "./front/dist"))
|
||||
# проверяем, что папка uploads еще не создана
|
||||
if not os.path.exists("./uploads"):
|
||||
os.mkdir("./uploads")
|
||||
# создаем эндпоинт для хранения файлов пользователя
|
||||
app.mount("/uploads", StaticFiles(directory = "./uploads"))
|
||||
|
||||
# эндпоинт для возвращения согласия в pdf
|
||||
@app.get("/privacy_policy.pdf")
|
||||
async def privacy_policy():
|
||||
return FileResponse("./privacy_policy.pdf")
|
||||
|
||||
# получение списка объявлений
|
||||
@app.get("/api/announcements", response_model=List[pydantic_schemas.Announcement])#адрес объявлений
|
||||
async def announcements_list(db: Annotated[Session, Depends(auth_utils.get_session)], obsolete: Union[bool, None] = False, user_id: Union[int, None] = None,
|
||||
metro: Union[str, None] = None,category: Union[str, None] = None):
|
||||
# параметры для сортировки (схема pydantic schemas.SortAnnouncements)
|
||||
params_to_sort = pydantic_schemas.SortAnnouncements(obsolete=obsolete, user_id=user_id, metro=metro, category=category)
|
||||
# получаем результат
|
||||
result = await add_poems_and_filters.filter_ann(db=db, schema=params_to_sort)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# получаем данные одного объявления
|
||||
@app.get("/api/announcement", response_model=pydantic_schemas.AnnResponce)
|
||||
async def single_announcement(ann_id:int, db: Annotated[Session, Depends(auth_utils.get_session)]): # передаем индекс обявления
|
||||
# Считываем данные из Body и отображаем их на странице.
|
||||
# В последствии будем вставлять данные в html-форму
|
||||
announcement = await db.get(orm_models.Announcement, ann_id)
|
||||
#announcement = await db.execute(select(orm_models.Announcement)).scalars().all()
|
||||
if not announcement:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
return announcement
|
||||
|
||||
|
||||
# Занести объявление в базу данных
|
||||
@app.put("/api/announcement")
|
||||
async def put_in_db(name: Annotated[str, Form()], category: Annotated[str, Form()], bestBy: Annotated[datetime.date, Form()],
|
||||
address: Annotated[str, Form()], longtitude: Annotated[float, Form()], latitude: Annotated[float, Form()],
|
||||
description: Annotated[str, Form()], metro: Annotated[str, Form()], current_user: Annotated[pydantic_schemas.User, Depends(auth_utils.get_current_active_user)],
|
||||
db: Annotated[Session, Depends(auth_utils.get_session)], src: Union[UploadFile, None] = None, trashId: Annotated[int, Form()] = None):
|
||||
|
||||
# имя загруженного файла по умолчанию - пустая строка
|
||||
uploaded_name = ""
|
||||
# если пользователь загрузил картинку
|
||||
if src:
|
||||
# процесс сохранения картинки
|
||||
f = src.file
|
||||
f.seek(0, os.SEEK_END)
|
||||
if f.tell() > 0:
|
||||
f.seek(0)
|
||||
destination = pathlib.Path("./uploads/" + str(hash(f)) + pathlib.Path(src.filename).suffix.lower())
|
||||
with destination.open('wb') as buffer:
|
||||
shutil.copyfileobj(f, buffer)
|
||||
|
||||
# изменяем название директории загруженного файла
|
||||
uploaded_name = "/uploads/" + destination.name
|
||||
|
||||
# создаем объект Announcement
|
||||
temp_ancmt = orm_models.Announcement(user_id=current_user.id, name=name, category=category, best_by=bestBy,
|
||||
address=address, longtitude=longtitude, latitude=latitude, description=description, metro=metro,
|
||||
trashId=trashId, src=uploaded_name, booked_by=0)
|
||||
try:
|
||||
db.add(temp_ancmt) # добавляем в бд
|
||||
await db.commit() # сохраняем изменения
|
||||
await db.refresh(temp_ancmt) # обновляем состояние объекта
|
||||
|
||||
return {"Success": True}
|
||||
except:
|
||||
raise HTTPException(status_code=500, detail="problem with adding object to db")
|
||||
|
||||
|
||||
# Удалить объявления из базы
|
||||
@app.delete("/api/announcement") #адрес объявления
|
||||
async def delete_from_db(announcement: pydantic_schemas.DelAnnouncement, db: Annotated[Session, Depends(auth_utils.get_session)]): # функция удаления объекта из БД
|
||||
# находим объект с заданным id в бд
|
||||
#to_delete = db.query(orm_models.Announcement).filter(orm_models.Announcement.id==announcement.id).first()
|
||||
query = await db.execute(select(orm_models.Announcement).where(orm_models.Announcement.id==announcement.id))
|
||||
to_delete = query.scalars().first()
|
||||
if not to_delete:
|
||||
raise HTTPException(status_code=404, detail="Item not found. Can't delete")
|
||||
try:
|
||||
await db.delete(to_delete) # удаление из БД
|
||||
await db.commit() # сохраняем изменения
|
||||
|
||||
return {"Success": True}
|
||||
except:
|
||||
raise HTTPException(status_code=500, detail="Problem with adding to database")
|
||||
|
||||
|
||||
# Забронировать объявление
|
||||
@app.post("/api/book")
|
||||
async def change_book_status(data: pydantic_schemas.Book, current_user: Annotated[pydantic_schemas.User, Depends(auth_utils.get_current_user)],
|
||||
db: Annotated[Session, Depends(auth_utils.get_session)]):
|
||||
# Находим объявление по данному id
|
||||
#announcement_to_change = db.query(orm_models.Announcement).filter(orm_models.Announcement.id == data.id).first()
|
||||
query = await db.execute(select(orm_models.Announcement).where(orm_models.Announcement.id == data.id))
|
||||
announcement_to_change = query.scalars().first()
|
||||
# Проверяем, что объявление с данным id существует
|
||||
if not announcement_to_change:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
# Проверяем, что объявление бронирует не владелец
|
||||
if current_user.id == announcement_to_change.user_id:
|
||||
raise HTTPException(status_code=403, detail="A user can't book his announcement")
|
||||
else:
|
||||
# Инкрементируем поле booked_by на 1
|
||||
announcement_to_change.booked_by += 1
|
||||
# фиксируем изменения в бд
|
||||
await db.commit()
|
||||
await db.refresh(announcement_to_change)
|
||||
return {"Success": True}
|
||||
|
||||
|
||||
# reginstration
|
||||
@app.post("/api/signup")
|
||||
async def create_user(nickname: Annotated[str, Form()], password: Annotated[str, Form()], db: Annotated[Session, Depends(auth_utils.get_session)],
|
||||
name: Annotated[str, Form()]=None, surname: Annotated[str, Form()]=None, avatar: Annotated[UploadFile, Form()]=None):
|
||||
|
||||
# проверяем, что юзера с введенным никнеймом не существует в бд
|
||||
#if db.query(orm_models.User).filter(orm_models.User.nickname == nickname).first() == None:
|
||||
query_user = await db.execute(select(orm_models.User).where(orm_models.User.nickname == nickname))
|
||||
user_with_entered_nick = query_user.scalars().first()
|
||||
if user_with_entered_nick == None:
|
||||
# создаем нового юзера
|
||||
new_user = orm_models.User(nickname=nickname, hashed_password=auth_utils.get_password_hash(password),
|
||||
name=name, surname=surname, reg_date=datetime.date.today())
|
||||
# добавляем в бд
|
||||
db.add(new_user)
|
||||
await db.commit()
|
||||
await db.refresh(new_user) # обновляем состояние объекта
|
||||
return {"Success": True}
|
||||
return {"Success": False, "Message": "Пользователь с таким email уже зарегестрирован"}
|
||||
|
||||
|
||||
# функция для генерации токена после успешного входа пользователя
|
||||
@app.post("/api/token", response_model=pydantic_schemas.Token)
|
||||
async def login_for_access_token(
|
||||
form_data: Annotated[OAuth2PasswordRequestForm, Depends()], db: Annotated[Session, Depends(auth_utils.get_session)]
|
||||
):
|
||||
# пробуем найти юзера в бд по введенным паролю и никнейму
|
||||
user = await auth_utils.authenticate_user(db, form_data.username, form_data.password)
|
||||
# если не нашли - кидаем ошибку
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
# задаем временной интервал, в течение которого токен можно использовать
|
||||
access_token_expires = auth_utils.timedelta(minutes=auth_utils.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
# создаем токен
|
||||
access_token = auth_utils.create_access_token(
|
||||
data={"user_id": user.id}, expires_delta=access_token_expires
|
||||
)
|
||||
return {"access_token":access_token}
|
||||
|
||||
|
||||
# получаем данные успешно вошедшего пользователя
|
||||
@app.get("/api/users/me", response_model=pydantic_schemas.User) #
|
||||
def read_users_me(current_user: Annotated[pydantic_schemas.User, Depends(auth_utils.get_current_active_user)]):
|
||||
return current_user
|
||||
|
||||
|
||||
# изменяем рейтинг пользователя
|
||||
@app.post("/api/user/rating")
|
||||
async def add_points(data: pydantic_schemas.AddRating, current_user: Annotated[pydantic_schemas.User, Depends(auth_utils.get_current_user)], db: Annotated[Session, Depends(auth_utils.get_session)]):
|
||||
# проверяем,
|
||||
if current_user.id != data.user_id:
|
||||
user = await auth_utils.get_user_by_id(db, data.user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
user.rating = (user.rating*user.num_of_ratings + data.rate)/(user.num_of_ratings + 1)
|
||||
user.num_of_ratings += 1
|
||||
await db.commit()
|
||||
await db.refresh(user) # обновляем состояние объекта
|
||||
return {"Success": True}
|
||||
|
||||
|
||||
# получаем рейтинг пользователя
|
||||
@app.get("/api/user/rating")
|
||||
async def add_points(user_id: int, db: Annotated[Session, Depends(auth_utils.get_session)]):
|
||||
user = await auth_utils.get_user_by_id(db, user_id=user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
return {"rating": user.rating}
|
||||
|
||||
|
||||
# Отправляем стихи
|
||||
@app.get("/api/user/poem", response_model=pydantic_schemas.Poem)
|
||||
async def poems_to_front(db: Annotated[Session, Depends(auth_utils.get_session)]):
|
||||
#num_of_poems = db.query(orm_models.Poems).count() # определяем кол-во стихов в бд
|
||||
query = await db.execute(select(orm_models.Poems)) # определяем кол-во стихов в бд
|
||||
num_of_poems = len(query.scalars().all())
|
||||
# если стихов в бд нет
|
||||
if num_of_poems < 1:
|
||||
await add_poems_and_filters.add_poems_to_db(db) # добавляем поэмы в базу данных
|
||||
# после добавления стихов снова определяем кол-во стихов в бд
|
||||
query = await db.execute(select(orm_models.Poems))
|
||||
num_of_poems = len(query.scalars().all())
|
||||
rand_id = random.randint(1, num_of_poems) # генерируем номер стихотворения
|
||||
#poem = db.query(orm_models.Poems).filter(orm_models.Poems.id == rand_id).first() # находим стих в бд
|
||||
query_poem = await db.execute(select(orm_models.Poems).where(orm_models.Poems.id == rand_id)) # находим стих в бд
|
||||
poem = query_poem.scalars().first()
|
||||
if not poem:
|
||||
raise HTTPException(status_code=404, detail="Poem not found")
|
||||
return poem
|
||||
|
||||
trashboxes_category = {
|
||||
"PORRIDGE": ["Опасные отходы", "Иное"],
|
||||
"conspects": ["Бумага"],
|
||||
"milk": ["Стекло", "Тетра Пак", "Иное"],
|
||||
"bred": ["Пластик", "Иное"],
|
||||
"wathing": ["Пластик", "Опасные отходы", "Иное"],
|
||||
"cloth": ["Одежда"],
|
||||
"fruits_vegatables": ["Иное"],
|
||||
"other_things": ["Металл", "Бумага", "Стекло", "Иное", "Тетра Пак", "Батарейки", "Крышечки", "Шины",
|
||||
"Опасные отходы", "Лампочки", "Пластик"]
|
||||
}
|
||||
|
||||
@app.get("/api/trashbox", response_model=List[pydantic_schemas.TrashboxResponse])
|
||||
async def get_trashboxes(data: pydantic_schemas.TrashboxRequest = Depends()): #крутая функция для работы с api
|
||||
# json, передаваемый стороннему API
|
||||
head = {'Authorization': 'Bearer ' + TRASHBOXES_TOKEN}
|
||||
# Данные пользователя (местоположение, количество мусорок, которое пользователь хочет видеть)
|
||||
my_data={
|
||||
'x' : f"{data.Lng}",
|
||||
'y' : f"{data.Lat}",
|
||||
'limit' : '1'
|
||||
}
|
||||
# Перевод категории с фронта на категорию с сайта
|
||||
try:
|
||||
list_of_category = trashboxes_category[data.Category]
|
||||
except:
|
||||
list_of_category = trashboxes_category['other_things']
|
||||
|
||||
# Получение ответа от стороннего апи
|
||||
response = requests.post(TRASHBOXES_BASE_URL + "/nearest_recycling/get", headers=head, data=my_data, timeout=10)
|
||||
infos = response.json()
|
||||
|
||||
if 'error' in infos and infos['error_description'] == 'Invalid bearer token':
|
||||
raise HTTPException(status_code=502, detail="Invalid trashboxes token")
|
||||
|
||||
# Чтение ответа
|
||||
trashboxes = []
|
||||
for trashbox in infos["results"]:
|
||||
temp_dict = {}
|
||||
for obj in trashbox["Objects"]:
|
||||
coord_list = obj["geometry"]
|
||||
temp_dict["Lat"] = coord_list["coordinates"][1]
|
||||
temp_dict["Lng"] = coord_list["coordinates"][0]
|
||||
|
||||
properties = obj["properties"]
|
||||
temp_dict["Name"] = properties["title"]
|
||||
temp_dict["Address"] = properties["address"]
|
||||
temp_dict["Categories"] = properties["content_text"].split(',')
|
||||
for a in list_of_category:
|
||||
if a in temp_dict["Categories"] and temp_dict not in trashboxes:
|
||||
trashboxes.append(temp_dict)
|
||||
uniq_trashboxes = [pydantic_schemas.TrashboxResponse(**ast.literal_eval(el1)) for el1 in set([str(el2) for el2 in trashboxes])]
|
||||
return uniq_trashboxes
|
||||
|
||||
|
||||
@app.get("/{rest_of_path:path}")
|
||||
async def react_app(req: Request, rest_of_path: str):
|
||||
return templates.TemplateResponse('index.html', { 'request': req })
|
||||
|
||||
|
||||
@app.post("/api/announcement/dispose")
|
||||
async def dispose(data: pydantic_schemas.DisposeRequest, current_user_schema: Annotated[pydantic_schemas.User, Depends(auth_utils.get_current_user)],
|
||||
db: Annotated[Session, Depends(auth_utils.get_session)]):
|
||||
# Находим в бд текущего юзера
|
||||
current_user = await auth_utils.get_user_by_id(db, current_user_schema.id)
|
||||
# Начисляем баллы пользователю за утилизацию
|
||||
current_user.points += 60
|
||||
# В полученном json переходим к данным мусорки
|
||||
data_trashbox = data.trashbox
|
||||
# создаем запись models.Trashbox
|
||||
new_trashox = orm_models.Trashbox(user_id=current_user.id, date_of_choice=datetime.date.today(), name=data_trashbox.Name,
|
||||
latitude=data_trashbox.Lat, longtitude=data_trashbox.Lng, address=data_trashbox.Address, category=data_trashbox.Category)
|
||||
# добавляем в бд
|
||||
db.add(new_trashox)
|
||||
# в соответствии с логикой api, после утилизации объявление пользователя удаляется
|
||||
# находим объявление с айди data.ann_id
|
||||
#ann_to_del = db.query(orm_models.Announcement).filter(orm_models.Announcement.id == data.ann_id).first() #
|
||||
query_ann = await db.execute(select(orm_models.Announcement).where(orm_models.Announcement.id == data.ann_id)) # находим объявление в бд
|
||||
ann_to_del = query_ann.scalars().first()
|
||||
if not ann_to_del:
|
||||
raise HTTPException(status_code=404, detail="Announcement not found")
|
||||
# удаляем объявление из бд
|
||||
await db.delete(ann_to_del)
|
||||
await db.commit()
|
||||
await db.refresh(new_trashox) # обновляем состояние объекта
|
||||
return {"Success": True}
|
||||
|
96
back/auth_utils.py
Normal file
@ -0,0 +1,96 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Annotated, Union
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from .db import SessionLocal
|
||||
from . import orm_models, pydantic_schemas
|
||||
from .config import SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/token")
|
||||
|
||||
|
||||
async def get_session() -> AsyncSession:
|
||||
async with SessionLocal() as session:
|
||||
yield session
|
||||
|
||||
|
||||
def verify_password(plain_password, hashed_password):
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password):
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
async def get_user_by_nickname(db: Annotated[AsyncSession, Depends(get_session)], nickname: str):
|
||||
query = await db.execute(select(orm_models.User).where(orm_models.User.nickname == nickname))
|
||||
user_with_required_nickname = query.scalars().first()
|
||||
if user_with_required_nickname:
|
||||
return user_with_required_nickname
|
||||
return None
|
||||
|
||||
|
||||
async def get_user_by_id(db: Annotated[AsyncSession, Depends(get_session)], user_id: int):
|
||||
query = await db.execute(select(orm_models.User).where(orm_models.User.id == user_id))
|
||||
user_with_required_id = query.scalars().first()
|
||||
if user_with_required_id:
|
||||
return user_with_required_id
|
||||
return None
|
||||
|
||||
|
||||
async def authenticate_user(db: Annotated[AsyncSession, Depends(get_session)], nickname: str, password: str):
|
||||
user = await get_user_by_nickname(db=db, nickname=nickname)
|
||||
if not user:
|
||||
return False
|
||||
if not verify_password(password, user.hashed_password):
|
||||
return False
|
||||
return user
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Union[timedelta, None] = None):
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=15)
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
async def get_current_user(db: Annotated[AsyncSession, Depends(get_session)], token: Annotated[str, Depends(oauth2_scheme)]):
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
user_id: int = payload.get("user_id")
|
||||
if user_id is None:
|
||||
raise credentials_exception
|
||||
token_data = pydantic_schemas.TokenData(user_id=user_id)
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
user = await get_user_by_id(db, user_id=token_data.user_id)
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
return pydantic_schemas.User(id=user.id, nickname=user.nickname, name=user.name, surname=user.surname,
|
||||
disabled=user.disabled, items=user.announcements, reg_date=user.reg_date, points=user.points)
|
||||
|
||||
|
||||
def get_current_active_user(
|
||||
current_user: Annotated[pydantic_schemas.User, Depends(get_current_user)]
|
||||
):
|
||||
if current_user.disabled:
|
||||
raise HTTPException(status_code=400, detail="Inactive user")
|
||||
return current_user
|
2
back/auxiliary_for_alembic.py
Normal file
@ -0,0 +1,2 @@
|
||||
from .db import Base
|
||||
from .orm_models import User, Announcement, Trashbox
|
13
back/config.py
Normal file
@ -0,0 +1,13 @@
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv('.env')
|
||||
|
||||
TRASHBOXES_TOKEN = os.environ.get("TRASHBOXES_TOKEN")
|
||||
TRASHBOXES_BASE_URL = os.environ.get("TRASHBOXES_BASE_URL")
|
||||
|
||||
SECRET_KEY = os.environ.get("SECRET_KEY")
|
||||
ALGORITHM = os.environ.get("ALGORITHM")
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.environ.get("ACCESS_TOKEN_EXPIRE_MINUTES"))
|
||||
|
||||
SQLALCHEMY_DATABASE_URL = os.environ.get("SQLALCHEMY_DATABASE_URL")
|
21
back/db.py
@ -1,13 +1,20 @@
|
||||
from sqlalchemy import create_engine
|
||||
from asyncio import current_task
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_scoped_session, create_async_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker, Session
|
||||
|
||||
SQLALCHEMY_DATABASE_URL = "sqlite:///./sql_app.db"
|
||||
from .config import SQLALCHEMY_DATABASE_URL
|
||||
|
||||
engine = create_engine(
|
||||
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
|
||||
)
|
||||
engine = create_async_engine(SQLALCHEMY_DATABASE_URL)
|
||||
|
||||
SessionLocal = sessionmaker(autoflush=True, bind=engine)
|
||||
SessionLocal = sessionmaker(bind=engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async_session = SessionLocal()
|
||||
# async_session = async_scoped_session(SessionLocal, scopefunc=current_task)
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
# Создаем таблицы
|
||||
async def init_models():
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
@ -1,40 +0,0 @@
|
||||
from .db import engine
|
||||
from .models import Announcement, UserDatabase, Trashbox, Base
|
||||
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
db = SessionLocal()
|
||||
|
||||
# Пробный чувак
|
||||
tom = UserDatabase(name="Tom", phone="89999999", email="pupka", password="1234", surname="Smith")
|
||||
# db.add(tom) # добавляем в бд
|
||||
# db.commit() # сохраняем изменения
|
||||
# db.refresh(tom) # обновляем состояние объекта
|
||||
|
||||
# Пробное объявление 1
|
||||
a1 = Announcement(user_id=1, category="cat", best_by="201223", address="abd", longtitude=23, latitude=22,
|
||||
description="abv", src="111", metro="Lesnaya", booked_by=2)
|
||||
# Пробное объявление 2
|
||||
a2 = Announcement(user_id=1, category="dog", best_by="221223", address="abd", longtitude=50, latitude=12,
|
||||
description="vvv", src="110", metro="Petrogradskaya", booked_by=2)
|
||||
|
||||
a3 = Announcement(user_id=1, category="a", best_by="221223", address="abd", longtitude=20, latitude=25,
|
||||
description="vvv", src="101", metro="metro", booked_by=2)
|
||||
|
||||
trash1 = Trashbox(name="Tom", address="abd", longtitude=23, latitude=22, category="indisposable")
|
||||
|
||||
# db.add(a1) # добавляем в бд
|
||||
# db.add(a2) # добавляем в бд
|
||||
# db.add(a3) # добавляем в бд
|
||||
# db.add(trash1) # добавляем в бд
|
||||
# db.commit() # сохраняем изменения
|
||||
# db.refresh(a1) # обновляем состояние объекта
|
||||
# db.refresh(a2) # обновляем состояние объекта
|
||||
# db.refresh(a3) # обновляем состояние объекта
|
||||
# db.refresh(trash1) # обновляем состояние объекта
|
||||
|
||||
# # Удалить все
|
||||
# db.query(User).delete()
|
||||
# db.query(Announcement).delete()
|
||||
# db.commit()
|
7
back/delete_all_poems.py
Normal file
@ -0,0 +1,7 @@
|
||||
from .orm_models import Poems
|
||||
from .db import database
|
||||
|
||||
all_poems = database.query(Poems).all()
|
||||
for to_delete in all_poems:
|
||||
database.delete(to_delete)
|
||||
database.commit()
|
6
back/delete_db.py
Normal file
@ -0,0 +1,6 @@
|
||||
from sqlalchemy import Table, MetaData, text
|
||||
from .db import engine, Base
|
||||
|
||||
tbl = Table('Poems', MetaData(), autoload_with=engine)
|
||||
tbl.drop(engine, checkfirst=False)
|
||||
a = input()
|
229
back/main.py
@ -1,219 +1,32 @@
|
||||
#подключение библиотек
|
||||
from fastapi import FastAPI, Response, Path, Depends, Body, Form, Query, status, HTTPException, APIRouter, UploadFile, File
|
||||
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.security import OAuth2PasswordRequestForm, OAuth2PasswordBearer
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.requests import Request
|
||||
import asyncio
|
||||
import uvicorn
|
||||
|
||||
from pydantic import json
|
||||
|
||||
from starlette.staticfiles import StaticFiles
|
||||
|
||||
import requests
|
||||
from uuid import uuid4
|
||||
|
||||
import ast
|
||||
import pathlib
|
||||
import shutil
|
||||
import os
|
||||
|
||||
from .utils import *
|
||||
from .models import Announcement, Trashbox, UserDatabase, Base
|
||||
from .db import engine, SessionLocal
|
||||
|
||||
from . import schema
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
db = SessionLocal()
|
||||
from .api import app as app_fastapi
|
||||
from .scheduler import app as app_rocketry
|
||||
from .db import init_models
|
||||
|
||||
|
||||
app = FastAPI()
|
||||
class Server(uvicorn.Server):
|
||||
"""Customized uvicorn.Server
|
||||
|
||||
templates = Jinja2Templates(directory="./front/dist")
|
||||
|
||||
app.mount("/static", StaticFiles(directory = "./front/dist"))
|
||||
app.mount("/uploads", StaticFiles(directory = "./uploads"))
|
||||
Uvicorn server overrides signals and we need to include
|
||||
Rocketry to the signals."""
|
||||
def handle_exit(self, sig: int, frame) -> None:
|
||||
app_rocketry.session.shut_down()
|
||||
return super().handle_exit(sig, frame)
|
||||
|
||||
|
||||
@app.get("/api/announcements")#адрес объявлений
|
||||
def annoncements_list(user_id: int = None, metro: str = None, category: str = None, booked_by: int = -1):
|
||||
# Считываем данные из Body и отображаем их на странице.
|
||||
# В последствии будем вставлять данные в html-форму
|
||||
async def main():
|
||||
"Run scheduler and the API"
|
||||
|
||||
a = db.query(Announcement)
|
||||
b = db.query(Announcement)
|
||||
c = db.query(Announcement)
|
||||
d = db.query(Announcement)
|
||||
e = db.query(Announcement)
|
||||
await init_models()
|
||||
|
||||
if user_id != None:
|
||||
b = a.filter(Announcement.user_id == user_id)
|
||||
server = Server(config=uvicorn.Config(app_fastapi, workers=1, loop="asyncio", host="0.0.0.0"))
|
||||
|
||||
if metro != None:
|
||||
c = a.filter(Announcement.metro == metro)
|
||||
api = asyncio.create_task(server.serve())
|
||||
sched = asyncio.create_task(app_rocketry.serve())
|
||||
|
||||
if category != None:
|
||||
d = a.filter(Announcement.category == category)
|
||||
await asyncio.wait([sched, api])
|
||||
|
||||
if booked_by != -1:
|
||||
e = a.filter(Announcement.booked_by == booked_by)
|
||||
|
||||
if not any([category, user_id, metro]) and booked_by == -1:
|
||||
result = a.all()
|
||||
|
||||
else:
|
||||
result = b.intersect(c, d, e).all()
|
||||
|
||||
return {"Success" : True, "list_of_announcements": result}
|
||||
|
||||
|
||||
@app.get("/api/announcement")#адрес объявлений
|
||||
def single_annoncement(user_id:int):
|
||||
# Считываем данные из Body и отображаем их на странице.
|
||||
# В последствии будем вставлять данные в html-форму
|
||||
try:
|
||||
annoncement = db.get(Announcement, user_id)
|
||||
return {"id": annoncement.id, "user_id": annoncement.user_id, "name": annoncement.name,
|
||||
"category": annoncement.category, "best_by": annoncement.best_by, "address": annoncement.address,
|
||||
"description": annoncement.description, "metro": annoncement.metro, "latitude": annoncement.latitude,
|
||||
"longtitude":annoncement.longtitude, "trashId": annoncement.trashId, "src":annoncement.src,
|
||||
"booked_by":annoncement.booked_by}
|
||||
except:
|
||||
return {"Answer" : False} #если неуданый доступ, то сообщаем об этом
|
||||
|
||||
|
||||
# Занести объявление в базу
|
||||
@app.put("/api/announcement")#адрес объявлений
|
||||
def put_in_db(name: Annotated[str, Form()], category: Annotated[str, Form()], bestBy: Annotated[int, Form()], address: Annotated[str, Form()], longtitude: Annotated[float, Form()], latitude: Annotated[float, Form()], description: Annotated[str, Form()], src: UploadFile, metro: Annotated[str, Form()], trashId: Annotated[int, Form()] = None):
|
||||
# try:
|
||||
userId = 1 # temporary
|
||||
|
||||
uploaded_name = ""
|
||||
|
||||
f = src.file
|
||||
f.seek(0, os.SEEK_END)
|
||||
if f.tell() > 0:
|
||||
f.seek(0)
|
||||
destination = pathlib.Path("./uploads/" + str(hash(src.file)) + pathlib.Path(src.filename).suffix.lower())
|
||||
with destination.open('wb') as buffer:
|
||||
shutil.copyfileobj(src.file, buffer)
|
||||
|
||||
uploaded_name = "/uploads/"+destination.name
|
||||
|
||||
temp_ancmt = Announcement(user_id=userId, name=name, category=category, best_by=bestBy, address=address, longtitude=longtitude, latitude=latitude, description=description, src=uploaded_name, metro=metro, trashId=trashId, booked_by=-1)
|
||||
db.add(temp_ancmt) # добавляем в бд
|
||||
db.commit() # сохраняем изменения
|
||||
db.refresh(temp_ancmt) # обновляем состояние объекта
|
||||
return {"Answer" : True}
|
||||
# except:
|
||||
# return {"Answer" : False}
|
||||
|
||||
|
||||
# Удалить объявления из базы
|
||||
@app.delete("/api/announcement") #адрес объявления
|
||||
def delete_from_db(data = Body()):#функция удаления объекта из БД
|
||||
try:
|
||||
db.delete(user_id=data.user_id)#удаление из БД
|
||||
db.commit() # сохраняем изменения
|
||||
return {"Answer" : True}
|
||||
except:
|
||||
return {"Answer" : False}
|
||||
|
||||
|
||||
# Забронировать объявление
|
||||
@app.post("/api/book")
|
||||
def change_book_status(data: schema.Book):
|
||||
try:
|
||||
# Получаем id пользователя, который бронирует объявление
|
||||
temp_user_id = 1
|
||||
# Находим объявление по данному id
|
||||
announcement_to_change = db.query(Announcement).filter(id == data.id).first()
|
||||
# Изменяем поле booked_status на полученный id
|
||||
announcement_to_change.booked_status = temp_user_id
|
||||
return {"Success": True}
|
||||
except:
|
||||
return {"Success": False}
|
||||
|
||||
|
||||
@app.post("/api/signup")
|
||||
def create_user(data = Body()):
|
||||
if db.query(UserDatabase).filter(UserDatabase.email == data["email"]).first() == None:
|
||||
new_user = UserDatabase(id=data["id"], email=data["email"], password=data["password"], name=data["name"], surname=data["surname"])
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
db.refresh(new_user) # обновляем состояние объекта
|
||||
return {"Success": True}
|
||||
return {"Success": False, "Message": "Пользователь с таким email уже зарегестрирован."}
|
||||
|
||||
|
||||
@app.post("/api/token", response_model=Token)
|
||||
async def login_for_access_token(
|
||||
form_data: Annotated[OAuth2PasswordRequestForm, Depends()]
|
||||
):
|
||||
# разобраться с первым параметром
|
||||
user = authenticate_user(db.query(UserDatabase).all(), form_data.username, form_data.password)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
access_token = create_access_token(
|
||||
data={"user_id": user.id}, expires_delta=access_token_expires
|
||||
)
|
||||
return access_token
|
||||
|
||||
|
||||
@app.get("/api/users/me/", response_model=User)
|
||||
async def read_users_me(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
||||
):
|
||||
return current_user
|
||||
|
||||
|
||||
@app.get("/api/users/me/items/")
|
||||
async def read_own_items(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
||||
):
|
||||
return [{"Current user name": current_user.name, "Current user surname": current_user.surname}]
|
||||
|
||||
|
||||
|
||||
@app.get("/api/trashbox")
|
||||
def get_trashboxes(lat:float, lng:float):#крутая функция для работы с api
|
||||
BASE_URL='https://geointelect2.gate.petersburg.ru'#адрес сайта и мой токин
|
||||
my_token='eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJhU1RaZm42bHpTdURYcUttRkg1SzN5UDFhT0FxUkhTNm9OendMUExaTXhFIn0.eyJleHAiOjE3ODM3ODk4NjgsImlhdCI6MTY4OTA5NTQ2OCwianRpIjoiNDUzNjQzZTgtYTkyMi00NTI4LWIzYmMtYWJiYTNmYjkyNTkxIiwiaXNzIjoiaHR0cHM6Ly9rYy5wZXRlcnNidXJnLnJ1L3JlYWxtcy9lZ3MtYXBpIiwiYXVkIjoiYWNjb3VudCIsInN1YiI6ImJjYjQ2NzljLTU3ZGItNDU5ZC1iNWUxLWRlOGI4Yzg5MTMwMyIsInR5cCI6IkJlYXJlciIsImF6cCI6ImFkbWluLXJlc3QtY2xpZW50Iiwic2Vzc2lvbl9zdGF0ZSI6ImM2ZDJiOTZhLWMxNjMtNDAxZS05ZjMzLTI0MmE0NDcxMDY5OCIsImFjciI6IjEiLCJhbGxvd2VkLW9yaWdpbnMiOlsiLyoiXSwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbImRlZmF1bHQtcm9sZXMtZWdzLWFwaSIsIm9mZmxpbmVfYWNjZXNzIiwidW1hX2F1dGhvcml6YXRpb24iXX0sInJlc291cmNlX2FjY2VzcyI6eyJhY2NvdW50Ijp7InJvbGVzIjpbIm1hbmFnZS1hY2NvdW50IiwibWFuYWdlLWFjY291bnQtbGlua3MiLCJ2aWV3LXByb2ZpbGUiXX19LCJzY29wZSI6ImVtYWlsIHByb2ZpbGUiLCJzaWQiOiJjNmQyYjk2YS1jMTYzLTQwMWUtOWYzMy0yNDJhNDQ3MTA2OTgiLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsIm5hbWUiOiLQktC70LDQtNC40LzQuNGAINCv0LrQvtCy0LvQtdCyIiwicHJlZmVycmVkX3VzZXJuYW1lIjoiZTBmYzc2OGRhOTA4MjNiODgwZGQzOGVhMDJjMmQ5NTciLCJnaXZlbl9uYW1lIjoi0JLQu9Cw0LTQuNC80LjRgCIsImZhbWlseV9uYW1lIjoi0K_QutC-0LLQu9C10LIifQ.E2bW0B-c6W5Lj63eP_G8eI453NlDMnW05l11TZT0GSsAtGayXGaolHtWrmI90D5Yxz7v9FGkkCmcUZYy1ywAdO9dDt_XrtFEJWFpG-3csavuMjXmqfQQ9SmPwDw-3toO64NuZVv6qVqoUlPPj57sLx4bLtVbB4pdqgyJYcrDHg7sgwz4d1Z3tAeUfSpum9s5ZfELequfpLoZMXn6CaYZhePaoK-CxeU3KPBPTPOVPKZZ19s7QY10VdkxLULknqf9opdvLs4j8NMimtwoIiHNBFlgQz10Cr7bhDKWugfvSRsICouniIiBJo76wrj5T92s-ztf1FShJuqnQcKE_QLd2A'
|
||||
head = {'Authorization': 'Bearer {}'.format(my_token)}
|
||||
|
||||
my_data={
|
||||
'x' : f"{lng}",
|
||||
'y' : f"{lat}",
|
||||
'limit' : '1'
|
||||
}
|
||||
|
||||
response = requests.post(f"{BASE_URL}/nearest_recycling/get", headers=head, data=my_data)
|
||||
infos = response.json()
|
||||
|
||||
trashboxes = []
|
||||
for trashbox in infos["results"]:
|
||||
temp_dict = {}
|
||||
for obj in trashbox["Objects"]:
|
||||
coord_list = obj["geometry"]
|
||||
temp_dict["Lat"] = coord_list["coordinates"][1]
|
||||
temp_dict["Lng"] = coord_list["coordinates"][0]
|
||||
|
||||
properties = obj["properties"]
|
||||
temp_dict["Name"] = properties["title"]
|
||||
temp_dict["Address"] = properties["address"]
|
||||
temp_dict["Categories"] = properties["content_text"].split(',')
|
||||
trashboxes.append(temp_dict)
|
||||
|
||||
uniq_trashboxes = [ast.literal_eval(el1) for el1 in set([str(el2) for el2 in trashboxes])]
|
||||
return JSONResponse(uniq_trashboxes)
|
||||
|
||||
@app.get("/{rest_of_path:path}")
|
||||
async def react_app(req: Request, rest_of_path: str):
|
||||
return templates.TemplateResponse('index.html', { 'request': req })
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
@ -1,45 +0,0 @@
|
||||
from sqlalchemy import Column, Integer, String
|
||||
|
||||
from .db import Base
|
||||
# from db import Base
|
||||
|
||||
class UserDatabase(Base):#класс пользователя
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)#айди пользователя
|
||||
phone = Column(Integer, nullable=True)#номер телефона пользователя
|
||||
email = Column(String)#электронная почта пользователя
|
||||
password = Column(String) # пароль
|
||||
hashed_password = Column(String)
|
||||
name = Column(String, nullable=True)#имя пользователя
|
||||
surname = Column(String)#фамилия пользователя
|
||||
|
||||
|
||||
class Announcement(Base): #класс объявления
|
||||
__tablename__ = "announcements"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)#айди объявления
|
||||
user_id = Column(Integer)#айди создателя объявления
|
||||
name = Column(String) # название объявления
|
||||
category = Column(String)#категория продукта из объявления
|
||||
best_by = Column(Integer)#срок годности продукта из объявления
|
||||
address = Column(String)
|
||||
longtitude = Column(Integer)
|
||||
latitude = Column(Integer)
|
||||
description = Column(String)#описание продукта в объявлении
|
||||
src = Column(String, nullable=True) #изображение продукта в объявлении
|
||||
metro = Column(String)#ближайщее метро от адреса нахождения продукта
|
||||
trashId = Column(Integer, nullable=True)
|
||||
booked_by = Column(Integer)#статус бронирования (либо -1, либо айди бронирующего)
|
||||
|
||||
|
||||
class Trashbox(Base):#класс мусорных баков
|
||||
__tablename__ = "trashboxes"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)#айди
|
||||
name = Column(String, nullable=True)#имя пользователя
|
||||
address = Column(String)
|
||||
latitude = Column(Integer)
|
||||
longtitude = Column(Integer)
|
||||
category = Column(String)#категория продукта из объявления
|
||||
|
69
back/orm_models.py
Normal file
@ -0,0 +1,69 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, Float, Date, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from .db import Base, engine
|
||||
|
||||
|
||||
class User(Base):#класс пользователя
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, unique=True)#айди пользователя
|
||||
nickname = Column(String) # никнейм пользователя
|
||||
hashed_password = Column(String)
|
||||
name = Column(String, nullable=True)#имя пользователя
|
||||
surname = Column(String)#фамилия пользователя
|
||||
disabled = Column(Boolean, default=False)
|
||||
rating = Column(Integer, default=0) # рейтинг пользователя (показатель надежности)
|
||||
points = Column(Integer, default=0) # баллы пользователя (заслуги перед платформой)
|
||||
num_of_ratings = Column(Integer, default=0) # количество оценок (т.е. то, сколько раз другие пользователи оценили текущего)
|
||||
reg_date = Column(Date) # дата регистрации
|
||||
|
||||
announcements = relationship("Announcement", back_populates="user", lazy='selectin')
|
||||
trashboxes_chosen = relationship("Trashbox", back_populates="user", lazy='selectin')
|
||||
|
||||
class Announcement(Base): #класс объявления
|
||||
__tablename__ = "announcements"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True) # айди объявления
|
||||
user_id = Column(Integer, ForeignKey("users.id")) # айди создателя объявления
|
||||
name = Column(String) # название объявления
|
||||
category = Column(String) #категория продукта из объявления
|
||||
best_by = Column(Date) #срок годности продукта из объявления
|
||||
address = Column(String)
|
||||
longtitude = Column(Float)
|
||||
latitude = Column(Float)
|
||||
description = Column(String) #описание продукта в объявлении
|
||||
src = Column(String, nullable=True) #изображение продукта в объявлении
|
||||
metro = Column(String) #ближайщее метро от адреса нахождения продукта
|
||||
trashId = Column(Integer, nullable=True)
|
||||
booked_by = Column(Integer) #количество забронировавших (0 - никто не забронировал)
|
||||
# state = Column(Enum(State), default=State.published) # состояние объявления (опубликовано, забронировано, устарело)
|
||||
obsolete = Column(Boolean, default=False) # состояние объявления (по-умолчанию считаем его актуальным)
|
||||
|
||||
user = relationship("User", back_populates="announcements")
|
||||
|
||||
|
||||
class Trashbox(Base): #класс мусорных баков
|
||||
__tablename__ = "trashboxes"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)#айди
|
||||
user_id = Column(Integer, ForeignKey("users.id")) # айди выбравшего мусорку
|
||||
name = Column(String, nullable=True)#название мусорки
|
||||
address = Column(String)
|
||||
latitude = Column(Float)
|
||||
longtitude = Column(Float)
|
||||
category = Column(String) #типы мусора (из тех, что возвращает API мусорки)
|
||||
date_of_choice = Column(Date) # Дата выбора мусорки пользователем
|
||||
|
||||
user = relationship("User", back_populates="trashboxes_chosen")
|
||||
|
||||
|
||||
class Poems(Base):#класс поэзии
|
||||
__tablename__ = "poems"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True) #айди
|
||||
title = Column(String) # название стихотворения
|
||||
text = Column(String) # текст стихотворения
|
||||
author = Column(String) # автор стихотворения
|
||||
|
||||
|
131
back/pydantic_schemas.py
Normal file
@ -0,0 +1,131 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Annotated, Union
|
||||
from datetime import date
|
||||
from typing import List
|
||||
from fastapi import UploadFile, Form
|
||||
|
||||
class Book(BaseModel):
|
||||
id: int
|
||||
|
||||
|
||||
class DelAnnouncement(BaseModel):
|
||||
id: int
|
||||
|
||||
|
||||
class Announcement(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
name: str
|
||||
category: str
|
||||
best_by: date
|
||||
address: str
|
||||
longtitude: float
|
||||
latitude: float
|
||||
description: str
|
||||
src: Union[str, None] = None #изображение продукта в объявлении
|
||||
metro: str #ближайщее метро от адреса нахождения продукта
|
||||
trashId: Union[int, None] = None
|
||||
booked_by: Union[int, None] = 0 #статус бронирования (либо 0, либо айди бронирующего)
|
||||
obsolete: bool
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
arbitrary_types_allowed=True
|
||||
|
||||
|
||||
# для "/api/announcement"
|
||||
class AnnResponce(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
name: str
|
||||
category: str
|
||||
best_by: date
|
||||
address: str
|
||||
longtitude: float
|
||||
latitude: float
|
||||
description: str
|
||||
src: Union[str, None] = None #изображение продукта в объявлении
|
||||
metro: str #ближайщее метро от адреса нахождения продукта
|
||||
trashId: Union[int, None] = None
|
||||
booked_by: Union[int, None] = 0 #статус бронирования (либо 0, либо айди бронирующего)
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
|
||||
# Схемы для токенов
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
# token_type: str
|
||||
|
||||
|
||||
class TokenData(BaseModel):
|
||||
user_id: Union[int, None] = None
|
||||
|
||||
|
||||
# Схемы юзера
|
||||
class User(BaseModel):
|
||||
id: int
|
||||
nickname: str
|
||||
reg_date: date
|
||||
disabled: Union[bool, None] = False
|
||||
items: list[Announcement] = []
|
||||
points: int
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
arbitrary_types_allowed=True
|
||||
|
||||
|
||||
class UserInDB(User):
|
||||
hashed_password: str
|
||||
|
||||
# Схема для стиха
|
||||
class Poem(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
text: str
|
||||
author: str
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
# Для "/api/trashbox"
|
||||
class TrashboxBase(BaseModel):
|
||||
Lat: float
|
||||
Lng: float
|
||||
|
||||
class TrashboxResponse(TrashboxBase):
|
||||
Name: str
|
||||
Address: str
|
||||
Categories: list[str]
|
||||
|
||||
class TrashboxRequest(TrashboxBase):
|
||||
Category: str
|
||||
|
||||
|
||||
# Для /api/announcement/dispose
|
||||
class TrashboxSelected(BaseModel):
|
||||
Lat: float
|
||||
Lng: float
|
||||
Name: str
|
||||
Address: str
|
||||
Category: str
|
||||
|
||||
class DisposeRequest(BaseModel):
|
||||
ann_id: int
|
||||
trashbox: TrashboxSelected
|
||||
|
||||
|
||||
# схема для передачи параметров, по которым ведется фильтрация
|
||||
class SortAnnouncements(BaseModel):
|
||||
obsolete: Union[int, None] = False
|
||||
user_id: Union[int, None] = None
|
||||
metro: Union[str, None] = None
|
||||
category: Union[str, None] = None
|
||||
# booked_by: Union[int, None] = None
|
||||
|
||||
# схема для начисления баллов
|
||||
class AddRating(BaseModel):
|
||||
user_id: int
|
||||
rate: int
|
14
back/scheduler.py
Normal file
@ -0,0 +1,14 @@
|
||||
from . import add_poems_and_filters
|
||||
from rocketry import Rocketry
|
||||
from rocketry.conds import daily
|
||||
import datetime
|
||||
from .db import async_session
|
||||
|
||||
app = Rocketry(execution="async")
|
||||
|
||||
# Create task:
|
||||
@app.task('daily')
|
||||
async def daily_check():
|
||||
# Фильтруем по сроку годности
|
||||
await add_poems_and_filters.check_obsolete(async_session, current_date=datetime.date.today())
|
||||
|
@ -1,5 +0,0 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
class Book(BaseModel):
|
||||
id: int
|
||||
|
118
back/utils.py
@ -1,118 +0,0 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Annotated, Union
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from pydantic import BaseModel
|
||||
|
||||
# to get a string like this run:
|
||||
# openssl rand -hex 32
|
||||
SECRET_KEY = "09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7"
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
||||
|
||||
|
||||
# fake_users_db = {
|
||||
# "johndoe": {
|
||||
# "email": "johndoe",
|
||||
# "full_name": "John Doe",
|
||||
# "email": "johndoe@example.com",
|
||||
# "hashed_password": "$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW",
|
||||
# "disabled": False,
|
||||
# }
|
||||
# }
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str
|
||||
|
||||
|
||||
class TokenData(BaseModel):
|
||||
email: Union[str, None] = None
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
# email: str
|
||||
email: Union[str, None] = None
|
||||
# password: str
|
||||
# password: Union[str, None] = None
|
||||
full_name: Union[str, None] = None
|
||||
disabled: Union[bool, None] = None
|
||||
|
||||
|
||||
class UserInDB(User):
|
||||
hashed_password: str
|
||||
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
|
||||
|
||||
def verify_password(plain_password, hashed_password):
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password):
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
# проблема здесь
|
||||
def get_user(db, email: str):
|
||||
user = None
|
||||
for person_with_correct_email in db.query(UserDatabase):
|
||||
if person_with_correct_email.email == email:
|
||||
user = person_with_correct_email
|
||||
return user #UserInDB(user_email)
|
||||
|
||||
|
||||
|
||||
|
||||
def authenticate_user(db, email: str, password: str):
|
||||
user = get_user(db, email)
|
||||
if not user:
|
||||
return False
|
||||
if not verify_password(password, user.hashed_password):
|
||||
return False
|
||||
return user
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Union[timedelta, None] = None):
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=15)
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]):
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
email: str = payload.get("sub")
|
||||
if email is None:
|
||||
raise credentials_exception
|
||||
token_data = TokenData(email=email)
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
user = get_user(fake_users_db, email=token_data.email)
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_active_user(
|
||||
current_user: Annotated[User, Depends(get_current_user)]
|
||||
):
|
||||
if current_user.disabled:
|
||||
raise HTTPException(status_code=400, detail="Inactive user")
|
||||
return current_user
|
@ -32,5 +32,16 @@ module.exports = {
|
||||
}
|
||||
],
|
||||
'jsx-quotes': [2, 'prefer-single'],
|
||||
'comma-dangle': 'off',
|
||||
'@typescript-eslint/comma-dangle': ['warn', {
|
||||
'arrays': 'always-multiline',
|
||||
'objects': 'always-multiline',
|
||||
'imports': 'always-multiline',
|
||||
'exports': 'always-multiline',
|
||||
'functions': 'only-multiline',
|
||||
'enums': 'always-multiline',
|
||||
'generics': 'always-multiline',
|
||||
'tuples': 'always-multiline',
|
||||
}],
|
||||
},
|
||||
}
|
||||
|
37
front/package-lock.json
generated
@ -8,8 +8,8 @@
|
||||
"name": "front",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@types/leaflet": "^1.9.3",
|
||||
"bootstrap": "^5.3.0",
|
||||
"jwt-decode": "^3.1.2",
|
||||
"leaflet": "^1.9.4",
|
||||
"react": "^18.2.0",
|
||||
"react-bootstrap": "^2.8.0",
|
||||
@ -17,10 +17,13 @@
|
||||
"react-dom": "^18.2.0",
|
||||
"react-insta-stories": "^2.6.1",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-leaflet-custom-control": "^1.3.5",
|
||||
"react-router-dom": "^6.14.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^8.0.2",
|
||||
"@types/leaflet": "^1.9.3",
|
||||
"@types/lodash": "^4.14.196",
|
||||
"@types/react": "^18.2.14",
|
||||
"@types/react-dom": "^18.2.6",
|
||||
"@typescript-eslint/eslint-plugin": "^5.61.0",
|
||||
@ -29,6 +32,7 @@
|
||||
"eslint": "^8.44.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.1",
|
||||
"lodash": "^4.17.21",
|
||||
"typescript": "^5.0.2",
|
||||
"vite": "^4.4.0"
|
||||
}
|
||||
@ -1058,7 +1062,8 @@
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.10.tgz",
|
||||
"integrity": "sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA=="
|
||||
"integrity": "sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.12",
|
||||
@ -1070,10 +1075,17 @@
|
||||
"version": "1.9.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.3.tgz",
|
||||
"integrity": "sha512-Caa1lYOgKVqDkDZVWkto2Z5JtVo09spEaUt2S69LiugbBpoqQu92HYFMGUbYezZbnBkyOxMNPXHSgRrRY5UyIA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/lodash": {
|
||||
"version": "4.14.196",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.196.tgz",
|
||||
"integrity": "sha512-22y3o88f4a94mKljsZcanlNWPzO0uBsBdzLAngf2tp533LzZcQzb6+eZPJ+vCTt+bqF2XnvT9gejTLsAcJAJyQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz",
|
||||
@ -2378,6 +2390,11 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jwt-decode": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz",
|
||||
"integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A=="
|
||||
},
|
||||
"node_modules/leaflet": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
@ -2411,6 +2428,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/lodash.debounce": {
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
|
||||
@ -2842,6 +2865,16 @@
|
||||
"react-dom": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-leaflet-custom-control": {
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/react-leaflet-custom-control/-/react-leaflet-custom-control-1.3.5.tgz",
|
||||
"integrity": "sha512-9/v7AxY6CoUbc6fAD/0u8O6wCBopxtdzJukWOR7vLZcyAN5rQCYWXjF5wXJ8klONweZGsRaGPJelfEBRtZAgQA==",
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.7.1",
|
||||
"react": "^17.0.2 || ^18.0.0",
|
||||
"react-dom": "^17.0.2 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-lifecycles-compat": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
|
||||
|
@ -12,8 +12,8 @@
|
||||
"addFetchApiRoute": "bash utils/addFetchApiRoute.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/leaflet": "^1.9.3",
|
||||
"bootstrap": "^5.3.0",
|
||||
"jwt-decode": "^3.1.2",
|
||||
"leaflet": "^1.9.4",
|
||||
"react": "^18.2.0",
|
||||
"react-bootstrap": "^2.8.0",
|
||||
@ -21,10 +21,13 @@
|
||||
"react-dom": "^18.2.0",
|
||||
"react-insta-stories": "^2.6.1",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-leaflet-custom-control": "^1.3.5",
|
||||
"react-router-dom": "^6.14.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^8.0.2",
|
||||
"@types/leaflet": "^1.9.3",
|
||||
"@types/lodash": "^4.14.196",
|
||||
"@types/react": "^18.2.14",
|
||||
"@types/react-dom": "^18.2.6",
|
||||
"@typescript-eslint/eslint-plugin": "^5.61.0",
|
||||
@ -33,6 +36,7 @@
|
||||
"eslint": "^8.44.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.1",
|
||||
"lodash": "^4.17.21",
|
||||
"typescript": "^5.0.2",
|
||||
"vite": "^4.4.0"
|
||||
}
|
||||
|
BIN
front/public/empty.png
Normal file
After Width: | Height: | Size: 287 KiB |
@ -6,7 +6,7 @@ const processAnnouncement = (data: AnnouncementResponse): Announcement => ({
|
||||
lng: data.longtitude,
|
||||
bestBy: data.best_by,
|
||||
bookedBy: data.booked_by,
|
||||
userId: data.user_id
|
||||
userId: data.user_id,
|
||||
})
|
||||
|
||||
export { processAnnouncement }
|
||||
|
@ -1,20 +1,28 @@
|
||||
import { isObject } from '../../utils/types'
|
||||
import { Category, isCategory } from '../../assets/category'
|
||||
|
||||
type AnnouncementResponse = {
|
||||
type Announcement = {
|
||||
id: number,
|
||||
user_id: number,
|
||||
userId: number,
|
||||
name: string,
|
||||
category: Category,
|
||||
best_by: number,
|
||||
bestBy: string,
|
||||
address: string,
|
||||
longtitude: number,
|
||||
latitude: number,
|
||||
description: string,
|
||||
lng: number,
|
||||
lat: number,
|
||||
description: string | null,
|
||||
src: string | null,
|
||||
metro: string,
|
||||
trashId: number | null,
|
||||
booked_by: number
|
||||
bookedBy: number,
|
||||
}
|
||||
|
||||
type AnnouncementResponse = Omit<Announcement, 'userId' | 'bestBy' | 'bookedBy' | 'lat' | 'lng'> & {
|
||||
user_id: Announcement['userId'],
|
||||
best_by: Announcement['bestBy'],
|
||||
longtitude: Announcement['lng'],
|
||||
latitude: Announcement['lat'],
|
||||
booked_by: Announcement['bookedBy'],
|
||||
}
|
||||
|
||||
const isAnnouncementResponse = (obj: unknown): obj is AnnouncementResponse => (
|
||||
@ -23,7 +31,7 @@ const isAnnouncementResponse = (obj: unknown): obj is AnnouncementResponse => (
|
||||
'user_id': 'number',
|
||||
'name': 'string',
|
||||
'category': isCategory,
|
||||
'best_by': 'number',
|
||||
'best_by': 'string',
|
||||
'address': 'string',
|
||||
'longtitude': 'number',
|
||||
'latitude': 'number',
|
||||
@ -31,26 +39,10 @@ const isAnnouncementResponse = (obj: unknown): obj is AnnouncementResponse => (
|
||||
'src': 'string?',
|
||||
'metro': 'string',
|
||||
'trashId': 'number?',
|
||||
'booked_by': 'number'
|
||||
'booked_by': 'number',
|
||||
})
|
||||
)
|
||||
|
||||
type Announcement = {
|
||||
id: number,
|
||||
userId: number,
|
||||
name: string,
|
||||
category: Category,
|
||||
bestBy: number,
|
||||
address: string,
|
||||
lng: number,
|
||||
lat: number,
|
||||
description: string | null,
|
||||
src: string | null,
|
||||
metro: string,
|
||||
trashId: number | null,
|
||||
bookedBy: number
|
||||
}
|
||||
|
||||
export type {
|
||||
Announcement,
|
||||
AnnouncementResponse,
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { API_URL } from '../../config'
|
||||
import { FiltersType, URLEncodeFilters } from '../../utils/filters'
|
||||
import { FiltersType, URLEncodeFilters, convertFilterNames } from '../../utils/filters'
|
||||
import { processAnnouncement } from '../announcement'
|
||||
import { Announcement } from '../announcement/types'
|
||||
import { AnnouncementsResponse } from './types'
|
||||
@ -7,11 +7,11 @@ import { AnnouncementsResponse } from './types'
|
||||
const initialAnnouncements: Announcement[] = []
|
||||
|
||||
const composeAnnouncementsURL = (filters: FiltersType) => (
|
||||
API_URL + '/announcements?' + new URLSearchParams(URLEncodeFilters(filters)).toString()
|
||||
API_URL + '/announcements?' + new URLSearchParams(convertFilterNames(URLEncodeFilters(filters))).toString()
|
||||
)
|
||||
|
||||
const processAnnouncements = (data: AnnouncementsResponse): Announcement[] => (
|
||||
data.list_of_announcements.map(processAnnouncement)
|
||||
data.map(processAnnouncement)
|
||||
)
|
||||
|
||||
export { initialAnnouncements, composeAnnouncementsURL, processAnnouncements }
|
||||
|
@ -1,16 +1,10 @@
|
||||
import { isArrayOf, isObject } from '../../utils/types'
|
||||
import { isArrayOf } from '../../utils/types'
|
||||
import { AnnouncementResponse, isAnnouncementResponse } from '../announcement/types'
|
||||
|
||||
type AnnouncementsResponse = {
|
||||
list_of_announcements: AnnouncementResponse[],
|
||||
Success: boolean
|
||||
}
|
||||
type AnnouncementsResponse = AnnouncementResponse[]
|
||||
|
||||
const isAnnouncementsResponse = (obj: unknown): obj is AnnouncementsResponse => (
|
||||
isObject(obj, {
|
||||
'list_of_announcements': obj => isArrayOf<AnnouncementResponse>(obj, isAnnouncementResponse),
|
||||
'Success': 'boolean'
|
||||
})
|
||||
isArrayOf(obj, isAnnouncementResponse)
|
||||
)
|
||||
|
||||
export type {
|
||||
|
16
front/src/api/book/index.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { API_URL } from '../../config'
|
||||
import { Book, BookResponse } from './types'
|
||||
|
||||
const composeBookURL = () => (
|
||||
API_URL + '/book?'
|
||||
)
|
||||
|
||||
const processBook = (data: BookResponse): Book => {
|
||||
if (!data.Success) {
|
||||
throw new Error('Не удалось забронировать объявление')
|
||||
}
|
||||
|
||||
return data.Success
|
||||
}
|
||||
|
||||
export { composeBookURL, processBook }
|
17
front/src/api/book/types.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { isObject } from '../../utils/types'
|
||||
|
||||
type BookResponse = {
|
||||
Success: boolean,
|
||||
}
|
||||
|
||||
const isBookResponse = (obj: unknown): obj is BookResponse => (
|
||||
isObject(obj, {
|
||||
'Success': 'boolean',
|
||||
})
|
||||
)
|
||||
|
||||
type Book = boolean
|
||||
|
||||
export type { BookResponse, Book }
|
||||
|
||||
export { isBookResponse }
|
19
front/src/api/dispose/index.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { API_URL } from '../../config'
|
||||
import { TrashboxDispose, DisposeResponse } from './types'
|
||||
|
||||
const composeDisposeURL = () => (
|
||||
API_URL + '/announcement/dispose?'
|
||||
)
|
||||
|
||||
const composeDisposeBody = (ann_id: number, trashbox: TrashboxDispose) => (
|
||||
JSON.stringify({
|
||||
ann_id,
|
||||
trashbox,
|
||||
})
|
||||
)
|
||||
|
||||
const processDispose = (data: DisposeResponse): boolean => {
|
||||
return data.Success
|
||||
}
|
||||
|
||||
export { composeDisposeURL, composeDisposeBody, processDispose }
|
23
front/src/api/dispose/types.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { composeDisposeBody } from '.'
|
||||
import { isObject } from '../../utils/types'
|
||||
import { Trashbox } from '../trashbox/types'
|
||||
|
||||
type TrashboxDispose = Omit<Trashbox, 'Categories'> & { Category: string }
|
||||
|
||||
type DisposeParams = Parameters<typeof composeDisposeBody>
|
||||
|
||||
type DisposeAnnParams = DisposeParams extends [ann_id: number, ...args: infer P] ? P : never
|
||||
|
||||
type DisposeResponse = {
|
||||
Success: boolean,
|
||||
}
|
||||
|
||||
const isDisposeResponse = (obj: unknown): obj is DisposeResponse => (
|
||||
isObject(obj, {
|
||||
'Success': 'boolean',
|
||||
})
|
||||
)
|
||||
|
||||
export type { TrashboxDispose, DisposeParams, DisposeAnnParams, DisposeResponse }
|
||||
|
||||
export { isDisposeResponse }
|
@ -1,4 +1,5 @@
|
||||
import { LatLng } from 'leaflet'
|
||||
|
||||
import { OsmAddressResponse } from './types'
|
||||
|
||||
const initialOsmAddress = ''
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { isObject } from '../../utils/types'
|
||||
|
||||
type OsmAddressResponse = {
|
||||
display_name: string
|
||||
display_name: string,
|
||||
}
|
||||
|
||||
const isOsmAddressResponse = (obj: unknown): obj is OsmAddressResponse => (
|
||||
|
19
front/src/api/poetry/index.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { API_URL } from '../../config'
|
||||
import { PoetryResponse, Poetry } from './types'
|
||||
|
||||
const initialPoetry: Poetry = {
|
||||
title: '',
|
||||
text: '',
|
||||
author: '',
|
||||
id: 0,
|
||||
}
|
||||
|
||||
const composePoetryURL = () => (
|
||||
API_URL + '/user/poem?'
|
||||
)
|
||||
|
||||
const processPoetry = (data: PoetryResponse): Poetry => {
|
||||
return data
|
||||
}
|
||||
|
||||
export { initialPoetry, composePoetryURL, processPoetry }
|
25
front/src/api/poetry/types.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { isObject } from '../../utils/types'
|
||||
|
||||
type PoetryResponse = {
|
||||
title: string,
|
||||
text: string,
|
||||
author: string,
|
||||
id: number,
|
||||
}
|
||||
|
||||
const isPoetryResponse = (obj: unknown): obj is PoetryResponse => (
|
||||
isObject(obj, {
|
||||
'title': 'string',
|
||||
'text': 'string',
|
||||
'author': 'string',
|
||||
'id': 'number',
|
||||
})
|
||||
)
|
||||
|
||||
type Poetry = PoetryResponse
|
||||
|
||||
const isPoetry = isPoetryResponse
|
||||
|
||||
export type { PoetryResponse, Poetry }
|
||||
|
||||
export { isPoetryResponse, isPoetry }
|
@ -6,7 +6,7 @@ const composePutAnnouncementURL = () => (
|
||||
)
|
||||
|
||||
const processPutAnnouncement = (data: PutAnnouncementResponse): PutAnnouncement => {
|
||||
return data.Answer
|
||||
return data.Success
|
||||
}
|
||||
|
||||
export { composePutAnnouncementURL, processPutAnnouncement }
|
||||
|
@ -1,12 +1,12 @@
|
||||
import { isObject } from '../../utils/types'
|
||||
|
||||
type PutAnnouncementResponse = {
|
||||
Answer: boolean
|
||||
Success: boolean,
|
||||
}
|
||||
|
||||
const isPutAnnouncementResponse = (obj: unknown): obj is PutAnnouncementResponse => (
|
||||
isObject(obj, {
|
||||
'Answer': 'boolean'
|
||||
'Success': 'boolean',
|
||||
})
|
||||
)
|
||||
|
||||
|
16
front/src/api/removeAnnouncement/index.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { API_URL } from '../../config'
|
||||
import { RemoveAnnouncement, RemoveAnnouncementResponse } from './types'
|
||||
|
||||
const composeRemoveAnnouncementURL = () => (
|
||||
API_URL + '/announcement?'
|
||||
)
|
||||
|
||||
function processRemoveAnnouncement(data: RemoveAnnouncementResponse): RemoveAnnouncement {
|
||||
if (!data.Success) {
|
||||
throw new Error('Не удалось закрыть объявление')
|
||||
}
|
||||
|
||||
return data.Success
|
||||
}
|
||||
|
||||
export { composeRemoveAnnouncementURL, processRemoveAnnouncement }
|
17
front/src/api/removeAnnouncement/types.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { isObject } from '../../utils/types'
|
||||
|
||||
type RemoveAnnouncementResponse = {
|
||||
Success: boolean,
|
||||
}
|
||||
|
||||
const isRemoveAnnouncementResponse = (obj: unknown): obj is RemoveAnnouncementResponse => (
|
||||
isObject(obj, {
|
||||
'Success': 'boolean',
|
||||
})
|
||||
)
|
||||
|
||||
type RemoveAnnouncement = boolean
|
||||
|
||||
export type { RemoveAnnouncementResponse, RemoveAnnouncement }
|
||||
|
||||
export { isRemoveAnnouncementResponse }
|
12
front/src/api/sendRate/index.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { API_URL } from '../../config'
|
||||
import { SendRateResponse, SendRate } from './types'
|
||||
|
||||
const composeSendRateURL = () => (
|
||||
API_URL + '/user/rating?'
|
||||
)
|
||||
|
||||
const processSendRate = (data: SendRateResponse): SendRate => {
|
||||
return data.Success
|
||||
}
|
||||
|
||||
export { composeSendRateURL, processSendRate }
|
17
front/src/api/sendRate/types.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { isObject } from '../../utils/types'
|
||||
|
||||
type SendRateResponse = {
|
||||
Success: boolean
|
||||
}
|
||||
|
||||
const isSendRateResponse = (obj: unknown): obj is SendRateResponse => (
|
||||
isObject(obj, {
|
||||
'Success': 'boolean',
|
||||
})
|
||||
)
|
||||
|
||||
type SendRate = boolean
|
||||
|
||||
export type { SendRateResponse, SendRate }
|
||||
|
||||
export { isSendRateResponse }
|
22
front/src/api/signup/index.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { API_URL } from '../../config'
|
||||
import { SignUp, SignUpResponse } from './types'
|
||||
|
||||
const composeSignUpURL = () => (
|
||||
API_URL + '/signup?'
|
||||
)
|
||||
|
||||
const composeSignUpBody = (formData: FormData) => {
|
||||
formData.append('nickname', formData.get('username') ?? '')
|
||||
|
||||
return formData
|
||||
}
|
||||
|
||||
const processSignUp = (data: SignUpResponse): SignUp => {
|
||||
if (!data.Success) {
|
||||
throw new Error(data.Message)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export { composeSignUpURL, composeSignUpBody, processSignUp }
|
23
front/src/api/signup/types.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { isConst, isObject } from '../../utils/types'
|
||||
|
||||
type SignUpResponse = {
|
||||
Success: true,
|
||||
} | {
|
||||
Success: false,
|
||||
Message: string,
|
||||
}
|
||||
|
||||
const isSignUpResponse = (obj: unknown): obj is SignUpResponse => (
|
||||
isObject(obj, {
|
||||
'Success': isConst(true),
|
||||
}) || isObject(obj, {
|
||||
'Success': isConst(false),
|
||||
'Message': 'string',
|
||||
})
|
||||
)
|
||||
|
||||
type SignUp = boolean
|
||||
|
||||
export type { SignUpResponse, SignUp }
|
||||
|
||||
export { isSignUpResponse }
|
12
front/src/api/token/index.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { API_URL } from '../../config'
|
||||
import { Token, TokenResponse } from './types'
|
||||
|
||||
const composeTokenURL = () => (
|
||||
API_URL + '/token?'
|
||||
)
|
||||
|
||||
const processToken = (data: TokenResponse): Token => {
|
||||
return data.access_token
|
||||
}
|
||||
|
||||
export { composeTokenURL, processToken }
|
17
front/src/api/token/types.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { isObject } from '../../utils/types'
|
||||
|
||||
type TokenResponse = {
|
||||
access_token: string,
|
||||
}
|
||||
|
||||
const isTokenResponse = (obj: unknown): obj is TokenResponse => (
|
||||
isObject(obj, {
|
||||
'access_token': 'string',
|
||||
})
|
||||
)
|
||||
|
||||
type Token = string
|
||||
|
||||
export type { TokenResponse, Token }
|
||||
|
||||
export { isTokenResponse }
|
@ -2,15 +2,18 @@ import { LatLng } from 'leaflet'
|
||||
|
||||
import { API_URL } from '../../config'
|
||||
import { Trashbox, TrashboxResponse } from './types'
|
||||
import { Category } from '../../assets/category'
|
||||
|
||||
const composeTrashboxURL = (position: LatLng) => (
|
||||
const composeTrashboxURL = (position: LatLng, category: Category) => (
|
||||
API_URL + '/trashbox?' + new URLSearchParams({
|
||||
lat: position.lat.toString(),
|
||||
lng: position.lng.toString()
|
||||
Lat: position.lat.toString(),
|
||||
Lng: position.lng.toString(),
|
||||
Category: category,
|
||||
}).toString()
|
||||
)
|
||||
|
||||
const processTrashbox = (data: TrashboxResponse): Trashbox[] =>
|
||||
const processTrashbox = (data: TrashboxResponse): Trashbox[] => (
|
||||
data
|
||||
)
|
||||
|
||||
export { composeTrashboxURL, processTrashbox }
|
||||
|
@ -1,18 +1,20 @@
|
||||
import { isArrayOf, isObject, isString } from '../../utils/types'
|
||||
|
||||
type Trashbox = {
|
||||
Name: string,
|
||||
Lat: number,
|
||||
Lng: number,
|
||||
Address: string,
|
||||
Categories: string[]
|
||||
Categories: string[],
|
||||
}
|
||||
|
||||
const isTrashbox = (obj: unknown): obj is Trashbox => (
|
||||
isObject(obj, {
|
||||
'Name': 'string',
|
||||
'Lat': 'number',
|
||||
'Lng': 'number',
|
||||
'Address': 'string',
|
||||
'Categories': obj => isArrayOf<string>(obj, isString)
|
||||
'Categories': obj => isArrayOf(obj, isString),
|
||||
})
|
||||
)
|
||||
|
||||
|
@ -1,25 +1,22 @@
|
||||
import { API_URL } from '../../config'
|
||||
import { UserResponse, User } from './types'
|
||||
|
||||
import { faker } from '@faker-js/faker/locale/ru'
|
||||
|
||||
|
||||
const initialUser: User = import.meta.env.DEV ? { // Temporary, until api is realized
|
||||
id: Math.random() * 100,
|
||||
name: faker.person.firstName() + ' ' + faker.person.lastName(),
|
||||
regDate: faker.date.anytime().getTime(),
|
||||
} : {
|
||||
const initialUser: User = {
|
||||
id: -1,
|
||||
name: '',
|
||||
regDate: 0,
|
||||
nickname: '',
|
||||
regDate: '',
|
||||
points: -1,
|
||||
}
|
||||
|
||||
const composeUserURL = () => (
|
||||
API_URL + '/user?'
|
||||
API_URL + '/users/me?'
|
||||
)
|
||||
|
||||
const processUser = (data: UserResponse): User => {
|
||||
return data
|
||||
return {
|
||||
...data,
|
||||
regDate: data.reg_date,
|
||||
}
|
||||
}
|
||||
|
||||
export { initialUser, composeUserURL, processUser }
|
||||
|
@ -2,28 +2,22 @@ import { isObject } from '../../utils/types'
|
||||
|
||||
type User = {
|
||||
id: number,
|
||||
name: string,
|
||||
regDate: number,
|
||||
nickname: string,
|
||||
regDate: string,
|
||||
points: number,
|
||||
}
|
||||
|
||||
const isUser = (obj: unknown): obj is User => (
|
||||
type UserResponse = Omit<User, 'regDate'> & { reg_date: string }
|
||||
|
||||
const isUserResponse = (obj: unknown): obj is UserResponse => (
|
||||
isObject(obj, {
|
||||
'id': 'number',
|
||||
'name': 'string',
|
||||
'regDate': 'number',
|
||||
'nickname': 'string',
|
||||
'reg_date': 'string',
|
||||
'points': 'number',
|
||||
})
|
||||
)
|
||||
|
||||
type UserResponse = User
|
||||
|
||||
// const isUserResponse = (obj: unknown): obj is UserResponse => (
|
||||
// isObject(obj, {
|
||||
|
||||
// })
|
||||
// )
|
||||
|
||||
const isUserResponse = isUser
|
||||
|
||||
export type { UserResponse, User }
|
||||
|
||||
export { isUserResponse, isUser }
|
||||
export { isUserResponse }
|
||||
|
14
front/src/api/userRating/index.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { API_URL } from '../../config'
|
||||
import { UserRatingResponse, UserRating } from './types'
|
||||
|
||||
const initialUserRating: UserRating = 0
|
||||
|
||||
const composeUserRatingURL = (userId: number) => (
|
||||
API_URL + '/user/rating?' + (new URLSearchParams({ user_id: userId.toString() })).toString()
|
||||
)
|
||||
|
||||
const processUserRating = (data: UserRatingResponse): UserRating => {
|
||||
return data.rating
|
||||
}
|
||||
|
||||
export { initialUserRating, composeUserRatingURL, processUserRating }
|
17
front/src/api/userRating/types.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { isObject } from '../../utils/types'
|
||||
|
||||
type UserRatingResponse = {
|
||||
rating: number
|
||||
}
|
||||
|
||||
const isUserRatingResponse = (obj: unknown): obj is UserRatingResponse => (
|
||||
isObject(obj, {
|
||||
'rating': 'number',
|
||||
})
|
||||
)
|
||||
|
||||
type UserRating = number
|
||||
|
||||
export type { UserRatingResponse, UserRating }
|
||||
|
||||
export { isUserRatingResponse }
|
@ -1 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" style="fill: rgb(17, 17, 17); --darkreader-inline-fill: #e8e6e3;" data-darkreader-inline-fill=""><path d="M20 2H4c-1.103 0-2 .897-2 2v18l4-4h14c1.103 0 2-.897 2-2V4c0-1.103-.897-2-2-2zm-3 9h-4v4h-2v-4H7V9h4V5h2v4h4v2z"></path></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" style="fill: rgb(17, 17, 17); --darkreader-inline-fill: #e8e6e3;" data-darkreader-inline-fill="">
|
||||
<path d="M20 2H4c-1.103 0-2 .897-2 2v18l4-4h14c1.103 0 2-.897 2-2V4c0-1.103-.897-2-2-2zm-3 9h-4v4h-2v-4H7V9h4V5h2v4h4v2z" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 315 B After Width: | Height: | Size: 317 B |
BIN
front/src/assets/dormitoryMarker.png
Normal file
After Width: | Height: | Size: 17 KiB |
@ -1 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" style="fill: rgb(17, 17, 17); --darkreader-inline-fill: #e8e6e3;" data-darkreader-inline-fill=""><path d="M13 20v-4.586L20.414 8c.375-.375.586-.884.586-1.415V4a1 1 0 0 0-1-1H4a1 1 0 0 0-1 1v2.585c0 .531.211 1.04.586 1.415L11 15.414V22l2-2z"></path></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" style="fill: rgb(17, 17, 17); --darkreader-inline-fill: #e8e6e3;" data-darkreader-inline-fill="">
|
||||
<path d="M13 20v-4.586L20.414 8c.375-.375.586-.884.586-1.415V4a1 1 0 0 0-1-1H4a1 1 0 0 0-1 1v2.585c0 .531.211 1.04.586 1.415L11 15.414V22l2-2z" />
|
||||
</svg>
|
Before Width: | Height: | Size: 337 B After Width: | Height: | Size: 338 B |
@ -1 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" style="fill: rgb(17, 17, 17); --darkreader-inline-fill: #e8e6e3;" data-darkreader-inline-fill=""><path d="M20.5 5A1.5 1.5 0 0 0 19 6.5V11h-1V4.5a1.5 1.5 0 0 0-3 0V11h-1V3.5a1.5 1.5 0 0 0-3 0V11h-1V5.5a1.5 1.5 0 0 0-3 0v10.81l-2.22-3.6a1.5 1.5 0 0 0-2.56 1.58l3.31 5.34A5 5 0 0 0 9.78 22H17a5 5 0 0 0 5-5V6.5A1.5 1.5 0 0 0 20.5 5z"></path></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" style="fill: rgb(17, 17, 17); --darkreader-inline-fill: #e8e6e3;" data-darkreader-inline-fill="">
|
||||
<path d="M20.5 5A1.5 1.5 0 0 0 19 6.5V11h-1V4.5a1.5 1.5 0 0 0-3 0V11h-1V3.5a1.5 1.5 0 0 0-3 0V11h-1V5.5a1.5 1.5 0 0 0-3 0v10.81l-2.22-3.6a1.5 1.5 0 0 0-2.56 1.58l3.31 5.34A5 5 0 0 0 9.78 22H17a5 5 0 0 0 5-5V6.5A1.5 1.5 0 0 0 20.5 5z" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 427 B After Width: | Height: | Size: 429 B |
7
front/src/assets/handStars.svg
Normal file
@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="rgb(185, 179, 170)" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.25993 21.3884H6C5.05719 21.3884 4.58579 21.3884 4.29289 21.0955C4 20.8026 4 20.3312 4 19.3884V18.2764C4 17.7579 4 17.4987 4.13318 17.2672C4.26636 17.0356 4.46727 16.9188 4.8691 16.6851C7.51457 15.1464 11.2715 14.2803 13.7791 15.7759C13.9475 15.8764 14.0991 15.9977 14.2285 16.1431C14.7866 16.77 14.746 17.7161 14.1028 18.2775C13.9669 18.396 13.8222 18.486 13.6764 18.5172C13.7962 18.5033 13.911 18.4874 14.0206 18.4699C14.932 18.3245 15.697 17.8375 16.3974 17.3084L18.2046 15.9433C18.8417 15.462 19.7873 15.4619 20.4245 15.943C20.9982 16.3762 21.1736 17.0894 20.8109 17.6707C20.388 18.3487 19.7921 19.216 19.2199 19.7459C18.6469 20.2766 17.7939 20.7504 17.0975 21.0865C16.326 21.4589 15.4738 21.6734 14.6069 21.8138C12.8488 22.0983 11.0166 22.0549 9.27633 21.6964C8.29253 21.4937 7.27079 21.3884 6.25993 21.3884Z"/>
|
||||
<path d="M10.8613 3.36335C11.3679 2.45445 11.6213 2 12 2C12.3787 2 12.6321 2.45445 13.1387 3.36335L13.2698 3.59849C13.4138 3.85677 13.4858 3.98591 13.598 4.07112C13.7103 4.15633 13.8501 4.18796 14.1296 4.25122L14.3842 4.30881C15.3681 4.53142 15.86 4.64273 15.977 5.01909C16.0941 5.39546 15.7587 5.78763 15.088 6.57197L14.9144 6.77489C14.7238 6.99777 14.6285 7.10922 14.5857 7.24709C14.5428 7.38496 14.5572 7.53365 14.586 7.83102L14.6122 8.10176C14.7136 9.14824 14.7644 9.67148 14.4579 9.90409C14.1515 10.1367 13.6909 9.92462 12.7697 9.50047L12.5314 9.39074C12.2696 9.27021 12.1387 9.20994 12 9.20994C11.8613 9.20994 11.7304 9.27021 11.4686 9.39074L11.2303 9.50047C10.3091 9.92462 9.84847 10.1367 9.54206 9.90409C9.23565 9.67148 9.28635 9.14824 9.38776 8.10176L9.41399 7.83102C9.44281 7.53364 9.45722 7.38496 9.41435 7.24709C9.37147 7.10922 9.27617 6.99777 9.08557 6.77489L8.91204 6.57197C8.2413 5.78763 7.90593 5.39546 8.02297 5.01909C8.14001 4.64273 8.63194 4.53142 9.61581 4.30881L9.87035 4.25122C10.1499 4.18796 10.2897 4.15633 10.402 4.07112C10.5142 3.98591 10.5862 3.85677 10.7302 3.59849L10.8613 3.36335Z"/>
|
||||
<path d="M19.4306 7.68167C19.684 7.22722 19.8106 7 20 7C20.1894 7 20.316 7.22723 20.5694 7.68167L20.6349 7.79925C20.7069 7.92839 20.7429 7.99296 20.799 8.03556C20.8551 8.07817 20.925 8.09398 21.0648 8.12561L21.1921 8.15441C21.684 8.26571 21.93 8.32136 21.9885 8.50955C22.047 8.69773 21.8794 8.89381 21.544 9.28598L21.4572 9.38744C21.3619 9.49889 21.3143 9.55461 21.2928 9.62354C21.2714 9.69248 21.2786 9.76682 21.293 9.91551L21.3061 10.0509C21.3568 10.5741 21.3822 10.8357 21.229 10.952C21.0758 11.0683 20.8455 10.9623 20.3849 10.7502L20.2657 10.6954C20.1348 10.6351 20.0694 10.605 20 10.605C19.9306 10.605 19.8652 10.6351 19.7343 10.6954L19.6151 10.7502C19.1545 10.9623 18.9242 11.0683 18.771 10.952C18.6178 10.8357 18.6432 10.5741 18.6939 10.0509L18.707 9.91551C18.7214 9.76682 18.7286 9.69248 18.7072 9.62354C18.6857 9.55461 18.6381 9.49889 18.5428 9.38744L18.456 9.28598C18.1207 8.89381 17.953 8.69773 18.0115 8.50955C18.07 8.32136 18.316 8.26571 18.8079 8.15441L18.9352 8.12561C19.075 8.09398 19.1449 8.07817 19.201 8.03556C19.2571 7.99296 19.2931 7.92839 19.3651 7.79925L19.4306 7.68167Z"/>
|
||||
<path d="M3.43063 7.68167C3.68396 7.22722 3.81063 7 4 7C4.18937 7 4.31604 7.22723 4.56937 7.68167L4.63491 7.79925C4.7069 7.92839 4.74289 7.99296 4.79901 8.03556C4.85513 8.07817 4.92503 8.09398 5.06482 8.12561L5.19209 8.15441C5.68403 8.26571 5.93 8.32136 5.98852 8.50955C6.04704 8.69773 5.87935 8.89381 5.54398 9.28598L5.45722 9.38744C5.36191 9.49889 5.31426 9.55461 5.29283 9.62354C5.27139 9.69248 5.27859 9.76682 5.293 9.91551L5.30612 10.0509C5.35682 10.5741 5.38218 10.8357 5.22897 10.952C5.07576 11.0683 4.84547 10.9623 4.38487 10.7502L4.2657 10.6954C4.13481 10.6351 4.06937 10.605 4 10.605C3.93063 10.605 3.86519 10.6351 3.7343 10.6954L3.61513 10.7502C3.15454 10.9623 2.92424 11.0683 2.77103 10.952C2.61782 10.8357 2.64318 10.5741 2.69388 10.0509L2.707 9.91551C2.72141 9.76682 2.72861 9.69248 2.70717 9.62354C2.68574 9.55461 2.63809 9.49889 2.54278 9.38744L2.45602 9.28598C2.12065 8.89381 1.95296 8.69773 2.01148 8.50955C2.07 8.32136 2.31597 8.26571 2.80791 8.15441L2.93518 8.12561C3.07497 8.09398 3.14487 8.07817 3.20099 8.03556C3.25711 7.99296 3.29311 7.92839 3.36509 7.79925L3.43063 7.68167Z"/>
|
||||
</svg>
|
After Width: | Height: | Size: 4.3 KiB |
BIN
front/src/assets/itmoMarker.png
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
front/src/assets/letiMarker.png
Normal file
After Width: | Height: | Size: 25 KiB |
2
front/src/assets/locate.svg
Normal file
@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg"><path d="M 15 3 L 15 5.0625 C 9.734375 5.539063 5.539063 9.734375 5.0625 15 L 3 15 L 3 17 L 5.0625 17 C 5.539063 22.265625 9.734375 26.460938 15 26.9375 L 15 29 L 17 29 L 17 26.9375 C 22.265625 26.460938 26.460938 22.265625 26.9375 17 L 29 17 L 29 15 L 26.9375 15 C 26.460938 9.734375 22.265625 5.539063 17 5.0625 L 17 3 Z M 15 7.03125 L 15 9 L 17 9 L 17 7.03125 C 21.191406 7.484375 24.515625 10.808594 24.96875 15 L 23 15 L 23 17 L 24.96875 17 C 24.515625 21.191406 21.191406 24.515625 17 24.96875 L 17 23 L 15 23 L 15 24.96875 C 10.808594 24.515625 7.484375 21.191406 7.03125 17 L 9 17 L 9 15 L 7.03125 15 C 7.484375 10.808594 10.808594 7.484375 15 7.03125 Z"/></svg>
|
After Width: | Height: | Size: 893 B |
@ -21,7 +21,7 @@ const stations: Record<Lines, Set<string>> = {
|
||||
'Кировский завод',
|
||||
'Автово',
|
||||
'Ленинский проспект',
|
||||
'Проспект Ветеранов'
|
||||
'Проспект Ветеранов',
|
||||
]),
|
||||
blue: new Set([
|
||||
'Парнас',
|
||||
@ -41,7 +41,7 @@ const stations: Record<Lines, Set<string>> = {
|
||||
'Парк Победы',
|
||||
'Московская',
|
||||
'Звёздная',
|
||||
'Купчино'
|
||||
'Купчино',
|
||||
]),
|
||||
green: new Set([
|
||||
'Приморская',
|
||||
@ -54,7 +54,7 @@ const stations: Record<Lines, Set<string>> = {
|
||||
'Ломоносовская',
|
||||
'Пролетарская',
|
||||
'Обухово',
|
||||
'Рыбацкое'
|
||||
'Рыбацкое',
|
||||
]),
|
||||
orange: new Set([
|
||||
'Спасская',
|
||||
@ -64,7 +64,7 @@ const stations: Record<Lines, Set<string>> = {
|
||||
'Новочеркасская',
|
||||
'Ладожская',
|
||||
'Проспект Большевиков',
|
||||
'Улица Дыбенко'
|
||||
'Улица Дыбенко',
|
||||
]),
|
||||
violet: new Set([
|
||||
'Комендантский проспект',
|
||||
@ -81,7 +81,7 @@ const stations: Record<Lines, Set<string>> = {
|
||||
'Международная',
|
||||
'Проспект славы',
|
||||
'Дунайскай',
|
||||
'Шушары'
|
||||
'Шушары',
|
||||
]),
|
||||
}
|
||||
|
||||
@ -105,5 +105,7 @@ const lineByName = (name: string) => (
|
||||
lines.find(line => stations[line].has(name))
|
||||
)
|
||||
|
||||
const DEFAULT_LINE = 'Петроградская'
|
||||
|
||||
export type { Lines }
|
||||
export { lines, stations, colors, lineNames, lineByName }
|
||||
export { lines, stations, colors, lineNames, lineByName, DEFAULT_LINE }
|
||||
|
@ -10,4 +10,4 @@
|
||||
<animate attributeName="stroke-opacity" begin="-0.9s" dur="1.8s" values="1; 0" calcMode="spline" keyTimes="0; 1" keySplines="0.3, 0.61, 0.355, 1" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
</g>
|
||||
</svg>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
4
front/src/assets/signOut.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14 20H6C4.89543 20 4 19.1046 4 18L4 6C4 4.89543 4.89543 4 6 4H14M10 12H21M21 12L18 15M21 12L18 9" stroke="rgb(185, 179, 170)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
After Width: | Height: | Size: 433 B |
73
front/src/assets/studentLocations.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import { iconDormitory, iconITMO, iconLETI } from '../utils/markerIcons'
|
||||
|
||||
type LocationType = 'dormitory' | 'leti' | 'itmo'
|
||||
|
||||
const studentLocations: {
|
||||
name: string,
|
||||
position: [number, number],
|
||||
type: LocationType
|
||||
}[] = [
|
||||
{
|
||||
name: 'Первое, второе, третье общежития',
|
||||
position: [59.987299, 30.330672],
|
||||
type: 'dormitory',
|
||||
},
|
||||
{
|
||||
name: 'Четвертое общежитие',
|
||||
position: [59.985620, 30.331319],
|
||||
type: 'dormitory',
|
||||
},
|
||||
{
|
||||
name: 'Шестое общежитие',
|
||||
position: [59.969713, 30.299851],
|
||||
type: 'dormitory',
|
||||
},
|
||||
{
|
||||
name: 'Седьмое общежитие',
|
||||
position: [60.003723, 30.287616],
|
||||
type: 'dormitory',
|
||||
},
|
||||
{
|
||||
name: 'Восьмое общежитие',
|
||||
position: [59.991115, 30.318752],
|
||||
type: 'dormitory',
|
||||
},
|
||||
{
|
||||
name: 'Общежития Межвузовского студенческого городка',
|
||||
position: [59.871053, 30.307154],
|
||||
type: 'dormitory',
|
||||
},
|
||||
{
|
||||
name: 'Одиннадцатое общежитие',
|
||||
position: [59.877962, 30.242889],
|
||||
type: 'dormitory',
|
||||
},
|
||||
{
|
||||
name: 'Общежитие Академии транспортных технологий',
|
||||
position: [59.870375, 30.308646],
|
||||
type: 'dormitory',
|
||||
},
|
||||
{
|
||||
name: 'ЛЭТИ шестой корпус',
|
||||
position: [59.971578, 30.296653],
|
||||
type: 'leti',
|
||||
},
|
||||
{
|
||||
name: 'ЛЭТИ Первый и другие корпуса',
|
||||
position: [59.971947, 30.324303],
|
||||
type: 'leti',
|
||||
},
|
||||
{
|
||||
name: 'ИТМО',
|
||||
position: [59.956363, 30.310029],
|
||||
type: 'itmo',
|
||||
},
|
||||
]
|
||||
|
||||
const locationsIcons: Record<LocationType, L.Icon> = {
|
||||
dormitory: iconDormitory,
|
||||
itmo: iconITMO,
|
||||
leti: iconLETI,
|
||||
}
|
||||
|
||||
export { studentLocations, locationsIcons }
|
@ -1,44 +1,34 @@
|
||||
import { Announcement } from '../api/announcement/types'
|
||||
import { getId } from '../utils/auth'
|
||||
import { FiltersType } from '../utils/filters'
|
||||
|
||||
const userCategories = ['givingOut', 'booked', 'history'] as const
|
||||
const userCategories = ['givingOut', 'needDispose'] as const
|
||||
|
||||
type UserCategory = typeof userCategories[number]
|
||||
|
||||
const UserCategoriesNames: Record<UserCategory, string> = {
|
||||
givingOut: 'Раздача',
|
||||
booked: 'Бронь',
|
||||
history: 'История',
|
||||
needDispose: 'Нужно утилизировать',
|
||||
}
|
||||
|
||||
const userCategoriesInfos: Record<UserCategory, (ann: Announcement) => string> = {
|
||||
givingOut: (ann: Announcement) => (
|
||||
`Годен до ${new Date(ann.bestBy).toLocaleDateString('ru')}`
|
||||
`Годен до ${ann.bestBy}`
|
||||
),
|
||||
booked: (ann: Announcement) => (
|
||||
`Бронь ещё ${(ann as Announcement & { bookedBy: number[] }).bookedBy.length} чел.`
|
||||
),
|
||||
history: (ann: Announcement) => (
|
||||
`Забрал ${new Date((ann as Announcement & { taken: number }).taken).toLocaleDateString('ru')}`
|
||||
needDispose: (ann: Announcement) => (
|
||||
`Было заинтересно ${ann.bookedBy} чел.`
|
||||
),
|
||||
}
|
||||
|
||||
const composeUserCategoriesFilters: Record<UserCategory, () => FiltersType> = {
|
||||
givingOut: () => {
|
||||
const userId = -1
|
||||
|
||||
return ({ userId })
|
||||
},
|
||||
booked: () => {
|
||||
const userId = -1
|
||||
|
||||
return ({ bookedBy: userId })
|
||||
},
|
||||
history: () => {
|
||||
const userId = -1
|
||||
|
||||
return ({ userId, status: 'taken' })
|
||||
}
|
||||
givingOut: () => ({
|
||||
userId: getId(),
|
||||
obsolete: false,
|
||||
}),
|
||||
needDispose: () => ({
|
||||
userId: getId(),
|
||||
obsolete: true,
|
||||
}),
|
||||
}
|
||||
|
||||
export type { UserCategory }
|
||||
|
@ -1 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" style="fill: rgb(17, 17, 17); --darkreader-inline-fill: #e8e6e3;" data-darkreader-inline-fill=""><path d="M7.5 6.5C7.5 8.981 9.519 11 12 11s4.5-2.019 4.5-4.5S14.481 2 12 2 7.5 4.019 7.5 6.5zM20 21h1v-1c0-3.859-3.141-7-7-7h-4c-3.86 0-7 3.141-7 7v1h17z"></path></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" style="fill: rgb(17, 17, 17); --darkreader-inline-fill: #e8e6e3;" data-darkreader-inline-fill="">
|
||||
<path d="M7.5 6.5C7.5 8.981 9.519 11 12 11s4.5-2.019 4.5-4.5S14.481 2 12 2 7.5 4.019 7.5 6.5zM20 21h1v-1c0-3.859-3.141-7-7-7h-4c-3.86 0-7 3.141-7 7v1h17z" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 348 B After Width: | Height: | Size: 350 B |
@ -1,17 +1,17 @@
|
||||
import { Modal, Button } from 'react-bootstrap'
|
||||
import { MapContainer, Marker, Popup, TileLayer } from 'react-leaflet'
|
||||
import { CSSProperties, useState } from 'react'
|
||||
import { LatLng } from 'leaflet'
|
||||
|
||||
import LineDot from './LineDot'
|
||||
import { categoryNames } from '../assets/category'
|
||||
import { useBook } from '../hooks/api'
|
||||
import { useBook, useRemoveAnnouncement } from '../hooks/api'
|
||||
import { Announcement } from '../api/announcement/types'
|
||||
import { iconItem } from '../utils/markerIcons'
|
||||
import { CSSProperties } from 'react'
|
||||
|
||||
type AnnouncementDetailsProps = {
|
||||
close: () => void,
|
||||
announcement: Announcement
|
||||
}
|
||||
import { useId } from '../hooks'
|
||||
import SelectDisposalTrashbox from './SelectDisposalTrashbox'
|
||||
import StarRating from './StarRating'
|
||||
import StudentLocations from './StudentLocations'
|
||||
|
||||
const styles = {
|
||||
container: {
|
||||
@ -19,17 +19,111 @@ const styles = {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
} as CSSProperties,
|
||||
map: {
|
||||
width: '100%',
|
||||
minHeight: 300,
|
||||
} as CSSProperties,
|
||||
}
|
||||
|
||||
function AnnouncementDetails({ close, announcement: { id, name, category, bestBy, description, lat, lng, address, metro } }: AnnouncementDetailsProps) {
|
||||
const { handleBook, status: bookStatus } = useBook(id)
|
||||
type ViewProps = {
|
||||
myId: number,
|
||||
announcement: Announcement,
|
||||
}
|
||||
|
||||
const View = ({
|
||||
myId,
|
||||
announcement: { name, category, bestBy, description, lat, lng, address, metro, userId },
|
||||
}: ViewProps) => (
|
||||
<>
|
||||
<h1>{name}</h1>
|
||||
|
||||
<span>{categoryNames[category]}</span>
|
||||
<span className='m-2'>•</span>{/* dot */}
|
||||
<span>Годен до {bestBy}</span>
|
||||
|
||||
<p className='mb-0'>{description}</p>
|
||||
|
||||
<p className='mb-3'>
|
||||
Рейтинг пользователя: <StarRating dynamic={myId !== userId} userId={userId} />
|
||||
</p>
|
||||
|
||||
<MapContainer style={styles.map} center={[lat, lng]} zoom={16} >
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url='https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'
|
||||
/>
|
||||
|
||||
<StudentLocations />
|
||||
|
||||
<Marker icon={iconItem} position={[lat, lng]}>
|
||||
<Popup>
|
||||
{address}
|
||||
<br />
|
||||
<LineDot station={metro} /> {metro}
|
||||
</Popup>
|
||||
</Marker>
|
||||
</MapContainer>
|
||||
</>
|
||||
)
|
||||
|
||||
type ControlProps = {
|
||||
myId: number,
|
||||
closeRefresh: () => void,
|
||||
announcement: Announcement,
|
||||
showDispose: () => void
|
||||
}
|
||||
|
||||
function Control({
|
||||
myId,
|
||||
closeRefresh,
|
||||
announcement: { bookedBy, id, userId },
|
||||
showDispose,
|
||||
}: ControlProps) {
|
||||
const { handleBook, bookButton } = useBook()
|
||||
|
||||
const { handleRemove, removeButton } = useRemoveAnnouncement(closeRefresh)
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>Забронировали {bookedBy + (bookButton.disabled ? 1 : 0)} чел.</p>
|
||||
{(myId === userId) ? (
|
||||
<div className='m-0'>
|
||||
<Button className='m-1' variant='success' onClick={showDispose}>Утилизировать</Button>
|
||||
<Button className='m-1' variant='success' onClick={() => void handleRemove(id)} {...removeButton} />
|
||||
</div>
|
||||
) : (
|
||||
<Button variant='success' onClick={() => void handleBook(id)} {...bookButton} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type AnnouncementDetailsProps = {
|
||||
close: () => void,
|
||||
refresh: () => void,
|
||||
announcement: Announcement,
|
||||
}
|
||||
|
||||
function AnnouncementDetails({
|
||||
close,
|
||||
refresh,
|
||||
announcement,
|
||||
}: AnnouncementDetailsProps) {
|
||||
const closeRefresh = () => {
|
||||
close()
|
||||
refresh()
|
||||
}
|
||||
|
||||
const [disposeShow, setDisposeShow] = useState(false)
|
||||
|
||||
const myId = useId()
|
||||
|
||||
return (
|
||||
<div
|
||||
className='modal'
|
||||
style={styles.container}
|
||||
>
|
||||
<Modal.Dialog style={{ minWidth: '50vw' }}>
|
||||
<Modal.Dialog centered className='modal-dialog'>
|
||||
<Modal.Header closeButton onHide={close}>
|
||||
<Modal.Title>
|
||||
Подробнее
|
||||
@ -37,36 +131,31 @@ function AnnouncementDetails({ close, announcement: { id, name, category, bestBy
|
||||
</Modal.Header>
|
||||
|
||||
<Modal.Body>
|
||||
<h1>{name}</h1>
|
||||
|
||||
<span>{categoryNames[category]}</span>
|
||||
<span className='m-2'>•</span>{/* dot */}
|
||||
<span>Годен до {new Date(bestBy).toLocaleString('ru-RU')}</span>
|
||||
|
||||
<p className='mb-3'>{description}</p>
|
||||
|
||||
<MapContainer style={{ width: '100%', minHeight: 300 }} center={[lat, lng]} zoom={16} >
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url='https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'
|
||||
/>
|
||||
|
||||
<Marker icon={iconItem} position={[lat, lng]}>
|
||||
<Popup>
|
||||
{address}
|
||||
<br />
|
||||
<LineDot station={metro} /> {metro}
|
||||
</Popup>
|
||||
</Marker>
|
||||
</MapContainer>
|
||||
<View myId={myId} announcement={announcement} />
|
||||
</Modal.Body>
|
||||
|
||||
<Modal.Footer>
|
||||
<Button variant='success' onClick={() => void handleBook()}>
|
||||
{bookStatus || 'Забронировать'}
|
||||
</Button>
|
||||
<Control
|
||||
myId={myId}
|
||||
closeRefresh={closeRefresh}
|
||||
showDispose={() => setDisposeShow(true)}
|
||||
announcement={announcement}
|
||||
/>
|
||||
</Modal.Footer>
|
||||
</Modal.Dialog>
|
||||
<Modal centered show={disposeShow} onHide={() => setDisposeShow(false)} style={{ zIndex: 100000 }}>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>
|
||||
Утилизация
|
||||
</Modal.Title>
|
||||
</Modal.Header>
|
||||
<SelectDisposalTrashbox
|
||||
annId={announcement.id}
|
||||
category={announcement.category}
|
||||
address={new LatLng(announcement.lat, announcement.lng)}
|
||||
closeRefresh={closeRefresh}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -1,54 +1,76 @@
|
||||
import { FormEventHandler } from 'react'
|
||||
import { Button, Form } from 'react-bootstrap'
|
||||
import { FormEventHandler, useCallback } from 'react'
|
||||
import { Button, ButtonGroup, Form } from 'react-bootstrap'
|
||||
|
||||
import { useSignIn, useSignUp } from '../hooks/api'
|
||||
import { composeSignUpBody } from '../api/signup'
|
||||
|
||||
type AuthFormProps = {
|
||||
register: boolean
|
||||
handleAuth: FormEventHandler<HTMLFormElement>,
|
||||
loading: boolean,
|
||||
error: string
|
||||
goBack: () => void,
|
||||
}
|
||||
|
||||
function AuthForm ({ handleAuth, register, loading, error }: AuthFormProps) {
|
||||
const buttonText = loading ? 'Загрузка...' : (error || (register ? 'Зарегистрироваться' : 'Войти'))
|
||||
const AuthForm = ({ goBack }: AuthFormProps) => {
|
||||
const { handleSignUp, signUpButton } = useSignUp()
|
||||
|
||||
const { handleSignIn, signInButton } = useSignIn()
|
||||
|
||||
const handleAuth: FormEventHandler<HTMLFormElement> = useCallback((e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
|
||||
const register = (e.nativeEvent as SubmitEvent).submitter?.id === 'register'
|
||||
|
||||
void (async () => {
|
||||
const accountCreated = register ? (
|
||||
await handleSignUp(composeSignUpBody(formData))
|
||||
) : true
|
||||
|
||||
if (accountCreated) {
|
||||
if (await handleSignIn(formData)) {
|
||||
goBack()
|
||||
}
|
||||
}
|
||||
|
||||
})()
|
||||
}, [goBack, handleSignUp, handleSignIn])
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleAuth}>
|
||||
<Form.Group className='mb-3' controlId='email'>
|
||||
<Form.Label>Почта</Form.Label>
|
||||
<Form.Control type='email' required />
|
||||
<Form.Group className='mb-3' controlId='username'>
|
||||
<Form.Label>Как меня называть</Form.Label>
|
||||
<Form.Control placeholder='Имя или псевдоним' name='username' type='text' required />
|
||||
</Form.Group>
|
||||
|
||||
{register && <>
|
||||
<Form.Group className='mb-3' controlId='name'>
|
||||
<Form.Label>Имя</Form.Label>
|
||||
<Form.Control type='text' required />
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group className='mb-3' controlId='surname'>
|
||||
<Form.Label>Фамилия</Form.Label>
|
||||
<Form.Control type='text' required />
|
||||
</Form.Group>
|
||||
</>}
|
||||
|
||||
<Form.Group className='mb-3' controlId='password'>
|
||||
<Form.Label>Пароль</Form.Label>
|
||||
<Form.Control type='password' required />
|
||||
<Form.Label>И я могу доказать, что это я</Form.Label>
|
||||
<Form.Control placeholder='Пароль' name='password' type='password' required />
|
||||
</Form.Group>
|
||||
|
||||
{register &&
|
||||
<Form.Group className='mb-3' controlId='privacyPolicyConsent'>
|
||||
<Form.Check>
|
||||
<Form.Check.Input type='checkbox' required />
|
||||
<Form.Check.Label>
|
||||
Я согласен с <a href={`${document.location.origin}/privacy_policy.pdf`} target='_blank' rel='noopener noreferrer'>условиями обработки персональных данных</a>
|
||||
</Form.Check.Label>
|
||||
</Form.Check>
|
||||
</Form.Group>
|
||||
}
|
||||
<p>
|
||||
Нажимая на кнопку, я даю своё согласие на обработку персональных данных и соглашаюсь с{' '}
|
||||
<a
|
||||
href={`${document.location.origin}/privacy_policy.pdf`}
|
||||
target='_blank'
|
||||
>условиями политики конфиденциальности</a>
|
||||
</p>
|
||||
|
||||
<Button variant='success' type='submit'>
|
||||
{buttonText}
|
||||
</Button>
|
||||
<ButtonGroup className='d-flex'>
|
||||
<Button
|
||||
className='w-100'
|
||||
id='register'
|
||||
variant='success'
|
||||
type='submit'
|
||||
{...signUpButton}
|
||||
/>
|
||||
<Button
|
||||
className='w-100'
|
||||
id='login'
|
||||
variant='success'
|
||||
type='submit'
|
||||
{...signInButton}
|
||||
/>
|
||||
</ButtonGroup>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
@ -1,23 +1,25 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Navbar } from 'react-bootstrap'
|
||||
import { PropsWithChildren } from 'react'
|
||||
|
||||
import BackButton from '../assets/backArrow.svg'
|
||||
|
||||
type BackHeaderProps = {
|
||||
text: string
|
||||
text: string,
|
||||
}
|
||||
|
||||
function BackHeader({ text }: BackHeaderProps) {
|
||||
function BackHeader({ text, children }: PropsWithChildren<BackHeaderProps>) {
|
||||
return (
|
||||
<Navbar>
|
||||
<Navbar.Brand as={Link} to='/'>
|
||||
<img src={BackButton} alt='Go back' />
|
||||
<img src={BackButton} alt='Назад' />
|
||||
</Navbar.Brand>
|
||||
<Navbar.Text className='me-auto'>
|
||||
<h4 className='mb-0'>
|
||||
{text}
|
||||
</h4>
|
||||
</Navbar.Text>
|
||||
{children}
|
||||
</Navbar>
|
||||
)
|
||||
}
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { CSSProperties } from 'react'
|
||||
|
||||
import addIcon from '../assets/addIcon.svg'
|
||||
import filterIcon from '../assets/filterIcon.svg'
|
||||
import userIcon from '../assets/userIcon.svg'
|
||||
import { CSSProperties } from 'react'
|
||||
|
||||
const styles = {
|
||||
navBar: {
|
||||
@ -15,22 +15,20 @@ const styles = {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
height: '100%',
|
||||
margin: 'auto'
|
||||
margin: 'auto',
|
||||
} as CSSProperties,
|
||||
navBarElement: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
justifyContent: 'center',
|
||||
} as CSSProperties,
|
||||
}
|
||||
|
||||
|
||||
|
||||
type BottomNavBarProps = {
|
||||
width: number,
|
||||
toggleFilters: (p: boolean) => void
|
||||
toggleFilters: (state: boolean) => void,
|
||||
}
|
||||
|
||||
function BottomNavBar({ width, toggleFilters }: BottomNavBarProps) {
|
||||
@ -38,7 +36,7 @@ function BottomNavBar({ width, toggleFilters }: BottomNavBarProps) {
|
||||
<div style={styles.navBar}>
|
||||
<div style={{ ...styles.navBarGroup, width: width }}>
|
||||
|
||||
<a style={styles.navBarElement} onClick={() => toggleFilters(true)}>
|
||||
<a href='#' style={styles.navBarElement} onClick={() => toggleFilters(true)}>
|
||||
<img src={filterIcon} alt='Фильтровать объявления' title='Фильтровать объявления' />
|
||||
</a>
|
||||
|
||||
@ -46,7 +44,7 @@ function BottomNavBar({ width, toggleFilters }: BottomNavBarProps) {
|
||||
<img src={addIcon} alt='Опубликовать объявление' title='Опубликовать объявление' />
|
||||
</Link>
|
||||
|
||||
<Link style={styles.navBarElement} to={'/user'} >
|
||||
<Link style={styles.navBarElement} to='/user' >
|
||||
<img src={userIcon} alt='Личный кабинет' title='Личный кабинет' />
|
||||
</Link>
|
||||
|
||||
|
23
front/src/components/CardLayout.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { PropsWithChildren } from 'react'
|
||||
import { Card } from 'react-bootstrap'
|
||||
|
||||
import BackHeader from './BackHeader'
|
||||
|
||||
type CardLayoutProps = {
|
||||
text: string,
|
||||
}
|
||||
|
||||
const CardLayout = ({ text, children }: PropsWithChildren<CardLayoutProps>) => (
|
||||
<>
|
||||
<div className='mx-4 px-3'>
|
||||
<BackHeader text={text} />
|
||||
</div>
|
||||
<Card className='m-4 mt-0'>
|
||||
<Card.Body>
|
||||
{children}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</>
|
||||
)
|
||||
|
||||
export default CardLayout
|
@ -1,10 +1,10 @@
|
||||
import { StoriesPreview } from '.'
|
||||
import StoriesPreview from './StoriesPreview'
|
||||
import { UserCategoriesNames, UserCategory, composeUserCategoriesFilters } from '../assets/userCategories'
|
||||
import { useAnnouncements } from '../hooks/api'
|
||||
import { gotError } from '../hooks/useFetch'
|
||||
import { gotError, gotResponse } from '../hooks/useFetch'
|
||||
|
||||
type CategoryPreviewProps = {
|
||||
category: UserCategory
|
||||
category: UserCategory,
|
||||
}
|
||||
|
||||
function CategoryPreview({ category }: CategoryPreviewProps) {
|
||||
@ -15,8 +15,12 @@ function CategoryPreview({ category }: CategoryPreviewProps) {
|
||||
<h4 className='fw-bold'>{UserCategoriesNames[category]}</h4>
|
||||
{gotError(announcements) ? (
|
||||
<p className='text-danger'>{announcements.error}</p>
|
||||
) : (announcements.loading ? 'Загрузка...' :
|
||||
<StoriesPreview announcements={announcements.data} category={category} />
|
||||
) : (
|
||||
gotResponse(announcements) ? (
|
||||
<StoriesPreview announcements={announcements.data} category={category} />
|
||||
) : (
|
||||
'Загрузка...'
|
||||
)
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
|
@ -10,21 +10,21 @@ type FiltersProps = {
|
||||
filter: FiltersType,
|
||||
setFilter: SetState<FiltersType>,
|
||||
filterShown: boolean,
|
||||
setFilterShown: SetState<boolean>
|
||||
setFilterShown: SetState<boolean>,
|
||||
}
|
||||
|
||||
function Filters({ filter, setFilter, filterShown, setFilterShown }: FiltersProps) {
|
||||
|
||||
const handleSubmit: FormEventHandler<HTMLFormElement> = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const formData = new FormData(event.currentTarget)
|
||||
|
||||
setFilter(prev => ({
|
||||
...prev,
|
||||
category: (formData.get('category') as (FiltersType['category'] | null)) || undefined,
|
||||
metro: (formData.get('metro') as (FiltersType['metro'] | null)) || undefined
|
||||
metro: (formData.get('metro') as (FiltersType['metro'] | null)) || undefined,
|
||||
}))
|
||||
|
||||
setFilterShown(false)
|
||||
@ -77,7 +77,7 @@ function Filters({ filter, setFilter, filterShown, setFilterShown }: FiltersProp
|
||||
</Form.Group>
|
||||
|
||||
<Button variant='success' type='submit'>
|
||||
Отправить
|
||||
Выбрать
|
||||
</Button>
|
||||
</Form>
|
||||
</Modal.Body>
|
||||
|
@ -3,8 +3,9 @@ import { colors, lineNames, lineByName } from '../assets/metro'
|
||||
function LineDot({ station }: { station: string }) {
|
||||
const line = lineByName(station)
|
||||
|
||||
if (line == undefined)
|
||||
if (line === undefined) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
const lineTitle = lineNames[line]
|
||||
const color = colors[line]
|
||||
|
38
front/src/components/LocateButton.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import { MouseEventHandler } from 'react'
|
||||
import { useMapEvent } from 'react-leaflet'
|
||||
import { LatLng } from 'leaflet'
|
||||
import Control from 'react-leaflet-custom-control'
|
||||
|
||||
import locateIcon from '../assets/locate.svg'
|
||||
|
||||
import styles from '../styles/Map.module.css'
|
||||
import { SetState } from '../utils/types'
|
||||
type LocaleButtonProps = {
|
||||
setPosition: SetState<LatLng>
|
||||
}
|
||||
|
||||
function LocateButton({ setPosition }: LocaleButtonProps) {
|
||||
const map = useMapEvent('locationfound', (e) => {
|
||||
setPosition(e.latlng)
|
||||
map.flyTo(e.latlng)
|
||||
})
|
||||
|
||||
const handleLocale: MouseEventHandler<HTMLAnchorElement> = (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
map.locate()
|
||||
}
|
||||
|
||||
return (
|
||||
<Control position='topleft'>
|
||||
<div className='leaflet-bar'>
|
||||
<a href='#' role='button' onClick={handleLocale}>
|
||||
<img className={styles.localeIcon} src={locateIcon} alt='locate' />
|
||||
</a>
|
||||
</div>
|
||||
</Control>
|
||||
)
|
||||
}
|
||||
|
||||
export default LocateButton
|
@ -7,11 +7,10 @@ import { iconItem } from '../utils/markerIcons'
|
||||
type LocationMarkerProps = {
|
||||
address: string,
|
||||
position: LatLng,
|
||||
setPosition: SetState<LatLng>
|
||||
setPosition: SetState<LatLng>,
|
||||
}
|
||||
|
||||
function LocationMarker({ address, position, setPosition }: LocationMarkerProps) {
|
||||
|
||||
const map = useMapEvents({
|
||||
dragend: () => {
|
||||
setPosition(map.getCenter())
|
||||
@ -21,13 +20,14 @@ function LocationMarker({ address, position, setPosition }: LocationMarkerProps)
|
||||
},
|
||||
resize: () => {
|
||||
setPosition(map.getCenter())
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Marker icon={iconItem} position={position}>
|
||||
<Marker icon={iconItem} position={position} zIndexOffset={1000}>
|
||||
<Popup>
|
||||
{address}
|
||||
<br />
|
||||
{position.lat.toFixed(4)}, {position.lng.toFixed(4)}
|
||||
</Popup>
|
||||
</Marker>
|
||||
|
@ -3,7 +3,7 @@ import { LatLng } from 'leaflet'
|
||||
|
||||
import { SetState } from '../utils/types'
|
||||
|
||||
function ClickHandler({ setPosition }: { setPosition: SetState<LatLng> }) {
|
||||
function MapClickHandler({ setPosition }: { setPosition: SetState<LatLng> }) {
|
||||
const map = useMapEvent('click', (e) => {
|
||||
setPosition(e.latlng)
|
||||
map.setView(e.latlng)
|
||||
@ -12,4 +12,4 @@ function ClickHandler({ setPosition }: { setPosition: SetState<LatLng> }) {
|
||||
return null
|
||||
}
|
||||
|
||||
export default ClickHandler
|
||||
export default MapClickHandler
|
41
front/src/components/Poetry.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import { usePoetry } from '../hooks/api'
|
||||
import { gotError, gotResponse } from '../hooks/useFetch'
|
||||
|
||||
import styles from '../styles/Poetry.module.css'
|
||||
|
||||
function Poetry() {
|
||||
const poetry = usePoetry()
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h4 className='fw-bold'>Поэзия</h4> {
|
||||
gotResponse(poetry) ? (
|
||||
gotError(poetry) ? (
|
||||
<div className='text-danger'>
|
||||
<h5>Ошибка получения стиха</h5>
|
||||
<p>{poetry.error}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h5>{poetry.data.title}</h5>
|
||||
<p
|
||||
className={styles.text}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
poetry.data.text.trim().replace(/(\n){3,}/g, '\n\n'),
|
||||
}}
|
||||
/>
|
||||
<p><em>{poetry.data.author}</em></p>
|
||||
<img className={styles.image} src={`/poem_pic/${poetry.data.id}.jpg`} alt='Иллюстрация' />
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<h5>Загрузка...</h5>
|
||||
)
|
||||
}
|
||||
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default Poetry
|
35
front/src/components/Points.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import { CSSProperties } from 'react'
|
||||
|
||||
import handStarsIcon from '../assets/handStars.svg'
|
||||
|
||||
type PointsProps = {
|
||||
points: number | string,
|
||||
}
|
||||
|
||||
const styles = {
|
||||
points: {
|
||||
float: 'right',
|
||||
} as CSSProperties,
|
||||
icon: {
|
||||
height: 24,
|
||||
paddingBottom: 5,
|
||||
marginRight: 5,
|
||||
} as CSSProperties,
|
||||
}
|
||||
|
||||
function Points({ points }: PointsProps) {
|
||||
return (
|
||||
<h5>
|
||||
Набрано очков:
|
||||
<span style={styles.points}>
|
||||
<img
|
||||
style={styles.icon}
|
||||
src={handStarsIcon}
|
||||
alt='Иконка руки, дающей звёзды' />
|
||||
{points}
|
||||
</span>
|
||||
</h5>
|
||||
)
|
||||
}
|
||||
|
||||
export default Points
|
120
front/src/components/SelectDisposalTrashbox.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
import { Button, Modal } from 'react-bootstrap'
|
||||
import { MapContainer, TileLayer } from 'react-leaflet'
|
||||
import { CSSProperties, useState } from 'react'
|
||||
import { LatLng } from 'leaflet'
|
||||
|
||||
import { useDispose, useTrashboxes } from '../hooks/api'
|
||||
import { UseFetchReturn, gotError, gotResponse } from '../hooks/useFetch'
|
||||
import TrashboxMarkers from './TrashboxMarkers'
|
||||
import { Category } from '../assets/category'
|
||||
import { Trashbox } from '../api/trashbox/types'
|
||||
|
||||
type SelectDisposalTrashboxProps = {
|
||||
annId: number,
|
||||
category: Category,
|
||||
address: LatLng,
|
||||
closeRefresh: () => void,
|
||||
}
|
||||
|
||||
type SelectedTrashbox = {
|
||||
index: number,
|
||||
category: string,
|
||||
}
|
||||
|
||||
const styles = {
|
||||
map: {
|
||||
width: '100%',
|
||||
height: 400,
|
||||
} as CSSProperties,
|
||||
}
|
||||
|
||||
function SelectDisposalTrashbox({ annId, category, address, closeRefresh }: SelectDisposalTrashboxProps) {
|
||||
const trashboxes = useTrashboxes(address, category)
|
||||
|
||||
const [selectedTrashbox, setSelectedTrashbox] = useState<SelectedTrashbox>({ index: -1, category: '' })
|
||||
|
||||
const { handleDispose, disposeButton } = useDispose(closeRefresh)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal.Body>
|
||||
<div className='mb-3'>
|
||||
{gotResponse(trashboxes)
|
||||
? (
|
||||
gotError(trashboxes) ? (
|
||||
<p
|
||||
style={styles.map}
|
||||
className='text-danger'
|
||||
>{trashboxes.error}</p>
|
||||
) : (
|
||||
<MapContainer
|
||||
scrollWheelZoom={false}
|
||||
style={styles.map}
|
||||
center={address}
|
||||
zoom={13}
|
||||
className=''
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url='https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'
|
||||
/>
|
||||
<TrashboxMarkers
|
||||
trashboxes={trashboxes.data}
|
||||
selectTrashbox={setSelectedTrashbox}
|
||||
/>
|
||||
</MapContainer>
|
||||
)
|
||||
) : (
|
||||
<div style={styles.map}>
|
||||
<p>Загрузка...</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
}
|
||||
</div>
|
||||
<DisplaySelected trashboxes={trashboxes} selectedTrashbox={selectedTrashbox} />
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button
|
||||
{...disposeButton}
|
||||
disabled={disposeButton.disabled || gotError(trashboxes) || !gotResponse(trashboxes) || selectedTrashbox.index < 0}
|
||||
variant='success'
|
||||
onClick={() => {
|
||||
if (gotResponse(trashboxes) && !gotError(trashboxes)) {
|
||||
const { Lat, Lng, Name, Address } = trashboxes.data[selectedTrashbox.index]
|
||||
void handleDispose(annId, {
|
||||
Category: selectedTrashbox.category,
|
||||
Lat,
|
||||
Lng,
|
||||
Name,
|
||||
Address,
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Modal.Footer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type DisplaySelectedProps = {
|
||||
trashboxes: UseFetchReturn<Trashbox[]>,
|
||||
selectedTrashbox: SelectedTrashbox,
|
||||
}
|
||||
|
||||
function DisplaySelected({ trashboxes, selectedTrashbox }: DisplaySelectedProps) {
|
||||
if (gotResponse(trashboxes) && !gotError(trashboxes) && selectedTrashbox.index > -1) {
|
||||
return (
|
||||
<>
|
||||
<p className='mb-0'>Выбран пункт сбора мусора на {trashboxes.data[selectedTrashbox.index].Address}</p>
|
||||
<p className='mb-0'>с категорией "{selectedTrashbox.category}"</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<p className='mb-0'>Выберите пункт сбора мусора и категорию</p>
|
||||
)
|
||||
}
|
||||
|
||||
export default SelectDisposalTrashbox
|
22
front/src/components/SignOut.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { Navbar } from 'react-bootstrap'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { CSSProperties } from 'react'
|
||||
|
||||
import { clearToken } from '../utils/auth'
|
||||
|
||||
import signOutIcon from '../assets/signOut.svg'
|
||||
|
||||
const styles = {
|
||||
rightIcon: {
|
||||
marginLeft: '1rem',
|
||||
marginRight: 0,
|
||||
} as CSSProperties,
|
||||
}
|
||||
|
||||
const SignOut = () => (
|
||||
<Navbar.Brand style={styles.rightIcon} as={Link} to='/'>
|
||||
<img onClick={clearToken} src={signOutIcon} alt='Выйти' />
|
||||
</Navbar.Brand>
|
||||
)
|
||||
|
||||
export default SignOut
|
93
front/src/components/StarRating.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import { useSendRate, useUserRating } from '../hooks/api'
|
||||
import { gotError, gotResponse } from '../hooks/useFetch'
|
||||
|
||||
import styles from '../styles/StarRating.module.css'
|
||||
|
||||
type StarProps = {
|
||||
filled: boolean,
|
||||
selected: boolean,
|
||||
selectRate?: () => void,
|
||||
sendMyRate?: () => void,
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
function Star({ filled, selected, selectRate, disabled }: StarProps) {
|
||||
return (
|
||||
<button
|
||||
className={`${styles.star} ${filled ? styles.starFilled : ''} ${selected ? styles.starSelected : ''}`}
|
||||
onMouseEnter={selectRate}
|
||||
onFocus={selectRate}
|
||||
disabled={disabled}
|
||||
>★</button> // star
|
||||
)
|
||||
}
|
||||
|
||||
type StarRatingProps = {
|
||||
userId: number,
|
||||
dynamic?: boolean,
|
||||
}
|
||||
|
||||
function StarRating({ userId, dynamic = false }: StarRatingProps) {
|
||||
const rating = useUserRating(userId)
|
||||
|
||||
const [selectedRate, setSelectedRate] = useState(0)
|
||||
const [myRate, setMyRate] = useState(0)
|
||||
const rated = myRate > 0
|
||||
|
||||
const { doSendRate } = useSendRate()
|
||||
|
||||
async function sendMyRate() {
|
||||
const res = await doSendRate(selectedRate, userId)
|
||||
|
||||
if (res) {
|
||||
rating.refetch()
|
||||
setMyRate(selectedRate)
|
||||
}
|
||||
}
|
||||
|
||||
if (!gotResponse(rating)) {
|
||||
return (
|
||||
<span>Загрузка...</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (gotError(rating)) {
|
||||
return (
|
||||
<span className='text-danger'>{rating.error}</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={styles.starContainer}
|
||||
|
||||
onClick={() => dynamic && !rated && void sendMyRate()}
|
||||
|
||||
onMouseEnter={() => rated && setSelectedRate(myRate)}
|
||||
onMouseLeave={() => setSelectedRate(0)}
|
||||
|
||||
onFocus={() => rated && setSelectedRate(myRate)}
|
||||
onBlur={() => setSelectedRate(0)}
|
||||
|
||||
onTouchStart={() => rated && setSelectedRate(myRate)}
|
||||
onTouchEnd={() => setSelectedRate(0)}
|
||||
|
||||
title={`Пользователи: ${Math.round(rating.data)}\nВы: ${myRate}`}
|
||||
tabIndex={0}
|
||||
>
|
||||
{...Array(5).fill(5).map((_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
filled={i < Math.round(rating.data)}
|
||||
selected={i < selectedRate}
|
||||
selectRate={() => dynamic && !rated && setSelectedRate(i + 1)}
|
||||
disabled={!dynamic || rated}
|
||||
/>
|
||||
))}
|
||||
</span >
|
||||
)
|
||||
}
|
||||
|
||||
export default StarRating
|
@ -1,6 +1,5 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { CSSProperties, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Button } from 'react-bootstrap'
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
|
||||
import { UserCategory, composeUserCategoriesFilters, userCategoriesInfos } from '../assets/userCategories'
|
||||
import { Announcement } from '../api/announcement/types'
|
||||
@ -9,62 +8,52 @@ import { URLEncodeFilters } from '../utils/filters'
|
||||
|
||||
import rightAngleIcon from '../assets/rightAngle.svg'
|
||||
|
||||
import styles from '../styles/StoriesPreview.module.css'
|
||||
|
||||
type StoriesPreviewProps = {
|
||||
announcements: Announcement[],
|
||||
category: UserCategory,
|
||||
}
|
||||
|
||||
const styles = {
|
||||
container: {
|
||||
transform: 'translateX(0)',
|
||||
} as CSSProperties,
|
||||
ul: {
|
||||
display: 'flex',
|
||||
gap: 10,
|
||||
listStyleType: 'none',
|
||||
overflow: 'scroll',
|
||||
paddingLeft: 0,
|
||||
scrollBehavior: 'smooth',
|
||||
} as CSSProperties,
|
||||
link: {
|
||||
textDecoration: 'none',
|
||||
color: 'var(--bs-body-color)'
|
||||
} as CSSProperties,
|
||||
image: {
|
||||
height: '25vh',
|
||||
maxWidth: 'calc(25vh * 9 / 16)',
|
||||
objectFit: 'contain',
|
||||
borderRadius: 12,
|
||||
marginBottom: 5,
|
||||
} as CSSProperties,
|
||||
title: {
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
marginBottom: 5,
|
||||
} as CSSProperties,
|
||||
scrollButton: {
|
||||
position: 'fixed',
|
||||
right: 0,
|
||||
top: 0,
|
||||
zIndex: 100,
|
||||
background: 'linear-gradient(to right, rgba(17, 17, 17, 0) 0%, rgba(17, 17, 17, 255) 100%)',
|
||||
display: 'block',
|
||||
height: '100%',
|
||||
width: '10%',
|
||||
border: 'none',
|
||||
cursor: 'default',
|
||||
borderRadius: 0,
|
||||
} as CSSProperties,
|
||||
leftScrollButton: {
|
||||
left: 0,
|
||||
transform: 'scaleX(-1)'
|
||||
} as CSSProperties,
|
||||
rightScrollButton: {
|
||||
right: 0,
|
||||
} as CSSProperties,
|
||||
}
|
||||
const StoriesPreview = ({ announcements, category }: StoriesPreviewProps) => (
|
||||
announcements.length > 0 ? (
|
||||
announcements.map((ann, i) => (
|
||||
<li key={`${category}${i}`}>
|
||||
<Link to={'/?' + new URLSearchParams({
|
||||
...URLEncodeFilters(composeUserCategoriesFilters[category]()),
|
||||
storyIndex: i.toString(),
|
||||
}).toString()} className={styles.link}>
|
||||
{ann.src?.endsWith('mp4') ? (
|
||||
<video src={ann.src} className={styles.image} />
|
||||
) : (
|
||||
<img
|
||||
src={ann.src || categoryGraphics[ann.category]}
|
||||
alt={'Изображение' + (ann.src ? 'предмета' : categoryNames[ann.category])}
|
||||
className={styles.image}
|
||||
/>
|
||||
)}
|
||||
<p className={styles.title}>{ann.name}</p>
|
||||
<p className={styles.title}>{userCategoriesInfos[category](ann)}</p>
|
||||
</Link>
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
<li>
|
||||
<Link to={'/?' + new URLSearchParams({
|
||||
...URLEncodeFilters(composeUserCategoriesFilters[category]()),
|
||||
storyIndex: '0',
|
||||
}).toString()} className={styles.link}>
|
||||
<img
|
||||
src='/static/empty.png'
|
||||
alt='Здесь ничего нет'
|
||||
className={styles.image}
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
)
|
||||
|
||||
function StoriesPreview({ announcements, category }: StoriesPreviewProps) {
|
||||
function StoriesPreviewCarousel({ announcements, category }: StoriesPreviewProps) {
|
||||
const ulElement = useRef<HTMLUListElement | null>(null)
|
||||
const [showScrollButtons, setShowScrollButtons] = useState({ left: false, right: false })
|
||||
|
||||
@ -88,7 +77,7 @@ function StoriesPreview({ announcements, category }: StoriesPreviewProps) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
const ul = ulElement.current
|
||||
|
||||
if (ul) {
|
||||
@ -104,40 +93,23 @@ function StoriesPreview({ announcements, category }: StoriesPreviewProps) {
|
||||
}
|
||||
}
|
||||
|
||||
return <div style={styles.container}>
|
||||
return <div className={styles.container}>
|
||||
{showScrollButtons.left &&
|
||||
<Button onClick={doScroll(false)} style={{ ...styles.scrollButton, ...styles.leftScrollButton }}>
|
||||
<button onClick={doScroll(false)} className={`${styles.scrollButton} ${styles.leftScrollButton}`}>
|
||||
<img src={rightAngleIcon} alt='Показать ещё' />
|
||||
</Button>
|
||||
</button>
|
||||
}
|
||||
<ul style={styles.ul} className='StoriesPreview_ul' ref={ulElement}>
|
||||
{useMemo(() => announcements.map((ann, i) => (
|
||||
<li key={`${category}${i}`}>
|
||||
<Link to={'/?' + new URLSearchParams({
|
||||
...URLEncodeFilters(composeUserCategoriesFilters[category]()),
|
||||
storyIndex: i.toString()
|
||||
}).toString()} style={styles.link}>
|
||||
{ann.src?.endsWith('mp4') ? (
|
||||
<video src={ann.src} style={styles.image} />
|
||||
) : (
|
||||
<img
|
||||
src={ann.src || categoryGraphics[ann.category]}
|
||||
alt={'Изображение' + (ann.src ? 'предмета' : categoryNames[ann.category])}
|
||||
style={styles.image}
|
||||
/>
|
||||
)}
|
||||
<p style={styles.title}>{ann.name}</p>
|
||||
<p style={styles.title}>{userCategoriesInfos[category](ann)}</p>
|
||||
</Link>
|
||||
</li>
|
||||
)), [announcements, category])}
|
||||
|
||||
<ul className={styles.list} ref={ulElement}>
|
||||
<StoriesPreview announcements={announcements} category={category} />
|
||||
</ul>
|
||||
|
||||
{showScrollButtons.right &&
|
||||
<Button onClick={doScroll(true)} style={{ ...styles.scrollButton, ...styles.rightScrollButton }}>
|
||||
<button onClick={doScroll(true)} className={`${styles.scrollButton} ${styles.rightScrollButton}`}>
|
||||
<img src={rightAngleIcon} alt='Показать ещё' />
|
||||
</Button>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
export default StoriesPreview
|
||||
export default StoriesPreviewCarousel
|
36
front/src/components/StudentLocations.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { Marker, Tooltip, useMap } from 'react-leaflet'
|
||||
import { LatLng } from 'leaflet'
|
||||
|
||||
import { locationsIcons, studentLocations } from '../assets/studentLocations'
|
||||
|
||||
type StudentLocationsProps = {
|
||||
setPosition?: (pos: LatLng) => void
|
||||
}
|
||||
|
||||
function StudentLocations({ setPosition }: StudentLocationsProps) {
|
||||
const map = useMap()
|
||||
|
||||
return (
|
||||
<>{
|
||||
studentLocations.map((el) =>
|
||||
<Marker
|
||||
icon={locationsIcons[el.type]}
|
||||
position={el.position}
|
||||
eventHandlers={{
|
||||
click: setPosition && (() => {
|
||||
setPosition(new LatLng(...el.position))
|
||||
map.setView(el.position)
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<Tooltip>
|
||||
{el.name}
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
)
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default StudentLocations
|
@ -5,30 +5,38 @@ import { iconTrash } from '../utils/markerIcons'
|
||||
|
||||
type TrashboxMarkersProps = {
|
||||
trashboxes: Trashbox[],
|
||||
selectTrashbox: ({ index, category }: { index: number, category: string }) => void
|
||||
selectTrashbox: ({ index, category }: {
|
||||
index: number,
|
||||
category: string,
|
||||
}) => void,
|
||||
}
|
||||
|
||||
function TrashboxMarkers({ trashboxes, selectTrashbox }: TrashboxMarkersProps) {
|
||||
return (
|
||||
<>{trashboxes.map((trashbox, index) => (
|
||||
<Marker icon={iconTrash} key={`${trashbox.Lat}${trashbox.Lng}`} position={[trashbox.Lat, trashbox.Lng]}>
|
||||
<Popup>
|
||||
<p>{trashbox.Address}</p>
|
||||
<p>Тип мусора: <>
|
||||
{trashbox.Categories.map((category, j) =>
|
||||
<span key={trashbox.Address + category}>
|
||||
<a href='#' onClick={() => selectTrashbox({ index, category })}>
|
||||
{category}
|
||||
</a>
|
||||
{(j < trashbox.Categories.length - 1) ? ', ' : ''}
|
||||
</span>
|
||||
)}
|
||||
</></p>
|
||||
<p>{trashbox.Lat.toFixed(4)}, {trashbox.Lng.toFixed(4)}</p>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}</>
|
||||
)
|
||||
}
|
||||
const TrashboxMarkers = ({ trashboxes, selectTrashbox }: TrashboxMarkersProps) => (
|
||||
<>{trashboxes.map((trashbox, index) => (
|
||||
<Marker icon={iconTrash} key={`${trashbox.Lat}${trashbox.Lng}`} position={[trashbox.Lat, trashbox.Lng]}>
|
||||
<Popup>
|
||||
<p className='fw-bold m-0'>{trashbox.Name}</p>
|
||||
<p className='m-0'>{trashbox.Address}</p>
|
||||
<p>Тип мусора:{' '}
|
||||
{trashbox.Categories.map((category, j) =>
|
||||
<span key={trashbox.Address + category}>
|
||||
<a href='#' onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
selectTrashbox({ index, category })
|
||||
}}>
|
||||
{category}
|
||||
</a>
|
||||
{(j < trashbox.Categories.length - 1) ? ', ' : ''}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className='m-0'>
|
||||
{trashbox.Lat.toFixed(4)}, {trashbox.Lng.toFixed(4)}
|
||||
</p>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}</>
|
||||
)
|
||||
|
||||
export default TrashboxMarkers
|
||||
|
@ -5,8 +5,16 @@ export { default as LineDot } from './LineDot'
|
||||
export { default as LocationMarker } from './LocationMarker'
|
||||
export { default as TrashboxMarkers } from './TrashboxMarkers'
|
||||
export { default as WithToken } from './WithToken'
|
||||
export { default as ClickHandler } from './ClickHandler'
|
||||
export { default as MapClickHandler } from './MapClickHandler'
|
||||
export { default as AuthForm } from './AuthForm'
|
||||
export { default as BackHeader } from './BackHeader'
|
||||
export { default as CategoryPreview } from './CategoryPreview'
|
||||
export { default as StoriesPreview } from './StoriesPreview'
|
||||
export { default as Points } from './Points'
|
||||
export { default as SignOut } from './SignOut'
|
||||
export { default as Poetry } from './Poetry'
|
||||
export { default as SelectDisposalTrashbox } from './SelectDisposalTrashbox'
|
||||
export { default as StarRating } from './StarRating'
|
||||
export { default as CardLayout } from './CardLayout'
|
||||
export { default as LocateButton } from './LocateButton'
|
||||
export { default as StudentLocations } from './StudentLocations'
|
||||
|
@ -1,7 +1,13 @@
|
||||
export { default as useAnnouncements } from './useAnnouncements'
|
||||
export { default as useBook } from './useBook'
|
||||
export { default as useAuth } from './useAuth'
|
||||
export { default as useTrashboxes } from './useTrashboxes'
|
||||
export { default as useAddAnnouncement } from './useAddAnnouncement'
|
||||
export { default as useOsmAddresses } from './useOsmAddress'
|
||||
export { default as useUser } from './useUser'
|
||||
export { default as useRemoveAnnouncement } from './useRemoveAnnouncement'
|
||||
export { default as useSignIn } from './useSignIn'
|
||||
export { default as useSignUp } from './useSignUp'
|
||||
export { default as usePoetry } from './usePoetry'
|
||||
export { default as useDispose } from './useDispose'
|
||||
export { default as useSendRate } from './useSendRate'
|
||||
export { default as useUserRating } from './useUserRating'
|
||||
|
@ -1,31 +1,26 @@
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { useSend } from '..'
|
||||
import useSendWithButton from '../useSendWithButton'
|
||||
import { composePutAnnouncementURL, processPutAnnouncement } from '../../api/putAnnouncement'
|
||||
import { isPutAnnouncementResponse } from '../../api/putAnnouncement/types'
|
||||
import useSendButtonCaption from '../useSendButtonCaption'
|
||||
|
||||
const useAddAnnouncement = () => {
|
||||
const { doSend, loading, error } = useSend(
|
||||
function useAddAnnouncement() {
|
||||
const { doSend, button } = useSendWithButton(
|
||||
'Опубликовать',
|
||||
'Опубликовано',
|
||||
true,
|
||||
composePutAnnouncementURL(),
|
||||
'PUT',
|
||||
true,
|
||||
isPutAnnouncementResponse,
|
||||
processPutAnnouncement,
|
||||
processPutAnnouncement
|
||||
)
|
||||
|
||||
const { update, ...button } = useSendButtonCaption('Опубликовать', loading, error, 'Опубликовано')
|
||||
|
||||
const doSendWithButton = useCallback(async (formData: FormData) => {
|
||||
const data = await doSend({}, {
|
||||
body: formData
|
||||
async function handleAdd(formData: FormData) {
|
||||
await doSend({}, {
|
||||
body: formData,
|
||||
})
|
||||
update(data)
|
||||
}
|
||||
|
||||
return data
|
||||
}, [doSend, update])
|
||||
|
||||
return { doSend: doSendWithButton, button }
|
||||
return { handleAdd, addButton: button }
|
||||
}
|
||||
|
||||
export default useAddAnnouncement
|
||||
|
@ -1,7 +1,6 @@
|
||||
import { useFetch } from '../'
|
||||
import useFetch from '../useFetch'
|
||||
import { FiltersType } from '../../utils/filters'
|
||||
import { composeAnnouncementsURL, initialAnnouncements, processAnnouncements } from '../../api/announcements'
|
||||
|
||||
import { isAnnouncementsResponse } from '../../api/announcements/types'
|
||||
|
||||
const useAnnouncements = (filters: FiltersType) => (
|
||||
|
@ -1,117 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import { API_URL } from '../../config'
|
||||
import { isConst, isObject } from '../../utils/types'
|
||||
import { handleHTTPErrors } from '../../utils'
|
||||
|
||||
interface AuthData {
|
||||
email: string,
|
||||
password: string,
|
||||
}
|
||||
|
||||
// interface LoginData extends AuthData { }
|
||||
|
||||
// interface SignUpData extends AuthData {
|
||||
// name: string,
|
||||
// surname: string
|
||||
// }
|
||||
|
||||
type SignUpResponse = {
|
||||
Success: true
|
||||
} | {
|
||||
Success: false,
|
||||
Message: string
|
||||
}
|
||||
|
||||
const isSignUpResponse = (obj: unknown): obj is SignUpResponse => (
|
||||
isObject(obj, {
|
||||
'Success': isConst(true)
|
||||
}) ||
|
||||
isObject(obj, {
|
||||
'Success': isConst(false),
|
||||
'Message': 'string'
|
||||
})
|
||||
)
|
||||
|
||||
interface LogInResponse {
|
||||
access_token: string,
|
||||
token_type: 'bearer'
|
||||
}
|
||||
|
||||
const isLogInResponse = (obj: unknown): obj is LogInResponse => (
|
||||
isObject(obj, {
|
||||
'access_token': 'string',
|
||||
'token_type': isConst('bearer')
|
||||
})
|
||||
)
|
||||
|
||||
function useAuth() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function doAuth(data: AuthData, newAccount: boolean) {
|
||||
setLoading(true)
|
||||
|
||||
if (newAccount) {
|
||||
try {
|
||||
const res = await fetch(API_URL + '/signup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
handleHTTPErrors(res)
|
||||
|
||||
const signupData: unknown = await res.json()
|
||||
|
||||
if (!isSignUpResponse(signupData)) {
|
||||
throw new Error('Malformed server response')
|
||||
}
|
||||
|
||||
if (signupData.Success === false) {
|
||||
throw new Error(signupData.Message)
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : err as string)
|
||||
setLoading(false)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(API_URL + '/auth/token' + new URLSearchParams({
|
||||
username: data.email,
|
||||
password: data.password
|
||||
}).toString(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
}
|
||||
})
|
||||
|
||||
const logInData: unknown = await res.json()
|
||||
|
||||
if (!isLogInResponse(logInData)) {
|
||||
throw new Error('Malformed server response')
|
||||
}
|
||||
|
||||
const token = logInData.access_token
|
||||
|
||||
setError('')
|
||||
setLoading(false)
|
||||
|
||||
return token
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : err as string)
|
||||
setLoading(false)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return { doAuth, loading, error }
|
||||
}
|
||||
|
||||
export default useAuth
|
@ -1,74 +1,32 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { getToken } from '../../utils/auth'
|
||||
import { API_URL } from '../../config'
|
||||
import { isObject } from '../../utils/types'
|
||||
import { handleHTTPErrors } from '../../utils'
|
||||
import { useSendWithButton } from '..'
|
||||
import { composeBookURL, processBook } from '../../api/book'
|
||||
import { isBookResponse } from '../../api/book/types'
|
||||
|
||||
type BookResponse = {
|
||||
Success: boolean
|
||||
}
|
||||
function useBook() {
|
||||
const { doSend, button } = useSendWithButton('Забронировать',
|
||||
'Забронировано',
|
||||
true,
|
||||
composeBookURL(),
|
||||
'POST',
|
||||
true,
|
||||
isBookResponse,
|
||||
processBook,
|
||||
)
|
||||
|
||||
const isBookResponse = (obj: unknown): obj is BookResponse => (
|
||||
isObject(obj, {
|
||||
'Success': 'boolean'
|
||||
})
|
||||
)
|
||||
const handleBook = useCallback(async (id: number) => {
|
||||
await doSend({}, {
|
||||
body: JSON.stringify({
|
||||
id,
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
}, [doSend])
|
||||
|
||||
type BookStatus = '' | 'Загрузка...' | 'Забронировано' | 'Ошибка бронирования'
|
||||
|
||||
function useBook(id: number) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [status, setStatus] = useState<BookStatus>('')
|
||||
|
||||
const handleBook = async () => {
|
||||
const token = getToken()
|
||||
|
||||
if (token) {
|
||||
setStatus('Загрузка...')
|
||||
|
||||
try {
|
||||
|
||||
const res = await fetch(API_URL + '/book', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
id: id
|
||||
}),
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
handleHTTPErrors(res)
|
||||
|
||||
const data: unknown = await res.json()
|
||||
|
||||
if (!isBookResponse(data)) {
|
||||
throw new Error('Malformed server response')
|
||||
}
|
||||
|
||||
if (data.Success === true) {
|
||||
setStatus('Забронировано')
|
||||
} else {
|
||||
throw new Error('Server refused to book')
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
setStatus('Ошибка бронирования')
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log(err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return navigate('/login')
|
||||
}
|
||||
}
|
||||
|
||||
return { handleBook, status }
|
||||
return { handleBook, bookButton: button }
|
||||
}
|
||||
|
||||
export default useBook
|
||||
|
35
front/src/hooks/api/useDispose.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import useSendWithButton from '../useSendWithButton'
|
||||
import { composeDisposeBody, composeDisposeURL, processDispose } from '../../api/dispose'
|
||||
import { DisposeParams, isDisposeResponse } from '../../api/dispose/types'
|
||||
|
||||
function useDispose(resolve: () => void) {
|
||||
const { doSend, button } = useSendWithButton(
|
||||
'Выбор сделан',
|
||||
'Зачтено',
|
||||
true,
|
||||
composeDisposeURL(),
|
||||
'POST',
|
||||
true,
|
||||
isDisposeResponse,
|
||||
processDispose,
|
||||
)
|
||||
|
||||
const doSendWithClose = useCallback(async (...args: DisposeParams) => {
|
||||
const res = await doSend({}, {
|
||||
body: composeDisposeBody(...args),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (res) {
|
||||
resolve()
|
||||
}
|
||||
}, [doSend, resolve])
|
||||
|
||||
return { handleDispose: doSendWithClose, disposeButton: button }
|
||||
}
|
||||
|
||||
export default useDispose
|
@ -1,6 +1,6 @@
|
||||
import { LatLng } from 'leaflet'
|
||||
|
||||
import { useFetch } from '../'
|
||||
import useFetch from '../useFetch'
|
||||
import { composeOsmAddressURL, processOsmAddress } from '../../api/osmAddress'
|
||||
import { isOsmAddressResponse } from '../../api/osmAddress/types'
|
||||
|
||||
@ -11,7 +11,7 @@ const useOsmAddresses = (addressPosition: LatLng) => (
|
||||
false,
|
||||
isOsmAddressResponse,
|
||||
processOsmAddress,
|
||||
''
|
||||
'',
|
||||
)
|
||||
)
|
||||
|
||||
|
17
front/src/hooks/api/usePoetry.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { composePoetryURL, initialPoetry, processPoetry } from '../../api/poetry'
|
||||
import { Poetry, isPoetryResponse } from '../../api/poetry/types'
|
||||
import useFetch, { UseFetchReturn } from '../useFetch'
|
||||
|
||||
const usePoetry = (): UseFetchReturn<Poetry> => (
|
||||
useFetch(
|
||||
composePoetryURL(),
|
||||
'GET',
|
||||
false,
|
||||
isPoetryResponse,
|
||||
processPoetry,
|
||||
initialPoetry,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
export default usePoetry
|
37
front/src/hooks/api/useRemoveAnnouncement.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import useSendWithButton from '../useSendWithButton'
|
||||
import { composeRemoveAnnouncementURL, processRemoveAnnouncement } from '../../api/removeAnnouncement'
|
||||
import { isRemoveAnnouncementResponse } from '../../api/removeAnnouncement/types'
|
||||
|
||||
function useRemoveAnnouncement(resolve: () => void) {
|
||||
const { doSend, button } = useSendWithButton(
|
||||
'Закрыть объявление',
|
||||
'Закрыто',
|
||||
true,
|
||||
composeRemoveAnnouncementURL(),
|
||||
'DELETE',
|
||||
true,
|
||||
isRemoveAnnouncementResponse,
|
||||
processRemoveAnnouncement,
|
||||
)
|
||||
|
||||
const doSendWithClose = useCallback(async (id: number) => {
|
||||
const res = await doSend({}, {
|
||||
body: JSON.stringify({
|
||||
id,
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (res) {
|
||||
resolve()
|
||||
}
|
||||
}, [doSend, resolve])
|
||||
|
||||
return { handleRemove: doSendWithClose, removeButton: button }
|
||||
}
|
||||
|
||||
export default useRemoveAnnouncement
|
34
front/src/hooks/api/useSendRate.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import useSend from '../useSend'
|
||||
import { composeSendRateURL, processSendRate } from '../../api/sendRate'
|
||||
import { isSendRateResponse } from '../../api/sendRate/types'
|
||||
|
||||
function useSendRate() {
|
||||
const { doSend, ...rest } = useSend(
|
||||
composeSendRateURL(),
|
||||
'POST',
|
||||
true,
|
||||
isSendRateResponse,
|
||||
processSendRate,
|
||||
)
|
||||
|
||||
const doSendRate = (rate: number, user_id: number) => (
|
||||
doSend({}, {
|
||||
body: JSON.stringify({
|
||||
rate,
|
||||
user_id,
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
return {
|
||||
doSendRate,
|
||||
...rest,
|
||||
}
|
||||
}
|
||||
|
||||
export default useSendRate
|
35
front/src/hooks/api/useSignIn.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import useSendWithButton from '../useSendWithButton'
|
||||
import { composeTokenURL, processToken } from '../../api/token'
|
||||
import { isTokenResponse } from '../../api/token/types'
|
||||
import { setToken } from '../../utils/auth'
|
||||
|
||||
function useSignIn() {
|
||||
const { doSend, button } = useSendWithButton(
|
||||
'Мы уже знакомы',
|
||||
'Войдено',
|
||||
false,
|
||||
composeTokenURL(),
|
||||
'POST',
|
||||
false,
|
||||
isTokenResponse,
|
||||
processToken,
|
||||
)
|
||||
|
||||
async function handleSignIn(formData: FormData) {
|
||||
const token = await doSend({}, {
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (token !== null && token !== undefined) {
|
||||
setToken(token)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return { handleSignIn, signInButton: button }
|
||||
}
|
||||
|
||||
export default useSignIn
|
28
front/src/hooks/api/useSignUp.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import useSendWithButton from '../useSendWithButton'
|
||||
import { composeSignUpURL, processSignUp } from '../../api/signup'
|
||||
import { isSignUpResponse } from '../../api/signup/types'
|
||||
|
||||
function useSignUp() {
|
||||
const { doSend, button } = useSendWithButton(
|
||||
'Я здесь впервые',
|
||||
'Зарегистрирован',
|
||||
false,
|
||||
composeSignUpURL(),
|
||||
'POST',
|
||||
false,
|
||||
isSignUpResponse,
|
||||
processSignUp,
|
||||
)
|
||||
|
||||
async function handleSignUp(formData: FormData) {
|
||||
const res = await doSend({}, {
|
||||
body: formData,
|
||||
})
|
||||
|
||||
return res ?? false
|
||||
}
|
||||
|
||||
return { handleSignUp, signUpButton: button }
|
||||
}
|
||||
|
||||
export default useSignUp
|
@ -1,17 +1,19 @@
|
||||
import { LatLng } from 'leaflet'
|
||||
|
||||
import { useFetch } from '../'
|
||||
import { composeTrashboxURL, processTrashbox } from '../../api/trashbox'
|
||||
import { isTrashboxResponse } from '../../api/trashbox/types'
|
||||
import { Trashbox, isTrashboxResponse } from '../../api/trashbox/types'
|
||||
import useFetch, { UseFetchReturn } from '../useFetch'
|
||||
|
||||
const useTrashboxes = (position: LatLng) => (
|
||||
import { Category } from '../../assets/category'
|
||||
import { composeTrashboxURL, processTrashbox } from '../../api/trashbox'
|
||||
|
||||
const useTrashboxes = (position: LatLng, category: Category): UseFetchReturn<Trashbox[]> => (
|
||||
useFetch(
|
||||
composeTrashboxURL(position),
|
||||
composeTrashboxURL(position, category),
|
||||
'GET',
|
||||
true,
|
||||
isTrashboxResponse,
|
||||
processTrashbox,
|
||||
[]
|
||||
[],
|
||||
)
|
||||
)
|
||||
|
||||
|