Compare commits

..

4 Commits

151 changed files with 1292 additions and 7115 deletions

View File

@ -29,7 +29,4 @@ dist-ssr
uploads/
.env
poems.txt
poem_pic/
__pycache__

3
.gitignore vendored
View File

@ -28,8 +28,5 @@ dist-ssr
*.db
uploads/
.env
poem_pic/
poem_pic/
__pycache__

View File

@ -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 python -m back.main
CMD uvicorn back.main:app --host 0.0.0.0 --port 80

View File

@ -4,9 +4,9 @@ Food and other stuff sharing platform. The service was developed during Digital
Members:
* Dmitry Gantimurov - Chief Backend
* Dmitriy Shishkov - Frontend & Interface Design
* Vladimir Yakovlev - Backend & Graphical Design
* Dmitry Gantimurov - Backend
* Dmitriy Shishkov - Frontend
* Vladimir Yakovlev - Backend & Design
## Dev build instructions
@ -25,7 +25,7 @@ Backend:
```sh
pip install -r requirements.txt
python -m back.main
uvicorn back.main:app --reload
```
## Deploy instructions
@ -35,5 +35,5 @@ Only docker/podman are required
```sh
docker build . -t 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
docker run --name porridger -p 8080:80 -v ./sql_app.db:/srv/sql_app.db -v uploads:/srv/uploads porridger:build
```

View File

@ -4,9 +4,8 @@
# 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
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
@ -49,16 +48,11 @@ prepend_sys_path = .
# 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
# sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
@ -72,12 +66,6 @@ version_path_separator = os # Use os.pathsep. Default configuration used for ne
# 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

View File

@ -1,116 +0,0 @@
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) # обновляем состояние объекта

View File

@ -1,333 +0,0 @@
#подключение библиотек
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}

View File

@ -1,96 +0,0 @@
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

View File

@ -1,2 +0,0 @@
from .db import Base
from .orm_models import User, Announcement, Trashbox

2
back/base.py Normal file
View File

@ -0,0 +1,2 @@
from .db import Base
from .models import UserDatabase, Announcement, Trashbox

View File

@ -1,13 +0,0 @@
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")

View File

@ -1,20 +1,21 @@
from asyncio import current_task
from sqlalchemy.ext.asyncio import AsyncSession, async_scoped_session, create_async_engine
from sqlalchemy.orm import sessionmaker
from typing import AsyncGenerator
from sqlalchemy import create_engine, select
# from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import sessionmaker, Session
from sqlalchemy.ext.declarative import declarative_base
from .config import SQLALCHEMY_DATABASE_URL
from fastapi import Depends
# from fastapi_users.db import SQLAlchemyBaseUserTableUUID, SQLAlchemyUserDatabase
engine = create_async_engine(SQLALCHEMY_DATABASE_URL)
SessionLocal = sessionmaker(bind=engine, class_=AsyncSession, expire_on_commit=False)
SQLALCHEMY_DATABASE_URL = "sqlite:///./sql_app.db"
async_session = SessionLocal()
# async_session = async_scoped_session(SessionLocal, scopefunc=current_task)
engine = create_engine(
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
)
SessionLocal = sessionmaker(bind=engine, autoflush=True, autocommit=False)
database = SessionLocal()
Base = declarative_base()
# Создаем таблицы
async def init_models():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)

40
back/db_manipultations.py Normal file
View File

@ -0,0 +1,40 @@
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()

View File

@ -1,7 +0,0 @@
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()

View File

@ -1,6 +0,0 @@
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()

View File

@ -1,32 +1,232 @@
import asyncio
import uvicorn
#подключение библиотек
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
from .api import app as app_fastapi
from .scheduler import app as app_rocketry
from .db import init_models
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 .db import Base, engine, SessionLocal, database
from .models import Announcement, Trashbox, UserDatabase
from . import schema
Base.metadata.create_all(bind=engine)
app = FastAPI()
templates = Jinja2Templates(directory="./front/dist")
app.mount("/static", StaticFiles(directory = "./front/dist"))
if not os.path.exists("./uploads"):
os.mkdir("C:/Users/38812/porridger/uploads")
app.mount("/uploads", StaticFiles(directory = "./uploads"))
class Server(uvicorn.Server):
"""Customized uvicorn.Server
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)
# Функция, создающая сессию БД при каждом запросе к нашему API.
# Срабатывает до запуска остальных функций.
# Всегда закрывает сессию при окончании работы с ней
@app.middleware("http")
async def db_session_middleware(request: Request, call_next):
response = Response("Internal server error", status_code=500)
try:
request.state.db = SessionLocal()
response = await call_next(request)
finally:
request.state.db.close()
return response
async def main():
"Run scheduler and the API"
@app.get("/api/announcements")#адрес объявлений
def annoncements_list(user_id: int = None, metro: str = None, category: str = None, booked_by: int = 0):
# Считываем данные из Body и отображаем их на странице.
# В последствии будем вставлять данные в html-форму
await init_models()
a = database.query(Announcement)
b = database.query(Announcement)
c = database.query(Announcement)
d = database.query(Announcement)
e = database.query(Announcement)
server = Server(config=uvicorn.Config(app_fastapi, workers=1, loop="asyncio", host="0.0.0.0"))
if user_id != None:
b = a.filter(Announcement.user_id == user_id)
api = asyncio.create_task(server.serve())
sched = asyncio.create_task(app_rocketry.serve())
if metro != None:
c = a.filter(Announcement.metro == metro)
await asyncio.wait([sched, api])
if category != None:
d = a.filter(Announcement.category == category)
if __name__ == "__main__":
asyncio.run(main())
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 = database.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:
database.delete(user_id=data.user_id)#удаление из БД
database.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 = database.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}
# reginstration
@app.post("/api/signup")
def create_user(data = Body()):
if database.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"])
database.add(new_user)
database.commit()
database.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(database, 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=schema.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 })

74
back/models.py Normal file
View File

@ -0,0 +1,74 @@
from sqlalchemy import Column, Integer, String
from fastapi_users.db import SQLAlchemyBaseUserTableUUID, SQLAlchemyUserDatabase
from fastapi import Depends
from .db import Base
class User(SQLAlchemyBaseUserTableUUID, Base):
pass
class UserDatabase(Base):#класс пользователя
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True, unique=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)#категория продукта из объявления
class Poems(Base):#класс поэзии
__tablename__ = "poems"
id = Column(Integer, primary_key=True, index=True) #айди
poem_text = Column(String) # текст стихотворения
# from typing import AsyncGenerator
# from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
# from fastapi_users.db import SQLAlchemyBaseUserTableUUID, SQLAlchemyUserDatabase
# # This function can be called during the initialization of the FastAPI app.
# async def create_db_and_tables():
# async with engine.begin() as conn:
# await conn.run_sync(Base.metadata.create_all)
# async def get_async_session() -> AsyncGenerator[AsyncSession, None]:
# async with async_session_maker() as session:
# yield session
# async def get_user_db(session: AsyncSession = Depends(get_async_session)):
# yield SQLAlchemyUserDatabase(session, User)

View File

@ -1,69 +0,0 @@
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) # автор стихотворения

View File

@ -1,131 +0,0 @@
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

View File

@ -1,14 +0,0 @@
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())

29
back/schema.py Normal file
View File

@ -0,0 +1,29 @@
from pydantic import BaseModel
from typing import Annotated, Union
class Book(BaseModel):
id: int
class Token(BaseModel):
access_token: str
token_type: str
class TokenData(BaseModel):
email: Union[str, None] = None
class User(BaseModel):
id: int
phone: Union[int, None] = None
email: str
name: Union[str, None] = None
surname: str
class Config:
orm_mode = True
class UserInDB(User):
password: str
hashed_password: str

2
back/service.py Normal file
View File

@ -0,0 +1,2 @@
from sqlalchemy.orm import Session

92
back/utils.py Normal file
View File

@ -0,0 +1,92 @@
from datetime import datetime, timedelta
from typing import Annotated, Union
from fastapi import Depends, FastAPI, HTTPException, status, Response, Request
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from jose import JWTError, jwt
from passlib.context import CryptContext
from sqlalchemy.orm import Session
from sqlalchemy import select
from .db import Session, database
from .models import UserDatabase
from .schema import Token, TokenData, UserInDB, User
SECRET_KEY = "09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
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: Session, email: str):
user_with_required_email = db.query(UserDatabase).filter(UserDatabase.email == email).first()
print(user_with_required_email)
if user_with_required_email:
return user_with_required_email
return None
def authenticate_user(db: Session, 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(db: 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])
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(db, email=token_data.email)
if user == 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
def get_db(request: Request):
return request.state.db

View File

@ -32,16 +32,5 @@ 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',
}],
},
}

View File

@ -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,13 +17,9 @@
"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",
@ -32,7 +28,6 @@
"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"
}
@ -822,22 +817,6 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/@faker-js/faker": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-8.0.2.tgz",
"integrity": "sha512-Uo3pGspElQW91PCvKSIAXoEgAUlRnH29sX2/p89kg7sP1m2PzCufHINd0FhTXQf6DYGiUlVncdSPa2F9wxed2A==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/fakerjs"
}
],
"engines": {
"node": "^14.17.0 || ^16.13.0 || >=18.0.0",
"npm": ">=6.14.13"
}
},
"node_modules/@humanwhocodes/config-array": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.10.tgz",
@ -1062,8 +1041,7 @@
"node_modules/@types/geojson": {
"version": "7946.0.10",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.10.tgz",
"integrity": "sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA==",
"dev": true
"integrity": "sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA=="
},
"node_modules/@types/json-schema": {
"version": "7.0.12",
@ -1075,17 +1053,10 @@
"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",
@ -2390,11 +2361,6 @@
"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",
@ -2428,12 +2394,6 @@
"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",
@ -2865,16 +2825,6 @@
"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",

View File

@ -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,13 +21,9 @@
"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",
@ -36,7 +32,6 @@
"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"
}

View File

@ -1,315 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<nav>
<a href="#back" class="back">
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" class="bi bi-arrow-left" viewBox="0 0 16 16">
<path fill-rule="evenodd"
d="M15 8a.5.5 0 0 0-.5-.5H2.707l3.147-3.146a.5.5 0 1 0-.708-.708l-4 4a.5.5 0 0 0 0 .708l4 4a.5.5 0 0 0 .708-.708L2.707 8.5H14.5A.5.5 0 0 0 15 8z" />
</svg>
</a>
<h1 class="heading">Иванов Иван, с нами с 17.07.2023</h1>
</nav>
<div id="root"></div>
<div class="poemContainer">
<h1>Поэзия</h1>
<div class="poemText">
<div class="eleven" style="position:relative;left:-60px">"Fury said to</div>
<div class="ten" style="position:relative;left:-40px">a mouse, That</div>
<div class="ten" style="position:relative;left:0px">he met</div>
<div class="ten" style="position:relative;left:10px">in the</div>
<div class="ten" style="position:relative;left:20px">house,</div>
<div class="ten" style="position:relative;left:17px">'Let us</div>
<div class="ten" style="position:relative;left:5px">both go</div>
<div class="ten" style="position:relative;left:-7px">to law:</div>
<div class="ten" style="position:relative;left:-23px"><i>I</i> will</div>
<div class="ten" style="position:relative;left:-26px">prosecute</div>
<div class="nine" style="position:relative;left:-40px"><i>you.</i></div>
<div class="nine" style="position:relative;left:-30px">Come, I'll</div>
<div class="nine" style="position:relative;left:-20px">take no</div>
<div class="nine" style="position:relative;left:-7px">denial;</div>
<div class="nine" style="position:relative;left:19px">We must</div>
<div class="nine" style="position:relative;left:45px">have a</div>
<div class="nine" style="position:relative;left:67px">trial:</div>
<div class="nine" style="position:relative;left:80px">For</div>
<div class="eight" style="position:relative;left:70px">really</div>
<div class="eight" style="position:relative;left:57px">this</div>
<div class="eight" style="position:relative;left:75px">morning</div>
<div class="eight" style="position:relative;left:95px">I've</div>
<div class="eight" style="position:relative;left:77px">nothing</div>
<div class="eight" style="position:relative;left:57px">to do.'</div>
<div class="seven" style="position:relative;left:38px">Said the</div>
<div class="seven" style="position:relative;left:30px">mouse to</div>
<div class="seven" style="position:relative;left:18px">the cur,</div>
<div class="seven" style="position:relative;left:22px">'Such a</div>
<div class="seven" style="position:relative;left:37px">trial,</div>
<div class="seven" style="position:relative;left:27px">dear sir,</div>
<div class="seven" style="position:relative;left:9px">With no</div>
<div class="seven" style="position:relative;left:-8px">jury or</div>
<div class="seven" style="position:relative;left:-18px">judge,</div>
<div class="seven" style="position:relative;left:-6px">would be</div>
<div class="seven" style="position:relative;left:7px">wasting</div>
<div class="seven" style="position:relative;left:25px">our breath.'</div>
<div class="six" style="position:relative;left:30px">'I'll be</div>
<div class="six" style="position:relative;left:24px">judge,</div>
<div class="six" style="position:relative;left:15px">I'll be</div>
<div class="six" style="position:relative;left:2px">jury,'</div>
<div class="six" style="position:relative;left:-4px">Said</div>
<div class="six" style="position:relative;left:17px">cunning</div>
<div class="six" style="position:relative;left:29px">old Fury;</div>
<div class="six" style="position:relative;left:37px">'I'll try</div>
<div class="six" style="position:relative;left:51px">the whole</div>
<div class="six" style="position:relative;left:70px">cause,</div>
<div class="six" style="position:relative;left:65px">and</div>
<div class="six" style="position:relative;left:60px">condemn</div>
<div class="six" style="position:relative;left:60px">you</div>
<div class="six" style="position:relative;left:68px">to</div>
<div class="six" style="position:relative;left:82px">death.' "</div>
</div>
<img src="uploads/mouse.jpg" class="poemImg">
</div>
<script>
const categoryGraphics = {
'PORRIDGE': 'dist/PORRIDGE.jpg',
'conspects': 'dist/conspects.jpg',
'milk': 'dist/milk.jpg',
'bred': 'dist/bred.jpg',
'wathing': 'dist/wathing.jpg',
'cloth': 'dist/cloth.jpg',
'fruits_vegatables': 'dist/fruits_vegatables.jpg',
'soup': 'dist/soup.jpg',
'dinner': 'dist/dinner.jpg',
'conserves': 'dist/conserves.jpg',
'pens': 'dist/pens.jpg',
'other_things': 'dist/other_things.jpg',
}
const categoryNames = {
'PORRIDGE': 'PORRIDGE',
'conspects': 'Конспекты',
'milk': 'Молочные продукты',
'bred': 'Хлебобулочные изделия',
'wathing': 'Моющие средства',
'cloth': 'Одежда',
'fruits_vegatables': 'Фрукты и овощи',
'soup': 'Супы',
'dinner': 'Ужин',
'conserves': 'Консервы',
'pens': 'Канцелярия',
'other_things': 'Всякая всячина',
}
const cats = ["Раздача", "Бронь", "История"]
const props = ["Годен до 01.09.2023", "Бронь ещё 5 чел.", "Забрал 16.07.2023"]
const stories = [2, 4, 1].map(
(n) => (new Array(n)).fill(1).map(
() => (
{
title: (Math.random() * Math.pow(10, Math.random() * 100)).toString(36),
category: Object.keys(categoryGraphics)[Math.round(Math.random() * (Object.keys(categoryGraphics).length - 1))]
}
)
)
)
console.log(stories)
const render = () => {
const root = document.getElementById('root')
root.innerHTML = ''
stories.forEach((c, i) => {
const section = document.createElement('section')
section.className = 'section'
section.innerHTML = `<h1>${cats[i]}</h1>`
const ul = document.createElement('ul')
c.forEach((v, j) => {
const story = document.createElement('li')
story.className = 'story'
story.innerHTML = `
<a href="#${i},${j}">
<img class="storyPic" src="${categoryGraphics[v.category]}" />
<p class="storyTitle">${v.title}</p>
<p class="storyTitle">${props[i]}</p>
`.trim()
ul.appendChild(story)
})
console.log(window.innerWidth, (window.innerHeight * 0.25 * 9 / 16), (window.innerWidth * 0.25 * 9 / 16) * c.length)
if ((window.innerWidth - 60) < (window.innerHeight * 0.25 * 9 / 16 + 10) * c.length) {
const seeMore = document.createElement('a')
seeMore.href = "#more"
seeMore.innerHTML = `<svg fill="currentColor" width="24" height="24" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 512"><!--! Font Awesome Free 6.1.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2022 Fonticons, Inc. --><path d="M64 448c-8.188 0-16.38-3.125-22.62-9.375c-12.5-12.5-12.5-32.75 0-45.25L178.8 256L41.38 118.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0l160 160c12.5 12.5 12.5 32.75 0 45.25l-160 160C80.38 444.9 72.19 448 64 448z"/></svg>`
seeMore.className = 'seeMore'
ul.appendChild(seeMore)
ul.classList.add('grad')
}
section.appendChild(ul)
root.appendChild(section)
})
}
// window.addEventListener('resize', render)
document.addEventListener('DOMContentLoaded', render)
</script>
<style>
* {
padding: 0;
margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}
a {
color: rgb(185, 179, 170);
text-decoration: none;
}
body {
background-color: #111;
color: rgb(185, 179, 170);
width: 100%;
max-width: calc(100vh*9/16);
margin: auto;
}
.section {
padding: 30px;
padding-bottom: 0;
}
ul {
display: flex;
list-style-type: none;
padding-top: 20px;
width: 100%;
overflow: hidden;
position: relative;
}
.grad::after {
content: '';
background: linear-gradient(to right, rgba(17, 17, 17, 0) 0%, rgba(17, 17, 17, 255) 100%);
display: block;
height: 100%;
width: 10%;
position: absolute;
right: 0;
}
li {
padding-right: 10px;
width: calc(25vh*9/16);
}
.storyPic {
max-height: 25vh;
border-radius: 12px;
}
.storyTitle {
padding-top: 5px;
text-overflow: ellipsis;
overflow: hidden;
/* max-width: 100%; */
}
.seeMore {
position: absolute;
left: calc(100% - 5% / 3 - 30px + 8px);
top: calc(50% - 15px);
z-index: 100;
width: 24px;
height: 24px;
padding: 3px;
color: rgb(185, 179, 170);
/* background-color: #111; */
border-radius: 100%;
}
nav {
padding: 30px;
padding-bottom: 0;
display: flex;
}
.back {
color: rgb(185, 179, 170);
display: flex;
align-items: center;
}
.back svg {
display: block;
height: 24px;
width: 24px;
padding: 3px;
}
.heading {
padding-left: 7px;
}
.poemContainer {
padding: 30px;
}
.poemText {
padding: 0 60px;
padding-top: 10px;
}
.eleven {
font-size: 105%;
margin: 0px;
}
.ten {
font-size: 100%;
margin: 0px;
}
.nine {
font-size: 90%;
margin: 0px;
}
.eight {
font-size: 80%;
margin: 0px;
}
.seven {
font-size: 70%;
margin: 0px;
}
.six {
font-size: 60%;
margin: 0px;
}
.poemImg {
max-width: 100%;
padding-top: 10px;
}
</style>
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 287 KiB

View File

@ -1,5 +1,12 @@
:root {
--bs-body-bg: rgb(17, 17, 17) !important;
body {
height: 100vh;
overflow: hidden;
color: white;
font-family: sans-serif;
}
.modal-content, .modal-content .form-select {
background-color: rgb(17, 17, 17) !important;
}
/* В связи со сложившейся политической обстановкой */

View File

@ -1,12 +0,0 @@
import { Announcement, AnnouncementResponse } from './types'
const processAnnouncement = (data: AnnouncementResponse): Announcement => ({
...data,
lat: data.latitude,
lng: data.longtitude,
bestBy: data.best_by,
bookedBy: data.booked_by,
userId: data.user_id,
})
export { processAnnouncement }

View File

@ -1,28 +1,20 @@
import { isObject } from '../../utils/types'
import { Category, isCategory } from '../../assets/category'
type Announcement = {
type AnnouncementResponse = {
id: number,
userId: number,
user_id: number,
name: string,
category: Category,
bestBy: string,
best_by: number,
address: string,
lng: number,
lat: number,
description: string | null,
longtitude: number,
latitude: number,
description: string,
src: string | null,
metro: string,
trashId: number | null,
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'],
booked_by: number
}
const isAnnouncementResponse = (obj: unknown): obj is AnnouncementResponse => (
@ -31,7 +23,7 @@ const isAnnouncementResponse = (obj: unknown): obj is AnnouncementResponse => (
'user_id': 'number',
'name': 'string',
'category': isCategory,
'best_by': 'string',
'best_by': 'number',
'address': 'string',
'longtitude': 'number',
'latitude': 'number',
@ -39,10 +31,26 @@ 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,

View File

@ -1,17 +1,25 @@
import { API_URL } from '../../config'
import { FiltersType, URLEncodeFilters, convertFilterNames } from '../../utils/filters'
import { processAnnouncement } from '../announcement'
import { FiltersType, URLEncodeFilters } from '../../utils/filters'
import { Announcement } from '../announcement/types'
import { AnnouncementsResponse } from './types'
const initialAnnouncements: Announcement[] = []
const composeAnnouncementsURL = (filters: FiltersType) => (
API_URL + '/announcements?' + new URLSearchParams(convertFilterNames(URLEncodeFilters(filters))).toString()
API_URL + '/announcements?' + new URLSearchParams(URLEncodeFilters(filters)).toString()
)
const processAnnouncements = (data: AnnouncementsResponse): Announcement[] => (
data.map(processAnnouncement)
)
const processAnnouncements = (data: AnnouncementsResponse): Announcement[] => {
const annList = data.list_of_announcements
return annList.map(ann => ({
...ann,
lat: ann.latitude,
lng: ann.longtitude,
bestBy: ann.best_by,
bookedBy: ann.booked_by,
userId: ann.user_id
}))
}
export { initialAnnouncements, composeAnnouncementsURL, processAnnouncements }

View File

@ -1,10 +1,16 @@
import { isArrayOf } from '../../utils/types'
import { isArrayOf, isObject } from '../../utils/types'
import { AnnouncementResponse, isAnnouncementResponse } from '../announcement/types'
type AnnouncementsResponse = AnnouncementResponse[]
type AnnouncementsResponse = {
list_of_announcements: AnnouncementResponse[],
Success: boolean
}
const isAnnouncementsResponse = (obj: unknown): obj is AnnouncementsResponse => (
isArrayOf(obj, isAnnouncementResponse)
isObject(obj, {
'list_of_announcements': obj => isArrayOf<AnnouncementResponse>(obj, isAnnouncementResponse),
'Success': 'boolean'
})
)
export type {

View File

@ -1,16 +0,0 @@
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 }

View File

@ -1,17 +0,0 @@
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 }

View File

@ -1,19 +0,0 @@
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 }

View File

@ -1,23 +0,0 @@
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 }

View File

@ -1,5 +1,4 @@
import { LatLng } from 'leaflet'
import { OsmAddressResponse } from './types'
const initialOsmAddress = ''

View File

@ -1,7 +1,7 @@
import { isObject } from '../../utils/types'
type OsmAddressResponse = {
display_name: string,
display_name: string
}
const isOsmAddressResponse = (obj: unknown): obj is OsmAddressResponse => (

View File

@ -1,19 +0,0 @@
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 }

View File

@ -1,25 +0,0 @@
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 }

View File

@ -6,7 +6,7 @@ const composePutAnnouncementURL = () => (
)
const processPutAnnouncement = (data: PutAnnouncementResponse): PutAnnouncement => {
return data.Success
return data.Answer
}
export { composePutAnnouncementURL, processPutAnnouncement }

View File

@ -1,12 +1,12 @@
import { isObject } from '../../utils/types'
type PutAnnouncementResponse = {
Success: boolean,
Answer: boolean
}
const isPutAnnouncementResponse = (obj: unknown): obj is PutAnnouncementResponse => (
isObject(obj, {
'Success': 'boolean',
'Answer': 'boolean'
})
)

View File

@ -1,16 +0,0 @@
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 }

View File

@ -1,17 +0,0 @@
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 }

View File

@ -1,12 +0,0 @@
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 }

View File

@ -1,17 +0,0 @@
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 }

View File

@ -1,22 +0,0 @@
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 }

View File

@ -1,23 +0,0 @@
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 }

View File

@ -1,12 +0,0 @@
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 }

View File

@ -1,17 +0,0 @@
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 }

View File

@ -2,18 +2,15 @@ import { LatLng } from 'leaflet'
import { API_URL } from '../../config'
import { Trashbox, TrashboxResponse } from './types'
import { Category } from '../../assets/category'
const composeTrashboxURL = (position: LatLng, category: Category) => (
const composeTrashboxURL = (position: LatLng) => (
API_URL + '/trashbox?' + new URLSearchParams({
Lat: position.lat.toString(),
Lng: position.lng.toString(),
Category: category,
lat: position.lat.toString(),
lng: position.lng.toString()
}).toString()
)
const processTrashbox = (data: TrashboxResponse): Trashbox[] => (
const processTrashbox = (data: TrashboxResponse): Trashbox[] =>
data
)
export { composeTrashboxURL, processTrashbox }

View File

@ -1,20 +1,18 @@
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(obj, isString),
'Categories': obj => isArrayOf<string>(obj, isString)
})
)

View File

@ -1,22 +0,0 @@
import { API_URL } from '../../config'
import { UserResponse, User } from './types'
const initialUser: User = {
id: -1,
nickname: '',
regDate: '',
points: -1,
}
const composeUserURL = () => (
API_URL + '/users/me?'
)
const processUser = (data: UserResponse): User => {
return {
...data,
regDate: data.reg_date,
}
}
export { initialUser, composeUserURL, processUser }

View File

@ -1,23 +0,0 @@
import { isObject } from '../../utils/types'
type User = {
id: number,
nickname: string,
regDate: string,
points: number,
}
type UserResponse = Omit<User, 'regDate'> & { reg_date: string }
const isUserResponse = (obj: unknown): obj is UserResponse => (
isObject(obj, {
'id': 'number',
'nickname': 'string',
'reg_date': 'string',
'points': 'number',
})
)
export type { UserResponse, User }
export { isUserResponse }

View File

@ -1,14 +0,0 @@
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 }

View File

@ -1,17 +0,0 @@
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 }

View File

@ -1,3 +1 @@
<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>
<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>

Before

Width:  |  Height:  |  Size: 317 B

After

Width:  |  Height:  |  Size: 315 B

View File

@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="rgb(185, 179, 170)" width="24" height="24" class="bi bi-arrow-left" viewBox="0 0 16 16">
<path fill-rule="evenodd"
d="M15 8a.5.5 0 0 0-.5-.5H2.707l3.147-3.146a.5.5 0 1 0-.708-.708l-4 4a.5.5 0 0 0 0 .708l4 4a.5.5 0 0 0 .708-.708L2.707 8.5H14.5A.5.5 0 0 0 15 8z" />
</svg>

Before

Width:  |  Height:  |  Size: 329 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

View File

@ -1,3 +1 @@
<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>
<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>

Before

Width:  |  Height:  |  Size: 338 B

After

Width:  |  Height:  |  Size: 337 B

View File

@ -1,3 +1 @@
<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>
<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>

Before

Width:  |  Height:  |  Size: 429 B

After

Width:  |  Height:  |  Size: 427 B

View File

@ -1,7 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@ -1,2 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 893 B

View File

@ -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,7 +105,5 @@ const lineByName = (name: string) => (
lines.find(line => stations[line].has(name))
)
const DEFAULT_LINE = 'Петроградская'
export type { Lines }
export { lines, stations, colors, lineNames, lineByName, DEFAULT_LINE }
export { lines, stations, colors, lineNames, lineByName }

View File

@ -1,4 +0,0 @@
<svg fill="rgb(185, 179, 170)" width="24" height="24" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 512">
<!--! Font Awesome Free 6.1.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2022 Fonticons, Inc. -->
<path d="M64 448c-8.188 0-16.38-3.125-22.62-9.375c-12.5-12.5-12.5-32.75 0-45.25L178.8 256L41.38 118.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0l160 160c12.5 12.5 12.5 32.75 0 45.25l-160 160C80.38 444.9 72.19 448 64 448z"/>
</svg>

Before

Width:  |  Height:  |  Size: 569 B

View File

@ -1,4 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 433 B

View File

@ -1,73 +0,0 @@
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 }

View File

@ -1,35 +0,0 @@
import { Announcement } from '../api/announcement/types'
import { getId } from '../utils/auth'
import { FiltersType } from '../utils/filters'
const userCategories = ['givingOut', 'needDispose'] as const
type UserCategory = typeof userCategories[number]
const UserCategoriesNames: Record<UserCategory, string> = {
givingOut: 'Раздача',
needDispose: 'Нужно утилизировать',
}
const userCategoriesInfos: Record<UserCategory, (ann: Announcement) => string> = {
givingOut: (ann: Announcement) => (
`Годен до ${ann.bestBy}`
),
needDispose: (ann: Announcement) => (
`Было заинтересно ${ann.bookedBy} чел.`
),
}
const composeUserCategoriesFilters: Record<UserCategory, () => FiltersType> = {
givingOut: () => ({
userId: getId(),
obsolete: false,
}),
needDispose: () => ({
userId: getId(),
obsolete: true,
}),
}
export type { UserCategory }
export { userCategories, UserCategoriesNames, userCategoriesInfos, composeUserCategoriesFilters }

View File

@ -1,3 +1 @@
<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>
<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>

Before

Width:  |  Height:  |  Size: 350 B

After

Width:  |  Height:  |  Size: 348 B

View File

@ -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, useRemoveAnnouncement } from '../hooks/api'
import { useBook } from '../hooks/api'
import { Announcement } from '../api/announcement/types'
import { iconItem } from '../utils/markerIcons'
import { useId } from '../hooks'
import SelectDisposalTrashbox from './SelectDisposalTrashbox'
import StarRating from './StarRating'
import StudentLocations from './StudentLocations'
import { CSSProperties } from 'react'
type AnnouncementDetailsProps = {
close: () => void,
announcement: Announcement
}
const styles = {
container: {
@ -19,42 +19,38 @@ const styles = {
alignItems: 'center',
justifyContent: 'center',
} as CSSProperties,
map: {
width: '100%',
minHeight: 300,
} as CSSProperties,
}
type ViewProps = {
myId: number,
announcement: Announcement,
}
function AnnouncementDetails({ close, announcement: { id, name, category, bestBy, description, lat, lng, address, metro } }: AnnouncementDetailsProps) {
const { handleBook, status: bookStatus } = useBook(id)
const View = ({
myId,
announcement: { name, category, bestBy, description, lat, lng, address, metro, userId },
}: ViewProps) => (
<>
return (
<div
className='modal'
style={styles.container}
>
<Modal.Dialog style={{ minWidth: '50vw' }}>
<Modal.Header closeButton onHide={close}>
<Modal.Title>
Подробнее
</Modal.Title>
</Modal.Header>
<Modal.Body>
<h1>{name}</h1>
<span>{categoryNames[category]}</span>
<span className='m-2'>&#x2022;</span>{/* dot */}
<span>Годен до {bestBy}</span>
<span>Годен до {new Date(bestBy).toLocaleString('ru-RU')}</span>
<p className='mb-0'>{description}</p>
<p className='mb-3'>{description}</p>
<p className='mb-3'>
Рейтинг пользователя: <StarRating dynamic={myId !== userId} userId={userId} />
</p>
<MapContainer style={styles.map} center={[lat, lng]} zoom={16} >
<MapContainer style={{ width: '100%', minHeight: 300 }} center={[lat, lng]} zoom={16} >
<TileLayer
attribution='&copy; <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}
@ -63,99 +59,14 @@ const View = ({
</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 centered className='modal-dialog'>
<Modal.Header closeButton onHide={close}>
<Modal.Title>
Подробнее
</Modal.Title>
</Modal.Header>
<Modal.Body>
<View myId={myId} announcement={announcement} />
</Modal.Body>
<Modal.Footer>
<Control
myId={myId}
closeRefresh={closeRefresh}
showDispose={() => setDisposeShow(true)}
announcement={announcement}
/>
<Button variant='success' onClick={() => void handleBook()}>
{bookStatus || 'Забронировать'}
</Button>
</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>
)
}

View File

@ -1,76 +1,54 @@
import { FormEventHandler, useCallback } from 'react'
import { Button, ButtonGroup, Form } from 'react-bootstrap'
import { useSignIn, useSignUp } from '../hooks/api'
import { composeSignUpBody } from '../api/signup'
import { FormEventHandler } from 'react'
import { Button, Form } from 'react-bootstrap'
type AuthFormProps = {
goBack: () => void,
register: boolean
handleAuth: FormEventHandler<HTMLFormElement>,
loading: boolean,
error: string
}
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])
function AuthForm ({ handleAuth, register, loading, error }: AuthFormProps) {
const buttonText = loading ? 'Загрузка...' : (error || (register ? 'Зарегистрироваться' : 'Войти'))
return (
<Form onSubmit={handleAuth}>
<Form.Group className='mb-3' controlId='username'>
<Form.Label>Как меня называть</Form.Label>
<Form.Control placeholder='Имя или псевдоним' name='username' type='text' required />
<Form.Group className='mb-3' controlId='email'>
<Form.Label>Почта</Form.Label>
<Form.Control type='email' 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 placeholder='Пароль' name='password' type='password' required />
<Form.Label>Пароль</Form.Label>
<Form.Control type='password' required />
</Form.Group>
<p>
Нажимая на кнопку, я даю своё согласие на обработку персональных данных и соглашаюсь с{' '}
<a
href={`${document.location.origin}/privacy_policy.pdf`}
target='_blank'
>условиями политики конфиденциальности</a>
</p>
{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>
}
<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>
<Button variant='success' type='submit'>
{buttonText}
</Button>
</Form>
)
}

View File

@ -1,27 +0,0 @@
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,
}
function BackHeader({ text, children }: PropsWithChildren<BackHeaderProps>) {
return (
<Navbar>
<Navbar.Brand as={Link} to='/'>
<img src={BackButton} alt='Назад' />
</Navbar.Brand>
<Navbar.Text className='me-auto'>
<h4 className='mb-0'>
{text}
</h4>
</Navbar.Text>
{children}
</Navbar>
)
}
export default BackHeader

View File

@ -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,20 +15,22 @@ 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: (state: boolean) => void,
toggleFilters: (p: boolean) => void
}
function BottomNavBar({ width, toggleFilters }: BottomNavBarProps) {
@ -36,7 +38,7 @@ function BottomNavBar({ width, toggleFilters }: BottomNavBarProps) {
<div style={styles.navBar}>
<div style={{ ...styles.navBarGroup, width: width }}>
<a href='#' style={styles.navBarElement} onClick={() => toggleFilters(true)}>
<a style={styles.navBarElement} onClick={() => toggleFilters(true)}>
<img src={filterIcon} alt='Фильтровать объявления' title='Фильтровать объявления' />
</a>
@ -44,7 +46,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>

View File

@ -1,23 +0,0 @@
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

View File

@ -1,29 +0,0 @@
import StoriesPreview from './StoriesPreview'
import { UserCategoriesNames, UserCategory, composeUserCategoriesFilters } from '../assets/userCategories'
import { useAnnouncements } from '../hooks/api'
import { gotError, gotResponse } from '../hooks/useFetch'
type CategoryPreviewProps = {
category: UserCategory,
}
function CategoryPreview({ category }: CategoryPreviewProps) {
const announcements = useAnnouncements(composeUserCategoriesFilters[category]())
return (
<section>
<h4 className='fw-bold'>{UserCategoriesNames[category]}</h4>
{gotError(announcements) ? (
<p className='text-danger'>{announcements.error}</p>
) : (
gotResponse(announcements) ? (
<StoriesPreview announcements={announcements.data} category={category} />
) : (
'Загрузка...'
)
)}
</section>
)
}
export default CategoryPreview

View File

@ -3,7 +3,7 @@ import { LatLng } from 'leaflet'
import { SetState } from '../utils/types'
function MapClickHandler({ setPosition }: { setPosition: SetState<LatLng> }) {
function ClickHandler({ setPosition }: { setPosition: SetState<LatLng> }) {
const map = useMapEvent('click', (e) => {
setPosition(e.latlng)
map.setView(e.latlng)
@ -12,4 +12,4 @@ function MapClickHandler({ setPosition }: { setPosition: SetState<LatLng> }) {
return null
}
export default MapClickHandler
export default ClickHandler

View File

@ -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>

View File

@ -3,9 +3,8 @@ 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]

View File

@ -1,38 +0,0 @@
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

View File

@ -7,10 +7,11 @@ 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())
@ -20,14 +21,13 @@ function LocationMarker({ address, position, setPosition }: LocationMarkerProps)
},
resize: () => {
setPosition(map.getCenter())
},
}
})
return (
<Marker icon={iconItem} position={position} zIndexOffset={1000}>
<Marker icon={iconItem} position={position}>
<Popup>
{address}
<br />
{position.lat.toFixed(4)}, {position.lng.toFixed(4)}
</Popup>
</Marker>

View File

@ -1,41 +0,0 @@
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

View File

@ -1,35 +0,0 @@
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

View File

@ -1,120 +0,0 @@
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='&copy; <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

View File

@ -1,22 +0,0 @@
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

View File

@ -1,93 +0,0 @@
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}
>&#9733;</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

View File

@ -1,115 +0,0 @@
import { Link } from 'react-router-dom'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { UserCategory, composeUserCategoriesFilters, userCategoriesInfos } from '../assets/userCategories'
import { Announcement } from '../api/announcement/types'
import { categoryGraphics, categoryNames } from '../assets/category'
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 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 StoriesPreviewCarousel({ announcements, category }: StoriesPreviewProps) {
const ulElement = useRef<HTMLUListElement | null>(null)
const [showScrollButtons, setShowScrollButtons] = useState({ left: false, right: false })
const determineShowScrollButtons = (ul: HTMLUListElement) => (
setShowScrollButtons({
left: ul.scrollLeft > 0,
right: ul.scrollLeft < (ul.scrollWidth - ul.clientWidth),
})
)
useEffect(() => {
const ul = ulElement.current
if (ul) {
determineShowScrollButtons(ul)
const f = () => determineShowScrollButtons(ul)
ul.addEventListener('scroll', f)
return () => ul.removeEventListener('scroll', f)
}
}, [])
useLayoutEffect(() => {
const ul = ulElement.current
if (ul) {
determineShowScrollButtons(ul)
}
}, [announcements])
const doScroll = (forward: boolean) => () => {
const ul = ulElement.current
if (ul) {
const storyWidth = window.innerHeight * 0.25 * 9 / 16 + 10
ul.scrollLeft += forward ? storyWidth : -storyWidth
}
}
return <div className={styles.container}>
{showScrollButtons.left &&
<button onClick={doScroll(false)} className={`${styles.scrollButton} ${styles.leftScrollButton}`}>
<img src={rightAngleIcon} alt='Показать ещё' />
</button>
}
<ul className={styles.list} ref={ulElement}>
<StoriesPreview announcements={announcements} category={category} />
</ul>
{showScrollButtons.right &&
<button onClick={doScroll(true)} className={`${styles.scrollButton} ${styles.rightScrollButton}`}>
<img src={rightAngleIcon} alt='Показать ещё' />
</button>
}
</div>
}
export default StoriesPreviewCarousel

View File

@ -1,36 +0,0 @@
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

View File

@ -5,38 +5,30 @@ 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
}
const TrashboxMarkers = ({ trashboxes, selectTrashbox }: TrashboxMarkersProps) => (
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 className='fw-bold m-0'>{trashbox.Name}</p>
<p className='m-0'>{trashbox.Address}</p>
<p>Тип мусора:{' '}
<p>{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 })
}}>
<a href='#' onClick={() => 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>
</></p>
<p>{trashbox.Lat.toFixed(4)}, {trashbox.Lng.toFixed(4)}</p>
</Popup>
</Marker>
))}</>
)
)
}
export default TrashboxMarkers

View File

@ -5,16 +5,5 @@ 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 MapClickHandler } from './MapClickHandler'
export { default as ClickHandler } from './ClickHandler'
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'

View File

@ -1,13 +1,6 @@
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'

View File

@ -1,26 +1,31 @@
import useSendWithButton from '../useSendWithButton'
import { useCallback } from 'react'
import { useSend } from '..'
import { composePutAnnouncementURL, processPutAnnouncement } from '../../api/putAnnouncement'
import { isPutAnnouncementResponse } from '../../api/putAnnouncement/types'
import useSendButtonCaption from '../useSendButtonCaption'
function useAddAnnouncement() {
const { doSend, button } = useSendWithButton(
'Опубликовать',
'Опубликовано',
true,
const useAddAnnouncement = () => {
const { doSend, loading, error } = useSend(
composePutAnnouncementURL(),
'PUT',
true,
isPutAnnouncementResponse,
processPutAnnouncement
processPutAnnouncement,
)
async function handleAdd(formData: FormData) {
await doSend({}, {
body: formData,
})
}
const { update, ...button } = useSendButtonCaption('Опубликовать', loading, error, 'Опубликовано')
return { handleAdd, addButton: button }
const doSendWithButton = useCallback(async (formData: FormData) => {
const data = await doSend({}, {
body: formData
})
update(data)
return data
}, [doSend, update])
return { doSend: doSendWithButton, button }
}
export default useAddAnnouncement

View File

@ -1,6 +1,7 @@
import useFetch from '../useFetch'
import { useFetch } from '../'
import { FiltersType } from '../../utils/filters'
import { composeAnnouncementsURL, initialAnnouncements, processAnnouncements } from '../../api/announcements'
import { isAnnouncementsResponse } from '../../api/announcements/types'
const useAnnouncements = (filters: FiltersType) => (

View File

@ -0,0 +1,117 @@
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

View File

@ -1,32 +1,74 @@
import { useCallback } from 'react'
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useSendWithButton } from '..'
import { composeBookURL, processBook } from '../../api/book'
import { isBookResponse } from '../../api/book/types'
import { getToken } from '../../utils/auth'
import { API_URL } from '../../config'
import { isObject } from '../../utils/types'
import { handleHTTPErrors } from '../../utils'
function useBook() {
const { doSend, button } = useSendWithButton('Забронировать',
'Забронировано',
true,
composeBookURL(),
'POST',
true,
isBookResponse,
processBook,
)
type BookResponse = {
Success: boolean
}
const handleBook = useCallback(async (id: number) => {
await doSend({}, {
const isBookResponse = (obj: unknown): obj is BookResponse => (
isObject(obj, {
'Success': 'boolean'
})
)
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: id
}),
headers: {
'Content-Type': 'application/json',
},
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
}
})
}, [doSend])
return { handleBook, bookButton: button }
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 }
}
export default useBook

View File

@ -1,35 +0,0 @@
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

View File

@ -1,6 +1,6 @@
import { LatLng } from 'leaflet'
import useFetch from '../useFetch'
import { useFetch } from '../'
import { composeOsmAddressURL, processOsmAddress } from '../../api/osmAddress'
import { isOsmAddressResponse } from '../../api/osmAddress/types'
@ -11,7 +11,7 @@ const useOsmAddresses = (addressPosition: LatLng) => (
false,
isOsmAddressResponse,
processOsmAddress,
'',
''
)
)

View File

@ -1,17 +0,0 @@
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

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