added cal event generation, lots of styling

This commit is contained in:
sunda 2021-10-25 16:56:04 +02:00
parent b932d96d80
commit e6e05d90ef
18 changed files with 368 additions and 55 deletions

View File

@ -1,27 +1,56 @@
import { h, render } from 'preact'
import { useState } from 'preact/hooks'
import { Fragment, h, render } from 'preact'
import { useEffect, useState } from 'preact/hooks'
import { ThemeProvider } from 'styled-components'
import { BrowserRouter, Route, Switch } from 'react-router-dom'
import { isWithinInterval, subHours, addHours } from 'date-fns'
import { zonedTimeToUtc, utcToZonedTime } from 'date-fns-tz'
import Main from './app'
import SeriesPage from './src/pages/SeriesPage'
import { useEventApi } from './src/hooks/data'
import { useTheme } from './src/store'
import { useEventApi, usePeertubeApi } from './src/hooks/data'
import { useStreamStore, useTheme } from './src/store'
import { useTimeout } from './src/hooks/timerHooks'
import LoaderLayout from './src/pages/LoaderLayout'
import FourOhFour from './src/pages/404'
import Series from './src/pages/Series'
import Program from './src/pages/Program'
import StreamPreview from './src/components/StreamPreview'
const App = () => {
const { theme } = useTheme((store) => store)
const { data, loading: eventsLoading } = useEventApi()
const [minLoadTimePassed, setMinTimeUp] = useState(false)
const { setCurrentStream, currentStream, streamIsLive } = useStreamStore(store => store)
usePeertubeApi(data.episodes)
useTimeout(() => {
setMinTimeUp(true)
}, 1500)
useEffect(() => {
if (Array.isArray(data.episodes)) {
data.episodes.forEach(stream => {
const utcStartDate = zonedTimeToUtc(
new Date(stream.beginsOn),
'Europe/Berlin'
)
const utcEndDate = zonedTimeToUtc(new Date(stream.endsOn), 'Europe/Berlin')
const { timeZone } = Intl.DateTimeFormat().resolvedOptions()
const zonedStartDate = utcToZonedTime(utcStartDate, timeZone)
const zonedEndDate = utcToZonedTime(utcEndDate, timeZone)
if (
isWithinInterval(new Date(), {
start: subHours(zonedStartDate, 1),
end: addHours(zonedEndDate, 1),
})
) {
setCurrentStream(stream)
}
})
}
}, [eventsLoading])
// console.log({ episodes: data.episodes, series: data.series })
const seriesData = data.series ? Object.values(data.series) : []
@ -32,20 +61,23 @@ const App = () => {
{!seriesData.length || eventsLoading || !minLoadTimePassed ? (
<LoaderLayout />
) : (
<BrowserRouter>
<Switch>
<Route exact path="/" component={Main} />
<Route exact path="/series" component={Series} />
<Route exact path="/program" component={Program} />
{seriesData.length ? seriesData.map(series => (
<Route exact path={`/series/${series.slug}`}>
<SeriesPage data={series} />
</Route>)) : null}
<Route path="*">
<FourOhFour />
</Route>
</Switch>
</BrowserRouter>
<Fragment>
<BrowserRouter>
<Switch>
<Route exact path="/" component={Main} />
<Route exact path="/series" component={Series} />
<Route exact path="/program" component={Program} />
{seriesData.length ? seriesData.map(series => (
<Route exact path={`/series/${series.slug}`}>
<SeriesPage data={series} />
</Route>)) : null}
<Route path="*">
<FourOhFour />
</Route>
</Switch>
</BrowserRouter>
<StreamPreview stream={currentStream} isLive={streamIsLive} />
</Fragment>
)}
</ThemeProvider>)
}

View File

@ -19,6 +19,7 @@
"axios": "^0.21.1",
"date-fns": "^2.19.0",
"date-fns-tz": "^1.1.4",
"datebook": "^7.0.7",
"dotenv": "^10.0.0",
"ical": "^0.8.0",
"ical.js": "^1.4.0",

View File

@ -31,6 +31,7 @@ export const screenSizes = {
sm: 800,
md: 1000,
lg: 1500,
xl: 1700,
}
export const defaultTheme = {

View File

@ -1,26 +1,37 @@
import { h } from 'preact'
import { ICalendar } from 'datebook'
import { format } from 'date-fns'
import striptags from 'striptags'
import Link from '../Link'
import { H2, H3, Label } from '../Text'
import strings from '../../data/strings'
import { andList } from '../../helpers/string'
import { colours } from '../../assets/theme'
import { Img, Left, Right, Center, Title, Row, Column, StyledButton as Button } from './styles'
import { colours, screenSizes } from '../../assets/theme'
import { Img, Left, Right, Center, Title, ButtonRow, StyledButton as Button } from './styles'
import { useEventApi } from '../../hooks/data'
import { useWindowSize } from '../../hooks/dom'
import Flex from '../Flex'
const EpisodeCard = ({ image, title, seriesId, beginsOn, id, ...rest }) => {
const EpisodeCard = ({ image, title, seriesId, beginsOn, endsOn, id, url, description, ...rest }) => {
const { data: { series: allSeries } } = useEventApi()
const series = seriesId ? allSeries.filter(({ id }) => id === seriesId)[0] : {}
const hosts = series.hosts ? series.hosts.map(host => host.actor.name) : null
const startTime = format(new Date(beginsOn), 'ha')
const startTime = format(new Date(beginsOn), 'h:mma')
const { width: screenWidth } = useWindowSize()
const isMobile = screenWidth < screenSizes.md
return (
<Row align="stretch" {...rest}>
<Flex align="stretch" direction={isMobile ? 'column' : 'row'} {...rest}>
<Left>
<Link to={`/series/${series.slug}#${id}`}><Img src={image} /></Link>
<Button>Play now</Button>
<Link to={`/series/${series.slug}#${id}`}>
<Img src={image} />
</Link>
<ButtonsRows title={title} description={description} beginsOn={beginsOn} endsOn={endsOn} url={url} />
</Left>
<Center justify="space-betweeen">
<Title size={24} colour={colours.rose} weight="500">{title}</Title>
@ -34,9 +45,28 @@ const EpisodeCard = ({ image, title, seriesId, beginsOn, id, ...rest }) => {
<Right>
<Label size={24} colour={colours.rose} weight="600">{startTime}</Label>
</Right>
</Row>
</Flex>
)
}
export const ButtonsRows = ({ title, description, beginsOn, endsOn, url }) => {
const icalendar = new ICalendar({
title,
location: 'https://stream.undersco.re/',
description: description ? striptags(description) : '',
start: new Date(beginsOn),
end: new Date(endsOn),
})
const dlIcal = () => icalendar.download()
return (
<ButtonRow justify="space-between">
<Button onClick={dlIcal}>{strings.en.subEvent}</Button>
<a href={url} target="_blank" rel="noopener noreferrer">
<Button>{strings.en.eventDetails}</Button>
</a>
</ButtonRow>)
}
export default EpisodeCard

View File

@ -1,43 +1,92 @@
import styled from 'styled-components'
import { colours } from '../../assets/theme'
import { colours, screenSizes } from '../../assets/theme'
import { Label, H2 } from '../Text'
import { Row as FlexRow, Column as FlexColumn } from '../Flex'
import Flexbox, { Row as FlexRow, Column as FlexColumn } from '../Flex'
import Button from '../Button'
export const Row = styled(FlexRow)`
export const ButtonRow = styled(Flexbox)`
width: 100%;
align-items: stretch;
button, a{
font-size: 16px;
width: 49%;
height: 100%;
}
a button {
width: 100%;
}
@media screen and (min-width: ${screenSizes.md}px) and (max-width: ${screenSizes.lg}px) {
flex-direction: column;
button, a {
width: 100%;
}
}
`
export const Column = styled(FlexColumn)`
`
export const Left = styled(FlexColumn)`
margin-right: 1em;
width: 20vw;
@media screen and (max-width: ${screenSizes.md}px) {
width: 80vw;
margin-right: 0em;
}
`
export const Center = styled(FlexColumn)`
max-width: 60%;
`
@media screen and (max-width: ${screenSizes.md}px) {
order: 2;
}
`
export const Title = styled(H2)`
max-width: 60%;
@media screen and (max-width: ${screenSizes.md}px) {
max-width: 80%;
}
@media screen and (max-width: ${screenSizes.sm}px) {
max-width: 70%;
}
margin-bottom: 1em;
`
`
export const Right = styled.div`
flex: 1;
text-align: right;
@media screen and (max-width: ${screenSizes.md}px) {
position: relative;
top: 1em;
order: 1;
}
`
export const StyledButton = styled(Button)`
/* width: max-content; */
margin-top: 0.5em;
padding: 0.3em 2em;
@media screen and (max-width: ${screenSizes.md}px) {
margin: 0.5em 0;
}
`
export const Img = styled.div`
background: url(${({ src }) => src});
width: 25vw;
/* height: 215px; */
width: 20vw;
padding-bottom: calc((9 / 16) * 100%);
background-size: cover;
position: relative;
background-position: center;
@media screen and (max-width: ${screenSizes.md}px) {
width: 80vw;
}
`

View File

@ -1,6 +1,16 @@
import { bool, number, oneOf } from 'prop-types'
import styled from 'styled-components'
const Flexbox = styled.div`
display: flex;
flex-direction: ${props => (props.direction || 'row')};
justify-content: ${props => props.justify || 'flex-start'};
align-items: ${props => props.align || 'flex-start'};
${props => props.flex ? `
flex: ${props.flex};
` : ''}
`
export const Row = styled.div`
display: flex;
flex-direction: ${props => (props.reverse ? 'row-reverse' : 'row')};
@ -28,4 +38,6 @@ const propTypes = {
}
Row.propTypes = propTypes
Column.propTypes = propTypes
Column.propTypes = propTypes
export default Flexbox

View File

@ -0,0 +1,8 @@
import strings from '../../data/strings'
export const getLabel = (stream, isLive, isMinimized) => {
const currentLanguage = 'en'
const prefix = isLive ? strings[currentLanguage].nowPlaying : strings[currentLanguage].startingSoon
if (isMinimized) return `${prefix}: ${stream.title}`
return prefix
}

View File

@ -0,0 +1,65 @@
import { Fragment, h } from 'preact'
import { useEffect, useRef } from 'preact/hooks'
import { PeerTubePlayer } from '@peertube/embed-api'
import { string } from 'prop-types'
import Link from '../Link'
import { Label } from '../Text'
import strings from '../../data/strings'
import { Box, Img, Iframe } from './styles'
import { colours, textSizes } from '../../assets/theme'
import { Row } from '../Flex'
import CrossSvg from '../Svg/Cross'
import { useUiStore } from '../../store'
import { getLabel } from './helpers'
import Chevron from '../Svg/Chevron'
// import { useEventApi } from '../../hooks/data'
const StreamPreview = ({ stream, isLive, ...rest }) => {
const currentLanguage = 'en'
const videoiFrame = useRef(null)
const ptVideo = useRef(null)
const { isMinimized, toggleMinimized } = useUiStore(store => ({ isMinimized: store.streamPreviewMinimized, toggleMinimized: store.toggleStreamPreviewMinimized }))
useEffect(() => {
const setupAndPlayVideo = async () => {
const player = new PeerTubePlayer(videoiFrame.current)
await player.ready
ptVideo.current = player
player.setVolume(0)
player.play()
}
if (isLive) {
setupAndPlayVideo()
}
}, [isLive, isMinimized])
return stream ? (
<Box isMinimized={isMinimized}>
<Row justify="space-between">
<Label colour={colours.midnightDarker} size={textSizes.lg}>{getLabel(stream, isLive, isMinimized)}</Label>
{isMinimized ? <Chevron colour={colours.midnightDarker} size={14} onClick={toggleMinimized} /> : <CrossSvg colour={colours.midnightDarker} size={16} onClick={toggleMinimized} />}
</Row>
{!isMinimized ?
<Fragment>
{isLive ?
<Iframe
width="560"
height="315"
sandbox="allow-same-origin allow-scripts allow-popups"
autoplay="autoplay"
muted="muted"
src={`https://tv.undersco.re${stream.embedPath}?api=1&controls=false`}
frameBorder="0"
allowFullScreen
ref={videoiFrame}
/>
: <Img src={stream.image} />}
</Fragment> : null}
</Box>
) : null
}
export default StreamPreview

View File

@ -0,0 +1,57 @@
import styled from 'styled-components'
import { colours, screenSizes } from '../../assets/theme'
export const Box = styled.div`
position: fixed;
bottom: ${props => props.isMinimized ? 0 : '2em'};
right: ${props => props.isMinimized ? 0 : '2em'};
background-color: ${colours.white};
padding: ${props => props.isMinimized ? '0.2em 0.2em' : '0.5em 0.5em'};
display: flex;
flex-direction: column;
label {
display: inline-block;
margin-bottom: ${props => props.isMinimized ? 0 : '0.5em'};
margin-right: ${props => props.isMinimized ? '0.5em' : 0};
font-size: ${props => props.isMinimized ? '15' : '21'}px;
}
@media screen and (max-width: ${screenSizes.xs}px) {
bottom: 0em;
right: 0em;
}
`
export const Img = styled.div`
background: url(${({ src }) => src});
width: 16vw;
padding-bottom: calc((9 / 16) * 100%);
background-size: cover;
position: relative;
background-position: center;
@media screen and (max-width: ${screenSizes.xl}px) {
width: 20vw;
}
@media screen and (max-width: ${screenSizes.lg}px) {
width: 25vw;
}
@media screen and (max-width: ${screenSizes.md}px) {
width: 33vw;
}
@media screen and (max-width: ${screenSizes.sm}px) {
width: 50vw;
}
@media screen and (max-width: ${screenSizes.xs}px) {
width: 60vw;
}
`
export const Iframe = styled.iframe`
width: 20vw;
height: 11.2vw;
`

View File

@ -1,8 +1,9 @@
import { h } from 'preact'
import { Svg } from './base'
import { svgPropTypes } from './proptypes'
const Chevron = ({ colour = 'inherit', size, ...rest }) => (
<svg
<Svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
height={size}
@ -15,7 +16,7 @@ const Chevron = ({ colour = 'inherit', size, ...rest }) => (
d="M49.68 19.005l.002-.003 46.733 46.733-14.849 14.85-31.889-31.89-30.889 30.89L3.94 64.735 49.675 19l.005.005z"
clipRule="evenodd"
/>
</svg>
</Svg>
)
Chevron.propTypes = {

View File

@ -1,9 +1,10 @@
import { h } from 'preact'
import { Svg } from './base'
import { svgPropTypes } from './proptypes'
const Cross = ({ colour = 'inherit', size, ...rest }) => (
<svg viewBox="0 0 100 100" height={size} {...rest}>
<Svg viewBox="0 0 100 100" height={size} {...rest}>
<path
stroke={colour}
strokeLinecap="none"
@ -11,7 +12,7 @@ const Cross = ({ colour = 'inherit', size, ...rest }) => (
strokeWidth="18"
d="M11.354 11.757l77.637 77.636m-77.627 0L89 11.757"
/>
</svg>
</Svg>
)
Cross.propTypes = {

View File

@ -1,15 +1,16 @@
import { h } from 'preact'
import { number, string } from 'prop-types'
import { Svg } from './base'
const Play = ({ size = '24', colour = 'inherit', ...rest }) => (
<svg viewBox="0 0 24 24" height={size} width={size} {...rest}>
<Svg viewBox="0 0 24 24" height={size} width={size} {...rest}>
<path
strokeWidth="1.5"
stroke={colour}
fill="transparent"
d="M2.12436,1.73205 C2.12436,0.96225 2.95769,0.481125 3.62436,0.866025 L21.6244,11.2583 C22.291,11.6432 22.291,12.6055 21.6244,12.9904 L3.62436,23.3827 C2.95769,23.7676 2.12436,23.2865 2.12436,22.5167 L2.12436,1.73205 Z"
/>
</svg>
</Svg>
)
Play.propTypes = {

View File

@ -0,0 +1,7 @@
import styled from 'styled-components'
export const Svg = styled.svg`
${props => props.onClick ? `
cursor: pointer;
` : ''}
`

View File

@ -2,7 +2,8 @@ export default {
en: {
program: 'Program',
pastStream: 'Previous Episodes',
nowPlaying: 'Now playing',
nowPlaying: 'Currently streaming',
startingSoon: 'Starting soon',
noStreams: 'No upcoming streams, check back soon.',
underscoreTagline: ['LEAVE THE', 'SURVEILLANCE ECONOMY', '— TOGETHER.'],
streamDateFuture: 'Going live at: ',
@ -21,6 +22,7 @@ export default {
nextStream: 'Next stream',
episodes: 'episodes',
today: 'today',
tomorrow: 'tomorrow'
tomorrow: 'tomorrow',
eventDetails: 'Event details',
},
}

View File

@ -1,9 +1,12 @@
import { h, render } from 'preact'
import { useEffect, useState } from 'preact/hooks'
import axios from 'axios'
import ICAL from 'ical.js'
import config from '../data/config'
import { useSeriesStore } from '../store/index'
import { useSeriesStore, useStreamStore } from '../store/index'
import 'regenerator-runtime/runtime'
import { useInterval } from './timerHooks'
import { secondsToMilliseconds } from 'date-fns'
export const useEventCalendar = () => {
const [data, setData] = useState([])
@ -112,6 +115,7 @@ export const useEventApi = () => {
setData(responseData)
console.log({ data: responseData })
setLoading(false)
}
}
@ -121,4 +125,31 @@ export const useEventApi = () => {
}, [])
return { loading, data }
}
export const usePeertubeApi = async () => {
const { currentStream, setCurrentStream, setStreamIsLive, streamIsLive } = useStreamStore(store => store)
if (!currentStream) return
const fetchData = async () => {
if (!currentStream.peertubeId) return
const { peertubeId } = currentStream
const {
data: { state, embedPath }
} = await axios.get(`https://tv.undersco.re/api/v1/videos/${peertubeId}`)
setStreamIsLive(state.id === 1)
setCurrentStream({ ...currentStream, embedPath })
}
useEffect(() => {
fetchData()
}, [])
useInterval(() => {
fetchData()
}, streamIsLive ? secondsToMilliseconds(15) : secondsToMilliseconds(1))
}

View File

@ -8,7 +8,8 @@ import Header, { NavigationModal as MenuModal } from '../../components/Header'
import { capitaliseFirstLetter } from '../../helpers/string'
import { defaultTheme } from '../../assets/theme'
import { ThemedBlock } from './styles'
import { useTheme, useUiStore } from '../../store'
import { useStreamStore, useTheme, useUiStore } from '../../store'
import StreamPreview from '../../components/StreamPreview'
const Page = ({ children, title = '', description, metaImg, backTo, noindex, withHeader = true, theme = defaultTheme }) => {
const { setTheme } = useTheme(store => store)

View File

@ -14,6 +14,7 @@ import { H1, H2, Span, Label } from '../../components/Text'
import Link from '../../components/Link'
import Button from '../../components/Button'
import { slugify } from '../../helpers/string'
import { ButtonsRows } from '../../components/EpisodeCard'
export const TrailerContainer = styled.div`
height: 22em;
@ -143,6 +144,8 @@ export const EpisodeCard = ({
image,
description,
beginsOn,
endsOn,
url,
hasPassed,
videoUrl,
onClickButton,
@ -180,15 +183,7 @@ export const EpisodeCard = ({
{hasPassed ? (
<Button onClick={onClickButton}>{translations.en.watchEpisode}</Button>
) : (
<a
href={
hasPassed
? videoUrl
: `webcal://cloud.undersco.re/remote.php/dav/public-calendars/${config.calendarId}/?export`
}
>
<Button>{translations.en.subEvent}</Button>
</a>
<ButtonsRows title={title} description={description} beginsOn={beginsOn} endsOn={endsOn} url={url} />
)}
</VCWrapper>
)

View File

@ -4,6 +4,8 @@ import { defaultTheme } from '../assets/theme'
export const useSeriesStore = create((set, get) => ({
series: {},
episodes: [],
// Methods
setSeries: series => set({ series }),
setEpisodes: () => {
if (get().series) {
@ -16,11 +18,28 @@ export const useSeriesStore = create((set, get) => ({
export const [useTheme] = create(set => ({
theme: defaultTheme,
// Methods
setTheme: (theme) => set({ theme }),
setDefaultTheme: () => set({ theme: defaultTheme })
}))
export const [useUiStore] = create((set, get) => ({
mobileMenuOpen: false,
streamPreviewMinimized: false,
streamActive: false,
// Methods
toggleMobileMenu: () => set({ mobileMenuOpen: !get().mobileMenuOpen }),
toggleStreamPreviewMinimized: () => set({ streamPreviewMinimized: !get().streamPreviewMinimized }),
toggleStreamActive: () => set({ streamActive: !get().streamActive }),
}))
export const [useStreamStore] = create((set) => ({
currentStream: null,
streamIsLive: false,
// Methods
setCurrentStream: (currentStream) => set({ currentStream }),
setStreamIsLive: (streamIsLive) => set({ streamIsLive }),
}))