4 Commits

Author SHA1 Message Date
Maniload 7827a8acd2 fix: Fixed site query parameter not getting appended to external profile auth URL 2025-05-16 01:29:56 +02:00
Kyrch 10d4144313 fix: external profile score should be float (#238) 2025-05-15 16:45:28 +02:00
Maniload a3df88e856 fix: Updated generated types after rebase 2025-04-12 08:17:27 +02:00
Maniload 65234d474c feat: Initial commit for external profiles 2025-04-12 08:10:41 +02:00
35 changed files with 1507 additions and 1334 deletions
+2
View File
@@ -42,6 +42,8 @@ const config: CodegenConfig = {
Performance: "@/lib/common/animethemes/types#ApiPerformance",
Playlist: "@/lib/common/animethemes/types#ApiPlaylist",
PlaylistTrack: "@/lib/common/animethemes/types#ApiPlaylistTrack",
ExternalProfile: "@/lib/common/animethemes/types#ApiExternalProfile",
ExternalProfileEntry: "@/lib/common/animethemes/types#ApiExternalProfileEntry",
Season: "@/lib/common/animethemes/types#ApiSeason",
Series: "@/lib/common/animethemes/types#ApiSeries",
Song: "@/lib/common/animethemes/types#ApiSong",
+1
View File
@@ -24,6 +24,7 @@ const nextConfig: NextConfig = {
},
staticPageGenerationTimeout: 3600,
experimental: {
turbo: {},
// We don't want to multi-thread page building
// to make use of caching between page builds.
workerThreads: false,
+495 -629
View File
File diff suppressed because it is too large Load Diff
+9 -10
View File
@@ -4,7 +4,7 @@
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build --turbopack",
"build": "next build",
"start": "next start",
"predev": "npm run compile-config",
"prebuild": "npm run compile-config",
@@ -23,7 +23,7 @@
"@graphql-tools/merge": "^9.0.16",
"@graphql-tools/schema": "^10.0.15",
"@graphql-tools/utils": "^10.7.1",
"@next/bundle-analyzer": "15.3.0",
"@next/bundle-analyzer": "^15.1.3",
"@radix-ui/react-compose-refs": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4",
@@ -44,12 +44,12 @@
"lodash-es": "^4.17.21",
"md5": "^2.3.0",
"motion": "^11.15.0",
"next": "^15.3.0",
"next": "^15.1.4",
"next-mdx-remote": "^5.0.0",
"p-limit": "^3.1.0",
"picocolors": "^1.0.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"rehype-pretty-code": "^0.14.0",
"rehype-slug": "^6.0.0",
"remark-gfm": "^4.0.0",
@@ -69,11 +69,11 @@
"@types/lodash-es": "^4.17.6",
"@types/md5": "^2.3.5",
"@types/node": "^22.10.5",
"@types/react": "19.1.1",
"@types/react": "^19.0.2",
"@typescript-eslint/eslint-plugin": "^8.19.0",
"esbuild": "^0.27.1",
"esbuild": "^0.24.2",
"eslint": "^8.57.0",
"eslint-config-next": "15.3.0",
"eslint-config-next": "^15.1.3",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-simple-import-sort": "^12.1.1",
"husky": "^9.0.11",
@@ -86,7 +86,6 @@
"ahooks": {
"react": "$react",
"react-dom": "$react-dom"
},
"@types/react": "19.1.1"
}
}
}
+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="a" xmlns="http://www.w3.org/2000/svg" width="135.95" height="102.56" viewBox="0 0 135.95 102.56"><path d="M92.99,76.36V6.24c0-4.02-2.22-6.24-6.24-6.24h-13.72c-4.02,0-6.24,2.22-6.24,6.24v33.3c0,.94,9.04,5.29,9.27,6.21,6.89,26.9,1.5,48.43-5.03,49.44,10.67.53,11.85,5.66,3.9,2.15,1.22-14.35,5.96-14.33,19.6-.53.12.12,2.8,5.74,2.96,5.74h32.22c4.02,0,6.24-2.22,6.24-6.24v-13.72c0-4.02-2.22-6.24-6.24-6.24h-36.73Z" style="fill:#02a9ff; fill-rule:evenodd;"/><path d="M36.03,0L0,102.56h28l6.1-17.74h30.49l5.96,17.74h27.86L62.5,0h-26.47ZM40.47,62.09l8.73-28.41,9.56,28.41h-18.29Z" style="fill:#fefefe; fill-rule:evenodd;"/></svg>

After

Width:  |  Height:  |  Size: 667 B

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="a" xmlns="http://www.w3.org/2000/svg" width="194.72" height="79.18" viewBox="0 0 194.72 79.18"><g id="b"><path id="c" d="M0,0v68.71h17.76V26.8l15.47,19.77,16.68-19.77v41.91h17.76V0h-17.76l-16.68,21.41L17.76,0H0Z" style="fill:#fff;"/><path id="d" d="M151.86,0v68.71h39.08l3.78-14.66h-25.1V0h-17.76Z" style="fill:#fff;"/><path id="e" d="M119.01,0c-21.64,0-35.07,10.21-39.37,25.39-4.2,14.82.34,34.37,10.29,53.79l14.86-10.47s-7.06-9.22-8.39-23.04h21.98v23.04h19.73V17.03h-19.73v14.97h-18.22c1.72-11.2,8.29-17.31,15.47-17.31h25.82l-5.12-14.69h-17.31Z" style="fill:#fff;"/></g></svg>

After

Width:  |  Height:  |  Size: 624 B

+8 -4
View File
@@ -1,4 +1,4 @@
import { Fragment, type MouseEvent } from "react";
import { Fragment, type MouseEvent, type ReactNode } from "react";
import styled from "styled-components";
import { faChevronDown } from "@fortawesome/free-solid-svg-icons";
@@ -60,7 +60,9 @@ const StyledThemeGroupContainer = styled.div`
margin-top: 8px;
`;
type AnimeSummaryCardProps =
type AnimeSummaryCardProps = {
children?: ReactNode;
} & (
| {
anime: AnimeSummaryCardAnimeFragment;
expandable?: false;
@@ -68,9 +70,10 @@ type AnimeSummaryCardProps =
| {
anime: AnimeSummaryCardAnimeFragment & AnimeSummaryCardAnimeExpandableFragment;
expandable: true;
};
}
);
export function AnimeSummaryCard({ anime, expandable = false, ...props }: AnimeSummaryCardProps) {
export function AnimeSummaryCard({ anime, expandable = false, children, ...props }: AnimeSummaryCardProps) {
const [isExpanded, toggleExpanded] = useToggle();
const { smallCover } = extractImages(anime);
const isMobile = useMediaQuery(`(max-width: ${theme.breakpoints.mobileMax})`);
@@ -135,6 +138,7 @@ export function AnimeSummaryCard({ anime, expandable = false, ...props }: AnimeS
)}
</StyledThemeContainerInline>
)}
{children}
</SummaryCard>
{expandable ? (
<Collapse collapse={!isExpanded}>
+1 -1
View File
@@ -19,7 +19,7 @@ const StyledOverlayButtons = styled.div`
opacity: 0;
transition-property: opacity;
${StyledWrapper}:hover &, &:has([data-state="open"]) {
${StyledWrapper}:hover & {
position: static;
opacity: 1;
transition-duration: 250ms;
+7 -7
View File
@@ -40,7 +40,7 @@ const StyledOverlayButtons = styled.div`
opacity: 0;
transition-property: opacity;
${StyledWrapper}:hover &, &:has([data-state="open"]) {
${StyledWrapper}:hover & {
position: static;
opacity: 1;
transition-duration: 250ms;
@@ -105,9 +105,9 @@ export function ThemeSummaryCard({
const videoSlug = createVideoSlug(theme, entry, video);
const href = `/anime/${anime.slug}/${videoSlug}`;
function handleToggleExpand(event: MouseEvent, filterLinks = false) {
if (filterLinks && isLink(event.target)) {
return;
function handleToggleExpand(event: MouseEvent) {
if (isLink(event.target)) {
event.stopPropagation();
} else if (expandable && !isMobile) {
toggleExpanded();
}
@@ -124,11 +124,11 @@ export function ThemeSummaryCard({
return (
<StyledWrapper>
<SummaryCard {...props}>
<SummaryCard onClick={handleToggleExpand} {...props}>
<Link href={href} onClick={() => onPlay?.()}>
<SummaryCard.Cover src={smallCover} />
</Link>
<SummaryCard.Body onClick={(event) => handleToggleExpand(event, true)}>
<SummaryCard.Body>
<SummaryCard.Title>
<SongTitle song={theme.song} href={href} onClick={() => onPlay?.()} />
<Performances song={theme.song} artist={artist} />
@@ -143,7 +143,7 @@ export function ThemeSummaryCard({
</SummaryCard.Description>
</SummaryCard.Body>
{children}
<StyledOverlayButtons>
<StyledOverlayButtons onClick={(event) => event.stopPropagation()}>
<ThemeMenu theme={theme} />
{expandable && (
<StyledExpandButton
+3 -8
View File
@@ -7,7 +7,6 @@ import gql from "graphql-tag";
import { SummaryCard } from "@/components/card/SummaryCard2";
import { Icon } from "@/components/icon/Icon";
import { VideoMenu } from "@/components/menu/VideoMenu";
import { TextLink } from "@/components/text/TextLink";
import { Performances } from "@/components/utils/Performances";
import { SongTitle } from "@/components/utils/SongTitle";
@@ -27,7 +26,7 @@ const StyledOverlayButtons = styled.div`
opacity: 0;
transition-property: opacity;
${StyledWrapper}:hover &, &:has([data-state="open"]) {
${StyledWrapper}:hover & {
position: static;
opacity: 1;
transition-duration: 250ms;
@@ -109,8 +108,8 @@ export function VideoSummaryCard({
<TextLink href={`/anime/${anime.slug}`}>{anime.name}</TextLink>
</SummaryCard.Description>
</SummaryCard.Body>
{menu !== null ? (
<StyledOverlayButtons>{menu ?? <VideoMenu entry={entry} video={video} />}</StyledOverlayButtons>
{menu ? (
<StyledOverlayButtons onClick={(event) => event.stopPropagation()}>{menu}</StyledOverlayButtons>
) : null}
{append}
</SummaryCard>
@@ -120,13 +119,11 @@ export function VideoSummaryCard({
export const VideoSummaryCardFragmentVideo = gql`
${createVideoSlug.fragments.video}
${VideoMenu.fragments.video}
fragment VideoSummaryCardVideo on Video {
id
basename
...createVideoSlugVideo
...VideoMenuVideo
audio {
basename
}
@@ -138,11 +135,9 @@ export const VideoSummaryCardFragmentEntry = gql`
${extractImages.fragments.resourceWithImages}
${createVideoSlug.fragments.theme}
${createVideoSlug.fragments.entry}
${VideoMenu.fragments.entry}
fragment VideoSummaryCardEntry on Entry {
...createVideoSlugEntry
...VideoMenuEntry
id
theme {
...createVideoSlugTheme
@@ -143,7 +143,7 @@ function PlaylistTrackAddForm({ video, entry, onCancel }: PlaylistTrackAddFormPr
return (
<Column style={{ "--gap": "24px" }}>
<VideoSummaryCard video={video} entry={entry} menu={null} />
<VideoSummaryCard video={video} entry={entry} />
<Row style={{ "--justify-content": "center" }}>
<Icon icon={faArrowDown} color="text-disabled" />
</Row>
@@ -149,7 +149,7 @@ function PlaylistTrackRemoveForm({
</Text>
?
</Text>
<VideoSummaryCard video={video} entry={entry} menu={null} />
<VideoSummaryCard video={video} entry={entry} />
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button variant="silent" onClick={onCancel}>
Close
@@ -0,0 +1,99 @@
import { type ReactNode, useState } from "react";
import styled from "styled-components";
import { Row } from "@/components/box/Flex";
import { Button } from "@/components/button/Button";
import { Dialog, DialogContent, DialogTrigger } from "@/components/dialog/Dialog";
import { SearchFilter } from "@/components/search-filter/SearchFilter";
import { SearchFilterGroup } from "@/components/search-filter/SearchFilterGroup";
import { Switcher, SwitcherOption } from "@/components/switcher/Switcher";
import { Text } from "@/components/text/Text";
import type { ExternalProfileSite } from "@/generated/graphql";
import { CLIENT_API_URL } from "@/utils/config";
interface ExternalProfileAddProps {
trigger: ReactNode;
preselectedSite?: ExternalProfileSite;
}
export function ExternalProfileAddDialog({ trigger, preselectedSite }: ExternalProfileAddProps) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent title="Add External Profile">
{/* Only render the form when dialog is open, so it will reset after closing. */}
{open ? (
<ExternalProfileAddForm preselectedSite={preselectedSite} onCancel={() => setOpen(false)} />
) : null}
</DialogContent>
</Dialog>
);
}
const StyledForm = styled.form`
position: relative;
display: flex;
flex-direction: column;
gap: 32px;
`;
const StyledSwitcher = styled(Switcher)`
width: 100%;
& > * {
flex: 1;
}
`;
interface ExternalProfileAddFormProps {
preselectedSite?: ExternalProfileSite;
onCancel(): void;
}
function ExternalProfileAddForm({ preselectedSite, onCancel }: ExternalProfileAddFormProps) {
const [site, setSite] = useState<ExternalProfileSite | "">(preselectedSite ?? "");
const authUrl = `${CLIENT_API_URL}/externaltoken/auth`;
return (
<StyledForm action={authUrl}>
<input type="hidden" name="site" value={site} />
<SearchFilterGroup>
<SearchFilter>
<Text variant="h2">External Site</Text>
<StyledSwitcher
selectedItem={site}
onChange={(newSite) => setSite(newSite as ExternalProfileSite | "")}
>
<SwitcherOption value="AniList">AniList</SwitcherOption>
<SwitcherOption value="MyAnimeList">MyAnimeList</SwitcherOption>
</StyledSwitcher>
</SearchFilter>
</SearchFilterGroup>
<Text as="p">
To add an external profile you need to grant AnimeThemes access to your profile. We will use this access
only to get the anime from your list.
</Text>
<Text as="p">
By default your external profile will be private, so no one can see your list except yourself.
</Text>
<Text as="p">
Clicking on{" "}
<Text as="button" type="submit" color="text-primary" link>
Authorize
</Text>{" "}
will redirect you to {site}, where you can grant us access.
</Text>
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button type="button" variant="silent" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" variant="primary">
Authorize
</Button>
</Row>
</StyledForm>
);
}
@@ -0,0 +1,76 @@
import styled from "styled-components";
import { Button } from "@/components/button/Button";
import { ExternalProfileAddDialog } from "@/components/external-profile/ExternalProfileAddDialog";
import { Text } from "@/components/text/Text";
import { withHover } from "@/styles/mixins";
const StyledGrid = styled.div`
display: grid;
grid-template-columns: 1fr auto auto 1fr;
justify-content: center;
gap: 16px;
& > * {
grid-column: 1 / -1;
display: grid;
grid-template-columns: subgrid;
}
`;
const StyledButton = styled(Button)`
grid-column: 1 / -1;
display: grid;
grid-template-columns: subgrid;
gap: inherit;
background-color: var(--background-color);
color: #ffffff;
transition: background-color 200ms;
${withHover`
background-color: color-mix(in oklab, var(--background-color), #000 10%);
`}
`;
const StyledSubgrid = styled.div`
grid-column: 2 / -2;
display: grid;
grid-template-columns: subgrid;
`;
const StyledLogo = styled.img`
justify-self: center;
align-self: center;
height: 1em;
`;
export default function ExternalProfileList() {
return (
<StyledGrid>
<ExternalProfileAddDialog
preselectedSite="AniList"
trigger={
<StyledButton style={{ "--background-color": "#152232" }}>
<StyledSubgrid>
<StyledLogo src="/img/anilist.svg" alt="AniList" />
<Text>Add AniList</Text>
</StyledSubgrid>
</StyledButton>
}
/>
<ExternalProfileAddDialog
preselectedSite="MyAnimeList"
trigger={
<StyledButton style={{ "--background-color": "#2e51a2" }}>
<StyledSubgrid>
<StyledLogo src="/img/myanimelist.svg" alt="MyAnimeList" />
<Text>Add MyAnimeList</Text>
</StyledSubgrid>
</StyledButton>
}
/>
</StyledGrid>
);
}
+32 -30
View File
@@ -5,12 +5,8 @@ import Link from "next/link";
import gql from "graphql-tag";
import {
VideoSummaryCard,
VideoSummaryCardFragmentEntry,
VideoSummaryCardFragmentVideo,
} from "@/components/card/VideoSummaryCard";
import type { FeaturedThemeEntryFragment, FeaturedThemeVideoFragment } from "@/generated/graphql";
import { ThemeSummaryCard } from "@/components/card/ThemeSummaryCard";
import type { FeaturedThemeThemeFragment } from "@/generated/graphql";
import useCompatability from "@/hooks/useCompatability";
import useSetting from "@/hooks/useSetting";
import { fetchRandomGrill } from "@/lib/client/randomGrill";
@@ -124,14 +120,13 @@ const StyledGrill = styled.img`
const Box = styled.div``;
interface FeaturedThemeProps {
entry: FeaturedThemeEntryFragment;
video: FeaturedThemeVideoFragment;
theme: FeaturedThemeThemeFragment;
hasGrill?: boolean;
card?: ReactNode;
onPlay?(): void;
}
export function FeaturedTheme({ entry, video, hasGrill = true, card, onPlay }: FeaturedThemeProps) {
export function FeaturedTheme({ theme, hasGrill = true, card, onPlay }: FeaturedThemeProps) {
const [grill, setGrill] = useState<string | null>(null);
const [featuredThemePreview] = useSetting(FeaturedThemePreview);
@@ -145,14 +140,14 @@ export function FeaturedTheme({ entry, video, hasGrill = true, card, onPlay }: F
const featuredThemeSummaryCard =
featuredThemePreview !== FeaturedThemePreview.DISABLED ? (
<StyledCenter>{card ?? <VideoSummaryCard entry={entry} video={video} />}</StyledCenter>
<StyledCenter>{card ?? <ThemeSummaryCard theme={theme} />}</StyledCenter>
) : (
(card ?? <VideoSummaryCard entry={entry} video={video} />)
card ?? <ThemeSummaryCard theme={theme} />
);
return (
<FeaturedThemeWrapper>
<FeaturedThemeBackground entry={entry} video={video} onPlay={onPlay} />
<FeaturedThemeBackground theme={theme} onPlay={onPlay} />
{featuredThemePreview !== FeaturedThemePreview.DISABLED && grill && (
<StyledGrillContainer>
<StyledGrill src={grill} />
@@ -163,15 +158,26 @@ export function FeaturedTheme({ entry, video, hasGrill = true, card, onPlay }: F
);
}
function FeaturedThemeBackground({ entry, video, onPlay }: FeaturedThemeProps) {
function FeaturedThemeBackground({ theme, onPlay }: FeaturedThemeProps) {
const [featuredThemePreview] = useSetting(FeaturedThemePreview);
const { canPlayVideo } = useCompatability();
const [fallbackToCover, setFallbackToCover] = useState(false);
const { smallCover: featuredCover } = extractImages(entry.theme.anime);
const { smallCover: featuredCover } = extractImages(theme.anime);
const videoSlug = createVideoSlug(entry.theme, entry, video);
if (!theme.anime || !theme.entries.length) {
return null;
}
const href = `/anime/${entry.theme.anime.slug}/${videoSlug}`;
const entry = theme.entries[0];
if (!entry.videos.length) {
return null;
}
const video = entry.videos[0];
const videoSlug = createVideoSlug(theme, entry, video);
const href = `/anime/${theme.anime.slug}/${videoSlug}`;
if (featuredThemePreview === FeaturedThemePreview.VIDEO && canPlayVideo && !fallbackToCover) {
return (
@@ -193,24 +199,20 @@ function FeaturedThemeBackground({ entry, video, onPlay }: FeaturedThemeProps) {
}
FeaturedTheme.fragments = {
entry: gql`
${VideoSummaryCardFragmentEntry}
theme: gql`
${ThemeSummaryCard.fragments.theme}
${extractImages.fragments.resourceWithImages}
fragment FeaturedThemeEntry on Entry {
...VideoSummaryCardEntry
theme {
anime {
...extractImagesResourceWithImages
fragment FeaturedThemeTheme on Theme {
...ThemeSummaryCardTheme
anime {
...extractImagesResourceWithImages
}
entries {
videos {
basename
}
}
}
`,
video: gql`
${VideoSummaryCardFragmentVideo}
fragment FeaturedThemeVideo on Video {
...VideoSummaryCardVideo
}
`,
};
@@ -9,37 +9,37 @@ import {
VideoSummaryCardFragmentVideo,
} from "@/components/card/VideoSummaryCard";
import { Skeleton } from "@/components/skeleton/Skeleton";
import type { HomePageMostPopularQuery } from "@/generated/graphql";
import type { HomePageMostViewedQuery } from "@/generated/graphql";
import { fetchDataClient } from "@/lib/client/index";
export function MostPopularEntries() {
const { data: mostPopular } = useQuery<HomePageMostPopularQuery["entryAll"] | Array<null>>({
export function MostViewedVideos() {
const { data: mostViewed } = useQuery<HomePageMostViewedQuery["videoAll"] | Array<null>>({
queryKey: ["HomePageTrending"],
queryFn: async () => {
const { data } = await fetchDataClient<HomePageMostPopularQuery>(gql`
${VideoSummaryCardFragmentEntry}
const { data } = await fetchDataClient<HomePageMostViewedQuery>(gql`
${VideoSummaryCardFragmentVideo}
${VideoSummaryCardFragmentEntry}
query HomePageMostPopular {
entryAll(orderBy: "tracks_count", orderDesc: true, limit: 10) {
...VideoSummaryCardEntry
videos {
...VideoSummaryCardVideo
query HomePageMostViewed {
videoAll(orderBy: "views_count", orderDesc: true, limit: 10) {
...VideoSummaryCardVideo
entries {
...VideoSummaryCardEntry
}
}
}
`);
return data.entryAll;
return data.videoAll;
},
placeholderData: range(10).map(() => null),
});
return (
<Column style={{ "--gap": "16px" }}>
{mostPopular?.map((entry, index) => (
{mostViewed?.map((video, index) => (
<Skeleton key={index} variant="summary-card" delay={index * 100}>
{entry ? <VideoSummaryCard video={entry.videos[0]} entry={entry} /> : null}
{video ? <VideoSummaryCard video={video} entry={video.entries[0]} /> : null}
</Skeleton>
))}
</Column>
-103
View File
@@ -1,103 +0,0 @@
import { useContext } from "react";
import { faArrowTurnDown, faArrowTurnUp, faEllipsisVertical, faPlus } from "@fortawesome/free-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, 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 { VideoMenuEntryFragment, VideoMenuVideoFragment } from "@/generated/graphql";
import createVideoSlug from "@/utils/createVideoSlug";
import extractImages from "@/utils/extractImages";
interface VideoMenuProps {
entry: VideoMenuEntryFragment;
video: VideoMenuVideoFragment;
}
export function VideoMenu({ entry, video }: VideoMenuProps) {
const { watchList, addWatchListItem, addWatchListItemNext } = useContext(PlayerContext);
return (
<Menu modal={false}>
<MenuTrigger asChild>
<Button variant="silent" isCircle>
<Icon icon={faEllipsisVertical} />
</Button>
</MenuTrigger>
<MenuContent>
<PlaylistTrackAddDialog
video={video}
entry={entry}
trigger={
<MenuItem onSelect={(event) => event.preventDefault()}>
<Icon icon={faPlus} />
<Text>Add to Playlist</Text>
</MenuItem>
}
/>
{watchList.length ? (
<>
<MenuSeparator />
<MenuItem onSelect={() => addWatchListItem(video, entry)}>
<Icon icon={faArrowTurnDown} color="text-disabled" />
<Text>Add to Watch List</Text>
</MenuItem>
<MenuItem onSelect={() => addWatchListItemNext(video, entry)}>
<Icon icon={faArrowTurnUp} color="text-disabled" />
<Text>Play Next</Text>
</MenuItem>
</>
) : null}
</MenuContent>
</Menu>
);
}
VideoMenu.fragments = {
entry: gql`
${SongTitleWithArtists.fragments.song}
${extractImages.fragments.resourceWithImages}
${createVideoSlug.fragments.theme}
${createVideoSlug.fragments.entry}
fragment VideoMenuEntry on Entry {
...createVideoSlugEntry
id
theme {
...createVideoSlugTheme
id
type
sequence
group {
name
slug
}
anime {
...extractImagesResourceWithImages
slug
name
}
song {
...SongTitleWithArtistsSong
}
}
}
`,
video: gql`
${createVideoSlug.fragments.video}
fragment VideoMenuVideo on Video {
...createVideoSlugVideo
id
basename
audio {
basename
}
}
`,
};
+3 -18
View File
@@ -17,7 +17,6 @@ const StyledPlayerProgress = styled.div`
`;
const StyledPlayerProgressBackground = styled.div`
position: relative;
width: 100%;
height: 2px;
@@ -29,7 +28,7 @@ const StyledPlayerProgressBackground = styled.div`
`;
const StyledPlayerProgressBar = styled.div`
position: absolute;
position: relative;
height: 100%;
background-color: ${theme.colors["text-primary"]};
@@ -72,27 +71,14 @@ const StyledPlayerProgressBarHover = styled.div`
}
`;
const StyledPlayerProgressBuffered = styled.div`
position: absolute;
width: 100%;
height: 2px;
background-color: ${theme.colors["text-muted"]};
opacity: 0.2;
${StyledPlayerProgress}:hover & {
height: 4px;
}
`;
export function ProgressBar() {
const context = useContext(VideoPlayerContext);
if (!context) {
throw new Error("ProgressBar needs to be inside VideoPlayer!");
}
const { playerRef, progressRef, bufferedRef } = context;
const { playerRef, progressRef } = context;
const progressHoverRef = useRef<HTMLDivElement>(null);
@@ -160,7 +146,6 @@ export function ProgressBar() {
}}
>
<StyledPlayerProgressBackground>
<StyledPlayerProgressBuffered ref={bufferedRef} />
<StyledPlayerProgressBar ref={progressRef} />
</StyledPlayerProgressBackground>
<StyledPlayerProgressBarHover ref={progressHoverRef} />
@@ -117,9 +117,8 @@ export const StyledAudioBackground = styled.div`
inset: 0;
margin: auto;
width: 100%;
height: 100%;
overflow: hidden;
max-width: 100%;
max-height: 100%;
`;
export const StyledAudio = styled.audio`
@@ -127,32 +126,17 @@ export const StyledAudio = styled.audio`
`;
export const StyledAudioCover = styled.img`
position: absolute;
inset: 0;
margin: auto;
width: 100%;
height: 100%;
max-width: 80%;
height: 80%;
border-radius: 8px;
object-fit: cover;
box-shadow: ${theme.shadows.high};
outline: none;
object-fit: contain;
[data-background] & {
pointer-events: none;
}
`;
export const StyledAudioCoverBackground = styled.img`
width: calc(100% + 32px);
height: calc(100% + 32px);
max-width: none;
margin: -16px;
object-fit: cover;
filter: blur(8px);
`;
export const StyledAside = styled.aside`
display: flex;
flex-direction: column;
+8 -223
View File
@@ -7,7 +7,6 @@ import {
StyledAudio,
StyledAudioBackground,
StyledAudioCover,
StyledAudioCoverBackground,
StyledPlaybackArea,
StyledPlayer,
StyledPlayerContent,
@@ -15,7 +14,6 @@ import {
StyledVideoBackground,
} from "@/components/video-player/VideoPlayer.style";
import { VideoPlayerBar } from "@/components/video-player/VideoPlayerBar";
import FullscreenContext from "@/context/fullscreenContext";
import PlayerContext, { type WatchListItem } from "@/context/playerContext";
import type { VideoSummaryCardEntryFragment, VideoSummaryCardVideoFragment } from "@/generated/graphql";
import useMouseRelax from "@/hooks/useMouseRelax";
@@ -24,7 +22,7 @@ import useWatchHistory from "@/hooks/useWatchHistory";
import { AUDIO_URL, VIDEO_URL } from "@/utils/config";
import createVideoSlug from "@/utils/createVideoSlug";
import extractImages from "@/utils/extractImages";
import { AudioMode, GlobalVolume, Muted } from "@/utils/settings";
import { AudioMode, GlobalVolume } from "@/utils/settings";
interface VideoPlayerContextValue {
video: VideoSummaryCardVideoFragment;
@@ -33,7 +31,6 @@ interface VideoPlayerContextValue {
videoPagePath: string;
playerRef: RefObject<HTMLVideoElement | HTMLAudioElement | null>;
progressRef: RefObject<HTMLDivElement | null>;
bufferedRef: RefObject<HTMLDivElement | null>;
previousVideoPath: string | null;
playPreviousTrack(navigate: boolean): void;
nextVideoPath: string | null;
@@ -43,7 +40,6 @@ interface VideoPlayerContextValue {
videoUrl: string;
audioUrl: string;
updateAudioMode(audioMode: string): void;
togglePip(): void;
}
export const VideoPlayerContext = createContext<VideoPlayerContextValue | null>(null);
@@ -72,9 +68,7 @@ export function VideoPlayer({ watchListItem, background, children, overlay, ...p
const containerRef = useRef<HTMLDivElement>(null);
const playerRef = useRef<HTMLVideoElement | HTMLAudioElement | null>(null);
const progressRef = useRef<HTMLDivElement>(null);
const bufferedRef = useRef<HTMLDivElement>(null);
const currentTimeBeforeModeSwitch = useRef<number | null>(null);
const fpsRef = useRef<number>(24);
const {
watchList,
@@ -85,10 +79,8 @@ export function VideoPlayer({ watchListItem, background, children, overlay, ...p
isWatchListUsingLocalAutoPlay,
isRepeat,
} = useContext(PlayerContext);
const { toggleFullscreen } = useContext(FullscreenContext);
const router = useRouter();
const [globalVolume, setGlobalVolume] = useSetting(GlobalVolume);
const [muted, setMuted] = useSetting(Muted);
const { smallCover, largeCover } = extractImages(anime);
const [audioMode, setAudioMode] = useSetting(AudioMode, { storageSync: false });
const { addToHistory } = useWatchHistory();
@@ -164,10 +156,9 @@ export function VideoPlayer({ watchListItem, background, children, overlay, ...p
useEffect(() => {
if (playerRef.current) {
playerRef.current.muted = muted;
playerRef.current.volume = globalVolume;
}
}, [globalVolume, muted]);
}, [globalVolume]);
useEffect(() => {
addToHistory({
@@ -185,10 +176,6 @@ export function VideoPlayer({ watchListItem, background, children, overlay, ...p
progressRef.current.style.width = "0%";
}
if (bufferedRef.current) {
bufferedRef.current.style.width = "0%";
}
// We don't want to re-add the theme when the history changes, because it can cause
// various issues when multiple tabs are open.
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -214,47 +201,6 @@ export function VideoPlayer({ watchListItem, background, children, overlay, ...p
}
}, [anime, theme, smallCover, playNextTrack, playPreviousTrack]);
// Calculate frame rate
// Source - https://stackoverflow.com/questions/72997777/how-do-i-get-the-frame-rate-of-an-html-video-with-javascript
useEffect(() => {
const videoElement = document.querySelector("video");
if (!videoElement) return;
let lastMediaTime = 0,
lastFrameNum = 0,
frameNotSeeked = true;
const fpsRounder: number[] = [];
const ticker: VideoFrameRequestCallback = (_, metadata) => {
const diff =
Math.abs(metadata.mediaTime - lastMediaTime) / Math.abs(metadata.presentedFrames - lastFrameNum);
if (
diff &&
diff < 1 &&
frameNotSeeked &&
fpsRounder.length < 50 &&
videoElement.playbackRate === 1 &&
document.hasFocus()
) {
fpsRounder.push(diff);
fpsRef.current = Math.round(fpsRounder.length / fpsRounder.reduce((a, b) => a + b));
}
frameNotSeeked = true;
lastMediaTime = metadata.mediaTime;
lastFrameNum = metadata.presentedFrames;
videoElement.requestVideoFrameCallback(ticker);
};
const handleSeeked = () => {
fpsRounder.pop();
frameNotSeeked = false;
};
videoElement.requestVideoFrameCallback(ticker);
videoElement.addEventListener("seeked", handleSeeked);
return () => videoElement.removeEventListener("seeked", handleSeeked);
}, []);
function onPlayerMount(player: HTMLVideoElement) {
playerRef.current = player;
if (playerRef.current) {
@@ -276,174 +222,26 @@ export function VideoPlayer({ watchListItem, background, children, overlay, ...p
}
}
const togglePlay = useCallback(() => {
function togglePlay() {
if (isPlaying) {
playerRef.current?.pause();
} else {
playerRef.current?.play();
}
}, [isPlaying]);
function togglePip() {
const videoElement = document.querySelector("video");
if (!videoElement) return;
if (document.pictureInPictureElement) {
void document.exitPictureInPicture();
} else {
void videoElement.requestPictureInPicture();
}
}
function updateProgress(event: SyntheticEvent<HTMLVideoElement | HTMLAudioElement>) {
const duration = event.currentTarget.duration;
if (progressRef.current) {
// Update the progress bar using a ref to prevent re-rendering.
const progress = (event.currentTarget.currentTime / duration) * 100;
const progress = (event.currentTarget.currentTime / event.currentTarget.duration) * 100;
progressRef.current.style.width = `${progress}%`;
}
if (bufferedRef.current) {
const buffered = event.currentTarget.buffered;
if (buffered.length > 0) {
const bufferedEnd = buffered.end(buffered.length - 1);
bufferedRef.current.style.width = `${(bufferedEnd / duration) * 100}%`;
}
}
}
const updateAudioMode = useCallback(
(audioMode: string) => {
currentTimeBeforeModeSwitch.current = playerRef.current?.currentTime ?? null;
setAudioMode(audioMode);
},
[setAudioMode],
);
// Handle keyboard inputs
const onKeyDown = useCallback(
(event: KeyboardEvent) => {
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
return;
}
switch (event.key.toLocaleLowerCase()) {
case " ": // Play/Pause
case "k":
event.preventDefault();
togglePlay();
break;
case "arrowright": // Seek forward
event.preventDefault();
if (playerRef.current) {
playerRef.current.currentTime += 5;
}
break;
case "l": // Seek forward large
event.preventDefault();
if (playerRef.current) {
playerRef.current.currentTime += 10;
}
break;
case "arrowleft": // Seek backward
event.preventDefault();
if (playerRef.current) {
playerRef.current.currentTime -= 5;
}
break;
case "j": // Seek backward large
event.preventDefault();
if (playerRef.current) {
playerRef.current.currentTime -= 10;
}
break;
case "n": // Next track
event.preventDefault();
playNextTrack(true);
break;
case "b": // Previous track
event.preventDefault();
playPreviousTrack(true);
break;
case "m": // Mute
event.preventDefault();
if (playerRef.current) {
setMuted(!muted);
}
break;
case "arrowup": // Volume up
event.preventDefault();
if (playerRef.current) {
setGlobalVolume(Math.min(globalVolume + 0.1, 1));
setMuted(false);
}
break;
case "arrowdown": // Volume down
event.preventDefault();
if (playerRef.current) {
setGlobalVolume(Math.max(globalVolume - 0.1, 0));
setMuted(false);
}
break;
case "d": // Download
event.preventDefault();
if (audioMode === AudioMode.ENABLED) {
const link = document.createElement("a");
link.href = `${audioUrl}?download`;
link.click();
} else {
const link = document.createElement("a");
link.href = `${videoUrl}?download`;
link.click();
}
break;
case "f": // Fullscreen
event.preventDefault();
toggleFullscreen();
break;
case "a": // Toggle audio mode
event.preventDefault();
updateAudioMode(audioMode === AudioMode.ENABLED ? AudioMode.DISABLED : AudioMode.ENABLED);
break;
case "p": // Toggle Picture-in-Picture
event.preventDefault();
togglePip();
break;
case ",": // Frame back
event.preventDefault();
if (playerRef.current && playerRef.current.paused) {
playerRef.current.currentTime -= 1 / fpsRef.current;
}
break;
case ".": // Frame forward
event.preventDefault();
if (playerRef.current && playerRef.current.paused) {
playerRef.current.currentTime += 1 / fpsRef.current;
}
break;
}
},
[
togglePlay,
playNextTrack,
playPreviousTrack,
audioMode,
toggleFullscreen,
updateAudioMode,
setMuted,
muted,
setGlobalVolume,
globalVolume,
audioUrl,
videoUrl,
],
);
// Keyboard shortcuts
useEffect(() => {
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [onKeyDown]);
function updateAudioMode(audioMode: string) {
currentTimeBeforeModeSwitch.current = playerRef.current?.currentTime ?? null;
setAudioMode(audioMode);
}
function getRelativeWatchListItem(offset: 1 | -1) {
if (!currentWatchListItem) {
@@ -484,7 +282,6 @@ export function VideoPlayer({ watchListItem, background, children, overlay, ...p
videoPagePath,
playerRef,
progressRef,
bufferedRef,
previousVideoPath,
playPreviousTrack,
nextVideoPath,
@@ -494,7 +291,6 @@ export function VideoPlayer({ watchListItem, background, children, overlay, ...p
videoUrl,
audioUrl,
updateAudioMode,
togglePip,
}}
>
<StyledPlayer
@@ -523,15 +319,6 @@ export function VideoPlayer({ watchListItem, background, children, overlay, ...p
>
{audioMode === AudioMode.ENABLED ? (
<StyledAudioBackground style={{ aspectRatio }}>
<StyledAudioCoverBackground
src={largeCover}
onPointerDown={onPlayerClick}
onLoad={(event) => {
setAspectRatio(
event.currentTarget.naturalWidth / event.currentTarget.naturalHeight,
);
}}
/>
<StyledAudioCover
src={largeCover}
onPointerDown={onPlayerClick}
@@ -545,7 +332,6 @@ export function VideoPlayer({ watchListItem, background, children, overlay, ...p
ref={onPlayerMount}
src={audioUrl}
autoPlay
playsInline
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onEnded={() => {
@@ -565,7 +351,6 @@ export function VideoPlayer({ watchListItem, background, children, overlay, ...p
ref={onPlayerMount}
src={videoUrl}
autoPlay
playsInline
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onEnded={() => {
@@ -2,21 +2,10 @@ import { Fragment, useContext } from "react";
import styled from "styled-components";
import Link from "next/link";
import {
faCheck,
faCompress,
faExpand,
faGear,
faKeyboard,
faPlus,
faShare,
faUpRightFromSquare,
} from "@fortawesome/free-solid-svg-icons";
import { Dialog } from "@radix-ui/react-dialog";
import { faCheck, faCompress, faExpand, faGear, faPlus, faShare } from "@fortawesome/free-solid-svg-icons";
import { Row } from "@/components/box/Flex";
import { IconTextButton } from "@/components/button/IconTextButton";
import { DialogContent, DialogTrigger } from "@/components/dialog/Dialog";
import { PlaylistTrackAddDialog } from "@/components/dialog/PlaylistTrackAddDialog";
import { Icon } from "@/components/icon/Icon";
import { Menu, MenuContent, MenuItem, MenuLabel, MenuSeparator, MenuTrigger } from "@/components/menu/Menu";
@@ -30,7 +19,6 @@ import FullscreenContext from "@/context/fullscreenContext";
import PlayerContext from "@/context/playerContext";
import useSetting from "@/hooks/useSetting";
import type { VideoPageProps } from "@/pages/anime/[animeSlug]/[videoSlug]";
import theme from "@/theme";
import createVideoSlug from "@/utils/createVideoSlug";
import { AudioMode } from "@/utils/settings";
@@ -55,38 +43,6 @@ const StyledOverlayButton = styled(IconTextButton)`
backdrop-filter: blur(5px);
`;
const StyledKeyList = styled.dl`
display: grid;
grid-template-columns: auto 1fr;
gap: 8px 16px;
align-items: baseline;
margin: 0;
`;
const StyledKey = styled.dt`
text-align: right;
color: ${theme.colors["text-muted"]};
`;
const StyledKeyDescription = styled.dd`
margin: 0;
color: ${theme.colors["text-disabled"]};
`;
const StyledKeyHint = styled.kbd`
display: inline-flex;
justify-content: center;
align-items: center;
width: 32px;
height: 32px;
border: 1px solid ${theme.colors["text-disabled"]};
border-radius: 4px;
font-size: 1.25rem;
background-color: ${theme.colors["background"]};
`;
const StyledKeyHintSpace = styled(StyledKeyHint)`
width: 96px;
`;
export function VideoPlayerOverlay({ anime, themeIndex, entryIndex, videoIndex }: VideoPageProps) {
const theme = anime.themes[themeIndex];
const entry = theme.entries[entryIndex];
@@ -94,8 +50,6 @@ export function VideoPlayerOverlay({ anime, themeIndex, entryIndex, videoIndex }
const { watchList, setWatchList, currentWatchListItem } = useContext(PlayerContext);
const { isFullscreen, toggleFullscreen } = useContext(FullscreenContext);
const isIOS = /iPhone|iPad|iPod/.test(navigator.userAgent);
const isPIPSupported = typeof document !== "undefined" && document.pictureInPictureEnabled;
const context = useContext(VideoPlayerContext);
const [audioMode] = useSetting(AudioMode, { storageSync: false });
@@ -128,80 +82,6 @@ export function VideoPlayerOverlay({ anime, themeIndex, entryIndex, videoIndex }
entry={{ ...entry, theme }}
trigger={<StyledOverlayButton icon={faPlus} isCircle title="Add to playlist" />}
/>
<Dialog>
<DialogTrigger asChild>
<StyledOverlayButton icon={faKeyboard} isCircle title="Keyboard shortcuts" />
</DialogTrigger>
<DialogContent title="Keyboard Shortcuts">
<StyledKeyList>
<StyledKey>
<StyledKeyHintSpace title="Space">_</StyledKeyHintSpace> or{" "}
<StyledKeyHint>K</StyledKeyHint>
</StyledKey>
<StyledKeyDescription>Play / Pause</StyledKeyDescription>
<StyledKey>
<StyledKeyHint></StyledKeyHint>
</StyledKey>
<StyledKeyDescription>Seek forward 5 seconds</StyledKeyDescription>
<StyledKey>
<StyledKeyHint></StyledKeyHint>
</StyledKey>
<StyledKeyDescription>Seek backward 5 seconds</StyledKeyDescription>
<StyledKey>
<StyledKeyHint>L</StyledKeyHint>
</StyledKey>
<StyledKeyDescription>Seek forward 10 seconds</StyledKeyDescription>
<StyledKey>
<StyledKeyHint>J</StyledKeyHint>
</StyledKey>
<StyledKeyDescription>Seek backward 10 seconds</StyledKeyDescription>
<StyledKey>
<StyledKeyHint>.</StyledKeyHint>
</StyledKey>
<StyledKeyDescription>Seek forward 1 frame</StyledKeyDescription>
<StyledKey>
<StyledKeyHint>,</StyledKeyHint>
</StyledKey>
<StyledKeyDescription>Seek backward 1 frame</StyledKeyDescription>
<StyledKey>
<StyledKeyHint>N</StyledKeyHint>
</StyledKey>
<StyledKeyDescription>Next track</StyledKeyDescription>
<StyledKey>
<StyledKeyHint>B</StyledKeyHint>
</StyledKey>
<StyledKeyDescription>Previous track</StyledKeyDescription>
<StyledKey>
<StyledKeyHint>M</StyledKeyHint>
</StyledKey>
<StyledKeyDescription>Mute / Unmute</StyledKeyDescription>
<StyledKey>
<StyledKeyHint></StyledKeyHint>
</StyledKey>
<StyledKeyDescription>Volume up</StyledKeyDescription>
<StyledKey>
<StyledKeyHint></StyledKeyHint>
</StyledKey>
<StyledKeyDescription>Volume down</StyledKeyDescription>
<StyledKey>
<StyledKeyHint>D</StyledKeyHint>
</StyledKey>
<StyledKeyDescription>Download track</StyledKeyDescription>
<StyledKey>
<StyledKeyHint>F</StyledKeyHint>
</StyledKey>
<StyledKeyDescription>Toggle fullscreen</StyledKeyDescription>
<StyledKey>
<StyledKeyHint>A</StyledKeyHint>
</StyledKey>
<StyledKeyDescription>Toggle audio mode</StyledKeyDescription>
<StyledKey>
<StyledKeyHint>P</StyledKeyHint>
</StyledKey>
<StyledKeyDescription>Toggle picture-in-picture</StyledKeyDescription>
</StyledKeyList>
</DialogContent>
</Dialog>
<ShareMenu
pagePath={context.videoPagePath}
videoUrl={context.videoUrl}
@@ -282,22 +162,12 @@ export function VideoPlayerOverlay({ anime, themeIndex, entryIndex, videoIndex }
)}
</MenuContent>
</Menu>
{isPIPSupported && audioMode === AudioMode.DISABLED && (
<StyledOverlayButton
icon={faUpRightFromSquare}
isCircle
onClick={context.togglePip}
title="Toggle picture-in-picture"
/>
)}
{!(isIOS && audioMode === AudioMode.ENABLED) && ( // Hide fullscreen button on iOS when audio mode is enabled
<StyledOverlayButton
icon={isFullscreen ? faCompress : faExpand}
isCircle
onClick={toggleFullscreen}
title="Toggle fullscreen"
/>
)}
<StyledOverlayButton
icon={isFullscreen ? faCompress : faExpand}
isCircle
onClick={toggleFullscreen}
title="Toggle fullscreen"
/>
</Row>
</StyledOverlay>
);
@@ -1,13 +1,13 @@
import type { ComponentPropsWithoutRef } from "react";
import styled from "styled-components";
import { faVolumeHigh, faVolumeLow, faVolumeOff, faVolumeXmark } from "@fortawesome/free-solid-svg-icons";
import { faVolumeHigh, faVolumeLow, faVolumeXmark } from "@fortawesome/free-solid-svg-icons";
import { Row } from "@/components/box/Flex";
import { IconTextButton } from "@/components/button/IconTextButton";
import { Slider } from "@/components/slider/Slider";
import useSetting from "@/hooks/useSetting";
import { GlobalVolume, Muted } from "@/utils/settings";
import { GlobalVolume } from "@/utils/settings";
const StyledRow = styled(Row)`
align-self: stretch;
@@ -28,25 +28,22 @@ const StyledSlider = styled(Slider)`
export function VolumeControl(props: ComponentPropsWithoutRef<typeof StyledRow>) {
const [volume, setVolume] = useSetting(GlobalVolume);
const [muted, setMuted] = useSetting(Muted);
let icon;
if (muted) {
icon = faVolumeXmark;
} else if (volume > 0.5) {
if (volume > 0.5) {
icon = faVolumeHigh;
} else if (volume > 0) {
icon = faVolumeLow;
} else {
icon = faVolumeOff;
icon = faVolumeXmark;
}
return (
<StyledRow style={{ "--gap": "8px" }} {...props}>
<IconTextButton icon={icon} isCircle onClick={() => setMuted(!muted)} />
<IconTextButton icon={icon} isCircle onClick={() => setVolume(volume === 0 ? 1 : 0)} />
<StyledSlider
value={[volume]}
onValueChange={([volume]) => { setVolume(volume); setMuted(false); }}
onValueChange={([volume]) => setVolume(volume)}
min={0}
max={1}
step={0.01}
+89 -5
View File
@@ -12,6 +12,8 @@ import type {
ApiPerformance,
ApiPlaylist,
ApiPlaylistTrack,
ApiExternalProfile,
ApiExternalProfileEntry,
ApiSeason,
ApiSeries,
ApiSong,
@@ -180,6 +182,36 @@ export type Entry = {
videos: Array<Video>;
};
export type ExternalProfile = {
__typename?: "ExternalProfile";
entries: Array<ExternalProfileEntry>;
id: Scalars["Int"]["output"];
name: Scalars["String"]["output"];
site: ExternalProfileSite;
user?: Maybe<UserPublic>;
visibility: ExternalProfileVisibility;
};
export type ExternalProfileEntry = {
__typename?: "ExternalProfileEntry";
anime: Anime;
id: Scalars["Int"]["output"];
is_favorite: Scalars["Boolean"]["output"];
profile: ExternalProfile;
score: Scalars["Float"]["output"];
watch_status: Scalars["String"]["output"];
};
export enum ExternalProfileSite {
AniList = "AniList",
MyAnimeList = "MyAnimeList",
}
export enum ExternalProfileVisibility {
Private = "Private",
Public = "Public",
}
export type FeaturedTheme = {
__typename?: "FeaturedTheme";
end_at: Scalars["String"]["output"];
@@ -282,7 +314,8 @@ export type Query = {
bracket?: Maybe<Bracket>;
bracketAll: Array<Bracket>;
dumpAll: Array<Dump>;
entryAll: Array<Entry>;
externalProfile?: Maybe<ExternalProfile>;
externalProfileAll: Array<ExternalProfile>;
featuredTheme?: Maybe<FeaturedTheme>;
imageAll: Array<Image>;
me: UserScopedQuery;
@@ -333,10 +366,14 @@ export type QueryBracketArgs = {
slug: Scalars["String"]["input"];
};
export type QueryEntryAllArgs = {
export type QueryExternalProfileArgs = {
id: Scalars["Int"]["input"];
};
export type QueryExternalProfileAllArgs = {
limit?: InputMaybe<Scalars["Int"]["input"]>;
orderBy?: InputMaybe<Scalars["String"]["input"]>;
orderDesc?: InputMaybe<Scalars["Boolean"]["input"]>;
name?: InputMaybe<Scalars["String"]["input"]>;
site?: InputMaybe<ExternalProfileSite>;
};
export type QueryImageAllArgs = {
@@ -728,8 +765,13 @@ export type ResolversTypes = {
Dump: ResolverTypeWrapper<ApiDump>;
EntitySearchResult: ResolverTypeWrapper<ResolversInterfaceTypes<ResolversTypes>["EntitySearchResult"]>;
Entry: ResolverTypeWrapper<ApiEntry>;
ExternalProfile: ResolverTypeWrapper<ApiExternalProfile>;
ExternalProfileEntry: ResolverTypeWrapper<ApiExternalProfileEntry>;
ExternalProfileSite: ExternalProfileSite;
ExternalProfileVisibility: ExternalProfileVisibility;
FeaturedTheme: ResolverTypeWrapper<ApiFeaturedTheme>;
Filter: Filter;
Float: ResolverTypeWrapper<Scalars["Float"]["output"]>;
GlobalSearchResult: ResolverTypeWrapper<
Omit<GlobalSearchResult, "anime" | "artists" | "playlists" | "series" | "studios" | "themes"> & {
anime: Array<ResolversTypes["Anime"]>;
@@ -802,8 +844,11 @@ export type ResolversParentTypes = {
Dump: ApiDump;
EntitySearchResult: ResolversInterfaceTypes<ResolversParentTypes>["EntitySearchResult"];
Entry: ApiEntry;
ExternalProfile: ApiExternalProfile;
ExternalProfileEntry: ApiExternalProfileEntry;
FeaturedTheme: ApiFeaturedTheme;
Filter: Filter;
Float: Scalars["Float"]["output"];
GlobalSearchResult: Omit<
GlobalSearchResult,
"anime" | "artists" | "playlists" | "series" | "studios" | "themes"
@@ -1031,6 +1076,32 @@ export type EntryResolvers<
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type ExternalProfileResolvers<
ContextType = any,
ParentType extends ResolversParentTypes["ExternalProfile"] = ResolversParentTypes["ExternalProfile"],
> = {
entries?: Resolver<Array<ResolversTypes["ExternalProfileEntry"]>, ParentType, ContextType>;
id?: Resolver<ResolversTypes["Int"], ParentType, ContextType>;
name?: Resolver<ResolversTypes["String"], ParentType, ContextType>;
site?: Resolver<ResolversTypes["ExternalProfileSite"], ParentType, ContextType>;
user?: Resolver<Maybe<ResolversTypes["UserPublic"]>, ParentType, ContextType>;
visibility?: Resolver<ResolversTypes["ExternalProfileVisibility"], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type ExternalProfileEntryResolvers<
ContextType = any,
ParentType extends ResolversParentTypes["ExternalProfileEntry"] = ResolversParentTypes["ExternalProfileEntry"],
> = {
anime?: Resolver<ResolversTypes["Anime"], ParentType, ContextType>;
id?: Resolver<ResolversTypes["Int"], ParentType, ContextType>;
is_favorite?: Resolver<ResolversTypes["Boolean"], ParentType, ContextType>;
profile?: Resolver<ResolversTypes["ExternalProfile"], ParentType, ContextType>;
score?: Resolver<ResolversTypes["Float"], ParentType, ContextType>;
watch_status?: Resolver<ResolversTypes["String"], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type FeaturedThemeResolvers<
ContextType = any,
ParentType extends ResolversParentTypes["FeaturedTheme"] = ResolversParentTypes["FeaturedTheme"],
@@ -1156,7 +1227,18 @@ export type QueryResolvers<
>;
bracketAll?: Resolver<Array<ResolversTypes["Bracket"]>, ParentType, ContextType>;
dumpAll?: Resolver<Array<ResolversTypes["Dump"]>, ParentType, ContextType>;
entryAll?: Resolver<Array<ResolversTypes["Entry"]>, ParentType, ContextType, Partial<QueryEntryAllArgs>>;
externalProfile?: Resolver<
Maybe<ResolversTypes["ExternalProfile"]>,
ParentType,
ContextType,
RequireFields<QueryExternalProfileArgs, "id">
>;
externalProfileAll?: Resolver<
Array<ResolversTypes["ExternalProfile"]>,
ParentType,
ContextType,
Partial<QueryExternalProfileAllArgs>
>;
featuredTheme?: Resolver<Maybe<ResolversTypes["FeaturedTheme"]>, ParentType, ContextType>;
imageAll?: Resolver<Array<ResolversTypes["Image"]>, ParentType, ContextType, Partial<QueryImageAllArgs>>;
me?: Resolver<ResolversTypes["UserScopedQuery"], ParentType, ContextType>;
@@ -1477,6 +1559,8 @@ export type Resolvers<ContextType = any> = {
Dump?: DumpResolvers<ContextType>;
EntitySearchResult?: EntitySearchResultResolvers<ContextType>;
Entry?: EntryResolvers<ContextType>;
ExternalProfile?: ExternalProfileResolvers<ContextType>;
ExternalProfileEntry?: ExternalProfileEntryResolvers<ContextType>;
FeaturedTheme?: FeaturedThemeResolvers<ContextType>;
GlobalSearchResult?: GlobalSearchResultResolvers<ContextType>;
Image?: ImageResolvers<ContextType>;
+220 -60
View File
@@ -132,6 +132,28 @@ export type Entry = {
videos: Array<Video>;
};
export type ExternalProfile = {
entries: Array<ExternalProfileEntry>;
id: Scalars["Int"]["output"];
name: Scalars["String"]["output"];
site: ExternalProfileSite;
user: Maybe<UserPublic>;
visibility: ExternalProfileVisibility;
};
export type ExternalProfileEntry = {
anime: Anime;
id: Scalars["Int"]["output"];
is_favorite: Scalars["Boolean"]["output"];
profile: ExternalProfile;
score: Scalars["Float"]["output"];
watch_status: Scalars["String"]["output"];
};
export type ExternalProfileSite = "AniList" | "MyAnimeList";
export type ExternalProfileVisibility = "Private" | "Public";
export type FeaturedTheme = {
end_at: Scalars["String"]["output"];
entry: Maybe<Entry>;
@@ -220,7 +242,8 @@ export type Query = {
bracket: Maybe<Bracket>;
bracketAll: Array<Bracket>;
dumpAll: Array<Dump>;
entryAll: Array<Entry>;
externalProfile: Maybe<ExternalProfile>;
externalProfileAll: Array<ExternalProfile>;
featuredTheme: Maybe<FeaturedTheme>;
imageAll: Array<Image>;
me: UserScopedQuery;
@@ -271,10 +294,14 @@ export type QueryBracketArgs = {
slug: Scalars["String"]["input"];
};
export type QueryEntryAllArgs = {
export type QueryExternalProfileArgs = {
id: Scalars["Int"]["input"];
};
export type QueryExternalProfileAllArgs = {
limit: InputMaybe<Scalars["Int"]["input"]>;
orderBy: InputMaybe<Scalars["String"]["input"]>;
orderDesc: InputMaybe<Scalars["Boolean"]["input"]>;
name: InputMaybe<Scalars["String"]["input"]>;
site: InputMaybe<ExternalProfileSite>;
};
export type QueryImageAllArgs = {
@@ -820,24 +847,23 @@ export type PlaylistTrackRemoveDialogEntryFragment = {
};
};
export type FeaturedThemeEntryFragment = {
export type FeaturedThemeThemeFragment = {
type: string;
sequence: number | null;
id: number;
version: number | null;
theme: {
anime: { slug: string; name: string; images: Array<{ link: string; facet: string | null }> };
entries: Array<{
id: number;
type: string;
sequence: number | null;
group: { name: string; slug: string } | null;
anime: { slug: string; name: string; images: Array<{ link: string; facet: string | null }> };
song: {
title: string | null;
performances: Array<{ alias: string | null; as: string | null; artist: { slug: string; name: string } }>;
} | null;
};
version: number | null;
videos: Array<{ id: number; basename: string; tags: string; audio: { basename: string } }>;
}>;
group: { name: string; slug: string } | null;
song: {
title: string | null;
performances: Array<{ alias: string | null; as: string | null; artist: { slug: string; name: string } }>;
} | null;
};
export type FeaturedThemeVideoFragment = { id: number; basename: string; tags: string; audio: { basename: string } };
export type AnimeThemeFilterThemeFragment = {
type: string;
sequence: number | null;
@@ -871,28 +897,33 @@ export type AnimeThemeFilterThemeFragment = {
}>;
};
export type HomePageMostPopularQueryVariables = Exact<{ [key: string]: never }>;
export type HomePageMostViewedQueryVariables = Exact<{ [key: string]: never }>;
export type HomePageMostPopularQuery = {
entryAll: Array<{
export type HomePageMostViewedQuery = {
videoAll: Array<{
id: number;
version: number | null;
videos: Array<{ id: number; basename: string; tags: string; audio: { basename: string } }>;
theme: {
basename: string;
tags: string;
entries: Array<{
id: number;
type: string;
sequence: number | null;
group: { name: string; slug: string } | null;
anime: { slug: string; name: string; images: Array<{ link: string; facet: string | null }> };
song: {
title: string | null;
performances: Array<{
alias: string | null;
as: string | null;
artist: { slug: string; name: string };
}>;
} | null;
};
version: number | null;
theme: {
id: number;
type: string;
sequence: number | null;
group: { name: string; slug: string } | null;
anime: { slug: string; name: string; images: Array<{ link: string; facet: string | null }> };
song: {
title: string | null;
performances: Array<{
alias: string | null;
as: string | null;
artist: { slug: string; name: string };
}>;
} | null;
};
}>;
audio: { basename: string };
}>;
};
@@ -1013,24 +1044,6 @@ export type ThemeMenuThemeFragment = {
}>;
};
export type VideoMenuEntryFragment = {
id: number;
version: number | null;
theme: {
id: number;
type: string;
sequence: number | null;
group: { name: string; slug: string } | null;
anime: { slug: string; name: string; images: Array<{ link: string; facet: string | null }> };
song: {
title: string | null;
performances: Array<{ alias: string | null; as: string | null; artist: { slug: string; name: string } }>;
} | null;
};
};
export type VideoMenuVideoFragment = { id: number; basename: string; tags: string; audio: { basename: string } };
export type SearchAnimeQueryVariables = Exact<{
args: SearchArgs;
}>;
@@ -2511,19 +2524,167 @@ export type EventPageQueryVariables = Exact<{ [key: string]: never }>;
export type EventPageQuery = { bracketAll: Array<{ slug: string; name: string }> };
export type ExternalProfilePageExternalProfileQueryVariables = Exact<{
externalProfileId: Scalars["Int"]["input"];
}>;
export type ExternalProfilePageExternalProfileQuery = {
externalProfile: {
id: number;
name: string;
site: ExternalProfileSite;
visibility: ExternalProfileVisibility;
entries: Array<{
watch_status: string;
is_favorite: boolean;
score: number;
anime: {
slug: string;
name: string;
year: number | null;
season: string | null;
media_format: string | null;
themes: Array<{
id: number;
type: string;
sequence: number | null;
group: { name: string; slug: string } | null;
anime: { slug: string };
entries: Array<{
version: number | null;
episodes: string | null;
spoiler: boolean;
nsfw: boolean;
videos: Array<{
tags: string;
resolution: number | null;
nc: boolean;
subbed: boolean;
lyrics: boolean;
uncen: boolean;
source: VideoSource | null;
overlap: VideoOverlap;
}>;
}>;
song: { title: string | null } | null;
}>;
images: Array<{ link: string; facet: string | null }>;
};
}>;
} | null;
};
export type ExternalProfilePageExternalProfileFragment = {
id: number;
name: string;
site: ExternalProfileSite;
visibility: ExternalProfileVisibility;
entries: Array<{
watch_status: string;
is_favorite: boolean;
score: number;
anime: {
slug: string;
name: string;
year: number | null;
season: string | null;
media_format: string | null;
themes: Array<{
id: number;
type: string;
sequence: number | null;
group: { name: string; slug: string } | null;
anime: { slug: string };
entries: Array<{
version: number | null;
episodes: string | null;
spoiler: boolean;
nsfw: boolean;
videos: Array<{
tags: string;
resolution: number | null;
nc: boolean;
subbed: boolean;
lyrics: boolean;
uncen: boolean;
source: VideoSource | null;
overlap: VideoOverlap;
}>;
}>;
song: { title: string | null } | null;
}>;
images: Array<{ link: string; facet: string | null }>;
};
}>;
};
export type ExternalProfilePageQueryVariables = Exact<{
name: Scalars["String"]["input"];
site: ExternalProfileSite;
}>;
export type ExternalProfilePageQuery = {
externalProfileAll: Array<{
id: number;
name: string;
site: ExternalProfileSite;
visibility: ExternalProfileVisibility;
entries: Array<{
watch_status: string;
is_favorite: boolean;
score: number;
anime: {
slug: string;
name: string;
year: number | null;
season: string | null;
media_format: string | null;
themes: Array<{
id: number;
type: string;
sequence: number | null;
group: { name: string; slug: string } | null;
anime: { slug: string };
entries: Array<{
version: number | null;
episodes: string | null;
spoiler: boolean;
nsfw: boolean;
videos: Array<{
tags: string;
resolution: number | null;
nc: boolean;
subbed: boolean;
lyrics: boolean;
uncen: boolean;
source: VideoSource | null;
overlap: VideoOverlap;
}>;
}>;
song: { title: string | null } | null;
}>;
images: Array<{ link: string; facet: string | null }>;
};
}>;
}>;
};
export type HomePageQueryVariables = Exact<{ [key: string]: never }>;
export type HomePageQuery = {
featuredTheme: {
entry: {
id: number;
version: number | null;
theme: {
id: number;
type: string;
sequence: number | null;
group: { name: string; slug: string } | null;
id: number;
anime: { slug: string; name: string; images: Array<{ link: string; facet: string | null }> };
entries: Array<{
id: number;
version: number | null;
videos: Array<{ id: number; basename: string; tags: string; audio: { basename: string } }>;
}>;
group: { name: string; slug: string } | null;
song: {
title: string | null;
performances: Array<{
@@ -2534,7 +2695,6 @@ export type HomePageQuery = {
} | null;
};
} | null;
video: { id: number; basename: string; tags: string; audio: { basename: string } } | null;
} | null;
announcementAll: Array<{ content: string }>;
};
+21 -7
View File
@@ -7,7 +7,7 @@ import pLimit from "p-limit";
import type { InputMaybe, ResolverFn } from "@/generated/graphql-resolvers";
import type { ApiIndex } from "@/lib/common/animethemes/types";
import { AUTH_REFERER, CLIENT_API_URL, PAGINATION_PAGE_SIZE, SERVER_API_URL } from "@/utils/config";
import { AUTH_REFERER, CLIENT_API_URL, PAGINATION_PAGE_SIZE, SERVER_API_KEY, SERVER_API_URL } from "@/utils/config";
import devLog from "@/utils/devLog";
const limit = pLimit(5);
@@ -87,6 +87,13 @@ export const INCLUDES = {
previous: "previous",
next: "next",
},
ExternalProfile: {
entries: "externalentries",
},
ExternalProfileEntry: {
profile: "externalprofile",
anime: "anime",
},
UserAuth: {
permissions: "permissions",
roles: "roles",
@@ -136,12 +143,6 @@ const ALLOWED_INCLUDES: Record<string, Array<string>> = {
"animethemes.animethemeentries.videos.animethemeentries.animetheme.group",
],
Theme: ["anime.images", "animethemeentries.videos", "animethemeentries.videos.audio", "song.artists", "group"],
Entry: [
"animetheme.group",
"animetheme.anime.images",
"animetheme.song.artists",
"videos.audio",
],
Artist: [
"songs.animethemes.anime",
"members",
@@ -208,6 +209,12 @@ const ALLOWED_INCLUDES: Record<string, Array<string>> = {
"video.audio",
"animethemeentry",
],
ExternalProfile: [
"externalentries.anime.animethemes.group",
"externalentries.anime.animethemes.animethemeentries.videos",
"externalentries.anime.animethemes.song",
"externalentries.anime.images",
],
UserAuth: ["permissions", "roles.permissions"],
FeaturedTheme: [
"animethemeentry.animetheme.anime.images",
@@ -474,6 +481,13 @@ export async function fetchJson<T = Record<string, unknown>>(
config.credentials = "include";
if (SERVER_API_KEY && !("cookie" in (config.headers ?? {}))) {
config.headers = {
Authorization: `Bearer ${SERVER_API_KEY}`,
...config.headers,
};
}
const response = await fetch(url, config);
if (!response.ok) {
+30 -5
View File
@@ -14,8 +14,10 @@ import type {
ApiArtistShow,
ApiAudioShow,
ApiDumpIndex,
ApiEntryIndex,
ApiEntryShow,
ApiExternalProfileEntryShow,
ApiExternalProfileIndex,
ApiExternalProfileShow,
ApiFeaturedThemeShow,
ApiImageIndex,
ApiPageIndex,
@@ -65,10 +67,6 @@ const resolvers: Resolvers = {
endpoint: () => `/artist`,
extractFromResponse: (response) => response.artists,
}),
entryAll: createApiResolverPaginated<ApiEntryIndex>()({
endpoint: (_, { orderBy, orderDesc }) => `/animethemeentry?sort=${orderDesc ? "-" : ""}${orderBy}`,
extractFromResponse: (response) => response.animethemeentries,
}),
series: createApiResolver<ApiSeriesShow>()({
endpoint: (_, { slug }) => `/series/${slug}`,
extractFromResponse: (response) => response.series,
@@ -128,6 +126,15 @@ const resolvers: Resolvers = {
`/playlist?sort=${orderDesc ? "-" : ""}${orderBy}&fields[playlist]=id,name,description,visibility,tracks_count${onlyNonEmpty ? "&filter[playlist][tracks_count-gte]=1" : ""}`,
extractFromResponse: (response) => response.playlists,
}),
externalProfile: createApiResolver<ApiExternalProfileShow>()({
endpoint: (_, { id }) => `/externalprofile/${id}`,
extractFromResponse: (response) => response.externalprofile,
}),
externalProfileAll: createApiResolverPaginated<ApiExternalProfileIndex>()({
endpoint: (_, { name, site }) =>
`/externalprofile?filter[externalprofile][name]=${name}&filter[externalprofile][site]=${site}`,
extractFromResponse: (response) => response.externalprofiles,
}),
me: () => ({}),
},
UserScopedQuery: {
@@ -456,6 +463,24 @@ const resolvers: Resolvers = {
baseInclude: INCLUDES.PlaylistTrack.video,
}),
},
ExternalProfile: {
entries: createApiResolverNotNull<ApiExternalProfileShow<"externalentries">>()({
extractFromParent: (externalProfile) => externalProfile.externalentries,
endpoint: (externalProfile) => `/externalprofile/${externalProfile.id}`,
extractFromResponse: (response) => response.externalprofile.externalentries,
type: "ExternalProfile",
baseInclude: INCLUDES.ExternalProfile.entries,
}),
},
ExternalProfileEntry: {
anime: createApiResolverNotNull<ApiExternalProfileEntryShow<"anime">>()({
extractFromParent: (entry) => entry.anime,
endpoint: (entry) => `/externalprofile/${entry.externalprofile.id}/externalentry/${entry.id}`,
extractFromResponse: (response) => response.externalentry.anime,
type: "ExternalProfileEntry",
baseInclude: INCLUDES.ExternalProfileEntry.anime,
}),
},
UserAuth: {
roles: createApiResolverNotNull<ApiUserShow<"roles">>()({
extractFromParent: (user) => user.roles,
+30 -1
View File
@@ -6,7 +6,6 @@ const typeDefs = gql`
animeAll(limit: Int, year: Int, season: String): [Anime!]!
theme(id: Int): Theme
themeAll(limit: Int, orderBy: String, orderDesc: Boolean, has: String): [Theme!]!
entryAll(limit: Int, orderBy: String, orderDesc: Boolean): [Entry!]!
artist(id: Int, slug: String): Artist
artistAll(limit: Int): [Artist!]!
series(id: Int, slug: String): Series
@@ -24,6 +23,8 @@ const typeDefs = gql`
dumpAll: [Dump!]!
playlist(id: String!): Playlist
playlistAll(limit: Int, orderBy: String, orderDesc: Boolean, onlyNonEmpty: Boolean): [Playlist!]!
externalProfile(id: Int!): ExternalProfile
externalProfileAll(limit: Int, name: String, site: ExternalProfileSite): [ExternalProfile!]!
announcementAll: [Announcement!]!
me: UserScopedQuery!
}
@@ -272,6 +273,34 @@ const typeDefs = gql`
next: PlaylistTrack
}
type ExternalProfile {
id: Int!
name: String!
site: ExternalProfileSite!
visibility: ExternalProfileVisibility!
entries: [ExternalProfileEntry!]!
user: UserPublic
}
enum ExternalProfileSite {
AniList
MyAnimeList
}
enum ExternalProfileVisibility {
Public
Private
}
type ExternalProfileEntry {
id: Int!
score: Float!
is_favorite: Boolean!
watch_status: String!
profile: ExternalProfile!
anime: Anime!
}
interface User {
id: Int!
name: String!
+35
View File
@@ -2,6 +2,8 @@
// General Types
// --------------------------------
import type { ExternalProfileSite, ExternalProfileVisibility } from "@/generated/graphql-resolvers";
export interface ApiIndex {
links: ApiIndexLinks;
meta: ApiIndexMeta;
@@ -143,6 +145,23 @@ export interface ApiPlaylistTrack {
video?: ApiVideo;
}
export interface ApiExternalProfile {
id: number;
name: string;
site: ExternalProfileSite;
visibility: ExternalProfileVisibility;
externalentries?: Array<ApiExternalProfileEntry>;
}
export interface ApiExternalProfileEntry {
id: number;
score: number;
is_favorite: boolean;
watch_status: string;
externalprofile: ApiExternalProfile;
anime?: ApiAnime;
}
export interface ApiResource {
id: number;
link: string | null;
@@ -324,6 +343,22 @@ export interface ApiPlaylistTrackIndex extends ApiIndex {
tracks: Array<ApiPlaylistTrack>;
}
export interface ApiExternalProfileShow<Includes extends keyof ApiExternalProfile = never> {
externalprofile: ApiExternalProfile & Required<Pick<ApiExternalProfile, Includes>>;
}
export interface ApiExternalProfileIndex extends ApiIndex {
externalprofiles: Array<ApiExternalProfile>;
}
export interface ApiExternalProfileEntryShow<Includes extends keyof ApiExternalProfileEntry = never> {
externalentry: ApiExternalProfileEntry & Required<Pick<ApiExternalProfileEntry, Includes>>;
}
export interface ApiExternalProfileEntryIndex extends ApiIndex {
externalentries: Array<ApiExternalProfileEntry>;
}
export interface ApiThemeShow<Includes extends keyof ApiTheme = never> {
animetheme: ApiTheme & Required<Pick<ApiTheme, Includes>>;
}
+2 -16
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useEffect, useState } from "react";
import type { ComponentType, ReactNode } from "react";
import styled, { ThemeProvider } from "styled-components";
import type { AppProps } from "next/app";
@@ -103,9 +103,7 @@ export default function MyApp({ Component, pageProps }: AppProps) {
const currentVideoSlug = getVideoSlugByProps(pageProps);
const [previousVideoSlug, setPreviousVideoSlug] = useState<string | null>(() => getVideoSlugByProps(pageProps));
const isIOS = /iPhone|iPad|iPod/.test(navigator.userAgent);
const [isFullscreen, { toggleFullscreen : nativeToggleFullscreen }] = useFullscreen(() => document.documentElement, {
const [isFullscreen, { toggleFullscreen }] = useFullscreen(() => document.documentElement, {
onEnter() {
document.documentElement.dataset.fullscreen = "true";
},
@@ -114,18 +112,6 @@ export default function MyApp({ Component, pageProps }: AppProps) {
},
});
const toggleFullscreen = useCallback(() => {
if (isIOS) {
// iOS does not support the Fullscreen API, so it's handled differently
const videoElement = document.querySelector("video");
if (videoElement && "webkitEnterFullscreen" in videoElement) {
(videoElement as HTMLVideoElement & { webkitEnterFullscreen: () => void }).webkitEnterFullscreen();
}
} else {
nativeToggleFullscreen();
}
}, [isIOS, nativeToggleFullscreen]);
useEffect(() => {
if (!isVideoPage && isFullscreen) {
toggleFullscreen();
+269
View File
@@ -0,0 +1,269 @@
import { memo, useMemo, useState } from "react";
import styled from "styled-components";
import type { GetServerSideProps } from "next";
import { faStar } from "@fortawesome/free-solid-svg-icons";
import gql from "graphql-tag";
import type { ParsedUrlQuery } from "querystring";
import useSWR from "swr";
import { Column, Row } from "@/components/box/Flex";
import { FilterToggleButton } from "@/components/button/FilterToggleButton";
import { AnimeSummaryCard } from "@/components/card/AnimeSummaryCard";
import { SidebarContainer } from "@/components/container/SidebarContainer";
import { DescriptionList } from "@/components/description-list/DescriptionList";
import { Icon } from "@/components/icon/Icon";
import { MultiCoverImage } from "@/components/image/MultiCoverImage";
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 { Collapse } from "@/components/utils/Collapse";
import type {
ExternalProfilePageExternalProfileQuery,
ExternalProfilePageExternalProfileQueryVariables,
ExternalProfilePageQuery,
ExternalProfilePageQueryVariables,
ExternalProfileSite,
} from "@/generated/graphql";
import useToggle from "@/hooks/useToggle";
import { fetchDataClient } from "@/lib/client";
import { fetchData } from "@/lib/server";
import theme from "@/theme/index";
import {
ANIME_A_Z,
ANIME_NEW_OLD,
ANIME_OLD_NEW,
ANIME_Z_A,
either,
getComparator,
sortTransformed,
} from "@/utils/comparators";
import type { SharedPageProps } from "@/utils/getSharedPageProps";
import getSharedPageProps from "@/utils/getSharedPageProps";
import type { Comparator } from "@/utils/types";
const SITE_MAPPINGS: Record<string, ExternalProfileSite> = {
al: "AniList",
anilist: "AniList",
mal: "MyAnimeList",
myanimelist: "MyAnimeList",
};
const StyledDesktopOnly = styled.div`
gap: 24px;
@media (max-width: ${theme.breakpoints.mobileMax}) {
display: none;
}
`;
const SCORE_ASC = "score-asc";
const SCORE_DESC = "score-desc";
const FAVORITES_FIRST = "favorites-first";
const comparators = {
[ANIME_A_Z]: sortTransformed(getComparator(ANIME_A_Z), (entry) => entry.anime),
[ANIME_Z_A]: sortTransformed(getComparator(ANIME_Z_A), (entry) => entry.anime),
[ANIME_OLD_NEW]: sortTransformed(getComparator(ANIME_OLD_NEW), (entry) => entry.anime),
[ANIME_NEW_OLD]: sortTransformed(getComparator(ANIME_NEW_OLD), (entry) => entry.anime),
[SCORE_ASC]: (a, b) => a.score - b.score,
[SCORE_DESC]: (a, b) => b.score - a.score,
[FAVORITES_FIRST]: (a, b) => (b.is_favorite ? 1 : 0) - (a.is_favorite ? 1 : 0),
} satisfies Record<string, Comparator<ExternalProfilePageQuery["externalProfileAll"][number]["entries"][number]>>;
type ExternalProfilePageProps = SharedPageProps & {
externalProfile: ExternalProfilePageQuery["externalProfileAll"][number];
};
interface ExternalProfilePageParams extends ParsedUrlQuery {
name: string;
site: ExternalProfileSite;
}
export default function ExternalProfilePage({ externalProfile: initialExternalProfile }: ExternalProfilePageProps) {
const { data: externalProfile } = useSWR(
["ExternalProfilePageExternalProfile", `/api/externalprofile/${initialExternalProfile.id}`],
async () => {
const { data } = await fetchDataClient<
ExternalProfilePageExternalProfileQuery,
ExternalProfilePageExternalProfileQueryVariables
>(
gql`
${ExternalProfilePage.fragments.externalProfile}
query ExternalProfilePageExternalProfile($externalProfileId: Int!) {
externalProfile(id: $externalProfileId) {
...ExternalProfilePageExternalProfile
}
}
`,
{ externalProfileId: initialExternalProfile.id },
);
if (!data.externalProfile) {
location.reload();
throw new Error("External profile was removed or user lost auth.");
}
return data.externalProfile;
},
{ fallbackData: initialExternalProfile },
);
const favorites = useMemo(
() => externalProfile.entries.filter((entry) => entry.is_favorite),
[externalProfile.entries],
);
const entriesFiltered = useMemo(
() => externalProfile.entries.filter((entry) => entry.watch_status === "Completed"),
[externalProfile.entries],
);
// For the cover image we want to show the user's favorites.
// If there aren't enough favorites we resort to the entries with the highest score.
const coverImageAnime = useMemo(
() =>
entriesFiltered
.sort(either(comparators[FAVORITES_FIRST]).or(comparators[SCORE_DESC]).chain())
.map((entry) => entry.anime),
[entriesFiltered],
);
return (
<>
<SEO title={externalProfile.name} />
<Text variant="h1">{externalProfile.name}</Text>
<SidebarContainer>
<Column style={{ "--gap": "24px" }}>
<StyledDesktopOnly>
<MultiCoverImage resourcesWithImages={coverImageAnime} />
</StyledDesktopOnly>
<DescriptionList>
<DescriptionList.Item title="Links">
<Text link>{externalProfile.site}</Text>
</DescriptionList.Item>
</DescriptionList>
</Column>
<Column style={{ "--gap": "24px" }}>
{favorites.length > 0 && <ExternalProfileEntryList title="Favorites" entries={favorites} />}
<ExternalProfileEntryList title="All Anime" entries={entriesFiltered} />
</Column>
</SidebarContainer>
</>
);
}
interface ExternalProfileEntryListProps {
title: string;
entries: ExternalProfilePageProps["externalProfile"]["entries"];
}
const ExternalProfileEntryList = memo(function SeriesAnime({ title, entries }: ExternalProfileEntryListProps) {
const [showFilter, toggleShowFilter] = useToggle();
const [sortBy, setSortBy] = useState<keyof typeof comparators>(SCORE_DESC);
const entriesSorted = useMemo(
() => [...entries].filter((entry) => entry.watch_status === "Completed").sort(comparators[sortBy]),
[entries, sortBy],
);
return (
<>
<Row style={{ "--justify-content": "space-between", "--align-items": "center" }}>
<Text variant="h2">
{title}
<Text color="text-disabled"> ({entries.length})</Text>
</Text>
<FilterToggleButton onClick={toggleShowFilter} />
</Row>
<Collapse collapse={!showFilter}>
<SearchFilterGroup>
<SearchFilterSortBy value={sortBy} setValue={setSortBy}>
<SearchFilterSortBy.Option value={SCORE_DESC}>Score (High Low)</SearchFilterSortBy.Option>
<SearchFilterSortBy.Option value={SCORE_ASC}>Score (Low High)</SearchFilterSortBy.Option>
<SearchFilterSortBy.Option value={ANIME_A_Z}>A Z</SearchFilterSortBy.Option>
<SearchFilterSortBy.Option value={ANIME_Z_A}>Z A</SearchFilterSortBy.Option>
<SearchFilterSortBy.Option value={ANIME_OLD_NEW}>Old New</SearchFilterSortBy.Option>
<SearchFilterSortBy.Option value={ANIME_NEW_OLD}>New Old</SearchFilterSortBy.Option>
</SearchFilterSortBy>
</SearchFilterGroup>
</Collapse>
<Column style={{ "--gap": "16px" }}>
{entriesSorted.map((entry) => (
<AnimeSummaryCard key={entry.anime.slug} anime={entry.anime} expandable>
{entry.score > 0 && (
<Text variant="small" color={"gold"} noWrap title="Score">
<Icon icon={faStar} /> {entry.score}
</Text>
)}
</AnimeSummaryCard>
))}
</Column>
</>
);
});
ExternalProfilePage.fragments = {
externalProfile: gql`
${AnimeSummaryCard.fragments.anime}
${AnimeSummaryCard.fragments.expandable}
fragment ExternalProfilePageExternalProfile on ExternalProfile {
id
name
site
visibility
entries {
watch_status
is_favorite
score
anime {
...AnimeSummaryCardAnime
...AnimeSummaryCardAnimeExpandable
}
}
}
`,
};
export const getServerSideProps: GetServerSideProps<ExternalProfilePageProps, ExternalProfilePageParams> = async ({
params,
req,
}) => {
if (!params) {
return { notFound: true };
}
const site = SITE_MAPPINGS[params.site.toLowerCase()];
if (!site) {
return { notFound: true };
}
const { data, apiRequests } = await fetchData<ExternalProfilePageQuery, ExternalProfilePageQueryVariables>(
gql`
${ExternalProfilePage.fragments.externalProfile}
query ExternalProfilePage($name: String!, $site: ExternalProfileSite!) {
externalProfileAll(name: $name, site: $site) {
...ExternalProfilePageExternalProfile
}
}
`,
{ name: params.name, site: site },
{ req },
);
if (!data.externalProfileAll.length) {
return { notFound: true };
}
return {
props: {
...getSharedPageProps(apiRequests),
externalProfile: data.externalProfileAll[0],
},
};
};
+11 -13
View File
@@ -21,7 +21,7 @@ import { AnnouncementCard } from "@/components/card/AnnouncementCard";
import { ShuffleDialog } from "@/components/dialog/ShuffleDialog";
import { ExternalLink } from "@/components/external-link/ExternalLink";
import { FeaturedTheme } from "@/components/featured-theme/FeaturedTheme";
import { MostPopularEntries } from "@/components/home/MostPopularEntries";
import { MostViewedVideos } from "@/components/home/MostViewedVideos";
import { RecentlyAddedPlaylists } from "@/components/home/RecentlyAddedPlaylists";
import { RecentlyAddedVideos } from "@/components/home/RecentlyAddedVideos";
import { Icon } from "@/components/icon/Icon";
@@ -67,7 +67,7 @@ const Grid = styled.div<{ $columns: number }>`
`;
interface HomePageProps {
featuredTheme: NonNullable<NonNullable<HomePageQuery["featuredTheme"]>> | null;
featuredTheme: NonNullable<NonNullable<HomePageQuery["featuredTheme"]>["entry"]>["theme"] | null;
announcementSources: MDXRemoteSerializeResult[];
}
@@ -82,10 +82,10 @@ export default function HomePage({ featuredTheme, announcementSources }: HomePag
{announcementSources.length > 0 ? <AnnouncementCard announcementSource={announcementSources[0]} /> : null}
{featuredTheme && featuredTheme.entry && featuredTheme.video ? (
{featuredTheme ? (
<>
<Text variant="h2">Featured Theme</Text>
<FeaturedTheme entry={featuredTheme.entry} video={featuredTheme.video} />
<FeaturedTheme theme={featuredTheme} />
</>
) : null}
@@ -154,8 +154,8 @@ export default function HomePage({ featuredTheme, announcementSources }: HomePag
<RecentlyAddedVideos />
</Column>
<Column style={{ "--gap": "24px" }}>
<Text variant="h2">Most Popular</Text>
<MostPopularEntries />
<Text variant="h2">Most Viewed</Text>
<MostViewedVideos />
</Column>
<Column style={{ "--gap": "24px" }}>
<Text variant="h2">New Playlists</Text>
@@ -224,16 +224,14 @@ export default function HomePage({ featuredTheme, announcementSources }: HomePag
export const getStaticProps: GetStaticProps<HomePageProps> = async () => {
const { data, apiRequests } = await fetchData<HomePageQuery>(gql`
${FeaturedTheme.fragments.entry}
${FeaturedTheme.fragments.video}
${FeaturedTheme.fragments.theme}
query HomePage {
featuredTheme {
entry {
...FeaturedThemeEntry
}
video {
...FeaturedThemeVideo
theme {
...FeaturedThemeTheme
}
}
}
announcementAll {
@@ -245,7 +243,7 @@ export const getStaticProps: GetStaticProps<HomePageProps> = async () => {
return {
props: {
...getSharedPageProps(apiRequests),
featuredTheme: data?.featuredTheme,
featuredTheme: data?.featuredTheme?.entry?.theme ?? null,
announcementSources: await Promise.all(
data?.announcementAll.map(
async (announcement) => (await serializeMarkdownSafe(announcement.content)).source,
+9 -6
View File
@@ -369,8 +369,15 @@ export default function PlaylistDetailPage({ playlist: initialPlaylist, me: init
) : null}
{isRanking && topRankedTrack ? (
<FeaturedTheme
entry={tracks[0].entry}
video={tracks[0].video}
theme={{
...tracks[0].entry.theme,
entries: [
{
...tracks[0].entry,
videos: [tracks[0].video],
},
],
}}
hasGrill={false}
card={
<PlaylistTrack
@@ -706,8 +713,6 @@ PlaylistDetailPage.fragments = {
${VideoSummaryCardFragmentEntry}
${PlaylistEditDialog.fragments.playlist}
${PlaylistTrackRemoveDialog.fragments.playlist}
${FeaturedTheme.fragments.entry}
${FeaturedTheme.fragments.video}
fragment PlaylistDetailPagePlaylist on Playlist {
...PlaylistEditDialogPlaylist
@@ -721,12 +726,10 @@ PlaylistDetailPage.fragments = {
id
video {
...VideoSummaryCardVideo
...FeaturedThemeVideo
id
}
entry {
...VideoSummaryCardEntry
...FeaturedThemeEntry
theme {
anime {
year
+9
View File
@@ -25,6 +25,7 @@ import { PlaylistAddDialog } from "@/components/dialog/PlaylistAddDialog";
import { PlaylistRemoveDialog } from "@/components/dialog/PlaylistRemoveDialog";
import { RegisterDialog } from "@/components/dialog/RegisterDialog";
import { UserInformationDialog } from "@/components/dialog/UserInformationDialog";
import ExternalProfileList from "@/components/external-profile/ExternalProfileList";
import { Icon } from "@/components/icon/Icon";
import { ProfileImage } from "@/components/image/ProfileImage";
import { Listbox, ListboxOption } from "@/components/listbox/Listbox";
@@ -65,6 +66,8 @@ const StyledHeader = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
min-height: 38px;
`;
const StyledHeaderTop = styled(StyledHeader)`
@@ -240,6 +243,12 @@ export default function ProfilePage({ me: initialMe }: ProfilePageProps) {
) : null}
<StyledProfileGrid>
<Column style={{ "--gap": "48px" }}>
<Column style={{ "--gap": "24px" }}>
<StyledHeader>
<Text variant="h2">External Profiles</Text>
</StyledHeader>
<ExternalProfileList />
</Column>
{me.playlistAll ? (
<Column style={{ "--gap": "24px" }}>
<StyledHeader>
+2
View File
@@ -5,6 +5,7 @@ import { error, warn } from "@/utils/log";
// Server-side
const SERVER_API_URL = process.env.ANIMETHEMES_API_URL;
const SERVER_API_KEY = process.env.ANIMETHEMES_API_KEY;
const ANALYZE = !!process.env.ANALYZE;
const MINIMAL_BUILD = !!process.env.MINIMAL_BUILD;
@@ -61,6 +62,7 @@ export {
CLIENT_API_URL,
MINIMAL_BUILD,
PAGINATION_PAGE_SIZE,
SERVER_API_KEY,
SERVER_API_URL,
STAGING,
validateConfig,
-5
View File
@@ -24,11 +24,6 @@ export const GlobalVolume: Setting<number> = Object.freeze({
__INITIAL_VALUE__: 1,
});
export const Muted: Setting<boolean> = Object.freeze({
__KEY__: "muted",
__INITIAL_VALUE__: false,
});
export const ColorTheme: Setting<string> = Object.freeze({
__KEY__: "theme",
__INITIAL_VALUE__: "system",