mirror of
https://github.com/AnimeThemes/animethemes-web.git
synced 2026-07-11 01:24:31 +02:00
feat: Added support for entries in playlist tracks (#223)
* Added link to API docs to footer. * Fixed global search returning wrong theme groups.
This commit is contained in:
@@ -24,7 +24,7 @@ const StyledThemeCard = styled(Card)`
|
||||
|
||||
const StyledRow = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: 2rem 1fr auto;
|
||||
grid-template-columns: 4ch 1fr auto;
|
||||
align-items: baseline;
|
||||
|
||||
grid-gap: 1rem;
|
||||
|
||||
@@ -11,7 +11,7 @@ import { TextLink } from "@/components/text/TextLink";
|
||||
import { Performances } from "@/components/utils/Performances";
|
||||
import { SongTitle } from "@/components/utils/SongTitle";
|
||||
import { SongTitleWithArtists } from "@/components/utils/SongTitleWithArtists";
|
||||
import type { VideoSummaryCardVideoFragment } from "@/generated/graphql";
|
||||
import type { VideoSummaryCardEntryFragment, VideoSummaryCardVideoFragment } from "@/generated/graphql";
|
||||
import theme from "@/theme";
|
||||
import createVideoSlug from "@/utils/createVideoSlug";
|
||||
import extractImages from "@/utils/extractImages";
|
||||
@@ -55,6 +55,7 @@ const StyledCoverOverlay = styled.div`
|
||||
|
||||
interface VideoSummaryCardProps {
|
||||
video: VideoSummaryCardVideoFragment;
|
||||
entry?: VideoSummaryCardEntryFragment;
|
||||
menu?: ReactNode;
|
||||
append?: ReactNode;
|
||||
onPlay?(): void;
|
||||
@@ -62,10 +63,10 @@ interface VideoSummaryCardProps {
|
||||
}
|
||||
|
||||
export const VideoSummaryCard = forwardRef(function VideoSummaryCard(
|
||||
{ video, menu, append, onPlay, isPlaying, ...props }: VideoSummaryCardProps,
|
||||
{ video, entry: filterEntry, menu, append, onPlay, isPlaying, ...props }: VideoSummaryCardProps,
|
||||
ref: ForwardedRef<HTMLDivElement>,
|
||||
) {
|
||||
const entry = video.entries[0];
|
||||
const entry = video.entries.find((entry) => entry.id === filterEntry?.id) ?? video.entries[0];
|
||||
const theme = entry.theme;
|
||||
const anime = theme?.anime;
|
||||
|
||||
@@ -148,3 +149,9 @@ export const VideoSummaryCardFragmentVideo = gql`
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const VideoSummaryCardFragmentEntry = gql`
|
||||
fragment VideoSummaryCardEntry on Entry {
|
||||
id
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -21,6 +21,7 @@ import { PlaylistTrackRemoveToast } from "@/components/toast/PlaylistTrackRemove
|
||||
import { Busy } from "@/components/utils/Busy";
|
||||
import { useToasts } from "@/context/toastContext";
|
||||
import type {
|
||||
PlaylistTrackAddDialogEntryFragment,
|
||||
PlaylistTrackAddDialogVideoFragment,
|
||||
PlaylistTrackAddFormPlaylistQuery,
|
||||
PlaylistTrackAddFormPlaylistQueryVariables,
|
||||
@@ -30,10 +31,11 @@ import axios from "@/lib/client/axios";
|
||||
|
||||
interface PlaylistTrackAddDialogProps {
|
||||
video: PlaylistTrackAddDialogVideoFragment;
|
||||
entry: PlaylistTrackAddDialogEntryFragment;
|
||||
trigger?: ReactNode;
|
||||
}
|
||||
|
||||
export function PlaylistTrackAddDialog({ video, trigger }: PlaylistTrackAddDialogProps) {
|
||||
export function PlaylistTrackAddDialog({ video, entry, trigger }: PlaylistTrackAddDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -49,7 +51,7 @@ export function PlaylistTrackAddDialog({ video, trigger }: PlaylistTrackAddDialo
|
||||
{/* Only render the form when dialog is open, so it will reset after closing. */}
|
||||
{open ? (
|
||||
<LoginGate>
|
||||
<PlaylistTrackAddForm video={video} onCancel={() => setOpen(false)} />
|
||||
<PlaylistTrackAddForm video={video} entry={entry} onCancel={() => setOpen(false)} />
|
||||
</LoginGate>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
@@ -68,14 +70,20 @@ PlaylistTrackAddDialog.fragments = {
|
||||
id
|
||||
}
|
||||
`,
|
||||
entry: gql`
|
||||
fragment PlaylistTrackAddDialogEntry on Entry {
|
||||
id
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
interface PlaylistTrackAddFormProps {
|
||||
video: PlaylistTrackAddDialogVideoFragment;
|
||||
entry: PlaylistTrackAddDialogEntryFragment;
|
||||
onCancel(): void;
|
||||
}
|
||||
|
||||
function PlaylistTrackAddForm({ video, onCancel }: PlaylistTrackAddFormProps) {
|
||||
function PlaylistTrackAddForm({ video, entry, onCancel }: PlaylistTrackAddFormProps) {
|
||||
const { data: playlists } = useSWR(["PlaylistTrackAddFormPlaylist", "/api/me/playlist", video.id], async () => {
|
||||
const { data } = await fetchDataClient<
|
||||
PlaylistTrackAddFormPlaylistQuery,
|
||||
@@ -135,7 +143,7 @@ function PlaylistTrackAddForm({ video, onCancel }: PlaylistTrackAddFormProps) {
|
||||
<Column style={{ "--gap": "16px" }}>
|
||||
{playlists?.length ? (
|
||||
playlists.map((playlist) => (
|
||||
<PlaylistTrackAddCard key={playlist.id} playlist={playlist} video={video} />
|
||||
<PlaylistTrackAddCard key={playlist.id} playlist={playlist} video={video} entry={entry} />
|
||||
))
|
||||
) : (
|
||||
<Text>You have not created a playlist, yet.</Text>
|
||||
@@ -162,9 +170,10 @@ interface PlaylistTrackAddCardProps {
|
||||
playlist: NonNullable<PlaylistTrackAddFormPlaylistQuery["me"]["playlistAll"]>[number] &
|
||||
Partial<NonNullable<PlaylistTrackAddFormPlaylistQuery["me"]["playlistAllFiltered"]>[number]>;
|
||||
video: PlaylistTrackAddDialogVideoFragment;
|
||||
entry: PlaylistTrackAddDialogEntryFragment;
|
||||
}
|
||||
|
||||
function PlaylistTrackAddCard({ playlist, video }: PlaylistTrackAddCardProps) {
|
||||
function PlaylistTrackAddCard({ playlist, video, entry }: PlaylistTrackAddCardProps) {
|
||||
const { dispatchToast } = useToasts();
|
||||
|
||||
const [isBusy, setBusy] = useState(false);
|
||||
@@ -173,7 +182,7 @@ function PlaylistTrackAddCard({ playlist, video }: PlaylistTrackAddCardProps) {
|
||||
setBusy(true);
|
||||
|
||||
try {
|
||||
await axios.post(`/playlist/${playlist.id}/track`, { video_id: video.id });
|
||||
await axios.post(`/playlist/${playlist.id}/track`, { video_id: video.id, entry_id: entry.id });
|
||||
await mutate((key) =>
|
||||
[key].flat().some((key) => key === `/api/playlist/${playlist.id}` || key === "/api/me/playlist"),
|
||||
);
|
||||
|
||||
@@ -53,9 +53,12 @@ export function Footer() {
|
||||
<StyledFooter>
|
||||
<StyledContainer>
|
||||
<StyledLinkList>
|
||||
<FooterTextLink href="/about/transparency">Transparency</FooterTextLink>
|
||||
<FooterTextLink href="/about/donate">Donate</FooterTextLink>
|
||||
<FooterTextLink href="/about/faq">FAQ</FooterTextLink>
|
||||
<FooterTextLink href="/about/donate">Donate</FooterTextLink>
|
||||
<FooterTextLink href="/about/transparency">Transparency</FooterTextLink>
|
||||
<FooterTextLink as="a" href="https://api-docs.animethemes.moe" target="_blank">
|
||||
API Documentation
|
||||
</FooterTextLink>
|
||||
</StyledLinkList>
|
||||
<StyledLinkList>
|
||||
<FooterTextLink href="/about/terms-of-service">Terms of Service</FooterTextLink>
|
||||
|
||||
@@ -49,6 +49,7 @@ export function ThemeMenu({ theme }: ThemeMenuProps) {
|
||||
<MenuContent>
|
||||
<PlaylistTrackAddDialog
|
||||
video={videoFlipped}
|
||||
entry={entry}
|
||||
trigger={
|
||||
<MenuItem onSelect={(event) => event.preventDefault()}>
|
||||
<Icon icon={faPlus} />
|
||||
|
||||
@@ -189,6 +189,7 @@ export function VideoPlayerBar() {
|
||||
},
|
||||
],
|
||||
}}
|
||||
entry={entry}
|
||||
trigger={
|
||||
<IconTextButton icon={faPlus} variant="solid" collapsible="socialListMax">
|
||||
Add to Playlist
|
||||
|
||||
@@ -89,6 +89,7 @@ export function VideoPlayerOverlay({ anime, themeIndex, entryIndex, videoIndex }
|
||||
},
|
||||
],
|
||||
}}
|
||||
entry={entry}
|
||||
trigger={<StyledOverlayButton icon={faPlus} isCircle title="Add to playlist" />}
|
||||
/>
|
||||
<ShareMenu
|
||||
|
||||
@@ -253,6 +253,7 @@ export type PlaylistSearchResult = EntitySearchResult & {
|
||||
|
||||
export type PlaylistTrack = {
|
||||
__typename?: "PlaylistTrack";
|
||||
entry: Entry;
|
||||
id: Scalars["String"]["output"];
|
||||
next?: Maybe<PlaylistTrack>;
|
||||
playlist: Playlist;
|
||||
@@ -1112,6 +1113,7 @@ export type PlaylistTrackResolvers<
|
||||
ContextType = any,
|
||||
ParentType extends ResolversParentTypes["PlaylistTrack"] = ResolversParentTypes["PlaylistTrack"],
|
||||
> = {
|
||||
entry?: Resolver<ResolversTypes["Entry"], ParentType, ContextType>;
|
||||
id?: Resolver<ResolversTypes["String"], ParentType, ContextType>;
|
||||
next?: Resolver<Maybe<ResolversTypes["PlaylistTrack"]>, ParentType, ContextType>;
|
||||
playlist?: Resolver<ResolversTypes["Playlist"], ParentType, ContextType>;
|
||||
|
||||
@@ -196,6 +196,7 @@ export type PlaylistSearchResult = EntitySearchResult & {
|
||||
};
|
||||
|
||||
export type PlaylistTrack = {
|
||||
entry: Entry;
|
||||
id: Scalars["String"]["output"];
|
||||
next: Maybe<PlaylistTrack>;
|
||||
playlist: Playlist;
|
||||
@@ -743,6 +744,8 @@ export type VideoSummaryCardVideoFragment = {
|
||||
audio: { basename: string };
|
||||
};
|
||||
|
||||
export type VideoSummaryCardEntryFragment = { id: number };
|
||||
|
||||
export type PlaylistEditDialogPlaylistFragment = { id: string; name: string; visibility: PlaylistVisibility };
|
||||
|
||||
export type PlaylistRemoveDialogPlaylistFragment = {
|
||||
@@ -774,6 +777,8 @@ export type PlaylistTrackAddDialogVideoFragment = {
|
||||
audio: { basename: string };
|
||||
};
|
||||
|
||||
export type PlaylistTrackAddDialogEntryFragment = { id: number };
|
||||
|
||||
export type PlaylistTrackAddFormPlaylistQueryVariables = Exact<{
|
||||
filterVideoId: Scalars["Int"]["input"];
|
||||
}>;
|
||||
@@ -2395,6 +2400,7 @@ export type PlaylistDetailPagePlaylistQuery = {
|
||||
}>;
|
||||
audio: { basename: string };
|
||||
};
|
||||
entry: { id: number };
|
||||
previous: { id: string } | null;
|
||||
next: { id: string } | null;
|
||||
}>;
|
||||
@@ -2441,6 +2447,7 @@ export type PlaylistDetailPagePlaylistFragment = {
|
||||
}>;
|
||||
audio: { basename: string };
|
||||
};
|
||||
entry: { id: number };
|
||||
previous: { id: string } | null;
|
||||
next: { id: string } | null;
|
||||
}>;
|
||||
@@ -2495,6 +2502,7 @@ export type PlaylistDetailPageQuery = {
|
||||
}>;
|
||||
audio: { basename: string };
|
||||
};
|
||||
entry: { id: number };
|
||||
previous: { id: string } | null;
|
||||
next: { id: string } | null;
|
||||
}>;
|
||||
|
||||
@@ -121,18 +121,18 @@ export const searchResolvers: Resolvers = {
|
||||
);
|
||||
searchParams.append("include[artist]", "images,songs");
|
||||
searchParams.append("include[playlist]", "user");
|
||||
searchParams.append("fields[anime]", "name,slug,year,season");
|
||||
searchParams.append("fields[animetheme]", "type,sequence,id");
|
||||
searchParams.append("fields[group]", "name,slug");
|
||||
searchParams.append("fields[anime]", "id,name,slug,year,season");
|
||||
searchParams.append("fields[animetheme]", "id,type,sequence");
|
||||
searchParams.append("fields[group]", "id,name,slug");
|
||||
searchParams.append("fields[animethemeentry]", "id,version,episodes,spoiler,nsfw");
|
||||
searchParams.append("fields[video]", "id,tags,resolution,nc,subbed,lyrics,uncen,source,overlap,basename");
|
||||
searchParams.append("fields[image]", "facet,link");
|
||||
searchParams.append("fields[image]", "id,facet,link");
|
||||
searchParams.append("fields[song]", "id,title");
|
||||
searchParams.append("fields[artist]", "name,slug");
|
||||
searchParams.append("fields[series]", "name,slug");
|
||||
searchParams.append("fields[studio]", "name,slug");
|
||||
searchParams.append("fields[artist]", "id,name,slug");
|
||||
searchParams.append("fields[series]", "id,name,slug");
|
||||
searchParams.append("fields[studio]", "id,name,slug");
|
||||
searchParams.append("fields[playlist]", "id,name,visibility,tracks_count");
|
||||
searchParams.append("fields[user]", "name");
|
||||
searchParams.append("fields[user]", "id,name");
|
||||
|
||||
const result = await fetchJson<GlobalSearchResult>(`/search?${searchParams}`);
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@ export const INCLUDES = {
|
||||
user: "user",
|
||||
},
|
||||
PlaylistTrack: {
|
||||
entry: "animethemeentry",
|
||||
video: "video",
|
||||
playlist: "playlist",
|
||||
previous: "previous",
|
||||
@@ -178,6 +179,7 @@ const ALLOWED_INCLUDES: Record<string, Array<string>> = {
|
||||
"tracks.video.animethemeentries.animetheme.anime.images",
|
||||
"tracks.video",
|
||||
"tracks.video.animethemeentries.animetheme.group",
|
||||
"tracks.animethemeentry",
|
||||
"tracks.previous",
|
||||
"tracks.next",
|
||||
],
|
||||
@@ -186,6 +188,7 @@ const ALLOWED_INCLUDES: Record<string, Array<string>> = {
|
||||
"video.animethemeentries.animetheme.song.artists",
|
||||
"video.animethemeentries.animetheme.group",
|
||||
"video.audio",
|
||||
"animethemeentry",
|
||||
],
|
||||
UserAuth: ["permissions", "roles.permissions"],
|
||||
FeaturedTheme: [
|
||||
|
||||
@@ -433,6 +433,13 @@ const resolvers: Resolvers = {
|
||||
}),
|
||||
},
|
||||
PlaylistTrack: {
|
||||
entry: createApiResolverNotNull<ApiPlaylistTrackShow<"animethemeentry">>()({
|
||||
extractFromParent: (track) => track.animethemeentry,
|
||||
endpoint: (track) => `/playlist/${track.playlist.id}/track/${track.id}`,
|
||||
extractFromResponse: (response) => response.track.animethemeentry,
|
||||
type: "PlaylistTrack",
|
||||
baseInclude: INCLUDES.PlaylistTrack.entry,
|
||||
}),
|
||||
video: createApiResolverNotNull<ApiPlaylistTrackShow<"video">>()({
|
||||
extractFromParent: (track) => track.video,
|
||||
endpoint: (track) => `/playlist/${track.playlist.id}/track/${track.id}`,
|
||||
|
||||
@@ -259,6 +259,7 @@ const typeDefs = gql`
|
||||
|
||||
type PlaylistTrack {
|
||||
id: String!
|
||||
entry: Entry!
|
||||
video: Video!
|
||||
playlist: Playlist!
|
||||
previous: PlaylistTrack
|
||||
|
||||
@@ -134,6 +134,7 @@ export interface ApiPlaylist {
|
||||
export interface ApiPlaylistTrack {
|
||||
id: string;
|
||||
playlist: ApiPlaylist;
|
||||
animethemeentry?: ApiEntry;
|
||||
video?: ApiVideo;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,11 @@ 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 {
|
||||
VideoSummaryCard,
|
||||
VideoSummaryCardFragmentEntry,
|
||||
VideoSummaryCardFragmentVideo,
|
||||
} from "@/components/card/VideoSummaryCard";
|
||||
import { SidebarContainer } from "@/components/container/SidebarContainer";
|
||||
import { DescriptionList } from "@/components/description-list/DescriptionList";
|
||||
import { PlaylistEditDialog } from "@/components/dialog/PlaylistEditDialog";
|
||||
@@ -306,7 +310,9 @@ export default function PlaylistDetailPage({ playlist: initialPlaylist, me: init
|
||||
const coverImageResources = useMemo(
|
||||
() =>
|
||||
playlist.tracks.flatMap((track) => {
|
||||
const anime = track.video.entries[0].theme?.anime;
|
||||
const entry =
|
||||
track.video.entries.find((entry) => entry.id === track.entry.id) ?? track.video.entries[0];
|
||||
const anime = entry.theme?.anime;
|
||||
|
||||
return anime ? [anime] : [];
|
||||
}),
|
||||
@@ -612,6 +618,7 @@ function PlaylistTrack({ playlist, track, isOwner, isRanking, isDraggable, onPla
|
||||
<StyledSummaryCardWrapper key={track.id}>
|
||||
<VideoSummaryCard
|
||||
video={track.video}
|
||||
entry={track.entry}
|
||||
onPlay={() => onPlay()}
|
||||
menu={
|
||||
<Menu modal={false}>
|
||||
@@ -623,6 +630,7 @@ function PlaylistTrack({ playlist, track, isOwner, isRanking, isDraggable, onPla
|
||||
<MenuContent>
|
||||
<PlaylistTrackAddDialog
|
||||
video={track.video}
|
||||
entry={track.entry}
|
||||
trigger={
|
||||
<MenuItem onSelect={(event) => event.preventDefault()}>
|
||||
<Icon icon={faPlus} color="text-disabled" />
|
||||
@@ -698,6 +706,7 @@ function PlaylistTrack({ playlist, track, isOwner, isRanking, isDraggable, onPla
|
||||
PlaylistDetailPage.fragments = {
|
||||
playlist: gql`
|
||||
${VideoSummaryCardFragmentVideo}
|
||||
${VideoSummaryCardFragmentEntry}
|
||||
${PlaylistEditDialog.fragments.playlist}
|
||||
${PlaylistTrackRemoveDialog.fragments.playlist}
|
||||
|
||||
@@ -723,6 +732,9 @@ PlaylistDetailPage.fragments = {
|
||||
}
|
||||
}
|
||||
}
|
||||
entry {
|
||||
...VideoSummaryCardEntry
|
||||
}
|
||||
previous {
|
||||
id
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user