33 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
99d2b92b03 Added script for fetch api route generation 2023-07-15 12:25:12 +03:00
27ae590981 Refactored component custom styling
Switched to library bootstrap css
2023-07-15 12:24:13 +03:00
cb848739e5 Added osmAddress api route
Related to #19
2023-07-15 11:08:15 +03:00
2ce0e5b65d Returned setData from useFetch
Fixed imports
Related to #19
2023-07-15 11:07:12 +03:00
48a48f9364 Improved useFetch
Related to #19
2023-07-14 20:34:50 +03:00
7ef4194bbd Added styled item and trash icons
Related to #12
2023-07-14 18:33:36 +03:00
ad290c8dd0 Switched to new category pictures
fixes #6
2023-07-14 18:26:42 +03:00
7b0ccc525c Code styling changes 2023-07-13 20:03:53 +03:00
9437c44054 Switched category data struct from Map to object
Moved lineByName function
2023-07-13 18:17:57 +03:00
6a0c4c9dac Added http errors handing to all fettch requests 2023-07-13 17:50:20 +03:00
d041df0bbd Added ellipsis to loading status string 2023-07-13 17:49:47 +03:00
619fd952a5 Separated auth form component
Added auth networking status
fixes #9
2023-07-13 17:48:58 +03:00
395b6c2d89 Added link to privacy policy
#17
2023-07-13 17:10:26 +03:00
a8b7cfbffa Added TypeScript for frontend
Added type definitions for components, functions, data
Added guards for network responses
fixes #8
2023-07-12 19:03:24 +03:00
110 changed files with 3685 additions and 2614 deletions

View File

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

View File

@ -1,7 +1,7 @@
from sqlalchemy import Column, Integer, String
from .db import Base
# from db import Base
class UserDatabase(Base):#класс пользователя
__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 typing import Annotated, Union
@ -79,7 +35,7 @@ class TokenData(BaseModel):
class User(BaseModel):
email: str
# email: str
email: Union[str, None] = None
# password: str
# password: Union[str, None] = None
@ -103,13 +59,14 @@ def get_password_hash(password):
return pwd_context.hash(password)
# проблема здесь
def get_user(db, email: str):
user = None
for person_with_correct_email in db:
for person_with_correct_email in db.query(UserDatabase):
if person_with_correct_email.email == email:
user = person_with_correct_email
break
return user #UserInDB(user_email)
return user #UserInDB(user_email)

View File

@ -1,16 +1,36 @@
/* eslint-env node */
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:react/jsx-runtime',
'plugin:@typescript-eslint/recommended',
'plugin:@typescript-eslint/recommended-requiring-type-checking',
'plugin:react-hooks/recommended',
],
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
settings: { react: { version: '18.2' } },
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
project: true,
tsconfigRootDir: __dirname,
},
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': 'warn',
'react/prop-types': 'off'
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/quotes': [
'error',
'single',
{
'avoidEscape': true,
'allowTemplateLiterals': true
}
],
'jsx-quotes': [2, 'prefer-single'],
},
}

View File

@ -5,11 +5,9 @@
<link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Porridger</title>
<!-- most likely will be loaded from browser cache -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-KK94CHFLLe+nY2dmCWGMq91rCGa5gtU4mk92HdvYe+M/SXH301p5ILy+dN9+nJOZ" crossorigin="anonymous">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2188
front/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,33 +1,39 @@
{
"name": "v2",
"name": "front",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "vite build",
"lint": "eslint src --ext js,jsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
"build": "tsc && vite build",
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"typecheck": "tsc",
"addFetchApiRoute": "bash utils/addFetchApiRoute.sh"
},
"dependencies": {
"bootstrap": "^5.2.3",
"leaflet": "^1.9.3",
"@types/leaflet": "^1.9.3",
"bootstrap": "^5.3.0",
"leaflet": "^1.9.4",
"react": "^18.2.0",
"react-bootstrap": "^2.7.4",
"react-bootstrap-typeahead": "^6.1.2",
"react-bootstrap": "^2.8.0",
"react-bootstrap-typeahead": "^6.2.3",
"react-dom": "^18.2.0",
"react-insta-stories": "^2.5.9",
"react-insta-stories": "^2.6.1",
"react-leaflet": "^4.2.1",
"react-router-dom": "^6.11.1"
"react-router-dom": "^6.14.1"
},
"devDependencies": {
"@types/react": "^18.0.28",
"@types/react-dom": "^18.0.11",
"@vitejs/plugin-react": "^4.0.0",
"eslint": "^8.38.0",
"eslint-plugin-react": "^7.32.2",
"@faker-js/faker": "^8.0.2",
"@types/react": "^18.2.14",
"@types/react-dom": "^18.2.6",
"@typescript-eslint/eslint-plugin": "^5.61.0",
"@typescript-eslint/parser": "^5.61.0",
"@vitejs/plugin-react": "^4.0.1",
"eslint": "^8.44.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.3.4",
"vite": "^4.3.2"
"eslint-plugin-react-refresh": "^0.4.1",
"typescript": "^5.0.2",
"vite": "^4.4.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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 94 KiB

BIN
front/public/bred.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 88 KiB

BIN
front/public/conspects.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 99 KiB

BIN
front/public/milk.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 128 KiB

BIN
front/public/wathing.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

View File

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

View File

@ -1,11 +1,9 @@
import { BrowserRouter as Router, Route, Routes, Link } from 'react-router-dom'
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'
import { HomePage, AddPage, LoginPage, UserPage } from './pages'
import { WithToken } from './components'
import 'leaflet/dist/leaflet.css'
import './App.css'
function App() {
@ -13,18 +11,17 @@ function App() {
<Router>
<Routes>
<Route index element={<HomePage />} />
<Route path="/add" element={
<Route path='/add' element={
<WithToken>
<AddPage />
</WithToken>
} />
<Route path="/user" element={
<Route path='/user' element={
<WithToken>
{/* <UserPage /> */}
<h1>For Yet Go <Link to="/">Home</Link></h1>
<UserPage />
</WithToken>
} />
<Route path="/login" element={<LoginPage />} />
<Route path='/login' element={<LoginPage />} />
</Routes>
</Router>
)

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

@ -0,0 +1,61 @@
import { isObject } from '../../utils/types'
import { Category, isCategory } from '../../assets/category'
type AnnouncementResponse = {
id: number,
user_id: number,
name: string,
category: Category,
best_by: number,
address: string,
longtitude: number,
latitude: number,
description: string,
src: string | null,
metro: string,
trashId: number | null,
booked_by: number
}
const isAnnouncementResponse = (obj: unknown): obj is AnnouncementResponse => (
isObject(obj, {
'id': 'number',
'user_id': 'number',
'name': 'string',
'category': isCategory,
'best_by': 'number',
'address': 'string',
'longtitude': 'number',
'latitude': 'number',
'description': 'string',
'src': 'string?',
'metro': 'string',
'trashId': '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,
}
export {
isAnnouncementResponse,
}

View File

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

View File

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

View File

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

View File

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

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

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

View File

@ -0,0 +1,26 @@
import { isArrayOf, isObject, isString } from '../../utils/types'
type Trashbox = {
Lat: number,
Lng: number,
Address: string,
Categories: string[]
}
const isTrashbox = (obj: unknown): obj is Trashbox => (
isObject(obj, {
'Lat': 'number',
'Lng': 'number',
'Address': 'string',
'Categories': obj => isArrayOf<string>(obj, isString)
})
)
type TrashboxResponse = Trashbox[]
const isTrashboxResponse = (obj: unknown): obj is Trashbox[] => (
isArrayOf(obj, isTrashbox)
)
export type { Trashbox, TrashboxResponse }
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

@ -1,32 +0,0 @@
const categoryGraphics = new Map([
["PORRIDGE", "static/PORRIDGE.jpg"],
["conspects", "static/conspects.jpg"],
["milk", "static/milk.jpg"],
["bred", "static/bred.jpg"],
["wathing", "static/wathing.jpg"],
["cloth", "static/cloth.jpg"],
["fruits_vegatables", "static/fruits_vegatables.jpg"],
["soup", "static/soup.jpg"],
["dinner", "static/dinner.jpg"],
["conserves", "static/conserves.jpg"],
["pens", "static/pens.jpg"],
["other_things", "static/other_things.jpg"]
])
const categoryNames = new Map([
["PORRIDGE", "PORRIDGE"],
["conspects", "Конспекты"],
["milk", "Молочные продукты"],
["bred", "Хлебобулочные изделия"],
["wathing", "Моющие средства"],
["cloth", "Одежда"],
["fruits_vegatables", "Фрукты и овощи"],
["soup", "Супы"],
["dinner", "Ужин"],
["conserves", "Консервы"],
["pens", "Канцелярия"],
["other_things", "Всякая всячина"]
])
export { categoryNames, categoryGraphics }

View File

@ -0,0 +1,42 @@
import { isLiteralUnion } from '../utils/types'
const categories = ['PORRIDGE', 'conspects', 'milk', 'bred', 'wathing', 'cloth',
'fruits_vegatables', 'soup', 'dinner', 'conserves', 'pens', 'other_things'] as const
type Category = typeof categories[number]
const isCategory = (obj: unknown): obj is Category => (
isLiteralUnion(obj, categories)
)
const categoryGraphics: Record<Category, string> = {
'PORRIDGE': 'static/PORRIDGE.jpg',
'conspects': 'static/conspects.jpg',
'milk': 'static/milk.jpg',
'bred': 'static/bred.jpg',
'wathing': 'static/wathing.jpg',
'cloth': 'static/cloth.jpg',
'fruits_vegatables': 'static/fruits_vegatables.jpg',
'soup': 'static/soup.jpg',
'dinner': 'static/dinner.jpg',
'conserves': 'static/conserves.jpg',
'pens': 'static/pens.jpg',
'other_things': 'static/other_things.jpg',
}
const categoryNames: Record<Category, string> = {
'PORRIDGE': 'PORRIDGE',
'conspects': 'Конспекты',
'milk': 'Молочные продукты',
'bred': 'Хлебобулочные изделия',
'wathing': 'Моющие средства',
'cloth': 'Одежда',
'fruits_vegatables': 'Фрукты и овощи',
'soup': 'Супы',
'dinner': 'Ужин',
'conserves': 'Консервы',
'pens': 'Канцелярия',
'other_things': 'Всякая всячина',
}
export type { Category }
export { categories, categoryNames, categoryGraphics, isCategory }

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -1,101 +0,0 @@
const stations = {
red: new Set([
"Девяткино",
"Гражданский проспект",
"Академическая",
"Политехническая",
"Площадь Мужества",
"Лесная",
"Выборгская",
"Площадь Ленина",
"Чернышевская",
"Площадь Восстания",
"Владимирская",
"Пушкинская",
"Технологический институт",
"Балтийская",
"Нарвская",
"Кировский завод",
"Автово",
"Ленинский проспект",
"Проспект Ветеранов"
]),
blue: new Set([
"Парнас",
"Проспект Просвещения",
"Озерки",
"Удельная",
"Пионерская",
"Чёрная речка",
"Петроградская",
"Горьковская",
"Невский проспект",
"Сенная площадь",
"Технологический институт",
"Фрунзенская",
"Московские ворота",
"Электросила",
"Парк Победы",
"Московская",
"Звёздная",
"Купчино"
]),
green: new Set([
"Приморская",
"Беговая",
"Василеостровская",
"Гостиный двор",
"Маяковская",
"Площадь Александра Невского",
"Елизаровская",
"Ломоносовская",
"Пролетарская",
"Обухово",
"Рыбацкое"
]),
orange: new Set([
"Спасская",
"Достоевская",
"Лиговский проспект",
"Площадь Александра Невского",
"Новочеркасская",
"Ладожская",
"Проспект Большевиков",
"Улица Дыбенко"
]),
violet: new Set([
"Комендантский проспект",
"Старая Деревня",
"Крестовский остров",
"Чкаловская",
"Спортивная",
"Адмиралтейская",
"Садовая",
"Звенигородская",
"Обводный канал",
"Волковская",
"Бухарестская",
"Международная",
"Проспект славы",
"Дунайскай",
"Шушары"
]),
}
const colors = {
red: "#D6083B",
blue: "#0078C9",
green: "#009A49",
orange: "#EA7125",
violet: "#702785",
}
const lines = {
red: "Красная",
blue: "Синяя",
green: "Зелёная",
orange: "Оранжевая",
violet: "Фиолетовая",
}
export { stations, colors, lines }

109
front/src/assets/metro.ts Normal file
View File

@ -0,0 +1,109 @@
const lines = ['red', 'blue', 'green', 'orange', 'violet'] as const
type Lines = typeof lines[number]
const stations: Record<Lines, Set<string>> = {
red: new Set([
'Девяткино',
'Гражданский проспект',
'Академическая',
'Политехническая',
'Площадь Мужества',
'Лесная',
'Выборгская',
'Площадь Ленина',
'Чернышевская',
'Площадь Восстания',
'Владимирская',
'Пушкинская',
'Технологический институт',
'Балтийская',
'Нарвская',
'Кировский завод',
'Автово',
'Ленинский проспект',
'Проспект Ветеранов'
]),
blue: new Set([
'Парнас',
'Проспект Просвещения',
'Озерки',
'Удельная',
'Пионерская',
'Чёрная речка',
'Петроградская',
'Горьковская',
'Невский проспект',
'Сенная площадь',
'Технологический институт',
'Фрунзенская',
'Московские ворота',
'Электросила',
'Парк Победы',
'Московская',
'Звёздная',
'Купчино'
]),
green: new Set([
'Приморская',
'Беговая',
'Василеостровская',
'Гостиный двор',
'Маяковская',
'Площадь Александра Невского',
'Елизаровская',
'Ломоносовская',
'Пролетарская',
'Обухово',
'Рыбацкое'
]),
orange: new Set([
'Спасская',
'Достоевская',
'Лиговский проспект',
'Площадь Александра Невского',
'Новочеркасская',
'Ладожская',
'Проспект Большевиков',
'Улица Дыбенко'
]),
violet: new Set([
'Комендантский проспект',
'Старая Деревня',
'Крестовский остров',
'Чкаловская',
'Спортивная',
'Адмиралтейская',
'Садовая',
'Звенигородская',
'Обводный канал',
'Волковская',
'Бухарестская',
'Международная',
'Проспект славы',
'Дунайскай',
'Шушары'
]),
}
const colors: Record<Lines, string> = {
red: '#D6083B',
blue: '#0078C9',
green: '#009A49',
orange: '#EA7125',
violet: '#702785',
}
const lineNames: Record<Lines, string> = {
red: 'Красная',
blue: 'Синяя',
green: 'Зелёная',
orange: 'Оранжевая',
violet: 'Фиолетовая',
}
const lineByName = (name: string) => (
lines.find(line => stations[line].has(name))
)
export type { Lines }
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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

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

@ -4,16 +4,32 @@ import { MapContainer, Marker, Popup, TileLayer } from 'react-leaflet'
import LineDot from './LineDot'
import { categoryNames } from '../assets/category'
import { useBook } from '../hooks/api'
import { Announcement } from '../api/announcement/types'
import { iconItem } from '../utils/markerIcons'
import { CSSProperties } from 'react'
function AnnouncementDetails({ close, announcement: { id, name, category, bestBy, description, lat, lng, address, metro } }) {
type AnnouncementDetailsProps = {
close: () => void,
announcement: Announcement
}
const styles = {
container: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
} as CSSProperties,
}
function AnnouncementDetails({ close, announcement: { id, name, category, bestBy, description, lat, lng, address, metro } }: AnnouncementDetailsProps) {
const { handleBook, status: bookStatus } = useBook(id)
return (
<div
className="modal"
style={{ display: 'flex', alignItems: "center", justifyContent: "center" }}
className='modal'
style={styles.container}
>
<Modal.Dialog style={{ minWidth: "50vw" }}>
<Modal.Dialog style={{ minWidth: '50vw' }}>
<Modal.Header closeButton onHide={close}>
<Modal.Title>
Подробнее
@ -23,19 +39,19 @@ function AnnouncementDetails({ close, announcement: { id, name, category, bestBy
<Modal.Body>
<h1>{name}</h1>
<span>{categoryNames.get(category)}</span>
<span>{categoryNames[category]}</span>
<span className='m-2'>&#x2022;</span>{/* dot */}
<span>Годен до {new Date(bestBy).toLocaleString('ru-RU')}</span>
<p className='mb-3'>{description}</p>
<MapContainer style={{ width: "100%", minHeight: 300 }} center={[lat, lng]} zoom={16} >
<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"
url='https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'
/>
<Marker position={[lat, lng]}>
<Marker icon={iconItem} position={[lat, lng]}>
<Popup>
{address}
<br />
@ -46,8 +62,8 @@ function AnnouncementDetails({ close, announcement: { id, name, category, bestBy
</Modal.Body>
<Modal.Footer>
<Button variant='success' onClick={handleBook}>
{bookStatus || "Забронировать"}
<Button variant='success' onClick={() => void handleBook()}>
{bookStatus || 'Забронировать'}
</Button>
</Modal.Footer>
</Modal.Dialog>
@ -55,4 +71,4 @@ function AnnouncementDetails({ close, announcement: { id, name, category, bestBy
)
}
export default AnnouncementDetails
export default AnnouncementDetails

View File

@ -0,0 +1,56 @@
import { FormEventHandler } from 'react'
import { Button, Form } from 'react-bootstrap'
type AuthFormProps = {
register: boolean
handleAuth: FormEventHandler<HTMLFormElement>,
loading: boolean,
error: string
}
function AuthForm ({ handleAuth, register, loading, error }: AuthFormProps) {
const buttonText = loading ? 'Загрузка...' : (error || (register ? 'Зарегистрироваться' : 'Войти'))
return (
<Form onSubmit={handleAuth}>
<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 type='password' required />
</Form.Group>
{register &&
<Form.Group className='mb-3' controlId='privacyPolicyConsent'>
<Form.Check>
<Form.Check.Input type='checkbox' required />
<Form.Check.Label>
Я согласен с <a href={`${document.location.origin}/privacy_policy.pdf`} target='_blank' rel='noopener noreferrer'>условиями обработки персональных данных</a>
</Form.Check.Label>
</Form.Check>
</Form.Group>
}
<Button variant='success' type='submit'>
{buttonText}
</Button>
</Form>
)
}
export default AuthForm

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

@ -1,50 +0,0 @@
import { Link } from 'react-router-dom'
import addIcon from '../assets/addIcon.svg'
import filterIcon from '../assets/filterIcon.svg'
import userIcon from '../assets/userIcon.svg'
const navBarStyles = {
backgroundColor: 'var(--bs-success)',
height: 56,
width: "100%",
}
const navBarGroupStyles = {
display: "flex",
flexDirection: "row",
height: "100%",
margin: "auto"
}
const navBarElementStyles = {
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center"
}
function BottomNavBar({ width, toggleFilters }) {
return (
<div style={navBarStyles}>
<div style={{ ...navBarGroupStyles, width: width }}>
<a style={navBarElementStyles} onClick={() => toggleFilters(true)}>
<img src={filterIcon} alt="Фильтровать объявления" title='Фильтровать объявления' />
</a>
<Link style={navBarElementStyles} to="/add" >
<img src={addIcon} alt="Опубликовать объявление" title='Опубликовать объявление' />
</Link>
<Link style={navBarElementStyles} to={"/user"} >
<img src={userIcon} alt="Личный кабинет" title='Личный кабинет' />
</Link>
</div>
</div>
)
}
export default BottomNavBar

View File

@ -0,0 +1,58 @@
import { Link } from 'react-router-dom'
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: {
backgroundColor: 'var(--bs-success)',
height: 56,
width: '100%',
} as CSSProperties,
navBarGroup: {
display: 'flex',
flexDirection: 'row',
height: '100%',
margin: 'auto'
} as CSSProperties,
navBarElement: {
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
} as CSSProperties,
}
type BottomNavBarProps = {
width: number,
toggleFilters: (p: boolean) => void
}
function BottomNavBar({ width, toggleFilters }: BottomNavBarProps) {
return (
<div style={styles.navBar}>
<div style={{ ...styles.navBarGroup, width: width }}>
<a style={styles.navBarElement} onClick={() => toggleFilters(true)}>
<img src={filterIcon} alt='Фильтровать объявления' title='Фильтровать объявления' />
</a>
<Link style={styles.navBarElement} to='/add' >
<img src={addIcon} alt='Опубликовать объявление' title='Опубликовать объявление' />
</Link>
<Link style={styles.navBarElement} to={'/user'} >
<img src={userIcon} alt='Личный кабинет' title='Личный кабинет' />
</Link>
</div>
</div>
)
}
export default BottomNavBar

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

@ -1,12 +0,0 @@
import { useMapEvent } from "react-leaflet"
function ClickHandler({ setPosition }) {
const map = useMapEvent('click', (e) => {
setPosition(e.latlng)
map.setView(e.latlng)
})
return null
}
export default ClickHandler

View File

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

View File

@ -1,11 +1,21 @@
import { Button, Form, Modal } from "react-bootstrap"
import { Button, Form, Modal } from 'react-bootstrap'
import { FormEventHandler } from 'react'
import { categoryNames } from "../assets/category"
import { stations, lines } from '../assets/metro'
import { categories, categoryNames } from '../assets/category'
import { stations, lines, lineNames } from '../assets/metro'
import { FiltersType } from '../utils/filters'
import { SetState } from '../utils/types'
function Filters({ filter, setFilter, filterShown, setFilterShown }) {
type FiltersProps = {
filter: FiltersType,
setFilter: SetState<FiltersType>,
filterShown: boolean,
setFilterShown: SetState<boolean>
}
const handleSubmit = (event) => {
function Filters({ filter, setFilter, filterShown, setFilterShown }: FiltersProps) {
const handleSubmit: FormEventHandler<HTMLFormElement> = (event) => {
event.preventDefault();
event.stopPropagation();
@ -13,8 +23,8 @@ function Filters({ filter, setFilter, filterShown, setFilterShown }) {
setFilter(prev => ({
...prev,
category: formData.get("category") || null,
metro: formData.get("metro") || null
category: (formData.get('category') as (FiltersType['category'] | null)) || undefined,
metro: (formData.get('metro') as (FiltersType['metro'] | null)) || undefined
}))
setFilterShown(false)
@ -30,35 +40,35 @@ function Filters({ filter, setFilter, filterShown, setFilterShown }) {
<Modal.Body>
<Form onSubmit={handleSubmit}>
<Form.Group className="mb-3" controlId="categoryFilter">
<Form.Group className='mb-3' controlId='categoryFilter'>
<Form.Label>
Категория
</Form.Label>
<Form.Select name="category" defaultValue={filter.category || undefined}>
<option value="">
<Form.Select name='category' defaultValue={filter.category || undefined}>
<option value=''>
Выберите категорию
</option>
{Array.from(categoryNames).map(
([category, categoryName]) =>
<option key={category} value={category}>{categoryName}</option>
{categories.map(
category =>
<option key={category} value={category}>{categoryNames[category]}</option>
)}
</Form.Select>
</Form.Group>
<Form.Group className="mb-3" controlId="metroFilter">
<Form.Group className='mb-3' controlId='metroFilter'>
<Form.Label>
Станция метро
</Form.Label>
<Form.Select name="metro" defaultValue={filter.metro || undefined}>
<option value="">
<Form.Select name='metro' defaultValue={filter.metro || undefined}>
<option value=''>
Выберите станцию метро
</option>
{Object.entries(stations).map(
([line, stations]) =>
<optgroup key={line} label={lines[line]}>
{Array.from(stations).map(metro =>
{lines.map(
line =>
<optgroup key={line} label={lineNames[line]}>
{Array.from(stations[line]).map(metro =>
<option key={metro} value={metro}>{metro}</option>
)}
</optgroup>
@ -66,7 +76,7 @@ function Filters({ filter, setFilter, filterShown, setFilterShown }) {
</Form.Select>
</Form.Group>
<Button variant="success" type="submit">
<Button variant='success' type='submit'>
Отправить
</Button>
</Form>
@ -75,4 +85,4 @@ function Filters({ filter, setFilter, filterShown, setFilterShown }) {
)
}
export default Filters
export default Filters

View File

@ -1,12 +0,0 @@
import { colors, lines } from '../assets/metro'
import { lineByName } from '../utils/metro'
function LineDot({ station }) {
const line = lineByName(station)
const lineTitle = lines[line]
const color = colors[line]
return <span title={`${lineTitle} ветка`} style={{ color: color }}>&#11044;</span>
}
export default LineDot

View File

@ -0,0 +1,15 @@
import { colors, lineNames, lineByName } from '../assets/metro'
function LineDot({ station }: { station: string }) {
const line = lineByName(station)
if (line == undefined)
return <></>
const lineTitle = lineNames[line]
const color = colors[line]
return <span title={`${lineTitle} ветка`} style={{ color: color }}>&#11044;</span>
}
export default LineDot

View File

@ -1,27 +0,0 @@
import { Marker, Popup, useMapEvents } from "react-leaflet"
const LocationMarker = ({ address, position, setPosition }) => {
const map = useMapEvents({
dragend: () => {
setPosition(map.getCenter())
},
zoomend: () => {
setPosition(map.getCenter())
},
resize: () => {
setPosition(map.getCenter())
}
})
return (
<Marker position={position}>
<Popup>
{address}
{position.lat.toFixed(4)}, {position.lng.toFixed(4)}
</Popup>
</Marker>
)
}
export default LocationMarker

View File

@ -0,0 +1,37 @@
import { Marker, Popup, useMapEvents } from 'react-leaflet'
import { LatLng } from 'leaflet'
import { SetState } from '../utils/types'
import { iconItem } from '../utils/markerIcons'
type LocationMarkerProps = {
address: string,
position: LatLng,
setPosition: SetState<LatLng>
}
function LocationMarker({ address, position, setPosition }: LocationMarkerProps) {
const map = useMapEvents({
dragend: () => {
setPosition(map.getCenter())
},
zoomend: () => {
setPosition(map.getCenter())
},
resize: () => {
setPosition(map.getCenter())
}
})
return (
<Marker icon={iconItem} position={position}>
<Popup>
{address}
{position.lat.toFixed(4)}, {position.lng.toFixed(4)}
</Popup>
</Marker>
)
}
export default LocationMarker

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

@ -1,15 +1,23 @@
import { Marker, Popup } from "react-leaflet"
import { Marker, Popup } from 'react-leaflet'
const TrashboxMarkers = ({ trashboxes, selectTrashbox }) => {
import { Trashbox } from '../api/trashbox/types'
import { iconTrash } from '../utils/markerIcons'
type TrashboxMarkersProps = {
trashboxes: Trashbox[],
selectTrashbox: ({ index, category }: { index: number, category: string }) => void
}
function TrashboxMarkers({ trashboxes, selectTrashbox }: TrashboxMarkersProps) {
return (
<>{trashboxes.map((trashbox, index) => (
<Marker key={trashbox.Lat + "" + trashbox.Lng} position={[trashbox.Lat, trashbox.Lng]}>
<Marker icon={iconTrash} key={`${trashbox.Lat}${trashbox.Lng}`} position={[trashbox.Lat, trashbox.Lng]}>
<Popup>
<p>{trashbox.Address}</p>
<p>Тип мусора: <>
{trashbox.Categories.map((category, j) =>
<span key={trashbox.Address + category}>
<a href="#" onClick={() => selectTrashbox({ index, category })}>
<a href='#' onClick={() => selectTrashbox({ index, category })}>
{category}
</a>
{(j < trashbox.Categories.length - 1) ? ', ' : ''}
@ -23,4 +31,4 @@ const TrashboxMarkers = ({ trashboxes, selectTrashbox }) => {
)
}
export default TrashboxMarkers
export default TrashboxMarkers

View File

@ -1,19 +0,0 @@
import { useEffect } from "react"
import { getToken } from "../utils/auth"
import { useNavigate } from "react-router-dom"
function WithToken({ children }) {
const navigate = useNavigate()
useEffect(() => {
if (!getToken()) {
return navigate("/login")
}
}, [navigate])
return (
<>{children}</>
)
}
export default WithToken

View File

@ -0,0 +1,20 @@
import { PropsWithChildren, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { getToken } from '../utils/auth'
function WithToken({ children }: PropsWithChildren) {
const navigate = useNavigate()
useEffect(() => {
if (!getToken()) {
return navigate('/login')
}
}, [navigate])
return (
<>{children}</>
)
}
export default WithToken

View File

@ -1,19 +0,0 @@
import AnnouncementDetails from "./AnnouncementDetails"
import BottomNavBar from "./BottomNavBar"
import Filters from "./Filters"
import LineDot from "./LineDot"
import LocationMarker from './LocationMarker'
import TrashboxMarkers from './TrashboxMarkers'
import WithToken from './WithToken'
import ClickHandler from './ClickHandler'
export {
AnnouncementDetails,
BottomNavBar,
Filters,
LineDot,
LocationMarker,
TrashboxMarkers,
WithToken,
ClickHandler,
}

View File

@ -0,0 +1,12 @@
export { default as AnnouncementDetails } from './AnnouncementDetails'
export { default as BottomNavBar } from './BottomNavBar'
export { default as Filters } from './Filters'
export { default as LineDot } from './LineDot'
export { default as LocationMarker } from './LocationMarker'
export { default as TrashboxMarkers } from './TrashboxMarkers'
export { default as WithToken } from './WithToken'
export { default as ClickHandler } from './ClickHandler'
export { default as AuthForm } from './AuthForm'
export { default as BackHeader } from './BackHeader'
export { default as CategoryPreview } from './CategoryPreview'
export { default as StoriesPreview } from './StoriesPreview'

View File

@ -1,3 +0,0 @@
const API_URL = "/api"
export { API_URL }

3
front/src/config.ts Normal file
View File

@ -0,0 +1,3 @@
const API_URL = '/api'
export { API_URL }

View File

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

View File

@ -1,54 +0,0 @@
import { useEffect, useRef, useState } from "react"
import { API_URL } from "../../config"
const useAddAnnouncement = () => {
const [status, setStatus] = useState("Опубликовать")
const timerIdRef = useRef()
const abortControllerRef = useRef()
const doAdd = async (formData) => {
if (status === "Загрузка") {
abortControllerRef.current?.abort()
setStatus("Отменено")
timerIdRef.current = setTimeout(() => setStatus("Опубликовать"), 3000)
return
}
setStatus("Загрузка")
const abortController = new AbortController()
abortControllerRef.current = abortController
try {
const res = await fetch(API_URL + "/announcement", {
method: 'PUT',
body: formData,
signal: abortController.signal
})
const data = await res.json()
if (!data.Answer) {
throw new Error("Не удалось опубликовать объявление")
}
setStatus("Опубликовано")
} catch (err) {
setStatus(err.message ?? err)
timerIdRef.current = setTimeout(() => setStatus("Опубликовать"), 10000)
}
}
useEffect(() => {
const abortController = abortControllerRef.current
return () => {
clearTimeout(timerIdRef.current)
abortController?.abort()
}
})
return {doAdd, status}
}
export default useAddAnnouncement

View File

@ -0,0 +1,31 @@
import { useCallback } from 'react'
import { useSend } from '..'
import { composePutAnnouncementURL, processPutAnnouncement } from '../../api/putAnnouncement'
import { isPutAnnouncementResponse } from '../../api/putAnnouncement/types'
import useSendButtonCaption from '../useSendButtonCaption'
const useAddAnnouncement = () => {
const { doSend, loading, error } = useSend(
composePutAnnouncementURL(),
'PUT',
true,
isPutAnnouncementResponse,
processPutAnnouncement,
)
const { update, ...button } = useSendButtonCaption('Опубликовать', loading, error, 'Опубликовано')
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

@ -0,0 +1,18 @@
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) => (
useFetch(
composeAnnouncementsURL(filters),
'GET',
false,
isAnnouncementsResponse,
processAnnouncements,
initialAnnouncements
)
)
export default useAnnouncements

View File

@ -1,61 +0,0 @@
import { useState } from "react"
import { API_URL } from "../../config"
function useAuth() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const doAuth = async (data, newAccount) => {
setLoading(true)
if (newAccount) {
try {
const res = await fetch(API_URL + "/signup", {
method: "POST",
body: data,
headers: {
'Content-Type': 'application/json'
}
})
const signupData = await res.json()
if (signupData.Success === false) {
throw new Error(signupData.Message)
}
} catch (err) {
setError(err.message)
setLoading(false)
return null
}
}
try {
const res = fetch(API_URL + '/auth/token' + new URLSearchParams({
username: data.email,
password: data.password
}), {
method: "POST",
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
})
const loginData = await res.json()
const token = loginData.access_token
setError('')
setLoading(false)
return token
} catch (err) {
setError(err.message)
setLoading(false)
return null
}
}
return { doAuth, loading, error }
}
export default useAuth

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,42 +0,0 @@
import { useState } from "react"
import { useNavigate } from "react-router-dom"
import { getToken } from "../../utils/auth"
import { API_URL } from "../../config"
function useBook(id) {
const navigate = useNavigate()
const [status, setStatus] = useState('')
const handleBook = () => {
const token = getToken()
if (token) {
setStatus("Загрузка")
fetch(API_URL + '/book', {
method: 'POST',
body: JSON.stringify({
id: id
}),
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
}
}).then(res => res.json()).then(data => {
if (data.Success === true) {
setStatus('Забронировано')
} else {
setStatus("Ошибка бронирования")
}
})
} else {
return navigate("/login")
}
}
return { handleBook, status }
}
export default useBook

View File

@ -0,0 +1,74 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { getToken } from '../../utils/auth'
import { API_URL } from '../../config'
import { isObject } from '../../utils/types'
import { handleHTTPErrors } from '../../utils'
type BookResponse = {
Success: boolean
}
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
}),
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
}
})
handleHTTPErrors(res)
const data: unknown = await res.json()
if (!isBookResponse(data)) {
throw new Error('Malformed server response')
}
if (data.Success === true) {
setStatus('Забронировано')
} else {
throw new Error('Server refused to book')
}
}
catch (err) {
setStatus('Ошибка бронирования')
if (import.meta.env.DEV) {
console.log(err)
}
}
} else {
return navigate('/login')
}
}
return { handleBook, status }
}
export default useBook

View File

@ -1,57 +0,0 @@
import { useEffect, useRef, useState } from "react"
import { isAborted } from '../../utils'
const useFetch = (url, params, initialData) => {
const [data, setData] = useState(initialData)
const [loading, setLoading] = useState(true)
const [error, setError] = useState("")
const abortControllerRef = useRef(null)
useEffect(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
const abortController = new AbortController()
abortControllerRef.current = abortController
fetch(url, { ...params, signal: abortControllerRef.current.signal })
.then(res => {
if (!res.ok) {
switch (res.status) {
case 401:
throw new Error("Ошибка авторизации")
case 404:
throw new Error("Объект не найден")
default: {
throw new Error("Ошибка ответа от сервера")
}
}
}
return res.json()
})
.then(data => {
setData(data)
setLoading(false)
})
.catch(err => {
if (!isAborted(err)) {
setError("Ошибка сети")
}
setLoading(false)
if (import.meta.env.DEV) {
console.log(url, params, err)
}
})
return () => abortControllerRef.current.abort()
}, [url, params])
return { data, loading, error, abort: abortControllerRef.current?.abort }
}
export default useFetch

View File

@ -1,26 +0,0 @@
import useFetch from './useFetch'
import { API_URL } from '../../config'
import { removeNull } from '../../utils'
const initialAnnouncements = { list_of_announcements: [], Success: true }
const useHomeAnnouncementList = (filters) => {
const { data, loading, error } = useFetch(
API_URL + '/announcements?' + new URLSearchParams(removeNull(filters)),
null,
initialAnnouncements
)
const annList = data.list_of_announcements
const res = annList.map(ann => ({
...ann,
lat: ann.latitude,
lng: ann.longtitude,
bestBy: ann.best_by
}))
return { data: error ? [] : res, loading, error }
}
export default useHomeAnnouncementList

View File

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

View File

@ -1,12 +0,0 @@
import { API_URL } from "../../config"
import useFetch from "./useFetch"
const useTrashboxes = (position) => {
return useFetch(
API_URL + "/trashbox?" + new URLSearchParams({ lat: position.lat, lng: position.lng }),
undefined,
[]
)
}
export default useTrashboxes

View File

@ -0,0 +1,18 @@
import { LatLng } from 'leaflet'
import { useFetch } from '../'
import { composeTrashboxURL, processTrashbox } from '../../api/trashbox'
import { isTrashboxResponse } from '../../api/trashbox/types'
const useTrashboxes = (position: LatLng) => (
useFetch(
composeTrashboxURL(position),
'GET',
true,
isTrashboxResponse,
processTrashbox,
[]
)
)
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,5 +0,0 @@
import useStoryDimensions from "./useStoryDimensions"
export {
useStoryDimensions,
}

5
front/src/hooks/index.ts Normal file
View File

@ -0,0 +1,5 @@
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';
function getWindowDimensions() {
const { innerWidth: width, innerHeight: height } = window;
const getWindowDimensions = () => (
{
width: window.innerWidth,
height: window.innerHeight
}
)
return {
width,
height
};
}
function useStoryDimensions(maxRatio = 16/9) {
function useStoryDimensions(maxRatio = 16 / 9) {
const [windowDimensions, setWindowDimensions] = useState(getWindowDimensions())
useEffect(() => {
function handleResize() {
setWindowDimensions(getWindowDimensions());
}
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
window.addEventListener('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)
@ -31,4 +31,4 @@ function useStoryDimensions(maxRatio = 16/9) {
}
}
export default useStoryDimensions
export default useStoryDimensions

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

@ -2,4 +2,4 @@
padding: 0;
margin: 0;
list-style: none;
}
}

View File

@ -1,11 +1,11 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import App from './App.tsx'
import './index.css'
import 'bootstrap/dist/css/bootstrap.min.css'
ReactDOM.createRoot(document.getElementById('root')).render(
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,

View File

@ -1,204 +0,0 @@
import { useEffect, useState } from "react"
import { Form, Button, Card } from "react-bootstrap"
import { MapContainer, TileLayer } from 'react-leaflet'
import { latLng } from "leaflet"
import { ClickHandler, LocationMarker, TrashboxMarkers } from "../components"
import { useAddAnnouncement, useTrashboxes } from "../hooks/api"
import { categoryNames } from "../assets/category"
import { stations, lines } from "../assets/metro"
function AddPage() {
const [addressPosition, setAddressPosition] = useState(latLng(59.972, 30.3227))
const [address, setAddress] = useState('')
const { data: trashboxes, trashboxes_loading, trashboxes_error } = useTrashboxes(addressPosition)
const [selectedTrashbox, setSelectedTrashbox] = useState({ index: -1, category: '' })
useEffect(() => {
(async () => {
try {
const res = await fetch(location.protocol + "//nominatim.openstreetmap.org/search?format=json&q=" + address)
const fetchData = await res.json()
console.log("f", fetchData)
} catch (err) {
console.error(err)
}
})()
}, [address])
useEffect(() => {
(async () => {
try {
const res = await fetch(location.protocol + "//nominatim.openstreetmap.org/reverse?format=json&accept-language=ru&lat=" + addressPosition.lat + "&lon=" + addressPosition.lng)
const fetchData = await res.json()
setAddress(fetchData.display_name)
} catch (err) {
console.error(err)
}
})()
}, [addressPosition])
const { doAdd, status } = useAddAnnouncement()
const handleSubmit = (event) => {
event.preventDefault()
event.stopPropagation()
const formData = new FormData(event.target)
formData.append("latitude", addressPosition.lat)
formData.append("longtitude", addressPosition.lng)
formData.append("address", address)
formData.set("bestBy", new Date(formData.get("bestBy")).getTime())
doAdd(formData)
}
return (
<Card className="m-4" style={{ height: 'calc(100vh - 3rem)' }}>
<Card.Body style={{ overflowY: "auto" }} >
<Form onSubmit={handleSubmit}>
<Form.Group className="mb-3" controlId="name">
<Form.Label>Заголовок объявления</Form.Label>
<Form.Control type="text" required name="name" />
</Form.Group>
<Form.Group className="mb-3" controlId="category">
<Form.Label>Категория</Form.Label>
<Form.Select required name="category">
<option value="" hidden>
Выберите категорию
</option>
{Array.from(categoryNames).map(
([category, categoryName]) =>
<option key={category} value={category}>{categoryName}</option>
)}
</Form.Select>
</Form.Group>
<Form.Group className="mb-3" controlId="bestBy">
<Form.Label>Срок годности</Form.Label>
<Form.Control type="date" required name="bestBy" />
</Form.Group>
<Form.Group className="mb-3" controlId="address">
<Form.Label>Адрес выдачи</Form.Label>
<div className="mb-3">
<MapContainer
scrollWheelZoom={false}
style={{ width: "100%", height: 400 }}
center={addressPosition}
zoom={13}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<LocationMarker
address={address}
position={addressPosition}
setPosition={setAddressPosition}
/>
<ClickHandler
setPosition={setAddressPosition}
/>
</MapContainer>
</div>
<p>Адрес: {address}</p>
</Form.Group>
<Form.Group className="mb-3" controlId="description">
<Form.Label>Описание</Form.Label>
<Form.Control as="textarea" name="description" rows={3} placeholder="Укажите свои контакты, а так же, когда вам будет удобно передать продукт" />
</Form.Group>
<Form.Group className="mb-3" controlId="src">
<Form.Label>Иллюстрация (фото или видео)</Form.Label>
<Form.Control
type="file"
name="src"
accept="video/mp4,video/mkv, video/x-m4v,video/*, image/*"
capture="environment"
/>
</Form.Group>
<Form.Group className="mb-3" controlId="metro">
<Form.Label>
Станция метро
</Form.Label>
<Form.Select name="metro">
<option value="">
Укажите ближайщую станцию метро
</option>
{Object.entries(stations).map(
([line, stations]) =>
<optgroup key={line} label={lines[line]}>
{Array.from(stations).map(metro =>
<option key={metro} value={metro}>{metro}</option>
)}
</optgroup>
)}
</Form.Select>
</Form.Group>
<Form.Group className="mb-3" controlId="password">
<Form.Label>Пункт сбора мусора</Form.Label>
<div className="mb-3">
{trashboxes_loading
? (
<div style={{ height: 400 }}>
<p>Загрузка</p>
</div>
) : (
trashboxes_error ? (
<p
style={{ height: 400 }}
className="text-danger"
>{trashboxes_error}</p>
) : (
<MapContainer
scrollWheelZoom={false}
style={{ width: "100%", height: 400 }}
center={addressPosition}
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}
selectTrashbox={setSelectedTrashbox}
/>
</MapContainer>
)
)
}
</div>
{selectedTrashbox.index > -1 ? (
<p>Выбран пункт сбора мусора на {
trashboxes[selectedTrashbox.index].Address
} с категорией {selectedTrashbox.category}</p>
) : (
<p>Выберите пунк сбора мусора и категорию</p>
)}
</Form.Group>
<Button variant="success" type="submit">
{status}
</Button>
</Form>
</Card.Body>
</Card>
)
}
export default AddPage

186
front/src/pages/AddPage.tsx Normal file
View File

@ -0,0 +1,186 @@
import { CSSProperties, FormEventHandler, useState } from 'react'
import { Form, Button, Card } from 'react-bootstrap'
import { MapContainer, TileLayer } from 'react-leaflet'
import { latLng } from 'leaflet'
import { ClickHandler, LocationMarker, TrashboxMarkers } from '../components'
import { useAddAnnouncement, useTrashboxes } from '../hooks/api'
import { categories, categoryNames } from '../assets/category'
import { stations, lines, lineNames } from '../assets/metro'
import { fallbackError, gotError } from '../hooks/useFetch'
import { useOsmAddresses } from '../hooks/api'
const styles = {
modal: {
height: 'calc(100vh - 3rem)',
} as CSSProperties,
body: {
overflowY: 'auto',
} as CSSProperties,
map: {
width: '100%',
height: 400,
} as CSSProperties,
}
function AddPage() {
const [addressPosition, setAddressPosition] = useState(latLng(59.972, 30.3227))
const trashboxes = useTrashboxes(addressPosition)
const [selectedTrashbox, setSelectedTrashbox] = useState({ index: -1, category: '' })
const address = useOsmAddresses(addressPosition)
const { doSend, button } = useAddAnnouncement()
const handleSubmit: FormEventHandler<HTMLFormElement> = (event) => {
event.preventDefault()
event.stopPropagation()
const formData = new FormData(event.currentTarget)
formData.append('latitude', addressPosition.lat.toString())
formData.append('longtitude', addressPosition.lng.toString())
formData.append('address', address.data || '') // if address.error
formData.set('bestBy', new Date((formData.get('bestBy') as number | null) || 0).getTime().toString())
void doSend(formData)
}
return (
<Card className='m-4' style={styles.modal}>
<Card.Body style={styles.body} >
<Form onSubmit={handleSubmit}>
<Form.Group className='mb-3' controlId='name'>
<Form.Label>Заголовок объявления</Form.Label>
<Form.Control type='text' required name='name' />
</Form.Group>
<Form.Group className='mb-3' controlId='category'>
<Form.Label>Категория</Form.Label>
<Form.Select required name='category'>
<option value='' hidden>
Выберите категорию
</option>
{categories.map(category =>
<option key={category} value={category}>{categoryNames[category]}</option>
)}
</Form.Select>
</Form.Group>
<Form.Group className='mb-3' controlId='bestBy'>
<Form.Label>Срок годности</Form.Label>
<Form.Control type='date' required name='bestBy' />
</Form.Group>
<Form.Group className='mb-3' controlId='address'>
<Form.Label>Адрес выдачи</Form.Label>
<div className='mb-3'>
<MapContainer
scrollWheelZoom={false}
style={styles.map}
center={addressPosition}
zoom={13}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url='https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'
/>
<LocationMarker
address={fallbackError(address)}
position={addressPosition}
setPosition={setAddressPosition}
/>
<ClickHandler
setPosition={setAddressPosition}
/>
</MapContainer>
</div>
<p>Адрес: {fallbackError(address)}</p>
</Form.Group>
<Form.Group className='mb-3' controlId='description'>
<Form.Label>Описание</Form.Label>
<Form.Control as='textarea' name='description' rows={3} placeholder='Укажите свои контакты, а так же, когда вам будет удобно передать продукт' />
</Form.Group>
<Form.Group className='mb-3' controlId='src'>
<Form.Label>Иллюстрация (фото или видео)</Form.Label>
<Form.Control
type='file'
name='src'
accept='video/mp4,video/mkv, video/x-m4v,video/*, image/*'
capture='environment'
/>
</Form.Group>
<Form.Group className='mb-3' controlId='metro'>
<Form.Label>
Станция метро
</Form.Label>
<Form.Select name='metro'>
<option value=''>
Укажите ближайщую станцию метро
</option>
{lines.map(
line =>
<optgroup key={line} label={lineNames[line]}>
{Array.from(stations[line]).map(metro =>
<option key={metro} value={metro}>{metro}</option>
)}
</optgroup>
)}
</Form.Select>
</Form.Group>
<Form.Group className='mb-3' controlId='trashbox'>
<Form.Label>Пункт сбора мусора</Form.Label>
<div className='mb-3'>
{trashboxes.loading
? (
<div style={styles.map}>
<p>Загрузка...</p>
</div>
) : (
gotError(trashboxes) ? (
<p
style={styles.map}
className='text-danger'
>{trashboxes.error}</p>
) : (
<MapContainer
scrollWheelZoom={false}
style={styles.map}
center={addressPosition}
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>
{!gotError(trashboxes) && selectedTrashbox.index > -1 ? (
<p>Выбран пункт сбора мусора на {
trashboxes.data[selectedTrashbox.index].Address
} с категорией {selectedTrashbox.category}</p>
) : (
<p>Выберите пунк сбора мусора и категорию</p>
)}
</Form.Group>
<Button variant='success' type='submit' {...button} />
</Form>
</Card.Body>
</Card>
)
}
export default AddPage

View File

@ -1,79 +0,0 @@
import { useEffect, useState } from 'react'
import Stories from 'react-insta-stories'
import { BottomNavBar, AnnouncementDetails, Filters } from '../components'
import { useStoryDimensions } from '../hooks'
import { useHomeAnnouncementList } from '../hooks/api'
import puffSpinner from '../assets/puff.svg'
import { categoryGraphics } from '../assets/category'
function generateStories(announcements) {
return announcements.map(announcement => {
return ({
id: announcement.id,
url: announcement.src || categoryGraphics.get(announcement.category),
type: announcement.src?.endsWith("mp4") ? "video" : undefined,
seeMore: ({ close }) => <AnnouncementDetails close={close} announcement={announcement} />
})
})
}
function fallbackGenerateStories(announcementsFetch) {
const stories = generateStories(announcementsFetch.data)
if (announcementsFetch.loading)
return fallbackStory()
if (announcementsFetch.error)
return fallbackStory(announcementsFetch.error, true)
if (stories.length === 0)
return fallbackStory("Здесь пока пусто")
return stories
}
const fallbackStory = (text, isError = false) => [{
content: ({ action }) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => { action('pause') }, [action])
return (
<div style={{ margin: 'auto' }} className={isError ? "text-danger" : ''}>
{text || <img src={puffSpinner} />}
</div>
)
},
header: { heading: text }
}]
const defaultFilters = { userId: null, category: null, metro: null, bookedBy: null }
function HomePage() {
const { height, width } = useStoryDimensions(16 / 10)
const [filterShown, setFilterShown] = useState(false)
const [filter, setFilter] = useState(defaultFilters)
const announcementsFetch = useHomeAnnouncementList(filter)
const stories = fallbackGenerateStories(announcementsFetch)
return (<>
<Filters filter={filter} setFilter={setFilter} filterShown={filterShown} setFilterShown={setFilterShown} />
<div style={{ display: "flex", justifyContent: "center", backgroundColor: "rgb(17, 17, 17)" }}>
<Stories
stories={stories}
defaultInterval={11000}
height={height}
width={width}
loop={true}
keyboardNavigation={true}
/>
</div>
<BottomNavBar toggleFilters={setFilterShown} width={width} />
</>)
}
export default HomePage

View File

@ -0,0 +1,98 @@
import { CSSProperties, useEffect, useMemo, useState } from 'react'
import Stories from 'react-insta-stories'
import { Story } from 'react-insta-stories/dist/interfaces'
import { BottomNavBar, AnnouncementDetails, Filters } from '../components'
import { useFilters, useStoryDimensions } from '../hooks'
import { useAnnouncements } from '../hooks/api'
import { Announcement } from '../api/announcement/types'
import { categoryGraphics } from '../assets/category'
import { UseFetchReturn, gotError } from '../hooks/useFetch'
import { useStoryIndex } from '../hooks'
import puffSpinner from '../assets/puff.svg'
function generateStories(announcements: Announcement[]): Story[] {
return announcements.map(announcement => {
return ({
id: announcement.id,
url: announcement.src || categoryGraphics[announcement.category],
type: announcement.src?.endsWith('mp4') ? 'video' : undefined,
seeMore: ({ close }: { close: () => void }) => <AnnouncementDetails close={close} announcement={announcement} />
})
})
}
function fallbackGenerateStories(announcements: UseFetchReturn<Announcement[]>) {
if (announcements.loading)
return fallbackStory()
if (gotError(announcements))
return fallbackStory(announcements.error, true)
const stories = generateStories(announcements.data)
if (stories.length === 0)
return fallbackStory('Здесь пока пусто')
return stories
}
const fallbackStory = (text = '', isError = false): Story[] => [{
content: ({ action }) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => { action('pause') }, [action])
return (
<div style={styles.center} className={isError ? 'text-danger' : ''}>
{text || <img src={puffSpinner} />}
</div>
)
},
}]
const styles = {
container: {
display: 'flex',
justifyContent: 'center',
backgroundColor: 'rgb(17, 17, 17)',
} as CSSProperties,
center: {
margin: 'auto'
} as CSSProperties,
}
function HomePage() {
const { height, width } = useStoryDimensions(16 / 9)
const [filterShown, setFilterShown] = useState(false)
const [filter, setFilter] = useFilters()
const announcements = useAnnouncements(filter)
const stories = useMemo(() => fallbackGenerateStories(announcements), [announcements])
const index = useStoryIndex(announcements.data?.length)
return (<>
<Filters filter={filter} setFilter={index.withReset(setFilter)} filterShown={filterShown} setFilterShown={setFilterShown} />
<div style={styles.container}>
<Stories
currentIndex={index.n}
onStoryEnd={index.increment}
onNext={index.increment}
onPrevious={index.decrement}
stories={stories}
defaultInterval={11000}
height={height}
width={width}
loop={true}
keyboardNavigation={true}
/>
</div>
<BottomNavBar toggleFilters={setFilterShown} width={width} />
</>)
}
export default HomePage

View File

@ -1,89 +0,0 @@
import { Form, Button, Card, Tabs, Tab } from "react-bootstrap"
import { useNavigate } from "react-router-dom";
import { useAuth } from "../hooks/api";
import { setToken } from "../utils/auth";
function LoginPage() {
const navigate = useNavigate()
const doAuth = useAuth()
const handleAuth = (newAccount) => (event) => {
event.preventDefault();
event.stopPropagation();
const formData = new FormData(event.currentTarget)
const data = {
email: formData.get('email'),
name: newAccount ? formData.get('name') : undefined,
password: formData.get('password')
}
const token = "a" // doAuth(data, newAccount)
setToken(token)
navigate("/")
}
return (
<Card className="m-4">
<Card.Body>
<Tabs defaultActiveKey="register" fill justify className="mb-3">
<Tab eventKey="register" title="Регистрация">
<Form onSubmit={handleAuth(true)}>
<Form.Group className="mb-3" controlId="email">
<Form.Label>Почта</Form.Label>
<Form.Control type="email" required />
</Form.Group>
<Form.Group className="mb-3" controlId="name">
<Form.Label>Имя</Form.Label>
<Form.Control type="text" required />
</Form.Group>
<Form.Group className="mb-3" controlId="surname">
<Form.Label>Фамилия</Form.Label>
<Form.Control type="text" required />
</Form.Group>
<Form.Group className="mb-3" controlId="password">
<Form.Label>Пароль</Form.Label>
<Form.Control type="password" required />
</Form.Group>
<Form.Group className="mb-3" controlId="privacyPolicyConsent">
<Form.Check type="checkbox" required label="Я согласен с условиями обработки персональных данных" />
</Form.Group>
<Button variant="success" type="submit">
Зарегистрироваться
</Button>
</Form>
</Tab>
<Tab eventKey="login" title="Вход">
<Form onSubmit={handleAuth(false)}>
<Form.Group className="mb-3" controlId="email">
<Form.Label>Почта</Form.Label>
<Form.Control type="email" required />
</Form.Group>
<Form.Group className="mb-3" controlId="password">
<Form.Label>Пароль</Form.Label>
<Form.Control type="password" required />
</Form.Group>
<Button variant="success" type="submit">
Войти
</Button>
</Form>
</Tab>
</Tabs>
</Card.Body>
</Card>
)
}
export default LoginPage

View File

@ -0,0 +1,51 @@
import { FormEventHandler } from 'react'
import { Card, Tabs, Tab } from 'react-bootstrap'
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../hooks/api';
import { setToken } from '../utils/auth';
import { AuthForm } from '../components';
function LoginPage() {
const navigate = useNavigate()
const { doAuth, loading, error } = useAuth()
const handleAuth = (newAccount: boolean): FormEventHandler<HTMLFormElement> => async (event) => {
event.preventDefault();
event.stopPropagation();
const formData = new FormData(event.currentTarget)
const data = {
email: formData.get('email') as string,
name: newAccount ? formData.get('name') as string : undefined,
surname: newAccount ? formData.get('surname') as string : undefined,
password: formData.get('password') as string
}
const token = import.meta.env.PROD ? await doAuth(data, newAccount) : 'a'
if (token) {
setToken(token)
navigate(-1 - Number(import.meta.env.DEV))
}
}
return (
<Card className='m-4'>
<Card.Body>
<Tabs defaultActiveKey='register' fill justify className='mb-3'>
<Tab eventKey='register' title='Регистрация'>
<AuthForm handleAuth={handleAuth(true)} register={true} loading={loading} error={error} />
</Tab>
<Tab eventKey='login' title='Вход'>
<AuthForm handleAuth={handleAuth(false)} register={false} loading={loading} error={error} />
</Tab>
</Tabs>
</Card.Body>
</Card>
)
}
export default LoginPage

View File

@ -1,7 +0,0 @@
function UserPage() {
/* TODO */
return <></>
}
export default UserPage

View File

@ -0,0 +1,26 @@
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() {
const user = useUser()
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

View File

@ -1,13 +0,0 @@
const getToken = () => {
const token = localStorage.getItem("Token")
/* check expirity */
return token
}
const setToken = (token) => {
localStorage.setItem("Token", token)
}
export { getToken, setToken }

13
front/src/utils/auth.ts Normal file
View File

@ -0,0 +1,13 @@
const getToken = () => {
const token = localStorage.getItem('Token')
/* check expirity */
return token
}
function setToken(token: string) {
localStorage.setItem('Token', token)
}
export { getToken, setToken }

View File

@ -0,0 +1,42 @@
import { Announcement } from '../api/announcement/types'
import { isCategory } from '../assets/category'
import { fallbackToUndefined, isInt } from './types'
const filterNames = ['userId', 'category', 'metro', 'bookedBy'] as const
type FilterNames = typeof filterNames[number]
type FiltersType = Partial<Pick<Announcement, FilterNames>>
const defaultFilters: FiltersType = { userId: undefined, category: undefined, metro: undefined, bookedBy: undefined }
const URLEncodeFilters = (filters: FiltersType) => (
Object.fromEntries(
filterNames.map(
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 { defaultFilters, filterNames, URLEncodeFilters, URLDecoreFilters }

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