Added TypeScript for frontend

Added type definitions for components, functions, data
Added guards for network responses
fixes #8
This commit is contained in:
Dmitriy Shishkov 2023-07-12 18:59:17 +03:00
parent 8fc85e415f
commit a8b7cfbffa
Signed by: dm1sh
GPG Key ID: 027994B0AA357688
52 changed files with 1616 additions and 1651 deletions

View File

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

24
front/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@ -10,6 +10,6 @@
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2171
front/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import { BrowserRouter as Router, Route, Routes, Link } from 'react-router-dom'
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'
import { HomePage, AddPage, LoginPage, UserPage } from './pages'
@ -20,8 +20,7 @@ function App() {
} />
<Route path="/user" element={
<WithToken>
{/* <UserPage /> */}
<h1>For Yet Go <Link to="/">Home</Link></h1>
<UserPage />
</WithToken>
} />
<Route path="/login" element={<LoginPage />} />

View File

@ -1,4 +1,12 @@
const categoryGraphics = new Map([
import { isLiteralUnion } from "../utils/types"
const categories = ["PORRIDGE", "conspects", "milk", "bred", "wathing", "cloth",
"fruits_vegatables", "soup", "dinner", "conserves", "pens", "other_things"] as const
type Category = typeof categories[number]
const isCategory = (obj: unknown): obj is Category => isLiteralUnion(obj, categories)
const categoryGraphics = new Map<Category, string>([
["PORRIDGE", "static/PORRIDGE.jpg"],
["conspects", "static/conspects.jpg"],
["milk", "static/milk.jpg"],
@ -14,7 +22,7 @@ const categoryGraphics = new Map([
])
const categoryNames = new Map([
const categoryNames = new Map<Category, string>([
["PORRIDGE", "PORRIDGE"],
["conspects", "Конспекты"],
["milk", "Молочные продукты"],
@ -29,4 +37,5 @@ const categoryNames = new Map([
["other_things", "Всякая всячина"]
])
export { categoryNames, categoryGraphics }
export type { Category }
export { categoryNames, categoryGraphics, isCategory }

View File

@ -1,4 +1,7 @@
const stations = {
const lines = ['red', 'blue', 'green', 'orange', 'violet'] as const
type Lines = typeof lines[number]
const stations: Record<Lines, Set<string>> = {
red: new Set([
"Девяткино",
"Гражданский проспект",
@ -82,7 +85,7 @@ const stations = {
]),
}
const colors = {
const colors: Record<Lines, string> = {
red: "#D6083B",
blue: "#0078C9",
green: "#009A49",
@ -90,7 +93,7 @@ const colors = {
violet: "#702785",
}
const lines = {
const lineNames: Record<Lines, string> = {
red: "Красная",
blue: "Синяя",
green: "Зелёная",
@ -98,4 +101,5 @@ const lines = {
violet: "Фиолетовая",
}
export { stations, colors, lines }
export type { Lines }
export { lines, stations, colors, lineNames }

View File

@ -4,8 +4,14 @@ import { MapContainer, Marker, Popup, TileLayer } from 'react-leaflet'
import LineDot from './LineDot'
import { categoryNames } from '../assets/category'
import { useBook } from '../hooks/api'
import { Announcement } from '../hooks/api/useHomeAnnouncementList'
function AnnouncementDetails({ close, announcement: { id, name, category, bestBy, description, lat, lng, address, metro } }) {
type AnnouncementDetailsProps = {
close: () => void,
announcement: Announcement
}
function AnnouncementDetails({ close, announcement: { id, name, category, bestBy, description, lat, lng, address, metro } }: AnnouncementDetailsProps) {
const { handleBook, status: bookStatus } = useBook(id)
return (
@ -46,7 +52,7 @@ function AnnouncementDetails({ close, announcement: { id, name, category, bestBy
</Modal.Body>
<Modal.Footer>
<Button variant='success' onClick={handleBook}>
<Button variant='success' onClick={() => void handleBook()}>
{bookStatus || "Забронировать"}
</Button>
</Modal.Footer>

View File

@ -4,20 +4,20 @@ import addIcon from '../assets/addIcon.svg'
import filterIcon from '../assets/filterIcon.svg'
import userIcon from '../assets/userIcon.svg'
const navBarStyles = {
const navBarStyles: React.CSSProperties = {
backgroundColor: 'var(--bs-success)',
height: 56,
width: "100%",
}
const navBarGroupStyles = {
const navBarGroupStyles: React.CSSProperties = {
display: "flex",
flexDirection: "row",
height: "100%",
margin: "auto"
}
const navBarElementStyles = {
const navBarElementStyles: React.CSSProperties = {
width: "100%",
height: "100%",
display: "flex",
@ -25,7 +25,12 @@ const navBarElementStyles = {
justifyContent: "center"
}
function BottomNavBar({ width, toggleFilters }) {
type BottomNavBarProps = {
width: number,
toggleFilters: (p: boolean) => void
}
function BottomNavBar({ width, toggleFilters }: BottomNavBarProps) {
return (
<div style={navBarStyles}>
<div style={{ ...navBarGroupStyles, width: width }}>

View File

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

View File

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

View File

@ -1,11 +1,21 @@
import { Button, Form, Modal } from "react-bootstrap"
import { categoryNames } from "../assets/category"
import { stations, lines } from '../assets/metro'
import { stations, lines, lineNames } from '../assets/metro'
import { FiltersType } from "../utils/filters"
import { SetState } from "../utils/types"
import { FormEventHandler } from "react"
function Filters({ filter, setFilter, filterShown, setFilterShown }) {
type FiltersProps = {
filter: FiltersType,
setFilter: SetState<FiltersType>,
filterShown: boolean,
setFilterShown: SetState<boolean>
}
const handleSubmit = (event) => {
function Filters({ filter, setFilter, filterShown, setFilterShown }: FiltersProps) {
const handleSubmit: FormEventHandler<HTMLFormElement> = (event) => {
event.preventDefault();
event.stopPropagation();
@ -13,8 +23,8 @@ function Filters({ filter, setFilter, filterShown, setFilterShown }) {
setFilter(prev => ({
...prev,
category: formData.get("category") || null,
metro: formData.get("metro") || null
category: (formData.get("category") as (FiltersType['category'] | null)) || undefined,
metro: (formData.get("metro") as (FiltersType['metro'] | null)) || undefined
}))
setFilterShown(false)
@ -55,10 +65,10 @@ function Filters({ filter, setFilter, filterShown, setFilterShown }) {
<option value="">
Выберите станцию метро
</option>
{Object.entries(stations).map(
([line, stations]) =>
<optgroup key={line} label={lines[line]}>
{Array.from(stations).map(metro =>
{lines.map(
line =>
<optgroup key={line} label={lineNames[line]}>
{Array.from(stations[line]).map(metro =>
<option key={metro} value={metro}>{metro}</option>
)}
</optgroup>

View File

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

View File

@ -1,6 +1,14 @@
import { Marker, Popup, useMapEvents } from "react-leaflet"
import { LatLng } from 'leaflet'
import { SetState } from "../utils/types"
const LocationMarker = ({ address, position, setPosition }) => {
type LocationMarkerProps = {
address: string,
position: LatLng,
setPosition: SetState<LatLng>
}
const LocationMarker = ({ address, position, setPosition }: LocationMarkerProps) => {
const map = useMapEvents({
dragend: () => {

View File

@ -1,9 +1,15 @@
import { Marker, Popup } from "react-leaflet"
import { Trashbox } from "../hooks/api/useTrashboxes"
const TrashboxMarkers = ({ trashboxes, selectTrashbox }) => {
type TrashboxMarkersProps = {
trashboxes: Trashbox[],
selectTrashbox: ({ index, category }: { index: number, category: string }) => void
}
const TrashboxMarkers = ({ trashboxes, selectTrashbox }: TrashboxMarkersProps) => {
return (
<>{trashboxes.map((trashbox, index) => (
<Marker key={trashbox.Lat + "" + trashbox.Lng} position={[trashbox.Lat, trashbox.Lng]}>
<Marker key={`${trashbox.Lat}${trashbox.Lng}`} position={[trashbox.Lat, trashbox.Lng]}>
<Popup>
<p>{trashbox.Address}</p>
<p>Тип мусора: <>

View File

@ -1,8 +1,8 @@
import { useEffect } from "react"
import { PropsWithChildren, useEffect } from "react"
import { getToken } from "../utils/auth"
import { useNavigate } from "react-router-dom"
function WithToken({ children }) {
function WithToken({ children }: PropsWithChildren) {
const navigate = useNavigate()
useEffect(() => {

View File

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

View File

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

View File

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

View File

@ -0,0 +1,110 @@
import { useState } from "react"
import { API_URL } from "../../config"
import { isConst, isObject } from "../../utils/types"
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('')
const doAuth = async (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'
}
})
const signupData: unknown = await res.json()
if (!isSignUpResponse(signupData)) {
throw new Error("Malformed server response")
}
if (signupData.Success === false) {
throw new Error(signupData.Message)
}
} catch (err) {
setError(err instanceof Error ? err.message : err as string)
setLoading(false)
return null
}
}
try {
const res = await fetch(API_URL + '/auth/token' + new URLSearchParams({
username: data.email,
password: data.password
}).toString(), {
method: "POST",
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
})
const logInData: unknown = await res.json()
if (!isLogInResponse(logInData)) {
throw new Error("Malformed server response")
}
const token = logInData.access_token
setError('')
setLoading(false)
return token
} catch (err) {
setError(err instanceof Error ? err.message : err as string)
setLoading(false)
return null
}
}
return { doAuth, loading, error }
}
export default useAuth

View File

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

View File

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

View File

@ -1,12 +1,12 @@
import { useEffect, useRef, useState } from "react"
import { isAborted } from '../../utils'
const useFetch = (url, params, initialData) => {
const useFetch = <T>(url: string, params: RequestInit | undefined, initialData: T, dataGuard: (obj: unknown) => obj is T) => {
const [data, setData] = useState(initialData)
const [loading, setLoading] = useState(true)
const [error, setError] = useState("")
const abortControllerRef = useRef(null)
const abortControllerRef = useRef<AbortController>()
useEffect(() => {
if (abortControllerRef.current) {
@ -33,11 +33,15 @@ const useFetch = (url, params, initialData) => {
return res.json()
})
.then(data => {
if (!dataGuard(data)) {
throw new Error("Неверный ответ от сервера")
}
setData(data)
setLoading(false)
})
.catch(err => {
if (!isAborted(err)) {
if (err instanceof Error && !isAborted(err)) {
setError("Ошибка сети")
}
@ -48,10 +52,13 @@ const useFetch = (url, params, initialData) => {
}
})
return () => abortControllerRef.current.abort()
}, [url, params])
return () => abortControllerRef.current?.abort()
}, [url, params, dataGuard])
return { data, loading, error, abort: abortControllerRef.current?.abort }
return {
data, loading, error,
abort: abortControllerRef.current?.abort.bind(abortControllerRef.current)
}
}
export default useFetch

View File

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

View File

@ -0,0 +1,96 @@
import useFetch from './useFetch'
import { API_URL } from '../../config'
import { FiltersType, filterNames } from '../../utils/filters'
import { isArrayOf, isObject } from '../../utils/types'
import { Category, isCategory } from '../../assets/category'
const initialAnnouncements = { list_of_announcements: [], Success: true }
type AnnouncementsListResponse = {
list_of_announcements: AnnouncementResponse[],
Success: boolean
}
const isAnnouncementsListResponse = (obj: unknown): obj is AnnouncementsListResponse => isObject(obj, {
"list_of_announcements": obj => isArrayOf<AnnouncementResponse>(obj, isAnnouncementResponse),
"Success": "boolean"
})
type AnnouncementResponse = {
id: number,
user_id: number,
name: string,
category: Category,
best_by: number,
address: string,
longtitude: number,
latitude: number,
description: string,
src: string | null,
metro: string,
trashId: number | null,
booked_by: number
}
const isAnnouncementResponse = (obj: unknown): obj is AnnouncementResponse => isObject(obj, {
"id": "number",
"user_id": "number",
"name": "string",
"category": isCategory,
"best_by": "number",
"address": "string",
"longtitude": "number",
"latitude": "number",
"description": "string",
"src": "string?",
"metro": "string",
"trashId": "number?",
"booked_by": "number"
})
type Announcement = {
id: number,
userId: number,
name: string,
category: Category,
bestBy: number,
address: string,
lng: number,
lat: number,
description: string | null,
src: string | null,
metro: string,
trashId: number | null,
bookedBy: number
}
const composeFilters = (filters: FiltersType) => Object.fromEntries(
filterNames.map(
fName => [fName, filters[fName]?.toString()]
).filter((p): p is [string, string] => typeof p[1] !== 'undefined')
)
const useHomeAnnouncementList = (filters: FiltersType) => {
const { data, loading, error } = useFetch(
API_URL + '/announcements?' + new URLSearchParams(composeFilters(filters)).toString(),
undefined,
initialAnnouncements,
isAnnouncementsListResponse
)
const annList = data.list_of_announcements
const res: Announcement[] = annList.map(ann => ({
...ann,
lat: ann.latitude,
lng: ann.longtitude,
bestBy: ann.best_by,
bookedBy: ann.booked_by,
userId: ann.user_id
}))
return { data: error ? [] : res, loading, error }
}
export type { Announcement, AnnouncementsListResponse }
export default useHomeAnnouncementList

View File

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

View File

@ -0,0 +1,35 @@
import { LatLng } from "leaflet"
import { API_URL } from "../../config"
import { isArrayOf, isObject } from "../../utils/types"
import useFetch from "./useFetch"
import { isString } from "../../utils/types"
type Trashbox = {
Lat: number,
Lng: number,
Address: string,
Categories: string[]
}
const isTrashbox = (obj: unknown): obj is Trashbox => isObject(obj, {
"Lat": "number",
"Lng": "number",
"Address": "string",
"Categories": obj => isArrayOf<string>(obj, isString)
})
const useTrashboxes = (position: LatLng) => {
return useFetch(
API_URL + "/trashbox?" + new URLSearchParams({
lat: position.lat.toString(),
lng: position.lng.toString()
}).toString(),
undefined,
[],
(obj): obj is Trashbox[] => isArrayOf(obj, isTrashbox)
)
}
export type { Trashbox }
export default useTrashboxes

View File

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

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from "react"
import { FormEventHandler, useEffect, useState } from "react"
import { Form, Button, Card } from "react-bootstrap"
import { MapContainer, TileLayer } from 'react-leaflet'
import { latLng } from "leaflet"
@ -7,21 +7,22 @@ import { ClickHandler, LocationMarker, TrashboxMarkers } from "../components"
import { useAddAnnouncement, useTrashboxes } from "../hooks/api"
import { categoryNames } from "../assets/category"
import { stations, lines } from "../assets/metro"
import { stations, lines, lineNames } from "../assets/metro"
import { isObject } from "../utils/types"
function AddPage() {
const [addressPosition, setAddressPosition] = useState(latLng(59.972, 30.3227))
const [address, setAddress] = useState('')
const { data: trashboxes, trashboxes_loading, trashboxes_error } = useTrashboxes(addressPosition)
const trashboxes = useTrashboxes(addressPosition)
const [selectedTrashbox, setSelectedTrashbox] = useState({ index: -1, category: '' })
useEffect(() => {
(async () => {
void (async () => {
try {
const res = await fetch(location.protocol + "//nominatim.openstreetmap.org/search?format=json&q=" + address)
const fetchData = await res.json()
const fetchData: unknown = await res.json()
console.log("f", fetchData)
@ -32,11 +33,15 @@ function AddPage() {
}, [address])
useEffect(() => {
(async () => {
void (async () => {
try {
const res = await fetch(location.protocol + "//nominatim.openstreetmap.org/reverse?format=json&accept-language=ru&lat=" + addressPosition.lat + "&lon=" + addressPosition.lng)
const res = await fetch(`${location.protocol}//nominatim.openstreetmap.org/reverse?format=json&accept-language=ru&lat=${addressPosition.lat}&lon=${addressPosition.lng}`)
const fetchData = await res.json()
const fetchData: unknown = await res.json()
if (!isObject<{ display_name: string }>(fetchData, { "display_name": "string" })) {
throw new Error("Malformed server response")
}
setAddress(fetchData.display_name)
@ -48,18 +53,18 @@ function AddPage() {
const { doAdd, status } = useAddAnnouncement()
const handleSubmit = (event) => {
const handleSubmit: FormEventHandler<HTMLFormElement> = (event) => {
event.preventDefault()
event.stopPropagation()
const formData = new FormData(event.target)
const formData = new FormData(event.currentTarget)
formData.append("latitude", addressPosition.lat)
formData.append("longtitude", addressPosition.lng)
formData.append("latitude", addressPosition.lat.toString())
formData.append("longtitude", addressPosition.lng.toString())
formData.append("address", address)
formData.set("bestBy", new Date(formData.get("bestBy")).getTime())
formData.set("bestBy", new Date((formData.get("bestBy") as number | null) || 0).getTime().toString())
doAdd(formData)
void doAdd(formData)
}
return (
@ -137,10 +142,10 @@ function AddPage() {
<option value="">
Укажите ближайщую станцию метро
</option>
{Object.entries(stations).map(
([line, stations]) =>
<optgroup key={line} label={lines[line]}>
{Array.from(stations).map(metro =>
{lines.map(
line =>
<optgroup key={line} label={lineNames[line]}>
{Array.from(stations[line]).map(metro =>
<option key={metro} value={metro}>{metro}</option>
)}
</optgroup>
@ -151,17 +156,17 @@ function AddPage() {
<Form.Group className="mb-3" controlId="password">
<Form.Label>Пункт сбора мусора</Form.Label>
<div className="mb-3">
{trashboxes_loading
{trashboxes.loading
? (
<div style={{ height: 400 }}>
<p>Загрузка</p>
</div>
) : (
trashboxes_error ? (
trashboxes.error ? (
<p
style={{ height: 400 }}
className="text-danger"
>{trashboxes_error}</p>
>{trashboxes.error}</p>
) : (
<MapContainer
scrollWheelZoom={false}
@ -175,7 +180,7 @@ function AddPage() {
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<TrashboxMarkers
trashboxes={trashboxes}
trashboxes={trashboxes.data}
selectTrashbox={setSelectedTrashbox}
/>
</MapContainer>
@ -185,7 +190,7 @@ function AddPage() {
</div>
{selectedTrashbox.index > -1 ? (
<p>Выбран пункт сбора мусора на {
trashboxes[selectedTrashbox.index].Address
trashboxes.data[selectedTrashbox.index].Address
} с категорией {selectedTrashbox.category}</p>
) : (
<p>Выберите пунк сбора мусора и категорию</p>

View File

@ -4,22 +4,25 @@ import Stories from 'react-insta-stories'
import { BottomNavBar, AnnouncementDetails, Filters } from '../components'
import { useStoryDimensions } from '../hooks'
import { useHomeAnnouncementList } from '../hooks/api'
import { defaultFilters } from '../utils/filters'
import puffSpinner from '../assets/puff.svg'
import { categoryGraphics } from '../assets/category'
import { Announcement } from '../hooks/api/useHomeAnnouncementList'
import { Story } from 'react-insta-stories/dist/interfaces'
function generateStories(announcements) {
function generateStories(announcements: Announcement[]): Story[] {
return announcements.map(announcement => {
return ({
id: announcement.id,
url: announcement.src || categoryGraphics.get(announcement.category),
type: announcement.src?.endsWith("mp4") ? "video" : undefined,
seeMore: ({ close }) => <AnnouncementDetails close={close} announcement={announcement} />
seeMore: ({ close }: { close: () => void }) => <AnnouncementDetails close={close} announcement={announcement} />
})
})
}
function fallbackGenerateStories(announcementsFetch) {
function fallbackGenerateStories(announcementsFetch: ReturnType<typeof useHomeAnnouncementList>) {
const stories = generateStories(announcementsFetch.data)
if (announcementsFetch.loading)
@ -34,7 +37,7 @@ function fallbackGenerateStories(announcementsFetch) {
return stories
}
const fallbackStory = (text, isError = false) => [{
const fallbackStory = (text = '', isError = false): Story[] => [{
content: ({ action }) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => { action('pause') }, [action])
@ -45,11 +48,8 @@ const fallbackStory = (text, isError = false) => [{
</div>
)
},
header: { heading: text }
}]
const defaultFilters = { userId: null, category: null, metro: null, bookedBy: null }
function HomePage() {
const { height, width } = useStoryDimensions(16 / 10)

View File

@ -1,3 +1,4 @@
import { FormEventHandler } from 'react'
import { Form, Button, Card, Tabs, Tab } from "react-bootstrap"
import { useNavigate } from "react-router-dom";
@ -7,25 +8,27 @@ import { setToken } from "../utils/auth";
function LoginPage() {
const navigate = useNavigate()
const doAuth = useAuth()
const { doAuth } = useAuth() // TODO: Add loading and error handling
const handleAuth = (newAccount) => (event) => {
const handleAuth = (newAccount: boolean): FormEventHandler<HTMLFormElement> => async (event) => {
event.preventDefault();
event.stopPropagation();
const formData = new FormData(event.currentTarget)
const data = {
email: formData.get('email'),
name: newAccount ? formData.get('name') : undefined,
password: formData.get('password')
email: formData.get('email') as string,
name: newAccount ? formData.get('name') as string : undefined,
surname: newAccount ? formData.get('surname') as string : undefined,
password: formData.get('password') as string
}
const token = "a" // doAuth(data, newAccount)
const token = import.meta.env.PROD ? await doAuth(data, newAccount) : "a"
setToken(token)
navigate("/")
if (token) {
setToken(token)
navigate("/")
}
}
return (

View File

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

View File

@ -0,0 +1,9 @@
import { Link } from "react-router-dom"
function UserPage() {
/* TODO */
return <h1>For Yet Go <Link to="/">Home</Link></h1>
}
export default UserPage

View File

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

View File

@ -0,0 +1,11 @@
import { Announcement } from "../hooks/api/useHomeAnnouncementList"
const filterNames = ["userId", "category", "metro", "bookedBy"] as const
type FilterNames = typeof filterNames[number]
type FiltersType = Partial<Pick<Announcement, FilterNames>>
const defaultFilters: FiltersType = { userId: undefined, category: undefined, metro: undefined, bookedBy: undefined }
export type { FilterNames, FiltersType }
export { defaultFilters, filterNames }

View File

@ -1,12 +0,0 @@
const removeNull = (obj) => Object.fromEntries(
Object.entries(obj)
.filter(([_, value]) => value != null)
.map(([key, value]) => [
key,
value === Object(value) ? removeNull(value) : value,
]),
)
const isAborted = (err) => err.name == 'AbortError'
export { removeNull, isAborted }

3
front/src/utils/index.ts Normal file
View File

@ -0,0 +1,3 @@
const isAborted = (err: Error) => err.name === 'AbortError'
export { isAborted }

View File

@ -1,7 +0,0 @@
import { stations } from "../assets/metro"
function lineByName(name) {
return Object.keys(stations).find(line => stations[line].has(name))
}
export { lineByName }

7
front/src/utils/metro.ts Normal file
View File

@ -0,0 +1,7 @@
import { stations, lines } from "../assets/metro"
function lineByName(name: string) {
return lines.find(line => stations[line].has(name))
}
export { lineByName }

63
front/src/utils/types.ts Normal file
View File

@ -0,0 +1,63 @@
const isRecord = <K extends string | number | symbol>(obj: unknown): obj is Record<K, unknown> => (
typeof obj === 'object' &&
!Array.isArray(obj) &&
Object.getOwnPropertySymbols(obj).length === 0 && // We don't like symbols as keys here
obj !== null
)
type Primitive = "bigint" | "boolean" | "function" | "number" | "object" | "string" | "symbol" | "undefined"
type PropertyGuard = Primitive | `${Primitive}?` | ((obj: unknown) => boolean)
type PropertiesGuards = Record<
string | number | symbol,
PropertyGuard>
const isObject = <T>(obj: unknown, properties: PropertiesGuards): obj is T => (
typeof obj === 'object' &&
obj !== null &&
// Does not actually iterate over symbols. Hope, will not need to use them
Object.entries(properties).every(([name, guard]) => {
const param: unknown = Reflect.get(obj, name)
console.log(name, param, guard)
if (typeof guard === 'function') {
return guard(param)
}
if (guard[guard.length - 1] === '?')
return (
(param !== undefined && param !== null && typeof param === guard.slice(0, -1)) ||
param === undefined || param === null
)
return typeof param === guard
})
)
const isConst = <T>(val: T) => (obj: unknown): obj is T => (
obj === val ||
(
typeof obj === 'number' && isNaN(obj) &&
typeof val === 'number' && isNaN(val)
)
)
const isLiteralUnion = <T extends readonly string[]>(obj: unknown, list: T): obj is T[number] => (
typeof obj === 'string' &&
list.includes(obj as T[number])
)
const isArrayOf = <T>(obj: unknown, itemGuard: ((obj: unknown) => obj is T)): obj is T[] => (
Array.isArray(obj) &&
obj.every(itemGuard)
)
const isString = (obj: unknown): obj is string => typeof obj === 'string'
type SetState<T> = React.Dispatch<React.SetStateAction<T>>
export type { SetState }
export { isRecord, isObject, isConst, isLiteralUnion, isArrayOf, isString }

1
front/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

25
front/tsconfig.json Normal file
View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

10
front/tsconfig.node.json Normal file
View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}