126 | {#each recommendations as recommendation (recommendation.id)}
127 |
128 |
129 |
130 |
131 | +{recommendation.rating} votes
132 |
133 | {textify(recommendation.mediaRecommendation.format)} · {textify(recommendation.mediaRecommendation.status)}
134 |
135 |
136 | {/each}
137 | {#if $media.data.Media.recommendations.pageInfo.hasNextPage}
138 |
139 | See more on AniList
140 |
141 | {/if}
142 |
143 | {/if}
144 | {#if $media.data.Media.externalLinks.length > 0}
145 |
167 | {/if}
--------------------------------------------------------------------------------
/src/entries/popup/routes/media/Social.svelte:
--------------------------------------------------------------------------------
1 |
24 |
25 |
26 |
27 |
28 | {#each ($followingStats.data.Page.mediaList || []) as following (following.user.id)}
29 |
30 |
31 |
32 |
33 |
34 | {following.user.name}
35 |
36 |
{following.status.toLowerCase()}
37 |
38 | {#if following.notes}
39 |
40 |
41 |
User Notes
42 |
{@html following.notes.split("\n").join("
")}
43 |
44 |
45 |
46 | {:else}
47 |
48 | {/if}
49 | {#if following.score > 0}
50 |
51 | 70 ? faSmile : following.score < 50 ? faFrown : faMeh}
54 | />
55 |
56 | {:else}
57 |
58 | {/if}
59 | {#if following.repeat > 0}
60 |
61 |
62 |
63 | {:else}
64 |
65 | {/if}
66 |
67 |
68 | {/each}
69 |
70 |
71 |
--------------------------------------------------------------------------------
/src/entries/popup/routes/media/Stats.svelte:
--------------------------------------------------------------------------------
1 |
40 |
41 |
42 | {#if $media.data.Media.rankings.length > 0}
43 |
55 | {/if}
56 |
57 |
58 | {#each $media.data.Media.stats.statusDistribution as status}
59 |
60 |
61 | {status.amount.toLocaleString()}
62 |
63 | {/each}
64 |
65 |
66 | {#each $media.data.Media.stats.statusDistribution as status}
67 |
68 | {/each}
69 |
70 |
71 |
72 |
73 | {#each new Array(10) as _, index}
74 | {@const score = $media.data.Media.stats.scoreDistribution.find(s => s.score === (index + 1) * 10) || { amount: 0, score: (index + 1) * 10 }}
75 | {@const percentage = score.amount / highestRating * 100}
76 |
77 |
{ score.score }
78 |
79 |
{ score.amount.toLocaleString() }
80 |
81 | {/each}
82 |
83 |
84 |
--------------------------------------------------------------------------------
/src/entries/popup/routes/media/index.svelte:
--------------------------------------------------------------------------------
1 |
69 |
70 |
71 |
78 |
82 |
83 |
84 |
85 |
86 |
87 |
88 | {textify($media.data.Media.format) || "Unknown"}
89 |
90 | ·
91 | {#if $media.data.Media.status === MediaStatus.RELEASING && $media.data.Media.nextAiringEpisode}
92 | {@const next = $media.data.Media.nextAiringEpisode}
93 | {@const date = new Date(next.airingAt * 1000)}
94 |
95 |
96 | Ep {next.episode}: {readableTime(parseSeconds(next.timeUntilAiring), { includeSeconds: false, includeWeeks: true })}
97 |
98 |
99 |
100 | {textify($media.data.Media.status) || "Unknown"}
101 |
102 | {:else}
103 |
104 | {textify($media.data.Media.status) || "Unknown"}
105 |
106 | {/if}
107 | {#if $media.data.Media.averageScore}
108 | ·
109 |
110 | 70 ? faSmile : $media.data.Media.averageScore < 50 ? faFrown : faMeh}
113 | />
114 |
115 | {/if}
116 |
117 | {#if $loggedIn}
118 |
119 |
122 |
123 | {#if $media.data.Media.mediaListEntry}
124 |
125 |
128 |
129 | {/if}
130 | {/if}
131 |
132 |
133 |
{$media.data.Media.title.userPreferred}
134 |
135 | {#if $loggedIn}
136 |
137 |
138 |
139 |
140 |
141 |
143 |
144 |
146 |
147 |
149 |
150 |
152 |
153 | {/if}
154 |
155 |
156 |
157 |
161 |
165 |
169 | {#if $loggedIn}
170 |
174 | {/if}
175 |
180 | AniList
181 |
182 |
183 |
184 |
185 |
186 |
--------------------------------------------------------------------------------
/src/lib/actions/clickaway.ts:
--------------------------------------------------------------------------------
1 | export function clickaway(node: HTMLElement, onClickaway: (node: HTMLElement) => void) {
2 | const handleClick = (event: MouseEvent) => {
3 | if (node && !node.contains(event.target as Node) && !event.defaultPrevented)
4 | onClickaway(node);
5 | }
6 |
7 | document.addEventListener('click', handleClick, true);
8 |
9 | return {
10 | destroy() {
11 | document.removeEventListener('click', handleClick, true);
12 | }
13 | }
14 | }
--------------------------------------------------------------------------------
/src/lib/actions/floating.ts:
--------------------------------------------------------------------------------
1 | // https://github.com/TehNut/svelte-floating-ui
2 |
3 | import type { ComputePositionConfig, ComputePositionReturn, Middleware, Padding } from '@floating-ui/core';
4 | import { arrow as arrowCore } from "@floating-ui/core";
5 | import { computePosition } from "@floating-ui/dom";
6 | import type { Writable } from "svelte/store";
7 | import { get } from 'svelte/store';
8 |
9 | type ComputeConfig = Omit
& {
10 | onComputed?: (computed: ComputePositionReturn) => void
11 | };
12 | type UpdatePosition = (contentOptions?: ComputeConfig) => void;
13 | type ReferenceAction = (node: HTMLElement) => void;
14 | type ContentAction = (node: HTMLElement, contentOptions?: ComputeConfig) => void;
15 | type ArrowOptions = { padding?: Padding, element: Writable };
16 |
17 | export function createFloatingActions(initOptions?: ComputeConfig): [ ReferenceAction, ContentAction, UpdatePosition ] {
18 | let referenceElement: HTMLElement;
19 | let contentElement: HTMLElement;
20 | let options: ComputeConfig | undefined = initOptions;
21 |
22 | const updatePosition = (updateOptions?: ComputeConfig) => {
23 | if (referenceElement && contentElement) {
24 | options = { ...initOptions, ...updateOptions };
25 | computePosition(referenceElement, contentElement, options)
26 | .then(v => {
27 | Object.assign(contentElement.style, {
28 | position: v.strategy,
29 | left: `${v.x}px`,
30 | top: `${v.y}px`,
31 | });
32 |
33 | options.onComputed && options.onComputed(v);
34 | });
35 | }
36 | }
37 |
38 | const referenceAction: ReferenceAction = node => {
39 | referenceElement = node;
40 | updatePosition();
41 | }
42 |
43 | const contentAction: ContentAction = (node, contentOptions?) => {
44 | contentElement = node;
45 | options = { ...initOptions, ...contentOptions };
46 | updatePosition();
47 | return {
48 | update: updatePosition
49 | }
50 | }
51 |
52 | return [
53 | referenceAction, // Action to set the reference element
54 | contentAction, // Action to set the content element and apply config overrides
55 | updatePosition // Method to update the content position
56 | ]
57 | }
58 |
59 | export function arrow(options?: ArrowOptions): Middleware {
60 | return {
61 | name: "arrow",
62 | options,
63 | fn(args) {
64 | const element = get(options.element);
65 |
66 | if (element) {
67 | return arrowCore({
68 | element,
69 | padding: options.padding
70 | }).fn(args);
71 | }
72 |
73 | return {};
74 | }
75 | }
76 | }
--------------------------------------------------------------------------------
/src/lib/actions/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./clickaway";
2 | export * from "./floating";
--------------------------------------------------------------------------------
/src/lib/components/Button.svelte:
--------------------------------------------------------------------------------
1 |
28 |
29 |
--------------------------------------------------------------------------------
/src/lib/components/DebugOverlay.svelte:
--------------------------------------------------------------------------------
1 |
7 |
8 | {#if anyVisible}
9 |
10 |
11 | {#if debug.displayQueryLimits}
12 | Queries: {$queryCount}/90
13 | {/if}
14 |
15 |
16 | {/if}
--------------------------------------------------------------------------------
/src/lib/components/Error.svelte:
--------------------------------------------------------------------------------
1 |
22 |
23 |
24 |
{randomEmoji}
25 | {text}
26 |
--------------------------------------------------------------------------------
/src/lib/components/Loader.svelte:
--------------------------------------------------------------------------------
1 |
26 |
27 |
28 |
{randomEmoji}
29 |
--------------------------------------------------------------------------------
/src/lib/components/MediaCard.svelte:
--------------------------------------------------------------------------------
1 |
13 |
14 |
15 |
16 |
{media.title.userPreferred}
17 |
18 | {textify(media.format) || "Unknown"} · {textify(media.status) || "Unknown"}
19 |
20 |
21 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/src/lib/components/MediaDetail.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 | {#if description}
12 |
13 | {description}
14 |
15 | {data}
16 | {title}
17 |
18 |
19 | {:else}
20 |
21 | {data}
22 | {title}
23 |
24 | {/if}
--------------------------------------------------------------------------------
/src/lib/components/MediaListCard.svelte:
--------------------------------------------------------------------------------
1 |
37 |
38 |
39 |
40 | {#if behindCount > 0}
41 | {behindCount} episode{behindCount === 1 ? "" : "s"} behind
42 | {/if}
43 | Progress: {listEntry.progress}{maxProgress ? "/" + maxProgress : ""}
44 |
45 |
46 |
47 |
53 |
54 |
55 | {#if listEntry.media.nextAiringEpisode}
56 |
57 | Ep {listEntry.media.nextAiringEpisode.episode}
58 |
59 | {readableTime(parseSeconds(listEntry.media.nextAiringEpisode.timeUntilAiring), { includeSeconds: false, includeWeeks: true })}
60 |
61 | {/if}
62 | {#if listEntry.media.nextAiringEpisode?.episode - 1 > listEntry.progress}
63 |
64 | {/if}
65 |
--------------------------------------------------------------------------------
/src/lib/components/NavLink.svelte:
--------------------------------------------------------------------------------
1 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/src/lib/components/QueryContainer.svelte:
--------------------------------------------------------------------------------
1 |
8 |
9 | {#if $query.fetching || query.isPaused$ && !$query.data}
10 |
11 |
12 |
13 | {:else if $query.error}
14 |
15 |
16 |
17 | {:else}
18 |
19 | {/if}
--------------------------------------------------------------------------------
/src/lib/components/Routes.svelte:
--------------------------------------------------------------------------------
1 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/src/lib/components/SearchResults.svelte:
--------------------------------------------------------------------------------
1 |
50 |
51 | {#if results?.length > 0}
52 |
53 |
54 | {#each results as media (media.id)}
55 |
56 |
57 |
58 |
59 |
60 |
{media.title.userPreferred}
61 |
62 | {#if $loggedIn}
63 |
64 |
65 |
66 |
67 |
68 |
69 | {#if canAddPlanning(media)}
70 |
71 |
74 |
75 | {/if}
76 | {#if canAddCurrent(media)}
77 |
78 |
81 |
82 | {/if}
83 | {#if canRewatch(media)}
84 |
85 |
88 |
89 | {/if}
90 |
91 | {:else}
92 |
93 | {/if}
94 |
95 | {/each}
96 |
97 |
98 | {/if}
--------------------------------------------------------------------------------
/src/lib/components/Section.svelte:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 |
10 | {title}
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/src/lib/components/Tooltip.svelte:
--------------------------------------------------------------------------------
1 |
70 |
71 | shown = true}
73 | on:focusin={() => shown = true}
74 | on:mouseleave={() => shown = false}
75 | on:focusout={() => shown = false}
76 | use:floatingRef
77 | class="relative {containerClasses}"
78 | >
79 |
80 | {#if shown}
81 |
82 |
83 | {content}
84 |
85 |
86 |
87 | {/if}
88 |
89 |
--------------------------------------------------------------------------------
/src/lib/components/notifications/ActivityNotification.svelte:
--------------------------------------------------------------------------------
1 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | {notification.user.name}
32 | {notification.context}
33 |
34 |
--------------------------------------------------------------------------------
/src/lib/components/notifications/MediaDeletionNotification.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
14 | {notification.deletedMediaTitle}
15 | {notification.context}
16 |
17 |
--------------------------------------------------------------------------------
/src/lib/components/notifications/MediaMergeNotification.svelte:
--------------------------------------------------------------------------------
1 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | {notification.deletedMediaTitles.join(", ")}
19 | {notification.context}
20 | {notification.media.title.userPreferred}
21 |
22 |
--------------------------------------------------------------------------------
/src/lib/components/notifications/MediaNotification.svelte:
--------------------------------------------------------------------------------
1 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | {#if notification.type === NotificationType.AIRING}
22 | {notification.contexts[0]}
23 | {notification.episode}
24 | {notification.contexts[1]}
25 | {notification.media.title.userPreferred}
26 | {notification.contexts[2]}
27 | {:else}
28 | {notification.media.title.userPreferred}
29 | {notification.context}
30 | {/if}
31 |
32 |
--------------------------------------------------------------------------------
/src/lib/components/notifications/NotificationContainer.svelte:
--------------------------------------------------------------------------------
1 |
11 |
12 | unread = false}
15 | >
16 |
17 |
20 |
--------------------------------------------------------------------------------
/src/lib/components/notifications/NotificationPage.svelte:
--------------------------------------------------------------------------------
1 |
63 |
64 |
65 |
66 | {#each $notifications.data.Page.notifications as notification, i (notification.id)}
67 |
68 | {/each}
69 |
70 |
--------------------------------------------------------------------------------
/src/lib/components/notifications/ThreadNotification.svelte:
--------------------------------------------------------------------------------
1 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | {notification.user.name}
30 | {notification.context}
31 |
32 | {notification.thread.title}
33 |
34 |
35 |
--------------------------------------------------------------------------------
/src/lib/components/notifications/UnknownNotification.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
14 | {notification.type}
15 | is an unknown notification type. Please
16 | report this
17 | so it can be supported!
18 |
19 |
--------------------------------------------------------------------------------
/src/lib/components/notifications/index.ts:
--------------------------------------------------------------------------------
1 | export { default as ActivityNotification } from "./ActivityNotification.svelte";
2 | export { default as MediaNotification } from "./MediaNotification.svelte";
3 | export { default as MediaMergeNotification } from "./MediaMergeNotification.svelte";
4 | export { default as MediaDeletionNotification } from "./MediaDeletionNotification.svelte";
5 | export { default as ThreadNotification } from "./ThreadNotification.svelte";
6 | export { default as UnknownNotification } from "./UnknownNotification.svelte";
--------------------------------------------------------------------------------
/src/lib/graphql/index.ts:
--------------------------------------------------------------------------------
1 | import { createClient, dedupExchange, fetchExchange, gql } from "@urql/svelte";
2 | import { cacheExchange } from "@urql/exchange-graphcache";
3 | import type { CacheExchangeOpts } from "@urql/exchange-graphcache"
4 | import { get } from "svelte/store";
5 | import { token, queryCount } from "$lib/store";
6 | import schema from "./introspection.json";
7 |
8 | export const client = createClient({
9 | url: "https://graphql.anilist.co",
10 | exchanges: [
11 | dedupExchange,
12 | // documentCacheExchange,
13 | // debugExchange,
14 | cacheExchange({
15 | schema: schema as CacheExchangeOpts["schema"],
16 | keys: {
17 | Page: () => null,
18 | PageInfo: () => null,
19 | MediaCoverImage: () => null,
20 | MediaTitle: () => null,
21 | MediaExternalLink: () => null,
22 | StaffName: () => null,
23 | StaffImage: () => null,
24 | CharacterName: () => null,
25 | CharacterImage: () => null,
26 | AiringSchedule: () => null,
27 | UserAvatar: () => null,
28 | MediaRank: () => null,
29 | MediaStats: () => null,
30 | StatusDistribution: () => null,
31 | ScoreDistribution: () => null,
32 | FuzzyDate: () => null,
33 | },
34 | updates: {
35 | Mutation: {
36 | DeleteMediaListEntry: (result, args, cache, info) => {
37 | cache.invalidate({ __typename: "MediaList", id: args.id as number });
38 | },
39 | ToggleFavourite: (result, args, cache, info) => {
40 | const data = cache.readFragment(gql`fragment _ on Media { isFavourite }`, {
41 | id: args.mangaId || args.animeId,
42 | });
43 | cache.writeFragment(gql`fragment _ on Media { isFavourite }`, {
44 | id: args.mangaId || args.animeId,
45 | isFavourite: !data.isFavourite,
46 | });
47 | }
48 | }
49 | },
50 | // storage: // TODO Store cache in background https://formidable.com/open-source/urql/docs/graphcache/offline/#custom-storages
51 | }),
52 | fetchExchange
53 | ],
54 | async fetch(input, init?) {
55 | const result = await fetch(input, init);
56 | if(result.headers.has("x-ratelimit-remaining"))
57 | queryCount.set(parseInt(result.headers.get("x-ratelimit-remaining")))
58 | return result;
59 | },
60 | fetchOptions: () => {
61 | const storedToken = get(token);
62 | const headers: Record = {};
63 |
64 | if (storedToken)
65 | headers.Authorization = `Bearer ${storedToken}`;
66 |
67 | return {
68 | headers
69 | };
70 | },
71 | });
--------------------------------------------------------------------------------
/src/lib/graphql/mutation/SetMediaListStatus.graphql:
--------------------------------------------------------------------------------
1 | mutation SetMediaListStatus($media: Int, $list: Int, $status: MediaListStatus, $delete: Boolean!) {
2 | SaveMediaListEntry(mediaId: $media, status: $status) @skip(if: $delete) {
3 | id
4 | status
5 | __typename
6 | }
7 | DeleteMediaListEntry(id: $list) @include(if: $delete) {
8 | deleted
9 | }
10 | }
--------------------------------------------------------------------------------
/src/lib/graphql/mutation/ToggleMediaFavorite.graphql:
--------------------------------------------------------------------------------
1 | mutation ToggleMediaFavorite($anime: Int, $manga: Int) {
2 | ToggleFavourite(animeId: $anime, mangaId: $manga) {
3 | __typename
4 | }
5 | }
--------------------------------------------------------------------------------
/src/lib/graphql/mutation/UpdateMediaListProgress.graphql:
--------------------------------------------------------------------------------
1 | mutation UpdateMediaListProgress($listId: Int, $mediaId: Int, $progress: Int, $volume: Int, $status: MediaListStatus) {
2 | SaveMediaListEntry(id: $listId, mediaId: $mediaId, progress: $progress, progressVolumes: $volume, status: $status) {
3 | __typename
4 | id
5 | progress
6 | status
7 | }
8 | }
--------------------------------------------------------------------------------
/src/lib/graphql/query/GetCurrentlyPopularMedia.graphql:
--------------------------------------------------------------------------------
1 | query GetCurrentlyPopularMedia {
2 | Page {
3 | media(sort: [ TRENDING_DESC, ID ], isAdult: false) {
4 | id
5 | coverImage {
6 | medium
7 | }
8 | }
9 | }
10 | }
--------------------------------------------------------------------------------
/src/lib/graphql/query/GetMediaById.graphql:
--------------------------------------------------------------------------------
1 | query GetMediaById($id: Int!) {
2 | Media(id: $id) {
3 | id
4 | coverImage {
5 | color
6 | extraLarge
7 | large
8 | medium
9 | }
10 | bannerImage
11 | title {
12 | userPreferred
13 | native
14 | romaji
15 | english
16 | }
17 | synonyms
18 | startDate {
19 | day
20 | month
21 | year
22 | }
23 | endDate {
24 | day
25 | month
26 | year
27 | }
28 | season
29 | seasonYear
30 | mediaListEntry {
31 | id
32 | status
33 | }
34 | siteUrl
35 | description
36 | isFavorite: isFavourite
37 | format
38 | status(version: 2)
39 | type
40 | averageScore
41 | meanScore
42 | popularity
43 | isAdult
44 | favorites: favourites
45 | nextAiringEpisode {
46 | episode
47 | airingAt
48 | timeUntilAiring
49 | }
50 | externalLinks {
51 | type
52 | site
53 | url
54 | icon
55 | language
56 | color
57 | }
58 | stats {
59 | scoreDistribution {
60 | amount
61 | score
62 | }
63 | statusDistribution {
64 | amount
65 | status
66 | }
67 | }
68 | rankings {
69 | format
70 | context
71 | type
72 | rank
73 | year
74 | season
75 | allTime
76 | }
77 | studios {
78 | edges {
79 | isMain
80 | node {
81 | id
82 | name
83 | siteUrl
84 | }
85 | }
86 | }
87 | relations {
88 | edges {
89 | relationType
90 | node {
91 | ...SimpleMedia
92 | }
93 | }
94 | }
95 | staff(perPage: 12, sort: [RELEVANCE, ID]) {
96 | edges {
97 | id
98 | role
99 | node {
100 | id
101 | name {
102 | userPreferred
103 | }
104 | image {
105 | large
106 | }
107 | siteUrl
108 | }
109 | }
110 | pageInfo {
111 | hasNextPage
112 | }
113 | }
114 | characters(perPage: 12, sort: [ROLE, RELEVANCE, ID]) {
115 | edges {
116 | id
117 | role
118 | node {
119 | id
120 | name {
121 | userPreferred
122 | }
123 | image {
124 | large
125 | }
126 | siteUrl
127 | }
128 | }
129 | pageInfo {
130 | hasNextPage
131 | }
132 | }
133 | recommendations(perPage: 8, sort: [ RATING_DESC, ID ]) {
134 | pageInfo {
135 | hasNextPage
136 | }
137 | nodes {
138 | id
139 | rating
140 | mediaRecommendation {
141 | ...SimpleMedia
142 | }
143 | }
144 | }
145 | }
146 | }
147 |
148 | fragment SimpleMedia on Media {
149 | id
150 | title {
151 | userPreferred
152 | }
153 | format
154 | status
155 | coverImage {
156 | color
157 | large
158 | }
159 | }
--------------------------------------------------------------------------------
/src/lib/graphql/query/GetMediaFollowingStats.graphql:
--------------------------------------------------------------------------------
1 | query GetMediaFollowingStats($id: Int!) {
2 | Page {
3 | mediaList(mediaId: $id, isFollowing: true, sort: UPDATED_TIME_DESC) {
4 | id
5 | status
6 | score(format: POINT_100)
7 | progress
8 | notes
9 | repeat
10 | user {
11 | id
12 | name
13 | siteUrl
14 | avatar {
15 | large
16 | }
17 | }
18 | }
19 | }
20 | }
--------------------------------------------------------------------------------
/src/lib/graphql/query/GetNotifications.graphql:
--------------------------------------------------------------------------------
1 | query GetNotifications($page: Int, $amount: Int, $reset: Boolean) {
2 | Viewer {
3 | id
4 | unreadNotificationCount
5 | }
6 | Page(page: $page, perPage: $amount) {
7 | pageInfo {
8 | hasNextPage
9 | }
10 | notifications(resetNotificationCount: $reset) {
11 | ... on ActivityLikeNotification {
12 | id
13 | activityId
14 | user {
15 | ...user
16 | }
17 | activity {
18 | ...activity
19 | }
20 | context
21 | createdAt
22 | type
23 | }
24 | ... on ActivityMentionNotification {
25 | id
26 | activityId
27 | user {
28 | ...user
29 | }
30 | activity {
31 | ...activity
32 | }
33 | context
34 | createdAt
35 | type
36 | }
37 | ... on ActivityMessageNotification {
38 | id
39 | activityId
40 | user {
41 | ...user
42 | }
43 | activity: message {
44 | url: siteUrl
45 | }
46 | activityId
47 | context
48 | createdAt
49 | type
50 | }
51 | ... on ActivityReplyLikeNotification {
52 | id
53 | activityId
54 | user {
55 | ...user
56 | }
57 | activity {
58 | ...activity
59 | }
60 | context
61 | createdAt
62 | type
63 | }
64 | ... on ActivityReplyNotification {
65 | id
66 | activityId
67 | user {
68 | ...user
69 | }
70 | activity {
71 | ...activity
72 | }
73 | context
74 | createdAt
75 | type
76 | }
77 | ... on ActivityLikeNotification {
78 | id
79 | activityId
80 | user {
81 | ...user
82 | }
83 | activity {
84 | ...activity
85 | }
86 | context
87 | createdAt
88 | type
89 | }
90 | ... on ActivityReplySubscribedNotification {
91 | id
92 | activityId
93 | user {
94 | ...user
95 | }
96 | activity {
97 | ...activity
98 | }
99 | context
100 | createdAt
101 | type
102 | }
103 | ... on AiringNotification {
104 | id
105 | media {
106 | ...media
107 | }
108 | episode
109 | contexts
110 | createdAt
111 | type
112 | }
113 | ... on RelatedMediaAdditionNotification {
114 | id
115 | media {
116 | ...media
117 | }
118 | context
119 | createdAt
120 | type
121 | }
122 | ... on FollowingNotification {
123 | id
124 | user {
125 | ...user
126 | }
127 | context
128 | createdAt
129 | type
130 | }
131 | ... on ThreadCommentLikeNotification {
132 | id
133 | thread {
134 | ...thread
135 | }
136 | user {
137 | ...user
138 | }
139 | commentId
140 | context
141 | createdAt
142 | type
143 | }
144 | ... on ThreadCommentMentionNotification {
145 | id
146 | thread {
147 | ...thread
148 | }
149 | user {
150 | ...user
151 | }
152 | commentId
153 | context
154 | createdAt
155 | type
156 | }
157 | ... on ThreadCommentReplyNotification {
158 | id
159 | thread {
160 | ...thread
161 | }
162 | user {
163 | ...user
164 | }
165 | commentId
166 | context
167 | createdAt
168 | type
169 | }
170 | ... on ThreadCommentSubscribedNotification {
171 | id
172 | thread {
173 | ...thread
174 | }
175 | user {
176 | ...user
177 | }
178 | commentId
179 | context
180 | createdAt
181 | type
182 | }
183 | ... on ThreadLikeNotification {
184 | id
185 | thread {
186 | ...thread
187 | }
188 | user {
189 | ...user
190 | }
191 | context
192 | createdAt
193 | type
194 | }
195 | ... on MediaDataChangeNotification {
196 | id
197 | media {
198 | ...media
199 | }
200 | context
201 | createdAt
202 | type
203 | }
204 | ... on MediaDeletionNotification {
205 | id
206 | deletedMediaTitle
207 | context
208 | createdAt
209 | type
210 | }
211 | ... on MediaMergeNotification {
212 | id
213 | media {
214 | ...media
215 | }
216 | deletedMediaTitles
217 | context
218 | createdAt
219 | type
220 | }
221 | }
222 | }
223 | }
224 |
225 | fragment media on Media {
226 | id
227 | title {
228 | userPreferred
229 | }
230 | img: coverImage {
231 | large: medium
232 | color
233 | }
234 | url: siteUrl
235 | }
236 |
237 | fragment user on User {
238 | id
239 | name
240 | img: avatar {
241 | large: medium
242 | }
243 | url: siteUrl
244 | }
245 |
246 | fragment thread on Thread {
247 | id
248 | title
249 | url: siteUrl
250 | }
251 |
252 | fragment activity on ActivityUnion {
253 | __typename
254 | ... on TextActivity {
255 | id
256 | url: siteUrl
257 | }
258 | ... on ListActivity {
259 | id
260 | url: siteUrl
261 | }
262 | ... on MessageActivity {
263 | id
264 | url: siteUrl
265 | }
266 | }
--------------------------------------------------------------------------------
/src/lib/graphql/query/GetRecentMedia.graphql:
--------------------------------------------------------------------------------
1 | query GetRecentMedia($perPage: Int, $sort: [MediaSort], $type: MediaType, $formatIn: [MediaFormat]) {
2 | Page(perPage: $perPage) {
3 | media(sort: $sort, type: $type, format_in: $formatIn, isAdult: false) {
4 | id
5 | siteUrl
6 | format
7 | status(version: 2)
8 | title {
9 | userPreferred
10 | }
11 | coverImage {
12 | color
13 | extraLarge
14 | large
15 | }
16 | }
17 | }
18 | }
--------------------------------------------------------------------------------
/src/lib/graphql/query/GetUserMediaList.graphql:
--------------------------------------------------------------------------------
1 | query GetUserMediaList($id: Int!, $type: MediaType!, $starred: [Int!]) {
2 | Page {
3 | mediaList(userId: $id, type: $type, mediaId_in: $starred, status_in: [ CURRENT, REPEATING ], sort: [UPDATED_TIME_DESC]) {
4 | id
5 | progress
6 | status
7 | media {
8 | id
9 | status
10 | format
11 | episodes
12 | duration
13 | chapters
14 | volumes
15 | title {
16 | userPreferred
17 | }
18 | coverImage {
19 | extraLarge
20 | large
21 | medium
22 | color
23 | }
24 | nextAiringEpisode {
25 | episode
26 | timeUntilAiring
27 | }
28 | }
29 | }
30 | }
31 | }
--------------------------------------------------------------------------------
/src/lib/graphql/query/GetViewer.graphql:
--------------------------------------------------------------------------------
1 | query GetViewer {
2 | Viewer {
3 | id
4 | name
5 | siteUrl
6 | avatar {
7 | large
8 | }
9 | }
10 | }
--------------------------------------------------------------------------------
/src/lib/graphql/query/SearchMedia.graphql:
--------------------------------------------------------------------------------
1 | query SearchMedia($search: String!, $adult: Boolean, $type: MediaType, $format: [MediaFormat!]) {
2 | Page {
3 | media(search: $search, type: $type, format_in: $format, isAdult: $adult) {
4 | ...SearchResultMedia
5 | }
6 | }
7 | }
8 |
9 | fragment SearchResultMedia on Media {
10 | id
11 | title {
12 | userPreferred
13 | }
14 | coverImage {
15 | medium
16 | color
17 | }
18 | mediaListEntry {
19 | id
20 | status
21 | }
22 | status
23 | type
24 | format
25 | siteUrl
26 | }
--------------------------------------------------------------------------------
/src/lib/model.ts:
--------------------------------------------------------------------------------
1 | export enum Theme {
2 | LIGHT = "light",
3 | DARK = "dark",
4 | DARK_OLD = "dark-old",
5 | CONTRAST = "contrast",
6 | }
7 |
8 | export enum Accent {
9 | BLUE = "blue",
10 | RED = "red",
11 | BLUE_DIM = "blue-dim",
12 | PEACH = "peach",
13 | ORANGE = "orange",
14 | YELLOW = "yellow",
15 | GREEN = "green",
16 | PURPLE = "purple",
17 | PINK = "pink"
18 | }
19 |
20 | export type ListConfiguration = {
21 | preventOverProgression: boolean;
22 | combineAnime: boolean;
23 | showStarred: boolean;
24 | starredMedia: number[];
25 | }
26 |
27 | export type ThemeConfiguration = {
28 | wide: boolean
29 | primary: Theme
30 | accent: Accent
31 | };
32 |
33 | export type NotificationConfiguration = {
34 | enablePolling: boolean
35 | pollingInterval: number
36 | desktopNotifications: boolean
37 | }
38 |
39 | export type DebugConfiguration = {
40 | displayQueryLimits: boolean
41 | }
42 |
43 | export type ExtensionConfiguration = {
44 | list: ListConfiguration
45 | theme: ThemeConfiguration
46 | notifications: NotificationConfiguration
47 | debug: DebugConfiguration
48 | };
49 |
50 | export type User = {
51 | id: number
52 | name: string
53 | avatar: {
54 | large: string
55 | }
56 | siteUrl: string
57 | }
--------------------------------------------------------------------------------
/src/lib/store/auth.ts:
--------------------------------------------------------------------------------
1 | import { derived, writable } from "svelte/store";
2 | import type { JwtPayload } from "jwt-decode";
3 | import jwtDecode from "jwt-decode";
4 | import { client } from "$lib/graphql";
5 | import { GetViewerDocument, type User, type UserAvatar } from "@anilist/graphql";
6 |
7 | type StoredUser = Pick & {
8 | avatar?: Pick,
9 | };
10 |
11 | const PLACEHOLDER_USER: StoredUser = {
12 | id: 0,
13 | name: "Foo",
14 | avatar: {
15 | large: "https://s4.anilist.co/file/anilistcdn/user/avatar/large/default.png",
16 | },
17 | siteUrl: "https://anilist.co"
18 | };
19 |
20 | export const token = writable(null);
21 | export const user = createUserStore();
22 | export const loggedIn = derived(token, token => {
23 | if (token === null)
24 | return false;
25 |
26 | const { exp } = jwtDecode(token);
27 | return exp ? exp - Math.floor(Date.now() / 1000) >= 0 : false;
28 | });
29 |
30 | function createUserStore() {
31 | const { set, subscribe, update } = writable(PLACEHOLDER_USER);
32 |
33 | return {
34 | set,
35 | subscribe,
36 | update,
37 | fetch: async () => {
38 | const response = await client.query(GetViewerDocument).toPromise();
39 | set(response.data.Viewer);
40 | },
41 | logout() {
42 | set(PLACEHOLDER_USER);
43 | token.set(null);
44 | },
45 | reset: () => {
46 | set(PLACEHOLDER_USER);
47 | }
48 | }
49 | }
--------------------------------------------------------------------------------
/src/lib/store/index.ts:
--------------------------------------------------------------------------------
1 | import { writable } from "svelte/store";
2 | import { withPrevious } from "svelte-previous";
3 | import type { ExtensionConfiguration } from "$lib/model";
4 | import { Theme, Accent } from "$lib/model";
5 |
6 | export const lastPage = writable("/");
7 | export const unreadNotifications = writable(0);
8 | export const queryCount = writable(90); // 90 is the per-minute rate limit that AniList uses
9 |
10 | const [ currentExtensionConfig_, previousExtensionConfig_ ] = withPrevious({
11 | list: {
12 | preventOverProgression: false,
13 | combineAnime: false,
14 | showStarred: false,
15 | starredMedia: []
16 | },
17 | theme: {
18 | primary: Theme.LIGHT,
19 | accent: Accent.BLUE,
20 | wide: false
21 | },
22 | notifications: {
23 | enablePolling: true,
24 | pollingInterval: 1,
25 | desktopNotifications: false
26 | },
27 | debug: {
28 | displayQueryLimits: false
29 | }
30 | });
31 |
32 | export const extensionConfig = currentExtensionConfig_;
33 | export const previousExtensionConfig = previousExtensionConfig_;
34 |
35 | export * from "./auth";
--------------------------------------------------------------------------------
/src/lib/util.ts:
--------------------------------------------------------------------------------
1 | import { createHashHistory } from "history";
2 | import type { HistorySource } from "svelte-navigator";
3 |
4 | export function textify(enumValue?: string): string {
5 | if (!enumValue)
6 | return enumValue;
7 |
8 | if ([ "ONA", "OVA", "TV" ].includes(enumValue))
9 | return enumValue;
10 |
11 | if (enumValue === "TV_SHORT")
12 | return "TV Short";
13 |
14 | return enumValue.split("_")
15 | .map(v => v.charAt(0) + v.substring(1).toLowerCase())
16 | .join(" ");
17 | }
18 |
19 | export function hexToRgb(hex: string) {
20 | if (!hex)
21 | return null;
22 |
23 | const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
24 | return result ? `${parseInt(result[1], 16)} ${parseInt(result[2], 16)} ${parseInt(result[3], 16)}` : null;
25 | }
26 |
27 | export function parseSeconds(seconds: number, includeWeeks?: boolean): ParsedTime {
28 | let weeks = 0;
29 | if (includeWeeks) {
30 | weeks = Math.floor(seconds / (3600 * 24 * 7));
31 | seconds -= weeks * 3600 * 24 * 7;
32 | }
33 | const days = Math.floor(seconds / (3600 * 24));
34 | seconds -= days * 3600 * 24;
35 | const hours = Math.floor(seconds / 3600);
36 | seconds -= hours * 3600;
37 | const minutes = Math.floor(seconds / 60);
38 | seconds -= minutes * 60;
39 |
40 | return {
41 | weeks,
42 | days,
43 | hours,
44 | minutes,
45 | seconds
46 | } as ParsedTime;
47 | }
48 |
49 | export function readableTime(parsed: ParsedTime, opts?: ReadableOpts): string {
50 | let str = "";
51 |
52 | if (parsed.weeks && opts?.includeWeeks)
53 | str += parsed.weeks + "w";
54 | if (parsed.days)
55 | str += parsed.days + "d";
56 | if (parsed.hours)
57 | str += parsed.hours + "h";
58 | if (parsed.minutes)
59 | str += parsed.minutes + "m";
60 | if (parsed.seconds && opts?.includeSeconds)
61 | str += parsed.seconds + "s";
62 |
63 | if (!opts?.includeSeconds && parsed.minutes < 1 && str.length === 0)
64 | str += "<1m";
65 |
66 | return str.replace(/([a-z])/g, "$1 ");
67 | }
68 |
69 | export const debounce = any>(func: F, waitFor: number) => {
70 | let timeout: ReturnType;
71 |
72 | return (...args: Parameters): Promise> =>
73 | new Promise(resolve => {
74 | if (timeout)
75 | clearTimeout(timeout)
76 |
77 | timeout = setTimeout(() => resolve(func(...args)), waitFor)
78 | });
79 | }
80 |
81 | export function createHashedHistory(): HistorySource {
82 | const history = createHashHistory();
83 | let listeners = [];
84 |
85 | history.listen(location => {
86 | if (history.action === "POP") {
87 | listeners.forEach(listener => listener(location));
88 | }
89 | });
90 |
91 | return {
92 | get location(): Location {
93 | return history.location as unknown as Location;
94 | },
95 | addEventListener(name, handler) {
96 | if (name !== "popstate") return;
97 | listeners.push(handler);
98 | },
99 | removeEventListener(name, handler) {
100 | if (name !== "popstate") return;
101 | listeners = listeners.filter(fn => fn !== handler);
102 | },
103 | history: {
104 | get state() {
105 | return history.location.state as object;
106 | },
107 | pushState(state: object, title, uri) {
108 | history.push(uri, state);
109 | },
110 | replaceState(state, title, uri) {
111 | history.replace(uri, state);
112 | },
113 | go(to) {
114 | history.go(to);
115 | },
116 | },
117 | }
118 | }
119 |
120 | type ReadableOpts = {
121 | includeWeeks?: boolean;
122 | includeSeconds?: boolean;
123 | }
124 |
125 | type ParsedTime = {
126 | weeks: number;
127 | days: number;
128 | hours: number;
129 | minutes: number;
130 | seconds: number;
131 | }
--------------------------------------------------------------------------------
/src/manifest.ts:
--------------------------------------------------------------------------------
1 | const sharedManifest = {
2 | icons: {
3 | 16: "icons/16.png",
4 | 32: "icons/32.png",
5 | 48: "icons/48.png",
6 | 128: "icons/128.png",
7 | },
8 | permissions: [
9 | "storage",
10 | "identity",
11 | "alarms"
12 | ],
13 | optional_permissions: [
14 | "notifications"
15 | ]
16 | };
17 |
18 | const browserAction = {
19 | default_icon: {
20 | 16: "icons/16.png",
21 | 32: "icons/32.png",
22 | 48: "icons/48.png",
23 | },
24 | default_popup: "src/entries/popup/index.html",
25 | };
26 |
27 | export const ManifestV2 = {
28 | ...sharedManifest,
29 | background: {
30 | scripts: ["src/entries/background/script.ts"],
31 | persistent: true,
32 | },
33 | browser_action: browserAction,
34 | manifest_version: 2,
35 | permissions: [...sharedManifest.permissions],
36 | };
37 |
38 | export const ManifestV3 = {
39 | ...sharedManifest,
40 | action: browserAction,
41 | background: {
42 | service_worker: "src/entries/background/serviceWorker.ts",
43 | },
44 | manifest_version: 3,
45 | };
46 |
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | interface ImportMeta {
5 | CURRENT_CONTENT_SCRIPT_CSS_URL: string;
6 | }
7 |
--------------------------------------------------------------------------------
/svelte.config.js:
--------------------------------------------------------------------------------
1 | import sveltePreprocess from "svelte-preprocess";
2 |
3 | export default {
4 | preprocess: sveltePreprocess({
5 | postcss: true
6 | }),
7 | };
8 |
--------------------------------------------------------------------------------
/tailwind.config.cjs:
--------------------------------------------------------------------------------
1 | const { black, transparent, current } = require("tailwindcss/colors");
2 |
3 | function withOpacityValue(variable) {
4 | return ({ opacityValue }) => {
5 | if (opacityValue === undefined) {
6 | return `rgb(var(${variable}))`
7 | }
8 | return `rgb(var(${variable}) / ${opacityValue})`
9 | }
10 | }
11 |
12 | module.exports = {
13 | content: ['./src/**/*.{html,js,svelte,ts}'],
14 | theme: {
15 | colors: {
16 | variable: withOpacityValue("--color-variable"),
17 | "variable-hex": "var(--color-variable)",
18 | accent: withOpacityValue("--color-accent"),
19 | background: withOpacityValue("--color-background"),
20 | foreground: withOpacityValue("--color-foreground"),
21 | "foreground-grey": {
22 | 100: withOpacityValue("--color-foreground-grey"),
23 | 900: withOpacityValue("--color-foreground-grey-dark"),
24 | },
25 | "foreground-blue": {
26 | 100: withOpacityValue("--color-foreground-blue"),
27 | 900: withOpacityValue("--color-foreground-blue-dark"),
28 | },
29 | "text": {
30 | 100: withOpacityValue("--color-text-bright"),
31 | 200: withOpacityValue("--color-text-lighter"),
32 | 300: withOpacityValue("--color-text-light"),
33 | 400: withOpacityValue("--color-text"),
34 | },
35 | shadow: withOpacityValue("--color-shadow"),
36 | overlay: withOpacityValue("--color-overlay"),
37 | blue: withOpacityValue("--color-blue"),
38 | red: withOpacityValue("--color-red"),
39 | "blue-dim": withOpacityValue("--color-blue-dim"),
40 | white: withOpacityValue("--color-white"),
41 | black: withOpacityValue("--color-black"),
42 | peach: withOpacityValue("--color-peach"),
43 | orange: withOpacityValue("--color-orange"),
44 | yellow: withOpacityValue("--color-yellow"),
45 | green: withOpacityValue("--color-green"),
46 | purple: withOpacityValue("--color-purple"),
47 | pink: withOpacityValue("--color-pink"),
48 | transparent,
49 | current,
50 | black
51 | }
52 | },
53 | plugins: [
54 | require("@tailwindcss/typography"),
55 | ],
56 | }
57 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@tsconfig/svelte/tsconfig.json",
3 | "compilerOptions": {
4 | "target": "esnext",
5 | "useDefineForClassFields": true,
6 | "module": "esnext",
7 | "resolveJsonModule": true,
8 | "baseUrl": ".",
9 | "strict": false,
10 | "paths": {
11 | "$lib/*": ["src/lib/*"],
12 | "$assets/*": ["src/assets/*"],
13 | "~/*": ["src/*"]
14 | },
15 | /**
16 | * Typecheck JS in `.svelte` and `.js` files by default.
17 | * Disable checkJs if you'd like to use dynamic types in JS.
18 | * Note that setting allowJs false does not prevent the use
19 | * of JS in `.svelte` files.
20 | */
21 | "allowJs": true,
22 | "checkJs": true
23 | },
24 | "include": ["src/**/*.d.ts", "src/**/*.ts", "src/**/*.js", "src/**/*.svelte"]
25 | }
26 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import path from "path";
2 | import { defineConfig, loadEnv, type Plugin } from "vite";
3 | import { svelte } from "@sveltejs/vite-plugin-svelte";
4 | import webExtension from "@samrum/vite-plugin-web-extension";
5 | import zip from "./scripts/zipVitePlugin";
6 | import { ManifestV2, ManifestV3 } from "./src/manifest";
7 | import pkg from "./package.json";
8 |
9 | // https://vitejs.dev/config/
10 | export default defineConfig(({ mode }) => {
11 | const configEnv = loadEnv(mode, process.cwd(), "");
12 |
13 | const manifest = configEnv.MANIFEST_VERSION === "3" ? ManifestV3 : ManifestV2;
14 |
15 | switch (mode) {
16 | case "chromium": {
17 | if (configEnv.EXTENSION_KEY)
18 | // @ts-ignore
19 | manifest.key = configEnv.EXTENSION_KEY;
20 | break;
21 | }
22 | case "firefox": {
23 | if (configEnv.EXTENSION_KEY)
24 | // @ts-ignore
25 | manifest.browser_specific_settings = { gecko: { id: configEnv.EXTENSION_KEY } };
26 | break;
27 | }
28 | }
29 |
30 | return {
31 | build: {
32 | outDir: `dist/${mode}`,
33 | chunkSizeWarningLimit: undefined,
34 | },
35 | optimizeDeps: {
36 | exclude: [ "@urql/svelte", "svelte-navigator" ]
37 | },
38 | plugins: [
39 | svelte(),
40 | webExtension({
41 | // @ts-ignore Error caused by both v2 and v3 support
42 | manifest: {
43 | author: pkg.author,
44 | description: pkg.description,
45 | name: pkg.displayName ?? pkg.name,
46 | version: pkg.version,
47 | ...manifest,
48 | },
49 | }),
50 | {
51 | // @ts-ignore idk why the .default is necessary :shrug:
52 | ...zip({
53 | dir: `dist/${mode}`,
54 | outputName: `../${pkg.name}-${pkg.version}-${mode}`
55 | }),
56 | apply: () => configEnv.zip !== undefined
57 | } as Plugin,
58 | ],
59 | resolve: {
60 | alias: {
61 | "@anilist/graphql": path.resolve(__dirname, "./node_modules/@anilist/graphql"),
62 | "$lib": path.resolve(__dirname, "./src/lib"),
63 | "$assets": path.resolve(__dirname, "./src/assets"),
64 | "~": path.resolve(__dirname, "./src"),
65 | },
66 | },
67 | };
68 | });
69 |
--------------------------------------------------------------------------------