Compare commits

..

No commits in common. "vertical_carousel" and "main" have entirely different histories.

5 changed files with 153 additions and 352 deletions

View File

@ -22,8 +22,7 @@
"react-insta-stories": "^2.6.1", "react-insta-stories": "^2.6.1",
"react-leaflet": "^4.2.1", "react-leaflet": "^4.2.1",
"react-leaflet-custom-control": "^1.3.5", "react-leaflet-custom-control": "^1.3.5",
"react-router-dom": "^6.14.1", "react-router-dom": "^6.14.1"
"swiper": "^11.0.6"
}, },
"devDependencies": { "devDependencies": {
"@faker-js/faker": "^8.0.2", "@faker-js/faker": "^8.0.2",

View File

@ -26,43 +26,31 @@ const styles = {
} }
type ViewProps = { type ViewProps = {
myId: number myId: number,
announcement: Announcement announcement: Announcement,
} }
const View = ({ const View = ({
myId, myId,
announcement: { announcement: { name, category, bestBy, description, lat, lng, address, metro, userId },
name,
category,
bestBy,
description,
lat,
lng,
address,
metro,
userId,
},
}: ViewProps) => ( }: ViewProps) => (
<> <>
<h1>{name}</h1> <h1>{name}</h1>
<span>{categoryNames[category]}</span> <span>{categoryNames[category]}</span>
<span className="m-2">&#x2022;</span> <span className='m-2'>&#x2022;</span>{/* dot */}
{/* dot */}
<span>Годен до {bestBy}</span> <span>Годен до {bestBy}</span>
<p className="mb-0">{description}</p> <p className='mb-0'>{description}</p>
<p className="mb-3"> <p className='mb-3'>
Рейтинг пользователя:{' '} Рейтинг пользователя: <StarRating dynamic={myId !== userId} userId={userId} />
<StarRating dynamic={myId !== userId} userId={userId} />
</p> </p>
<MapContainer style={styles.map} center={[lat, lng]} zoom={16}> <MapContainer style={styles.map} center={[lat, lng]} zoom={16} >
<TileLayer <TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors' attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" url='https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'
/> />
<StudentLocations /> <StudentLocations />
@ -79,9 +67,9 @@ const View = ({
) )
type ControlProps = { type ControlProps = {
myId: number myId: number,
closeRefresh: () => void closeRefresh: () => void,
announcement: Announcement announcement: Announcement,
showDispose: () => void showDispose: () => void
} }
@ -98,44 +86,25 @@ function Control({
return ( return (
<> <>
<p>Забронировали {bookedBy + (bookButton.disabled ? 1 : 0)} чел.</p> <p>Забронировали {bookedBy + (bookButton.disabled ? 1 : 0)} чел.</p>
{myId === userId ? ( {(myId === userId) ? (
<div className="m-0"> <div className='m-0'>
<Button <Button className='m-1' variant='success' onClick={showDispose}>Утилизировать</Button>
className="m-1" <Button className='m-1' variant='success' onClick={() => void handleRemove(id)} {...removeButton} />
variant="success"
onClick={showDispose}
>
Утилизировать
</Button>
<Button
className="m-1"
variant="success"
onClick={() => void handleRemove(id)}
{...removeButton}
/>
</div> </div>
) : ( ) : (
<Button <Button variant='success' onClick={() => void handleBook(id)} {...bookButton} />
variant="success"
onClick={() => void handleBook(id)}
{...bookButton}
/>
)} )}
</> </>
) )
} }
type AnnouncementDetailsProps = { type AnnouncementDetailsProps = {
className?: string close: () => void,
show: boolean refresh: () => void,
close: () => void announcement: Announcement,
refresh: () => void
announcement: Announcement
} }
function AnnouncementDetails({ function AnnouncementDetails({
className,
show,
close, close,
refresh, refresh,
announcement, announcement,
@ -150,38 +119,35 @@ function AnnouncementDetails({
const myId = useId() const myId = useId()
return ( return (
<Modal <div
centered className='modal'
show={show} style={styles.container}
onHide={close}
keyboard
className={className}
// backdrop={false}
> >
<Modal.Header closeButton onHide={close}> <Modal.Dialog centered className='modal-dialog'>
<Modal.Title>Подробнее</Modal.Title> <Modal.Header closeButton onHide={close}>
</Modal.Header> <Modal.Title>
Подробнее
</Modal.Title>
</Modal.Header>
<Modal.Body> <Modal.Body>
<View myId={myId} announcement={announcement} /> <View myId={myId} announcement={announcement} />
</Modal.Body> </Modal.Body>
<Modal.Footer> <Modal.Footer>
<Control <Control
myId={myId} myId={myId}
closeRefresh={closeRefresh} closeRefresh={closeRefresh}
showDispose={() => setDisposeShow(true)} showDispose={() => setDisposeShow(true)}
announcement={announcement} announcement={announcement}
/> />
</Modal.Footer> </Modal.Footer>
<Modal </Modal.Dialog>
centered <Modal centered show={disposeShow} onHide={() => setDisposeShow(false)} style={{ zIndex: 100000 }}>
show={disposeShow}
onHide={() => setDisposeShow(false)}
style={{ zIndex: 100000 }}
>
<Modal.Header closeButton> <Modal.Header closeButton>
<Modal.Title>Утилизация</Modal.Title> <Modal.Title>
Утилизация
</Modal.Title>
</Modal.Header> </Modal.Header>
<SelectDisposalTrashbox <SelectDisposalTrashbox
annId={announcement.id} annId={announcement.id}
@ -190,7 +156,7 @@ function AnnouncementDetails({
closeRefresh={closeRefresh} closeRefresh={closeRefresh}
/> />
</Modal> </Modal>
</Modal> </div>
) )
} }

View File

@ -8,78 +8,48 @@ function useStoryIndex(annLength: number | undefined) {
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
const withReset = const withReset = <T>(f: SetState<T>) => (...args: Parameters<SetState<T>>) => {
<T>(f: SetState<T>) => setIndex(0)
(...args: Parameters<SetState<T>>) => { setSearchParams(prev => ({
setIndex(0) ...Object.fromEntries(prev),
setSearchParams( storyIndex: '0',
(prev) => ({ }), { replace: true })
...Object.fromEntries(prev), f(...args)
storyIndex: '0', }
}),
{ replace: true }
)
f(...args)
}
useEffect(() => { useEffect(() => {
setIndex( setIndex(annLength ?
annLength Number.parseInt(searchParams.get('storyIndex') || '0') :
? Number.parseInt(searchParams.get('storyIndex') || '0') 0)
: 0
)
// searchParams have actual query string at first render // searchParams have actual query string at first render
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [annLength]) }, [annLength])
const increment = () => const increment = () => setIndex(prev => {
setIndex((prev) => { const newIndex = (prev + 1) % (annLength || 1)
const newIndex = (prev + 1) % (annLength || 1) setSearchParams(prev => ({
setSearchParams( ...Object.fromEntries(prev),
(prev) => ({ storyIndex: newIndex.toString(),
...Object.fromEntries(prev), }), { replace: true })
storyIndex: newIndex.toString(),
}),
{ replace: true }
)
return newIndex return newIndex
}) })
const decrement = () => const decrement = () => setIndex(prev => {
setIndex((prev) => { const newIndex = prev > 0 ? (prev - 1) : 0
const newIndex = prev > 0 ? prev - 1 : 0 setSearchParams(prev => ({
setSearchParams( ...Object.fromEntries(prev),
(prev) => ({ storyIndex: newIndex.toString(),
...Object.fromEntries(prev), }), { replace: true })
storyIndex: newIndex.toString(),
}),
{ replace: true }
)
return newIndex return newIndex
}) })
const set = (n: number) =>
setIndex(() => {
const newIndex = n % (annLength || 1)
setSearchParams(
(prev) => ({
...Object.fromEntries(prev),
storyIndex: newIndex.toString(),
}),
{ replace: true }
)
return newIndex
})
return { return {
n: index, n: index,
withReset, withReset,
increment, increment,
decrement, decrement,
set,
} }
} }

View File

@ -1,8 +1,6 @@
import { CSSProperties, useEffect, useMemo, useState } from 'react' import { CSSProperties, useEffect, useMemo, useState } from 'react'
// import Stories from 'react-insta-stories' import Stories from 'react-insta-stories'
// import { Story } from 'react-insta-stories/dist/interfaces' import { Story } from 'react-insta-stories/dist/interfaces'
import { Swiper, SwiperSlide, useSwiper, useSwiperSlide } from 'swiper/react'
import { Virtual, Keyboard, Mousewheel, Zoom, Autoplay } from 'swiper/modules'
import { BottomNavBar, AnnouncementDetails, Filters } from '../components' import { BottomNavBar, AnnouncementDetails, Filters } from '../components'
import { useFilters, useStoryDimensions, useStoryIndex } from '../hooks' import { useFilters, useStoryDimensions, useStoryIndex } from '../hooks'
@ -11,212 +9,102 @@ import { Announcement } from '../api/announcement/types'
import { categoryGraphics } from '../assets/category' import { categoryGraphics } from '../assets/category'
import { UseFetchReturn, gotError } from '../hooks/useFetch' import { UseFetchReturn, gotError } from '../hooks/useFetch'
import 'swiper/css'
import 'swiper/css/virtual'
import 'swiper/css/zoom'
import 'swiper/css/keyboard'
import 'swiper/css/mousewheel'
import 'swiper/css/autoplay'
import puffSpinner from '../assets/puff.svg' import puffSpinner from '../assets/puff.svg'
import { SetState } from '../utils/types'
import { Button } from 'react-bootstrap' function generateStories(announcements: Announcement[], refresh: () => void): Story[] {
import { throttle } from '../utils' return announcements.map(announcement => {
return ({
id: announcement.id,
url: announcement.src || categoryGraphics[announcement.category],
type: announcement.src?.endsWith('mp4') ? 'video' : undefined,
seeMore: ({ close }: { close: () => void }) => (
<AnnouncementDetails close={close} refresh={refresh} announcement={announcement} />
),
})
})
}
function fallbackGenerateStories(announcements: UseFetchReturn<Announcement[]>) {
if (announcements.loading) {
return fallbackStory()
}
if (gotError(announcements)) {
return fallbackStory(announcements.error, true)
}
if (announcements.data.length === 0) {
return [{ url: '/static/empty.png' }]
}
const stories = generateStories(announcements.data, announcements.refetch)
return stories
}
const fallbackStory = (text = '', isError = false): Story[] => [{
content: ({ action }) => {
// ESLint can't detect that it is a component
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
action('pause')
}, [action])
return (
<div className={`m-auto ${isError ? 'text-danger' : ''}`}>
{text || <img src={puffSpinner} />}
</div>
)
},
}]
const styles = { const styles = {
container: { container: {
display: 'flex', display: 'flex',
justifyContent: 'center', justifyContent: 'center',
backgroundColor: 'rgb(17, 17, 17)', backgroundColor: 'rgb(17, 17, 17)',
height: 'calc(100dvh - 56px)',
} as CSSProperties, } as CSSProperties,
detailsContainer: {
position: 'fixed',
top: 0,
zIndex: 1000,
textAlign: 'initial',
} as CSSProperties,
cardContainer: {
height: '100%',
margin: '0 auto',
position: 'relative',
} as CSSProperties,
cardMedia: {
height: '100%',
} as CSSProperties,
cardDetails: {
position: 'absolute',
width: '100%',
bottom: 0,
textAlign: 'center',
height: '56px',
boxShadow: 'inset 0px -50px 36px -28px rgba(0, 0, 0, 0.35)',
} as CSSProperties,
progressBar: {
height: '0.5dvh',
width: 'calc(100% - 2dvh)',
left: '1dvh',
position: 'absolute',
top: '1dvh',
zIndex: 1000,
} as CSSProperties,
}
type CardProps = {
announcement: Announcement
detailsShown: boolean
percentage: number
setDetailsShown: SetState<boolean>
}
const Card = ({
announcement,
detailsShown,
percentage,
setDetailsShown,
}: CardProps) => {
const swiperSlide = useSwiperSlide()
const swiper = useSwiper()
useEffect(() => {
if (detailsShown) swiper.autoplay.stop()
else swiper.autoplay.start()
}, [detailsShown])
return (
<>
<AnnouncementDetails
className="swiper-no-swiping"
show={detailsShown && swiperSlide.isActive}
announcement={announcement}
close={() => setDetailsShown(false)}
refresh={() => {}}
/>
<div style={styles.cardContainer}>
{swiperSlide.isActive && (
<div style={styles.progressBar}>
<div
style={{
background: 'white',
height: '100%',
width: (1 - percentage) * 100 + '%',
transition: 'width 0.1s linear',
}}
></div>
</div>
)}
<div className="swiper-zoom-container">
{announcement.src?.endsWith('mp4') ||
announcement.src?.endsWith('webm') ? (
<video
style={styles.cardMedia}
src={announcement.src}
autoPlay={swiperSlide.isActive}
/>
) : (
<img
style={styles.cardMedia}
src={
announcement.src ||
categoryGraphics[announcement.category]
}
loading="lazy"
/>
)}
</div>
<div style={styles.cardDetails}>
<Button
variant="success"
onClick={() => setDetailsShown(true)}
>
Подробности
</Button>
</div>
</div>
</>
)
} }
function HomePage() { function HomePage() {
const { height, width } = useStoryDimensions(16 / 9) const { height, width } = useStoryDimensions(16 / 9)
const [filterShown, setFilterShown] = useState(false) const [filterShown, setFilterShown] = useState(false)
const [detailsShown, setDetailsShown] = useState(false)
const [filter, setFilter] = useFilters() const [filter, setFilter] = useFilters()
const announcements = useAnnouncements(filter) const announcements = useAnnouncements(filter)
const stories = useMemo(() => fallbackGenerateStories(announcements), [announcements])
const index = useStoryIndex(announcements.data?.length) const index = useStoryIndex(announcements.data?.length)
const [autoplayPercentage, setAutoplayPercentage] = useState(0) return (<>
<Filters
return ( filter={filter}
<> setFilter={index.withReset(setFilter)}
<Filters filterShown={filterShown}
filter={filter} setFilterShown={setFilterShown}
setFilter={index.withReset(setFilter)} />
filterShown={filterShown} <div style={styles.container}>
setFilterShown={setFilterShown} <Stories
currentIndex={index.n % stories.length}
onStoryEnd={index.increment}
onNext={index.increment}
onPrevious={index.decrement}
stories={stories}
defaultInterval={11000}
height={height}
width={width}
loop={true}
keyboardNavigation={true}
/> />
<div style={styles.container}> </div>
{!announcements.loading && !gotError(announcements) && ( <BottomNavBar
<Swiper toggleFilters={setFilterShown}
autoplay={{ delay: 11000, stopOnLastSlide: true }} width={width}
direction="vertical" />
keyboard </>)
loop
modules={[
Autoplay,
Keyboard,
Mousewheel,
Virtual,
Zoom,
]}
mousewheel
onAutoplayTimeLeft={throttle(
(_: unknown, __: unknown, percentage: number) =>
setAutoplayPercentage(percentage),
100
)}
noSwiping={true}
onActiveIndexChange={(s) => index.set(s.activeIndex)}
slidesPerView={1}
spaceBetween={0}
virtual
zoom={{}}
initialSlide={index.n}
onZoomChange={(s, scale) => {
if (scale == 1) {
s.enable()
s.autoplay.start()
} else {
s.disable()
s.autoplay.stop()
}
}}
>
{announcements.data.map((announcement, i) => (
<SwiperSlide
key={announcement.id}
virtualIndex={i}
zoom
>
<Card
announcement={announcement}
detailsShown={detailsShown}
percentage={autoplayPercentage}
setDetailsShown={setDetailsShown}
/>
</SwiperSlide>
))}
</Swiper>
)}
</div>
<BottomNavBar toggleFilters={setFilterShown} width={width} />
</>
)
} }
export default HomePage export default HomePage

View File

@ -1,5 +1,6 @@
const isAborted = <T>(err: Error): err is AbortError<T> => const isAborted = <T>(err: Error): err is AbortError<T> => (
err.name === 'AbortError' err.name === 'AbortError'
)
type AbortErrorMessage = 'resent' | 'unmount' | 'cancel' type AbortErrorMessage = 'resent' | 'unmount' | 'cancel'
@ -29,27 +30,4 @@ function handleHTTPErrors(res: Response) {
} }
} }
const throttle = (fn: Function, wait: number = 300) => { export { isAborted, AbortError, handleHTTPErrors }
let inThrottle: boolean,
lastFn: ReturnType<typeof setTimeout>,
lastTime: number
return function (this: any) {
const context = this,
args = arguments
if (!inThrottle) {
fn.apply(context, args)
lastTime = Date.now()
inThrottle = true
} else {
clearTimeout(lastFn)
lastFn = setTimeout(() => {
if (Date.now() - lastTime >= wait) {
fn.apply(context, args)
lastTime = Date.now()
}
}, Math.max(wait - (Date.now() - lastTime), 0))
}
}
}
export { isAborted, AbortError, handleHTTPErrors, throttle }