Skip to content

Commit

Permalink
feat(favorites): implement favorites store and hooks
Browse files Browse the repository at this point in the history
  • Loading branch information
ChristiaanScheermeijer committed Jun 14, 2021
1 parent 9a8bd5f commit 1032cdb
Show file tree
Hide file tree
Showing 13 changed files with 149 additions and 23 deletions.
1 change: 1 addition & 0 deletions .commitlintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ module.exports = {
'playlist',
'videodetail',
'search',
'favorites',
],
],
},
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ The allowed scopes are:
- playlist
- videodetail
- search
- favorites

### Subject

Expand Down
3 changes: 3 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import QueryProvider from './providers/QueryProvider';
import './i18n/config';
import './styles/main.scss';
import { initializeWatchHistory } from './stores/WatchHistoryStore';
import { initializeFavorites } from './stores/FavoritesStore';

interface State {
error: Error | null;
Expand All @@ -27,6 +28,8 @@ class App extends Component {
if (config.options.enableContinueWatching) {
initializeWatchHistory();
}

initializeFavorites();
}

render() {
Expand Down
12 changes: 10 additions & 2 deletions src/components/Video/Video.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import IconButton from '../IconButton/IconButton';
import { formatDuration } from '../../utils/formatting';

import styles from './Video.module.scss';
import FavoriteBorder from '../../icons/FavoriteBorder';

type Poster = 'fading' | 'normal';

Expand All @@ -24,11 +25,13 @@ type Props = {
play: boolean;
startPlay: () => void;
goBack: () => void;
isFavorited: boolean;
onFavoriteButtonClick: () => void;
poster: Poster;
relatedShelf?: JSX.Element;
};

const Video: React.FC<Props> = ({ item, play, startPlay, goBack, poster, relatedShelf }: Props) => {
const Video: React.FC<Props> = ({ item, play, startPlay, goBack, poster, relatedShelf, isFavorited, onFavoriteButtonClick }: Props) => {
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [mouseActive, setMouseActive] = useState(false);
const breakpoint: Breakpoint = useBreakpoint();
Expand Down Expand Up @@ -77,7 +80,12 @@ const Video: React.FC<Props> = ({ item, play, startPlay, goBack, poster, related
onClick={() => null}
fullWidth={breakpoint < Breakpoint.sm}
/>
<Button label={t('video:favorite')} startIcon={<Favorite />} onClick={() => null} />
<Button
label={t('video:favorite')}
startIcon={isFavorited ? <Favorite /> : <FavoriteBorder />}
onClick={onFavoriteButtonClick}
color={isFavorited ? 'primary' : 'default'}
/>
<Button label={t('video:share')} startIcon={<Share />} onClick={() => null} />
</div>
</div>
Expand Down
1 change: 1 addition & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const API_BASE_URL = 'https://content.jwplatform.com';
7 changes: 6 additions & 1 deletion src/containers/Video/Video.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import VideoComponent from '../../components/Video/Video';
import { cardUrl, videoUrl } from '../../utils/formatting';
import usePlaylist from '../../hooks/usePlaylist';
import Shelf from '../Shelf/Shelf';
import { useFavorites } from '../../stores/FavoritesStore';

export type VideoType = 'movie' | 'series';

Expand All @@ -22,6 +23,7 @@ export type VideoProps = {
const Video = ({ playlistId, videoType, episodeId, mediaId }: VideoProps): JSX.Element => {
const history = useHistory();
const location = useLocation();
const { hasItem, saveItem, removeItem } = useFavorites();
const play = new URLSearchParams(location.search).get('play') === '1';
const config: Config = useContext(ConfigContext);
const posterFading: boolean = config ? config.options.posterFading === true : false;
Expand All @@ -31,8 +33,9 @@ const Video = ({ playlistId, videoType, episodeId, mediaId }: VideoProps): JSX.E
const updateBlurImage = useBlurImageUpdater(playlist);

const getMovieItem = () => playlist.find((item) => item.mediaid === mediaId);
const getSeriesItem = () => playlist.length && playlist[0];
const getSeriesItem = () => playlist[0];
const item = videoType === 'movie' ? getMovieItem() : getSeriesItem();
const isFavorited = !!item && hasItem(item);

const startPlay = () => item && history.push(videoUrl(item, playlistId, true));
const goBack = () => item && history.push(videoUrl(item, playlistId, false));
Expand All @@ -55,6 +58,8 @@ const Video = ({ playlistId, videoType, episodeId, mediaId }: VideoProps): JSX.E
startPlay={startPlay}
goBack={goBack}
poster={posterFading ? 'fading' : 'normal'}
isFavorited={isFavorited}
onFavoriteButtonClick={() => isFavorited ? removeItem(item) : saveItem(item)}
relatedShelf={
config.recommendationsPlaylist ? (
<Shelf
Expand Down
8 changes: 1 addition & 7 deletions src/hooks/usePlaylist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,10 @@ import { UseBaseQueryResult, useQuery } from 'react-query';
import type { Playlist } from 'types/playlist';

import { generatePlaylistPlaceholder } from '../utils/collection';
import { getPlaylistById } from '../services/api.service';

const baseUrl = 'https://content.jwplatform.com';
const placeholderData = generatePlaylistPlaceholder(30);

const getPlaylistById = (playlistId: string, relatedMediaId?: string) => {
const relatedQuery = relatedMediaId ? `?related_media_id=${relatedMediaId}` : '';

return fetch(`${baseUrl}/v2/playlists/${playlistId}${relatedQuery}`).then((res) => res.json());
};

export type UsePlaylistResult<TData = Playlist, TError = unknown> = UseBaseQueryResult<TData, TError>;

export default function usePlaylist(playlistId: string, relatedMediaId?: string): UsePlaylistResult {
Expand Down
8 changes: 2 additions & 6 deletions src/hooks/useSearchPlaylist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,13 @@ import { UseBaseQueryResult, useQuery } from 'react-query';
import type { Playlist } from 'types/playlist';

import { generatePlaylistPlaceholder } from '../utils/collection';
import { getSearchPlaylist } from '../services/api.service';

const baseUrl = 'https://content.jwplatform.com';
const placeholderData = generatePlaylistPlaceholder();

const getSearchPlaylist = (playlistId: string, query: string) => {
return fetch(`${baseUrl}/v2/playlists/${playlistId}?search=${encodeURIComponent(query)}`).then((res) => res.json());
};

export type UseSearchPlaylistResult<TData = Playlist, TError = unknown> = UseBaseQueryResult<TData, TError>;

export default function useSearchPlaylist(playlistId: string, query: string, usePlaceholderData: boolean = true): UseSearchPlaylistResult {
export default function useSearchPlaylist (playlistId: string, query: string, usePlaceholderData: boolean = true): UseSearchPlaylistResult {
return useQuery(['playlist', playlistId, query], () => {
return getSearchPlaylist(playlistId, query)
}, {
Expand Down
2 changes: 1 addition & 1 deletion src/icons/Favorite.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@ import createIcon from './Icon';
export default createIcon(
'0 0 24 24',
<g>
<path d="M12.1,18.55L12,18.65L11.89,18.55C7.14,14.24 4,11.39 4,8.5C4,6.5 5.5,5 7.5,5C9.04,5 10.54,6 11.07,7.36H12.93C13.46,6 14.96,5 16.5,5C18.5,5 20,6.5 20,8.5C20,11.39 16.86,14.24 12.1,18.55M16.5,3C14.76,3 13.09,3.81 12,5.08C10.91,3.81 9.24,3 7.5,3C4.42,3 2,5.41 2,8.5C2,12.27 5.4,15.36 10.55,20.03L12,21.35L13.45,20.03C18.6,15.36 22,12.27 22,8.5C22,5.41 19.58,3 16.5,3Z" />
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
</g>,
);
10 changes: 10 additions & 0 deletions src/icons/FavoriteBorder.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import React from 'react';

import createIcon from './Icon';

export default createIcon(
'0 0 24 24',
<g>
<path d="M12.1,18.55L12,18.65L11.89,18.55C7.14,14.24 4,11.39 4,8.5C4,6.5 5.5,5 7.5,5C9.04,5 10.54,6 11.07,7.36H12.93C13.46,6 14.96,5 16.5,5C18.5,5 20,6.5 20,8.5C20,11.39 16.86,14.24 12.1,18.55M16.5,3C14.76,3 13.09,3.81 12,5.08C10.91,3.81 9.24,3 7.5,3C4.42,3 2,5.41 2,8.5C2,12.27 5.4,15.36 10.55,20.03L12,21.35L13.45,20.03C18.6,15.36 22,12.27 22,8.5C22,5.41 19.58,3 16.5,3Z" />
</g>,
);
49 changes: 49 additions & 0 deletions src/services/api.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { Playlist, PlaylistItem } from '../../types/playlist';
import { API_BASE_URL } from '../config';

/**
* Get playlist by id
* @param {string} id
* @param relatedMediaId
*/
export const getPlaylistById = (id: string, relatedMediaId?: string) : Promise<Playlist | undefined> => {
const relatedQuery = relatedMediaId ? `?related_media_id=${relatedMediaId}` : '';

return fetch(`${API_BASE_URL}/v2/playlists/${id}${relatedQuery}`)
.then((res) => res.json());
};

/**
* Get search playlist
* @param {string} playlistId
* @param {string} query
*/
export const getSearchPlaylist = (playlistId: string, query: string) : Promise<Playlist | undefined> => {
return fetch(`${API_BASE_URL}/v2/playlists/${playlistId}?search=${encodeURIComponent(query)}`)
.then((res) => res.json());
};

/**
* Get media by id
* @param {string} id
*/
export const getMediaById = (id: string): Promise<PlaylistItem | undefined> => {
return fetch(`${API_BASE_URL}/v2/media/${id}`)
.then((res) => res.json() as Promise<Playlist>)
.then(data => data.playlist[0]);
};

/**
* Gets multiple media items by the given ids. Filters out items that don't exist.
* @param {string[]} ids
*/
export const getMediaByIds = async (ids: string[]): Promise<PlaylistItem[]> => {
// @todo this should be updated when it will become possible to request multiple media items in a single request
const responses = await Promise.all(ids.map(id => getMediaById(id)));

function notEmpty<Value> (value: Value | null | undefined): value is Value {
return value !== null && value !== undefined;
}

return responses.filter(notEmpty);
}
64 changes: 58 additions & 6 deletions src/stores/FavoritesStore.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,65 @@
import { Store } from 'pullstate';

type Favorite = {
mediaId: string;
};
import type { PlaylistItem } from '../../types/playlist';
import * as persist from '../utils/persist';
import type { Favorite } from '../../types/favorite';
import { getMediaByIds } from '../services/api.service';

type FavoritesStore = {
favorites: Favorite[];
favorites: PlaylistItem[];
};

export const FavoritesStore = new Store<FavoritesStore>({
favorites: [{ mediaId: '' }],
const PERSIST_KEY_FAVORITES = `favorites${window.configId ? `-${window.configId}` : ''}`;

export const favoritesStore = new Store<FavoritesStore>({
favorites: [],
});

export const initializeFavorites = async () => {
const savedFavorites: Favorite[] | null = persist.getItem(PERSIST_KEY_FAVORITES) as Favorite[] | null;

if (savedFavorites) {
const favoritesPlaylist = await getMediaByIds(savedFavorites.map(({ mediaid }) => mediaid));

favoritesStore.update((state) => {
state.favorites = favoritesPlaylist;
});
}

return favoritesStore.subscribe(state => state.favorites, (favorites) => {
persist.setItem(PERSIST_KEY_FAVORITES, favorites.map(playlistItem => ({
mediaid: playlistItem.mediaid,
title: playlistItem.title,
tags: playlistItem.tags,
duration: playlistItem.duration,
}) as Favorite));
});
};

type SaveItemFn = (item: PlaylistItem) => void;
type RemoveItemFn = (item: PlaylistItem) => void;
type HasItemFn = (item: PlaylistItem) => boolean;

export const useFavorites = (): { saveItem: SaveItemFn, removeItem: RemoveItemFn, hasItem: HasItemFn } => {
const favorites = favoritesStore.useState((s) => s.favorites);

const saveItem = (item: PlaylistItem) => {
favoritesStore.update((s, o) => {
if (!o.favorites.some(current => current.mediaid === item.mediaid)) {
s.favorites.push(item);
}
});
};

const removeItem = (item: PlaylistItem) => {
favoritesStore.update((s) => {
s.favorites = s.favorites.filter(current => current.mediaid !== item.mediaid);
});
};

const hasItem = (item: PlaylistItem) => {
return favorites.some(current => current.mediaid === item.mediaid);
};

return { saveItem, removeItem, hasItem };
};
6 changes: 6 additions & 0 deletions types/favorite.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export type Favorite = {
mediaid: string;
title: string;
tags: string;
duration: number;
};

0 comments on commit 1032cdb

Please sign in to comment.