mirror of
https://github.com/AnimeThemes/animethemes-web.git
synced 2026-07-11 01:24:31 +02:00
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:
Generated
+33
-33
@@ -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
@@ -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">
|
||||
|
||||
@@ -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"]};
|
||||
`}
|
||||
}
|
||||
`;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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; }>;
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -244,6 +244,7 @@ const typeDefs = `
|
||||
type Playlist {
|
||||
id: String!
|
||||
name: String!
|
||||
description: String
|
||||
visibility: PlaylistVisibility!
|
||||
tracks_count: Int!
|
||||
tracks: [PlaylistTrack!]!
|
||||
|
||||
+6
-1
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -98,7 +98,7 @@ export default createGlobalStyle`
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
input {
|
||||
input, textarea {
|
||||
width: 100%;
|
||||
border: none;
|
||||
outline: none;
|
||||
|
||||
Reference in New Issue
Block a user