mirror of
https://github.com/Stremio/stremio-web.git
synced 2026-03-11 21:27:05 +00:00
feat: add BottomSheet to calendar for mobile
This commit is contained in:
parent
076c1e0701
commit
0b70f67dd9
15 changed files with 359 additions and 22 deletions
10
package-lock.json
generated
10
package-lock.json
generated
|
|
@ -47,6 +47,7 @@
|
|||
"@babel/preset-env": "7.16.0",
|
||||
"@babel/preset-react": "7.16.0",
|
||||
"@types/react": "^18.2.9",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"babel-loader": "8.2.3",
|
||||
"clean-webpack-plugin": "4.0.0",
|
||||
"copy-webpack-plugin": "9.0.1",
|
||||
|
|
@ -3270,6 +3271,15 @@
|
|||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "18.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz",
|
||||
"integrity": "sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/resolve": {
|
||||
"version": "1.17.1",
|
||||
"dev": true,
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@
|
|||
"@babel/preset-env": "7.16.0",
|
||||
"@babel/preset-react": "7.16.0",
|
||||
"@types/react": "^18.2.9",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"babel-loader": "8.2.3",
|
||||
"clean-webpack-plugin": "4.0.0",
|
||||
"copy-webpack-plugin": "9.0.1",
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ html {
|
|||
min-height: 480px;
|
||||
font-family: 'PlusJakartaSans', 'sans-serif';
|
||||
overflow: auto;
|
||||
overscroll-behavior: none;
|
||||
|
||||
body {
|
||||
width: 100%;
|
||||
|
|
|
|||
90
src/common/BottomSheet/BottomSheet.less
Normal file
90
src/common/BottomSheet/BottomSheet.less
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
// Copyright (C) 2017-2024 Smart code 203358507
|
||||
|
||||
@import (reference) '~stremio/common/screen-sizes.less';
|
||||
|
||||
.bottom-sheet {
|
||||
z-index: 99;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
|
||||
.backdrop {
|
||||
z-index: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: var(--primary-background-color);
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.1s ease-out;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.container {
|
||||
z-index: 1;
|
||||
position: absolute;
|
||||
top: var(--horizontal-nav-bar-size);
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
border-radius: 2rem 2rem 0 0;
|
||||
background-color: var(--modal-background-color);
|
||||
box-shadow: var(--outer-glow);
|
||||
overflow: hidden;
|
||||
|
||||
&:not(.dragging) {
|
||||
transition: transform 0.1s ease-out;
|
||||
}
|
||||
|
||||
.heading {
|
||||
position: relative;
|
||||
|
||||
.handle {
|
||||
position: relative;
|
||||
height: 2.5rem;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
height: 0.3rem;
|
||||
width: 3rem;
|
||||
border-radius: 1rem;
|
||||
background-color: var(--primary-foreground-color);
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 1rem;
|
||||
padding-left: 1.5rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--primary-foreground-color);
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (min-width: @xsmall) {
|
||||
.bottom-sheet {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
83
src/common/BottomSheet/BottomSheet.tsx
Normal file
83
src/common/BottomSheet/BottomSheet.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
// Copyright (C) 2017-2024 Smart code 203358507
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import classNames from 'classnames';
|
||||
import useBinaryState from 'stremio/common/useBinaryState';
|
||||
import styles from './BottomSheet.less';
|
||||
|
||||
const CLOSE_THRESHOLD = 100;
|
||||
|
||||
type Props = {
|
||||
children: JSX.Element,
|
||||
title: string,
|
||||
show?: boolean,
|
||||
onClose: () => void,
|
||||
};
|
||||
|
||||
const BottomSheet = ({ children, title, show }: Props) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [startOffset, setStartOffset] = useState(0);
|
||||
const [offset, setOffset] = useState(0);
|
||||
|
||||
const [opened, open, close] = useBinaryState();
|
||||
|
||||
const containerStyle = useMemo(() => ({
|
||||
transform: `translateY(${offset}px)`
|
||||
}), [offset]);
|
||||
|
||||
const containerHeight = () => containerRef.current?.offsetHeight ?? 0;
|
||||
|
||||
const onClose = () => setOffset(containerHeight());
|
||||
|
||||
const onTouchStart = ({ touches }: React.TouchEvent<HTMLDivElement>) => {
|
||||
const { clientY } = touches[0];
|
||||
setStartOffset(clientY);
|
||||
};
|
||||
|
||||
const onTouchMove = useCallback(({ touches }: React.TouchEvent<HTMLDivElement>) => {
|
||||
const { clientY } = touches[0];
|
||||
setOffset(Math.max(0, clientY - startOffset));
|
||||
}, [startOffset]);
|
||||
|
||||
const onTouchEnd = useCallback(() => {
|
||||
setOffset((offset) => offset > CLOSE_THRESHOLD ? containerHeight() : 0);
|
||||
setStartOffset(0);
|
||||
}, []);
|
||||
|
||||
const onTransitionEnd = useCallback(() => {
|
||||
(offset === containerHeight()) && close();
|
||||
}, [offset]);
|
||||
|
||||
useEffect(() => {
|
||||
setOffset(0);
|
||||
show ? open() : close();
|
||||
}, [show]);
|
||||
|
||||
return opened && createPortal((
|
||||
<div className={styles['bottom-sheet']}>
|
||||
<div className={styles['backdrop']} onClick={onClose} />
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={classNames(styles['container'], { [styles['dragging']]: startOffset }, 'animation-slide-up')}
|
||||
style={containerStyle}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
onTransitionEnd={onTransitionEnd}
|
||||
>
|
||||
<div className={styles['heading']}>
|
||||
<div className={styles['handle']} />
|
||||
<div className={styles['title']}>
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles['content']} onClick={onClose}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
), document.body);
|
||||
};
|
||||
|
||||
export default BottomSheet;
|
||||
4
src/common/BottomSheet/index.ts
Normal file
4
src/common/BottomSheet/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
// Copyright (C) 2017-2024 Smart code 203358507
|
||||
|
||||
import BottomSheet from './BottomSheet';
|
||||
export default BottomSheet;
|
||||
|
|
@ -19,4 +19,23 @@
|
|||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
:global(.animation-slide-up) {
|
||||
:local {
|
||||
animation-name: slide-up;
|
||||
}
|
||||
|
||||
animation-timing-function: ease-out;
|
||||
animation-duration: 0.1s;
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
0% {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateY(0%);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
const AddonDetailsModal = require('./AddonDetailsModal');
|
||||
const { default: BottomSheet } = require('./BottomSheet');
|
||||
const Button = require('./Button');
|
||||
const Checkbox = require('./Checkbox');
|
||||
const { default: Chips } = require('./Chips');
|
||||
|
|
@ -50,6 +51,7 @@ const EventModal = require('./EventModal');
|
|||
|
||||
module.exports = {
|
||||
AddonDetailsModal,
|
||||
BottomSheet,
|
||||
Button,
|
||||
Checkbox,
|
||||
Chips,
|
||||
|
|
|
|||
8
src/common/useBinaryState.d.ts
vendored
Normal file
8
src/common/useBinaryState.d.ts
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
declare const useBinaryState: () => [
|
||||
boolean,
|
||||
() => void,
|
||||
() => void,
|
||||
() => void,
|
||||
];
|
||||
|
||||
export = useBinaryState;
|
||||
|
|
@ -1,11 +1,13 @@
|
|||
// Copyright (C) 2017-2024 Smart code 203358507
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { MainNavBars, PaginationInput, useProfile, withCoreSuspender } from 'stremio/common';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { MainNavBars, PaginationInput, BottomSheet, useProfile, withCoreSuspender } from 'stremio/common';
|
||||
import Table from './Table';
|
||||
import List from './List';
|
||||
import Details from './Details';
|
||||
import Placeholder from './Placeholder';
|
||||
import useCalendar from './useCalendar';
|
||||
import useCalendarDate from './useCalendarDate';
|
||||
import useSelectableInputs from './useSelectableInputs';
|
||||
import styles from './Calendar.less';
|
||||
|
||||
|
|
@ -16,11 +18,14 @@ type Props = {
|
|||
const Calendar = ({ urlParams }: Props) => {
|
||||
const calendar = useCalendar(urlParams);
|
||||
const profile = useProfile();
|
||||
|
||||
|
||||
const [paginationInput] = useSelectableInputs(calendar, profile);
|
||||
const { toDayMonth } = useCalendarDate(profile);
|
||||
|
||||
const [selected, setSelected] = useState<CalendarDate | null>(null);
|
||||
|
||||
const detailsTitle = useMemo(() => toDayMonth(selected), [selected]);
|
||||
|
||||
return (
|
||||
<MainNavBars className={styles['calendar']} route={'calendar'}>
|
||||
{
|
||||
|
|
@ -49,6 +54,12 @@ const Calendar = ({ urlParams }: Props) => {
|
|||
profile={profile}
|
||||
onChange={setSelected}
|
||||
/>
|
||||
<BottomSheet title={detailsTitle} show={selected}>
|
||||
<Details
|
||||
selected={selected}
|
||||
items={calendar.items}
|
||||
/>
|
||||
</BottomSheet>
|
||||
</div>
|
||||
:
|
||||
<Placeholder />
|
||||
|
|
|
|||
59
src/routes/Calendar/Details/Details.less
Normal file
59
src/routes/Calendar/Details/Details.less
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
// Copyright (C) 2017-2024 Smart code 203358507
|
||||
|
||||
.details {
|
||||
position: relative;
|
||||
|
||||
.video {
|
||||
flex: none;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0 1.5rem;
|
||||
height: 4rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: var(--primary-foreground-color);
|
||||
|
||||
.name {
|
||||
flex: auto;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.info {
|
||||
flex: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.icon {
|
||||
flex: none;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 50%;
|
||||
color: var(--primary-foreground-color);
|
||||
}
|
||||
|
||||
&:hover, &:active {
|
||||
background-color: var(--overlay-color);
|
||||
|
||||
.icon {
|
||||
display: block;
|
||||
background-color: var(--secondary-accent-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 10rem;
|
||||
font-size: 1rem;
|
||||
color: var(--primary-foreground-color);
|
||||
}
|
||||
}
|
||||
45
src/routes/Calendar/Details/Details.tsx
Normal file
45
src/routes/Calendar/Details/Details.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
// Copyright (C) 2017-2024 Smart code 203358507
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import Icon from '@stremio/stremio-icons/react';
|
||||
import Button from 'stremio/common/Button';
|
||||
import styles from './Details.less';
|
||||
|
||||
type Props = {
|
||||
selected: CalendarDate | null,
|
||||
items: CalendarItem[],
|
||||
};
|
||||
|
||||
const Details = ({ selected, items }: Props) => {
|
||||
const videos = useMemo(() => {
|
||||
return items.find(({ date }) => date.day === selected?.day)?.items ?? [];
|
||||
}, [selected, items]);
|
||||
|
||||
return (
|
||||
<div className={styles['details']}>
|
||||
{
|
||||
videos.map(({ id, name, season, episode, deepLinks }) => (
|
||||
<Button className={styles['video']} key={id} href={deepLinks.metaDetailsStreams}>
|
||||
<div className={styles['name']}>
|
||||
{name}
|
||||
</div>
|
||||
<div className={styles['info']}>
|
||||
S{season}E{episode}
|
||||
</div>
|
||||
<Icon className={styles['icon']} name={'play'} />
|
||||
</Button>
|
||||
))
|
||||
}
|
||||
{
|
||||
!videos.length ?
|
||||
<div className={styles['placeholder']}>
|
||||
No new episodes for this day
|
||||
</div>
|
||||
:
|
||||
null
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Details;
|
||||
4
src/routes/Calendar/Details/index.ts
Normal file
4
src/routes/Calendar/Details/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
// Copyright (C) 2017-2024 Smart code 203358507
|
||||
|
||||
import Details from './Details';
|
||||
export default Details;
|
||||
|
|
@ -105,6 +105,16 @@
|
|||
}
|
||||
}
|
||||
|
||||
.more {
|
||||
display: none;
|
||||
flex: none;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0.5rem;
|
||||
align-self: center;
|
||||
color: var(--primary-foreground-color);
|
||||
}
|
||||
|
||||
&.today {
|
||||
.heading {
|
||||
.day {
|
||||
|
|
@ -128,26 +138,14 @@
|
|||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-height: @small) {
|
||||
.cell {
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-height: @xxsmall) {
|
||||
@media only screen and (orientation: portrait) {
|
||||
.cell {
|
||||
.items {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: @minimum) and (orientation: portrait) {
|
||||
.cell {
|
||||
.items {
|
||||
.item {
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
.more {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -160,9 +158,5 @@
|
|||
.heading {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.items {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,12 @@ const Cell = ({ selected, monthInfo, date, items, onClick }: Props) => {
|
|||
))
|
||||
}
|
||||
</HorizontalScroll>
|
||||
{
|
||||
items.length > 0 ?
|
||||
<Icon className={styles['more']} name={'more-horizontal'} />
|
||||
:
|
||||
null
|
||||
}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue