diff --git a/.commitlintrc.js b/.commitlintrc.js index 0c0c8be0b..723a4b1a5 100644 --- a/.commitlintrc.js +++ b/.commitlintrc.js @@ -1,11 +1,12 @@ module.exports = { - extends: ['@commitlint/config-conventional'], - rules : { - 'scope-enum': [ - 2, 'always', [ - 'project', - ], + extends: ['@commitlint/config-conventional'], + rules: { + 'scope-enum': [ + 2, 'always', [ + 'project', + 'home', + 'playlist' ], - }, - }; - \ No newline at end of file + ], + }, +}; diff --git a/public/config.json b/public/config.json index 54a4b344f..40a6de93c 100644 --- a/public/config.json +++ b/public/config.json @@ -14,19 +14,13 @@ "content": [ { "playlistId": "WXu7kuaW", - "featured": true, - "enableText": false + "featured": true }, { - "playlistId": "lrYLc95e", - "aspectratio": "23", - "cols": {"xs": 2, "sm": 3, "md": 4, "lg": 5, "xl": 6} + "playlistId": "lrYLc95e" }, { - "playlistId": "Q352cyuc", - "type": "grid", - "enableSeeAll": true, - "rows": 1 + "playlistId": "Q352cyuc" }, { "playlistId": "oR7ahO0J" diff --git a/src/components/Shelf/Shelf.module.scss b/src/components/Shelf/Shelf.module.scss new file mode 100644 index 000000000..959a1878b --- /dev/null +++ b/src/components/Shelf/Shelf.module.scss @@ -0,0 +1,2 @@ +@use '../../styles/variables'; +@use '../../styles/theme'; diff --git a/src/components/Shelf/Shelf.test.tsx b/src/components/Shelf/Shelf.test.tsx new file mode 100644 index 000000000..4ea25a6d3 --- /dev/null +++ b/src/components/Shelf/Shelf.test.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; + +import Shelf from './Shelf'; + +describe('FeaturedShelf Component tests', () => { + test.skip('dummy test', () => { + render(); + expect(screen.getByText('hello world')).toBeInTheDocument(); + }); +}); diff --git a/src/components/Shelf/Shelf.tsx b/src/components/Shelf/Shelf.tsx new file mode 100644 index 000000000..b2dc35b9f --- /dev/null +++ b/src/components/Shelf/Shelf.tsx @@ -0,0 +1,101 @@ +import React, { useContext } from 'react'; +import type { Config } from 'types/Config'; + +import { ConfigContext } from '../../providers/configProvider'; +import TileDock from '../TileDock/TileDock'; + +import styles from './Shelf.module.scss'; + +export type Image = { + src: string; + type: string; + width: number; +}; + +export type ShelfProps = { + title: string; + playlist: string[]; + featured: boolean; +}; + +export type Source = { + file: string; + type: string; +}; + +export type Track = { + file: string; + kind: string; + label: string; +}; + +export type Item = { + description: string; + duration: number; + feedid: string; + image: string; + images: Image[]; + junction_id: string; + link: string; + mediaid: string; + pubdate: number; + sources: Source[]; + tags: string; + title: string; + tracks: Track[]; + variations: Record; +}; + +const Shelf: React.FC = ({ + title, + playlist, + featured, +}: ShelfProps) => { + const config: Config = useContext(ConfigContext); + + return ( +
+

+ Playlist {title} {featured} +

+ ( + + )} + renderRightControl={(handleClick) => ( + + )} + renderTile={(item: unknown) => { + return ( +
+
+
+
+ )}} + /> +
+ ); +}; + +export default Shelf; diff --git a/src/components/TileDock/TileDock.css b/src/components/TileDock/TileDock.css new file mode 100644 index 000000000..146948425 --- /dev/null +++ b/src/components/TileDock/TileDock.css @@ -0,0 +1,35 @@ +.tileDock { + overflow: hidden; + position: relative; +} +.tileDock ul { + display: block; + white-space: nowrap; + margin: 0; + padding: 0; +} +.tileDock li { + display: inline-block; + list-style-type: none; + white-space: normal; +} +.tileDock .offsetTile { + position: absolute; + top: 0px; + left: 0px; + width: 100%; + height: 100%; +} +.tileDock .leftControl { + left: 0px; + position: absolute; + top: 50%; + transform: translateY(-100%); + z-index: 1; +} +.tileDock .rightControl { + position: absolute; + right: 0px; + top: 50%; + transform: translateY(-100%); +} diff --git a/src/components/TileDock/TileDock.tsx b/src/components/TileDock/TileDock.tsx new file mode 100644 index 000000000..0c38a2fdf --- /dev/null +++ b/src/components/TileDock/TileDock.tsx @@ -0,0 +1,196 @@ +import React, { useLayoutEffect, useRef, useState } from 'react'; +import './TileDock.css'; + +export type CycleMode = 'stop' | 'restart' | 'endless'; +type Direction = 'left' | 'right'; +type Position = { x: number; y: number; }; + +export type TileDockProps = { + items: unknown[]; + cycleMode?: CycleMode; + tilesToShow?: number; + spacing?: number; + tileHeight?: number; + minimalTouchMovement?: number; + showControls?: boolean; + animated?: boolean; + transitionTime?: string; + renderTile: (item: unknown, index: number) => JSX.Element; + renderLeftControl?: (handleClick: () => void) => JSX.Element; + renderRightControl?: (handleClick: () => void) => JSX.Element; +}; + +const TileDock = ({ + items, + tilesToShow = 6, + cycleMode = 'endless', + spacing = 12, + tileHeight = 300, + minimalTouchMovement = 30, + showControls = true, + animated = !window.matchMedia('(prefers-reduced-motion)').matches, + transitionTime = '0.6s', + renderTile, + renderLeftControl, + renderRightControl, +}: TileDockProps) => { + const [index, setIndex] = useState(0); + const [slideToIndex, setSlideToIndex] = useState(0); + const [transform, setTransform] = useState(-100); + const [doAnimationReset, setDoAnimationReset] = useState(false); + const [touchPosition, setTouchPosition] = useState({ x: 0, y: 0 } as Position); + const frameRef = useRef() as React.MutableRefObject; + const tilesToShowRounded: number = Math.floor(tilesToShow); + const offset: number = Math.round((tilesToShow - tilesToShowRounded) * 10) / 10; + const offsetCompensation: number = offset ? 1 : 0; + const tileWidth: number = 100 / (tilesToShowRounded + offset * 2); + const isMultiPage: boolean = items.length > tilesToShowRounded; + const transformWithOffset: number = isMultiPage ? 100 - tileWidth * (tilesToShowRounded + offsetCompensation - offset) + transform : 0; + + const sliceItems = (items: unknown[]): unknown[] => { + const sliceFrom: number = index; + const sliceTo: number = index + tilesToShowRounded * 3 + offsetCompensation * 2; + const cycleModeEndlessCompensation: number = cycleMode === 'endless' ? tilesToShowRounded : 0; + const listStartClone: unknown[] = items.slice(0, tilesToShowRounded + cycleModeEndlessCompensation + offsetCompensation,); + const listEndClone: unknown[] = items.slice(0 - (tilesToShowRounded + offsetCompensation)); + const itemsWithClones: unknown[] = [...listEndClone, ...items, ...listStartClone]; + const itemsSlice: unknown[] = itemsWithClones.slice(sliceFrom, sliceTo); + + return itemsSlice; + }; + + const tileList: unknown[] = isMultiPage ? sliceItems(items) : items; + const isAnimating: boolean = index !== slideToIndex; + const transitionBasis: string = `transform ${animated ? transitionTime : '0s'} ease`; + + const needControls: boolean = showControls && isMultiPage; + const showLeftControl: boolean = needControls && !(cycleMode === 'stop' && index === 0); + const showRightControl: boolean = needControls && !(cycleMode === 'stop' && index === items.length - tilesToShowRounded); + + const slide = (direction: Direction) : void => { + const directionFactor = (direction === 'right')? 1 : -1; + let nextIndex: number = index + (tilesToShowRounded * directionFactor); + + if(nextIndex < 0){ + if (cycleMode === 'stop') nextIndex = 0; + if (cycleMode === 'restart') nextIndex = index === 0 ? 0 - tilesToShowRounded : 0; + } + if (nextIndex > items.length - tilesToShowRounded) { + if(cycleMode === 'stop') nextIndex = items.length - tilesToShowRounded; + if(cycleMode === 'restart') nextIndex = index === items.length - tilesToShowRounded ? items.length : items.length - tilesToShowRounded; + } + + const steps: number = Math.abs(index - nextIndex); + const movement: number = steps * tileWidth * (0 - directionFactor); + + setSlideToIndex(nextIndex); + setTransform(-100 + movement); + if (!animated) setDoAnimationReset(true); + }; + + const handleTouchStart = (event: React.TouchEvent): void => setTouchPosition({ x: event.touches[0].clientX, y: event.touches[0].clientY }); + const handleTouchEnd = (event: React.TouchEvent): void => { + const newPosition = { x: event.changedTouches[0].clientX, y: event.changedTouches[0].clientY }; + const movementX: number = Math.abs(newPosition.x - touchPosition.x); + const movementY: number = Math.abs(newPosition.y - touchPosition.y); + const direction: Direction = (newPosition.x < touchPosition.x) ? 'right' : 'left'; + + if (movementX > minimalTouchMovement && movementX > movementY) { + slide(direction); + } + }; + + useLayoutEffect(() => { + const resetAnimation = (): void => { + let resetIndex: number = slideToIndex; + + resetIndex = resetIndex >= items.length ? slideToIndex - items.length : resetIndex; + resetIndex = resetIndex < 0 ? items.length + slideToIndex : resetIndex; + + setIndex(resetIndex); + if (frameRef.current) frameRef.current.style.transition = 'none'; + setTransform(-100); + setTimeout(() => { + if (frameRef.current) frameRef.current.style.transition = transitionBasis; + }, 0); + setDoAnimationReset(false); + }; + + if (doAnimationReset) resetAnimation(); + }, [ + doAnimationReset, + index, + items.length, + slideToIndex, + tileWidth, + tilesToShowRounded, + transitionBasis, + ]); + + const renderGradientEdge = () : string => { + const firstPercentage = cycleMode === 'stop' && index === 0 ? offset * tileWidth : 0; + const secondPercentage = tileWidth * offset; + const thirdPercentage = 100 - tileWidth * offset; + + return `linear-gradient(90deg, rgba(255,255,255,1) ${firstPercentage}%, rgba(255,255,255,0) ${secondPercentage}%, rgba(255,255,255,0) ${thirdPercentage}%, rgba(255,255,255,1) 100%)`; + }; + const ulStyle = { + transform: `translate3d(${transformWithOffset}%, 0, 0)`, + // Todo: set capital W before creating package + webkitTransform: `translate3d(${transformWithOffset}%, 0, 0)`, + transition: transitionBasis, + marginLeft: -spacing / 2, + marginRight: -spacing / 2, + }; + + return ( +
+ {showLeftControl && !!renderLeftControl && ( +
+ {renderLeftControl(() => slide('left'))} +
+ )} +
    setDoAnimationReset(true)} + > + {tileList.map((item:any, listIndex) => { + const isVisible = + isAnimating || + !isMultiPage || + (listIndex > tilesToShowRounded - offsetCompensation - 1 && + listIndex < tilesToShowRounded * 2 + offsetCompensation + offsetCompensation); + + return ( +
  • + {renderTile(item, listIndex)} +
  • + ); + })} +
+ {offsetCompensation > 0 && isMultiPage && ( +
+ )} + {showRightControl && !!renderRightControl && ( +
+ {renderRightControl(() => slide('right'))} +
+ )} +
+ ); +}; + +export default TileDock; diff --git a/src/container/Shelf/Shelf.test.tsx b/src/container/Shelf/Shelf.test.tsx new file mode 100644 index 000000000..6bec19383 --- /dev/null +++ b/src/container/Shelf/Shelf.test.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { render } from '@testing-library/react'; + +import Shelf from './Shelf'; + +describe('Playlist Component tests', () => { + test.skip('dummy test', () => { + render( + + ); + // expect(screen.getByText('hello world')).toBeInTheDocument(); + }); +}); diff --git a/src/container/Shelf/Shelf.tsx b/src/container/Shelf/Shelf.tsx new file mode 100644 index 000000000..f2b8d7fb7 --- /dev/null +++ b/src/container/Shelf/Shelf.tsx @@ -0,0 +1,28 @@ +import React from 'react'; + +import usePlaylist from '../../hooks/usePlaylist'; +import ShelfComponent from '../../components/Shelf/Shelf'; + +type ShelfProps = { + playlistId: string; + featured?: boolean; +}; + +const Shelf = ({ playlistId, featured = false }: ShelfProps) : JSX.Element => { + const { isLoading, error, data: { title, playlist } = {} } = usePlaylist( + playlistId, + ); + + if (isLoading) { + return

Spinner here (todo)

; + } + if (error) { + return

Error here {error}

; + } + + return ( + + ); +}; + +export default Shelf; diff --git a/src/containers/Slider.tsx b/src/containers/Slider.tsx deleted file mode 100644 index 3631e08b1..000000000 --- a/src/containers/Slider.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import React, { EventHandler } from 'react'; -import { TileDock, CYCLE_MODE_STOP } from 'tile-dock'; - -import logo from '../assets/logo.svg'; - - -// temporary - -export const columnMapping = { - landscape: { - xs: 1, - sm: 3, - md: 4, - lg: 5, - xl: 6, - }, - portrait: { - xs: 1, - sm: 4, - md: 5, - lg: 7, - xl: 9, - }, -}; - - const Slider = () => { - const items = Array(10).fill(""); - - return ( -
- }) => ( - - )} - renderRightControl={({ handleClick }: { handleClick: EventHandler }) => ( - - )} - renderTile={() => { - return ; - }} - /> -
- ); -}; - - - - -const Card = () => { - return ( -
- logo -
- ); -} - - -export default Slider diff --git a/src/hooks/usePlaylist.ts b/src/hooks/usePlaylist.ts new file mode 100644 index 000000000..6605caf25 --- /dev/null +++ b/src/hooks/usePlaylist.ts @@ -0,0 +1,13 @@ +import { useQuery } from 'react-query'; + +const baseUrl = 'https://content.jwplatform.com'; // temp data, till config arrives + +const getPlaylistById = (playlistId: string) => { + return fetch(`${baseUrl}/v2/playlists/${playlistId}`).then((res) => + res.json(), + ); +}; + +export default function usePlaylist(playlistId: string) { + return useQuery(['playlist', playlistId], () => getPlaylistById(playlistId)); +} diff --git a/src/providers/QueryProvider.tsx b/src/providers/QueryProvider.tsx new file mode 100644 index 000000000..1ad2976f8 --- /dev/null +++ b/src/providers/QueryProvider.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { QueryClient, QueryClientProvider } from 'react-query'; + +const queryClient = new QueryClient(); + +type QueryProviderProps = { + children: JSX.Element; +}; + +function QueryProvider({ children }: QueryProviderProps): JSX.Element { + return ( + {children} + ); +} + +export default QueryProvider; diff --git a/src/screens/Home/Home.module.scss b/src/screens/Home/Home.module.scss new file mode 100644 index 000000000..ea771d43a --- /dev/null +++ b/src/screens/Home/Home.module.scss @@ -0,0 +1,6 @@ +@use '../../styles/variables'; +@use '../../styles/theme'; + +.Home { + color: 'white'; +} diff --git a/src/screens/Home/Home.test.tsx b/src/screens/Home/Home.test.tsx new file mode 100644 index 000000000..619c9cfe2 --- /dev/null +++ b/src/screens/Home/Home.test.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; + +import Home from './Home'; + +describe('Home Component tests', () => { + test('dummy test', () => { + render(); + // expect(screen.getByText('hello world')).toBeInTheDocument(); + }); +}); diff --git a/src/screens/Home/Home.tsx b/src/screens/Home/Home.tsx new file mode 100644 index 000000000..49345bddf --- /dev/null +++ b/src/screens/Home/Home.tsx @@ -0,0 +1,26 @@ +import React, { useContext } from 'react'; +import type { Config, Content } from 'types/Config'; + +import Shelf from '../../container/Shelf/Shelf'; +import { ConfigContext } from '../../providers/configProvider'; + +import styles from './Home.module.scss'; + +const Home = () : JSX.Element => { + const config: Config = useContext(ConfigContext); + const content: Content[] = config?.content; + + return ( +
+ {content.slice(0,1).map((contentItem: Content) => ( + + ))} +
+ ); +}; + +export default Home;