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
15 changed files with 844 additions and 8 deletions
-4
View File
@@ -30,10 +30,6 @@ This is a list of all available options:
; The URL to the AnimeThemes API which will be used on the server.
ANIMETHEMES_API_URL=http://localhost
; If specified, this API key will be used to make requests to the AnimeThemes API.
; This is used to by-pass rate limiting.
ANIMETHEMES_API_KEY=...
; Set to any truthy value to activate minimal build mode.
; In minimal build mode, only a small subset of pages get prerendered at build time.
MINIMAL_BUILD=true
+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
@@ -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}>
@@ -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>
);
}
+92
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,6 +314,8 @@ export type Query = {
bracket?: Maybe<Bracket>;
bracketAll: Array<Bracket>;
dumpAll: Array<Dump>;
externalProfile?: Maybe<ExternalProfile>;
externalProfileAll: Array<ExternalProfile>;
featuredTheme?: Maybe<FeaturedTheme>;
imageAll: Array<Image>;
me: UserScopedQuery;
@@ -332,6 +366,16 @@ export type QueryBracketArgs = {
slug: Scalars["String"]["input"];
};
export type QueryExternalProfileArgs = {
id: Scalars["Int"]["input"];
};
export type QueryExternalProfileAllArgs = {
limit?: InputMaybe<Scalars["Int"]["input"]>;
name?: InputMaybe<Scalars["String"]["input"]>;
site?: InputMaybe<ExternalProfileSite>;
};
export type QueryImageAllArgs = {
facet?: InputMaybe<Scalars["String"]["input"]>;
limit?: InputMaybe<Scalars["Int"]["input"]>;
@@ -721,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"]>;
@@ -795,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"
@@ -1024,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"],
@@ -1149,6 +1227,18 @@ export type QueryResolvers<
>;
bracketAll?: Resolver<Array<ResolversTypes["Bracket"]>, ParentType, ContextType>;
dumpAll?: Resolver<Array<ResolversTypes["Dump"]>, ParentType, ContextType>;
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>;
@@ -1469,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>;
+179
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,6 +242,8 @@ export type Query = {
bracket: Maybe<Bracket>;
bracketAll: Array<Bracket>;
dumpAll: Array<Dump>;
externalProfile: Maybe<ExternalProfile>;
externalProfileAll: Array<ExternalProfile>;
featuredTheme: Maybe<FeaturedTheme>;
imageAll: Array<Image>;
me: UserScopedQuery;
@@ -270,6 +294,16 @@ export type QueryBracketArgs = {
slug: Scalars["String"]["input"];
};
export type QueryExternalProfileArgs = {
id: Scalars["Int"]["input"];
};
export type QueryExternalProfileAllArgs = {
limit: InputMaybe<Scalars["Int"]["input"]>;
name: InputMaybe<Scalars["String"]["input"]>;
site: InputMaybe<ExternalProfileSite>;
};
export type QueryImageAllArgs = {
facet: InputMaybe<Scalars["String"]["input"]>;
limit: InputMaybe<Scalars["Int"]["input"]>;
@@ -2490,6 +2524,151 @@ 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 = {
+13
View File
@@ -87,6 +87,13 @@ export const INCLUDES = {
previous: "previous",
next: "next",
},
ExternalProfile: {
entries: "externalentries",
},
ExternalProfileEntry: {
profile: "externalprofile",
anime: "anime",
},
UserAuth: {
permissions: "permissions",
roles: "roles",
@@ -202,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",
+30
View File
@@ -15,6 +15,9 @@ import type {
ApiAudioShow,
ApiDumpIndex,
ApiEntryShow,
ApiExternalProfileEntryShow,
ApiExternalProfileIndex,
ApiExternalProfileShow,
ApiFeaturedThemeShow,
ApiImageIndex,
ApiPageIndex,
@@ -123,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: {
@@ -451,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
View File
@@ -23,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!
}
@@ -271,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>>;
}
+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],
},
};
};
+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>