feat: add BottomSheet to calendar for mobile

This commit is contained in:
Tim 2024-09-23 14:28:06 +02:00
parent 076c1e0701
commit 0b70f67dd9
15 changed files with 359 additions and 22 deletions

10
package-lock.json generated
View file

@ -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,

View file

@ -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",

View file

@ -91,6 +91,7 @@ html {
min-height: 480px;
font-family: 'PlusJakartaSans', 'sans-serif';
overflow: auto;
overscroll-behavior: none;
body {
width: 100%;

View 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;
}
}

View 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;

View file

@ -0,0 +1,4 @@
// Copyright (C) 2017-2024 Smart code 203358507
import BottomSheet from './BottomSheet';
export default BottomSheet;

View file

@ -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%);
}
}

View file

@ -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
View file

@ -0,0 +1,8 @@
declare const useBinaryState: () => [
boolean,
() => void,
() => void,
() => void,
];
export = useBinaryState;

View file

@ -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 />

View 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);
}
}

View 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;

View file

@ -0,0 +1,4 @@
// Copyright (C) 2017-2024 Smart code 203358507
import Details from './Details';
export default Details;

View file

@ -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;
}
}
}

View file

@ -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>
);
};