Compare commits

...

184 Commits

Author SHA1 Message Date
e2a7f73804
Added link to telegram channel
Fixes #40
2023-09-14 22:23:48 +03:00
fc8f2b527b
Replaced checkbox with text agreements
Fixes #47
2023-09-14 22:18:14 +03:00
a2b0b25233
Fixed unknown category trashboxes
Added specific error for trashboxes token expiration
2023-09-14 01:30:51 +03:00
1f7f69e933
Changed poems adding to packet add_all 2023-09-14 01:30:08 +03:00
860ea43091
Added rounded corners for poem pic 2023-09-14 01:29:20 +03:00
32d1b1b0e6
Added tipical student locations 2023-09-14 00:34:37 +03:00
7ecfe6faa4 Merge pull request 'asynchronous-porridger' (#49) from asynchronous-porridger into main
Reviewed-on: dm1sh/porridger#49
2023-09-13 22:46:16 +03:00
7a4b3978a7
Fixed stories preview buttons color 2023-09-13 22:43:17 +03:00
18a7a0cbb9
Renamed Answer to Success return fields on front 2023-09-13 22:41:53 +03:00
2b001579c5
Fixed front ann book and delete err handling
Button no longer pre-updates on err
File uploading fix
2023-09-13 22:26:53 +03:00
a60ff39c43 postgres related error fixed in filter_ann 2023-09-12 21:29:55 +03:00
761f48c56f HTTP exceptions added instead of True, False responces 2023-09-12 00:35:24 +03:00
f74199b064 HTTP exceptions added instead of True, False responces 2023-09-12 00:31:53 +03:00
e6b34d684a HTTP exceptions added to endpoints (instead of just True, False responces) 2023-09-12 00:22:36 +03:00
60e5463028 HTTP exceptions added (intead of just True or False answers) 2023-09-12 00:19:52 +03:00
558922dcf4
Removed useless files 2023-09-08 19:40:46 +03:00
64a84d7c70
Switched obsolete checking period to daily 2023-09-08 19:40:44 +03:00
acd0a8fbf7
Fixed initial tables creation 2023-09-08 19:40:41 +03:00
22dc21bda1
Refactored trash category convertion 2023-09-08 19:40:38 +03:00
543b7b0c46
Fixed assets mounting in app and container 2023-09-08 19:40:33 +03:00
0df1d50612
Added config loading from .env file 2023-09-08 19:40:29 +03:00
c922c8611e
Fixed async-await bugs 2023-09-08 19:40:23 +03:00
f2de7c419e
Humanized form captions
Related to #42
2023-09-05 08:42:19 +03:00
e9bf7eabaf
Made 'Петроградская' default metro station
Related to #44
2023-09-05 08:35:45 +03:00
74f89ae7cb
Fixed poetry image width 2023-09-05 08:26:35 +03:00
7453a60eee async completely fixed 2023-09-03 00:29:17 +03:00
37d219c516 filter_ann almost fixed 2023-09-02 19:04:30 +03:00
f744cce713 fixing ann_filters function 2023-09-02 15:32:20 +03:00
2c870ee983 making async api - 2 2023-08-31 23:41:48 +03:00
4326c70dbc trying to make api work - 1 2023-08-31 22:32:50 +03:00
93d2e2713e add_poems_to_db function made async type 2023-08-31 21:37:18 +03:00
834b0f27bb add_poems_to_db function made async type 2023-08-31 21:34:42 +03:00
ee79b9d4c5 наработки Вовы и Димы 2023-08-31 21:08:23 +03:00
c43814ccd4 Наработки вовы и димы 2023-08-31 21:05:38 +03:00
30cce3608a begin implementing async 2023-08-31 19:22:16 +03:00
1d2f786a8a begin implementing async 2023-08-31 19:16:45 +03:00
a2da356912 minor changes 2023-08-25 13:37:59 +03:00
e5d94959ec delete_all_poems script 2023-08-25 13:36:31 +03:00
6742c963ab refactoring - 1 2023-08-23 23:46:51 +03:00
fb3763b910 Check of poems in database added in function /api/user/poem 2023-08-18 18:49:40 +03:00
f345ed6587
Updated deployment instructions and container 2023-08-16 09:35:22 +03:00
0468125b23
updated manual poems adding 2023-08-16 09:34:52 +03:00
d7759adf26
Merge branch 'main' of ssh://git.dm1sh.ru:7820/dm1sh/porridger 2023-08-16 00:19:05 +03:00
48f6059bbd
Fixed on more homepage error 2023-08-16 00:18:22 +03:00
1a51c580d4
fixed error on empty poems table 2023-08-15 23:42:04 +03:00
11abdb9147
removed global package-lock.json (again) 2023-08-15 23:37:38 +03:00
85d437a0fb
Replaced empty text with picture 2023-08-15 23:33:58 +03:00
7798b0170d
Added location marker 2023-08-15 23:33:58 +03:00
410931b475
Code styling fixes 2023-08-15 23:33:58 +03:00
326016be2a api/trashbox fixed 2023-08-13 17:36:03 +03:00
3fb5d8bb10 api/trashboxes fixed (pydantic schemas implemented) 2023-08-13 16:59:49 +03:00
97e2ae5489 Merge branch 'main' of https://github.com/dm1sh/porridger_tmp 2023-08-13 16:33:39 +03:00
650d703f8f get_query_results-> filter_ann (in service.py module) 2023-08-13 16:33:30 +03:00
898acd0a5d
Removed debug console.log 2023-08-12 23:49:09 +03:00
79ff0a3813
Removed orphane code and root package-lock 2023-08-12 23:49:08 +03:00
6241fafbc6 Merge branch 'main' of https://github.com/dm1sh/porridger_tmp 2023-08-12 23:46:17 +03:00
1880f8abec clean imports (delete imports, which are not used) 2023-08-12 23:46:09 +03:00
480644c1e9 Merge branch 'main' of https://github.com/dm1sh/porridger_tmp 2023-08-12 12:42:52 +03:00
d5e19f45d2 Правки в мусорку 2023-08-12 12:27:50 +03:00
0aaef69a5a
Trashboxes fetching fixes 2023-08-12 02:50:26 +03:00
a5798cf767 Merge branch 'main' of https://github.com/dm1sh/porridger_tmp 2023-08-12 01:54:40 +03:00
2cfe8512f4 delete and poem endpoints fixed 2023-08-12 01:52:47 +03:00
ce97038f95
Merge branch 'main' of github.com:dm1sh/porridger_tmp 2023-08-12 01:35:31 +03:00
a4a6f620fb
Added empty ann list message on stories preview
Converted to use css module
2023-08-12 01:33:38 +03:00
b12f19ac51
Fixed useSend loading flow on abort
Made data null on error
Made it remain in loading state on refetch and remount abortion
2023-08-12 01:32:02 +03:00
043a210324 Мусорка Работает, завтра приукрашу 2023-08-12 00:25:56 +03:00
4cf7bb8889 Merge branch 'main' of https://github.com/dm1sh/porridger_tmp 2023-08-11 23:38:08 +03:00
2708cc53a6 Вовины наработки по мусоркам(почему-то не работают 2023-08-11 23:38:00 +03:00
3bf00cea6a
Fixed putting ann media src saving 2023-08-11 17:27:01 +03:00
8fb75f8329 api/trashbox endpoint request parameters changed 2023-08-11 15:47:03 +03:00
b3215596f1 Variables used in api/trashbox moved to service.py 2023-08-11 15:06:05 +03:00
07dfc1606c small changes 2023-08-11 15:02:49 +03:00
9a9ade6145 (db: ...) is place in another place 2023-08-11 14:59:13 +03:00
f25ac9aa0d fixing trashbox 2023-08-11 14:17:14 +03:00
73eaf00b96 Merge branch 'main' of https://github.com/dm1sh/porridger_tmp 2023-08-10 19:53:18 +03:00
53f91567e2 obsolete filter fixed 2023-08-10 19:51:11 +03:00
7731226864 Наработки по мусору 2023-08-10 01:06:00 +03:00
8cfac08e8d Наработки по мусоркам 2023-08-09 19:54:27 +03:00
f8235ca7f4
Fixed fetch loading ending before result saving 2023-08-09 17:55:54 +03:00
fa98b392a8
Added poetry picture, fixed spacing on UserPage 2023-08-09 14:25:50 +03:00
6478b45301
Improved StarRating indication of rated mark 2023-08-09 14:24:44 +03:00
9937708da5 Попытка работы с .env(не работает, поэтому коммент 2023-08-09 01:37:57 +03:00
02db525e8b Merge branch 'main' of https://github.com/dm1sh/porridger_tmp 2023-08-08 23:42:02 +03:00
0c47da5543 Change rating and dispose endpoints fixed 2023-08-08 23:41:54 +03:00
6d215d4f66 Merge branch 'main' of https://github.com/dm1sh/porridger_tmp 2023-08-08 19:36:59 +03:00
3a3e036f0d на 2023-08-08 19:36:51 +03:00
864f5a040c
Added address to dispose trashbox request
styling fixes
2023-08-08 19:26:38 +03:00
d9925647c6
Refactored Rating component
Separated annDetails, added to userPage, made actually operating
2023-08-08 19:26:37 +03:00
f432193508
Updated user to use latest api 2023-08-08 19:26:37 +03:00
60779ea489
Updated poetry to use latest api
Added poem id, changed url, fixed formatting, enabled actual fetching
2023-08-08 19:26:37 +03:00
ea18439140
Added book error indication 2023-08-08 19:26:37 +03:00
4135c29160
Fixed announcement typing
Expired -> obsolete filter front
Removed useless despose utils
2023-08-08 19:26:36 +03:00
6c5c7aa0c2
Fixed url paths and made reg_date working well on back 2023-08-08 19:26:36 +03:00
835dcf3979 Изменения 2023-08-08 19:04:11 +03:00
15d61ecc4b Merge branch 'main' of https://github.com/dm1sh/porridger_tmp 2023-08-08 19:00:59 +03:00
bd863bc911 points added; rating still isn't fixed 2023-08-08 19:00:06 +03:00
d3147b69ad
Fixed spelling (once again), wrong initialisation and comment 2023-08-08 12:36:08 +03:00
5bdad31dae
Merge pull request #2 from dm1sh/userPage
User page
2023-08-08 09:22:58 +00:00
9a4226dc30
New auth form design, fixed useSend token sending 2023-08-08 12:20:47 +03:00
c52a623907
Updated announcement fetching to new response schema 2023-08-08 12:07:12 +03:00
17dab2156d Merge branch 'main' of https://github.com/dm1sh/porridger_tmp 2023-08-08 01:23:00 +03:00
832a2ce985 Auth works again; relationships between Trashbox and User added 2023-08-08 01:22:47 +03:00
e1e1244b3a
Updated sign up interface 2023-08-08 01:09:50 +03:00
e571b878bd
Improved poems getting endpoint 2023-08-08 00:26:47 +03:00
d3d7760c5b Merge branch 'main' of https://github.com/dm1sh/porridger_tmp 2023-08-07 22:30:46 +03:00
f15c17a17e get_query_results works correctly. relationships between models changed 2023-08-07 22:27:23 +03:00
2b5a917107
Minor api fixes, made useId hook login optional 2023-08-07 14:10:52 +03:00
d2a3393a11
Added users rating in announcement details 2023-08-07 14:09:04 +03:00
9ae5824393 Пока запускается, но не работает 2023-08-07 13:48:22 +03:00
dfe1f90748 Merge branch 'main' of https://github.com/dm1sh/porridger_tmp 2023-08-07 13:07:20 +03:00
bf327dda28 poems_to_front func added 2023-08-07 13:07:12 +03:00
dd88913abb маление изменения 2023-08-07 12:55:44 +03:00
d7f85bbbbb маленькие новвоведения 2023-08-07 12:54:56 +03:00
5dc90b625e Merge branch 'main' of https://github.com/dm1sh/porridger_tmp 2023-08-07 00:03:04 +03:00
5fcee1157e rating routes added 2023-08-06 23:59:33 +03:00
8bbdbce9f8 Вова работает над поэмами 2023-08-04 13:44:30 +03:00
95e5c95cd4 комментирование углубил 2023-08-03 22:01:03 +03:00
cc414e38bd Merge branch 'main' of https://github.com/dm1sh/porridger_tmp 2023-08-02 00:58:25 +03:00
680b4ad7a2 Merge branch 'main' of https://github.com/dm1sh/porridger_tmp 2023-08-02 00:52:32 +03:00
26e42d874e get current user(using email) completed 2023-08-02 00:39:46 +03:00
b93ab9794d
Added announcement disposal:
Added ann details button
Added modal shown on its click
Moved trashbox selection there
Added trashboxes mock while testing in restricted area
2023-08-01 18:23:56 +03:00
3e25550843
Added typings for /api/trashbox response and /api/announcement/dispose request 2023-08-01 15:57:43 +03:00
a41c684a74
Added schema for GET /api/poetry endpoint 2023-08-01 13:34:55 +03:00
2028dd9419
Fixed get_current_user db argument setting 2023-08-01 12:15:23 +03:00
0145ed8f44 Returning schemas.User from get_current_user 2023-07-31 23:55:16 +03:00
47fca02858
Fixed filter setting an resetting (one more time) 2023-07-31 18:50:36 +03:00
24bd39f689
Added expiration filter to front 2023-07-31 17:23:24 +03:00
6724a97352
Added poetry display on userPage 2023-07-31 15:03:56 +03:00
e7327945e3
Improved loading handling 2023-07-31 14:55:12 +03:00
9b35a54ae9
Even more code styling things 2023-07-31 12:41:19 +03:00
29d46be492 Удалено условие booked by в api/announcements 2023-07-31 01:08:59 +03:00
9eb30d2066
Fixed imports styling 2023-07-30 15:01:42 +03:00
466977d457
Fixed homepage filters setting, names, and userId category 2023-07-30 14:54:13 +03:00
aaf0d20c65 UserDatabase->User, ForeignKey added 2023-07-30 12:35:08 +03:00
517609ddbd in create_user (signup) func. dots -> commas 2023-07-29 23:48:28 +03:00
86acf3e326 Odd fields in User model removed. Schemas updated 2023-07-29 23:46:33 +03:00
8b6010f453
Added redirect to home on successfull ann addition 2023-07-29 18:49:02 +03:00
cf81e3d817
Fixed ann removal 2023-07-29 18:47:23 +03:00
e214ea53e7
Fixed sign in redirect on success 2023-07-29 18:11:05 +03:00
ef94349341
Fixed story preview styling 2023-07-29 14:04:48 +03:00
6338e86a33
Added back header to ann add and auth pages
Unified card layout
2023-07-29 13:20:29 +03:00
85472233a3
Connected signing up and signing in to back 2023-07-29 10:41:54 +03:00
eb19113d78 Pass json with id and announc. will be deleted 2023-07-28 00:13:50 +03:00
9e98a224e3 edited path for creation upload folder 2023-07-27 23:25:27 +03:00
8c81935004 create new user without passw., id in create_user 2023-07-27 23:22:24 +03:00
5012642f7a Poems added to db 2023-07-27 21:49:04 +03:00
e5da503ee5 fastapi_users import deleted 2023-07-27 20:27:41 +03:00
b37443b862 SessionLocal ->Session 2023-07-27 20:17:37 +03:00
bd7d4f3c5d Bug in book func fixed (Announcement.id) 2023-07-27 20:14:01 +03:00
48aad4ece7 Merge branch 'main' of https://github.com/dm1sh/porridger_tmp 2023-07-27 20:13:13 +03:00
0e5aeae491
Fixed doSend arguments 2023-07-27 20:09:03 +03:00
50c2b0a615
Added sign out button to user page 2023-07-27 20:07:25 +03:00
6b32cc70b6 some changes (test commit) 2023-07-27 20:05:20 +03:00
6bb7ab5ce9 Auth is working. Disabled field added (models) 2023-07-27 19:24:27 +03:00
dd719a20ec Auth. was fixed. Problem is in getting curr. user 2023-07-27 19:24:27 +03:00
904b00059f Trying to change models 2023-07-27 19:24:27 +03:00
d97ca1c43f Alembic installed and activated. Poems table added 2023-07-27 19:24:27 +03:00
b06306a20b Still no result 2023-07-27 19:24:27 +03:00
d2c7ce453e Prepare to use another auth code 2023-07-27 19:24:27 +03:00
8513e8610b Добавлена response_model=User в get_current_user 2023-07-27 19:24:27 +03:00
ee823ff0c4 добавили responce_model=User к get_current_user 2023-07-27 19:24:27 +03:00
959596311b добавлен параметр response_model к get_user 2023-07-27 19:24:27 +03:00
98139e2162 К схемам из schema.py добавлены доп. поля (соотв. models) 2023-07-27 19:24:27 +03:00
7c317805fb fastapi.Responce has been imported 2023-07-27 19:24:27 +03:00
a234f95ace pass new parameters to sessionmaker 2023-07-27 19:24:27 +03:00
21970120bc parameters of sessionmaker changed 2023-07-27 19:24:27 +03:00
91c99e0fd8 Imported modules corrected 2023-07-27 19:24:27 +03:00
d93b2e131c
Added announcement removal for published by user 2023-07-27 18:43:37 +03:00
9688f56c43
Added useId hook for id retrival from token 2023-07-27 17:54:06 +03:00
3bb6809454
Moved useBook to useSend API 2023-07-27 16:56:00 +03:00
2a229c96ba
Separated useSendWithButton hook from useAddAnnouncement 2023-07-27 16:55:07 +03:00
8220b43e9b
Fixed possibly nullable useFetch or useSend result data 2023-07-27 16:50:26 +03:00
40c5f08dfe
Added points icon 2023-07-27 14:16:12 +03:00
e60d5d6732
Added user points indicator 2023-07-27 12:53:27 +03:00
abe3e64883
Added initial loading setting
Enabled pushing to history on filter setting
2023-07-27 12:00:22 +03:00
325898e76d Загрузить файлы в «back» 2023-07-23 19:27:40 +03:00
9b4ef41030
Added query filters getting and setting on homepage
fixes #27
2023-07-20 14:58:05 +03:00
96388a9bea
Fixed userPage story preview link to use actual filter
Related to #27
2023-07-20 13:25:16 +03:00
58d1996ce3
Added story index management via query on homepage
Related to #27
2023-07-20 13:14:04 +03:00
bc154f8b6b
Fixed useFetch and useUser typing 2023-07-20 00:55:12 +03:00
7a044970f0
Implemented UserPage 2023-07-19 23:25:25 +03:00
7cf83d099d
Added api/user request prototype 2023-07-19 23:24:58 +03:00
1b4eed529a
Developed userPage prototype 2023-07-19 23:23:01 +03:00
bc55ab8f68
Converted api/announcements to use api/announcement processer as its part 2023-07-18 23:07:41 +03:00
148 changed files with 7363 additions and 1169 deletions

View File

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

3
.gitignore vendored
View File

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

View File

@ -11,4 +11,4 @@ COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade -r requirements.txt
COPY ./back ./back
COPY --from=builder /src/dist ./front/dist
CMD uvicorn back.main:app --host 0.0.0.0 --port 80
CMD python -m back.main

View File

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

114
alembic.ini Normal file
View File

@ -0,0 +1,114 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = migrations
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python-dateutil library that can be
# installed by adding `alembic[tz]` to the pip requirements
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to migrations/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
; sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = --fix REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

@ -0,0 +1,116 @@
from sqlalchemy.orm import Session
from sqlalchemy.sql import text, literal_column
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Annotated
from fastapi import Depends
from sqlalchemy import select, or_, and_
import datetime
from . import auth_utils, orm_models, pydantic_schemas
# Загружаем стихи
async def add_poems_to_db(async_db: AsyncSession):
poems = []
f1 = open('poems.txt', encoding='utf-8', mode='r')#открыть фаил для чтения на русском
for a in range(1, 110):
f1.seek(0)#перейти к началу
i=0
str1 = ""
stixi = ""
author = ""
flag = False
while str1 != f"стих {a}\n":
str1=f1.readline()
name=f1.readline()
# Цикл для склеивания стихотворения
while str1 != f"стих {a+1}\n":
str1=f1.readline()
if str1 != f"стих {a + 1}\n":
if (str1 != f"Автор:\n" and flag == False):
stixi += str1 # удаление /n и заключение в список
else:
if str1 == f"Автор:\n":#чтобы не записывать слово "автор"
flag = True
else:
author += str1
poem = orm_models.Poems(title=name, text=stixi, author=author)
# В конце каждой итерации добавляем в базу данных
poems.append(poem)
async_db.add_all(poems)
await async_db.commit()
# close the file
f1.close()
async def filter_ann(schema: pydantic_schemas.SortAnnouncements, db: AsyncSession):
"""Функция для последовательного применения различных фильтров (через схему SortAnnouncements)"""
fields = schema.__dict__ # параметры передоваемой схемы SortAnnouncements (ключи и значения)
# проходим по названиям фильтров и их значениям
# выбираем все строки
query = await db.execute(select(orm_models.Announcement))
res = set(query.scalars().all())
for name, filt_val in fields.items():
# res = await db.execute(statement)
# если фильтр задан
if filt_val is not None:
if name == "obsolete":
filt_val = bool(filt_val)
filter_query = await db.execute(select(orm_models.Announcement).where(literal_column(f"announcements.{name}") == filt_val))
filtered = set(filter_query.scalars().all())
res = res.intersection(filtered)
# # отфильтровываем подходящие объявления
# res = await db.execute(
# select(orm_models.Announcement).where(
# ((schema.obsolete == None) | ((schema.obsolete != None) & (orm_models.Announcement.obsolete == schema.obsolete)))
# & ((schema.user_id == None) | ((schema.user_id != None) & (orm_models.Announcement.user_id == schema.user_id)))
# & ((schema.metro == None) | ((schema.metro != None) & (orm_models.Announcement.metro == schema.metro)))
# & ((schema.category == None) | ((schema.category != None) & (orm_models.Announcement.category == schema.category)))
# )
# )
# .where(schema.user_id != None and orm_models.Announcement.user_id == schema.user_id)
# .where(schema.metro != None and orm_models.Announcement.metro == schema.metro)
# .where(schema.category != None and orm_models.Announcement.category == schema.category)
# statement = text("SELECT * FROM announcements "
# "WHERE announcements.obsolete = :obsolete "
# "INTERSECT"
# "SELECT * FROM announcements "
# "WHERE announcements.user_id == :user_id "
# "INTERSECT"
# "SELECT * FROM announcements "
# "WHERE announcements.metro == :metro "
# "INTERSECT"
# "SELECT * FROM announcements "
# "WHERE announcements.category == :category")
# res = await db.execute(statement,
# {"obsolete": schema.obsolete,
# "user_id": schema.user_id,
# "metro": schema.metro,
# "category": schema.category}
# )
# возвращаем все подходящие объявления
return res
async def check_obsolete(db: AsyncSession, current_date: datetime.date):
"""
Функция участвует в процессе обновления поля obsolete у всех объявлений раз в сутки
"""
# обращаемся ко всем объявлениям бд
query_announcements = await db.execute(select(orm_models.Announcement))
announcements = query_announcements.scalars().all()
# для каждого объявления
for ann in announcements:
# если просрочено
if ann.best_by < current_date:
ann.obsolete = True
await db.commit()
await db.refresh(ann) # обновляем состояние объекта

333
back/api.py Normal file
View File

@ -0,0 +1,333 @@
#подключение библиотек
from fastapi import FastAPI, Depends, Form, status, HTTPException, APIRouter, UploadFile
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.security import OAuth2PasswordRequestForm
from fastapi.templating import Jinja2Templates
from fastapi.requests import Request
from typing import Any, Annotated, List, Union
from starlette.staticfiles import StaticFiles
from sqlalchemy.orm import Session
from sqlalchemy import select
import requests
from uuid import uuid4
import random
import datetime
import asyncio
import ast
import pathlib
import shutil
import os
from . import add_poems_and_filters, auth_utils, orm_models, pydantic_schemas
from .config import TRASHBOXES_BASE_URL, TRASHBOXES_TOKEN
# создаем приложение Fastapi
app = FastAPI()
# Jinja2 - шаблоны
templates = Jinja2Templates(directory="./front/dist")
# хранение картинок для стихов
app.mount("/poem_pic", StaticFiles(directory = "./poem_pic"))
# создаем эндпоинт для хранения статических файлов
app.mount("/static", StaticFiles(directory = "./front/dist"))
# проверяем, что папка uploads еще не создана
if not os.path.exists("./uploads"):
os.mkdir("./uploads")
# создаем эндпоинт для хранения файлов пользователя
app.mount("/uploads", StaticFiles(directory = "./uploads"))
# эндпоинт для возвращения согласия в pdf
@app.get("/privacy_policy.pdf")
async def privacy_policy():
return FileResponse("./privacy_policy.pdf")
# получение списка объявлений
@app.get("/api/announcements", response_model=List[pydantic_schemas.Announcement])#адрес объявлений
async def announcements_list(db: Annotated[Session, Depends(auth_utils.get_session)], obsolete: Union[bool, None] = False, user_id: Union[int, None] = None,
metro: Union[str, None] = None,category: Union[str, None] = None):
# параметры для сортировки (схема pydantic schemas.SortAnnouncements)
params_to_sort = pydantic_schemas.SortAnnouncements(obsolete=obsolete, user_id=user_id, metro=metro, category=category)
# получаем результат
result = await add_poems_and_filters.filter_ann(db=db, schema=params_to_sort)
return result
# получаем данные одного объявления
@app.get("/api/announcement", response_model=pydantic_schemas.AnnResponce)
async def single_announcement(ann_id:int, db: Annotated[Session, Depends(auth_utils.get_session)]): # передаем индекс обявления
# Считываем данные из Body и отображаем их на странице.
# В последствии будем вставлять данные в html-форму
announcement = await db.get(orm_models.Announcement, ann_id)
#announcement = await db.execute(select(orm_models.Announcement)).scalars().all()
if not announcement:
raise HTTPException(status_code=404, detail="Item not found")
return announcement
# Занести объявление в базу данных
@app.put("/api/announcement")
async def put_in_db(name: Annotated[str, Form()], category: Annotated[str, Form()], bestBy: Annotated[datetime.date, Form()],
address: Annotated[str, Form()], longtitude: Annotated[float, Form()], latitude: Annotated[float, Form()],
description: Annotated[str, Form()], metro: Annotated[str, Form()], current_user: Annotated[pydantic_schemas.User, Depends(auth_utils.get_current_active_user)],
db: Annotated[Session, Depends(auth_utils.get_session)], src: Union[UploadFile, None] = None, trashId: Annotated[int, Form()] = None):
# имя загруженного файла по умолчанию - пустая строка
uploaded_name = ""
# если пользователь загрузил картинку
if src:
# процесс сохранения картинки
f = src.file
f.seek(0, os.SEEK_END)
if f.tell() > 0:
f.seek(0)
destination = pathlib.Path("./uploads/" + str(hash(f)) + pathlib.Path(src.filename).suffix.lower())
with destination.open('wb') as buffer:
shutil.copyfileobj(f, buffer)
# изменяем название директории загруженного файла
uploaded_name = "/uploads/" + destination.name
# создаем объект Announcement
temp_ancmt = orm_models.Announcement(user_id=current_user.id, name=name, category=category, best_by=bestBy,
address=address, longtitude=longtitude, latitude=latitude, description=description, metro=metro,
trashId=trashId, src=uploaded_name, booked_by=0)
try:
db.add(temp_ancmt) # добавляем в бд
await db.commit() # сохраняем изменения
await db.refresh(temp_ancmt) # обновляем состояние объекта
return {"Success": True}
except:
raise HTTPException(status_code=500, detail="problem with adding object to db")
# Удалить объявления из базы
@app.delete("/api/announcement") #адрес объявления
async def delete_from_db(announcement: pydantic_schemas.DelAnnouncement, db: Annotated[Session, Depends(auth_utils.get_session)]): # функция удаления объекта из БД
# находим объект с заданным id в бд
#to_delete = db.query(orm_models.Announcement).filter(orm_models.Announcement.id==announcement.id).first()
query = await db.execute(select(orm_models.Announcement).where(orm_models.Announcement.id==announcement.id))
to_delete = query.scalars().first()
if not to_delete:
raise HTTPException(status_code=404, detail="Item not found. Can't delete")
try:
await db.delete(to_delete) # удаление из БД
await db.commit() # сохраняем изменения
return {"Success": True}
except:
raise HTTPException(status_code=500, detail="Problem with adding to database")
# Забронировать объявление
@app.post("/api/book")
async def change_book_status(data: pydantic_schemas.Book, current_user: Annotated[pydantic_schemas.User, Depends(auth_utils.get_current_user)],
db: Annotated[Session, Depends(auth_utils.get_session)]):
# Находим объявление по данному id
#announcement_to_change = db.query(orm_models.Announcement).filter(orm_models.Announcement.id == data.id).first()
query = await db.execute(select(orm_models.Announcement).where(orm_models.Announcement.id == data.id))
announcement_to_change = query.scalars().first()
# Проверяем, что объявление с данным id существует
if not announcement_to_change:
raise HTTPException(status_code=404, detail="Item not found")
# Проверяем, что объявление бронирует не владелец
if current_user.id == announcement_to_change.user_id:
raise HTTPException(status_code=403, detail="A user can't book his announcement")
else:
# Инкрементируем поле booked_by на 1
announcement_to_change.booked_by += 1
# фиксируем изменения в бд
await db.commit()
await db.refresh(announcement_to_change)
return {"Success": True}
# reginstration
@app.post("/api/signup")
async def create_user(nickname: Annotated[str, Form()], password: Annotated[str, Form()], db: Annotated[Session, Depends(auth_utils.get_session)],
name: Annotated[str, Form()]=None, surname: Annotated[str, Form()]=None, avatar: Annotated[UploadFile, Form()]=None):
# проверяем, что юзера с введенным никнеймом не существует в бд
#if db.query(orm_models.User).filter(orm_models.User.nickname == nickname).first() == None:
query_user = await db.execute(select(orm_models.User).where(orm_models.User.nickname == nickname))
user_with_entered_nick = query_user.scalars().first()
if user_with_entered_nick == None:
# создаем нового юзера
new_user = orm_models.User(nickname=nickname, hashed_password=auth_utils.get_password_hash(password),
name=name, surname=surname, reg_date=datetime.date.today())
# добавляем в бд
db.add(new_user)
await db.commit()
await db.refresh(new_user) # обновляем состояние объекта
return {"Success": True}
return {"Success": False, "Message": "Пользователь с таким email уже зарегестрирован"}
# функция для генерации токена после успешного входа пользователя
@app.post("/api/token", response_model=pydantic_schemas.Token)
async def login_for_access_token(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()], db: Annotated[Session, Depends(auth_utils.get_session)]
):
# пробуем найти юзера в бд по введенным паролю и никнейму
user = await auth_utils.authenticate_user(db, form_data.username, form_data.password)
# если не нашли - кидаем ошибку
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
# задаем временной интервал, в течение которого токен можно использовать
access_token_expires = auth_utils.timedelta(minutes=auth_utils.ACCESS_TOKEN_EXPIRE_MINUTES)
# создаем токен
access_token = auth_utils.create_access_token(
data={"user_id": user.id}, expires_delta=access_token_expires
)
return {"access_token":access_token}
# получаем данные успешно вошедшего пользователя
@app.get("/api/users/me", response_model=pydantic_schemas.User) #
def read_users_me(current_user: Annotated[pydantic_schemas.User, Depends(auth_utils.get_current_active_user)]):
return current_user
# изменяем рейтинг пользователя
@app.post("/api/user/rating")
async def add_points(data: pydantic_schemas.AddRating, current_user: Annotated[pydantic_schemas.User, Depends(auth_utils.get_current_user)], db: Annotated[Session, Depends(auth_utils.get_session)]):
# проверяем,
if current_user.id != data.user_id:
user = await auth_utils.get_user_by_id(db, data.user_id)
if not user:
raise HTTPException(status_code=404, detail="Item not found")
user.rating = (user.rating*user.num_of_ratings + data.rate)/(user.num_of_ratings + 1)
user.num_of_ratings += 1
await db.commit()
await db.refresh(user) # обновляем состояние объекта
return {"Success": True}
# получаем рейтинг пользователя
@app.get("/api/user/rating")
async def add_points(user_id: int, db: Annotated[Session, Depends(auth_utils.get_session)]):
user = await auth_utils.get_user_by_id(db, user_id=user_id)
if not user:
raise HTTPException(status_code=404, detail="Item not found")
return {"rating": user.rating}
# Отправляем стихи
@app.get("/api/user/poem", response_model=pydantic_schemas.Poem)
async def poems_to_front(db: Annotated[Session, Depends(auth_utils.get_session)]):
#num_of_poems = db.query(orm_models.Poems).count() # определяем кол-во стихов в бд
query = await db.execute(select(orm_models.Poems)) # определяем кол-во стихов в бд
num_of_poems = len(query.scalars().all())
# если стихов в бд нет
if num_of_poems < 1:
await add_poems_and_filters.add_poems_to_db(db) # добавляем поэмы в базу данных
# после добавления стихов снова определяем кол-во стихов в бд
query = await db.execute(select(orm_models.Poems))
num_of_poems = len(query.scalars().all())
rand_id = random.randint(1, num_of_poems) # генерируем номер стихотворения
#poem = db.query(orm_models.Poems).filter(orm_models.Poems.id == rand_id).first() # находим стих в бд
query_poem = await db.execute(select(orm_models.Poems).where(orm_models.Poems.id == rand_id)) # находим стих в бд
poem = query_poem.scalars().first()
if not poem:
raise HTTPException(status_code=404, detail="Poem not found")
return poem
trashboxes_category = {
"PORRIDGE": ["Опасные отходы", "Иное"],
"conspects": ["Бумага"],
"milk": ["Стекло", "Тетра Пак", "Иное"],
"bred": ["Пластик", "Иное"],
"wathing": ["Пластик", "Опасные отходы", "Иное"],
"cloth": ["Одежда"],
"fruits_vegatables": ["Иное"],
"other_things": ["Металл", "Бумага", "Стекло", "Иное", "Тетра Пак", "Батарейки", "Крышечки", "Шины",
"Опасные отходы", "Лампочки", "Пластик"]
}
@app.get("/api/trashbox", response_model=List[pydantic_schemas.TrashboxResponse])
async def get_trashboxes(data: pydantic_schemas.TrashboxRequest = Depends()): #крутая функция для работы с api
# json, передаваемый стороннему API
head = {'Authorization': 'Bearer ' + TRASHBOXES_TOKEN}
# Данные пользователя (местоположение, количество мусорок, которое пользователь хочет видеть)
my_data={
'x' : f"{data.Lng}",
'y' : f"{data.Lat}",
'limit' : '1'
}
# Перевод категории с фронта на категорию с сайта
try:
list_of_category = trashboxes_category[data.Category]
except:
list_of_category = trashboxes_category['other_things']
# Получение ответа от стороннего апи
response = requests.post(TRASHBOXES_BASE_URL + "/nearest_recycling/get", headers=head, data=my_data, timeout=10)
infos = response.json()
if 'error' in infos and infos['error_description'] == 'Invalid bearer token':
raise HTTPException(status_code=502, detail="Invalid trashboxes token")
# Чтение ответа
trashboxes = []
for trashbox in infos["results"]:
temp_dict = {}
for obj in trashbox["Objects"]:
coord_list = obj["geometry"]
temp_dict["Lat"] = coord_list["coordinates"][1]
temp_dict["Lng"] = coord_list["coordinates"][0]
properties = obj["properties"]
temp_dict["Name"] = properties["title"]
temp_dict["Address"] = properties["address"]
temp_dict["Categories"] = properties["content_text"].split(',')
for a in list_of_category:
if a in temp_dict["Categories"] and temp_dict not in trashboxes:
trashboxes.append(temp_dict)
uniq_trashboxes = [pydantic_schemas.TrashboxResponse(**ast.literal_eval(el1)) for el1 in set([str(el2) for el2 in trashboxes])]
return uniq_trashboxes
@app.get("/{rest_of_path:path}")
async def react_app(req: Request, rest_of_path: str):
return templates.TemplateResponse('index.html', { 'request': req })
@app.post("/api/announcement/dispose")
async def dispose(data: pydantic_schemas.DisposeRequest, current_user_schema: Annotated[pydantic_schemas.User, Depends(auth_utils.get_current_user)],
db: Annotated[Session, Depends(auth_utils.get_session)]):
# Находим в бд текущего юзера
current_user = await auth_utils.get_user_by_id(db, current_user_schema.id)
# Начисляем баллы пользователю за утилизацию
current_user.points += 60
# В полученном json переходим к данным мусорки
data_trashbox = data.trashbox
# создаем запись models.Trashbox
new_trashox = orm_models.Trashbox(user_id=current_user.id, date_of_choice=datetime.date.today(), name=data_trashbox.Name,
latitude=data_trashbox.Lat, longtitude=data_trashbox.Lng, address=data_trashbox.Address, category=data_trashbox.Category)
# добавляем в бд
db.add(new_trashox)
# в соответствии с логикой api, после утилизации объявление пользователя удаляется
# находим объявление с айди data.ann_id
#ann_to_del = db.query(orm_models.Announcement).filter(orm_models.Announcement.id == data.ann_id).first() #
query_ann = await db.execute(select(orm_models.Announcement).where(orm_models.Announcement.id == data.ann_id)) # находим объявление в бд
ann_to_del = query_ann.scalars().first()
if not ann_to_del:
raise HTTPException(status_code=404, detail="Announcement not found")
# удаляем объявление из бд
await db.delete(ann_to_del)
await db.commit()
await db.refresh(new_trashox) # обновляем состояние объекта
return {"Success": True}

96
back/auth_utils.py Normal file
View File

@ -0,0 +1,96 @@
from datetime import datetime, timedelta
from typing import Annotated, Union
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from passlib.context import CryptContext
from sqlalchemy import select
from sqlalchemy.orm import Session
from sqlalchemy.ext.asyncio import AsyncSession
from .db import SessionLocal
from . import orm_models, pydantic_schemas
from .config import SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/token")
async def get_session() -> AsyncSession:
async with SessionLocal() as session:
yield session
def verify_password(plain_password, hashed_password):
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password):
return pwd_context.hash(password)
async def get_user_by_nickname(db: Annotated[AsyncSession, Depends(get_session)], nickname: str):
query = await db.execute(select(orm_models.User).where(orm_models.User.nickname == nickname))
user_with_required_nickname = query.scalars().first()
if user_with_required_nickname:
return user_with_required_nickname
return None
async def get_user_by_id(db: Annotated[AsyncSession, Depends(get_session)], user_id: int):
query = await db.execute(select(orm_models.User).where(orm_models.User.id == user_id))
user_with_required_id = query.scalars().first()
if user_with_required_id:
return user_with_required_id
return None
async def authenticate_user(db: Annotated[AsyncSession, Depends(get_session)], nickname: str, password: str):
user = await get_user_by_nickname(db=db, nickname=nickname)
if not user:
return False
if not verify_password(password, user.hashed_password):
return False
return user
def create_access_token(data: dict, expires_delta: Union[timedelta, None] = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=15)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
async def get_current_user(db: Annotated[AsyncSession, Depends(get_session)], token: Annotated[str, Depends(oauth2_scheme)]):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
user_id: int = payload.get("user_id")
if user_id is None:
raise credentials_exception
token_data = pydantic_schemas.TokenData(user_id=user_id)
except JWTError:
raise credentials_exception
user = await get_user_by_id(db, user_id=token_data.user_id)
if user is None:
raise credentials_exception
return pydantic_schemas.User(id=user.id, nickname=user.nickname, name=user.name, surname=user.surname,
disabled=user.disabled, items=user.announcements, reg_date=user.reg_date, points=user.points)
def get_current_active_user(
current_user: Annotated[pydantic_schemas.User, Depends(get_current_user)]
):
if current_user.disabled:
raise HTTPException(status_code=400, detail="Inactive user")
return current_user

View File

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

13
back/config.py Normal file
View File

@ -0,0 +1,13 @@
import os
from dotenv import load_dotenv
load_dotenv('.env')
TRASHBOXES_TOKEN = os.environ.get("TRASHBOXES_TOKEN")
TRASHBOXES_BASE_URL = os.environ.get("TRASHBOXES_BASE_URL")
SECRET_KEY = os.environ.get("SECRET_KEY")
ALGORITHM = os.environ.get("ALGORITHM")
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.environ.get("ACCESS_TOKEN_EXPIRE_MINUTES"))
SQLALCHEMY_DATABASE_URL = os.environ.get("SQLALCHEMY_DATABASE_URL")

View File

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

View File

@ -1,40 +0,0 @@
from .db import engine
from .models import Announcement, UserDatabase, Trashbox, Base
Base.metadata.create_all(bind=engine)
db = SessionLocal()
# Пробный чувак
tom = UserDatabase(name="Tom", phone="89999999", email="pupka", password="1234", surname="Smith")
# db.add(tom) # добавляем в бд
# db.commit() # сохраняем изменения
# db.refresh(tom) # обновляем состояние объекта
# Пробное объявление 1
a1 = Announcement(user_id=1, category="cat", best_by="201223", address="abd", longtitude=23, latitude=22,
description="abv", src="111", metro="Lesnaya", booked_by=2)
# Пробное объявление 2
a2 = Announcement(user_id=1, category="dog", best_by="221223", address="abd", longtitude=50, latitude=12,
description="vvv", src="110", metro="Petrogradskaya", booked_by=2)
a3 = Announcement(user_id=1, category="a", best_by="221223", address="abd", longtitude=20, latitude=25,
description="vvv", src="101", metro="metro", booked_by=2)
trash1 = Trashbox(name="Tom", address="abd", longtitude=23, latitude=22, category="indisposable")
# db.add(a1) # добавляем в бд
# db.add(a2) # добавляем в бд
# db.add(a3) # добавляем в бд
# db.add(trash1) # добавляем в бд
# db.commit() # сохраняем изменения
# db.refresh(a1) # обновляем состояние объекта
# db.refresh(a2) # обновляем состояние объекта
# db.refresh(a3) # обновляем состояние объекта
# db.refresh(trash1) # обновляем состояние объекта
# # Удалить все
# db.query(User).delete()
# db.query(Announcement).delete()
# db.commit()

7
back/delete_all_poems.py Normal file
View File

@ -0,0 +1,7 @@
from .orm_models import Poems
from .db import database
all_poems = database.query(Poems).all()
for to_delete in all_poems:
database.delete(to_delete)
database.commit()

6
back/delete_db.py Normal file
View File

@ -0,0 +1,6 @@
from sqlalchemy import Table, MetaData, text
from .db import engine, Base
tbl = Table('Poems', MetaData(), autoload_with=engine)
tbl.drop(engine, checkfirst=False)
a = input()

View File

@ -1,218 +1,32 @@
#подключение библиотек
from fastapi import FastAPI, Response, Path, Depends, Body, Form, Query, status, HTTPException, APIRouter, UploadFile, File
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.security import OAuth2PasswordRequestForm, OAuth2PasswordBearer
from fastapi.templating import Jinja2Templates
from fastapi.requests import Request
import asyncio
import uvicorn
from pydantic import json
from starlette.staticfiles import StaticFiles
import requests
from uuid import uuid4
import ast
import pathlib
import shutil
import os
from .utils import *
from .db import Base, engine, SessionLocal, database
from .models import Announcement, Trashbox, UserDatabase
from . import schema
Base.metadata.create_all(bind=engine)
app = FastAPI()
templates = Jinja2Templates(directory="./front/dist")
app.mount("/static", StaticFiles(directory = "./front/dist"))
if not os.path.exists("./uploads"):
os.mkdir("C:/Users/38812/porridger/uploads")
app.mount("/uploads", StaticFiles(directory = "./uploads"))
from .api import app as app_fastapi
from .scheduler import app as app_rocketry
from .db import init_models
@app.get("/api/announcements")#адрес объявлений
def annoncements_list(user_id: int = None, metro: str = None, category: str = None, booked_by: int = -1):
# Считываем данные из Body и отображаем их на странице.
# В последствии будем вставлять данные в html-форму
class Server(uvicorn.Server):
"""Customized uvicorn.Server
a = database.query(Announcement)
b = database.query(Announcement)
c = database.query(Announcement)
d = database.query(Announcement)
e = database.query(Announcement)
if user_id != None:
b = a.filter(Announcement.user_id == user_id)
if metro != None:
c = a.filter(Announcement.metro == metro)
if category != None:
d = a.filter(Announcement.category == category)
if booked_by != -1:
e = a.filter(Announcement.booked_by == booked_by)
if not any([category, user_id, metro]) and booked_by == -1:
result = a.all()
else:
result = b.intersect(c, d, e).all()
return {"Success" : True, "list_of_announcements": result}
Uvicorn server overrides signals and we need to include
Rocketry to the signals."""
def handle_exit(self, sig: int, frame) -> None:
app_rocketry.session.shut_down()
return super().handle_exit(sig, frame)
@app.get("/api/announcement")#адрес объявлений
def single_annoncement(user_id:int):
# Считываем данные из Body и отображаем их на странице.
# В последствии будем вставлять данные в html-форму
try:
annoncement = database.get(Announcement, user_id)
return {"id": annoncement.id, "user_id": annoncement.user_id, "name": annoncement.name,
"category": annoncement.category, "best_by": annoncement.best_by, "address": annoncement.address,
"description": annoncement.description, "metro": annoncement.metro, "latitude": annoncement.latitude,
"longtitude":annoncement.longtitude, "trashId": annoncement.trashId, "src":annoncement.src,
"booked_by":annoncement.booked_by}
except:
return {"Answer" : False} #если неуданый доступ, то сообщаем об этом
async def main():
"Run scheduler and the API"
await init_models()
# Занести объявление в базу
@app.put("/api/announcement")#адрес объявлений
def put_in_db(name: Annotated[str, Form()], category: Annotated[str, Form()], bestBy: Annotated[int, Form()], address: Annotated[str, Form()], longtitude: Annotated[float, Form()], latitude: Annotated[float, Form()], description: Annotated[str, Form()], src: UploadFile, metro: Annotated[str, Form()], trashId: Annotated[int, Form()] = None):
# try:
userId = 1 # temporary
uploaded_name = ""
server = Server(config=uvicorn.Config(app_fastapi, workers=1, loop="asyncio", host="0.0.0.0"))
f = src.file
f.seek(0, os.SEEK_END)
if f.tell() > 0:
f.seek(0)
destination = pathlib.Path("./uploads/" + str(hash(src.file)) + pathlib.Path(src.filename).suffix.lower())
with destination.open('wb') as buffer:
shutil.copyfileobj(src.file, buffer)
api = asyncio.create_task(server.serve())
sched = asyncio.create_task(app_rocketry.serve())
uploaded_name = "/uploads/"+destination.name
await asyncio.wait([sched, api])
temp_ancmt = Announcement(user_id=userId, name=name, category=category, best_by=bestBy, address=address, longtitude=longtitude, latitude=latitude, description=description, src=uploaded_name, metro=metro, trashId=trashId, booked_by=-1)
db.add(temp_ancmt) # добавляем в бд
db.commit() # сохраняем изменения
db.refresh(temp_ancmt) # обновляем состояние объекта
return {"Answer" : True}
# except:
# return {"Answer" : False}
# Удалить объявления из базы
@app.delete("/api/announcement") #адрес объявления
def delete_from_db(data = Body()):#функция удаления объекта из БД
try:
database.delete(user_id=data.user_id)#удаление из БД
database.commit() # сохраняем изменения
return {"Answer" : True}
except:
return {"Answer" : False}
# Забронировать объявление
@app.post("/api/book")
def change_book_status(data: schema.Book):
try:
# Получаем id пользователя, который бронирует объявление
temp_user_id = 1
# Находим объявление по данному id
announcement_to_change = database.query(Announcement).filter(id == data.id).first()
# Изменяем поле booked_status на полученный id
announcement_to_change.booked_status = temp_user_id
return {"Success": True}
except:
return {"Success": False}
# reginstration
@app.post("/api/signup")
def create_user(data = Body()):
if database.query(UserDatabase).filter(UserDatabase.email == data["email"]).first() == None:
new_user = UserDatabase(id=data["id"], email=data["email"], password=data["password"], name=data["name"], surname=data["surname"])
database.add(new_user)
database.commit()
database.refresh(new_user) # обновляем состояние объекта
return {"Success": True}
return {"Success": False, "Message": "Пользователь с таким email уже зарегестрирован."}
@app.post("/api/token", response_model=Token)
async def login_for_access_token(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()]
):
# разобраться с первым параметром
user = authenticate_user(database, form_data.username, form_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"user_id": user.id}, expires_delta=access_token_expires
)
return access_token
@app.get("/api/users/me/", response_model=schema.User)
async def read_users_me(
current_user: Annotated[User, Depends(get_current_active_user)]
):
return current_user
@app.get("/api/users/me/items/")
async def read_own_items(
current_user: Annotated[User, Depends(get_current_active_user)]
):
return [{"Current user name": current_user.name, "Current user surname": current_user.surname}]
@app.get("/api/trashbox")
def get_trashboxes(lat:float, lng:float):#крутая функция для работы с api
BASE_URL='https://geointelect2.gate.petersburg.ru'#адрес сайта и мой токин
my_token='eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJhU1RaZm42bHpTdURYcUttRkg1SzN5UDFhT0FxUkhTNm9OendMUExaTXhFIn0.eyJleHAiOjE3ODM3ODk4NjgsImlhdCI6MTY4OTA5NTQ2OCwianRpIjoiNDUzNjQzZTgtYTkyMi00NTI4LWIzYmMtYWJiYTNmYjkyNTkxIiwiaXNzIjoiaHR0cHM6Ly9rYy5wZXRlcnNidXJnLnJ1L3JlYWxtcy9lZ3MtYXBpIiwiYXVkIjoiYWNjb3VudCIsInN1YiI6ImJjYjQ2NzljLTU3ZGItNDU5ZC1iNWUxLWRlOGI4Yzg5MTMwMyIsInR5cCI6IkJlYXJlciIsImF6cCI6ImFkbWluLXJlc3QtY2xpZW50Iiwic2Vzc2lvbl9zdGF0ZSI6ImM2ZDJiOTZhLWMxNjMtNDAxZS05ZjMzLTI0MmE0NDcxMDY5OCIsImFjciI6IjEiLCJhbGxvd2VkLW9yaWdpbnMiOlsiLyoiXSwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbImRlZmF1bHQtcm9sZXMtZWdzLWFwaSIsIm9mZmxpbmVfYWNjZXNzIiwidW1hX2F1dGhvcml6YXRpb24iXX0sInJlc291cmNlX2FjY2VzcyI6eyJhY2NvdW50Ijp7InJvbGVzIjpbIm1hbmFnZS1hY2NvdW50IiwibWFuYWdlLWFjY291bnQtbGlua3MiLCJ2aWV3LXByb2ZpbGUiXX19LCJzY29wZSI6ImVtYWlsIHByb2ZpbGUiLCJzaWQiOiJjNmQyYjk2YS1jMTYzLTQwMWUtOWYzMy0yNDJhNDQ3MTA2OTgiLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsIm5hbWUiOiLQktC70LDQtNC40LzQuNGAINCv0LrQvtCy0LvQtdCyIiwicHJlZmVycmVkX3VzZXJuYW1lIjoiZTBmYzc2OGRhOTA4MjNiODgwZGQzOGVhMDJjMmQ5NTciLCJnaXZlbl9uYW1lIjoi0JLQu9Cw0LTQuNC80LjRgCIsImZhbWlseV9uYW1lIjoi0K_QutC-0LLQu9C10LIifQ.E2bW0B-c6W5Lj63eP_G8eI453NlDMnW05l11TZT0GSsAtGayXGaolHtWrmI90D5Yxz7v9FGkkCmcUZYy1ywAdO9dDt_XrtFEJWFpG-3csavuMjXmqfQQ9SmPwDw-3toO64NuZVv6qVqoUlPPj57sLx4bLtVbB4pdqgyJYcrDHg7sgwz4d1Z3tAeUfSpum9s5ZfELequfpLoZMXn6CaYZhePaoK-CxeU3KPBPTPOVPKZZ19s7QY10VdkxLULknqf9opdvLs4j8NMimtwoIiHNBFlgQz10Cr7bhDKWugfvSRsICouniIiBJo76wrj5T92s-ztf1FShJuqnQcKE_QLd2A'
head = {'Authorization': 'Bearer {}'.format(my_token)}
my_data={
'x' : f"{lng}",
'y' : f"{lat}",
'limit' : '1'
}
response = requests.post(f"{BASE_URL}/nearest_recycling/get", headers=head, data=my_data)
infos = response.json()
trashboxes = []
for trashbox in infos["results"]:
temp_dict = {}
for obj in trashbox["Objects"]:
coord_list = obj["geometry"]
temp_dict["Lat"] = coord_list["coordinates"][1]
temp_dict["Lng"] = coord_list["coordinates"][0]
properties = obj["properties"]
temp_dict["Name"] = properties["title"]
temp_dict["Address"] = properties["address"]
temp_dict["Categories"] = properties["content_text"].split(',')
trashboxes.append(temp_dict)
uniq_trashboxes = [ast.literal_eval(el1) for el1 in set([str(el2) for el2 in trashboxes])]
return JSONResponse(uniq_trashboxes)
@app.get("/{rest_of_path:path}")
async def react_app(req: Request, rest_of_path: str):
return templates.TemplateResponse('index.html', { 'request': req })
if __name__ == "__main__":
asyncio.run(main())

View File

@ -1,62 +0,0 @@
from sqlalchemy import Column, Integer, String
from fastapi import Depends
from .db import Base
class UserDatabase(Base):#класс пользователя
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)#айди пользователя
phone = Column(Integer, nullable=True)#номер телефона пользователя
email = Column(String)#электронная почта пользователя
password = Column(String) # пароль
hashed_password = Column(String)
name = Column(String, nullable=True)#имя пользователя
surname = Column(String)#фамилия пользователя
class Announcement(Base): #класс объявления
__tablename__ = "announcements"
id = Column(Integer, primary_key=True, index=True)#айди объявления
user_id = Column(Integer)#айди создателя объявления
name = Column(String) # название объявления
category = Column(String)#категория продукта из объявления
best_by = Column(Integer)#срок годности продукта из объявления
address = Column(String)
longtitude = Column(Integer)
latitude = Column(Integer)
description = Column(String)#описание продукта в объявлении
src = Column(String, nullable=True) #изображение продукта в объявлении
metro = Column(String)#ближайщее метро от адреса нахождения продукта
trashId = Column(Integer, nullable=True)
booked_by = Column(Integer)#статус бронирования (либо -1, либо айди бронирующего)
class Trashbox(Base):#класс мусорных баков
__tablename__ = "trashboxes"
id = Column(Integer, primary_key=True, index=True)#айди
name = Column(String, nullable=True)#имя пользователя
address = Column(String)
latitude = Column(Integer)
longtitude = Column(Integer)
category = Column(String)#категория продукта из объявления
# from typing import AsyncGenerator
# from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
# from fastapi_users.db import SQLAlchemyBaseUserTableUUID, SQLAlchemyUserDatabase
# # This function can be called during the initialization of the FastAPI app.
# async def create_db_and_tables():
# async with engine.begin() as conn:
# await conn.run_sync(Base.metadata.create_all)
# async def get_async_session() -> AsyncGenerator[AsyncSession, None]:
# async with async_session_maker() as session:
# yield session
# async def get_user_db(session: AsyncSession = Depends(get_async_session)):
# yield SQLAlchemyUserDatabase(session, User)

69
back/orm_models.py Normal file
View File

@ -0,0 +1,69 @@
from sqlalchemy import Column, Integer, String, Boolean, Float, Date, ForeignKey
from sqlalchemy.orm import relationship
from .db import Base, engine
class User(Base):#класс пользователя
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True, unique=True)#айди пользователя
nickname = Column(String) # никнейм пользователя
hashed_password = Column(String)
name = Column(String, nullable=True)#имя пользователя
surname = Column(String)#фамилия пользователя
disabled = Column(Boolean, default=False)
rating = Column(Integer, default=0) # рейтинг пользователя (показатель надежности)
points = Column(Integer, default=0) # баллы пользователя (заслуги перед платформой)
num_of_ratings = Column(Integer, default=0) # количество оценок (т.е. то, сколько раз другие пользователи оценили текущего)
reg_date = Column(Date) # дата регистрации
announcements = relationship("Announcement", back_populates="user", lazy='selectin')
trashboxes_chosen = relationship("Trashbox", back_populates="user", lazy='selectin')
class Announcement(Base): #класс объявления
__tablename__ = "announcements"
id = Column(Integer, primary_key=True, index=True) # айди объявления
user_id = Column(Integer, ForeignKey("users.id")) # айди создателя объявления
name = Column(String) # название объявления
category = Column(String) #категория продукта из объявления
best_by = Column(Date) #срок годности продукта из объявления
address = Column(String)
longtitude = Column(Float)
latitude = Column(Float)
description = Column(String) #описание продукта в объявлении
src = Column(String, nullable=True) #изображение продукта в объявлении
metro = Column(String) #ближайщее метро от адреса нахождения продукта
trashId = Column(Integer, nullable=True)
booked_by = Column(Integer) #количество забронировавших (0 - никто не забронировал)
# state = Column(Enum(State), default=State.published) # состояние объявления (опубликовано, забронировано, устарело)
obsolete = Column(Boolean, default=False) # состояние объявления (по-умолчанию считаем его актуальным)
user = relationship("User", back_populates="announcements")
class Trashbox(Base): #класс мусорных баков
__tablename__ = "trashboxes"
id = Column(Integer, primary_key=True, index=True)#айди
user_id = Column(Integer, ForeignKey("users.id")) # айди выбравшего мусорку
name = Column(String, nullable=True)#название мусорки
address = Column(String)
latitude = Column(Float)
longtitude = Column(Float)
category = Column(String) #типы мусора (из тех, что возвращает API мусорки)
date_of_choice = Column(Date) # Дата выбора мусорки пользователем
user = relationship("User", back_populates="trashboxes_chosen")
class Poems(Base):#класс поэзии
__tablename__ = "poems"
id = Column(Integer, primary_key=True, index=True) #айди
title = Column(String) # название стихотворения
text = Column(String) # текст стихотворения
author = Column(String) # автор стихотворения

131
back/pydantic_schemas.py Normal file
View File

@ -0,0 +1,131 @@
from pydantic import BaseModel
from typing import Annotated, Union
from datetime import date
from typing import List
from fastapi import UploadFile, Form
class Book(BaseModel):
id: int
class DelAnnouncement(BaseModel):
id: int
class Announcement(BaseModel):
id: int
user_id: int
name: str
category: str
best_by: date
address: str
longtitude: float
latitude: float
description: str
src: Union[str, None] = None #изображение продукта в объявлении
metro: str #ближайщее метро от адреса нахождения продукта
trashId: Union[int, None] = None
booked_by: Union[int, None] = 0 #статус бронирования (либо 0, либо айди бронирующего)
obsolete: bool
class Config:
orm_mode = True
arbitrary_types_allowed=True
# для "/api/announcement"
class AnnResponce(BaseModel):
id: int
user_id: int
name: str
category: str
best_by: date
address: str
longtitude: float
latitude: float
description: str
src: Union[str, None] = None #изображение продукта в объявлении
metro: str #ближайщее метро от адреса нахождения продукта
trashId: Union[int, None] = None
booked_by: Union[int, None] = 0 #статус бронирования (либо 0, либо айди бронирующего)
class Config:
orm_mode = True
# Схемы для токенов
class Token(BaseModel):
access_token: str
# token_type: str
class TokenData(BaseModel):
user_id: Union[int, None] = None
# Схемы юзера
class User(BaseModel):
id: int
nickname: str
reg_date: date
disabled: Union[bool, None] = False
items: list[Announcement] = []
points: int
class Config:
orm_mode = True
arbitrary_types_allowed=True
class UserInDB(User):
hashed_password: str
# Схема для стиха
class Poem(BaseModel):
id: int
title: str
text: str
author: str
class Config:
orm_mode = True
# Для "/api/trashbox"
class TrashboxBase(BaseModel):
Lat: float
Lng: float
class TrashboxResponse(TrashboxBase):
Name: str
Address: str
Categories: list[str]
class TrashboxRequest(TrashboxBase):
Category: str
# Для /api/announcement/dispose
class TrashboxSelected(BaseModel):
Lat: float
Lng: float
Name: str
Address: str
Category: str
class DisposeRequest(BaseModel):
ann_id: int
trashbox: TrashboxSelected
# схема для передачи параметров, по которым ведется фильтрация
class SortAnnouncements(BaseModel):
obsolete: Union[int, None] = False
user_id: Union[int, None] = None
metro: Union[str, None] = None
category: Union[str, None] = None
# booked_by: Union[int, None] = None
# схема для начисления баллов
class AddRating(BaseModel):
user_id: int
rate: int

14
back/scheduler.py Normal file
View File

@ -0,0 +1,14 @@
from . import add_poems_and_filters
from rocketry import Rocketry
from rocketry.conds import daily
import datetime
from .db import async_session
app = Rocketry(execution="async")
# Create task:
@app.task('daily')
async def daily_check():
# Фильтруем по сроку годности
await add_poems_and_filters.check_obsolete(async_session, current_date=datetime.date.today())

View File

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

View File

@ -1,97 +0,0 @@
from datetime import datetime, timedelta
from typing import Annotated, Union
from fastapi import Depends, FastAPI, HTTPException, status, Response
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from jose import JWTError, jwt
from passlib.context import CryptContext
from sqlalchemy.orm import Session
from sqlalchemy import select
from .db import SessionLocal, database
from .models import UserDatabase
from .schema import Token, TokenData, UserInDB, User
SECRET_KEY = "09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
# fake_users_db = {
# "johndoe": {
# "email": "johndoe",
# "full_name": "John Doe",
# "email": "johndoe@example.com",
# "hashed_password": "$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW",
# "disabled": False,
# }
# }
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
def verify_password(plain_password, hashed_password):
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password):
return pwd_context.hash(password)
# проблема здесь
def get_user(db: SessionLocal, email: str, response_model=User):
user_with_required_email = db.query(UserDatabase).filter(UserDatabase.email == email).one()
if user_with_required_email:
return UserInDB(user_with_required_email)
return None
def authenticate_user(db: SessionLocal, email: str, password: str):
user = get_user(db, email)
if not user:
return False
if not verify_password(password, user.hashed_password):
return False
return user
def create_access_token(data: dict, expires_delta: Union[timedelta, None] = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=15)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
async def get_current_user(db: SessionLocal, token: Annotated[str, Depends(oauth2_scheme)], response_model=User):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
email: str = payload.get("sub")
if email is None:
raise credentials_exception
token_data = TokenData(email=email)
except JWTError:
raise credentials_exception
user = get_user(db, email=token_data.email)
if user == None:
raise credentials_exception
return user
async def get_current_active_user(
current_user: Annotated[User, Depends(get_current_user)]
):
if current_user.disabled:
raise HTTPException(status_code=400, detail="Inactive user")
return current_user

View File

@ -32,5 +32,16 @@ module.exports = {
}
],
'jsx-quotes': [2, 'prefer-single'],
'comma-dangle': 'off',
'@typescript-eslint/comma-dangle': ['warn', {
'arrays': 'always-multiline',
'objects': 'always-multiline',
'imports': 'always-multiline',
'exports': 'always-multiline',
'functions': 'only-multiline',
'enums': 'always-multiline',
'generics': 'always-multiline',
'tuples': 'always-multiline',
}],
},
}

View File

@ -8,8 +8,8 @@
"name": "front",
"version": "0.0.0",
"dependencies": {
"@types/leaflet": "^1.9.3",
"bootstrap": "^5.3.0",
"jwt-decode": "^3.1.2",
"leaflet": "^1.9.4",
"react": "^18.2.0",
"react-bootstrap": "^2.8.0",
@ -17,9 +17,13 @@
"react-dom": "^18.2.0",
"react-insta-stories": "^2.6.1",
"react-leaflet": "^4.2.1",
"react-leaflet-custom-control": "^1.3.5",
"react-router-dom": "^6.14.1"
},
"devDependencies": {
"@faker-js/faker": "^8.0.2",
"@types/leaflet": "^1.9.3",
"@types/lodash": "^4.14.196",
"@types/react": "^18.2.14",
"@types/react-dom": "^18.2.6",
"@typescript-eslint/eslint-plugin": "^5.61.0",
@ -28,6 +32,7 @@
"eslint": "^8.44.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.1",
"lodash": "^4.17.21",
"typescript": "^5.0.2",
"vite": "^4.4.0"
}
@ -817,6 +822,22 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/@faker-js/faker": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-8.0.2.tgz",
"integrity": "sha512-Uo3pGspElQW91PCvKSIAXoEgAUlRnH29sX2/p89kg7sP1m2PzCufHINd0FhTXQf6DYGiUlVncdSPa2F9wxed2A==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/fakerjs"
}
],
"engines": {
"node": "^14.17.0 || ^16.13.0 || >=18.0.0",
"npm": ">=6.14.13"
}
},
"node_modules/@humanwhocodes/config-array": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.10.tgz",
@ -1041,7 +1062,8 @@
"node_modules/@types/geojson": {
"version": "7946.0.10",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.10.tgz",
"integrity": "sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA=="
"integrity": "sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA==",
"dev": true
},
"node_modules/@types/json-schema": {
"version": "7.0.12",
@ -1053,10 +1075,17 @@
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.3.tgz",
"integrity": "sha512-Caa1lYOgKVqDkDZVWkto2Z5JtVo09spEaUt2S69LiugbBpoqQu92HYFMGUbYezZbnBkyOxMNPXHSgRrRY5UyIA==",
"dev": true,
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/lodash": {
"version": "4.14.196",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.196.tgz",
"integrity": "sha512-22y3o88f4a94mKljsZcanlNWPzO0uBsBdzLAngf2tp533LzZcQzb6+eZPJ+vCTt+bqF2XnvT9gejTLsAcJAJyQ==",
"dev": true
},
"node_modules/@types/prop-types": {
"version": "15.7.5",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz",
@ -2361,6 +2390,11 @@
"node": ">=6"
}
},
"node_modules/jwt-decode": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz",
"integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A=="
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
@ -2394,6 +2428,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true
},
"node_modules/lodash.debounce": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
@ -2825,6 +2865,16 @@
"react-dom": "^18.0.0"
}
},
"node_modules/react-leaflet-custom-control": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/react-leaflet-custom-control/-/react-leaflet-custom-control-1.3.5.tgz",
"integrity": "sha512-9/v7AxY6CoUbc6fAD/0u8O6wCBopxtdzJukWOR7vLZcyAN5rQCYWXjF5wXJ8klONweZGsRaGPJelfEBRtZAgQA==",
"peerDependencies": {
"leaflet": "^1.7.1",
"react": "^17.0.2 || ^18.0.0",
"react-dom": "^17.0.2 || ^18.0.0"
}
},
"node_modules/react-lifecycles-compat": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",

View File

@ -12,8 +12,8 @@
"addFetchApiRoute": "bash utils/addFetchApiRoute.sh"
},
"dependencies": {
"@types/leaflet": "^1.9.3",
"bootstrap": "^5.3.0",
"jwt-decode": "^3.1.2",
"leaflet": "^1.9.4",
"react": "^18.2.0",
"react-bootstrap": "^2.8.0",
@ -21,9 +21,13 @@
"react-dom": "^18.2.0",
"react-insta-stories": "^2.6.1",
"react-leaflet": "^4.2.1",
"react-leaflet-custom-control": "^1.3.5",
"react-router-dom": "^6.14.1"
},
"devDependencies": {
"@faker-js/faker": "^8.0.2",
"@types/leaflet": "^1.9.3",
"@types/lodash": "^4.14.196",
"@types/react": "^18.2.14",
"@types/react-dom": "^18.2.6",
"@typescript-eslint/eslint-plugin": "^5.61.0",
@ -32,6 +36,7 @@
"eslint": "^8.44.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.1",
"lodash": "^4.17.21",
"typescript": "^5.0.2",
"vite": "^4.4.0"
}

315
front/prototype.html Normal file
View File

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

BIN
front/public/empty.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,16 @@
import { API_URL } from '../../config'
import { Book, BookResponse } from './types'
const composeBookURL = () => (
API_URL + '/book?'
)
const processBook = (data: BookResponse): Book => {
if (!data.Success) {
throw new Error('Не удалось забронировать объявление')
}
return data.Success
}
export { composeBookURL, processBook }

View File

@ -0,0 +1,17 @@
import { isObject } from '../../utils/types'
type BookResponse = {
Success: boolean,
}
const isBookResponse = (obj: unknown): obj is BookResponse => (
isObject(obj, {
'Success': 'boolean',
})
)
type Book = boolean
export type { BookResponse, Book }
export { isBookResponse }

View File

@ -0,0 +1,19 @@
import { API_URL } from '../../config'
import { TrashboxDispose, DisposeResponse } from './types'
const composeDisposeURL = () => (
API_URL + '/announcement/dispose?'
)
const composeDisposeBody = (ann_id: number, trashbox: TrashboxDispose) => (
JSON.stringify({
ann_id,
trashbox,
})
)
const processDispose = (data: DisposeResponse): boolean => {
return data.Success
}
export { composeDisposeURL, composeDisposeBody, processDispose }

View File

@ -0,0 +1,23 @@
import { composeDisposeBody } from '.'
import { isObject } from '../../utils/types'
import { Trashbox } from '../trashbox/types'
type TrashboxDispose = Omit<Trashbox, 'Categories'> & { Category: string }
type DisposeParams = Parameters<typeof composeDisposeBody>
type DisposeAnnParams = DisposeParams extends [ann_id: number, ...args: infer P] ? P : never
type DisposeResponse = {
Success: boolean,
}
const isDisposeResponse = (obj: unknown): obj is DisposeResponse => (
isObject(obj, {
'Success': 'boolean',
})
)
export type { TrashboxDispose, DisposeParams, DisposeAnnParams, DisposeResponse }
export { isDisposeResponse }

View File

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

View File

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

View File

@ -0,0 +1,19 @@
import { API_URL } from '../../config'
import { PoetryResponse, Poetry } from './types'
const initialPoetry: Poetry = {
title: '',
text: '',
author: '',
id: 0,
}
const composePoetryURL = () => (
API_URL + '/user/poem?'
)
const processPoetry = (data: PoetryResponse): Poetry => {
return data
}
export { initialPoetry, composePoetryURL, processPoetry }

View File

@ -0,0 +1,25 @@
import { isObject } from '../../utils/types'
type PoetryResponse = {
title: string,
text: string,
author: string,
id: number,
}
const isPoetryResponse = (obj: unknown): obj is PoetryResponse => (
isObject(obj, {
'title': 'string',
'text': 'string',
'author': 'string',
'id': 'number',
})
)
type Poetry = PoetryResponse
const isPoetry = isPoetryResponse
export type { PoetryResponse, Poetry }
export { isPoetryResponse, isPoetry }

View File

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

View File

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

View File

@ -0,0 +1,16 @@
import { API_URL } from '../../config'
import { RemoveAnnouncement, RemoveAnnouncementResponse } from './types'
const composeRemoveAnnouncementURL = () => (
API_URL + '/announcement?'
)
function processRemoveAnnouncement(data: RemoveAnnouncementResponse): RemoveAnnouncement {
if (!data.Success) {
throw new Error('Не удалось закрыть объявление')
}
return data.Success
}
export { composeRemoveAnnouncementURL, processRemoveAnnouncement }

View File

@ -0,0 +1,17 @@
import { isObject } from '../../utils/types'
type RemoveAnnouncementResponse = {
Success: boolean,
}
const isRemoveAnnouncementResponse = (obj: unknown): obj is RemoveAnnouncementResponse => (
isObject(obj, {
'Success': 'boolean',
})
)
type RemoveAnnouncement = boolean
export type { RemoveAnnouncementResponse, RemoveAnnouncement }
export { isRemoveAnnouncementResponse }

View File

@ -0,0 +1,12 @@
import { API_URL } from '../../config'
import { SendRateResponse, SendRate } from './types'
const composeSendRateURL = () => (
API_URL + '/user/rating?'
)
const processSendRate = (data: SendRateResponse): SendRate => {
return data.Success
}
export { composeSendRateURL, processSendRate }

View File

@ -0,0 +1,17 @@
import { isObject } from '../../utils/types'
type SendRateResponse = {
Success: boolean
}
const isSendRateResponse = (obj: unknown): obj is SendRateResponse => (
isObject(obj, {
'Success': 'boolean',
})
)
type SendRate = boolean
export type { SendRateResponse, SendRate }
export { isSendRateResponse }

View File

@ -0,0 +1,22 @@
import { API_URL } from '../../config'
import { SignUp, SignUpResponse } from './types'
const composeSignUpURL = () => (
API_URL + '/signup?'
)
const composeSignUpBody = (formData: FormData) => {
formData.append('nickname', formData.get('username') ?? '')
return formData
}
const processSignUp = (data: SignUpResponse): SignUp => {
if (!data.Success) {
throw new Error(data.Message)
}
return true
}
export { composeSignUpURL, composeSignUpBody, processSignUp }

View File

@ -0,0 +1,23 @@
import { isConst, isObject } from '../../utils/types'
type SignUpResponse = {
Success: true,
} | {
Success: false,
Message: string,
}
const isSignUpResponse = (obj: unknown): obj is SignUpResponse => (
isObject(obj, {
'Success': isConst(true),
}) || isObject(obj, {
'Success': isConst(false),
'Message': 'string',
})
)
type SignUp = boolean
export type { SignUpResponse, SignUp }
export { isSignUpResponse }

View File

@ -0,0 +1,12 @@
import { API_URL } from '../../config'
import { Token, TokenResponse } from './types'
const composeTokenURL = () => (
API_URL + '/token?'
)
const processToken = (data: TokenResponse): Token => {
return data.access_token
}
export { composeTokenURL, processToken }

View File

@ -0,0 +1,17 @@
import { isObject } from '../../utils/types'
type TokenResponse = {
access_token: string,
}
const isTokenResponse = (obj: unknown): obj is TokenResponse => (
isObject(obj, {
'access_token': 'string',
})
)
type Token = string
export type { TokenResponse, Token }
export { isTokenResponse }

View File

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

View File

@ -1,18 +1,20 @@
import { isArrayOf, isObject, isString } from '../../utils/types'
type Trashbox = {
Name: string,
Lat: number,
Lng: number,
Address: string,
Categories: string[]
Categories: string[],
}
const isTrashbox = (obj: unknown): obj is Trashbox => (
isObject(obj, {
'Name': 'string',
'Lat': 'number',
'Lng': 'number',
'Address': 'string',
'Categories': obj => isArrayOf<string>(obj, isString)
'Categories': obj => isArrayOf(obj, isString),
})
)

View File

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

View File

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

View File

@ -0,0 +1,14 @@
import { API_URL } from '../../config'
import { UserRatingResponse, UserRating } from './types'
const initialUserRating: UserRating = 0
const composeUserRatingURL = (userId: number) => (
API_URL + '/user/rating?' + (new URLSearchParams({ user_id: userId.toString() })).toString()
)
const processUserRating = (data: UserRatingResponse): UserRating => {
return data.rating
}
export { initialUserRating, composeUserRatingURL, processUserRating }

View File

@ -0,0 +1,17 @@
import { isObject } from '../../utils/types'
type UserRatingResponse = {
rating: number
}
const isUserRatingResponse = (obj: unknown): obj is UserRatingResponse => (
isObject(obj, {
'rating': 'number',
})
)
type UserRating = number
export type { UserRatingResponse, UserRating }
export { isUserRatingResponse }

View File

@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" style="fill: rgb(17, 17, 17); --darkreader-inline-fill: #e8e6e3;" data-darkreader-inline-fill=""><path d="M20 2H4c-1.103 0-2 .897-2 2v18l4-4h14c1.103 0 2-.897 2-2V4c0-1.103-.897-2-2-2zm-3 9h-4v4h-2v-4H7V9h4V5h2v4h4v2z"></path></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" style="fill: rgb(17, 17, 17); --darkreader-inline-fill: #e8e6e3;" data-darkreader-inline-fill="">
<path d="M20 2H4c-1.103 0-2 .897-2 2v18l4-4h14c1.103 0 2-.897 2-2V4c0-1.103-.897-2-2-2zm-3 9h-4v4h-2v-4H7V9h4V5h2v4h4v2z" />
</svg>

Before

Width:  |  Height:  |  Size: 315 B

After

Width:  |  Height:  |  Size: 317 B

View File

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

After

Width:  |  Height:  |  Size: 329 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" style="fill: rgb(17, 17, 17); --darkreader-inline-fill: #e8e6e3;" data-darkreader-inline-fill=""><path d="M13 20v-4.586L20.414 8c.375-.375.586-.884.586-1.415V4a1 1 0 0 0-1-1H4a1 1 0 0 0-1 1v2.585c0 .531.211 1.04.586 1.415L11 15.414V22l2-2z"></path></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" style="fill: rgb(17, 17, 17); --darkreader-inline-fill: #e8e6e3;" data-darkreader-inline-fill="">
<path d="M13 20v-4.586L20.414 8c.375-.375.586-.884.586-1.415V4a1 1 0 0 0-1-1H4a1 1 0 0 0-1 1v2.585c0 .531.211 1.04.586 1.415L11 15.414V22l2-2z" />
</svg>

Before

Width:  |  Height:  |  Size: 337 B

After

Width:  |  Height:  |  Size: 338 B

View File

@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" style="fill: rgb(17, 17, 17); --darkreader-inline-fill: #e8e6e3;" data-darkreader-inline-fill=""><path d="M20.5 5A1.5 1.5 0 0 0 19 6.5V11h-1V4.5a1.5 1.5 0 0 0-3 0V11h-1V3.5a1.5 1.5 0 0 0-3 0V11h-1V5.5a1.5 1.5 0 0 0-3 0v10.81l-2.22-3.6a1.5 1.5 0 0 0-2.56 1.58l3.31 5.34A5 5 0 0 0 9.78 22H17a5 5 0 0 0 5-5V6.5A1.5 1.5 0 0 0 20.5 5z"></path></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" style="fill: rgb(17, 17, 17); --darkreader-inline-fill: #e8e6e3;" data-darkreader-inline-fill="">
<path d="M20.5 5A1.5 1.5 0 0 0 19 6.5V11h-1V4.5a1.5 1.5 0 0 0-3 0V11h-1V3.5a1.5 1.5 0 0 0-3 0V11h-1V5.5a1.5 1.5 0 0 0-3 0v10.81l-2.22-3.6a1.5 1.5 0 0 0-2.56 1.58l3.31 5.34A5 5 0 0 0 9.78 22H17a5 5 0 0 0 5-5V6.5A1.5 1.5 0 0 0 20.5 5z" />
</svg>

Before

Width:  |  Height:  |  Size: 427 B

After

Width:  |  Height:  |  Size: 429 B

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="24" height="24" viewBox="0 0 24 24" fill="rgb(185, 179, 170)" xmlns="http://www.w3.org/2000/svg">
<path d="M6.25993 21.3884H6C5.05719 21.3884 4.58579 21.3884 4.29289 21.0955C4 20.8026 4 20.3312 4 19.3884V18.2764C4 17.7579 4 17.4987 4.13318 17.2672C4.26636 17.0356 4.46727 16.9188 4.8691 16.6851C7.51457 15.1464 11.2715 14.2803 13.7791 15.7759C13.9475 15.8764 14.0991 15.9977 14.2285 16.1431C14.7866 16.77 14.746 17.7161 14.1028 18.2775C13.9669 18.396 13.8222 18.486 13.6764 18.5172C13.7962 18.5033 13.911 18.4874 14.0206 18.4699C14.932 18.3245 15.697 17.8375 16.3974 17.3084L18.2046 15.9433C18.8417 15.462 19.7873 15.4619 20.4245 15.943C20.9982 16.3762 21.1736 17.0894 20.8109 17.6707C20.388 18.3487 19.7921 19.216 19.2199 19.7459C18.6469 20.2766 17.7939 20.7504 17.0975 21.0865C16.326 21.4589 15.4738 21.6734 14.6069 21.8138C12.8488 22.0983 11.0166 22.0549 9.27633 21.6964C8.29253 21.4937 7.27079 21.3884 6.25993 21.3884Z"/>
<path d="M10.8613 3.36335C11.3679 2.45445 11.6213 2 12 2C12.3787 2 12.6321 2.45445 13.1387 3.36335L13.2698 3.59849C13.4138 3.85677 13.4858 3.98591 13.598 4.07112C13.7103 4.15633 13.8501 4.18796 14.1296 4.25122L14.3842 4.30881C15.3681 4.53142 15.86 4.64273 15.977 5.01909C16.0941 5.39546 15.7587 5.78763 15.088 6.57197L14.9144 6.77489C14.7238 6.99777 14.6285 7.10922 14.5857 7.24709C14.5428 7.38496 14.5572 7.53365 14.586 7.83102L14.6122 8.10176C14.7136 9.14824 14.7644 9.67148 14.4579 9.90409C14.1515 10.1367 13.6909 9.92462 12.7697 9.50047L12.5314 9.39074C12.2696 9.27021 12.1387 9.20994 12 9.20994C11.8613 9.20994 11.7304 9.27021 11.4686 9.39074L11.2303 9.50047C10.3091 9.92462 9.84847 10.1367 9.54206 9.90409C9.23565 9.67148 9.28635 9.14824 9.38776 8.10176L9.41399 7.83102C9.44281 7.53364 9.45722 7.38496 9.41435 7.24709C9.37147 7.10922 9.27617 6.99777 9.08557 6.77489L8.91204 6.57197C8.2413 5.78763 7.90593 5.39546 8.02297 5.01909C8.14001 4.64273 8.63194 4.53142 9.61581 4.30881L9.87035 4.25122C10.1499 4.18796 10.2897 4.15633 10.402 4.07112C10.5142 3.98591 10.5862 3.85677 10.7302 3.59849L10.8613 3.36335Z"/>
<path d="M19.4306 7.68167C19.684 7.22722 19.8106 7 20 7C20.1894 7 20.316 7.22723 20.5694 7.68167L20.6349 7.79925C20.7069 7.92839 20.7429 7.99296 20.799 8.03556C20.8551 8.07817 20.925 8.09398 21.0648 8.12561L21.1921 8.15441C21.684 8.26571 21.93 8.32136 21.9885 8.50955C22.047 8.69773 21.8794 8.89381 21.544 9.28598L21.4572 9.38744C21.3619 9.49889 21.3143 9.55461 21.2928 9.62354C21.2714 9.69248 21.2786 9.76682 21.293 9.91551L21.3061 10.0509C21.3568 10.5741 21.3822 10.8357 21.229 10.952C21.0758 11.0683 20.8455 10.9623 20.3849 10.7502L20.2657 10.6954C20.1348 10.6351 20.0694 10.605 20 10.605C19.9306 10.605 19.8652 10.6351 19.7343 10.6954L19.6151 10.7502C19.1545 10.9623 18.9242 11.0683 18.771 10.952C18.6178 10.8357 18.6432 10.5741 18.6939 10.0509L18.707 9.91551C18.7214 9.76682 18.7286 9.69248 18.7072 9.62354C18.6857 9.55461 18.6381 9.49889 18.5428 9.38744L18.456 9.28598C18.1207 8.89381 17.953 8.69773 18.0115 8.50955C18.07 8.32136 18.316 8.26571 18.8079 8.15441L18.9352 8.12561C19.075 8.09398 19.1449 8.07817 19.201 8.03556C19.2571 7.99296 19.2931 7.92839 19.3651 7.79925L19.4306 7.68167Z"/>
<path d="M3.43063 7.68167C3.68396 7.22722 3.81063 7 4 7C4.18937 7 4.31604 7.22723 4.56937 7.68167L4.63491 7.79925C4.7069 7.92839 4.74289 7.99296 4.79901 8.03556C4.85513 8.07817 4.92503 8.09398 5.06482 8.12561L5.19209 8.15441C5.68403 8.26571 5.93 8.32136 5.98852 8.50955C6.04704 8.69773 5.87935 8.89381 5.54398 9.28598L5.45722 9.38744C5.36191 9.49889 5.31426 9.55461 5.29283 9.62354C5.27139 9.69248 5.27859 9.76682 5.293 9.91551L5.30612 10.0509C5.35682 10.5741 5.38218 10.8357 5.22897 10.952C5.07576 11.0683 4.84547 10.9623 4.38487 10.7502L4.2657 10.6954C4.13481 10.6351 4.06937 10.605 4 10.605C3.93063 10.605 3.86519 10.6351 3.7343 10.6954L3.61513 10.7502C3.15454 10.9623 2.92424 11.0683 2.77103 10.952C2.61782 10.8357 2.64318 10.5741 2.69388 10.0509L2.707 9.91551C2.72141 9.76682 2.72861 9.69248 2.70717 9.62354C2.68574 9.55461 2.63809 9.49889 2.54278 9.38744L2.45602 9.28598C2.12065 8.89381 1.95296 8.69773 2.01148 8.50955C2.07 8.32136 2.31597 8.26571 2.80791 8.15441L2.93518 8.12561C3.07497 8.09398 3.14487 8.07817 3.20099 8.03556C3.25711 7.99296 3.29311 7.92839 3.36509 7.79925L3.43063 7.68167Z"/>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg"><path d="M 15 3 L 15 5.0625 C 9.734375 5.539063 5.539063 9.734375 5.0625 15 L 3 15 L 3 17 L 5.0625 17 C 5.539063 22.265625 9.734375 26.460938 15 26.9375 L 15 29 L 17 29 L 17 26.9375 C 22.265625 26.460938 26.460938 22.265625 26.9375 17 L 29 17 L 29 15 L 26.9375 15 C 26.460938 9.734375 22.265625 5.539063 17 5.0625 L 17 3 Z M 15 7.03125 L 15 9 L 17 9 L 17 7.03125 C 21.191406 7.484375 24.515625 10.808594 24.96875 15 L 23 15 L 23 17 L 24.96875 17 C 24.515625 21.191406 21.191406 24.515625 17 24.96875 L 17 23 L 15 23 L 15 24.96875 C 10.808594 24.515625 7.484375 21.191406 7.03125 17 L 9 17 L 9 15 L 7.03125 15 C 7.484375 10.808594 10.808594 7.484375 15 7.03125 Z"/></svg>

After

Width:  |  Height:  |  Size: 893 B

View File

@ -21,7 +21,7 @@ const stations: Record<Lines, Set<string>> = {
'Кировский завод',
'Автово',
'Ленинский проспект',
'Проспект Ветеранов'
'Проспект Ветеранов',
]),
blue: new Set([
'Парнас',
@ -41,7 +41,7 @@ const stations: Record<Lines, Set<string>> = {
'Парк Победы',
'Московская',
'Звёздная',
'Купчино'
'Купчино',
]),
green: new Set([
'Приморская',
@ -54,7 +54,7 @@ const stations: Record<Lines, Set<string>> = {
'Ломоносовская',
'Пролетарская',
'Обухово',
'Рыбацкое'
'Рыбацкое',
]),
orange: new Set([
'Спасская',
@ -64,7 +64,7 @@ const stations: Record<Lines, Set<string>> = {
'Новочеркасская',
'Ладожская',
'Проспект Большевиков',
'Улица Дыбенко'
'Улица Дыбенко',
]),
violet: new Set([
'Комендантский проспект',
@ -81,7 +81,7 @@ const stations: Record<Lines, Set<string>> = {
'Международная',
'Проспект славы',
'Дунайскай',
'Шушары'
'Шушары',
]),
}
@ -105,5 +105,7 @@ const lineByName = (name: string) => (
lines.find(line => stations[line].has(name))
)
const DEFAULT_LINE = 'Петроградская'
export type { Lines }
export { lines, stations, colors, lineNames, lineByName }
export { lines, stations, colors, lineNames, lineByName, DEFAULT_LINE }

View File

@ -10,4 +10,4 @@
<animate attributeName="stroke-opacity" begin="-0.9s" dur="1.8s" values="1; 0" calcMode="spline" keyTimes="0; 1" keySplines="0.3, 0.61, 0.355, 1" repeatCount="indefinite"/>
</circle>
</g>
</svg>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

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

After

Width:  |  Height:  |  Size: 569 B

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 20H6C4.89543 20 4 19.1046 4 18L4 6C4 4.89543 4.89543 4 6 4H14M10 12H21M21 12L18 15M21 12L18 9" stroke="rgb(185, 179, 170)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>

After

Width:  |  Height:  |  Size: 433 B

View File

@ -0,0 +1,73 @@
import { iconDormitory, iconITMO, iconLETI } from '../utils/markerIcons'
type LocationType = 'dormitory' | 'leti' | 'itmo'
const studentLocations: {
name: string,
position: [number, number],
type: LocationType
}[] = [
{
name: 'Первое, второе, третье общежития',
position: [59.987299, 30.330672],
type: 'dormitory',
},
{
name: 'Четвертое общежитие',
position: [59.985620, 30.331319],
type: 'dormitory',
},
{
name: 'Шестое общежитие',
position: [59.969713, 30.299851],
type: 'dormitory',
},
{
name: 'Седьмое общежитие',
position: [60.003723, 30.287616],
type: 'dormitory',
},
{
name: 'Восьмое общежитие',
position: [59.991115, 30.318752],
type: 'dormitory',
},
{
name: 'Общежития Межвузовского студенческого городка',
position: [59.871053, 30.307154],
type: 'dormitory',
},
{
name: 'Одиннадцатое общежитие',
position: [59.877962, 30.242889],
type: 'dormitory',
},
{
name: 'Общежитие Академии транспортных технологий',
position: [59.870375, 30.308646],
type: 'dormitory',
},
{
name: 'ЛЭТИ шестой корпус',
position: [59.971578, 30.296653],
type: 'leti',
},
{
name: 'ЛЭТИ Первый и другие корпуса',
position: [59.971947, 30.324303],
type: 'leti',
},
{
name: 'ИТМО',
position: [59.956363, 30.310029],
type: 'itmo',
},
]
const locationsIcons: Record<LocationType, L.Icon> = {
dormitory: iconDormitory,
itmo: iconITMO,
leti: iconLETI,
}
export { studentLocations, locationsIcons }

View File

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

View File

@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" style="fill: rgb(17, 17, 17); --darkreader-inline-fill: #e8e6e3;" data-darkreader-inline-fill=""><path d="M7.5 6.5C7.5 8.981 9.519 11 12 11s4.5-2.019 4.5-4.5S14.481 2 12 2 7.5 4.019 7.5 6.5zM20 21h1v-1c0-3.859-3.141-7-7-7h-4c-3.86 0-7 3.141-7 7v1h17z"></path></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" style="fill: rgb(17, 17, 17); --darkreader-inline-fill: #e8e6e3;" data-darkreader-inline-fill="">
<path d="M7.5 6.5C7.5 8.981 9.519 11 12 11s4.5-2.019 4.5-4.5S14.481 2 12 2 7.5 4.019 7.5 6.5zM20 21h1v-1c0-3.859-3.141-7-7-7h-4c-3.86 0-7 3.141-7 7v1h17z" />
</svg>

Before

Width:  |  Height:  |  Size: 348 B

After

Width:  |  Height:  |  Size: 350 B

View File

@ -1,17 +1,17 @@
import { Modal, Button } from 'react-bootstrap'
import { MapContainer, Marker, Popup, TileLayer } from 'react-leaflet'
import { CSSProperties, useState } from 'react'
import { LatLng } from 'leaflet'
import LineDot from './LineDot'
import { categoryNames } from '../assets/category'
import { useBook } from '../hooks/api'
import { useBook, useRemoveAnnouncement } from '../hooks/api'
import { Announcement } from '../api/announcement/types'
import { iconItem } from '../utils/markerIcons'
import { CSSProperties } from 'react'
type AnnouncementDetailsProps = {
close: () => void,
announcement: Announcement
}
import { useId } from '../hooks'
import SelectDisposalTrashbox from './SelectDisposalTrashbox'
import StarRating from './StarRating'
import StudentLocations from './StudentLocations'
const styles = {
container: {
@ -19,17 +19,111 @@ const styles = {
alignItems: 'center',
justifyContent: 'center',
} as CSSProperties,
map: {
width: '100%',
minHeight: 300,
} as CSSProperties,
}
function AnnouncementDetails({ close, announcement: { id, name, category, bestBy, description, lat, lng, address, metro } }: AnnouncementDetailsProps) {
const { handleBook, status: bookStatus } = useBook(id)
type ViewProps = {
myId: number,
announcement: Announcement,
}
const View = ({
myId,
announcement: { name, category, bestBy, description, lat, lng, address, metro, userId },
}: ViewProps) => (
<>
<h1>{name}</h1>
<span>{categoryNames[category]}</span>
<span className='m-2'>&#x2022;</span>{/* dot */}
<span>Годен до {bestBy}</span>
<p className='mb-0'>{description}</p>
<p className='mb-3'>
Рейтинг пользователя: <StarRating dynamic={myId !== userId} userId={userId} />
</p>
<MapContainer style={styles.map} center={[lat, lng]} zoom={16} >
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url='https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'
/>
<StudentLocations />
<Marker icon={iconItem} position={[lat, lng]}>
<Popup>
{address}
<br />
<LineDot station={metro} /> {metro}
</Popup>
</Marker>
</MapContainer>
</>
)
type ControlProps = {
myId: number,
closeRefresh: () => void,
announcement: Announcement,
showDispose: () => void
}
function Control({
myId,
closeRefresh,
announcement: { bookedBy, id, userId },
showDispose,
}: ControlProps) {
const { handleBook, bookButton } = useBook()
const { handleRemove, removeButton } = useRemoveAnnouncement(closeRefresh)
return (
<>
<p>Забронировали {bookedBy + (bookButton.disabled ? 1 : 0)} чел.</p>
{(myId === userId) ? (
<div className='m-0'>
<Button className='m-1' variant='success' onClick={showDispose}>Утилизировать</Button>
<Button className='m-1' variant='success' onClick={() => void handleRemove(id)} {...removeButton} />
</div>
) : (
<Button variant='success' onClick={() => void handleBook(id)} {...bookButton} />
)}
</>
)
}
type AnnouncementDetailsProps = {
close: () => void,
refresh: () => void,
announcement: Announcement,
}
function AnnouncementDetails({
close,
refresh,
announcement,
}: AnnouncementDetailsProps) {
const closeRefresh = () => {
close()
refresh()
}
const [disposeShow, setDisposeShow] = useState(false)
const myId = useId()
return (
<div
className='modal'
style={styles.container}
>
<Modal.Dialog style={{ minWidth: '50vw' }}>
<Modal.Dialog centered className='modal-dialog'>
<Modal.Header closeButton onHide={close}>
<Modal.Title>
Подробнее
@ -37,36 +131,31 @@ function AnnouncementDetails({ close, announcement: { id, name, category, bestBy
</Modal.Header>
<Modal.Body>
<h1>{name}</h1>
<span>{categoryNames[category]}</span>
<span className='m-2'>&#x2022;</span>{/* dot */}
<span>Годен до {new Date(bestBy).toLocaleString('ru-RU')}</span>
<p className='mb-3'>{description}</p>
<MapContainer style={{ width: '100%', minHeight: 300 }} center={[lat, lng]} zoom={16} >
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url='https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'
/>
<Marker icon={iconItem} position={[lat, lng]}>
<Popup>
{address}
<br />
<LineDot station={metro} /> {metro}
</Popup>
</Marker>
</MapContainer>
<View myId={myId} announcement={announcement} />
</Modal.Body>
<Modal.Footer>
<Button variant='success' onClick={() => void handleBook()}>
{bookStatus || 'Забронировать'}
</Button>
<Control
myId={myId}
closeRefresh={closeRefresh}
showDispose={() => setDisposeShow(true)}
announcement={announcement}
/>
</Modal.Footer>
</Modal.Dialog>
<Modal centered show={disposeShow} onHide={() => setDisposeShow(false)} style={{ zIndex: 100000 }}>
<Modal.Header closeButton>
<Modal.Title>
Утилизация
</Modal.Title>
</Modal.Header>
<SelectDisposalTrashbox
annId={announcement.id}
category={announcement.category}
address={new LatLng(announcement.lat, announcement.lng)}
closeRefresh={closeRefresh}
/>
</Modal>
</div>
)
}

View File

@ -1,54 +1,76 @@
import { FormEventHandler } from 'react'
import { Button, Form } from 'react-bootstrap'
import { FormEventHandler, useCallback } from 'react'
import { Button, ButtonGroup, Form } from 'react-bootstrap'
import { useSignIn, useSignUp } from '../hooks/api'
import { composeSignUpBody } from '../api/signup'
type AuthFormProps = {
register: boolean
handleAuth: FormEventHandler<HTMLFormElement>,
loading: boolean,
error: string
goBack: () => void,
}
function AuthForm ({ handleAuth, register, loading, error }: AuthFormProps) {
const buttonText = loading ? 'Загрузка...' : (error || (register ? 'Зарегистрироваться' : 'Войти'))
const AuthForm = ({ goBack }: AuthFormProps) => {
const { handleSignUp, signUpButton } = useSignUp()
const { handleSignIn, signInButton } = useSignIn()
const handleAuth: FormEventHandler<HTMLFormElement> = useCallback((e) => {
e.preventDefault()
e.stopPropagation()
const formData = new FormData(e.currentTarget)
const register = (e.nativeEvent as SubmitEvent).submitter?.id === 'register'
void (async () => {
const accountCreated = register ? (
await handleSignUp(composeSignUpBody(formData))
) : true
if (accountCreated) {
if (await handleSignIn(formData)) {
goBack()
}
}
})()
}, [goBack, handleSignUp, handleSignIn])
return (
<Form onSubmit={handleAuth}>
<Form.Group className='mb-3' controlId='email'>
<Form.Label>Почта</Form.Label>
<Form.Control type='email' required />
<Form.Group className='mb-3' controlId='username'>
<Form.Label>Как меня называть</Form.Label>
<Form.Control placeholder='Имя или псевдоним' name='username' type='text' required />
</Form.Group>
{register && <>
<Form.Group className='mb-3' controlId='name'>
<Form.Label>Имя</Form.Label>
<Form.Control type='text' required />
</Form.Group>
<Form.Group className='mb-3' controlId='surname'>
<Form.Label>Фамилия</Form.Label>
<Form.Control type='text' required />
</Form.Group>
</>}
<Form.Group className='mb-3' controlId='password'>
<Form.Label>Пароль</Form.Label>
<Form.Control type='password' required />
<Form.Label>И я могу доказать, что это я</Form.Label>
<Form.Control placeholder='Пароль' name='password' type='password' required />
</Form.Group>
{register &&
<Form.Group className='mb-3' controlId='privacyPolicyConsent'>
<Form.Check>
<Form.Check.Input type='checkbox' required />
<Form.Check.Label>
Я согласен с <a href={`${document.location.origin}/privacy_policy.pdf`} target='_blank' rel='noopener noreferrer'>условиями обработки персональных данных</a>
</Form.Check.Label>
</Form.Check>
</Form.Group>
}
<p>
Нажимая на кнопку, я даю своё согласие на обработку персональных данных и соглашаюсь с{' '}
<a
href={`${document.location.origin}/privacy_policy.pdf`}
target='_blank'
>условиями политики конфиденциальности</a>
</p>
<Button variant='success' type='submit'>
{buttonText}
</Button>
<ButtonGroup className='d-flex'>
<Button
className='w-100'
id='register'
variant='success'
type='submit'
{...signUpButton}
/>
<Button
className='w-100'
id='login'
variant='success'
type='submit'
{...signInButton}
/>
</ButtonGroup>
</Form>
)
}

View File

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

View File

@ -1,9 +1,9 @@
import { Link } from 'react-router-dom'
import { CSSProperties } from 'react'
import addIcon from '../assets/addIcon.svg'
import filterIcon from '../assets/filterIcon.svg'
import userIcon from '../assets/userIcon.svg'
import { CSSProperties } from 'react'
const styles = {
navBar: {
@ -15,22 +15,20 @@ const styles = {
display: 'flex',
flexDirection: 'row',
height: '100%',
margin: 'auto'
margin: 'auto',
} as CSSProperties,
navBarElement: {
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
justifyContent: 'center',
} as CSSProperties,
}
type BottomNavBarProps = {
width: number,
toggleFilters: (p: boolean) => void
toggleFilters: (state: boolean) => void,
}
function BottomNavBar({ width, toggleFilters }: BottomNavBarProps) {
@ -38,7 +36,7 @@ function BottomNavBar({ width, toggleFilters }: BottomNavBarProps) {
<div style={styles.navBar}>
<div style={{ ...styles.navBarGroup, width: width }}>
<a style={styles.navBarElement} onClick={() => toggleFilters(true)}>
<a href='#' style={styles.navBarElement} onClick={() => toggleFilters(true)}>
<img src={filterIcon} alt='Фильтровать объявления' title='Фильтровать объявления' />
</a>
@ -46,7 +44,7 @@ function BottomNavBar({ width, toggleFilters }: BottomNavBarProps) {
<img src={addIcon} alt='Опубликовать объявление' title='Опубликовать объявление' />
</Link>
<Link style={styles.navBarElement} to={'/user'} >
<Link style={styles.navBarElement} to='/user' >
<img src={userIcon} alt='Личный кабинет' title='Личный кабинет' />
</Link>

View File

@ -0,0 +1,23 @@
import { PropsWithChildren } from 'react'
import { Card } from 'react-bootstrap'
import BackHeader from './BackHeader'
type CardLayoutProps = {
text: string,
}
const CardLayout = ({ text, children }: PropsWithChildren<CardLayoutProps>) => (
<>
<div className='mx-4 px-3'>
<BackHeader text={text} />
</div>
<Card className='m-4 mt-0'>
<Card.Body>
{children}
</Card.Body>
</Card>
</>
)
export default CardLayout

View File

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

View File

@ -10,21 +10,21 @@ type FiltersProps = {
filter: FiltersType,
setFilter: SetState<FiltersType>,
filterShown: boolean,
setFilterShown: SetState<boolean>
setFilterShown: SetState<boolean>,
}
function Filters({ filter, setFilter, filterShown, setFilterShown }: FiltersProps) {
const handleSubmit: FormEventHandler<HTMLFormElement> = (event) => {
event.preventDefault();
event.stopPropagation();
event.preventDefault()
event.stopPropagation()
const formData = new FormData(event.currentTarget)
setFilter(prev => ({
...prev,
category: (formData.get('category') as (FiltersType['category'] | null)) || undefined,
metro: (formData.get('metro') as (FiltersType['metro'] | null)) || undefined
metro: (formData.get('metro') as (FiltersType['metro'] | null)) || undefined,
}))
setFilterShown(false)
@ -77,7 +77,7 @@ function Filters({ filter, setFilter, filterShown, setFilterShown }: FiltersProp
</Form.Group>
<Button variant='success' type='submit'>
Отправить
Выбрать
</Button>
</Form>
</Modal.Body>

View File

@ -3,8 +3,9 @@ import { colors, lineNames, lineByName } from '../assets/metro'
function LineDot({ station }: { station: string }) {
const line = lineByName(station)
if (line == undefined)
if (line === undefined) {
return <></>
}
const lineTitle = lineNames[line]
const color = colors[line]

View File

@ -0,0 +1,38 @@
import { MouseEventHandler } from 'react'
import { useMapEvent } from 'react-leaflet'
import { LatLng } from 'leaflet'
import Control from 'react-leaflet-custom-control'
import locateIcon from '../assets/locate.svg'
import styles from '../styles/Map.module.css'
import { SetState } from '../utils/types'
type LocaleButtonProps = {
setPosition: SetState<LatLng>
}
function LocateButton({ setPosition }: LocaleButtonProps) {
const map = useMapEvent('locationfound', (e) => {
setPosition(e.latlng)
map.flyTo(e.latlng)
})
const handleLocale: MouseEventHandler<HTMLAnchorElement> = (e) => {
e.preventDefault()
e.stopPropagation()
map.locate()
}
return (
<Control position='topleft'>
<div className='leaflet-bar'>
<a href='#' role='button' onClick={handleLocale}>
<img className={styles.localeIcon} src={locateIcon} alt='locate' />
</a>
</div>
</Control>
)
}
export default LocateButton

View File

@ -7,11 +7,10 @@ import { iconItem } from '../utils/markerIcons'
type LocationMarkerProps = {
address: string,
position: LatLng,
setPosition: SetState<LatLng>
setPosition: SetState<LatLng>,
}
function LocationMarker({ address, position, setPosition }: LocationMarkerProps) {
const map = useMapEvents({
dragend: () => {
setPosition(map.getCenter())
@ -21,13 +20,14 @@ function LocationMarker({ address, position, setPosition }: LocationMarkerProps)
},
resize: () => {
setPosition(map.getCenter())
}
},
})
return (
<Marker icon={iconItem} position={position}>
<Marker icon={iconItem} position={position} zIndexOffset={1000}>
<Popup>
{address}
<br />
{position.lat.toFixed(4)}, {position.lng.toFixed(4)}
</Popup>
</Marker>

View File

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

View File

@ -0,0 +1,41 @@
import { usePoetry } from '../hooks/api'
import { gotError, gotResponse } from '../hooks/useFetch'
import styles from '../styles/Poetry.module.css'
function Poetry() {
const poetry = usePoetry()
return (
<section>
<h4 className='fw-bold'>Поэзия</h4> {
gotResponse(poetry) ? (
gotError(poetry) ? (
<div className='text-danger'>
<h5>Ошибка получения стиха</h5>
<p>{poetry.error}</p>
</div>
) : (
<>
<h5>{poetry.data.title}</h5>
<p
className={styles.text}
dangerouslySetInnerHTML={{
__html:
poetry.data.text.trim().replace(/(\n){3,}/g, '\n\n'),
}}
/>
<p><em>{poetry.data.author}</em></p>
<img className={styles.image} src={`/poem_pic/${poetry.data.id}.jpg`} alt='Иллюстрация' />
</>
)
) : (
<h5>Загрузка...</h5>
)
}
</section>
)
}
export default Poetry

View File

@ -0,0 +1,35 @@
import { CSSProperties } from 'react'
import handStarsIcon from '../assets/handStars.svg'
type PointsProps = {
points: number | string,
}
const styles = {
points: {
float: 'right',
} as CSSProperties,
icon: {
height: 24,
paddingBottom: 5,
marginRight: 5,
} as CSSProperties,
}
function Points({ points }: PointsProps) {
return (
<h5>
Набрано очков:
<span style={styles.points}>
<img
style={styles.icon}
src={handStarsIcon}
alt='Иконка руки, дающей звёзды' />
{points}
</span>
</h5>
)
}
export default Points

View File

@ -0,0 +1,120 @@
import { Button, Modal } from 'react-bootstrap'
import { MapContainer, TileLayer } from 'react-leaflet'
import { CSSProperties, useState } from 'react'
import { LatLng } from 'leaflet'
import { useDispose, useTrashboxes } from '../hooks/api'
import { UseFetchReturn, gotError, gotResponse } from '../hooks/useFetch'
import TrashboxMarkers from './TrashboxMarkers'
import { Category } from '../assets/category'
import { Trashbox } from '../api/trashbox/types'
type SelectDisposalTrashboxProps = {
annId: number,
category: Category,
address: LatLng,
closeRefresh: () => void,
}
type SelectedTrashbox = {
index: number,
category: string,
}
const styles = {
map: {
width: '100%',
height: 400,
} as CSSProperties,
}
function SelectDisposalTrashbox({ annId, category, address, closeRefresh }: SelectDisposalTrashboxProps) {
const trashboxes = useTrashboxes(address, category)
const [selectedTrashbox, setSelectedTrashbox] = useState<SelectedTrashbox>({ index: -1, category: '' })
const { handleDispose, disposeButton } = useDispose(closeRefresh)
return (
<>
<Modal.Body>
<div className='mb-3'>
{gotResponse(trashboxes)
? (
gotError(trashboxes) ? (
<p
style={styles.map}
className='text-danger'
>{trashboxes.error}</p>
) : (
<MapContainer
scrollWheelZoom={false}
style={styles.map}
center={address}
zoom={13}
className=''
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url='https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'
/>
<TrashboxMarkers
trashboxes={trashboxes.data}
selectTrashbox={setSelectedTrashbox}
/>
</MapContainer>
)
) : (
<div style={styles.map}>
<p>Загрузка...</p>
</div>
)
}
</div>
<DisplaySelected trashboxes={trashboxes} selectedTrashbox={selectedTrashbox} />
</Modal.Body>
<Modal.Footer>
<Button
{...disposeButton}
disabled={disposeButton.disabled || gotError(trashboxes) || !gotResponse(trashboxes) || selectedTrashbox.index < 0}
variant='success'
onClick={() => {
if (gotResponse(trashboxes) && !gotError(trashboxes)) {
const { Lat, Lng, Name, Address } = trashboxes.data[selectedTrashbox.index]
void handleDispose(annId, {
Category: selectedTrashbox.category,
Lat,
Lng,
Name,
Address,
})
}
}}
/>
</Modal.Footer>
</>
)
}
type DisplaySelectedProps = {
trashboxes: UseFetchReturn<Trashbox[]>,
selectedTrashbox: SelectedTrashbox,
}
function DisplaySelected({ trashboxes, selectedTrashbox }: DisplaySelectedProps) {
if (gotResponse(trashboxes) && !gotError(trashboxes) && selectedTrashbox.index > -1) {
return (
<>
<p className='mb-0'>Выбран пункт сбора мусора на {trashboxes.data[selectedTrashbox.index].Address}</p>
<p className='mb-0'>с категорией "{selectedTrashbox.category}"</p>
</>
)
}
return (
<p className='mb-0'>Выберите пункт сбора мусора и категорию</p>
)
}
export default SelectDisposalTrashbox

View File

@ -0,0 +1,22 @@
import { Navbar } from 'react-bootstrap'
import { Link } from 'react-router-dom'
import { CSSProperties } from 'react'
import { clearToken } from '../utils/auth'
import signOutIcon from '../assets/signOut.svg'
const styles = {
rightIcon: {
marginLeft: '1rem',
marginRight: 0,
} as CSSProperties,
}
const SignOut = () => (
<Navbar.Brand style={styles.rightIcon} as={Link} to='/'>
<img onClick={clearToken} src={signOutIcon} alt='Выйти' />
</Navbar.Brand>
)
export default SignOut

View File

@ -0,0 +1,93 @@
import { useState } from 'react'
import { useSendRate, useUserRating } from '../hooks/api'
import { gotError, gotResponse } from '../hooks/useFetch'
import styles from '../styles/StarRating.module.css'
type StarProps = {
filled: boolean,
selected: boolean,
selectRate?: () => void,
sendMyRate?: () => void,
disabled: boolean
}
function Star({ filled, selected, selectRate, disabled }: StarProps) {
return (
<button
className={`${styles.star} ${filled ? styles.starFilled : ''} ${selected ? styles.starSelected : ''}`}
onMouseEnter={selectRate}
onFocus={selectRate}
disabled={disabled}
>&#9733;</button> // star
)
}
type StarRatingProps = {
userId: number,
dynamic?: boolean,
}
function StarRating({ userId, dynamic = false }: StarRatingProps) {
const rating = useUserRating(userId)
const [selectedRate, setSelectedRate] = useState(0)
const [myRate, setMyRate] = useState(0)
const rated = myRate > 0
const { doSendRate } = useSendRate()
async function sendMyRate() {
const res = await doSendRate(selectedRate, userId)
if (res) {
rating.refetch()
setMyRate(selectedRate)
}
}
if (!gotResponse(rating)) {
return (
<span>Загрузка...</span>
)
}
if (gotError(rating)) {
return (
<span className='text-danger'>{rating.error}</span>
)
}
return (
<span
className={styles.starContainer}
onClick={() => dynamic && !rated && void sendMyRate()}
onMouseEnter={() => rated && setSelectedRate(myRate)}
onMouseLeave={() => setSelectedRate(0)}
onFocus={() => rated && setSelectedRate(myRate)}
onBlur={() => setSelectedRate(0)}
onTouchStart={() => rated && setSelectedRate(myRate)}
onTouchEnd={() => setSelectedRate(0)}
title={`Пользователи: ${Math.round(rating.data)}\nВы: ${myRate}`}
tabIndex={0}
>
{...Array(5).fill(5).map((_, i) => (
<Star
key={i}
filled={i < Math.round(rating.data)}
selected={i < selectedRate}
selectRate={() => dynamic && !rated && setSelectedRate(i + 1)}
disabled={!dynamic || rated}
/>
))}
</span >
)
}
export default StarRating

View File

@ -0,0 +1,115 @@
import { Link } from 'react-router-dom'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { UserCategory, composeUserCategoriesFilters, userCategoriesInfos } from '../assets/userCategories'
import { Announcement } from '../api/announcement/types'
import { categoryGraphics, categoryNames } from '../assets/category'
import { URLEncodeFilters } from '../utils/filters'
import rightAngleIcon from '../assets/rightAngle.svg'
import styles from '../styles/StoriesPreview.module.css'
type StoriesPreviewProps = {
announcements: Announcement[],
category: UserCategory,
}
const StoriesPreview = ({ announcements, category }: StoriesPreviewProps) => (
announcements.length > 0 ? (
announcements.map((ann, i) => (
<li key={`${category}${i}`}>
<Link to={'/?' + new URLSearchParams({
...URLEncodeFilters(composeUserCategoriesFilters[category]()),
storyIndex: i.toString(),
}).toString()} className={styles.link}>
{ann.src?.endsWith('mp4') ? (
<video src={ann.src} className={styles.image} />
) : (
<img
src={ann.src || categoryGraphics[ann.category]}
alt={'Изображение' + (ann.src ? 'предмета' : categoryNames[ann.category])}
className={styles.image}
/>
)}
<p className={styles.title}>{ann.name}</p>
<p className={styles.title}>{userCategoriesInfos[category](ann)}</p>
</Link>
</li>
))
) : (
<li>
<Link to={'/?' + new URLSearchParams({
...URLEncodeFilters(composeUserCategoriesFilters[category]()),
storyIndex: '0',
}).toString()} className={styles.link}>
<img
src='/static/empty.png'
alt='Здесь ничего нет'
className={styles.image}
/>
</Link>
</li>
)
)
function StoriesPreviewCarousel({ announcements, category }: StoriesPreviewProps) {
const ulElement = useRef<HTMLUListElement | null>(null)
const [showScrollButtons, setShowScrollButtons] = useState({ left: false, right: false })
const determineShowScrollButtons = (ul: HTMLUListElement) => (
setShowScrollButtons({
left: ul.scrollLeft > 0,
right: ul.scrollLeft < (ul.scrollWidth - ul.clientWidth),
})
)
useEffect(() => {
const ul = ulElement.current
if (ul) {
determineShowScrollButtons(ul)
const f = () => determineShowScrollButtons(ul)
ul.addEventListener('scroll', f)
return () => ul.removeEventListener('scroll', f)
}
}, [])
useLayoutEffect(() => {
const ul = ulElement.current
if (ul) {
determineShowScrollButtons(ul)
}
}, [announcements])
const doScroll = (forward: boolean) => () => {
const ul = ulElement.current
if (ul) {
const storyWidth = window.innerHeight * 0.25 * 9 / 16 + 10
ul.scrollLeft += forward ? storyWidth : -storyWidth
}
}
return <div className={styles.container}>
{showScrollButtons.left &&
<button onClick={doScroll(false)} className={`${styles.scrollButton} ${styles.leftScrollButton}`}>
<img src={rightAngleIcon} alt='Показать ещё' />
</button>
}
<ul className={styles.list} ref={ulElement}>
<StoriesPreview announcements={announcements} category={category} />
</ul>
{showScrollButtons.right &&
<button onClick={doScroll(true)} className={`${styles.scrollButton} ${styles.rightScrollButton}`}>
<img src={rightAngleIcon} alt='Показать ещё' />
</button>
}
</div>
}
export default StoriesPreviewCarousel

View File

@ -0,0 +1,36 @@
import { Marker, Tooltip, useMap } from 'react-leaflet'
import { LatLng } from 'leaflet'
import { locationsIcons, studentLocations } from '../assets/studentLocations'
type StudentLocationsProps = {
setPosition?: (pos: LatLng) => void
}
function StudentLocations({ setPosition }: StudentLocationsProps) {
const map = useMap()
return (
<>{
studentLocations.map((el) =>
<Marker
icon={locationsIcons[el.type]}
position={el.position}
eventHandlers={{
click: setPosition && (() => {
setPosition(new LatLng(...el.position))
map.setView(el.position)
}),
}}
>
<Tooltip>
{el.name}
</Tooltip>
</Marker>
)
}
</>
)
}
export default StudentLocations

View File

@ -5,30 +5,38 @@ import { iconTrash } from '../utils/markerIcons'
type TrashboxMarkersProps = {
trashboxes: Trashbox[],
selectTrashbox: ({ index, category }: { index: number, category: string }) => void
selectTrashbox: ({ index, category }: {
index: number,
category: string,
}) => void,
}
function TrashboxMarkers({ trashboxes, selectTrashbox }: TrashboxMarkersProps) {
return (
<>{trashboxes.map((trashbox, index) => (
<Marker icon={iconTrash} key={`${trashbox.Lat}${trashbox.Lng}`} position={[trashbox.Lat, trashbox.Lng]}>
<Popup>
<p>{trashbox.Address}</p>
<p>Тип мусора: <>
{trashbox.Categories.map((category, j) =>
<span key={trashbox.Address + category}>
<a href='#' onClick={() => selectTrashbox({ index, category })}>
{category}
</a>
{(j < trashbox.Categories.length - 1) ? ', ' : ''}
</span>
)}
</></p>
<p>{trashbox.Lat.toFixed(4)}, {trashbox.Lng.toFixed(4)}</p>
</Popup>
</Marker>
))}</>
)
}
const TrashboxMarkers = ({ trashboxes, selectTrashbox }: TrashboxMarkersProps) => (
<>{trashboxes.map((trashbox, index) => (
<Marker icon={iconTrash} key={`${trashbox.Lat}${trashbox.Lng}`} position={[trashbox.Lat, trashbox.Lng]}>
<Popup>
<p className='fw-bold m-0'>{trashbox.Name}</p>
<p className='m-0'>{trashbox.Address}</p>
<p>Тип мусора:{' '}
{trashbox.Categories.map((category, j) =>
<span key={trashbox.Address + category}>
<a href='#' onClick={(e) => {
e.preventDefault()
e.stopPropagation()
selectTrashbox({ index, category })
}}>
{category}
</a>
{(j < trashbox.Categories.length - 1) ? ', ' : ''}
</span>
)}
</p>
<p className='m-0'>
{trashbox.Lat.toFixed(4)}, {trashbox.Lng.toFixed(4)}
</p>
</Popup>
</Marker>
))}</>
)
export default TrashboxMarkers

View File

@ -5,5 +5,16 @@ export { default as LineDot } from './LineDot'
export { default as LocationMarker } from './LocationMarker'
export { default as TrashboxMarkers } from './TrashboxMarkers'
export { default as WithToken } from './WithToken'
export { default as ClickHandler } from './ClickHandler'
export { default as MapClickHandler } from './MapClickHandler'
export { default as AuthForm } from './AuthForm'
export { default as BackHeader } from './BackHeader'
export { default as CategoryPreview } from './CategoryPreview'
export { default as StoriesPreview } from './StoriesPreview'
export { default as Points } from './Points'
export { default as SignOut } from './SignOut'
export { default as Poetry } from './Poetry'
export { default as SelectDisposalTrashbox } from './SelectDisposalTrashbox'
export { default as StarRating } from './StarRating'
export { default as CardLayout } from './CardLayout'
export { default as LocateButton } from './LocateButton'
export { default as StudentLocations } from './StudentLocations'

View File

@ -1,6 +1,13 @@
export { default as useAnnouncements } from './useAnnouncements'
export { default as useBook } from './useBook'
export { default as useAuth } from './useAuth'
export { default as useTrashboxes } from './useTrashboxes'
export { default as useAddAnnouncement } from './useAddAnnouncement'
export { default as useOsmAddresses } from './useOsmAddress'
export { default as useUser } from './useUser'
export { default as useRemoveAnnouncement } from './useRemoveAnnouncement'
export { default as useSignIn } from './useSignIn'
export { default as useSignUp } from './useSignUp'
export { default as usePoetry } from './usePoetry'
export { default as useDispose } from './useDispose'
export { default as useSendRate } from './useSendRate'
export { default as useUserRating } from './useUserRating'

View File

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

View File

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

View File

@ -1,117 +0,0 @@
import { useState } from 'react'
import { API_URL } from '../../config'
import { isConst, isObject } from '../../utils/types'
import { handleHTTPErrors } from '../../utils'
interface AuthData {
email: string,
password: string,
}
// interface LoginData extends AuthData { }
// interface SignUpData extends AuthData {
// name: string,
// surname: string
// }
type SignUpResponse = {
Success: true
} | {
Success: false,
Message: string
}
const isSignUpResponse = (obj: unknown): obj is SignUpResponse => (
isObject(obj, {
'Success': isConst(true)
}) ||
isObject(obj, {
'Success': isConst(false),
'Message': 'string'
})
)
interface LogInResponse {
access_token: string,
token_type: 'bearer'
}
const isLogInResponse = (obj: unknown): obj is LogInResponse => (
isObject(obj, {
'access_token': 'string',
'token_type': isConst('bearer')
})
)
function useAuth() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
async function doAuth(data: AuthData, newAccount: boolean) {
setLoading(true)
if (newAccount) {
try {
const res = await fetch(API_URL + '/signup', {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
})
handleHTTPErrors(res)
const signupData: unknown = await res.json()
if (!isSignUpResponse(signupData)) {
throw new Error('Malformed server response')
}
if (signupData.Success === false) {
throw new Error(signupData.Message)
}
} catch (err) {
setError(err instanceof Error ? err.message : err as string)
setLoading(false)
return null
}
}
try {
const res = await fetch(API_URL + '/auth/token' + new URLSearchParams({
username: data.email,
password: data.password
}).toString(), {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
})
const logInData: unknown = await res.json()
if (!isLogInResponse(logInData)) {
throw new Error('Malformed server response')
}
const token = logInData.access_token
setError('')
setLoading(false)
return token
} catch (err) {
setError(err instanceof Error ? err.message : err as string)
setLoading(false)
return null
}
}
return { doAuth, loading, error }
}
export default useAuth

View File

@ -1,74 +1,32 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useCallback } from 'react'
import { getToken } from '../../utils/auth'
import { API_URL } from '../../config'
import { isObject } from '../../utils/types'
import { handleHTTPErrors } from '../../utils'
import { useSendWithButton } from '..'
import { composeBookURL, processBook } from '../../api/book'
import { isBookResponse } from '../../api/book/types'
type BookResponse = {
Success: boolean
}
function useBook() {
const { doSend, button } = useSendWithButton('Забронировать',
'Забронировано',
true,
composeBookURL(),
'POST',
true,
isBookResponse,
processBook,
)
const isBookResponse = (obj: unknown): obj is BookResponse => (
isObject(obj, {
'Success': 'boolean'
})
)
const handleBook = useCallback(async (id: number) => {
await doSend({}, {
body: JSON.stringify({
id,
}),
headers: {
'Content-Type': 'application/json',
},
})
}, [doSend])
type BookStatus = '' | 'Загрузка...' | 'Забронировано' | 'Ошибка бронирования'
function useBook(id: number) {
const navigate = useNavigate()
const [status, setStatus] = useState<BookStatus>('')
const handleBook = async () => {
const token = getToken()
if (token) {
setStatus('Загрузка...')
try {
const res = await fetch(API_URL + '/book', {
method: 'POST',
body: JSON.stringify({
id: id
}),
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
}
})
handleHTTPErrors(res)
const data: unknown = await res.json()
if (!isBookResponse(data)) {
throw new Error('Malformed server response')
}
if (data.Success === true) {
setStatus('Забронировано')
} else {
throw new Error('Server refused to book')
}
}
catch (err) {
setStatus('Ошибка бронирования')
if (import.meta.env.DEV) {
console.log(err)
}
}
} else {
return navigate('/login')
}
}
return { handleBook, status }
return { handleBook, bookButton: button }
}
export default useBook

View File

@ -0,0 +1,35 @@
import { useCallback } from 'react'
import useSendWithButton from '../useSendWithButton'
import { composeDisposeBody, composeDisposeURL, processDispose } from '../../api/dispose'
import { DisposeParams, isDisposeResponse } from '../../api/dispose/types'
function useDispose(resolve: () => void) {
const { doSend, button } = useSendWithButton(
'Выбор сделан',
'Зачтено',
true,
composeDisposeURL(),
'POST',
true,
isDisposeResponse,
processDispose,
)
const doSendWithClose = useCallback(async (...args: DisposeParams) => {
const res = await doSend({}, {
body: composeDisposeBody(...args),
headers: {
'Content-Type': 'application/json',
},
})
if (res) {
resolve()
}
}, [doSend, resolve])
return { handleDispose: doSendWithClose, disposeButton: button }
}
export default useDispose

View File

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

View File

@ -0,0 +1,17 @@
import { composePoetryURL, initialPoetry, processPoetry } from '../../api/poetry'
import { Poetry, isPoetryResponse } from '../../api/poetry/types'
import useFetch, { UseFetchReturn } from '../useFetch'
const usePoetry = (): UseFetchReturn<Poetry> => (
useFetch(
composePoetryURL(),
'GET',
false,
isPoetryResponse,
processPoetry,
initialPoetry,
)
)
export default usePoetry

View File

@ -0,0 +1,37 @@
import { useCallback } from 'react'
import useSendWithButton from '../useSendWithButton'
import { composeRemoveAnnouncementURL, processRemoveAnnouncement } from '../../api/removeAnnouncement'
import { isRemoveAnnouncementResponse } from '../../api/removeAnnouncement/types'
function useRemoveAnnouncement(resolve: () => void) {
const { doSend, button } = useSendWithButton(
'Закрыть объявление',
'Закрыто',
true,
composeRemoveAnnouncementURL(),
'DELETE',
true,
isRemoveAnnouncementResponse,
processRemoveAnnouncement,
)
const doSendWithClose = useCallback(async (id: number) => {
const res = await doSend({}, {
body: JSON.stringify({
id,
}),
headers: {
'Content-Type': 'application/json',
},
})
if (res) {
resolve()
}
}, [doSend, resolve])
return { handleRemove: doSendWithClose, removeButton: button }
}
export default useRemoveAnnouncement

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