├── .env ├── .env.chromium ├── .env.firefox ├── .github └── workflows │ └── release.yml ├── .gitignore ├── README.md ├── codegen.yml ├── package-lock.json ├── package.json ├── postcss.config.cjs ├── public └── icons │ ├── 128.png │ ├── 16.png │ ├── 32.png │ └── 48.png ├── scripts └── zipVitePlugin.js ├── src ├── assets │ ├── anilist.svg │ ├── app.css │ └── logo.svg ├── entries │ ├── background │ │ ├── auth.ts │ │ ├── main.ts │ │ ├── notifications.ts │ │ ├── script.ts │ │ └── serviceWorker.ts │ └── popup │ │ ├── App.svelte │ │ ├── index.html │ │ ├── main.ts │ │ └── routes │ │ ├── Login.svelte │ │ ├── MediaList.svelte │ │ ├── New.svelte │ │ ├── Notifications.svelte │ │ ├── Search.svelte │ │ ├── Settings.svelte │ │ ├── index.ts │ │ └── media │ │ ├── Details.svelte │ │ ├── General.svelte │ │ ├── Social.svelte │ │ ├── Stats.svelte │ │ └── index.svelte ├── lib │ ├── actions │ │ ├── clickaway.ts │ │ ├── floating.ts │ │ └── index.ts │ ├── components │ │ ├── Button.svelte │ │ ├── DebugOverlay.svelte │ │ ├── Error.svelte │ │ ├── Loader.svelte │ │ ├── MediaCard.svelte │ │ ├── MediaDetail.svelte │ │ ├── MediaListCard.svelte │ │ ├── NavLink.svelte │ │ ├── QueryContainer.svelte │ │ ├── Routes.svelte │ │ ├── SearchResults.svelte │ │ ├── Section.svelte │ │ ├── Tooltip.svelte │ │ └── notifications │ │ │ ├── ActivityNotification.svelte │ │ │ ├── MediaDeletionNotification.svelte │ │ │ ├── MediaMergeNotification.svelte │ │ │ ├── MediaNotification.svelte │ │ │ ├── NotificationContainer.svelte │ │ │ ├── NotificationPage.svelte │ │ │ ├── ThreadNotification.svelte │ │ │ ├── UnknownNotification.svelte │ │ │ └── index.ts │ ├── graphql │ │ ├── index.ts │ │ ├── introspection.json │ │ ├── mutation │ │ │ ├── SetMediaListStatus.graphql │ │ │ ├── ToggleMediaFavorite.graphql │ │ │ └── UpdateMediaListProgress.graphql │ │ └── query │ │ │ ├── GetCurrentlyPopularMedia.graphql │ │ │ ├── GetMediaById.graphql │ │ │ ├── GetMediaFollowingStats.graphql │ │ │ ├── GetNotifications.graphql │ │ │ ├── GetRecentMedia.graphql │ │ │ ├── GetUserMediaList.graphql │ │ │ ├── GetViewer.graphql │ │ │ └── SearchMedia.graphql │ ├── model.ts │ ├── store │ │ ├── auth.ts │ │ └── index.ts │ └── util.ts ├── manifest.ts └── vite-env.d.ts ├── svelte.config.js ├── tailwind.config.cjs ├── tsconfig.json └── vite.config.ts /.env: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TehNut/AniMouto/56b884514d9a5deef43f763f723e3e7799caf5db/.env -------------------------------------------------------------------------------- /.env.chromium: -------------------------------------------------------------------------------- 1 | MANIFEST_VERSION=3 2 | # Forces the dev environment to use the same ID as the published version so that auth works 3 | EXTENSION_KEY=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxByUKvH37J56ktpjucZGKLzuzWq2Qift8IFbM8FOPHflW5Je7STp5/C7LtoFZ0o7P2SJUOfsyNAED9dF59s4sClsqMCPKY+vs7w9B+Adm76xM76ZRBTbacmIlxk3WCLqECAXQiO6IWe/STEA9YtLLdFkJyf43bri8P21Pb/jKz0Uipq4eLd7ZqzjtM3pzCP1zYRNjehyk0s2yBur5GIy9LksG7lOm3Y2yKwsmpGn34H5hHl23lg+wwYUGfbfMKXKHf7pk2ew+lIW29nTLYr0aatYxBef/Hd6yZwhHZx/UqaZ53UiRDokAY78A1l58Buu22uf+dRf4OYSwQ6s/N1PhQIDAQAB 4 | VITE_ANILIST_APP_ID=1387 -------------------------------------------------------------------------------- /.env.firefox: -------------------------------------------------------------------------------- 1 | MANIFEST_VERSION=2 2 | # Forces the dev environment to use the same ID as the published version so that auth works 3 | EXTENSION_KEY={61c7bb0d-21b3-4d14-a079-a0bb4d0f13ba} 4 | VITE_ANILIST_APP_ID=1336 -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # TODO job for attaching release notes for the new version from a full changelog file 2 | # TODO job for publishing to Chrome Webstore: https://github.com/mnao305/chrome-extension-upload 3 | # TODO job for publishing to Mozilla Webstore: https://github.com/trmcnvn/firefox-addon 4 | 5 | name: Upload Release Artifacts 6 | 7 | on: 8 | push: 9 | tags: 10 | - '*' 11 | 12 | jobs: 13 | publish: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: Setup Node 18 | uses: actions/setup-node@v3 19 | with: 20 | node-version: 18 21 | - name: Create release 22 | id: create_release 23 | uses: actions/create-release@v1 24 | env: 25 | GITHUB_TOKEN: ${{ github.token }} 26 | with: 27 | tag_name: ${{ github.ref }} 28 | release_name: Release ${{ github.ref }} 29 | draft: false 30 | prerelease: false 31 | - name: Setup ENV 32 | run: echo "TAG=${GITHUB_REF:10}" >> $GITHUB_ENV 33 | - name: Assemble packages 34 | run: | 35 | npm ci 36 | npm run codegen:graphql 37 | npm run package:chromium 38 | npm run package:firefox 39 | - name: Upload release assets 40 | id: upload_release_assets 41 | uses: AButler/upload-release-assets@v2.0 42 | with: 43 | release-tag: ${{ env.TAG }} 44 | repo-token: ${{ github.token }} 45 | files: 'dist/*.zip' -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/dist 2 | **/node_modules 3 | .DS_Store 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AniMouto ![GitHub package.json version](https://img.shields.io/github/package-json/v/TehNut/AniMouto.svg?color=%230098fd&style=for-the-badge) ![GitHub](https://img.shields.io/github/license/TehNut/AniMouto.svg?style=for-the-badge) [![Chrome Web Store](https://img.shields.io/chrome-web-store/rating/ilhjhegbgdghfkdgeahkpikkjgaaoklh.svg?label=Chrome&style=for-the-badge&logo=google-chrome)](https://chrome.google.com/webstore/detail/animouto/ilhjhegbgdghfkdgeahkpikkjgaaoklh) [![Mozilla Add-on](https://img.shields.io/amo/rating/animouto.svg?label=Firefox&style=for-the-badge&logo=mozilla-firefox)](https://addons.mozilla.org/en-US/firefox/addon/animouto/) 2 | 3 | 4 | 5 | ### Let an imouto enhance your AniList experience. 6 | 7 | AniMouto is an unofficial AniList extension which allows quick access to many features of AniList including your current Airing, Anime, and Manga lists, notifications, and search. 8 | 9 | AniMouto is designed to feel like a true extension to AniList by providing a very similar look and feel. 10 | 11 | Get it for [Chrome](https://chrome.google.com/webstore/detail/animouto/ilhjhegbgdghfkdgeahkpikkjgaaoklh) or [Firefox](https://addons.mozilla.org/en-US/firefox/addon/animouto/). 12 | 13 | ## Images 14 | 15 | 16 | 17 | ## Contributing 18 | 19 | Make sure you have [Node.js](https://nodejs.org/) installed. 20 | 21 | Run these commands to get the project locally: 22 | 23 | ```sh 24 | git clone https://github.com/TehNut/AniMouto.git # or clone your own fork 25 | cd AniMouto 26 | npm install 27 | npm run codegen:graphql 28 | ``` 29 | 30 | The dependencies should now all be installed. Next, run the `watch:*` script. This will watch for file changes and rebuild the extension for each supported browser environment. You can either install the built directories as temporary extensions, or run the relevant `serve` command to launch temporary browser environments with the extension pre-installed. 31 | 32 | ### Packaging 33 | 34 | #### `npm run build:*` 35 | 36 | Builds the project for all supported browser environments and places them into `/dist/`. 37 | 38 | #### `npm run package` 39 | 40 | This is currently not yet implemented. -------------------------------------------------------------------------------- /codegen.yml: -------------------------------------------------------------------------------- 1 | schema: https://graphql.anilist.co 2 | documents: './src/lib/graphql/**/*.graphql' 3 | config: 4 | namingConvention: 5 | enumValues: keep 6 | generates: 7 | ./node_modules/@anilist/graphql/index.ts: 8 | plugins: 9 | - typescript 10 | - typescript-operations 11 | - typed-document-node 12 | config: 13 | useTypeImports: true 14 | ./src/lib/graphql/introspection.json: 15 | plugins: 16 | - urql-introspection 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "animouto", 3 | "version": "3.0.1", 4 | "displayName": "AniMouto", 5 | "author": "Nicholas \"TehNut\" Ignoffo", 6 | "description": "Let an imouto enhance your AniList experience.", 7 | "license": "MIT", 8 | "type": "module", 9 | "scripts": { 10 | "build:chromium": "vite build --mode chromium", 11 | "build:firefox": "vite build --mode firefox", 12 | "watch:chromium": "vite build --watch --mode chromium", 13 | "watch:firefox": "vite build --watch --mode firefox", 14 | "serve:chromium": "web-ext run -t chromium --start-url \"https://studio.apollographql.com/sandbox/explorer?endpoint=https://graphql.anilist.co\" --source-dir ./dist/chromium", 15 | "serve:firefox": "web-ext run --start-url \"https://studio.apollographql.com/sandbox/explorer?endpoint=https://graphql.anilist.co\" --source-dir ./dist/firefox", 16 | "dev:chromium": "concurrently --restart-tries 5 --restart-after 5000 -c \"bgBlue.bold,bgMagenta.bold\" \"npm:watch:chromium\" \"npm:serve:chromium\"", 17 | "dev:firefox": "concurrently --restart-tries 5 --restart-after 5000 -c \"bgBlue.bold,bgMagenta.bold\" \"npm:watch:firefox\" \"npm:serve:firefox\"", 18 | "package:chromium": "cross-env zip=1 vite build --mode chromium", 19 | "package:firefox": "cross-env zip=1 vite build --mode firefox", 20 | "check": "svelte-check --tsconfig ./tsconfig.json", 21 | "codegen:graphql": "graphql-codegen" 22 | }, 23 | "dependencies": { 24 | "@floating-ui/dom": "1.5.3", 25 | "@fortawesome/free-regular-svg-icons": "6.4.2", 26 | "@fortawesome/free-solid-svg-icons": "6.4.2", 27 | "@tailwindcss/typography": "0.5.10", 28 | "@urql/exchange-graphcache": "6.3.3", 29 | "@urql/svelte": "4.0.4", 30 | "graphql": "16.8.1", 31 | "graphql-tag": "2.12.6", 32 | "history": "5.3.0", 33 | "jwt-decode": "3.1.2", 34 | "svelte-fa": "3.0.4", 35 | "svelte-lazy": "1.2.2", 36 | "svelte-navigator": "3.2.2", 37 | "svelte-previous": "2.1.4", 38 | "svelte-ripple": "0.1.1", 39 | "timeago.js": "4.0.2", 40 | "webext-storage-cache": "6.0.0", 41 | "webextension-polyfill": "0.10.0" 42 | }, 43 | "devDependencies": { 44 | "@graphql-codegen/cli": "5.0.0", 45 | "@graphql-codegen/typed-document-node": "5.0.1", 46 | "@graphql-codegen/typescript": "4.0.1", 47 | "@graphql-codegen/typescript-operations": "4.0.1", 48 | "@graphql-codegen/urql-introspection": "3.0.0", 49 | "@samrum/vite-plugin-web-extension": "2.1.1", 50 | "@sveltejs/vite-plugin-svelte": "2.4.6", 51 | "@tsconfig/svelte": "5.0.2", 52 | "@types/webextension-polyfill": "0.10.5", 53 | "@types/yazl": "2.4.4", 54 | "autoprefixer": "10.4.16", 55 | "concurrently": "8.2.2", 56 | "cross-env": "7.0.3", 57 | "postcss": "8.4.31", 58 | "svelte": "^3.59.2", 59 | "svelte-check": "3.5.2", 60 | "svelte-preprocess": "5.0.4", 61 | "tailwindcss": "3.3.3", 62 | "tslib": "2.6.2", 63 | "typescript": "5.2.2", 64 | "vite": "4.5.0", 65 | "web-ext": "7.3.1", 66 | "yazl": "2.5.1" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/icons/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TehNut/AniMouto/56b884514d9a5deef43f763f723e3e7799caf5db/public/icons/128.png -------------------------------------------------------------------------------- /public/icons/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TehNut/AniMouto/56b884514d9a5deef43f763f723e3e7799caf5db/public/icons/16.png -------------------------------------------------------------------------------- /public/icons/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TehNut/AniMouto/56b884514d9a5deef43f763f723e3e7799caf5db/public/icons/32.png -------------------------------------------------------------------------------- /public/icons/48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TehNut/AniMouto/56b884514d9a5deef43f763f723e3e7799caf5db/public/icons/48.png -------------------------------------------------------------------------------- /scripts/zipVitePlugin.js: -------------------------------------------------------------------------------- 1 | // Modified from https://github.com/liufeifeiholy/vite-plugin-zip to better suit needs 2 | // - Zip in correct format (foo.zip/distFiles instead of foo.zip/foo/distFiles) 3 | 4 | import * as path from 'path' 5 | import * as fs from 'fs' 6 | import { ZipFile } from 'yazl' 7 | 8 | const zip = (options = { }) => { 9 | 10 | let { 11 | dir, 12 | outputName 13 | } = options 14 | 15 | let outputPath 16 | let config 17 | 18 | return { 19 | name: 'vite:zip', 20 | apply: 'build', 21 | enforce: 'post', 22 | configResolved(resolvedConfig) { 23 | config = resolvedConfig 24 | outputPath = path.isAbsolute(config.build.outDir) 25 | ? config.build.outDir 26 | : path.join(config.root, config.build.outDir) 27 | }, 28 | 29 | closeBundle() { 30 | let arr = [] 31 | dir = dir || outputPath 32 | outputName = outputName || `${new Date().getMonth() + 1}${new Date().getDate()}` 33 | 34 | deleteExistingArchive(dir, outputName); 35 | getFileList(dir, arr) 36 | generateZip(arr, dir, outputName) 37 | } 38 | } 39 | } 40 | 41 | const deleteExistingArchive = (dir, outputName) => { 42 | const outFile = path.resolve(process.cwd(), `${dir}/${outputName}.zip`); 43 | if (fs.existsSync(outFile)) 44 | fs.rmSync(outFile); 45 | } 46 | 47 | const getFileList = function(dir, arr) { 48 | if (!dir) return 49 | 50 | try { 51 | const stat = fs.statSync(dir) 52 | if (stat.isFile()) { 53 | arr.push(dir) 54 | return 55 | } else if (stat.isDirectory()) { 56 | const files = fs.readdirSync(dir) 57 | while (files.length) { 58 | const file = files.pop() 59 | const filePath = `${dir}/${file}` 60 | if (filePath.indexOf('.zip') > -1) { // delete existing .zip file 61 | fs.rmSync(filePath) 62 | continue 63 | } 64 | getFileList(filePath, arr) 65 | } 66 | } 67 | } catch(e) { 68 | console.log(e) 69 | } 70 | } 71 | 72 | const generateZip = (arr, dir, outputName) => { 73 | const zipfile = new ZipFile() 74 | const outFile = path.resolve(process.cwd(), `${dir}/${outputName}.zip`) 75 | arr.forEach(file => { 76 | zipfile.addFile(file, file.replace(dir + "/", "")) 77 | }) 78 | zipfile.outputStream.pipe(fs.createWriteStream(outFile)).on("close", function() { 79 | console.log("done"); 80 | }); 81 | zipfile.end(); 82 | } 83 | 84 | export default zip -------------------------------------------------------------------------------- /src/assets/anilist.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/assets/app.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | /* Theme modifiable */ 7 | --color-background: 237 241 245; 8 | --color-foreground: 250 250 250; 9 | --color-foreground-grey: 245 246 246; 10 | --color-foreground-grey-dark: 234 236 237; 11 | --color-foreground-blue: 246 248 251; 12 | --color-foreground-blue-dark: 241 243 247; 13 | --color-text: 92 114 138; 14 | --color-text-light: 122 133 143; 15 | --color-text-lighter: 146 153 161; 16 | /* No theming */ 17 | --color-shadow: 6 13 34; 18 | --color-overlay: 31 38 49; 19 | --color-text-bright: 237 241 245; 20 | --color-blue: 61 180 242; 21 | --color-red: 232 93 117; 22 | --color-blue-dim: 141 178 219; 23 | --color-white: 255 255 255; 24 | --color-black: 0 0 0; 25 | --color-peach: 250 122 122; 26 | --color-orange: 247 154 99; 27 | --color-yellow: 247 191 99; 28 | --color-green: 123 213 85; 29 | --color-purple: 146 86 243; 30 | --color-pink: 252 157 214; 31 | --sidebar-background: 31 35 45; 32 | } 33 | 34 | .root { 35 | --scroller-thumb: var(--color-accent, var(--color-blue)); 36 | } 37 | 38 | .theme-light { 39 | --color-background: 237 241 245; 40 | --color-foreground: 250 250 250; 41 | --color-foreground-grey: 245 246 246; 42 | --color-foreground-grey-dark: 234 236 237; 43 | --color-foreground-blue: 246 248 251; 44 | --color-foreground-blue-dark: 241 243 247; 45 | --color-text: 92 114 138; 46 | --color-text-light: 122 133 143; 47 | --color-text-lighter: 146 153 161; 48 | } 49 | 50 | .theme-dark { 51 | --color-background: 11 22 34; 52 | --color-foreground: 21 31 46; 53 | --color-foreground-grey: 15 22 31; 54 | --color-foreground-grey-dark: 6 12 19; 55 | --color-foreground-blue: 15 22 31; 56 | --color-foreground-blue-dark: 6 12 19; 57 | --color-text: 159 173 189; 58 | --color-text-light: 114 138 161; 59 | --color-text-lighter: 133 150 165; 60 | 61 | --sidebar-background: var(--color-foreground); 62 | } 63 | 64 | .theme-dark-old { 65 | --color-background: 39 44 56; 66 | --color-foreground: 31 35 45; 67 | --color-foreground-grey: 25 29 38; 68 | --color-foreground-grey-dark: 16 20 25; 69 | --color-foreground-blue: 25 29 38; 70 | --color-foreground-blue-dark: 19 23 29; 71 | --color-text: 159 173 189; 72 | --color-text-light: 129 140 153; 73 | --color-text-lighter: 133 150 165; 74 | } 75 | 76 | .theme-contrast { 77 | --color-background: 214 224 239; 78 | --color-foreground: 245 246 249; 79 | --color-foreground-grey: 229 233 245; 80 | --color-foreground-grey-dark: 221 225 239; 81 | --color-foreground-blue: 229 233 245; 82 | --color-foreground-blue-dark: 221 225 239; 83 | --color-text: 0 0 0; 84 | --color-text-light: 94 101 111; 85 | --color-text-lighter: 94 101 111; 86 | --color-shadow: 37 41 51; 87 | --color-blue: 18 172 253; 88 | --color-blue-dim: 85 144 208; 89 | } 90 | 91 | ::-webkit-scrollbar { 92 | width: 10px; 93 | height: 10px; 94 | } 95 | 96 | ::-webkit-scrollbar-track { 97 | background: none; 98 | } 99 | 100 | ::-webkit-scrollbar-thumb { 101 | background: rgb(var(--scroller-thumb)); 102 | } -------------------------------------------------------------------------------- /src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Layer 1 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/entries/background/auth.ts: -------------------------------------------------------------------------------- 1 | import { identity, storage } from "webextension-polyfill"; 2 | 3 | export async function beginAuthentication(): Promise { 4 | try { 5 | const response = await identity.launchWebAuthFlow({ 6 | url: `https://anilist.co/api/v2/oauth/authorize?client_id=${import.meta.env.VITE_ANILIST_APP_ID}&response_type=token`, 7 | interactive: true 8 | }); 9 | 10 | const keyVal = response.split("#")[1].split("&"); 11 | const token = keyVal[0].split("=")[1]; 12 | 13 | // In some user environments, the popup closes upon completion of login prompt 14 | // To support these cases, we set the token and lastPage here instead of relying on the frontend to do so 15 | await storage.local.set({ token, lastPage: "/medialist" }); 16 | 17 | return token; 18 | } catch (e) { 19 | console.log(e) 20 | } 21 | } -------------------------------------------------------------------------------- /src/entries/background/main.ts: -------------------------------------------------------------------------------- 1 | import { runtime } from "webextension-polyfill"; 2 | import { beginAuthentication } from "./auth"; 3 | import { setupAlarms, updateNotificationCount } from "./notifications"; 4 | 5 | type Message = { 6 | type: "AUTH" | "RESET_ALARMS" | "UPDATE_NOTIFICATION_COUNT", 7 | data: any 8 | }; 9 | 10 | runtime.onMessage.addListener(async (message: Message, sender) => { 11 | if (!message.type) 12 | return; 13 | 14 | switch (message.type) { 15 | case "AUTH": return await beginAuthentication(); 16 | case "RESET_ALARMS": return await setupAlarms(); 17 | case "UPDATE_NOTIFICATION_COUNT": return await updateNotificationCount(); 18 | } 19 | }); 20 | 21 | export async function queryAniList({ query, token, variables }: { query: string, token?: string, variables?: Record }): Promise<{ data: T }> { 22 | const headers: Record = { 23 | "Accept": "application/json", 24 | "Content-Type": "application/json" 25 | }; 26 | if (token) 27 | headers.Authorization = `Bearer ${token}`; 28 | 29 | return fetch("https://graphql.anilist.co", { 30 | method: "POST", 31 | headers, 32 | body: JSON.stringify({ 33 | query, 34 | variables 35 | }) 36 | }).then(res => res.json() as Promise<{ data: T }>); 37 | } -------------------------------------------------------------------------------- /src/entries/background/notifications.ts: -------------------------------------------------------------------------------- 1 | import { runtime, storage, alarms, action, notifications, permissions } from "webextension-polyfill"; 2 | import { NotificationType } from "@anilist/graphql"; 3 | import type { ExtensionConfiguration } from "$lib/model"; 4 | import { queryAniList } from "./main"; 5 | 6 | runtime.onInstalled.addListener(setupAlarms); 7 | runtime.onStartup.addListener(setupAlarms); 8 | 9 | permissions.onAdded.addListener(permissions => { 10 | if (permissions.permissions?.includes("notifications") && !notifications?.onClicked.hasListener(handleNotificationClick)) 11 | notifications.onClicked.addListener(handleNotificationClick) 12 | }); 13 | 14 | function handleNotificationClick(id: string) { 15 | console.log(id) 16 | if (id.startsWith("https://anilist.co/")) 17 | window.open(id); 18 | 19 | if (id === "unknown") 20 | window.open("https://github.com/TehNut/AniMouto"); // Maybe add a section to the readme about this 21 | } 22 | 23 | export async function setupAlarms() { 24 | const { token, config } = await storage.local.get(["token", "config"]) as { token: string, config: ExtensionConfiguration }; 25 | await alarms.clearAll(); 26 | 27 | if (token && config.notifications.enablePolling) 28 | alarms.create("notifications", { delayInMinutes: config.notifications.pollingInterval, periodInMinutes: config.notifications.pollingInterval }); 29 | 30 | checkForNotifications(); 31 | 32 | if (notifications) 33 | notifications.onClicked.addListener(handleNotificationClick) 34 | } 35 | 36 | export async function updateNotificationCount() { 37 | const { unreadNotificationCount } = await storage.local.get() as { unreadNotificationCount: number }; 38 | await action.setBadgeText({ text: unreadNotificationCount ? unreadNotificationCount.toString() : "" }); 39 | await action.setBadgeBackgroundColor({ color: [61, 180, 242, Math.floor(255 * 0.8)] }); 40 | } 41 | 42 | alarms.onAlarm.addListener(alarm => { 43 | if (alarm.name === "notifications") 44 | checkForNotifications(); 45 | }); 46 | 47 | async function checkForNotifications() { 48 | const { token, unreadNotificationCount: currentCount, config } = await storage.local.get() as { token: string, unreadNotificationCount: number, config: ExtensionConfiguration }; 49 | if (!token) 50 | return; 51 | 52 | const response = await queryAniList<{ Viewer: { unreadNotificationCount: number } }>({ query: "{ Viewer { unreadNotificationCount } }", token }).then(res => res.data); 53 | 54 | await action.setBadgeText({ text: response.Viewer.unreadNotificationCount ? response.Viewer.unreadNotificationCount.toString() : "" }); 55 | await action.setBadgeBackgroundColor({ color: [61, 180, 242, Math.floor(255 * 0.8)] }); 56 | 57 | if (config.notifications.desktopNotifications && response.Viewer.unreadNotificationCount > 0 && response.Viewer.unreadNotificationCount - currentCount > 0) 58 | handleDesktopNotifications(response.Viewer.unreadNotificationCount - currentCount); 59 | 60 | await storage.local.set({ unreadNotificationCount: 0 }); 61 | } 62 | 63 | async function handleDesktopNotifications(totalUnread: number) { 64 | if (!(await permissions.contains({ permissions: ["notifications"] }))) 65 | return; 66 | 67 | const { token } = await storage.local.get("token") as { token: string }; 68 | const maxPage = Math.ceil(totalUnread / 50); 69 | 70 | async function handlePage(page: number, perPage?: number) { 71 | const { Page: { notifications } } = await queryAniList<{ Page: { notifications: any[] } }>({ token, query: notificationQuery, variables: { amount: perPage || 50, page } }) 72 | .then(res => res.data); 73 | 74 | notifications.forEach(notification => { 75 | switch (notification.type) { 76 | case NotificationType.ACTIVITY_LIKE: 77 | case NotificationType.ACTIVITY_MENTION: 78 | case NotificationType.ACTIVITY_REPLY: 79 | case NotificationType.ACTIVITY_REPLY_LIKE: 80 | case NotificationType.ACTIVITY_REPLY_SUBSCRIBED: { 81 | createNotification(notification.activity ? notification.activity.url : notification.user!.url, "Activity", notification.type, { user: notification.user!.name }); 82 | break; 83 | } 84 | case NotificationType.ACTIVITY_MESSAGE: { 85 | createNotification(`https://anilist.co/activity/${notification.activityId}`, "Message", notification.type, { user: notification.user!.name }); 86 | break; 87 | } 88 | case NotificationType.AIRING: { 89 | createNotification(notification.media!.url, "Episode", notification.type, { episode: notification.episode, media: notification.media?.title?.userPreferred }); 90 | break; 91 | } 92 | case NotificationType.RELATED_MEDIA_ADDITION: { 93 | createNotification(notification.media!.url, "Related Media", notification.type, { media: notification.media?.title?.userPreferred }); 94 | break; 95 | } 96 | case NotificationType.FOLLOWING: { 97 | createNotification(notification.activity ? notification.activity.url : notification.user!.url, "Follower", notification.type, { user: notification.user!.name }); 98 | break; 99 | } 100 | case NotificationType.THREAD_COMMENT_LIKE: 101 | case NotificationType.THREAD_COMMENT_MENTION: 102 | case NotificationType.THREAD_COMMENT_REPLY: 103 | case NotificationType.THREAD_LIKE: 104 | case NotificationType.THREAD_SUBSCRIBED: { 105 | createNotification(notification.thread!.url + "/comment/" + notification.commentId, "Forum Activity", notification.type, { user: notification.user!.name, thread: notification.thread!.title }); 106 | break; 107 | } 108 | default: { 109 | createNotification("unknown", "unknown", null); 110 | } 111 | } 112 | }); 113 | 114 | totalUnread -= notifications.length; 115 | if (page < maxPage) 116 | await handlePage(page + 1, totalUnread); 117 | } 118 | 119 | await handlePage(1, totalUnread); 120 | } 121 | 122 | const notificationContexts = { 123 | activity_like: "{user} liked your activity.", 124 | activity_mention: "{user} mentioned you in their activity.", 125 | activity_message: "{user} sent you a message.", 126 | activity_reply: "{user} replied to your activity.", 127 | activity_reply_like: "{user} liked your activity reply.", 128 | activity_reply_subscribed: "{user} replied to an activity you're subscribed to.", 129 | following: "{user} started following you.", 130 | airing: "Episode {episode} of {media} aired.", 131 | related_media_addition: "{media} was recently added to the site.", 132 | media_data_change: "{media} recieved site data changes.", 133 | media_deletion: "{deleted} was deleted from the site.", 134 | media_merge: "{merged} was merged into {media}.", 135 | thread_like: "{user} liked your forum thread, {thread}.", 136 | thread_subscribed: "{user} commented in your subscribed forum thread {thread}.", 137 | thread_comment_like: "{user} liked your comment, in the forum thread {thread}.", 138 | thread_comment_reply: "{user} replied to your comment, in the forum thread {thread}.", 139 | thread_comment_mention: "{user} mentioned you, in the forum thread {thread}.", 140 | } 141 | 142 | export function formatHandlebars(template: string, data?: any) { 143 | if (!data) 144 | return template; 145 | 146 | Object.entries(data).forEach(([k, v]) => { 147 | template = template.replace(new RegExp(`\{${k}\}`, "g"), v + ""); 148 | }); 149 | 150 | return template; 151 | } 152 | 153 | function createNotification(id: string, titleType: string, type?: NotificationType, message?: any) { 154 | if (!type) { 155 | notifications.create(id, { 156 | type: "basic", 157 | iconUrl: "icons/128.png", 158 | isClickable: true, 159 | title: "Unknown notification", 160 | message: "This is an unknown notification type! Please report this so it can be properly supported." 161 | 162 | }); 163 | } else { 164 | notifications.create(id, { 165 | type: "basic", 166 | iconUrl: "icons/128.png", 167 | isClickable: true, 168 | title: `New ${titleType}`, 169 | message: formatHandlebars(notificationContexts[type.toLowerCase()], message) 170 | }); 171 | } 172 | } 173 | 174 | const notificationQuery = "query($amount: Int, $page: Int) { Page(perPage: $amount, page: $page) { notifications(resetNotificationCount: false) { ... on ActivityLikeNotification { id activityId user { ...user } activity { ...activity } context createdAt type } ... on ActivityMentionNotification { id activityId user { ...user } activity { ...activity } context createdAt type } ... on ActivityMessageNotification { id activityId user { ...user } activity: message { url: siteUrl } activityId context createdAt type } ... on ActivityReplyLikeNotification { id activityId user { ...user } activity { ...activity } context createdAt type } ... on ActivityReplyNotification { id activityId user { ...user } activity { ...activity } context createdAt type } ... on ActivityLikeNotification { id activityId user { ...user } activity { ...activity } context createdAt type } ... on ActivityReplySubscribedNotification { id activityId user { ...user } activity { ...activity } context createdAt type } ... on AiringNotification { id media { ...media } episode contexts createdAt type } ... on RelatedMediaAdditionNotification { id media { ...media } context createdAt type } ... on FollowingNotification { id user { ...user } context createdAt type } ... on ThreadCommentLikeNotification { id thread { ...thread } user { ...user } commentId context createdAt type } ... on ThreadCommentMentionNotification { id thread { ...thread } user { ...user } commentId context createdAt type } ... on ThreadCommentReplyNotification { id thread { ...thread } user { ...user } commentId context createdAt type } ... on ThreadCommentSubscribedNotification { id thread { ...thread } user { ...user } commentId context createdAt type } ... on ThreadLikeNotification { id thread { ...thread } user { ...user } context createdAt type } } } } fragment media on Media { title { userPreferred } url: siteUrl } fragment user on User { name url: siteUrl } fragment thread on Thread { title url: siteUrl } fragment activity on ActivityUnion { __typename ... on TextActivity { url: siteUrl } ... on ListActivity { url: siteUrl } ... on MessageActivity { url: siteUrl } }"; -------------------------------------------------------------------------------- /src/entries/background/script.ts: -------------------------------------------------------------------------------- 1 | import "./main"; 2 | -------------------------------------------------------------------------------- /src/entries/background/serviceWorker.ts: -------------------------------------------------------------------------------- 1 | import "./main"; 2 | -------------------------------------------------------------------------------- /src/entries/popup/App.svelte: -------------------------------------------------------------------------------- 1 | 45 | 46 | 49 | 50 |
52 | 53 | 81 |
82 | 86 | 87 |
-------------------------------------------------------------------------------- /src/entries/popup/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Popup 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/entries/popup/main.ts: -------------------------------------------------------------------------------- 1 | import { get } from "svelte/store"; 2 | import { runtime, storage } from "webextension-polyfill"; 3 | import App from "./App.svelte"; 4 | import type { ExtensionConfiguration, User } from "$lib/model"; 5 | import { extensionConfig, lastPage, token, user, unreadNotifications } from "$lib/store"; 6 | 7 | async function bootstrap() { 8 | await loadConfig(); 9 | await loadLastPage(); 10 | await loadToken(); 11 | 12 | new App({ 13 | target: document.getElementById("app"), 14 | }); 15 | } 16 | 17 | async function loadConfig() { 18 | const config: ExtensionConfiguration = (await storage.local.get("config")).config; 19 | if (!config) { 20 | // Save default config to disk 21 | await storage.local.set({ config: get(extensionConfig) }); 22 | } else { 23 | // Load config into store 24 | extensionConfig.set(config); 25 | // Save config to disk on every change 26 | extensionConfig.subscribe(config => storage.local.set({ config })) 27 | } 28 | } 29 | 30 | async function loadLastPage() { 31 | const page: string = (await storage.local.get("lastPage")).lastPage; 32 | if (!page) 33 | await storage.local.set({ lastPage: "/" }); 34 | else 35 | lastPage.set(page); 36 | 37 | // Save config to disk on every change 38 | lastPage.subscribe(page => storage.local.set({ lastPage: page })); 39 | } 40 | 41 | async function loadToken() { 42 | const { token: storedToken, cachedUser, unreadNotificationCount }: { token: string, cachedUser?: User, unreadNotificationCount?: number } = (await storage.local.get([ "token", "cachedUser", "unreadNotificationCount" ])) as any; 43 | if (storedToken) { 44 | token.set(storedToken); 45 | unreadNotifications.set(unreadNotificationCount || 0); 46 | if (cachedUser?.id > 0) 47 | user.set(cachedUser); 48 | else { 49 | user.fetch(); 50 | } 51 | } 52 | 53 | token.subscribe(token => storage.local.set({ token })); 54 | user.subscribe(user => storage.local.set({ cachedUser: user })); 55 | unreadNotifications.subscribe(count => { 56 | storage.local.set({ unreadNotificationCount: count}) 57 | runtime.sendMessage({ type: "UPDATE_NOTIFICATION_COUNT" }); 58 | }); 59 | } 60 | 61 | bootstrap(); -------------------------------------------------------------------------------- /src/entries/popup/routes/Login.svelte: -------------------------------------------------------------------------------- 1 | 34 | 35 |
36 |
37 | {#if $popularMedia.data?.Page.media} 38 | {#each $popularMedia.data.Page.media as media} 39 | Key visual 40 | {/each} 41 | {/if} 42 |
43 |
44 |
45 | 46 | 47 |
48 |

Browse anonymously or login to your AniList account for the full experience!

49 |
50 |
51 | 52 |
53 | {#await loginPromise} 54 | 55 | {:catch e} 56 | 57 | {/await} 58 |
-------------------------------------------------------------------------------- /src/entries/popup/routes/MediaList.svelte: -------------------------------------------------------------------------------- 1 | 84 | 85 |
86 | {#if !showStarred} 87 | 88 | 91 | 92 | {/if} 93 | {#if $extensionConfig.list.starredMedia.length > 0} 94 | 95 | 98 | 99 | {/if} 100 | 101 | 104 | 105 |
106 |
107 | 108 | {#if airingAnime?.length > 0} 109 |
110 |
111 | 112 | Airing 113 | 114 | {#if airingBehind.minutesBehind > 0} 115 | 116 | {readableTime(parseSeconds(airingBehind.minutesBehind * 60))} behind 117 | ({airingBehind.episodesBehind} Episodes) 118 | 119 | {/if} 120 |
121 |
122 | {#each (airingAnime || []) as listEntry (listEntry.id)} 123 | 124 | {/each} 125 |
126 |
127 | {/if} 128 | {#if watchingAnime?.length > 0} 129 |
130 |
131 | {#if !combineAnime} 132 | 133 | Anime in Progress 134 | 135 | {:else} 136 | Anime in Progress 137 | {/if} 138 | {#if watchingBehind.minutesBehind > 0} 139 | 140 | {readableTime(parseSeconds(watchingBehind.minutesBehind * 60))} left 141 | ({watchingBehind.episodesBehind} Episodes) 142 | 143 | {/if} 144 |
145 |
146 | {#each (watchingAnime || []) as listEntry (listEntry.id)} 147 | 148 | {/each} 149 |
150 |
151 | {/if} 152 |
153 | 154 | {#if reading?.length > 0} 155 |
156 |
157 | Manga in Progress 158 |
159 |
160 | {#each reading as listEntry (listEntry.id)} 161 | 162 | {/each} 163 |
164 |
165 | {/if} 166 |
167 |
-------------------------------------------------------------------------------- /src/entries/popup/routes/New.svelte: -------------------------------------------------------------------------------- 1 | 40 | 41 |
42 | 43 |
44 | New Anime 45 |
46 | {#each $recentAnime.data.Page.media || [] as media (media.id)} 47 | 48 | {/each} 49 |
50 |
51 |
52 | 53 | 54 |
55 | New Manga 56 |
57 | {#each $recentManga.data.Page.media || [] as media (media.id)} 58 | 59 | {/each} 60 |
61 |
62 |
63 | 64 | 65 |
66 | New Light Novels 67 |
68 | {#each $recentNovels.data.Page.media || [] as media (media.id)} 69 | 70 | {/each} 71 |
72 |
73 |
74 |
-------------------------------------------------------------------------------- /src/entries/popup/routes/Notifications.svelte: -------------------------------------------------------------------------------- 1 | 24 | 25 |
26 | 27 | 30 | 31 |
32 |
33 |
34 | Notifications 35 |
36 |
37 | {#each pages as _, page} 38 | lastPage = true} /> 39 | {/each} 40 | {#if !lastPage} 41 | 42 | {/if} 43 |
44 |
-------------------------------------------------------------------------------- /src/entries/popup/routes/Search.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 36 | 37 |
38 |
39 | debounceSearchText(e.currentTarget.value)} 45 | /> 46 | {#if $search.fetching} 47 | 48 | {/if} 49 |
50 |
51 | 56 | 61 | 66 | 71 |
72 | {#if $search.fetching} 73 | 74 | {:else if $search.error} 75 | 76 | {:else if $search.data} 77 |
78 | {#if !$search.data.Page.media?.length} 79 | 80 | {:else} 81 | m.type === MediaType.ANIME)} 84 | /> 85 | m.type === MediaType.MANGA && m.format !== MediaFormat.NOVEL)} 88 | /> 89 | m.format === MediaFormat.NOVEL)} 92 | /> 93 | {/if} 94 |
95 | {/if} 96 | 97 |
98 | 99 | -------------------------------------------------------------------------------- /src/entries/popup/routes/Settings.svelte: -------------------------------------------------------------------------------- 1 | 68 | 69 |
70 | {#if $loggedIn} 71 |
72 |

Logged in as {$user.name}

73 |

74 | Your token will expire on 75 | 76 | {getTokenExpiration().toLocaleDateString()} 77 | . 78 | You will need to re-login after this date. 79 |

80 |
81 | 84 | 87 |
88 |

Don't forget to revoke your token after logging out.

89 |
90 | {/if} 91 |
92 |

Primary Colors

93 |
94 | {#each themes as theme} 95 | 96 | 101 | 102 | {/each} 103 |
104 |

Accent Color

105 |
106 | {#each Object.values(Accent) as accent} 107 | 108 |
116 | 124 |
125 | {#if $loggedIn} 126 |
127 | 135 |

Prevents you from progressing beyond the latest episode on airing series.

136 |
137 |
138 | 146 |

Allows periodic background checks for new notifications.

147 | 156 |

The number of minutes between polling checks.

157 | 166 |

Pushes AniList notifications to your desktop. Requires you to accept the permission popup, then enable it again.

167 |
168 | {/if} 169 |
170 | 178 |

Adds an overlay that displays the current rate limit status.

179 |
180 |
-------------------------------------------------------------------------------- /src/entries/popup/routes/index.ts: -------------------------------------------------------------------------------- 1 | export { default as LoginPage } from "./Login.svelte" 2 | export { default as MediaListPage } from "./MediaList.svelte" 3 | export { default as NewPage } from "./New.svelte" 4 | export { default as NotificationsPage } from "./Notifications.svelte" 5 | export { default as SearchPage } from "./Search.svelte" 6 | export { default as SettingsPage } from "./Settings.svelte" 7 | export { default as MediaPage } from "./media/index.svelte" -------------------------------------------------------------------------------- /src/entries/popup/routes/media/Details.svelte: -------------------------------------------------------------------------------- 1 | 39 | 40 | 41 | {#if startDate || endDate || $media.data.Media.season || $media.data.Media.seasonYear} 42 |
43 |
44 | {#if !isManga && ($media.data.Media.season || $media.data.Media.seasonYear)} 45 | 46 | {/if} 47 | {#if startDate} 48 | 49 | {/if} 50 | {#if endDate} 51 | 52 | {/if} 53 |
54 |
55 | {/if} 56 | 57 | {#if !isManga && (studios.length > 0 || producers.length > 0)} 58 |
59 |
60 | {#if studios.length > 0} 61 | 62 |
63 | {#each studios as studio} 64 | {studio.name} 65 | {/each} 66 |
67 |
68 | {/if} 69 | {#if producers.length > 0} 70 | 71 |
72 | {#each producers as producer} 73 | {producer.name} 74 | {/each} 75 |
76 |
77 | {/if} 78 |
79 |
80 | {/if} 81 | 82 | {#if $media.data.Media.averageScore || $media.data.Media.meanScore || $media.data.Media.favorites || $media.data.Media.popularity} 83 |
84 |
85 | {#if $media.data.Media.averageScore} 86 | 87 | {/if} 88 | {#if $media.data.Media.meanScore} 89 | 90 | {/if} 91 | {#if $media.data.Media.favorites} 92 | 93 | {/if} 94 | {#if $media.data.Media.popularity} 95 | 96 | {/if} 97 |
98 |
99 | {/if} 100 | 101 |
102 |
103 | {#if $media.data.Media.title.native} 104 | 105 | {/if} 106 | {#if $media.data.Media.title.romaji} 107 | 108 | {/if} 109 | {#if $media.data.Media.title.english} 110 | 111 | {/if} 112 | {#if $media.data.Media.synonyms && $media.data.Media.synonyms.length > 0} 113 | 114 |
115 | {#each $media.data.Media.synonyms as synonym} 116 | {synonym} 117 | {/each} 118 |
119 |
120 | {/if} 121 |
122 |
-------------------------------------------------------------------------------- /src/entries/popup/routes/media/General.svelte: -------------------------------------------------------------------------------- 1 | 44 | 45 | {#if $media.data.Media.isAdult} 46 |
47 | 48 |

49 | This entry contains content intended for adult viewers only. 50 |

51 |
52 | {/if} 53 | 54 | {#if $media.data.Media.description} 55 |
56 |
57 | {@html $media.data.Media.description} 58 |
59 |
60 | {/if} 61 | {#if $media.data.Media.relations?.edges.length > 0} 62 |
63 |
64 | {#each sortedRelations as relation (relation.node.id)} 65 | 66 |
67 | {textify(relation.relationType)} 68 |
69 |
70 | {/each} 71 |
72 | {/if} 73 | {#if $media.data.Media.characters?.edges.length > 0} 74 |
75 | 76 | Characters 77 | 78 |
79 | {#each $media.data.Media.characters.edges as character (character.node.id)} 80 | 81 | 82 |
83 | 84 |
85 |

{character.node.name.userPreferred}

86 | {textify(character.role)} 87 |
88 | 89 | {/each} 90 | {#if $media.data.Media.characters.pageInfo.hasNextPage} 91 | 92 | See more on AniList 93 | 94 | {/if} 95 |
96 |
97 | {/if} 98 | {#if $media.data.Media.staff.edges.length > 0} 99 |
100 | 101 | Staff 102 | 103 |
104 | {#each $media.data.Media.staff.edges as staff} 105 | 106 | 107 |
108 | 109 |
110 |

{staff.node.name.userPreferred}

111 | {staff.role.replace("_", "")} 112 |
113 | 114 | {/each} 115 | {#if $media.data.Media.staff.pageInfo.hasNextPage} 116 | 117 | See more on AniList 118 | 119 | {/if} 120 |
121 |
122 | {/if} 123 | {#if recommendations.length > 0} 124 |
125 |
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 |
146 |
147 | {#each $media.data.Media.externalLinks.sort((a, b) => a.type.localeCompare(b.type) || a.site.localeCompare(b.site)) as link (link.url)} 148 | 149 |
150 |
151 | {#if link.icon} 152 | External link icon 153 | {:else} 154 | 155 | {/if} 156 |
157 |

{link.site}

158 | {#if link.language} 159 | {languageCodes[link.language] || ""} 160 | {/if} 161 | 162 |
163 |
164 | {/each} 165 |
166 |
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 |
44 | 54 |
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 |
76 |
77 |
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 |
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 | 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 | Key visual 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 | AniList Logo 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 | User profile 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 | Key visual 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 | Key visual 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 | User profile 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 | --------------------------------------------------------------------------------