feat: Add support for playlist descriptions (#217)

* Added watch list actions to theme menu.
* Restructured theme and video menus.
* Changed some icons.
* Improved frontend performance of the playlist page.
This commit is contained in:
Mani
2024-05-25 22:07:57 +02:00
committed by GitHub
parent 1f0844465c
commit 633236f347
13 changed files with 352 additions and 141 deletions
+33 -33
View File
@@ -8,9 +8,9 @@
"name": "animethemes-web",
"version": "3.0.0",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.1.1",
"@fortawesome/free-brands-svg-icons": "^6.1.1",
"@fortawesome/pro-solid-svg-icons": "^6.1.1",
"@fortawesome/fontawesome-svg-core": "^6.5.2",
"@fortawesome/free-brands-svg-icons": "^6.5.2",
"@fortawesome/pro-solid-svg-icons": "^6.5.2",
"@graphql-tools/merge": "^8.2.1",
"@graphql-tools/schema": "^8.2.0",
"@graphql-tools/utils": "^8.6.5",
@@ -1697,41 +1697,41 @@
}
},
"node_modules/@fortawesome/fontawesome-common-types": {
"version": "6.2.1",
"resolved": "https://npm.fontawesome.com/@fortawesome/fontawesome-common-types/-/6.2.1/fontawesome-common-types-6.2.1.tgz",
"integrity": "sha512-Sz07mnQrTekFWLz5BMjOzHl/+NooTdW8F8kDQxjWwbpOJcnoSg4vUDng8d/WR1wOxM0O+CY9Zw0nR054riNYtQ==",
"version": "6.5.2",
"resolved": "https://npm.fontawesome.com/@fortawesome/fontawesome-common-types/-/6.5.2/fontawesome-common-types-6.5.2.tgz",
"integrity": "sha512-gBxPg3aVO6J0kpfHNILc+NMhXnqHumFxOmjYCFfOiLZfwhnnfhtsdA2hfJlDnj+8PjAs6kKQPenOTKj3Rf7zHw==",
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/fontawesome-svg-core": {
"version": "6.2.1",
"resolved": "https://npm.fontawesome.com/@fortawesome/fontawesome-svg-core/-/6.2.1/fontawesome-svg-core-6.2.1.tgz",
"integrity": "sha512-HELwwbCz6C1XEcjzyT1Jugmz2NNklMrSPjZOWMlc+ZsHIVk+XOvOXLGGQtFBwSyqfJDNgRq4xBCwWOaZ/d9DEA==",
"version": "6.5.2",
"resolved": "https://npm.fontawesome.com/@fortawesome/fontawesome-svg-core/-/6.5.2/fontawesome-svg-core-6.5.2.tgz",
"integrity": "sha512-5CdaCBGl8Rh9ohNdxeeTMxIj8oc3KNBgIeLMvJosBMdslK/UnEB8rzyDRrbKdL1kDweqBPo4GT9wvnakHWucZw==",
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.2.1"
"@fortawesome/fontawesome-common-types": "6.5.2"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-brands-svg-icons": {
"version": "6.2.1",
"resolved": "https://npm.fontawesome.com/@fortawesome/free-brands-svg-icons/-/6.2.1/free-brands-svg-icons-6.2.1.tgz",
"integrity": "sha512-L8l4MfdHPmZlJ72PvzdfwOwbwcCAL0vx48tJRnI6u1PJXh+j2f3yDoKyQgO3qjEsgD5Fr2tQV/cPP8F/k6aUig==",
"version": "6.5.2",
"resolved": "https://npm.fontawesome.com/@fortawesome/free-brands-svg-icons/-/6.5.2/free-brands-svg-icons-6.5.2.tgz",
"integrity": "sha512-zi5FNYdmKLnEc0jc0uuHH17kz/hfYTg4Uei0wMGzcoCL/4d3WM3u1VMc0iGGa31HuhV5i7ZK8ZlTCQrHqRHSGQ==",
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.2.1"
"@fortawesome/fontawesome-common-types": "6.5.2"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/pro-solid-svg-icons": {
"version": "6.2.1",
"resolved": "https://npm.fontawesome.com/@fortawesome/pro-solid-svg-icons/-/6.2.1/pro-solid-svg-icons-6.2.1.tgz",
"integrity": "sha512-gIGrv/hcw5/UsW0swBfxL9jwb2F+Yut+9QY47ZPs8+Ac9/AmYxIzJ31ZnYawwLdrxU/09Unc+AHbTfn0QXLfvQ==",
"version": "6.5.2",
"resolved": "https://npm.fontawesome.com/@fortawesome/pro-solid-svg-icons/-/6.5.2/pro-solid-svg-icons-6.5.2.tgz",
"integrity": "sha512-yhKyRyLDJoTSUN8h+oBl+yspfE6ARPRRbLWbWN4qEiOEcULKTtCTqsqovrnLsCB7qbllRwC50ai+RZ7bC1OPaA==",
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.2.1"
"@fortawesome/fontawesome-common-types": "6.5.2"
},
"engines": {
"node": ">=6"
@@ -13598,32 +13598,32 @@
}
},
"@fortawesome/fontawesome-common-types": {
"version": "6.2.1",
"resolved": "https://npm.fontawesome.com/@fortawesome/fontawesome-common-types/-/6.2.1/fontawesome-common-types-6.2.1.tgz",
"integrity": "sha512-Sz07mnQrTekFWLz5BMjOzHl/+NooTdW8F8kDQxjWwbpOJcnoSg4vUDng8d/WR1wOxM0O+CY9Zw0nR054riNYtQ=="
"version": "6.5.2",
"resolved": "https://npm.fontawesome.com/@fortawesome/fontawesome-common-types/-/6.5.2/fontawesome-common-types-6.5.2.tgz",
"integrity": "sha512-gBxPg3aVO6J0kpfHNILc+NMhXnqHumFxOmjYCFfOiLZfwhnnfhtsdA2hfJlDnj+8PjAs6kKQPenOTKj3Rf7zHw=="
},
"@fortawesome/fontawesome-svg-core": {
"version": "6.2.1",
"resolved": "https://npm.fontawesome.com/@fortawesome/fontawesome-svg-core/-/6.2.1/fontawesome-svg-core-6.2.1.tgz",
"integrity": "sha512-HELwwbCz6C1XEcjzyT1Jugmz2NNklMrSPjZOWMlc+ZsHIVk+XOvOXLGGQtFBwSyqfJDNgRq4xBCwWOaZ/d9DEA==",
"version": "6.5.2",
"resolved": "https://npm.fontawesome.com/@fortawesome/fontawesome-svg-core/-/6.5.2/fontawesome-svg-core-6.5.2.tgz",
"integrity": "sha512-5CdaCBGl8Rh9ohNdxeeTMxIj8oc3KNBgIeLMvJosBMdslK/UnEB8rzyDRrbKdL1kDweqBPo4GT9wvnakHWucZw==",
"requires": {
"@fortawesome/fontawesome-common-types": "6.2.1"
"@fortawesome/fontawesome-common-types": "6.5.2"
}
},
"@fortawesome/free-brands-svg-icons": {
"version": "6.2.1",
"resolved": "https://npm.fontawesome.com/@fortawesome/free-brands-svg-icons/-/6.2.1/free-brands-svg-icons-6.2.1.tgz",
"integrity": "sha512-L8l4MfdHPmZlJ72PvzdfwOwbwcCAL0vx48tJRnI6u1PJXh+j2f3yDoKyQgO3qjEsgD5Fr2tQV/cPP8F/k6aUig==",
"version": "6.5.2",
"resolved": "https://npm.fontawesome.com/@fortawesome/free-brands-svg-icons/-/6.5.2/free-brands-svg-icons-6.5.2.tgz",
"integrity": "sha512-zi5FNYdmKLnEc0jc0uuHH17kz/hfYTg4Uei0wMGzcoCL/4d3WM3u1VMc0iGGa31HuhV5i7ZK8ZlTCQrHqRHSGQ==",
"requires": {
"@fortawesome/fontawesome-common-types": "6.2.1"
"@fortawesome/fontawesome-common-types": "6.5.2"
}
},
"@fortawesome/pro-solid-svg-icons": {
"version": "6.2.1",
"resolved": "https://npm.fontawesome.com/@fortawesome/pro-solid-svg-icons/-/6.2.1/pro-solid-svg-icons-6.2.1.tgz",
"integrity": "sha512-gIGrv/hcw5/UsW0swBfxL9jwb2F+Yut+9QY47ZPs8+Ac9/AmYxIzJ31ZnYawwLdrxU/09Unc+AHbTfn0QXLfvQ==",
"version": "6.5.2",
"resolved": "https://npm.fontawesome.com/@fortawesome/pro-solid-svg-icons/-/6.5.2/pro-solid-svg-icons-6.5.2.tgz",
"integrity": "sha512-yhKyRyLDJoTSUN8h+oBl+yspfE6ARPRRbLWbWN4qEiOEcULKTtCTqsqovrnLsCB7qbllRwC50ai+RZ7bC1OPaA==",
"requires": {
"@fortawesome/fontawesome-common-types": "6.2.1"
"@fortawesome/fontawesome-common-types": "6.5.2"
}
},
"@graphql-codegen/cli": {
+3 -3
View File
@@ -16,9 +16,9 @@
"prepare": "husky"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.1.1",
"@fortawesome/free-brands-svg-icons": "^6.1.1",
"@fortawesome/pro-solid-svg-icons": "^6.1.1",
"@fortawesome/fontawesome-svg-core": "^6.5.2",
"@fortawesome/free-brands-svg-icons": "^6.5.2",
"@fortawesome/pro-solid-svg-icons": "^6.5.2",
"@graphql-tools/merge": "^8.2.1",
"@graphql-tools/schema": "^8.2.0",
"@graphql-tools/utils": "^8.6.5",
@@ -1,7 +1,7 @@
import { useState } from "react";
import type { ReactNode } from "react";
import { faArrowDown, faListMusic, faMinus, faPlus } from "@fortawesome/pro-solid-svg-icons";
import { faArrowDown, faMinus, faPlus } from "@fortawesome/pro-solid-svg-icons";
import gql from "graphql-tag";
import useSWR, { mutate } from "swr";
@@ -22,7 +22,8 @@ import { Busy } from "@/components/utils/Busy";
import { useToasts } from "@/context/toastContext";
import type {
PlaylistTrackAddDialogVideoFragment,
PlaylistTrackAddFormPlaylistQuery, PlaylistTrackAddFormPlaylistQueryVariables
PlaylistTrackAddFormPlaylistQuery,
PlaylistTrackAddFormPlaylistQueryVariables
} from "@/generated/graphql";
import { fetchDataClient } from "@/lib/client";
import axios from "@/lib/client/axios";
@@ -39,7 +40,7 @@ export function PlaylistTrackAddDialog({ video, trigger }: PlaylistTrackAddDialo
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger ?? (
<IconTextButton icon={faListMusic} variant="solid" collapsible>Add to Playlist</IconTextButton>
<IconTextButton icon={faPlus} variant="solid" collapsible>Add to Playlist</IconTextButton>
)}
</DialogTrigger>
<DialogContent title="Add to playlist">
+24
View File
@@ -0,0 +1,24 @@
import styled from "styled-components";
import { withColorTheme } from "@/styles/mixins";
import theme from "@/theme";
export const TextArea = styled.textarea`
display: block;
padding: 8px 16px;
border-radius: 8px;
resize: vertical;
background-color: ${theme.colors["solid-on-card"]};
color: ${theme.colors["text-muted"]};
scrollbar-color: ${theme.colors["gray-800"]} transparent;
&:focus-within {
box-shadow: ${theme.shadows.low};
${withColorTheme("dark")`
box-shadow: 0 0 0 2px ${theme.colors["text-primary"]};
`}
}
`;
+31 -12
View File
@@ -1,12 +1,15 @@
import { faEllipsisV, faListMusic } from "@fortawesome/pro-solid-svg-icons";
import { useContext } from "react";
import { faArrowTurnDownRight, faArrowTurnRight, faEllipsisV, faPlus } from "@fortawesome/pro-solid-svg-icons";
import gql from "graphql-tag";
import { Button } from "@/components/button/Button";
import { PlaylistTrackAddDialog } from "@/components/dialog/PlaylistTrackAddDialog";
import { Icon } from "@/components/icon/Icon";
import { Menu, MenuContent, MenuItem, MenuTrigger } from "@/components/menu/Menu";
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "@/components/menu/Menu";
import { Text } from "@/components/text/Text";
import { SongTitleWithArtists } from "@/components/utils/SongTitleWithArtists";
import PlayerContext from "@/context/playerContext";
import type { ThemeMenuThemeFragment } from "@/generated/graphql";
import createVideoSlug from "@/utils/createVideoSlug";
import extractImages from "@/utils/extractImages";
@@ -16,6 +19,8 @@ interface ThemeMenuProps {
}
export function ThemeMenu({ theme }: ThemeMenuProps) {
const { watchList, addWatchListItem, addWatchListItemNext } = useContext(PlayerContext);
const entry = theme.entries[0];
const video = entry?.videos[0];
@@ -23,6 +28,15 @@ export function ThemeMenu({ theme }: ThemeMenuProps) {
return null;
}
// Flip the structure on it's head, because we need video as the root object here.
const videoFlipped = {
...video,
entries: [{
...entry,
theme,
}],
};
return (
<Menu modal={false}>
<MenuTrigger asChild>
@@ -32,22 +46,27 @@ export function ThemeMenu({ theme }: ThemeMenuProps) {
</MenuTrigger>
<MenuContent>
<PlaylistTrackAddDialog
video={{
// Flip the structure on it's head,
// because we need video as the root object here.
...video,
entries: [{
...entry,
theme,
}],
}}
video={videoFlipped}
trigger={
<MenuItem onSelect={(event) => event.preventDefault()}>
<Icon icon={faListMusic} />
<Icon icon={faPlus} />
<Text>Add to Playlist</Text>
</MenuItem>
}
/>
{watchList.length ? (
<>
<MenuSeparator />
<MenuItem onSelect={() => addWatchListItem(videoFlipped)}>
<Icon icon={faArrowTurnDownRight} color="text-disabled" />
<Text>Add to Watch List</Text>
</MenuItem>
<MenuItem onSelect={() => addWatchListItemNext(videoFlipped)}>
<Icon icon={faArrowTurnRight} color="text-disabled" />
<Text>Play Next</Text>
</MenuItem>
</>
) : null}
</MenuContent>
</Menu>
);
@@ -2,7 +2,7 @@ import { useContext } from "react";
import styled from "styled-components";
import Link from "next/link";
import { faBackwardStep, faForwardStep, faListMusic, faPause, faPlay, faXmark } from "@fortawesome/pro-solid-svg-icons";
import { faBackwardStep, faForwardStep, faPause, faPlay, faPlus, faXmark } from "@fortawesome/pro-solid-svg-icons";
import { Column } from "@/components/box/Flex";
import { Solid } from "@/components/box/Solid";
@@ -183,7 +183,7 @@ export function VideoPlayerBar() {
}],
}}
trigger={
<IconTextButton icon={faListMusic} variant="solid" collapsible="socialListMax">Add to Playlist</IconTextButton>
<IconTextButton icon={faPlus} variant="solid" collapsible="socialListMax">Add to Playlist</IconTextButton>
}
/>
<ShareMenu pagePath={videoPagePath} videoUrl={videoUrl} audioUrl={audioUrl} />
@@ -2,7 +2,7 @@ import { Fragment, useContext } from "react";
import styled from "styled-components";
import Link from "next/link";
import { faCheck, faCog, faCompress, faExpand, faListMusic, faShare } from "@fortawesome/pro-solid-svg-icons";
import { faCheck, faCog, faCompress, faExpand, faPlus, faShare } from "@fortawesome/pro-solid-svg-icons";
import { Row } from "@/components/box/Flex";
import { IconTextButton } from "@/components/button/IconTextButton";
@@ -88,18 +88,18 @@ export function VideoPlayerOverlay({ anime, themeIndex, entryIndex, videoIndex }
}],
}}
trigger={
<StyledOverlayButton icon={faListMusic} isCircle />
<StyledOverlayButton icon={faPlus} isCircle title="Add to playlist" />
}
/>
<ShareMenu
pagePath={context.videoPagePath}
videoUrl={context.videoUrl}
audioUrl={context.audioUrl}
trigger={<StyledOverlayButton icon={faShare} isCircle />}
trigger={<StyledOverlayButton icon={faShare} isCircle title="Share" />}
/>
<Menu modal={false}>
<MenuTrigger asChild>
<StyledOverlayButton icon={faCog} isCircle />
<StyledOverlayButton icon={faCog} isCircle title="Change media type" />
</MenuTrigger>
<MenuContent collisionPadding={16}>
<MenuLabel>
@@ -153,7 +153,7 @@ export function VideoPlayerOverlay({ anime, themeIndex, entryIndex, videoIndex }
) : null)}
</MenuContent>
</Menu>
<StyledOverlayButton icon={isFullscreen ? faCompress : faExpand} isCircle onClick={toggleFullscreen} />
<StyledOverlayButton icon={isFullscreen ? faCompress : faExpand} isCircle onClick={toggleFullscreen} title="Toggle fullscreen" />
</Row>
</StyledOverlay>
);
+4 -3
View File
@@ -178,6 +178,7 @@ export type Permission = {
};
export type Playlist = {
description: Maybe<Scalars['String']>;
forward: Array<PlaylistTrack>;
id: Scalars['String'];
name: Scalars['String'];
@@ -870,14 +871,14 @@ export type PlaylistDetailPagePlaylistQueryVariables = Exact<{
}>;
export type PlaylistDetailPagePlaylistQuery = { playlist: { id: string, name: string, visibility: PlaylistVisibility, tracks_count: number, tracks: Array<{ id: string, video: { id: number, basename: string, tags: string, entries: Array<{ id: number, version: number | null, theme: { id: number, type: string, sequence: number | null, anime: { year: number | null, season: string | null, slug: string, name: string, images: Array<{ link: string, facet: string | null }> }, group: { name: string, slug: string } | null, song: { title: string | null, performances: Array<{ as: string | null, artist: { slug: string, name: string } }> } | null } }>, audio: { basename: string } }, previous: { id: string } | null, next: { id: string } | null }>, user: { name: string } } | null };
export type PlaylistDetailPagePlaylistQuery = { playlist: { id: string, name: string, description: string | null, visibility: PlaylistVisibility, tracks_count: number, tracks: Array<{ id: string, video: { id: number, basename: string, tags: string, entries: Array<{ id: number, version: number | null, theme: { id: number, type: string, sequence: number | null, anime: { year: number | null, season: string | null, slug: string, name: string, images: Array<{ link: string, facet: string | null }> }, group: { name: string, slug: string } | null, song: { title: string | null, performances: Array<{ as: string | null, artist: { slug: string, name: string } }> } | null } }>, audio: { basename: string } }, previous: { id: string } | null, next: { id: string } | null }>, user: { name: string } } | null };
export type PlaylistDetailPageMeQueryVariables = Exact<{ [key: string]: never; }>;
export type PlaylistDetailPageMeQuery = { me: { user: { name: string } | null } };
export type PlaylistDetailPagePlaylistFragment = { id: string, name: string, visibility: PlaylistVisibility, tracks_count: number, tracks: Array<{ id: string, video: { id: number, basename: string, tags: string, entries: Array<{ id: number, version: number | null, theme: { id: number, type: string, sequence: number | null, anime: { year: number | null, season: string | null, slug: string, name: string, images: Array<{ link: string, facet: string | null }> }, group: { name: string, slug: string } | null, song: { title: string | null, performances: Array<{ as: string | null, artist: { slug: string, name: string } }> } | null } }>, audio: { basename: string } }, previous: { id: string } | null, next: { id: string } | null }>, user: { name: string } };
export type PlaylistDetailPagePlaylistFragment = { id: string, name: string, description: string | null, visibility: PlaylistVisibility, tracks_count: number, tracks: Array<{ id: string, video: { id: number, basename: string, tags: string, entries: Array<{ id: number, version: number | null, theme: { id: number, type: string, sequence: number | null, anime: { year: number | null, season: string | null, slug: string, name: string, images: Array<{ link: string, facet: string | null }> }, group: { name: string, slug: string } | null, song: { title: string | null, performances: Array<{ as: string | null, artist: { slug: string, name: string } }> } | null } }>, audio: { basename: string } }, previous: { id: string } | null, next: { id: string } | null }>, user: { name: string } };
type PlaylistDetailPageUser_UserAuth_Fragment = { name: string };
@@ -890,7 +891,7 @@ export type PlaylistDetailPageQueryVariables = Exact<{
}>;
export type PlaylistDetailPageQuery = { playlist: { id: string, name: string, visibility: PlaylistVisibility, tracks_count: number, tracks: Array<{ id: string, video: { id: number, basename: string, tags: string, entries: Array<{ id: number, version: number | null, theme: { id: number, type: string, sequence: number | null, anime: { year: number | null, season: string | null, slug: string, name: string, images: Array<{ link: string, facet: string | null }> }, group: { name: string, slug: string } | null, song: { title: string | null, performances: Array<{ as: string | null, artist: { slug: string, name: string } }> } | null } }>, audio: { basename: string } }, previous: { id: string } | null, next: { id: string } | null }>, user: { name: string } } | null, me: { user: { name: string } | null } };
export type PlaylistDetailPageQuery = { playlist: { id: string, name: string, description: string | null, visibility: PlaylistVisibility, tracks_count: number, tracks: Array<{ id: string, video: { id: number, basename: string, tags: string, entries: Array<{ id: number, version: number | null, theme: { id: number, type: string, sequence: number | null, anime: { year: number | null, season: string | null, slug: string, name: string, images: Array<{ link: string, facet: string | null }> }, group: { name: string, slug: string } | null, song: { title: string | null, performances: Array<{ as: string | null, artist: { slug: string, name: string } }> } | null } }>, audio: { basename: string } }, previous: { id: string } | null, next: { id: string } | null }>, user: { name: string } } | null, me: { user: { name: string } | null } };
export type GalleryPageQueryVariables = Exact<{ [key: string]: never; }>;
+2 -2
View File
@@ -96,12 +96,12 @@ const resolvers: IResolvers = {
pagination: true
}),
playlist: apiResolver({
endpoint: (_, { id }) => `/playlist/${id}?fields[playlist]=id,name,visibility,tracks_count`,
endpoint: (_, { id }) => `/playlist/${id}?fields[playlist]=id,name,description,visibility,tracks_count`,
extractor: (result) => result.playlist,
}),
playlistAll: apiResolver({
endpoint: (_, { orderBy, orderDesc, onlyNonEmpty }) =>
`/playlist?sort=${orderDesc ? "-" : ""}${orderBy}&fields[playlist]=id,name,visibility,tracks_count${onlyNonEmpty ? "&filter[playlist][tracks_count-gte]=1" : ""}`,
`/playlist?sort=${orderDesc ? "-" : ""}${orderBy}&fields[playlist]=id,name,description,visibility,tracks_count${onlyNonEmpty ? "&filter[playlist][tracks_count-gte]=1" : ""}`,
extractor: (result) => result.playlists,
pagination: true,
}),
+1
View File
@@ -244,6 +244,7 @@ const typeDefs = `
type Playlist {
id: String!
name: String!
description: String
visibility: PlaylistVisibility!
tracks_count: Int!
tracks: [PlaylistTrack!]!
+6 -1
View File
@@ -121,7 +121,12 @@ export default function MyApp({ Component, pageProps }: AppProps) {
useEffect(() => {
const hotkeyListener = (event: KeyboardEvent) => {
if (!pageProps.isSearch && ((event.key === "s" && event.ctrlKey) || event.key === "/")) {
if (
!pageProps.isSearch &&
!(event.target instanceof HTMLInputElement) &&
!(event.target instanceof HTMLTextAreaElement) &&
((event.key === "s" && event.ctrlKey) || event.key === "/")
) {
event.preventDefault();
router.push("/search");
}
+236 -76
View File
@@ -1,19 +1,33 @@
import { useContext, useMemo, useState } from "react";
import { memo, startTransition, useCallback, useContext, useMemo, useState } from "react";
import styled from "styled-components";
import type { GetServerSideProps } from "next";
import { useRouter } from "next/router";
import { faEllipsisV, faGrid, faListMusic, faMinus, faPlus, faShuffle, faTrophy } from "@fortawesome/pro-solid-svg-icons";
import {
faArrowTurnDownRight,
faArrowTurnRight,
faCheck,
faEllipsisV,
faGripVertical,
faPen,
faPlus,
faShuffle,
faTrash,
faTrophy,
faXmark
} from "@fortawesome/pro-solid-svg-icons";
import { isAxiosError } from "axios";
import { Reorder, useDragControls } from "framer-motion";
import gql from "graphql-tag";
import { shuffle } from "lodash-es";
import type { ParsedUrlQuery } from "querystring";
import useSWR from "swr";
import useSWR, { mutate } from "swr";
import { Column } from "@/components/box/Flex";
import { Column, Row } from "@/components/box/Flex";
import { Button } from "@/components/button/Button";
import { FilterToggleButton } from "@/components/button/FilterToggleButton";
import { IconTextButton } from "@/components/button/IconTextButton";
import { Card } from "@/components/card/Card";
import { VideoSummaryCard, VideoSummaryCardFragmentVideo } from "@/components/card/VideoSummaryCard";
import { SidebarContainer } from "@/components/container/SidebarContainer";
import { DescriptionList } from "@/components/description-list/DescriptionList";
@@ -21,14 +35,17 @@ import { PlaylistEditDialog } from "@/components/dialog/PlaylistEditDialog";
import { PlaylistTrackAddDialog } from "@/components/dialog/PlaylistTrackAddDialog";
import { PlaylistTrackRemoveDialog } from "@/components/dialog/PlaylistTrackRemoveDialog";
import { FeaturedTheme } from "@/components/featured-theme/FeaturedTheme";
import { TextArea } from "@/components/form/TextArea";
import { Icon } from "@/components/icon/Icon";
import { MultiCoverImage } from "@/components/image/MultiCoverImage";
import { Menu, MenuContent, MenuItem, MenuTrigger } from "@/components/menu/Menu";
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "@/components/menu/Menu";
import { SearchFilterGroup } from "@/components/search-filter/SearchFilterGroup";
import { SearchFilterSortBy } from "@/components/search-filter/SearchFilterSortBy";
import { SEO } from "@/components/seo/SEO";
import { Text } from "@/components/text/Text";
import { Busy } from "@/components/utils/Busy";
import { Collapse } from "@/components/utils/Collapse";
import { HeightTransition } from "@/components/utils/HeightTransition";
import PlayerContext, { createWatchListItem } from "@/context/playerContext";
import type {
PlaylistDetailPageMeQuery,
@@ -70,7 +87,7 @@ const StyledHeader = styled.div`
align-items: center;
gap: 16px;
& > :last-child {
& > :last-child:not(:first-child) {
margin-inline-start: auto;
}
`;
@@ -197,11 +214,14 @@ export default function PlaylistDetailPage({ playlist: initialPlaylist, me: init
const [ showFilter, toggleShowFilter ] = useToggle();
const [ sortBy, setSortBy ] = useState(UNSORTED);
const [ isDescriptionEditable, setDescriptionEditable ] = useState(false);
const [ description, setDescription ] = useState(playlist.description ?? "");
const isOwner = me.user?.name === playlist.user.name;
const isRanking = playlist.name.startsWith("[#] ");
const tracks = [...playlist.tracks].map((track, index) => ({ ...track, rank: index + 1 }));
const tracksSorted = [...tracks].sort(
const tracks = useMemo(() => [...playlist.tracks].map((track, index) => ({ ...track, rank: index + 1 })), [playlist.tracks]);
const tracksSorted = useMemo(() => [...tracks].sort(
sortTransformed(
getComparator(sortBy) ?? getRankComparator(sortBy),
(track) => {
@@ -222,16 +242,16 @@ export default function PlaylistDetailPage({ playlist: initialPlaylist, me: init
}
}
)
);
), [sortBy, tracks]);
function playAll(initiatingVideoIndex: number) {
const playAll = useCallback((initiatingVideoIndex: number) => {
const watchList = tracksSorted.map((track) => createWatchListItem(track.video));
setWatchList(watchList, true);
setWatchListFactory(null);
setCurrentWatchListItem(watchList[initiatingVideoIndex]);
}
}, [setCurrentWatchListItem, setWatchList, setWatchListFactory, tracksSorted]);
function shuffleAll() {
const shuffleAll = useCallback(() => {
if (tracksSorted.length === 0) {
return;
}
@@ -249,16 +269,16 @@ export default function PlaylistDetailPage({ playlist: initialPlaylist, me: init
const videoSlug = createVideoSlug(theme, entry, video);
void router.push(`/anime/${anime.slug}/${videoSlug}`);
}
}
}, [router, setCurrentWatchListItem, setWatchList, setWatchListFactory, tracksSorted]);
async function updateTrackOrder(newTracks: typeof tracks) {
const updateTrackOrder = useCallback(async (newTracks: typeof tracks) => {
await mutate({
...playlist,
tracks: newTracks,
}, { revalidate: false });
}
}, [mutate, playlist]);
async function updateTrackOrderRemote(trackId: string) {
const updateTrackOrderRemote = useCallback(async (trackId: string) => {
const trackIndex = tracks.findIndex((track) => track.id === trackId);
const nextId = tracks[trackIndex + 1]?.id;
@@ -272,7 +292,7 @@ export default function PlaylistDetailPage({ playlist: initialPlaylist, me: init
await mutate();
}
}
}, [mutate, playlist.id, tracks]);
const coverImageResources = useMemo(() => playlist.tracks.flatMap((track) => {
const anime = track.video.entries[0].theme?.anime;
@@ -294,9 +314,14 @@ export default function PlaylistDetailPage({ playlist: initialPlaylist, me: init
resourcesWithImages={coverImageResources}
/>
</StyledDesktopOnly>
{isOwner ? (
<PlaylistEditDialog playlist={playlist} />
) : null}
{isOwner && (
<Column style={{ "--gap": "16px" }}>
<PlaylistEditDialog playlist={playlist} />
{!isDescriptionEditable && !description && (
<IconTextButton icon={faPlus} variant="solid" onClick={() => setDescriptionEditable(true)}>Add Description</IconTextButton>
)}
</Column>
)}
<DescriptionList>
<DescriptionList.Item title="Playlist by">
<Text link>{playlist.user.name}</Text>
@@ -304,6 +329,16 @@ export default function PlaylistDetailPage({ playlist: initialPlaylist, me: init
</DescriptionList>
</Column>
<Column style={{ "--gap": "24px" }}>
{(isDescriptionEditable || description) ? (
<Description
playlist={playlist}
description={description}
setDescription={setDescription}
isEditable={isDescriptionEditable}
setEditable={setDescriptionEditable}
isOwner={isOwner}
/>
) : null}
{(isRanking && topRankedTrack) ? (
<FeaturedTheme
theme={{
@@ -338,7 +373,7 @@ export default function PlaylistDetailPage({ playlist: initialPlaylist, me: init
</StyledHeader>
<Collapse collapse={!showFilter}>
<SearchFilterGroup>
<SearchFilterSortBy value={sortBy} setValue={setSortBy}>
<SearchFilterSortBy value={sortBy} setValue={(sortBy) => startTransition(() => setSortBy(sortBy))}>
<SearchFilterSortBy.Option value={UNSORTED}>Custom</SearchFilterSortBy.Option>
<SearchFilterSortBy.Option value={RANK_DESC}>Reversed</SearchFilterSortBy.Option>
<SearchFilterSortBy.Option value={SONG_A_Z}>A Z (Song)</SearchFilterSortBy.Option>
@@ -350,43 +385,159 @@ export default function PlaylistDetailPage({ playlist: initialPlaylist, me: init
</SearchFilterSortBy>
</SearchFilterGroup>
</Collapse>
<StyledReorderContainer>
{(sortBy === UNSORTED && isOwner) ? (
<Reorder.Group as="div" axis="y" values={tracksSorted} onReorder={updateTrackOrder}>
{tracksSorted.map((track, index) => (
<PlaylistTrack
key={track.id}
playlist={playlist}
track={track}
isOwner={isOwner}
isRanking={isRanking}
isDraggable
onPlay={() => playAll(index)}
onDragEnd={() => updateTrackOrderRemote(track.id)}
/>
))}
</Reorder.Group>
) : (
<div>
{tracksSorted.map((track, index) => (
<PlaylistTrack
key={track.id}
playlist={playlist}
track={track}
isOwner={isOwner}
isRanking={isRanking}
onPlay={() => playAll(index)}
/>
))}
</div>
)}
</StyledReorderContainer>
<PlaylistTrackList
playlist={playlist}
tracks={tracksSorted}
isReorderable={sortBy === UNSORTED && isOwner}
isOwner={isOwner}
isRanking={isRanking}
playAll={playAll}
updateTrackOrderRemote={updateTrackOrderRemote}
updateTrackOrder={updateTrackOrder}
/>
</Column>
</SidebarContainer>
</>
);
}
interface DescriptionProps {
playlist: PlaylistDetailPageProps["playlist"];
description: string;
setDescription: (newValue: string) => void;
isEditable: boolean;
setEditable: (newIsEditable: boolean) => void;
isOwner: boolean;
}
function Description({ playlist, description, setDescription, isEditable, setEditable, isOwner }: DescriptionProps) {
const [ isCollapsed, setCollapsed ] = useState(true);
const [isBusy, setBusy] = useState(false);
const [error, setError] = useState("");
async function submit() {
setBusy(true);
setError("");
try {
await axios.put(`/playlist/${playlist.id}`, {
description,
});
await mutate((key) => (
[key].flat().some((key) =>
key === `/api/playlist/${playlist.id}` ||
key === "/api/me/playlist"
)
));
} catch (error: unknown) {
if (isAxiosError(error) && error.response) {
setError(error.response.data.message ?? "An unknown error occured!");
}
return;
} finally {
setBusy(false);
}
setEditable(false);
}
function cancel() {
setDescription(playlist.description ?? "");
setEditable(false);
}
return (
<>
<StyledHeader>
<Text variant="h2">Description</Text>
{isOwner ? (isEditable ? (
<Row>
{!isBusy && <IconTextButton icon={faXmark} collapsible onClick={() => cancel()}>Cancel</IconTextButton>}
<Busy isBusy={isBusy}>
<IconTextButton icon={faCheck} collapsible onClick={() => submit()}>Save</IconTextButton>
</Busy>
</Row>
) : (
<IconTextButton icon={faPen} collapsible onClick={() => setEditable(!isEditable)}>Edit</IconTextButton>
)) : null}
</StyledHeader>
{(isOwner && isEditable) ? (
<div>
<TextArea value={description} onChange={(event) => setDescription(event.target.value)} rows={5} maxLength={1000} placeholder="Write your description here" />
<Text variant="small" color="text-muted">{description.length} / 1000</Text>
</div>
) : (
<Card hoverable onClick={() => setCollapsed(!isCollapsed)}>
<HeightTransition>
<Text as="p" maxLines={isCollapsed ? 2 : null}>{description}</Text>
</HeightTransition>
</Card>
)}
{error ? (
<Text color="text-warning"><strong>The playlist could not be updated: </strong>{error}</Text>
) : null}
</>
);
}
interface PlaylistTrackListProps {
playlist: PlaylistDetailPageProps["playlist"];
tracks: Array<PlaylistDetailPageProps["playlist"]["tracks"][number] & { rank: number }>;
isReorderable: boolean;
isOwner: boolean;
isRanking: boolean;
playAll: (index: number) => void;
updateTrackOrderRemote: (trackId: string) => void;
updateTrackOrder: (tracks: Array<PlaylistDetailPageProps["playlist"]["tracks"][number] & { rank: number }>) => void;
}
const PlaylistTrackList = memo(function PlaylistTrackList({
playlist,
tracks,
isReorderable,
isOwner,
isRanking,
playAll,
updateTrackOrderRemote,
updateTrackOrder
}: PlaylistTrackListProps) {
return (
<StyledReorderContainer>
{isReorderable ? (
<Reorder.Group as="div" axis="y" values={tracks} onReorder={updateTrackOrder}>
{tracks.map((track, index) => (
<PlaylistTrack
key={track.id}
playlist={playlist}
track={track}
isOwner={isOwner}
isRanking={isRanking}
isDraggable
onPlay={() => playAll(index)}
onDragEnd={() => updateTrackOrderRemote(track.id)}
/>
))}
</Reorder.Group>
) : (
<div>
{tracks.map((track, index) => (
<PlaylistTrack
key={track.id}
playlist={playlist}
track={track}
isOwner={isOwner}
isRanking={isRanking}
onPlay={() => playAll(index)}
/>
))}
</div>
)}
</StyledReorderContainer>
);
});
const StyledDragHandle = styled(Icon)`
cursor: grab;
user-select: none;
@@ -408,7 +559,7 @@ interface PlaylistTrackProps {
}
function PlaylistTrack({ playlist, track, isOwner, isRanking, isDraggable, onPlay, onDragEnd }: PlaylistTrackProps) {
const { addWatchListItem, addWatchListItemNext } = useContext(PlayerContext);
const { watchList, addWatchListItem, addWatchListItemNext } = useContext(PlayerContext);
const controls = useDragControls();
const element = (
@@ -428,36 +579,44 @@ function PlaylistTrack({ playlist, track, isOwner, isRanking, isDraggable, onPla
video={track.video}
trigger={
<MenuItem onSelect={(event) => event.preventDefault()}>
<Icon icon={faListMusic}/>
<Text>Add to Playlist</Text>
<Icon icon={faPlus} color="text-disabled" />
<Text>Add to another Playlist</Text>
</MenuItem>
}
/>
{isOwner ? (
<PlaylistTrackRemoveDialog
playlist={playlist}
trackId={track.id}
video={track.video}
trigger={
<MenuItem onSelect={(event) => event.preventDefault()}>
<Icon icon={faMinus}/>
<Text>Remove from Playlist</Text>
</MenuItem>
}
/>
{watchList.length ? (
<>
<MenuSeparator />
<MenuItem onSelect={() => addWatchListItem(track.video)}>
<Icon icon={faArrowTurnDownRight} color="text-disabled" />
<Text>Add to Watch List</Text>
</MenuItem>
<MenuItem onSelect={() => addWatchListItemNext(track.video)}>
<Icon icon={faArrowTurnRight} color="text-disabled" />
<Text>Play Next</Text>
</MenuItem>
</>
) : null}
{isOwner ? (
<>
<MenuSeparator />
<PlaylistTrackRemoveDialog
playlist={playlist}
trackId={track.id}
video={track.video}
trigger={
<MenuItem onSelect={(event) => event.preventDefault()}>
<Icon icon={faTrash} color="text-disabled" />
<Text>Remove from Playlist</Text>
</MenuItem>
}
/>
</>
) : null}
<MenuItem onSelect={() => addWatchListItem(track.video)}>
<Icon icon={faPlus}/>
<Text>Add to Watch List</Text>
</MenuItem>
<MenuItem onSelect={() => addWatchListItemNext(track.video)}>
<Icon icon={faPlus}/>
<Text>Play Next</Text>
</MenuItem>
</MenuContent>
</Menu>
}
append={isDraggable ? <StyledDragHandle icon={faGrid} color="text-disabled" onPointerDown={(event) => controls.start(event)} /> : null}
append={isDraggable ? <StyledDragHandle icon={faGripVertical} color="text-disabled" onPointerDown={(event) => controls.start(event)} /> : null}
/>
{isRanking ? (
<StyledRank>{track.rank === 1 ? (
@@ -489,6 +648,7 @@ PlaylistDetailPage.fragments = {
...PlaylistTrackRemoveDialogPlaylist
id
name
description
visibility
tracks_count
tracks {
+1 -1
View File
@@ -98,7 +98,7 @@ export default createGlobalStyle`
-webkit-appearance: none;
}
input {
input, textarea {
width: 100%;
border: none;
outline: none;