19 Commits

Author SHA1 Message Date
9b4ef41030 Added query filters getting and setting on homepage
fixes #27
2023-07-20 14:58:05 +03:00
96388a9bea Fixed userPage story preview link to use actual filter
Related to #27
2023-07-20 13:25:16 +03:00
58d1996ce3 Added story index management via query on homepage
Related to #27
2023-07-20 13:14:04 +03:00
bc154f8b6b Fixed useFetch and useUser typing 2023-07-20 00:55:12 +03:00
7a044970f0 Implemented UserPage 2023-07-19 23:25:25 +03:00
7cf83d099d Added api/user request prototype 2023-07-19 23:24:58 +03:00
1b4eed529a Developed userPage prototype 2023-07-19 23:23:01 +03:00
bc55ab8f68 Converted api/announcements to use api/announcement processer as its part 2023-07-18 23:07:41 +03:00
de8a1abcbf Fixed Dima's incomplete merge 2023-07-17 15:19:26 +03:00
898d590cf3 Обновить back/main.py 2023-07-17 13:23:23 +03:00
1c25d66027 Fixed trashbox request causing constant rerenders 2023-07-17 12:19:25 +03:00
b7bbd937b4 Converted put(api/announcement) to use useSend
Added useSendButtonCaption hook
Related to #19
2023-07-17 12:18:54 +03:00
b360b06d34 Возвращаем просто токен, а не джисонку 2023-07-16 23:25:22 +03:00
349d40daa4 Merge branch 'main' of https://git.dm1sh.ru/dm1sh/porridger 2023-07-16 16:15:05 +03:00
8bae63231b Добавить в остальные функции проверку токена 2023-07-16 16:10:37 +03:00
3668e8c33f Changed story dimensions
Improved logging
2023-07-16 00:18:30 +03:00
f4226237ab Added useSend hook, converted useFetch to use it
Moved useFetch
Related to #19
2023-07-16 00:08:51 +03:00
5b14d75048 Обновить back/main.py 2023-07-15 13:17:07 +03:00
0218cdced5 Code styling
Added brackets for const lambdas
Converted const lambdas with multiple instructions to functions
2023-07-15 12:55:25 +03:00
56 changed files with 1460 additions and 612 deletions

View File

@ -1,218 +1,219 @@
#подключение библиотек #подключение библиотек
from fastapi import FastAPI, Response, Path, Depends, Body, Form, Query, status, HTTPException, APIRouter, UploadFile, File 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.responses import HTMLResponse, FileResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.security import OAuth2PasswordRequestForm, OAuth2PasswordBearer from fastapi.security import OAuth2PasswordRequestForm, OAuth2PasswordBearer
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from fastapi.requests import Request from fastapi.requests import Request
from pydantic import json from pydantic import json
from starlette.staticfiles import StaticFiles from starlette.staticfiles import StaticFiles
import requests import requests
from uuid import uuid4 from uuid import uuid4
import ast import ast
import pathlib import pathlib
import shutil import shutil
import os import os
from .utils import * from .utils import *
from .models import Announcement, Trashbox, UserDatabase, Base from .models import Announcement, Trashbox, UserDatabase, Base
from .db import engine, SessionLocal from .db import engine, SessionLocal
from . import schema from . import schema
Base.metadata.create_all(bind=engine)
Base.metadata.create_all(bind=engine)
db = SessionLocal()
db = SessionLocal()
app = FastAPI()
app = FastAPI()
templates = Jinja2Templates(directory="./front/dist")
templates = Jinja2Templates(directory="./front/dist")
app.mount("/static", StaticFiles(directory = "./front/dist"))
app.mount("/static", StaticFiles(directory = "./front/dist")) app.mount("/uploads", StaticFiles(directory = "./uploads"))
app.mount("/uploads", StaticFiles(directory = "./uploads"))
@app.get("/api/announcements")#адрес объявлений @app.get("/api/announcements")#адрес объявлений
def annoncements_list(user_id: int = None, metro: str = None, category: str = None, booked_by: int = -1): def annoncements_list(user_id: int = None, metro: str = None, category: str = None, booked_by: int = -1):
# Считываем данные из Body и отображаем их на странице. # Считываем данные из Body и отображаем их на странице.
# В последствии будем вставлять данные в html-форму # В последствии будем вставлять данные в html-форму
a = db.query(Announcement) a = db.query(Announcement)
b = db.query(Announcement) b = db.query(Announcement)
c = db.query(Announcement) c = db.query(Announcement)
d = db.query(Announcement) d = db.query(Announcement)
e = db.query(Announcement) e = db.query(Announcement)
if user_id != None: if user_id != None:
b = a.filter(Announcement.user_id == user_id) b = a.filter(Announcement.user_id == user_id)
if metro != None: if metro != None:
c = a.filter(Announcement.metro == metro) c = a.filter(Announcement.metro == metro)
if category != None: if category != None:
d = a.filter(Announcement.category == category) d = a.filter(Announcement.category == category)
if booked_by != -1: if booked_by != -1:
e = a.filter(Announcement.booked_by == booked_by) e = a.filter(Announcement.booked_by == booked_by)
if not any([category, user_id, metro]) and booked_by == -1: if not any([category, user_id, metro]) and booked_by == -1:
result = a.all() result = a.all()
else: else:
result = b.intersect(c, d, e).all() result = b.intersect(c, d, e).all()
return {"Success" : True, "list_of_announcements": result} return {"Success" : True, "list_of_announcements": result}
@app.get("/api/announcement")#адрес объявлений @app.get("/api/announcement")#адрес объявлений
def single_annoncement(user_id:int): def single_annoncement(user_id:int):
# Считываем данные из Body и отображаем их на странице. # Считываем данные из Body и отображаем их на странице.
# В последствии будем вставлять данные в html-форму # В последствии будем вставлять данные в html-форму
try: try:
annoncement = db.get(Announcement, user_id) annoncement = db.get(Announcement, user_id)
return {"id": annoncement.id, "user_id": annoncement.user_id, "name": annoncement.name, return {"id": annoncement.id, "user_id": annoncement.user_id, "name": annoncement.name,
"category": annoncement.category, "best_by": annoncement.best_by, "address": annoncement.address, "category": annoncement.category, "best_by": annoncement.best_by, "address": annoncement.address,
"description": annoncement.description, "metro": annoncement.metro, "latitude": annoncement.latitude, "description": annoncement.description, "metro": annoncement.metro, "latitude": annoncement.latitude,
"longtitude":annoncement.longtitude, "trashId": annoncement.trashId, "src":annoncement.src, "longtitude":annoncement.longtitude, "trashId": annoncement.trashId, "src":annoncement.src,
"booked_by":annoncement.booked_by} "booked_by":annoncement.booked_by}
except: except:
return {"Answer" : False} #если неуданый доступ, то сообщаем об этом return {"Answer" : False} #если неуданый доступ, то сообщаем об этом
# Занести объявление в базу # Занести объявление в базу
@app.put("/api/announcement")#адрес объявлений @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: Annotated[UploadFile | None, File()], metro: Annotated[str, Form()], trashId: Annotated[int | None, Form()] = -1): 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: # try:
userId = 1 # temporary userId = 1 # temporary
uploaded_name = "" uploaded_name = ""
f = src.file f = src.file
f.seek(0, os.SEEK_END) f.seek(0, os.SEEK_END)
if f.tell() > 0: if f.tell() > 0:
f.seek(0) f.seek(0)
destination = pathlib.Path("./uploads/" + str(hash(src.file)) + pathlib.Path(src.filename).suffix.lower()) destination = pathlib.Path("./uploads/" + str(hash(src.file)) + pathlib.Path(src.filename).suffix.lower())
with destination.open('wb') as buffer: with destination.open('wb') as buffer:
shutil.copyfileobj(src.file, buffer) shutil.copyfileobj(src.file, buffer)
uploaded_name = "/uploads/"+destination.name 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) 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.add(temp_ancmt) # добавляем в бд
db.commit() # сохраняем изменения db.commit() # сохраняем изменения
db.refresh(temp_ancmt) # обновляем состояние объекта db.refresh(temp_ancmt) # обновляем состояние объекта
return {"Answer" : True} return {"Answer" : True}
# except: # except:
# return {"Answer" : False} # return {"Answer" : False}
# Удалить объявления из базы # Удалить объявления из базы
@app.delete("/api/announcement") #адрес объявления @app.delete("/api/announcement") #адрес объявления
def delete_from_db(data = Body()):#функция удаления объекта из БД def delete_from_db(data = Body()):#функция удаления объекта из БД
try: try:
db.delete(user_id=data.user_id)#удаление из БД db.delete(user_id=data.user_id)#удаление из БД
db.commit() # сохраняем изменения db.commit() # сохраняем изменения
return {"Answer" : True} return {"Answer" : True}
except: except:
return {"Answer" : False} return {"Answer" : False}
# Забронировать объявление # Забронировать объявление
@app.post("/api/book") @app.post("/api/book")
def change_book_status(data: schema.Book): def change_book_status(data: schema.Book):
try: try:
# Получаем id пользователя, который бронирует объявление # Получаем id пользователя, который бронирует объявление
temp_user_id = 1 temp_user_id = 1
# Находим объявление по данному id # Находим объявление по данному id
announcement_to_change = db.query(Announcement).filter(id == data.id).first() announcement_to_change = db.query(Announcement).filter(id == data.id).first()
# Изменяем поле booked_status на полученный id # Изменяем поле booked_status на полученный id
announcement_to_change.booked_status = temp_user_id announcement_to_change.booked_status = temp_user_id
return {"Success": True} return {"Success": True}
except: except:
return {"Success": False} return {"Success": False}
@app.post("/api/signup") @app.post("/api/signup")
def create_user(data = Body()): def create_user(data = Body()):
if db.query(UserDatabase).filter(User.email == data["email"]).first() == None: if db.query(UserDatabase).filter(UserDatabase.email == data["email"]).first() == None:
new_user = UserDatabase(id=data["id"], email=data["email"], password=data["password"], name=data["name"], surname=data["surname"]) new_user = UserDatabase(id=data["id"], email=data["email"], password=data["password"], name=data["name"], surname=data["surname"])
db.add(new_user) db.add(new_user)
db.commit() db.commit()
db.refresh(new_user) # обновляем состояние объекта db.refresh(new_user) # обновляем состояние объекта
return {"Success": True} return {"Success": True}
return {"Success": False, "Message": "Пользователь с таким email уже зарегестрирован."} return {"Success": False, "Message": "Пользователь с таким email уже зарегестрирован."}
@app.post("/api/token", response_model=Token) @app.post("/api/token", response_model=Token)
async def login_for_access_token( async def login_for_access_token(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()] form_data: Annotated[OAuth2PasswordRequestForm, Depends()]
): ):
user = authenticate_user(db.query(UserDatabase).all(), form_data.username, form_data.password) # разобраться с первым параметром
if not user: user = authenticate_user(db.query(UserDatabase).all(), form_data.username, form_data.password)
raise HTTPException( if not user:
status_code=status.HTTP_401_UNAUTHORIZED, raise HTTPException(
detail="Incorrect username or password", status_code=status.HTTP_401_UNAUTHORIZED,
headers={"WWW-Authenticate": "Bearer"}, detail="Incorrect username or password",
) headers={"WWW-Authenticate": "Bearer"},
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) )
access_token = create_access_token( access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
data={"user_id": user.id}, expires_delta=access_token_expires access_token = create_access_token(
) data={"user_id": user.id}, expires_delta=access_token_expires
return {"access_token": access_token, "token_type": "bearer"} )
return access_token
@app.get("/api/users/me/", response_model=User)
async def read_users_me( @app.get("/api/users/me/", response_model=User)
current_user: Annotated[User, Depends(get_current_active_user)] async def read_users_me(
): current_user: Annotated[User, Depends(get_current_active_user)]
return current_user ):
return current_user
@app.get("/api/users/me/items/")
async def read_own_items( @app.get("/api/users/me/items/")
current_user: Annotated[User, Depends(get_current_active_user)] 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}] ):
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 @app.get("/api/trashbox")
BASE_URL='https://geointelect2.gate.petersburg.ru'#адрес сайта и мой токин def get_trashboxes(lat:float, lng:float):#крутая функция для работы с api
my_token='eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJhU1RaZm42bHpTdURYcUttRkg1SzN5UDFhT0FxUkhTNm9OendMUExaTXhFIn0.eyJleHAiOjE3Nzg2NTk4MjEsImlhdCI6MTY4Mzk2NTQyMSwianRpIjoiOTI2ZGMyNmEtMGYyZi00OTZiLWI0NTUtMWQyYWM5YmRlMTZkIiwiaXNzIjoiaHR0cHM6Ly9rYy5wZXRlcnNidXJnLnJ1L3JlYWxtcy9lZ3MtYXBpIiwiYXVkIjoiYWNjb3VudCIsInN1YiI6ImJjYjQ2NzljLTU3ZGItNDU5ZC1iNWUxLWRlOGI4Yzg5MTMwMyIsInR5cCI6IkJlYXJlciIsImF6cCI6ImFkbWluLXJlc3QtY2xpZW50Iiwic2Vzc2lvbl9zdGF0ZSI6IjE2MGU1ZGVkLWFmMjMtNDkyNS05OTc1LTRhMzM0ZjVmNTkyOSIsImFjciI6IjEiLCJhbGxvd2VkLW9yaWdpbnMiOlsiLyoiXSwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbImRlZmF1bHQtcm9sZXMtZWdzLWFwaSIsIm9mZmxpbmVfYWNjZXNzIiwidW1hX2F1dGhvcml6YXRpb24iXX0sInJlc291cmNlX2FjY2VzcyI6eyJhY2NvdW50Ijp7InJvbGVzIjpbIm1hbmFnZS1hY2NvdW50IiwibWFuYWdlLWFjY291bnQtbGlua3MiLCJ2aWV3LXByb2ZpbGUiXX19LCJzY29wZSI6ImVtYWlsIHByb2ZpbGUiLCJzaWQiOiIxNjBlNWRlZC1hZjIzLTQ5MjUtOTk3NS00YTMzNGY1ZjU5MjkiLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsIm5hbWUiOiLQktC70LDQtNC40LzQuNGAINCv0LrQvtCy0LvQtdCyIiwicHJlZmVycmVkX3VzZXJuYW1lIjoiZTBmYzc2OGRhOTA4MjNiODgwZGQzOGVhMDJjMmQ5NTciLCJnaXZlbl9uYW1lIjoi0JLQu9Cw0LTQuNC80LjRgCIsImZhbWlseV9uYW1lIjoi0K_QutC-0LLQu9C10LIifQ.BRyUIyY-KKnZ9xqTNa9vIsfKF0UN2VoA9h4NN4y7IgBVLiiS-j43QbeE6qgjIQo0pV3J8jtCAIPvJbO-Ex-GNkw_flgMiGHhKEpsHPW3WK-YZ-XsZJzVQ_pOmLte-Kql4z97WJvolqiXT0nMo2dlX2BGvNs6JNbupvcuGwL4YYpekYAaFNYMQrxi8bSN-R7FIqxP-gzZDAuQSWRRSUqVBLvmgRhphTM-FAx1sX833oXL9tR7ze3eDR_obSV0y6cKVIr4eIlKxFd82qiMrN6A6CTUFDeFjeAGERqeBPnJVXU36MHu7Ut7eOVav9OUARARWRkrZRkqzTfZ1iqEBq5Tsg' BASE_URL='https://geointelect2.gate.petersburg.ru'#адрес сайта и мой токин
head = {'Authorization': 'Bearer {}'.format(my_token)} 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}", my_data={
'y' : f"{lat}", 'x' : f"{lng}",
'limit' : '1' 'y' : f"{lat}",
} 'limit' : '1'
}
response = requests.post(f"{BASE_URL}/nearest_recycling/get", headers=head, data=my_data)
infos = response.json() response = requests.post(f"{BASE_URL}/nearest_recycling/get", headers=head, data=my_data)
infos = response.json()
trashboxes = []
for trashbox in infos["results"]: trashboxes = []
temp_dict = {} for trashbox in infos["results"]:
for obj in trashbox["Objects"]: temp_dict = {}
coord_list = obj["geometry"] for obj in trashbox["Objects"]:
temp_dict["Lat"] = coord_list["coordinates"][1] coord_list = obj["geometry"]
temp_dict["Lng"] = coord_list["coordinates"][0] temp_dict["Lat"] = coord_list["coordinates"][1]
temp_dict["Lng"] = coord_list["coordinates"][0]
properties = obj["properties"]
temp_dict["Name"] = properties["title"] properties = obj["properties"]
temp_dict["Address"] = properties["address"] temp_dict["Name"] = properties["title"]
temp_dict["Categories"] = properties["content_text"].split(',') temp_dict["Address"] = properties["address"]
trashboxes.append(temp_dict) 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) 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): @app.get("/{rest_of_path:path}")
return templates.TemplateResponse('index.html', { 'request': req }) async def react_app(req: Request, rest_of_path: str):
return templates.TemplateResponse('index.html', { 'request': req })

View File

@ -1,7 +1,7 @@
from sqlalchemy import Column, Integer, String from sqlalchemy import Column, Integer, String
from .db import Base from .db import Base
# from db import Base
class UserDatabase(Base):#класс пользователя class UserDatabase(Base):#класс пользователя
__tablename__ = "users" __tablename__ = "users"

View File

@ -1,47 +1,3 @@
# from passlib.context import CryptContext
# import os
# from datetime import datetime, timedelta
# from typing import Union, Any
# from jose import jwt
# ACCESS_TOKEN_EXPIRE_MINUTES = 30 # 30 minutes
# REFRESH_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days
# ALGORITHM = "HS256"
# # В предположении, что попыток взлома не будет, возьмем простейший ключ
# JWT_SECRET_KEY = "secret key" # может также быть os.environ["JWT_SECRET_KEY"]
# JWT_REFRESH_SECRET_KEY = "refresh secret key" # может также быть os.environ["JWT_REFRESH_SECRET_KEY"]
# def get_hashed_password(password: str) -> str:
# return password_context.hash(password)
# def verify_password(password: str, hashed_pass: str) -> bool:
# return password_context.verify(password, hashed_pass)
# def create_access_token(subject: Union[str, Any], expires_delta: int = None) -> str:
# if expires_delta is not None:
# expires_delta = datetime.utcnow() + expires_delta
# else:
# expires_delta = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
# to_encode = {"exp": expires_delta, "sub": str(subject)}
# encoded_jwt = jwt.encode(to_encode, JWT_SECRET_KEY, ALGORITHM)
# return encoded_jwt
# def create_refresh_token(subject: Union[str, Any], expires_delta: int = None) -> str:
# if expires_delta is not None:
# expires_delta = datetime.utcnow() + expires_delta
# else:
# expires_delta = datetime.utcnow() + timedelta(minutes=REFRESH_TOKEN_EXPIRE_MINUTES)
# to_encode = {"exp": expires_delta, "sub": str(subject)}
# encoded_jwt = jwt.encode(to_encode, JWT_REFRESH_SECRET_KEY, ALGORITHM)
# return encoded_jwt
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Annotated, Union from typing import Annotated, Union
@ -79,7 +35,7 @@ class TokenData(BaseModel):
class User(BaseModel): class User(BaseModel):
email: str # email: str
email: Union[str, None] = None email: Union[str, None] = None
# password: str # password: str
# password: Union[str, None] = None # password: Union[str, None] = None
@ -103,13 +59,14 @@ def get_password_hash(password):
return pwd_context.hash(password) return pwd_context.hash(password)
# проблема здесь
def get_user(db, email: str): def get_user(db, email: str):
user = None user = None
for person_with_correct_email in db: for person_with_correct_email in db.query(UserDatabase):
if person_with_correct_email.email == email: if person_with_correct_email.email == email:
user = person_with_correct_email user = person_with_correct_email
break return user #UserInDB(user_email)
return user #UserInDB(user_email)

View File

@ -20,6 +20,7 @@
"react-router-dom": "^6.14.1" "react-router-dom": "^6.14.1"
}, },
"devDependencies": { "devDependencies": {
"@faker-js/faker": "^8.0.2",
"@types/react": "^18.2.14", "@types/react": "^18.2.14",
"@types/react-dom": "^18.2.6", "@types/react-dom": "^18.2.6",
"@typescript-eslint/eslint-plugin": "^5.61.0", "@typescript-eslint/eslint-plugin": "^5.61.0",
@ -817,6 +818,22 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0" "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": { "node_modules/@humanwhocodes/config-array": {
"version": "0.11.10", "version": "0.11.10",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.10.tgz", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.10.tgz",

View File

@ -24,6 +24,7 @@
"react-router-dom": "^6.14.1" "react-router-dom": "^6.14.1"
}, },
"devDependencies": { "devDependencies": {
"@faker-js/faker": "^8.0.2",
"@types/react": "^18.2.14", "@types/react": "^18.2.14",
"@types/react-dom": "^18.2.6", "@types/react-dom": "^18.2.6",
"@typescript-eslint/eslint-plugin": "^5.61.0", "@typescript-eslint/eslint-plugin": "^5.61.0",

315
front/prototype.html Normal file
View File

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

View File

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

View File

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

@ -17,21 +17,23 @@ type AnnouncementResponse = {
booked_by: number booked_by: number
} }
const isAnnouncementResponse = (obj: unknown): obj is AnnouncementResponse => isObject(obj, { const isAnnouncementResponse = (obj: unknown): obj is AnnouncementResponse => (
'id': 'number', isObject(obj, {
'user_id': 'number', 'id': 'number',
'name': 'string', 'user_id': 'number',
'category': isCategory, 'name': 'string',
'best_by': 'number', 'category': isCategory,
'address': 'string', 'best_by': 'number',
'longtitude': 'number', 'address': 'string',
'latitude': 'number', 'longtitude': 'number',
'description': 'string', 'latitude': 'number',
'src': 'string?', 'description': 'string',
'metro': 'string', 'src': 'string?',
'trashId': 'number?', 'metro': 'string',
'booked_by': 'number' 'trashId': 'number?',
}) 'booked_by': 'number'
})
)
type Announcement = { type Announcement = {
id: number, id: number,

View File

@ -1,24 +1,17 @@
import { API_URL } from '../../config' import { API_URL } from '../../config'
import { FiltersType, URLEncodeFilters } from '../../utils/filters' import { FiltersType, URLEncodeFilters } from '../../utils/filters'
import { processAnnouncement } from '../announcement'
import { Announcement } from '../announcement/types' import { Announcement } from '../announcement/types'
import { AnnouncementsResponse } from './types' import { AnnouncementsResponse } from './types'
const initialAnnouncements: Announcement[] = [] const initialAnnouncements: Announcement[] = []
const composeAnnouncementsURL = (filters: FiltersType) => const composeAnnouncementsURL = (filters: FiltersType) => (
API_URL + '/announcements?' + new URLSearchParams(URLEncodeFilters(filters)).toString() API_URL + '/announcements?' + new URLSearchParams(URLEncodeFilters(filters)).toString()
)
const processAnnouncements = (data: AnnouncementsResponse): Announcement[] => { const processAnnouncements = (data: AnnouncementsResponse): Announcement[] => (
const annList = data.list_of_announcements data.list_of_announcements.map(processAnnouncement)
)
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 } export { initialAnnouncements, composeAnnouncementsURL, processAnnouncements }

View File

@ -6,10 +6,12 @@ type AnnouncementsResponse = {
Success: boolean Success: boolean
} }
const isAnnouncementsResponse = (obj: unknown): obj is AnnouncementsResponse => isObject(obj, { const isAnnouncementsResponse = (obj: unknown): obj is AnnouncementsResponse => (
'list_of_announcements': obj => isArrayOf<AnnouncementResponse>(obj, isAnnouncementResponse), isObject(obj, {
'Success': 'boolean' 'list_of_announcements': obj => isArrayOf<AnnouncementResponse>(obj, isAnnouncementResponse),
}) 'Success': 'boolean'
})
)
export type { export type {
AnnouncementsResponse, AnnouncementsResponse,

View File

@ -3,10 +3,12 @@ import { OsmAddressResponse } from './types'
const initialOsmAddress = '' const initialOsmAddress = ''
const composeOsmAddressURL = (addressPosition: LatLng) => const composeOsmAddressURL = (addressPosition: LatLng) => (
`${location.protocol}//nominatim.openstreetmap.org/reverse?format=json&accept-language=ru&lat=${addressPosition.lat}&lon=${addressPosition.lng}` `${location.protocol}//nominatim.openstreetmap.org/reverse?format=json&accept-language=ru&lat=${addressPosition.lat}&lon=${addressPosition.lng}`
)
const processOsmAddress = (data: OsmAddressResponse): string => const processOsmAddress = (data: OsmAddressResponse): string => (
data.display_name data.display_name
)
export { initialOsmAddress, composeOsmAddressURL, processOsmAddress } export { initialOsmAddress, composeOsmAddressURL, processOsmAddress }

View File

@ -4,9 +4,11 @@ type OsmAddressResponse = {
display_name: string display_name: string
} }
const isOsmAddressResponse = (obj: unknown): obj is OsmAddressResponse => isObject(obj, { const isOsmAddressResponse = (obj: unknown): obj is OsmAddressResponse => (
'display_name': 'string', isObject(obj, {
}) 'display_name': 'string',
})
)
export type { export type {
OsmAddressResponse, OsmAddressResponse,

View File

@ -0,0 +1,12 @@
import { API_URL } from '../../config'
import { PutAnnouncement, PutAnnouncementResponse } from './types'
const composePutAnnouncementURL = () => (
API_URL + '/announcement?'
)
const processPutAnnouncement = (data: PutAnnouncementResponse): PutAnnouncement => {
return data.Answer
}
export { composePutAnnouncementURL, processPutAnnouncement }

View File

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

View File

@ -1,11 +1,16 @@
import { LatLng } from 'leaflet' import { LatLng } from 'leaflet'
import { API_URL } from '../../config' import { API_URL } from '../../config'
import { Trashbox, TrashboxResponse } from './types'
const composeTrashboxURL = (position: LatLng) => const composeTrashboxURL = (position: LatLng) => (
API_URL + '/trashbox?' + new URLSearchParams({ API_URL + '/trashbox?' + new URLSearchParams({
lat: position.lat.toString(), lat: position.lat.toString(),
lng: position.lng.toString() lng: position.lng.toString()
}).toString() }).toString()
)
export { composeTrashboxURL } const processTrashbox = (data: TrashboxResponse): Trashbox[] =>
data
export { composeTrashboxURL, processTrashbox }

View File

@ -7,16 +7,20 @@ type Trashbox = {
Categories: string[] Categories: string[]
} }
const isTrashbox = (obj: unknown): obj is Trashbox => isObject(obj, { const isTrashbox = (obj: unknown): obj is Trashbox => (
'Lat': 'number', isObject(obj, {
'Lng': 'number', 'Lat': 'number',
'Address': 'string', 'Lng': 'number',
'Categories': obj => isArrayOf<string>(obj, isString) 'Address': 'string',
}) 'Categories': obj => isArrayOf<string>(obj, isString)
})
)
type TrashboxResponse = Trashbox[] type TrashboxResponse = Trashbox[]
const isTrashboxResponse = (obj: unknown): obj is Trashbox[] => isArrayOf(obj, isTrashbox) const isTrashboxResponse = (obj: unknown): obj is Trashbox[] => (
isArrayOf(obj, isTrashbox)
)
export type { Trashbox, TrashboxResponse } export type { Trashbox, TrashboxResponse }
export { isTrashbox, isTrashboxResponse } export { isTrashbox, isTrashboxResponse }

View File

@ -0,0 +1,25 @@
import { API_URL } from '../../config'
import { UserResponse, User } from './types'
import { faker } from '@faker-js/faker/locale/ru'
const initialUser: User = import.meta.env.DEV ? { // Temporary, until api is realized
id: Math.random() * 100,
name: faker.person.firstName() + ' ' + faker.person.lastName(),
regDate: faker.date.anytime().getTime(),
} : {
id: -1,
name: '',
regDate: 0,
}
const composeUserURL = () => (
API_URL + '/user?'
)
const processUser = (data: UserResponse): User => {
return data
}
export { initialUser, composeUserURL, processUser }

View File

@ -0,0 +1,29 @@
import { isObject } from '../../utils/types'
type User = {
id: number,
name: string,
regDate: number,
}
const isUser = (obj: unknown): obj is User => (
isObject(obj, {
'id': 'number',
'name': 'string',
'regDate': 'number',
})
)
type UserResponse = User
// const isUserResponse = (obj: unknown): obj is UserResponse => (
// isObject(obj, {
// })
// )
const isUserResponse = isUser
export type { UserResponse, User }
export { isUserResponse, isUser }

View File

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

After

Width:  |  Height:  |  Size: 329 B

View File

@ -4,7 +4,9 @@ const categories = ['PORRIDGE', 'conspects', 'milk', 'bred', 'wathing', 'cloth',
'fruits_vegatables', 'soup', 'dinner', 'conserves', 'pens', 'other_things'] as const 'fruits_vegatables', 'soup', 'dinner', 'conserves', 'pens', 'other_things'] as const
type Category = typeof categories[number] type Category = typeof categories[number]
const isCategory = (obj: unknown): obj is Category => isLiteralUnion(obj, categories) const isCategory = (obj: unknown): obj is Category => (
isLiteralUnion(obj, categories)
)
const categoryGraphics: Record<Category, string> = { const categoryGraphics: Record<Category, string> = {
'PORRIDGE': 'static/PORRIDGE.jpg', 'PORRIDGE': 'static/PORRIDGE.jpg',

View File

@ -101,9 +101,9 @@ const lineNames: Record<Lines, string> = {
violet: 'Фиолетовая', violet: 'Фиолетовая',
} }
const lineByName = (name: string) => const lineByName = (name: string) => (
lines.find(line => stations[line].has(name)) lines.find(line => stations[line].has(name))
)
export type { Lines } export type { Lines }
export { lines, stations, colors, lineNames, lineByName } export { lines, stations, colors, lineNames, lineByName }

View File

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

After

Width:  |  Height:  |  Size: 569 B

View File

@ -0,0 +1,45 @@
import { Announcement } from '../api/announcement/types'
import { FiltersType } from '../utils/filters'
const userCategories = ['givingOut', 'booked', 'history'] as const
type UserCategory = typeof userCategories[number]
const UserCategoriesNames: Record<UserCategory, string> = {
givingOut: 'Раздача',
booked: 'Бронь',
history: 'История',
}
const userCategoriesInfos: Record<UserCategory, (ann: Announcement) => string> = {
givingOut: (ann: Announcement) => (
`Годен до ${new Date(ann.bestBy).toLocaleDateString('ru')}`
),
booked: (ann: Announcement) => (
`Бронь ещё ${(ann as Announcement & { bookedBy: number[] }).bookedBy.length} чел.`
),
history: (ann: Announcement) => (
`Забрал ${new Date((ann as Announcement & { taken: number }).taken).toLocaleDateString('ru')}`
),
}
const composeUserCategoriesFilters: Record<UserCategory, () => FiltersType> = {
givingOut: () => {
const userId = -1
return ({ userId })
},
booked: () => {
const userId = -1
return ({ bookedBy: userId })
},
history: () => {
const userId = -1
return ({ userId, status: 'taken' })
}
}
export type { UserCategory }
export { userCategories, UserCategoriesNames, userCategoriesInfos, composeUserCategoriesFilters }

View File

@ -8,8 +8,9 @@ type AuthFormProps = {
error: string error: string
} }
const AuthForm = ({ handleAuth, register, loading, error }: AuthFormProps) => { function AuthForm ({ handleAuth, register, loading, error }: AuthFormProps) {
const buttonText = loading ? 'Загрузка...' : (error || (register ? 'Зарегистрироваться' : 'Войти')) const buttonText = loading ? 'Загрузка...' : (error || (register ? 'Зарегистрироваться' : 'Войти'))
return ( return (
<Form onSubmit={handleAuth}> <Form onSubmit={handleAuth}>
<Form.Group className='mb-3' controlId='email'> <Form.Group className='mb-3' controlId='email'>

View File

@ -0,0 +1,25 @@
import { Link } from 'react-router-dom'
import { Navbar } from 'react-bootstrap'
import BackButton from '../assets/backArrow.svg'
type BackHeaderProps = {
text: string
}
function BackHeader({ text }: BackHeaderProps) {
return (
<Navbar>
<Navbar.Brand as={Link} to='/'>
<img src={BackButton} alt='Go back' />
</Navbar.Brand>
<Navbar.Text className='me-auto'>
<h4 className='mb-0'>
{text}
</h4>
</Navbar.Text>
</Navbar>
)
}
export default BackHeader

View File

@ -0,0 +1,25 @@
import { StoriesPreview } from '.'
import { UserCategoriesNames, UserCategory, composeUserCategoriesFilters } from '../assets/userCategories'
import { useAnnouncements } from '../hooks/api'
import { gotError } 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>
) : (announcements.loading ? 'Загрузка...' :
<StoriesPreview announcements={announcements.data} category={category} />
)}
</section>
)
}
export default CategoryPreview

View File

@ -10,7 +10,7 @@ type LocationMarkerProps = {
setPosition: SetState<LatLng> setPosition: SetState<LatLng>
} }
const LocationMarker = ({ address, position, setPosition }: LocationMarkerProps) => { function LocationMarker({ address, position, setPosition }: LocationMarkerProps) {
const map = useMapEvents({ const map = useMapEvents({
dragend: () => { dragend: () => {

View File

@ -0,0 +1,143 @@
import { Link } from 'react-router-dom'
import { CSSProperties, useEffect, useMemo, useRef, useState } from 'react'
import { Button } from 'react-bootstrap'
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'
type StoriesPreviewProps = {
announcements: Announcement[],
category: UserCategory,
}
const styles = {
container: {
transform: 'translateX(0)',
} as CSSProperties,
ul: {
display: 'flex',
gap: 10,
listStyleType: 'none',
overflow: 'scroll',
paddingLeft: 0,
scrollBehavior: 'smooth',
} as CSSProperties,
link: {
textDecoration: 'none',
color: 'var(--bs-body-color)'
} as CSSProperties,
image: {
height: '25vh',
maxWidth: 'calc(25vh * 9 / 16)',
objectFit: 'contain',
borderRadius: 12,
marginBottom: 5,
} as CSSProperties,
title: {
overflow: 'hidden',
textOverflow: 'ellipsis',
marginBottom: 5,
} as CSSProperties,
scrollButton: {
position: 'fixed',
right: 0,
top: 0,
zIndex: 100,
background: 'linear-gradient(to right, rgba(17, 17, 17, 0) 0%, rgba(17, 17, 17, 255) 100%)',
display: 'block',
height: '100%',
width: '10%',
border: 'none',
cursor: 'default',
borderRadius: 0,
} as CSSProperties,
leftScrollButton: {
left: 0,
transform: 'scaleX(-1)'
} as CSSProperties,
rightScrollButton: {
right: 0,
} as CSSProperties,
}
function StoriesPreview({ 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)
}
}, [])
useEffect(() => {
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 style={styles.container}>
{showScrollButtons.left &&
<Button onClick={doScroll(false)} style={{ ...styles.scrollButton, ...styles.leftScrollButton }}>
<img src={rightAngleIcon} alt='Показать ещё' />
</Button>
}
<ul style={styles.ul} className='StoriesPreview_ul' ref={ulElement}>
{useMemo(() => announcements.map((ann, i) => (
<li key={`${category}${i}`}>
<Link to={'/?' + new URLSearchParams({
...URLEncodeFilters(composeUserCategoriesFilters[category]()),
storyIndex: i.toString()
}).toString()} style={styles.link}>
{ann.src?.endsWith('mp4') ? (
<video src={ann.src} style={styles.image} />
) : (
<img
src={ann.src || categoryGraphics[ann.category]}
alt={'Изображение' + (ann.src ? 'предмета' : categoryNames[ann.category])}
style={styles.image}
/>
)}
<p style={styles.title}>{ann.name}</p>
<p style={styles.title}>{userCategoriesInfos[category](ann)}</p>
</Link>
</li>
)), [announcements, category])}
</ul>
{showScrollButtons.right &&
<Button onClick={doScroll(true)} style={{ ...styles.scrollButton, ...styles.rightScrollButton }}>
<img src={rightAngleIcon} alt='Показать ещё' />
</Button>
}
</div>
}
export default StoriesPreview

View File

@ -8,7 +8,7 @@ type TrashboxMarkersProps = {
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 ( return (
<>{trashboxes.map((trashbox, index) => ( <>{trashboxes.map((trashbox, index) => (
<Marker icon={iconTrash} key={`${trashbox.Lat}${trashbox.Lng}`} position={[trashbox.Lat, trashbox.Lng]}> <Marker icon={iconTrash} key={`${trashbox.Lat}${trashbox.Lng}`} position={[trashbox.Lat, trashbox.Lng]}>

View File

@ -7,3 +7,6 @@ export { default as TrashboxMarkers } from './TrashboxMarkers'
export { default as WithToken } from './WithToken' export { default as WithToken } from './WithToken'
export { default as ClickHandler } from './ClickHandler' export { default as ClickHandler } from './ClickHandler'
export { default as AuthForm } from './AuthForm' export { default as AuthForm } from './AuthForm'
export { default as BackHeader } from './BackHeader'
export { default as CategoryPreview } from './CategoryPreview'
export { default as StoriesPreview } from './StoriesPreview'

View File

@ -4,3 +4,4 @@ export { default as useAuth } from './useAuth'
export { default as useTrashboxes } from './useTrashboxes' export { default as useTrashboxes } from './useTrashboxes'
export { default as useAddAnnouncement } from './useAddAnnouncement' export { default as useAddAnnouncement } from './useAddAnnouncement'
export { default as useOsmAddresses } from './useOsmAddress' export { default as useOsmAddresses } from './useOsmAddress'
export { default as useUser } from './useUser'

View File

@ -1,77 +1,31 @@
import { useEffect, useRef, useState } from 'react' import { useCallback } from 'react'
import { API_URL } from '../../config'
import { isLiteralUnion } from '../../utils/types'
import { handleHTTPErrors } from '../../utils'
const addErrors = ['Не удалось опубликовать объявление', 'Неверный ответ от сервера', 'Неизвестная ошибка'] as const
type AddError = typeof addErrors[number]
const isAddError = (obj: unknown): obj is AddError => isLiteralUnion(obj, addErrors)
const buttonStates = ['Опубликовать', 'Загрузка...', 'Опубликовано', 'Отменено'] as const
type ButtonState = typeof buttonStates[number] | AddError
type AddResponse = {
Answer: boolean
}
const isAddResponse = (obj: unknown): obj is AddResponse =>
typeof obj === 'object' && obj !== null && typeof Reflect.get(obj, 'Answer') === 'boolean'
import { useSend } from '..'
import { composePutAnnouncementURL, processPutAnnouncement } from '../../api/putAnnouncement'
import { isPutAnnouncementResponse } from '../../api/putAnnouncement/types'
import useSendButtonCaption from '../useSendButtonCaption'
const useAddAnnouncement = () => { const useAddAnnouncement = () => {
const [status, setStatus] = useState<ButtonState>('Опубликовать') const { doSend, loading, error } = useSend(
composePutAnnouncementURL(),
'PUT',
true,
isPutAnnouncementResponse,
processPutAnnouncement,
)
const timerIdRef = useRef<number>() const { update, ...button } = useSendButtonCaption('Опубликовать', loading, error, 'Опубликовано')
const abortControllerRef = useRef<AbortController>()
const doAdd = async (formData: FormData) => { const doSendWithButton = useCallback(async (formData: FormData) => {
if (status === 'Загрузка...') { const data = await doSend({}, {
abortControllerRef.current?.abort() body: formData
setStatus('Отменено') })
timerIdRef.current = setTimeout(() => setStatus('Опубликовать'), 3000) update(data)
return
}
setStatus('Загрузка...') return data
}, [doSend, update])
const abortController = new AbortController() return { doSend: doSendWithButton, button }
abortControllerRef.current = abortController
try {
const res = await fetch(API_URL + '/announcement', {
method: 'PUT',
body: formData,
signal: abortController.signal
})
handleHTTPErrors(res)
const data: unknown = await res.json()
if (!isAddResponse(data)) throw new Error('Неверный ответ от сервера')
if (!data.Answer) {
throw new Error('Не удалось опубликовать объявление')
}
setStatus('Опубликовано')
} catch (err) {
setStatus(isAddError(err) ? err : 'Неизвестная ошибка')
timerIdRef.current = setTimeout(() => setStatus('Опубликовать'), 10000)
}
}
useEffect(() => {
const abortController = abortControllerRef.current
return () => {
clearTimeout(timerIdRef.current)
abortController?.abort()
}
})
return { doAdd, status }
} }
export default useAddAnnouncement export default useAddAnnouncement

View File

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

View File

@ -38,16 +38,18 @@ interface LogInResponse {
token_type: 'bearer' token_type: 'bearer'
} }
const isLogInResponse = (obj: unknown): obj is LogInResponse => isObject(obj, { const isLogInResponse = (obj: unknown): obj is LogInResponse => (
'access_token': 'string', isObject(obj, {
'token_type': isConst('bearer') 'access_token': 'string',
}) 'token_type': isConst('bearer')
})
)
function useAuth() { function useAuth() {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const doAuth = async (data: AuthData, newAccount: boolean) => { async function doAuth(data: AuthData, newAccount: boolean) {
setLoading(true) setLoading(true)
if (newAccount) { if (newAccount) {

View File

@ -10,9 +10,11 @@ type BookResponse = {
Success: boolean Success: boolean
} }
const isBookResponse = (obj: unknown): obj is BookResponse => isObject(obj, { const isBookResponse = (obj: unknown): obj is BookResponse => (
'Success': 'boolean' isObject(obj, {
}) 'Success': 'boolean'
})
)
type BookStatus = '' | 'Загрузка...' | 'Забронировано' | 'Ошибка бронирования' type BookStatus = '' | 'Загрузка...' | 'Забронировано' | 'Ошибка бронирования'

View File

@ -1,128 +0,0 @@
import { useEffect, useRef, useState } from 'react'
import { handleHTTPErrors, isAborted } from '../../utils'
import { getToken } from '../../utils/auth'
import { useNavigate } from 'react-router-dom'
import { SetState } from '../../utils/types'
type UseFetchShared = {
loading: boolean,
abort?: () => void,
}
type UseFetchSucced<T> = {
error: null,
data: T,
} & UseFetchShared
type UseFetchErrored = {
error: string,
data: undefined
} & UseFetchShared
const gotError = <T>(res: UseFetchErrored | UseFetchSucced<T>): res is UseFetchErrored =>
typeof res.error === 'string'
const fallbackError = <T>(res: UseFetchSucced<T> | UseFetchErrored) =>
gotError(res) ? res.error : res.data
type UseFetchReturn<T> = ({
error: null,
data: T
} | {
error: string,
data: undefined
}) & {
loading: boolean,
setData: SetState<T | undefined>
abort?: (() => void)
}
const useFetch = <R, T>(
url: string,
method: RequestInit['method'],
needAuth: boolean,
guardResponse: (data: unknown) => data is R,
processData: (data: R) => T,
initialData?: T,
params?: RequestInit
): UseFetchReturn<T> => {
const [data, setData] = useState(initialData)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const navigate = useNavigate()
const abortControllerRef = useRef<AbortController>()
useEffect(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
const abortController = new AbortController()
abortControllerRef.current = abortController
const headers = new Headers({
...params?.headers
})
if (needAuth) {
const token = getToken()
if (token === null) {
return navigate('/login')
}
headers.append('Auth', `Bearer ${token}`)
}
fetch(url, {
method,
...params,
headers,
signal: abortControllerRef.current.signal,
})
.then(res => {
handleHTTPErrors(res)
return res.json()
})
.then(data => {
if (!guardResponse(data)) {
throw new Error('Malformed server response')
}
setData(processData(data))
setLoading(false)
})
.catch(err => {
if (err instanceof Error && !isAborted(err)) {
setError('Ошибка сети')
if (import.meta.env.DEV) {
console.log(url, params, err)
}
}
setLoading(false)
})
return () => abortControllerRef.current?.abort()
}, [url, method, needAuth, params, guardResponse, processData, navigate])
return {
...(
error === null ? ({
data: data!, error: null
}) : ({ data: undefined, error })
),
loading,
setData,
abort: abortControllerRef.current?.abort.bind(abortControllerRef.current)
}
}
export default useFetch
export { gotError, fallbackError }

View File

@ -1,10 +1,10 @@
import { LatLng } from 'leaflet' import { LatLng } from 'leaflet'
import useFetch from './useFetch' import { useFetch } from '../'
import { composeOsmAddressURL, processOsmAddress } from '../../api/osmAddress' import { composeOsmAddressURL, processOsmAddress } from '../../api/osmAddress'
import { isOsmAddressResponse } from '../../api/osmAddress/types' import { isOsmAddressResponse } from '../../api/osmAddress/types'
const useOsmAddresses = (addressPosition: LatLng) => const useOsmAddresses = (addressPosition: LatLng) => (
useFetch( useFetch(
composeOsmAddressURL(addressPosition), composeOsmAddressURL(addressPosition),
'GET', 'GET',
@ -13,5 +13,6 @@ const useOsmAddresses = (addressPosition: LatLng) =>
processOsmAddress, processOsmAddress,
'' ''
) )
)
export default useOsmAddresses export default useOsmAddresses

View File

@ -1,18 +1,18 @@
import { LatLng } from 'leaflet' import { LatLng } from 'leaflet'
import useFetch from './useFetch' import { useFetch } from '../'
import { composeTrashboxURL } from '../../api/trashbox' import { composeTrashboxURL, processTrashbox } from '../../api/trashbox'
import { isTrashboxResponse } from '../../api/trashbox/types' import { isTrashboxResponse } from '../../api/trashbox/types'
const useTrashboxes = (position: LatLng) => const useTrashboxes = (position: LatLng) => (
useFetch( useFetch(
composeTrashboxURL(position), composeTrashboxURL(position),
'GET', 'GET',
true, true,
isTrashboxResponse, isTrashboxResponse,
(data) => data, processTrashbox,
[] []
) )
)
export default useTrashboxes export default useTrashboxes

View File

@ -0,0 +1,23 @@
import { initialUser } from '../../api/user'
import { User } from '../../api/user/types'
import { UseFetchReturn } from '../useFetch'
const useUser = (): UseFetchReturn<User> => (
// useFetch(
// composeUserUrl(getToken()),
// 'GET',
// true,
// isUserResponse,
// processUser,
// initialUser
// )
{
data: initialUser,
loading: false,
error: null,
setData: () => {0}
}
)
export default useUser

View File

@ -1 +1,5 @@
export { default as useStoryDimensions } from './useStoryDimensions' export { default as useStoryDimensions } from './useStoryDimensions'
export { default as useSend } from './useSend'
export { default as useFetch } from './useFetch'
export { default as useStoryIndex } from './useStoryIndex'
export { default as useFilters } from './useFilters'

View File

@ -0,0 +1,68 @@
import { useEffect, useState } from 'react'
import { SetState } from '../utils/types'
import useSend from './useSend'
type UseFetchShared<T> = {
loading: boolean,
abort?: () => void,
setData: SetState<T | undefined>
}
type UseFetchSucced<T> = {
error: null,
data: T,
} & UseFetchShared<T>
type UseFetchErrored<T> = {
error: string,
data: undefined
} & UseFetchShared<T>
const gotError = <T>(res: UseFetchErrored<T> | UseFetchSucced<T>): res is UseFetchErrored<T> => (
typeof res.error === 'string'
)
const fallbackError = <T>(res: UseFetchSucced<T> | UseFetchErrored<T>) => (
gotError(res) ? res.error : res.data
)
type UseFetchReturn<T> = UseFetchSucced<T> | UseFetchErrored<T>
function useFetch<R, T>(
url: string,
method: RequestInit['method'],
needAuth: boolean,
guardResponse: (data: unknown) => data is R,
processResponse: (data: R) => T,
initialData?: T,
params?: Omit<RequestInit, 'method'>
): UseFetchReturn<T> {
const [data, setData] = useState(initialData)
const { doSend, loading, error } = useSend(url, method, needAuth, guardResponse, processResponse, params)
useEffect(() => {
doSend().then(
data => { if (data !== undefined) setData(data) }
).catch( // must never occur
err => import.meta.env.DEV && console.error('Failed to do fetch request', err)
)
}, [doSend])
return {
...(
error === null ? ({
data: data!, error: null
}) : ({ data: undefined, error })
),
loading,
setData
}
}
export type { UseFetchReturn }
export default useFetch
export { gotError, fallbackError }

View File

@ -0,0 +1,47 @@
import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { FiltersType, URLDecoreFilters, URLEncodeFilters, defaultFilters } from '../utils/filters'
import { SetState } from '../utils/types'
function useFilters(initialFilters: FiltersType = defaultFilters): [FiltersType, SetState<FiltersType>] {
const [searchParams, setSearchParams] = useSearchParams()
const [filters, setFilters] = useState(initialFilters)
const appendFiltersSearchParams = (filters: FiltersType) => (
setSearchParams(params => ({
...Object.fromEntries(params),
...URLEncodeFilters(filters)
}), { replace: true })
)
useEffect(() => {
const urlFilters = URLDecoreFilters(searchParams)
setFilters(prev => ({
...prev,
...urlFilters,
}))
appendFiltersSearchParams({
...URLEncodeFilters(initialFilters),
...URLEncodeFilters(urlFilters),
})
// searchParams have actual query string at first render
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const withQuery = (f: SetState<FiltersType>) => (
(nextInit: (FiltersType | ((prev: FiltersType) => FiltersType))) => {
const newFilters = (typeof nextInit === 'function') ? nextInit(filters) : nextInit
appendFiltersSearchParams(newFilters)
f(nextInit)
}
)
return [filters, withQuery(setFilters)]
}
export default useFilters

View File

@ -0,0 +1,95 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { getToken } from '../utils/auth'
import { handleHTTPErrors, isAborted } from '../utils'
function useSend<R, T>(
url: string,
method: RequestInit['method'],
needAuth: boolean,
guardResponse: (data: unknown) => data is R,
processResponse: (data: R) => T,
defaultParams?: Omit<RequestInit, 'method'>
) {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const navigate = useNavigate()
const abortControllerRef = useRef<AbortController>()
useEffect(() => () => abortControllerRef.current?.abort(), [])
/** Don't use in useEffect. If you need request result, go with useFetch instead */
const doSend = useCallback(async (urlProps?: Record<string, string>, params?: Omit<RequestInit, 'method'>) => {
setLoading(true)
setError(null)
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
const abortController = new AbortController()
abortControllerRef.current = abortController
const headers = new Headers({
...defaultParams?.headers,
...params?.headers
})
if (needAuth) {
const token = getToken()
if (token === null) {
navigate('/login')
return undefined
}
headers.append('Auth', `Bearer ${token}`)
}
try {
const res = await fetch(url + new URLSearchParams(urlProps).toString(), {
method,
...defaultParams,
...params,
headers,
signal: abortControllerRef.current.signal,
})
handleHTTPErrors(res)
const data: unknown = await res.json()
if (!guardResponse(data)) {
throw new Error('Malformed server response')
}
setLoading(false)
return processResponse(data)
} catch (err) {
if (err instanceof Error && !isAborted(err)) {
setError('Ошибка сети')
if (import.meta.env.DEV) {
console.log(url, params, err)
}
}
setLoading(false)
return undefined
}
}, [defaultParams, needAuth, navigate, url, method, guardResponse, processResponse])
return {
doSend, loading, error,
abort: abortControllerRef.current?.abort.bind(abortControllerRef.current)
}
}
export default useSend

View File

@ -0,0 +1,44 @@
import { useCallback, useEffect, useState } from 'react'
function useSendButtonCaption(
initial: string,
loading: boolean,
error: string | null,
result = initial,
singular = true
) {
const [caption, setCaption] = useState(initial)
const [disabled, setDisabled] = useState(false)
const [title, setTitle] = useState(initial)
const update = useCallback(<T extends NonNullable<unknown>>(data: T | undefined) => {
if (data !== undefined) {
setCaption(result)
setTitle('Отправить ещё раз')
if (singular) {
setDisabled(true)
setTitle('')
}
}
}, [result, singular])
useEffect(() => {
if (loading) {
setCaption('Загрузка...')
setTitle('Отменить и отправить ещё раз')
}
}, [loading])
useEffect(() => {
if (!loading && error !== null) {
setCaption(error + ', нажмите, чтобы попробовать ещё раз')
setTitle('')
}
}, [error, loading])
return { update, children: caption, disabled, title }
}
export default useSendButtonCaption

View File

@ -1,27 +1,27 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
function getWindowDimensions() { const getWindowDimensions = () => (
const { innerWidth: width, innerHeight: height } = window; {
width: window.innerWidth,
height: window.innerHeight
}
)
return { function useStoryDimensions(maxRatio = 16 / 9) {
width,
height
};
}
function useStoryDimensions(maxRatio = 16/9) {
const [windowDimensions, setWindowDimensions] = useState(getWindowDimensions()) const [windowDimensions, setWindowDimensions] = useState(getWindowDimensions())
useEffect(() => { useEffect(() => {
function handleResize() { function handleResize() {
setWindowDimensions(getWindowDimensions()); setWindowDimensions(getWindowDimensions());
} }
window.addEventListener('resize', handleResize); window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize);
}, []); }, []);
const height = windowDimensions.height - 56 const bottomBarHeight = 56
const height = windowDimensions.height - bottomBarHeight
const ratio = Math.max(maxRatio, height / windowDimensions.width) const ratio = Math.max(maxRatio, height / windowDimensions.width)

View File

@ -0,0 +1,47 @@
import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { SetState } from '../utils/types'
function useStoryIndex(annLength: number | undefined) {
const [index, setIndex] = useState(0)
const [searchParams, setSearchParams] = useSearchParams()
const withReset = <T>(f: SetState<T>) => (...args: Parameters<SetState<T>>) => {
console.log('resetting index')
setIndex(0)
setSearchParams(prev => ({ ...prev, storyIndex: '0' }), { replace: true })
f(...args)
}
useEffect(() => {
setIndex(annLength ?
Number.parseInt(searchParams.get('storyIndex') || '0') :
0)
// searchParams have actual query string at first render
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [annLength])
const increment = () => setIndex(prev => {
const newIndex = (prev + 1) % (annLength || 1)
setSearchParams(prev => ({ ...prev, storyIndex: newIndex.toString() }), { replace: true })
return newIndex
})
const decrement = () => setIndex(prev => {
const newIndex = prev > 0 ? (prev - 1) : 0
setSearchParams(prev => ({ ...prev, storyIndex: newIndex.toString() }), { replace: true })
return newIndex
})
return {
n: index,
withReset,
increment,
decrement
}
}
export default useStoryIndex

View File

@ -1,14 +1,13 @@
import { CSSProperties, FormEventHandler, useEffect, useState } from 'react' import { CSSProperties, FormEventHandler, useState } from 'react'
import { Form, Button, Card } from 'react-bootstrap' import { Form, Button, Card } from 'react-bootstrap'
import { MapContainer, TileLayer } from 'react-leaflet' import { MapContainer, TileLayer } from 'react-leaflet'
import { latLng } from 'leaflet' import { latLng } from 'leaflet'
import { ClickHandler, LocationMarker, TrashboxMarkers } from '../components' import { ClickHandler, LocationMarker, TrashboxMarkers } from '../components'
import { useAddAnnouncement, useTrashboxes } from '../hooks/api' import { useAddAnnouncement, useTrashboxes } from '../hooks/api'
import { handleHTTPErrors } from '../utils'
import { categories, categoryNames } from '../assets/category' import { categories, categoryNames } from '../assets/category'
import { stations, lines, lineNames } from '../assets/metro' import { stations, lines, lineNames } from '../assets/metro'
import { fallbackError, gotError } from '../hooks/api/useFetch' import { fallbackError, gotError } from '../hooks/useFetch'
import { useOsmAddresses } from '../hooks/api' import { useOsmAddresses } from '../hooks/api'
const styles = { const styles = {
@ -32,25 +31,7 @@ function AddPage() {
const address = useOsmAddresses(addressPosition) const address = useOsmAddresses(addressPosition)
useEffect(() => { const { doSend, button } = useAddAnnouncement()
if (!gotError(address))
void (async () => {
try {
const res = await fetch(location.protocol + '//nominatim.openstreetmap.org/search?format=json&q=' + encodeURIComponent(address.data))
handleHTTPErrors(res)
const fetchData: unknown = await res.json()
console.log('f', fetchData)
} catch (err) {
console.error(err)
}
})()
}, [address])
const { doAdd, status } = useAddAnnouncement()
const handleSubmit: FormEventHandler<HTMLFormElement> = (event) => { const handleSubmit: FormEventHandler<HTMLFormElement> = (event) => {
event.preventDefault() event.preventDefault()
@ -63,7 +44,7 @@ function AddPage() {
formData.append('address', address.data || '') // if address.error formData.append('address', address.data || '') // if address.error
formData.set('bestBy', new Date((formData.get('bestBy') as number | null) || 0).getTime().toString()) formData.set('bestBy', new Date((formData.get('bestBy') as number | null) || 0).getTime().toString())
void doAdd(formData) void doSend(formData)
} }
return ( return (
@ -151,7 +132,7 @@ function AddPage() {
</Form.Select> </Form.Select>
</Form.Group> </Form.Group>
<Form.Group className='mb-3' controlId='password'> <Form.Group className='mb-3' controlId='trashbox'>
<Form.Label>Пункт сбора мусора</Form.Label> <Form.Label>Пункт сбора мусора</Form.Label>
<div className='mb-3'> <div className='mb-3'>
{trashboxes.loading {trashboxes.loading
@ -195,9 +176,7 @@ function AddPage() {
)} )}
</Form.Group> </Form.Group>
<Button variant='success' type='submit'> <Button variant='success' type='submit' {...button} />
{status}
</Button>
</Form> </Form>
</Card.Body> </Card.Body>
</Card> </Card>

View File

@ -1,16 +1,16 @@
import { CSSProperties, useEffect, useState } from 'react' import { CSSProperties, useEffect, useMemo, useState } from 'react'
import Stories from 'react-insta-stories' import Stories from 'react-insta-stories'
import { Story } from 'react-insta-stories/dist/interfaces' import { Story } from 'react-insta-stories/dist/interfaces'
import { BottomNavBar, AnnouncementDetails, Filters } from '../components' import { BottomNavBar, AnnouncementDetails, Filters } from '../components'
import { useStoryDimensions } from '../hooks' import { useFilters, useStoryDimensions } from '../hooks'
import { useAnnouncements } from '../hooks/api' import { useAnnouncements } from '../hooks/api'
import { defaultFilters } from '../utils/filters'
import { Announcement } from '../api/announcement/types' import { Announcement } from '../api/announcement/types'
import { categoryGraphics } from '../assets/category' import { categoryGraphics } from '../assets/category'
import { UseFetchReturn, gotError } from '../hooks/useFetch'
import { useStoryIndex } from '../hooks'
import puffSpinner from '../assets/puff.svg' import puffSpinner from '../assets/puff.svg'
import { gotError } from '../hooks/api/useFetch'
function generateStories(announcements: Announcement[]): Story[] { function generateStories(announcements: Announcement[]): Story[] {
return announcements.map(announcement => { return announcements.map(announcement => {
@ -23,14 +23,14 @@ function generateStories(announcements: Announcement[]): Story[] {
}) })
} }
function fallbackGenerateStories(announcementsFetch: ReturnType<typeof useAnnouncements>) { function fallbackGenerateStories(announcements: UseFetchReturn<Announcement[]>) {
if (announcementsFetch.loading) if (announcements.loading)
return fallbackStory() return fallbackStory()
if (gotError(announcementsFetch)) if (gotError(announcements))
return fallbackStory(announcementsFetch.error, true) return fallbackStory(announcements.error, true)
const stories = generateStories(announcementsFetch.data) const stories = generateStories(announcements.data)
if (stories.length === 0) if (stories.length === 0)
return fallbackStory('Здесь пока пусто') return fallbackStory('Здесь пока пусто')
@ -63,19 +63,26 @@ const styles = {
} }
function HomePage() { function HomePage() {
const { height, width } = useStoryDimensions(16 / 10) const { height, width } = useStoryDimensions(16 / 9)
const [filterShown, setFilterShown] = useState(false) const [filterShown, setFilterShown] = useState(false)
const [filter, setFilter] = useState(defaultFilters)
const announcementsFetch = useAnnouncements(filter) const [filter, setFilter] = useFilters()
const stories = fallbackGenerateStories(announcementsFetch) const announcements = useAnnouncements(filter)
const stories = useMemo(() => fallbackGenerateStories(announcements), [announcements])
const index = useStoryIndex(announcements.data?.length)
return (<> return (<>
<Filters filter={filter} setFilter={setFilter} filterShown={filterShown} setFilterShown={setFilterShown} /> <Filters filter={filter} setFilter={index.withReset(setFilter)} filterShown={filterShown} setFilterShown={setFilterShown} />
<div style={styles.container}> <div style={styles.container}>
<Stories <Stories
currentIndex={index.n}
onStoryEnd={index.increment}
onNext={index.increment}
onPrevious={index.decrement}
stories={stories} stories={stories}
defaultInterval={11000} defaultInterval={11000}
height={height} height={height}

View File

@ -28,7 +28,7 @@ function LoginPage() {
if (token) { if (token) {
setToken(token) setToken(token)
navigate('/') navigate(-1 - Number(import.meta.env.DEV))
} }
} }

View File

@ -1,9 +1,26 @@
import { Link } from 'react-router-dom' import { Container } from 'react-bootstrap'
import BackHeader from '../components/BackHeader'
import { useUser } from '../hooks/api'
import { userCategories } from '../assets/userCategories'
import { CategoryPreview } from '../components'
import { gotError } from '../hooks/useFetch'
function UserPage() { function UserPage() {
/* TODO */ const user = useUser()
return <h1>For Yet Go <Link to='/'>Home</Link></h1> return (
<Container style={{ maxWidth: 'calc(100vh*9/16)' }}>
<BackHeader text={
gotError(user) ?
user.error :
`${user.data.name}, с нами с ${new Date(user.data.regDate).toLocaleDateString('ru')}`
} />
{userCategories.map(cat => (
<CategoryPreview key={cat} category={cat} />
))}
</Container>
)
} }
export default UserPage export default UserPage

View File

@ -6,7 +6,7 @@ const getToken = () => {
return token return token
} }
const setToken = (token: string) => { function setToken(token: string) {
localStorage.setItem('Token', token) localStorage.setItem('Token', token)
} }

View File

@ -1,4 +1,6 @@
import { Announcement } from '../api/announcement/types' import { Announcement } from '../api/announcement/types'
import { isCategory } from '../assets/category'
import { fallbackToUndefined, isInt } from './types'
const filterNames = ['userId', 'category', 'metro', 'bookedBy'] as const const filterNames = ['userId', 'category', 'metro', 'bookedBy'] as const
type FilterNames = typeof filterNames[number] type FilterNames = typeof filterNames[number]
@ -7,11 +9,34 @@ type FiltersType = Partial<Pick<Announcement, FilterNames>>
const defaultFilters: FiltersType = { userId: undefined, category: undefined, metro: undefined, bookedBy: undefined } const defaultFilters: FiltersType = { userId: undefined, category: undefined, metro: undefined, bookedBy: undefined }
const URLEncodeFilters = (filters: FiltersType) => Object.fromEntries( const URLEncodeFilters = (filters: FiltersType) => (
filterNames.map( Object.fromEntries(
fName => [fName, filters[fName]?.toString()] filterNames.map(
).filter((p): p is [string, string] => typeof p[1] !== 'undefined') fName => {
const v = filters[fName]
if (v) {
return [fName, encodeURIComponent(v)]
}
return [fName, undefined]
}
).filter((p): p is [string, string] => typeof p[1] !== 'undefined')
)
) )
const URLDecoreFilters = (params: URLSearchParams): FiltersType => {
const strFilters = Object.fromEntries(
filterNames.map(
fName => [fName, params.get(fName)]
).filter((p): p is [string, string] => p[1] !== null)
)
return {
bookedBy: fallbackToUndefined(strFilters['bookedBy'], isInt),
category: fallbackToUndefined(strFilters['category'], isCategory),
metro: strFilters['metro'],
userId: fallbackToUndefined(strFilters['userId'], isInt)
}
}
export type { FilterNames, FiltersType } export type { FilterNames, FiltersType }
export { defaultFilters, filterNames, URLEncodeFilters } export { defaultFilters, filterNames, URLEncodeFilters, URLDecoreFilters }

View File

@ -1,6 +1,8 @@
const isAborted = (err: Error) => err.name === 'AbortError' const isAborted = (err: Error) => (
err.name === 'AbortError'
)
const handleHTTPErrors = (res: Response) => { function handleHTTPErrors(res: Response) {
if (!res.ok) { if (!res.ok) {
switch (res.status) { switch (res.status) {
case 401: case 401:

View File

@ -20,7 +20,9 @@ const isObject = <T>(obj: unknown, properties: PropertiesGuards): obj is T => (
Object.entries(properties).every(([name, guard]) => { Object.entries(properties).every(([name, guard]) => {
const param: unknown = Reflect.get(obj, name) const param: unknown = Reflect.get(obj, name)
console.log(name, param, guard) if (import.meta.env.DEV) {
console.debug('isObject', name, param, guard)
}
if (typeof guard === 'function') { if (typeof guard === 'function') {
return guard(param) return guard(param)
@ -54,10 +56,22 @@ const isArrayOf = <T>(obj: unknown, itemGuard: ((obj: unknown) => obj is T)): ob
obj.every(itemGuard) obj.every(itemGuard)
) )
const isString = (obj: unknown): obj is string => typeof obj === 'string' const isString = (obj: unknown): obj is string => (
typeof obj === 'string'
)
const isInt = (obj: unknown): obj is number => (
Number.isSafeInteger(obj)
)
function fallbackToUndefined<T>(obj: unknown, isT: ((obj: unknown) => obj is T)) {
if (!isT(obj)) return undefined
return obj
}
type SetState<T> = React.Dispatch<React.SetStateAction<T>> type SetState<T> = React.Dispatch<React.SetStateAction<T>>
export type { SetState } export type { SetState }
export { isRecord, isObject, isConst, isLiteralUnion, isArrayOf, isString } export { isRecord, isObject, isConst, isLiteralUnion, isArrayOf, isString, isInt, fallbackToUndefined }

View File

@ -9,8 +9,9 @@ import { ${NAME}Response, ${NAME} } from './types'
const initial${NAME}: ${NAME} = {} const initial${NAME}: ${NAME} = {}
const compose${NAME}URL = () => const compose${NAME}URL = () => (
API_URL + '/${NAME,}?' API_URL + '/${NAME,}?'
)
const process${NAME} = (data: ${NAME}Response): ${NAME} => { const process${NAME} = (data: ${NAME}Response): ${NAME} => {
return data return data
@ -26,19 +27,21 @@ type ${NAME}Response = {
} }
const is${NAME}Response = (obj: unknown): obj is ${NAME}Response => const is${NAME}Response = (obj: unknown): obj is ${NAME}Response => (
isObject(obj, { isObject(obj, {
}) })
)
type ${NAME} = { type ${NAME} = {
} }
const is${NAME} = (obj: unknown): obj is ${NAME} => const is${NAME} = (obj: unknown): obj is ${NAME} => (
isObject(obj, { isObject(obj, {
}) })
)
export type { ${NAME}Response, ${NAME} } export type { ${NAME}Response, ${NAME} }