Compare commits
33 Commits
Author | SHA1 | Date | |
---|---|---|---|
9b4ef41030
|
|||
96388a9bea
|
|||
58d1996ce3
|
|||
bc154f8b6b
|
|||
7a044970f0
|
|||
7cf83d099d
|
|||
1b4eed529a
|
|||
bc55ab8f68
|
|||
de8a1abcbf
|
|||
898d590cf3 | |||
1c25d66027
|
|||
b7bbd937b4
|
|||
b360b06d34 | |||
349d40daa4 | |||
8bae63231b | |||
3668e8c33f
|
|||
f4226237ab
|
|||
5b14d75048 | |||
0218cdced5
|
|||
99d2b92b03
|
|||
27ae590981
|
|||
cb848739e5
|
|||
2ce0e5b65d
|
|||
48a48f9364
|
|||
7ef4194bbd
|
|||
ad290c8dd0
|
|||
7b0ccc525c
|
|||
9437c44054
|
|||
6a0c4c9dac
|
|||
d041df0bbd
|
|||
619fd952a5
|
|||
395b6c2d89
|
|||
a8b7cfbffa
|
13
back/main.py
@ -24,7 +24,6 @@ from .db import engine, SessionLocal
|
|||||||
|
|
||||||
from . import schema
|
from . import schema
|
||||||
|
|
||||||
|
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
@ -37,6 +36,7 @@ templates = Jinja2Templates(directory="./front/dist")
|
|||||||
app.mount("/static", StaticFiles(directory = "./front/dist"))
|
app.mount("/static", StaticFiles(directory = "./front/dist"))
|
||||||
app.mount("/uploads", StaticFiles(directory = "./uploads"))
|
app.mount("/uploads", StaticFiles(directory = "./uploads"))
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/announcements")#адрес объявлений
|
@app.get("/api/announcements")#адрес объявлений
|
||||||
def annoncements_list(user_id: int = None, metro: str = None, category: str = None, booked_by: int = -1):
|
def annoncements_list(user_id: int = None, metro: str = None, category: str = None, booked_by: int = -1):
|
||||||
# Считываем данные из Body и отображаем их на странице.
|
# Считываем данные из Body и отображаем их на странице.
|
||||||
@ -86,7 +86,7 @@ def single_annoncement(user_id:int):
|
|||||||
|
|
||||||
# Занести объявление в базу
|
# Занести объявление в базу
|
||||||
@app.put("/api/announcement")#адрес объявлений
|
@app.put("/api/announcement")#адрес объявлений
|
||||||
def put_in_db(name: Annotated[str, Form()], category: Annotated[str, Form()], bestBy: Annotated[int, Form()], address: Annotated[str, Form()], longtitude: Annotated[float, Form()], latitude: Annotated[float, Form()], description: Annotated[str, Form()], src: Annotated[UploadFile | None, File()], metro: Annotated[str, Form()], trashId: Annotated[int | None, Form()] = -1):
|
def put_in_db(name: Annotated[str, Form()], category: Annotated[str, Form()], bestBy: Annotated[int, Form()], address: Annotated[str, Form()], longtitude: Annotated[float, Form()], latitude: Annotated[float, Form()], description: Annotated[str, Form()], src: UploadFile, metro: Annotated[str, Form()], trashId: Annotated[int, Form()] = None):
|
||||||
# try:
|
# try:
|
||||||
userId = 1 # temporary
|
userId = 1 # temporary
|
||||||
|
|
||||||
@ -102,7 +102,7 @@ def put_in_db(name: Annotated[str, Form()], category: Annotated[str, Form()], be
|
|||||||
|
|
||||||
uploaded_name = "/uploads/"+destination.name
|
uploaded_name = "/uploads/"+destination.name
|
||||||
|
|
||||||
temp_ancmt = Announcement(user_id=userId, name=name, category=category, best_by=bestBy, address=address, longtitude=longtitude, latitude=latitude, description=description, src=uploaded_name, metro=metro, trashId=trashId)
|
temp_ancmt = Announcement(user_id=userId, name=name, category=category, best_by=bestBy, address=address, longtitude=longtitude, latitude=latitude, description=description, src=uploaded_name, metro=metro, trashId=trashId, booked_by=-1)
|
||||||
db.add(temp_ancmt) # добавляем в бд
|
db.add(temp_ancmt) # добавляем в бд
|
||||||
db.commit() # сохраняем изменения
|
db.commit() # сохраняем изменения
|
||||||
db.refresh(temp_ancmt) # обновляем состояние объекта
|
db.refresh(temp_ancmt) # обновляем состояние объекта
|
||||||
@ -139,7 +139,7 @@ def change_book_status(data: schema.Book):
|
|||||||
|
|
||||||
@app.post("/api/signup")
|
@app.post("/api/signup")
|
||||||
def create_user(data = Body()):
|
def create_user(data = Body()):
|
||||||
if db.query(UserDatabase).filter(User.email == data["email"]).first() == None:
|
if db.query(UserDatabase).filter(UserDatabase.email == data["email"]).first() == None:
|
||||||
new_user = UserDatabase(id=data["id"], email=data["email"], password=data["password"], name=data["name"], surname=data["surname"])
|
new_user = UserDatabase(id=data["id"], email=data["email"], password=data["password"], name=data["name"], surname=data["surname"])
|
||||||
db.add(new_user)
|
db.add(new_user)
|
||||||
db.commit()
|
db.commit()
|
||||||
@ -152,6 +152,7 @@ def create_user(data = Body()):
|
|||||||
async def login_for_access_token(
|
async def login_for_access_token(
|
||||||
form_data: Annotated[OAuth2PasswordRequestForm, Depends()]
|
form_data: Annotated[OAuth2PasswordRequestForm, Depends()]
|
||||||
):
|
):
|
||||||
|
# разобраться с первым параметром
|
||||||
user = authenticate_user(db.query(UserDatabase).all(), form_data.username, form_data.password)
|
user = authenticate_user(db.query(UserDatabase).all(), form_data.username, form_data.password)
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@ -163,7 +164,7 @@ async def login_for_access_token(
|
|||||||
access_token = create_access_token(
|
access_token = create_access_token(
|
||||||
data={"user_id": user.id}, expires_delta=access_token_expires
|
data={"user_id": user.id}, expires_delta=access_token_expires
|
||||||
)
|
)
|
||||||
return {"access_token": access_token, "token_type": "bearer"}
|
return access_token
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/users/me/", response_model=User)
|
@app.get("/api/users/me/", response_model=User)
|
||||||
@ -184,7 +185,7 @@ async def read_own_items(
|
|||||||
@app.get("/api/trashbox")
|
@app.get("/api/trashbox")
|
||||||
def get_trashboxes(lat:float, lng:float):#крутая функция для работы с api
|
def get_trashboxes(lat:float, lng:float):#крутая функция для работы с api
|
||||||
BASE_URL='https://geointelect2.gate.petersburg.ru'#адрес сайта и мой токин
|
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'
|
my_token='eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJhU1RaZm42bHpTdURYcUttRkg1SzN5UDFhT0FxUkhTNm9OendMUExaTXhFIn0.eyJleHAiOjE3ODM3ODk4NjgsImlhdCI6MTY4OTA5NTQ2OCwianRpIjoiNDUzNjQzZTgtYTkyMi00NTI4LWIzYmMtYWJiYTNmYjkyNTkxIiwiaXNzIjoiaHR0cHM6Ly9rYy5wZXRlcnNidXJnLnJ1L3JlYWxtcy9lZ3MtYXBpIiwiYXVkIjoiYWNjb3VudCIsInN1YiI6ImJjYjQ2NzljLTU3ZGItNDU5ZC1iNWUxLWRlOGI4Yzg5MTMwMyIsInR5cCI6IkJlYXJlciIsImF6cCI6ImFkbWluLXJlc3QtY2xpZW50Iiwic2Vzc2lvbl9zdGF0ZSI6ImM2ZDJiOTZhLWMxNjMtNDAxZS05ZjMzLTI0MmE0NDcxMDY5OCIsImFjciI6IjEiLCJhbGxvd2VkLW9yaWdpbnMiOlsiLyoiXSwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbImRlZmF1bHQtcm9sZXMtZWdzLWFwaSIsIm9mZmxpbmVfYWNjZXNzIiwidW1hX2F1dGhvcml6YXRpb24iXX0sInJlc291cmNlX2FjY2VzcyI6eyJhY2NvdW50Ijp7InJvbGVzIjpbIm1hbmFnZS1hY2NvdW50IiwibWFuYWdlLWFjY291bnQtbGlua3MiLCJ2aWV3LXByb2ZpbGUiXX19LCJzY29wZSI6ImVtYWlsIHByb2ZpbGUiLCJzaWQiOiJjNmQyYjk2YS1jMTYzLTQwMWUtOWYzMy0yNDJhNDQ3MTA2OTgiLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsIm5hbWUiOiLQktC70LDQtNC40LzQuNGAINCv0LrQvtCy0LvQtdCyIiwicHJlZmVycmVkX3VzZXJuYW1lIjoiZTBmYzc2OGRhOTA4MjNiODgwZGQzOGVhMDJjMmQ5NTciLCJnaXZlbl9uYW1lIjoi0JLQu9Cw0LTQuNC80LjRgCIsImZhbWlseV9uYW1lIjoi0K_QutC-0LLQu9C10LIifQ.E2bW0B-c6W5Lj63eP_G8eI453NlDMnW05l11TZT0GSsAtGayXGaolHtWrmI90D5Yxz7v9FGkkCmcUZYy1ywAdO9dDt_XrtFEJWFpG-3csavuMjXmqfQQ9SmPwDw-3toO64NuZVv6qVqoUlPPj57sLx4bLtVbB4pdqgyJYcrDHg7sgwz4d1Z3tAeUfSpum9s5ZfELequfpLoZMXn6CaYZhePaoK-CxeU3KPBPTPOVPKZZ19s7QY10VdkxLULknqf9opdvLs4j8NMimtwoIiHNBFlgQz10Cr7bhDKWugfvSRsICouniIiBJo76wrj5T92s-ztf1FShJuqnQcKE_QLd2A'
|
||||||
head = {'Authorization': 'Bearer {}'.format(my_token)}
|
head = {'Authorization': 'Bearer {}'.format(my_token)}
|
||||||
|
|
||||||
my_data={
|
my_data={
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
from sqlalchemy import Column, Integer, String
|
from sqlalchemy import Column, Integer, String
|
||||||
|
|
||||||
from .db import Base
|
from .db import Base
|
||||||
|
# from db import Base
|
||||||
|
|
||||||
class UserDatabase(Base):#класс пользователя
|
class UserDatabase(Base):#класс пользователя
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
@ -1,47 +1,3 @@
|
|||||||
# from passlib.context import CryptContext
|
|
||||||
# import os
|
|
||||||
# from datetime import datetime, timedelta
|
|
||||||
# from typing import Union, Any
|
|
||||||
# from jose import jwt
|
|
||||||
|
|
||||||
# ACCESS_TOKEN_EXPIRE_MINUTES = 30 # 30 minutes
|
|
||||||
# REFRESH_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days
|
|
||||||
# ALGORITHM = "HS256"
|
|
||||||
# # В предположении, что попыток взлома не будет, возьмем простейший ключ
|
|
||||||
# JWT_SECRET_KEY = "secret key" # может также быть os.environ["JWT_SECRET_KEY"]
|
|
||||||
# JWT_REFRESH_SECRET_KEY = "refresh secret key" # может также быть os.environ["JWT_REFRESH_SECRET_KEY"]
|
|
||||||
|
|
||||||
|
|
||||||
# def get_hashed_password(password: str) -> str:
|
|
||||||
# return password_context.hash(password)
|
|
||||||
|
|
||||||
|
|
||||||
# def verify_password(password: str, hashed_pass: str) -> bool:
|
|
||||||
# return password_context.verify(password, hashed_pass)
|
|
||||||
|
|
||||||
|
|
||||||
# def create_access_token(subject: Union[str, Any], expires_delta: int = None) -> str:
|
|
||||||
# if expires_delta is not None:
|
|
||||||
# expires_delta = datetime.utcnow() + expires_delta
|
|
||||||
# else:
|
|
||||||
# expires_delta = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
|
||||||
|
|
||||||
# to_encode = {"exp": expires_delta, "sub": str(subject)}
|
|
||||||
# encoded_jwt = jwt.encode(to_encode, JWT_SECRET_KEY, ALGORITHM)
|
|
||||||
# return encoded_jwt
|
|
||||||
|
|
||||||
# def create_refresh_token(subject: Union[str, Any], expires_delta: int = None) -> str:
|
|
||||||
# if expires_delta is not None:
|
|
||||||
# expires_delta = datetime.utcnow() + expires_delta
|
|
||||||
# else:
|
|
||||||
# expires_delta = datetime.utcnow() + timedelta(minutes=REFRESH_TOKEN_EXPIRE_MINUTES)
|
|
||||||
|
|
||||||
# to_encode = {"exp": expires_delta, "sub": str(subject)}
|
|
||||||
# encoded_jwt = jwt.encode(to_encode, JWT_REFRESH_SECRET_KEY, ALGORITHM)
|
|
||||||
# return encoded_jwt
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Annotated, Union
|
from typing import Annotated, Union
|
||||||
|
|
||||||
@ -79,7 +35,7 @@ class TokenData(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class User(BaseModel):
|
class User(BaseModel):
|
||||||
email: str
|
# email: str
|
||||||
email: Union[str, None] = None
|
email: Union[str, None] = None
|
||||||
# password: str
|
# password: str
|
||||||
# password: Union[str, None] = None
|
# password: Union[str, None] = None
|
||||||
@ -103,13 +59,14 @@ def get_password_hash(password):
|
|||||||
return pwd_context.hash(password)
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
# проблема здесь
|
||||||
def get_user(db, email: str):
|
def get_user(db, email: str):
|
||||||
user = None
|
user = None
|
||||||
for person_with_correct_email in db:
|
for person_with_correct_email in db.query(UserDatabase):
|
||||||
if person_with_correct_email.email == email:
|
if person_with_correct_email.email == email:
|
||||||
user = person_with_correct_email
|
user = person_with_correct_email
|
||||||
break
|
return user #UserInDB(user_email)
|
||||||
return user #UserInDB(user_email)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,16 +1,36 @@
|
|||||||
|
/* eslint-env node */
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
root: true,
|
||||||
env: { browser: true, es2020: true },
|
env: { browser: true, es2020: true },
|
||||||
extends: [
|
extends: [
|
||||||
'eslint:recommended',
|
'eslint:recommended',
|
||||||
'plugin:react/recommended',
|
'plugin:@typescript-eslint/recommended',
|
||||||
'plugin:react/jsx-runtime',
|
'plugin:@typescript-eslint/recommended-requiring-type-checking',
|
||||||
'plugin:react-hooks/recommended',
|
'plugin:react-hooks/recommended',
|
||||||
],
|
],
|
||||||
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
|
parser: '@typescript-eslint/parser',
|
||||||
settings: { react: { version: '18.2' } },
|
parserOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
sourceType: 'module',
|
||||||
|
project: true,
|
||||||
|
tsconfigRootDir: __dirname,
|
||||||
|
},
|
||||||
plugins: ['react-refresh'],
|
plugins: ['react-refresh'],
|
||||||
rules: {
|
rules: {
|
||||||
'react-refresh/only-export-components': 'warn',
|
'react-refresh/only-export-components': [
|
||||||
'react/prop-types': 'off'
|
'warn',
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||||
|
'@typescript-eslint/quotes': [
|
||||||
|
'error',
|
||||||
|
'single',
|
||||||
|
{
|
||||||
|
'avoidEscape': true,
|
||||||
|
'allowTemplateLiterals': true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'jsx-quotes': [2, 'prefer-single'],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -5,11 +5,9 @@
|
|||||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Porridger</title>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
2188
front/package-lock.json
generated
@ -1,33 +1,39 @@
|
|||||||
{
|
{
|
||||||
"name": "v2",
|
"name": "front",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
"build": "vite build",
|
"build": "tsc && vite build",
|
||||||
"lint": "eslint src --ext js,jsx --report-unused-disable-directives --max-warnings 0",
|
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"typecheck": "tsc",
|
||||||
|
"addFetchApiRoute": "bash utils/addFetchApiRoute.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bootstrap": "^5.2.3",
|
"@types/leaflet": "^1.9.3",
|
||||||
"leaflet": "^1.9.3",
|
"bootstrap": "^5.3.0",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-bootstrap": "^2.7.4",
|
"react-bootstrap": "^2.8.0",
|
||||||
"react-bootstrap-typeahead": "^6.1.2",
|
"react-bootstrap-typeahead": "^6.2.3",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-insta-stories": "^2.5.9",
|
"react-insta-stories": "^2.6.1",
|
||||||
"react-leaflet": "^4.2.1",
|
"react-leaflet": "^4.2.1",
|
||||||
"react-router-dom": "^6.11.1"
|
"react-router-dom": "^6.14.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.0.28",
|
"@faker-js/faker": "^8.0.2",
|
||||||
"@types/react-dom": "^18.0.11",
|
"@types/react": "^18.2.14",
|
||||||
"@vitejs/plugin-react": "^4.0.0",
|
"@types/react-dom": "^18.2.6",
|
||||||
"eslint": "^8.38.0",
|
"@typescript-eslint/eslint-plugin": "^5.61.0",
|
||||||
"eslint-plugin-react": "^7.32.2",
|
"@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-hooks": "^4.6.0",
|
||||||
"eslint-plugin-react-refresh": "^0.3.4",
|
"eslint-plugin-react-refresh": "^0.4.1",
|
||||||
"vite": "^4.3.2"
|
"typescript": "^5.0.2",
|
||||||
|
"vite": "^4.4.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
315
front/prototype.html
Normal 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>
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 94 KiB |
BIN
front/public/bred.jpg
Normal file
After Width: | Height: | Size: 114 KiB |
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 140 KiB |
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 88 KiB |
BIN
front/public/conspects.jpg
Normal file
After Width: | Height: | Size: 83 KiB |
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 123 KiB |
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 99 KiB |
BIN
front/public/milk.jpg
Normal file
After Width: | Height: | Size: 61 KiB |
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 128 KiB |
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 75 KiB |
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 128 KiB |
BIN
front/public/wathing.jpg
Normal file
After Width: | Height: | Size: 89 KiB |
@ -1,16 +1,9 @@
|
|||||||
body {
|
:root {
|
||||||
height: 100vh;
|
--bs-body-bg: rgb(17, 17, 17) !important;
|
||||||
overflow: hidden;
|
|
||||||
color: white;
|
|
||||||
font-family: sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-content, .modal-content .form-select {
|
|
||||||
background-color: rgb(17, 17, 17) !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* В связи со сложившейся политической обстановкой */
|
/* В связи со сложившейся политической обстановкой */
|
||||||
.leaflet-attribution-flag {
|
.leaflet-attribution-flag {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: -100px;
|
right: -100px;
|
||||||
}
|
}
|
@ -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 { HomePage, AddPage, LoginPage, UserPage } from './pages'
|
||||||
|
|
||||||
import { WithToken } from './components'
|
import { WithToken } from './components'
|
||||||
|
|
||||||
import 'leaflet/dist/leaflet.css'
|
import 'leaflet/dist/leaflet.css'
|
||||||
|
|
||||||
import './App.css'
|
import './App.css'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@ -13,18 +11,17 @@ function App() {
|
|||||||
<Router>
|
<Router>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route index element={<HomePage />} />
|
<Route index element={<HomePage />} />
|
||||||
<Route path="/add" element={
|
<Route path='/add' element={
|
||||||
<WithToken>
|
<WithToken>
|
||||||
<AddPage />
|
<AddPage />
|
||||||
</WithToken>
|
</WithToken>
|
||||||
} />
|
} />
|
||||||
<Route path="/user" element={
|
<Route path='/user' element={
|
||||||
<WithToken>
|
<WithToken>
|
||||||
{/* <UserPage /> */}
|
<UserPage />
|
||||||
<h1>For Yet Go <Link to="/">Home</Link></h1>
|
|
||||||
</WithToken>
|
</WithToken>
|
||||||
} />
|
} />
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path='/login' element={<LoginPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Router>
|
</Router>
|
||||||
)
|
)
|
12
front/src/api/announcement/index.ts
Normal 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 }
|
61
front/src/api/announcement/types.ts
Normal 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,
|
||||||
|
}
|
17
front/src/api/announcements/index.ts
Normal 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 }
|
22
front/src/api/announcements/types.ts
Normal 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,
|
||||||
|
}
|
14
front/src/api/osmAddress/index.ts
Normal 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 }
|
19
front/src/api/osmAddress/types.ts
Normal 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,
|
||||||
|
}
|
12
front/src/api/putAnnouncement/index.ts
Normal 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 }
|
17
front/src/api/putAnnouncement/types.ts
Normal 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 }
|
16
front/src/api/trashbox/index.ts
Normal 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 }
|
26
front/src/api/trashbox/types.ts
Normal 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 }
|
25
front/src/api/user/index.ts
Normal 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 }
|
29
front/src/api/user/types.ts
Normal 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 }
|
4
front/src/assets/backArrow.svg
Normal 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 |
@ -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 }
|
|
42
front/src/assets/category.ts
Normal 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 }
|
BIN
front/src/assets/itemMarker.png
Normal file
After Width: | Height: | Size: 20 KiB |
@ -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
@ -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 }
|
4
front/src/assets/rightAngle.svg
Normal 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 |
BIN
front/src/assets/trashMarker.png
Normal file
After Width: | Height: | Size: 5.2 KiB |
45
front/src/assets/userCategories.ts
Normal 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 }
|
@ -4,16 +4,32 @@ import { MapContainer, Marker, Popup, TileLayer } from 'react-leaflet'
|
|||||||
import LineDot from './LineDot'
|
import LineDot from './LineDot'
|
||||||
import { categoryNames } from '../assets/category'
|
import { categoryNames } from '../assets/category'
|
||||||
import { useBook } from '../hooks/api'
|
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)
|
const { handleBook, status: bookStatus } = useBook(id)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="modal"
|
className='modal'
|
||||||
style={{ display: 'flex', alignItems: "center", justifyContent: "center" }}
|
style={styles.container}
|
||||||
>
|
>
|
||||||
<Modal.Dialog style={{ minWidth: "50vw" }}>
|
<Modal.Dialog style={{ minWidth: '50vw' }}>
|
||||||
<Modal.Header closeButton onHide={close}>
|
<Modal.Header closeButton onHide={close}>
|
||||||
<Modal.Title>
|
<Modal.Title>
|
||||||
Подробнее
|
Подробнее
|
||||||
@ -23,19 +39,19 @@ function AnnouncementDetails({ close, announcement: { id, name, category, bestBy
|
|||||||
<Modal.Body>
|
<Modal.Body>
|
||||||
<h1>{name}</h1>
|
<h1>{name}</h1>
|
||||||
|
|
||||||
<span>{categoryNames.get(category)}</span>
|
<span>{categoryNames[category]}</span>
|
||||||
<span className='m-2'>•</span>{/* dot */}
|
<span className='m-2'>•</span>{/* dot */}
|
||||||
<span>Годен до {new Date(bestBy).toLocaleString('ru-RU')}</span>
|
<span>Годен до {new Date(bestBy).toLocaleString('ru-RU')}</span>
|
||||||
|
|
||||||
<p className='mb-3'>{description}</p>
|
<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
|
<TileLayer
|
||||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
attribution='© <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>
|
<Popup>
|
||||||
{address}
|
{address}
|
||||||
<br />
|
<br />
|
||||||
@ -46,8 +62,8 @@ function AnnouncementDetails({ close, announcement: { id, name, category, bestBy
|
|||||||
</Modal.Body>
|
</Modal.Body>
|
||||||
|
|
||||||
<Modal.Footer>
|
<Modal.Footer>
|
||||||
<Button variant='success' onClick={handleBook}>
|
<Button variant='success' onClick={() => void handleBook()}>
|
||||||
{bookStatus || "Забронировать"}
|
{bookStatus || 'Забронировать'}
|
||||||
</Button>
|
</Button>
|
||||||
</Modal.Footer>
|
</Modal.Footer>
|
||||||
</Modal.Dialog>
|
</Modal.Dialog>
|
56
front/src/components/AuthForm.tsx
Normal 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
|
25
front/src/components/BackHeader.tsx
Normal 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
|
@ -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
|
|
58
front/src/components/BottomNavBar.tsx
Normal 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
|
25
front/src/components/CategoryPreview.tsx
Normal 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
|
@ -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
|
|
15
front/src/components/ClickHandler.tsx
Normal 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
|
@ -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 { categories, categoryNames } from '../assets/category'
|
||||||
import { stations, lines } from '../assets/metro'
|
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.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
||||||
@ -13,8 +23,8 @@ function Filters({ filter, setFilter, filterShown, setFilterShown }) {
|
|||||||
|
|
||||||
setFilter(prev => ({
|
setFilter(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
category: formData.get("category") || null,
|
category: (formData.get('category') as (FiltersType['category'] | null)) || undefined,
|
||||||
metro: formData.get("metro") || null
|
metro: (formData.get('metro') as (FiltersType['metro'] | null)) || undefined
|
||||||
}))
|
}))
|
||||||
|
|
||||||
setFilterShown(false)
|
setFilterShown(false)
|
||||||
@ -30,35 +40,35 @@ function Filters({ filter, setFilter, filterShown, setFilterShown }) {
|
|||||||
|
|
||||||
<Modal.Body>
|
<Modal.Body>
|
||||||
<Form onSubmit={handleSubmit}>
|
<Form onSubmit={handleSubmit}>
|
||||||
<Form.Group className="mb-3" controlId="categoryFilter">
|
<Form.Group className='mb-3' controlId='categoryFilter'>
|
||||||
<Form.Label>
|
<Form.Label>
|
||||||
Категория
|
Категория
|
||||||
</Form.Label>
|
</Form.Label>
|
||||||
|
|
||||||
<Form.Select name="category" defaultValue={filter.category || undefined}>
|
<Form.Select name='category' defaultValue={filter.category || undefined}>
|
||||||
<option value="">
|
<option value=''>
|
||||||
Выберите категорию
|
Выберите категорию
|
||||||
</option>
|
</option>
|
||||||
{Array.from(categoryNames).map(
|
{categories.map(
|
||||||
([category, categoryName]) =>
|
category =>
|
||||||
<option key={category} value={category}>{categoryName}</option>
|
<option key={category} value={category}>{categoryNames[category]}</option>
|
||||||
)}
|
)}
|
||||||
</Form.Select>
|
</Form.Select>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
|
|
||||||
<Form.Group className="mb-3" controlId="metroFilter">
|
<Form.Group className='mb-3' controlId='metroFilter'>
|
||||||
<Form.Label>
|
<Form.Label>
|
||||||
Станция метро
|
Станция метро
|
||||||
</Form.Label>
|
</Form.Label>
|
||||||
|
|
||||||
<Form.Select name="metro" defaultValue={filter.metro || undefined}>
|
<Form.Select name='metro' defaultValue={filter.metro || undefined}>
|
||||||
<option value="">
|
<option value=''>
|
||||||
Выберите станцию метро
|
Выберите станцию метро
|
||||||
</option>
|
</option>
|
||||||
{Object.entries(stations).map(
|
{lines.map(
|
||||||
([line, stations]) =>
|
line =>
|
||||||
<optgroup key={line} label={lines[line]}>
|
<optgroup key={line} label={lineNames[line]}>
|
||||||
{Array.from(stations).map(metro =>
|
{Array.from(stations[line]).map(metro =>
|
||||||
<option key={metro} value={metro}>{metro}</option>
|
<option key={metro} value={metro}>{metro}</option>
|
||||||
)}
|
)}
|
||||||
</optgroup>
|
</optgroup>
|
||||||
@ -66,7 +76,7 @@ function Filters({ filter, setFilter, filterShown, setFilterShown }) {
|
|||||||
</Form.Select>
|
</Form.Select>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
|
|
||||||
<Button variant="success" type="submit">
|
<Button variant='success' type='submit'>
|
||||||
Отправить
|
Отправить
|
||||||
</Button>
|
</Button>
|
||||||
</Form>
|
</Form>
|
@ -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 }}>⬤</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
export default LineDot
|
|
15
front/src/components/LineDot.tsx
Normal 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 }}>⬤</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LineDot
|
@ -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
|
|
37
front/src/components/LocationMarker.tsx
Normal 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
|
143
front/src/components/StoriesPreview.tsx
Normal 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
|
@ -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 (
|
return (
|
||||||
<>{trashboxes.map((trashbox, index) => (
|
<>{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>
|
<Popup>
|
||||||
<p>{trashbox.Address}</p>
|
<p>{trashbox.Address}</p>
|
||||||
<p>Тип мусора: <>
|
<p>Тип мусора: <>
|
||||||
{trashbox.Categories.map((category, j) =>
|
{trashbox.Categories.map((category, j) =>
|
||||||
<span key={trashbox.Address + category}>
|
<span key={trashbox.Address + category}>
|
||||||
<a href="#" onClick={() => selectTrashbox({ index, category })}>
|
<a href='#' onClick={() => selectTrashbox({ index, category })}>
|
||||||
{category}
|
{category}
|
||||||
</a>
|
</a>
|
||||||
{(j < trashbox.Categories.length - 1) ? ', ' : ''}
|
{(j < trashbox.Categories.length - 1) ? ', ' : ''}
|
@ -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
|
|
20
front/src/components/WithToken.tsx
Normal 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
|
@ -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,
|
|
||||||
}
|
|
12
front/src/components/index.ts
Normal 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'
|
@ -1,3 +0,0 @@
|
|||||||
const API_URL = "/api"
|
|
||||||
|
|
||||||
export { API_URL }
|
|
3
front/src/config.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
const API_URL = '/api'
|
||||||
|
|
||||||
|
export { API_URL }
|
@ -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 useBook } from './useBook'
|
||||||
export { default as useAuth } from './useAuth'
|
export { default as useAuth } from './useAuth'
|
||||||
export { default as useTrashboxes } from './useTrashboxes'
|
export { default as useTrashboxes } from './useTrashboxes'
|
||||||
export { default as useAddAnnouncement } from './useAddAnnouncement'
|
export { default as useAddAnnouncement } from './useAddAnnouncement'
|
||||||
|
export { default as useOsmAddresses } from './useOsmAddress'
|
||||||
|
export { default as useUser } from './useUser'
|
@ -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
|
|
31
front/src/hooks/api/useAddAnnouncement.ts
Normal 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
|
18
front/src/hooks/api/useAnnouncements.ts
Normal 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
|
@ -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
|
|
117
front/src/hooks/api/useAuth.ts
Normal 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
|
@ -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
|
|
74
front/src/hooks/api/useBook.ts
Normal 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
|
@ -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
|
|
@ -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
|
|
18
front/src/hooks/api/useOsmAddress.ts
Normal 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
|
@ -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
|
|
18
front/src/hooks/api/useTrashboxes.ts
Normal 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
|
23
front/src/hooks/api/useUser.ts
Normal 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
|
@ -1,5 +0,0 @@
|
|||||||
import useStoryDimensions from "./useStoryDimensions"
|
|
||||||
|
|
||||||
export {
|
|
||||||
useStoryDimensions,
|
|
||||||
}
|
|
5
front/src/hooks/index.ts
Normal 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'
|
68
front/src/hooks/useFetch.ts
Normal 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 }
|
47
front/src/hooks/useFilters.ts
Normal 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
|
95
front/src/hooks/useSend.ts
Normal 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
|
44
front/src/hooks/useSendButtonCaption.ts
Normal 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
|
@ -1,15 +1,13 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
function getWindowDimensions() {
|
const getWindowDimensions = () => (
|
||||||
const { innerWidth: width, innerHeight: height } = window;
|
{
|
||||||
|
width: window.innerWidth,
|
||||||
|
height: window.innerHeight
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
function useStoryDimensions(maxRatio = 16 / 9) {
|
||||||
width,
|
|
||||||
height
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function useStoryDimensions(maxRatio = 16/9) {
|
|
||||||
const [windowDimensions, setWindowDimensions] = useState(getWindowDimensions())
|
const [windowDimensions, setWindowDimensions] = useState(getWindowDimensions())
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -17,11 +15,13 @@ function useStoryDimensions(maxRatio = 16/9) {
|
|||||||
setWindowDimensions(getWindowDimensions());
|
setWindowDimensions(getWindowDimensions());
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener("resize", handleResize);
|
window.addEventListener('resize', handleResize);
|
||||||
return () => window.removeEventListener("resize", handleResize);
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const height = windowDimensions.height - 56
|
const bottomBarHeight = 56
|
||||||
|
|
||||||
|
const height = windowDimensions.height - bottomBarHeight
|
||||||
|
|
||||||
const ratio = Math.max(maxRatio, height / windowDimensions.width)
|
const ratio = Math.max(maxRatio, height / windowDimensions.width)
|
||||||
|
|
47
front/src/hooks/useStoryIndex.ts
Normal 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
|
@ -1,11 +1,11 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
|
|
||||||
import App from './App.jsx'
|
import App from './App.tsx'
|
||||||
|
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
import 'bootstrap/dist/css/bootstrap.min.css'
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
@ -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='© <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='© <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
@ -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='© <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='© <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
|
@ -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
|
|
98
front/src/pages/HomePage.tsx
Normal 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
|
@ -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
|
|
51
front/src/pages/LoginPage.tsx
Normal 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
|
@ -1,7 +0,0 @@
|
|||||||
function UserPage() {
|
|
||||||
/* TODO */
|
|
||||||
|
|
||||||
return <></>
|
|
||||||
}
|
|
||||||
|
|
||||||
export default UserPage
|
|
26
front/src/pages/UserPage.tsx
Normal 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
|
@ -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
@ -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 }
|
42
front/src/utils/filters.ts
Normal 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 }
|
@ -1,12 +0,0 @@
|
|||||||
const removeNull = (obj) => Object.fromEntries(
|
|
||||||
Object.entries(obj)
|
|
||||||
.filter(([_, value]) => value != null)
|
|
||||||
.map(([key, value]) => [
|
|
||||||
key,
|
|
||||||
value === Object(value) ? removeNull(value) : value,
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
|
|
||||||
const isAborted = (err) => err.name == 'AbortError'
|
|
||||||
|
|
||||||
export { removeNull, isAborted }
|
|