mirror of
https://github.com/ThaUnknown/miru.git
synced 2026-04-21 05:02:04 +00:00
feat: kitsu sync, local sync continue/planning list
fix: fav and bookmark icon not updating for local sync fix: cards not showing local progress fix: animated anilist PFPs not working fix: menubar being selectable fix: offline banner moving content outside of screen fix: local sync entry deletion being problematic
This commit is contained in:
parent
1f82dc8fa9
commit
14720a07c6
30 changed files with 864 additions and 264 deletions
28
.vscode/launch.json
vendored
Normal file
28
.vscode/launch.json
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Launch Chromium",
|
||||
"port": 8412,
|
||||
"request": "launch",
|
||||
"type": "chrome",
|
||||
"url": "http://localhost:7344/",
|
||||
"webRoot": "${workspaceFolder}/src",
|
||||
"timeout": 60000,
|
||||
"presentation": {
|
||||
"hidden": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"compounds": [
|
||||
{
|
||||
"name": "Attach",
|
||||
"configurations": [
|
||||
"Launch Chromium"
|
||||
],
|
||||
"presentation": {
|
||||
"order": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "ui",
|
||||
"version": "6.3.44",
|
||||
"version": "6.3.45",
|
||||
"license": "BUSL-1.1",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@9.14.4",
|
||||
|
|
|
|||
|
|
@ -27,8 +27,7 @@
|
|||
let repeat = _repeat(media) ?? 0
|
||||
|
||||
function deleteEntry () {
|
||||
if (!media.mediaListEntry) return
|
||||
authAggregator.delete(media.mediaListEntry.id)
|
||||
authAggregator.delete(media)
|
||||
}
|
||||
|
||||
function saveEntry () {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
import ChevronLeft from 'lucide-svelte/icons/chevron-left'
|
||||
import ChevronRight from 'lucide-svelte/icons/chevron-right'
|
||||
import Play from 'lucide-svelte/icons/play'
|
||||
import { readable } from 'svelte/store'
|
||||
|
||||
import Pagination from './Pagination.svelte'
|
||||
import { Button } from './ui/button'
|
||||
|
|
@ -71,7 +72,7 @@
|
|||
searchStore.set({ media, episode })
|
||||
}
|
||||
|
||||
export let following = authAggregator.following(media.id)
|
||||
export let following = authAggregator.following(media.id) ?? readable(null)
|
||||
|
||||
$: followerEntries = $following?.data?.Page?.mediaList?.filter(e => e?.user?.id !== authAggregator.id()) ?? []
|
||||
</script>
|
||||
|
|
|
|||
8
src/lib/components/icons/Kitsu.svelte
Normal file
8
src/lib/components/icons/Kitsu.svelte
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<svg width='224' height='224' viewBox='0 0 224 224' {...$$props}>
|
||||
<g fill='none' fill-rule='evenodd'>
|
||||
<g fill='#E75E45'>
|
||||
<path d='M152.7 48.5c-6.8-2.5-14.1-4.1-21.8-4.4-12.7-.6-24.8 2.2-35.4 7.6-.6.3-1.3.6-2 1v36.4c0 .5 0 2.4-.3 4-.7 3.7-2.9 7-6 9.1-2.6 1.8-5.6 2.6-8.8 2.5-.6 0-1.3-.1-1.9-.2-1.6-.3-3.3-.9-3.8-1.1-1.4-.5-21.8-8.4-31.6-12.2-1.2-.5-2.2-.9-3-1.2-11.7 9.9-24 21.7-35.5 35.6-.1.1-.6.7-.7.8-1.5 2.1-1.6 5.1 0 7.4 1.2 1.7 3.1 2.7 5 2.8 1.3.1 2.7-.3 3.9-1.1.1-.1.2-.2.4-.3 12.2-8.8 25.6-15.9 39.8-21.6 1-.5 2.2-.8 3.3-.7 1.6.1 3.1.7 4.3 1.9 2.3 2.3 2.4 6 .5 8.5-.8 1.2-1.5 2.4-2.2 3.6-7.6 12.4-13.7 25.9-18.3 40-.1.4-.2.7-.3 1.1v.1c-.4 1.7-.1 3.5 1 5 1.2 1.7 3.1 2.7 5.1 2.8 1.4.1 2.7-.3 3.9-1.1.5-.4 1-.8 1.4-1.3.1-.2.3-.4.4-.6 5-7.1 10.5-13.8 16.4-20 26.3-28.2 61.2-48.1 100.3-55.9.3-.1.6-.1.9-.1 2.2.1 3.9 2 3.8 4.2-.1 1.9-1.4 3.3-3.2 3.7-36.3 7.7-101.7 50.8-78.8 113.4.4 1 .7 1.6 1.2 2.5 1.2 1.7 3.1 2.7 5 2.8 1.1 0 4.2-.3 6.1-3.7 3.7-7 10.7-14.8 30.9-23.2 56.3-23.3 65.6-56.6 66.6-77.7v-1.2c.9-31.4-18.6-58.8-46.6-69.2zm-56.5 165C91 198 91.5 183 97.6 168.7c11.7 18.9 32.1 20.5 32.1 20.5-20.9 8.7-29.1 17.3-33.5 24.3z' />
|
||||
<path d='M1.1 50.6c.1.2.3.4.4.5 5.3 7.2 11.3 13.5 17.8 19.1.1.1.2.1.2.2 4.2 3.6 12.2 8.8 18 10.9 0 0 36.1 13.9 38 14.7.7.3 1.7.6 2.2.7 1.6.3 3.3 0 4.8-1s2.4-2.5 2.7-4.1c.1-.6.2-1.6.2-2.3V48.5c.1-6.2-1.9-15.6-3.7-20.8 0-.1-.1-.2-.1-.3-2.8-8.1-6.6-16-11.4-23.5l-.3-.6-.1-.1c-2-2.8-6-3.5-8.9-1.5-.5.3-.8.7-1.2 1.1-.3.4-.5.7-.8 1.1-6.4 9.3-9 20.6-7.3 31.7-3.3 1.7-6.8 4-7.2 4.3-.4.3-3.9 2.7-6.6 5.2-9.7-5.5-21.3-7.2-32.2-4.6-.4.1-.9.2-1.3.3-.5.2-1 .4-1.4.7-2.9 2-3.7 5.9-1.8 8.9v.2zm63.5-40.1c3.4 5.7 6.3 11.6 8.6 17.8-4.6.8-9.1 2-13.5 3.6-.6-7.5 1.1-14.9 4.9-21.4zM31.5 51.3c-3.2 3.5-5.9 7.3-8.3 11.3-4.9-4.3-9.4-9.2-13.5-14.4 7.5-1.3 15-.2 21.8 3.1z' />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
|
|
@ -16,15 +16,17 @@
|
|||
export let size: NonNullable<$$Props['size']> = 'icon-sm'
|
||||
export let variant: NonNullable<$$Props['variant']> = 'ghost'
|
||||
|
||||
function toggleBookmark () {
|
||||
if (!media.mediaListEntry?.status) {
|
||||
authAggregator.entry({ id: media.id, status: 'PLANNING', lists: lists(media)?.filter(({ enabled }) => enabled).map(({ name }) => name) })
|
||||
async function toggleBookmark () {
|
||||
if (!list(media)) {
|
||||
await authAggregator.entry({ id: media.id, status: 'PLANNING', lists: lists(media)?.filter(({ enabled }) => enabled).map(({ name }) => name) })
|
||||
} else {
|
||||
authAggregator.delete(media.mediaListEntry.id)
|
||||
await authAggregator.delete(media)
|
||||
}
|
||||
++key
|
||||
}
|
||||
let key = 1
|
||||
</script>
|
||||
|
||||
<Button {size} {variant} class={className} on:click={clickwrap(toggleBookmark)} on:keydown={keywrap(toggleBookmark)}>
|
||||
<Bookmark fill={list(media) ? 'currentColor' : 'transparent'} size={iconSizes[size]} />
|
||||
<Bookmark fill={key && list(media) ? 'currentColor' : 'transparent'} size={iconSizes[size]} />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -14,8 +14,15 @@
|
|||
export let media: Media
|
||||
export let size: NonNullable<$$Props['size']> = 'icon-sm'
|
||||
export let variant: NonNullable<$$Props['variant']> = 'ghost'
|
||||
|
||||
let key = 1
|
||||
|
||||
async function toggleFav () {
|
||||
await authAggregator.toggleFav(media.id)
|
||||
++key
|
||||
}
|
||||
</script>
|
||||
|
||||
<Button {size} {variant} class={className} on:click={clickwrap(() => authAggregator.toggleFav(media.id))} on:keydown={keywrap(() => authAggregator.toggleFav(media.id))}>
|
||||
<Heart fill={fav(media) ? 'currentColor' : 'transparent'} size={iconSizes[size]} />
|
||||
<Button {size} {variant} class={className} on:click={clickwrap(toggleFav)} on:keydown={keywrap(toggleFav)} on:click={() => ++key}>
|
||||
<Heart fill={key && fav(media) ? 'currentColor' : 'transparent'} size={iconSizes[size]} />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
|
||||
import { goto } from '$app/navigation'
|
||||
import { coverMedium, format, title } from '$lib/modules/anilist/util'
|
||||
import { list } from '$lib/modules/auth'
|
||||
import { hover } from '$lib/modules/navigate'
|
||||
|
||||
export let media: Media
|
||||
|
|
@ -23,6 +24,8 @@
|
|||
function onhover (state: boolean) {
|
||||
hidden = !state
|
||||
}
|
||||
|
||||
$: status = list(media)
|
||||
</script>
|
||||
|
||||
<div class='text-white p-4 cursor-pointer shrink-0 relative pointer-events-auto' class:z-40={!hidden} use:hover={[onclick, onhover]}>
|
||||
|
|
@ -34,8 +37,8 @@
|
|||
<Load src={coverMedium(media)} alt='cover' class='object-cover w-full h-full rounded' color={media.coverImage?.color} />
|
||||
</div>
|
||||
<div class='pt-3 font-black text-[.8rem] line-clamp-2'>
|
||||
{#if media.mediaListEntry?.status}
|
||||
<StatusDot variant={media.mediaListEntry.status} />
|
||||
{#if status}
|
||||
<StatusDot variant={status} />
|
||||
{/if}
|
||||
{title(media)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
{#each groupMessages($messages) as { type, user, date, messages }, i (i)}
|
||||
{@const incoming = type === 'incoming'}
|
||||
<div class='message flex flex-row mt-3' class:flex-row={incoming} class:flex-row-reverse={!incoming}>
|
||||
<img src={user.avatar?.medium ?? ''} alt='ProfilePicture' class='w-10 h-10 rounded-full p-1 mt-auto' loading='lazy' decoding='async' />
|
||||
<img src={user.avatar?.large ?? ''} alt='ProfilePicture' class='w-10 h-10 rounded-full p-1 mt-auto' loading='lazy' decoding='async' />
|
||||
<div class='flex flex-col px-2 items-start flex-auto' class:items-start={incoming} class:items-end={!incoming}>
|
||||
<div class='pb-1 flex flex-row items-center px-1'>
|
||||
<div class='font-bold text-sm'>
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
<div>
|
||||
{#each processed as [key, user] (key)}
|
||||
<div class='flex items-center pb-2'>
|
||||
<img src={user.avatar?.medium} alt='ProfilePicture' class='w-10 h-10 rounded-full p-1 mt-auto' loading='lazy' decoding='async' />
|
||||
<img src={user.avatar?.large} alt='ProfilePicture' class='w-10 h-10 rounded-full p-1 mt-auto' loading='lazy' decoding='async' />
|
||||
<div class='text-md pl-2'>
|
||||
{user.name}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@
|
|||
<div class='pt-2 flex items-end'>
|
||||
{#if thread.user}
|
||||
<Avatar.Root class='size-4 mr-2'>
|
||||
<Avatar.Image src={thread.user.avatar?.medium} alt='avatar' />
|
||||
<Avatar.Image src={thread.user.avatar?.large} alt='avatar' />
|
||||
<Avatar.Fallback>{thread.user.name}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
let ident: { nick: string, id: string, pfpid: string, type: 'al' | 'guest' }
|
||||
|
||||
if (viewer?.viewer) {
|
||||
const url = viewer.viewer.avatar?.medium ?? ''
|
||||
const url = viewer.viewer.avatar?.large ?? ''
|
||||
const id = '' + viewer.viewer.id
|
||||
const pfpid = url.slice(url.lastIndexOf('/') + 2 + id.length + 1)
|
||||
ident = { nick: viewer.viewer.name, id, pfpid, type: 'al' }
|
||||
|
|
|
|||
|
|
@ -5,18 +5,21 @@
|
|||
import { click } from '$lib/modules/navigate'
|
||||
|
||||
const debug = persisted('debug', '')
|
||||
function tabindex (node: HTMLElement) {
|
||||
node.tabIndex = -1
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class='w-[calc(100%-3.5rem)] left-[3.5rem] top-0 z-[2000] flex navbar absolute h-8'>
|
||||
<div class='draggable w-full' />
|
||||
<div class='window-controls flex text-white backdrop-blur'>
|
||||
<button class='max-button flex items-center justify-center h-8 w-[46px]' use:click={native.minimise} tabindex={-1}>
|
||||
<button class='max-button flex items-center justify-center h-8 w-[46px]' use:click={native.minimise} use:tabindex>
|
||||
<svg class='svg-controls w-3 h-3' role='img' viewBox='0 0 12 12'><rect fill='currentColor' height='1' width='10' x='1' y='6' />
|
||||
</button>
|
||||
<button class='restore-button flex items-center justify-center h-8 w-[46px]' use:click={native.maximise} tabindex={-1}>
|
||||
<button class='restore-button flex items-center justify-center h-8 w-[46px]' use:click={native.maximise} use:tabindex>
|
||||
<svg class='svg-controls w-3 h-3' role='img' viewBox='0 0 12 12'><rect fill='none' height='9' stroke='currentColor' width='9' x='1.5' y='1.5' />
|
||||
</button>
|
||||
<button class='close-button flex items-center justify-center h-8 w-[46px]' use:click={native.close} tabindex={-1}>
|
||||
<button class='close-button flex items-center justify-center h-8 w-[46px]' use:click={native.close} use:tabindex>
|
||||
<svg class='svg-controls w-3 h-3' role='img' viewBox='0 0 12 12'><polygon fill='currentColor' fill-rule='evenodd' points='11 1.576 6.583 6 11 10.424 10.424 11 6 6.583 1.576 11 1 10.424 5.417 6 1 1.576 1.576 1 6 5.417 10.424 1' />
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
export let user: ResultOf<typeof UserFrag>
|
||||
|
||||
$: name = user.name
|
||||
$: avatar = user.avatar?.medium ?? ''
|
||||
$: avatar = user.avatar?.large ?? ''
|
||||
$: banner = user.bannerImage ?? ''
|
||||
$: bubble = user.donatorBadge
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@
|
|||
{#if hasAuth}
|
||||
{@const viewer = client.profile()}
|
||||
<Avatar.Root class='size-6 rounded-md'>
|
||||
<Avatar.Image src={viewer?.avatar?.medium ?? ''} alt={viewer?.name} />
|
||||
<Avatar.Image src={viewer?.avatar?.large ?? ''} alt={viewer?.name} />
|
||||
<Avatar.Fallback>{viewer?.name}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
{:else}
|
||||
|
|
|
|||
|
|
@ -20,11 +20,7 @@ import type { AnyVariables, OperationContext, RequestPolicy, TypedDocumentNode }
|
|||
|
||||
import { dev } from '$app/environment'
|
||||
import native from '$lib/modules/native'
|
||||
import { safeLocalStorage, sleep } from '$lib/utils'
|
||||
|
||||
function arrayEqual <T> (a: T[], b: T[]) {
|
||||
return a.length === b.length && a.every((v, i) => v === b[i])
|
||||
}
|
||||
import { arrayEqual, safeLocalStorage, sleep } from '$lib/utils'
|
||||
|
||||
class FetchError extends Error {
|
||||
res
|
||||
|
|
@ -452,8 +448,9 @@ class AnilistClient {
|
|||
return await this.client.mutation(ToggleFavourite, { id })
|
||||
}
|
||||
|
||||
async deleteEntry (id: number) {
|
||||
return await this.client.mutation(DeleteEntry, { id })
|
||||
async deleteEntry (media: Media) {
|
||||
if (!media.mediaListEntry?.id) return
|
||||
return await this.client.mutation(DeleteEntry, { id: media.mediaListEntry.id })
|
||||
}
|
||||
|
||||
async entry (variables: VariablesOf<typeof Entry>) {
|
||||
|
|
|
|||
24
src/lib/modules/anilist/graphql-turbo.d.ts
vendored
24
src/lib/modules/anilist/graphql-turbo.d.ts
vendored
|
|
@ -10,12 +10,14 @@ declare module 'gql.tada' {
|
|||
TadaDocumentNode<{ relationType: "ADAPTATION" | "PREQUEL" | "SEQUEL" | "PARENT" | "SIDE_STORY" | "CHARACTER" | "SUMMARY" | "ALTERNATIVE" | "SPIN_OFF" | "OTHER" | "SOURCE" | "COMPILATION" | "CONTAINS" | null; node: { id: number; title: { userPreferred: string | null; } | null; coverImage: { medium: string | null; } | null; type: "ANIME" | "MANGA" | null; status: "FINISHED" | "RELEASING" | "NOT_YET_RELEASED" | "CANCELLED" | "HIATUS" | null; format: "MANGA" | "TV" | "TV_SHORT" | "MOVIE" | "SPECIAL" | "OVA" | "ONA" | "MUSIC" | "NOVEL" | "ONE_SHOT" | null; episodes: number | null; synonyms: (string | null)[] | null; season: "WINTER" | "SPRING" | "SUMMER" | "FALL" | null; seasonYear: number | null; startDate: { year: number | null; month: number | null; day: number | null; } | null; endDate: { year: number | null; month: number | null; day: number | null; } | null; } | null; }, {}, { fragment: "MediaEdgeFrag"; on: "MediaEdge"; masked: false; }>;
|
||||
"\n fragment FullMedia on Media @_unmask {\nid,\nidMal,\ntitle {\n romaji,\n english,\n native,\n userPreferred\n},\ndescription(asHtml: false),\nseason,\nseasonYear,\nformat,\nstatus,\nepisodes,\nduration,\naverageScore,\ngenres,\nisFavourite,\ncoverImage {\n extraLarge,\n medium,\n color,\n},\nsource,\ncountryOfOrigin,\nisAdult,\nbannerImage,\nsynonyms,\nnextAiringEpisode {\n id,\n timeUntilAiring,\n episode\n},\nstartDate {\n year,\n month,\n day\n},\ntrailer {\n id,\n site\n},\nmediaListEntry {\n ...FullMediaList\n},\nstudios(isMain: true) {\n nodes {\n id,\n name\n }\n},\nnotaired: airingSchedule(page: 1, perPage: 50, notYetAired: true) {\n n: nodes {\n a: airingAt,\n e: episode\n }\n},\naired: airingSchedule(page: 1, perPage: 50, notYetAired: false) {\n n: nodes {\n a: airingAt,\n e: episode\n }\n},\nrelations {\n edges {\n ...MediaEdgeFrag\n }\n}\n}":
|
||||
TadaDocumentNode<{ id: number; idMal: number | null; title: { romaji: string | null; english: string | null; native: string | null; userPreferred: string | null; } | null; description: string | null; season: "WINTER" | "SPRING" | "SUMMER" | "FALL" | null; seasonYear: number | null; format: "MANGA" | "TV" | "TV_SHORT" | "MOVIE" | "SPECIAL" | "OVA" | "ONA" | "MUSIC" | "NOVEL" | "ONE_SHOT" | null; status: "FINISHED" | "RELEASING" | "NOT_YET_RELEASED" | "CANCELLED" | "HIATUS" | null; episodes: number | null; duration: number | null; averageScore: number | null; genres: (string | null)[] | null; isFavourite: boolean; coverImage: { extraLarge: string | null; medium: string | null; color: string | null; } | null; source: "OTHER" | "ANIME" | "MANGA" | "NOVEL" | "ORIGINAL" | "LIGHT_NOVEL" | "VISUAL_NOVEL" | "VIDEO_GAME" | "DOUJINSHI" | "WEB_NOVEL" | "LIVE_ACTION" | "GAME" | "COMIC" | "MULTIMEDIA_PROJECT" | "PICTURE_BOOK" | null; countryOfOrigin: unknown; isAdult: boolean | null; bannerImage: string | null; synonyms: (string | null)[] | null; nextAiringEpisode: { id: number; timeUntilAiring: number; episode: number; } | null; startDate: { year: number | null; month: number | null; day: number | null; } | null; trailer: { id: string | null; site: string | null; } | null; mediaListEntry: { id: number; status: "CURRENT" | "PLANNING" | "COMPLETED" | "DROPPED" | "PAUSED" | "REPEATING" | null; progress: number | null; repeat: number | null; score: number | null; customLists: unknown; } | null; studios: { nodes: ({ id: number; name: string; } | null)[] | null; } | null; notaired: { n: ({ a: number; e: number; } | null)[] | null; } | null; aired: { n: ({ a: number; e: number; } | null)[] | null; } | null; relations: { edges: ({ relationType: "ADAPTATION" | "PREQUEL" | "SEQUEL" | "PARENT" | "SIDE_STORY" | "CHARACTER" | "SUMMARY" | "ALTERNATIVE" | "SPIN_OFF" | "OTHER" | "SOURCE" | "COMPILATION" | "CONTAINS" | null; node: { id: number; title: { userPreferred: string | null; } | null; coverImage: { medium: string | null; } | null; type: "ANIME" | "MANGA" | null; status: "FINISHED" | "RELEASING" | "NOT_YET_RELEASED" | "CANCELLED" | "HIATUS" | null; format: "MANGA" | "TV" | "TV_SHORT" | "MOVIE" | "SPECIAL" | "OVA" | "ONA" | "MUSIC" | "NOVEL" | "ONE_SHOT" | null; episodes: number | null; synonyms: (string | null)[] | null; season: "WINTER" | "SPRING" | "SUMMER" | "FALL" | null; seasonYear: number | null; startDate: { year: number | null; month: number | null; day: number | null; } | null; endDate: { year: number | null; month: number | null; day: number | null; } | null; } | null; } | null)[] | null; } | null; }, {}, { fragment: "FullMedia"; on: "Media"; masked: false; }>;
|
||||
"\n fragment UserFrag on User @_unmask {\n id,\n bannerImage,\n about,\n isFollowing,\n isFollower,\n donatorBadge,\n options {\n profileColor\n },\n createdAt,\n name,\n avatar {\n large\n },\n statistics {\n anime {\n count,\n minutesWatched,\n episodesWatched,\n genres(limit: 3, sort: COUNT_DESC) {\n genre,\n count\n }\n }\n }\n }\n":
|
||||
TadaDocumentNode<{ id: number; bannerImage: string | null; about: string | null; isFollowing: boolean | null; isFollower: boolean | null; donatorBadge: string | null; options: { profileColor: string | null; } | null; createdAt: number | null; name: string; avatar: { large: string | null; } | null; statistics: { anime: { count: number; minutesWatched: number; episodesWatched: number; genres: ({ genre: string | null; count: number; } | null)[] | null; } | null; } | null; }, {}, { fragment: "UserFrag"; on: "User"; masked: false; }>;
|
||||
"\n query Search($page: Int, $perPage: Int, $search: String, $genre: [String], $format: [MediaFormat], $status: [MediaStatus], $statusNot: [MediaStatus], $season: MediaSeason, $seasonYear: Int, $isAdult: Boolean, $sort: [MediaSort], $onList: Boolean, $ids: [Int]) {\n Page(page: $page, perPage: $perPage) {\n pageInfo {\n hasNextPage\n },\n media(type: ANIME, format_not: MUSIC, id_in: $ids, search: $search, genre_in: $genre, format_in: $format, status_in: $status, status_not_in: $statusNot, season: $season, seasonYear: $seasonYear, isAdult: $isAdult, sort: $sort, onList: $onList) {\n ...FullMedia\n }\n }\n }\n":
|
||||
TadaDocumentNode<{ Page: { pageInfo: { hasNextPage: boolean | null; } | null; media: ({ id: number; idMal: number | null; title: { romaji: string | null; english: string | null; native: string | null; userPreferred: string | null; } | null; description: string | null; season: "WINTER" | "SPRING" | "SUMMER" | "FALL" | null; seasonYear: number | null; format: "MANGA" | "TV" | "TV_SHORT" | "MOVIE" | "SPECIAL" | "OVA" | "ONA" | "MUSIC" | "NOVEL" | "ONE_SHOT" | null; status: "FINISHED" | "RELEASING" | "NOT_YET_RELEASED" | "CANCELLED" | "HIATUS" | null; episodes: number | null; duration: number | null; averageScore: number | null; genres: (string | null)[] | null; isFavourite: boolean; coverImage: { extraLarge: string | null; medium: string | null; color: string | null; } | null; source: "OTHER" | "ANIME" | "MANGA" | "NOVEL" | "ORIGINAL" | "LIGHT_NOVEL" | "VISUAL_NOVEL" | "VIDEO_GAME" | "DOUJINSHI" | "WEB_NOVEL" | "LIVE_ACTION" | "GAME" | "COMIC" | "MULTIMEDIA_PROJECT" | "PICTURE_BOOK" | null; countryOfOrigin: unknown; isAdult: boolean | null; bannerImage: string | null; synonyms: (string | null)[] | null; nextAiringEpisode: { id: number; timeUntilAiring: number; episode: number; } | null; startDate: { year: number | null; month: number | null; day: number | null; } | null; trailer: { id: string | null; site: string | null; } | null; mediaListEntry: { id: number; status: "CURRENT" | "PLANNING" | "COMPLETED" | "DROPPED" | "PAUSED" | "REPEATING" | null; progress: number | null; repeat: number | null; score: number | null; customLists: unknown; } | null; studios: { nodes: ({ id: number; name: string; } | null)[] | null; } | null; notaired: { n: ({ a: number; e: number; } | null)[] | null; } | null; aired: { n: ({ a: number; e: number; } | null)[] | null; } | null; relations: { edges: ({ relationType: "ADAPTATION" | "PREQUEL" | "SEQUEL" | "PARENT" | "SIDE_STORY" | "CHARACTER" | "SUMMARY" | "ALTERNATIVE" | "SPIN_OFF" | "OTHER" | "SOURCE" | "COMPILATION" | "CONTAINS" | null; node: { id: number; title: { userPreferred: string | null; } | null; coverImage: { medium: string | null; } | null; type: "ANIME" | "MANGA" | null; status: "FINISHED" | "RELEASING" | "NOT_YET_RELEASED" | "CANCELLED" | "HIATUS" | null; format: "MANGA" | "TV" | "TV_SHORT" | "MOVIE" | "SPECIAL" | "OVA" | "ONA" | "MUSIC" | "NOVEL" | "ONE_SHOT" | null; episodes: number | null; synonyms: (string | null)[] | null; season: "WINTER" | "SPRING" | "SUMMER" | "FALL" | null; seasonYear: number | null; startDate: { year: number | null; month: number | null; day: number | null; } | null; endDate: { year: number | null; month: number | null; day: number | null; } | null; } | null; } | null)[] | null; } | null; } | null)[] | null; } | null; }, { ids?: (number | null)[] | null | undefined; onList?: boolean | null | undefined; sort?: ("ID" | "ID_DESC" | "TITLE_ROMAJI" | "TITLE_ROMAJI_DESC" | "TITLE_ENGLISH" | "TITLE_ENGLISH_DESC" | "TITLE_NATIVE" | "TITLE_NATIVE_DESC" | "TYPE" | "TYPE_DESC" | "FORMAT" | "FORMAT_DESC" | "START_DATE" | "START_DATE_DESC" | "END_DATE" | "END_DATE_DESC" | "SCORE" | "SCORE_DESC" | "POPULARITY" | "POPULARITY_DESC" | "TRENDING" | "TRENDING_DESC" | "EPISODES" | "EPISODES_DESC" | "DURATION" | "DURATION_DESC" | "STATUS" | "STATUS_DESC" | "CHAPTERS" | "CHAPTERS_DESC" | "VOLUMES" | "VOLUMES_DESC" | "UPDATED_AT" | "UPDATED_AT_DESC" | "SEARCH_MATCH" | "FAVOURITES" | "FAVOURITES_DESC" | null)[] | null | undefined; isAdult?: boolean | null | undefined; seasonYear?: number | null | undefined; season?: "WINTER" | "SPRING" | "SUMMER" | "FALL" | null | undefined; statusNot?: ("FINISHED" | "RELEASING" | "NOT_YET_RELEASED" | "CANCELLED" | "HIATUS" | null)[] | null | undefined; status?: ("FINISHED" | "RELEASING" | "NOT_YET_RELEASED" | "CANCELLED" | "HIATUS" | null)[] | null | undefined; format?: ("MANGA" | "TV" | "TV_SHORT" | "MOVIE" | "SPECIAL" | "OVA" | "ONA" | "MUSIC" | "NOVEL" | "ONE_SHOT" | null)[] | null | undefined; genre?: (string | null)[] | null | undefined; search?: string | null | undefined; perPage?: number | null | undefined; page?: number | null | undefined; }, void>;
|
||||
"\n query IDMedia($id: Int!) {\n Media(id: $id, type: ANIME) {\n ...FullMedia\n }\n }\n":
|
||||
TadaDocumentNode<{ Media: { id: number; idMal: number | null; title: { romaji: string | null; english: string | null; native: string | null; userPreferred: string | null; } | null; description: string | null; season: "WINTER" | "SPRING" | "SUMMER" | "FALL" | null; seasonYear: number | null; format: "MANGA" | "TV" | "TV_SHORT" | "MOVIE" | "SPECIAL" | "OVA" | "ONA" | "MUSIC" | "NOVEL" | "ONE_SHOT" | null; status: "FINISHED" | "RELEASING" | "NOT_YET_RELEASED" | "CANCELLED" | "HIATUS" | null; episodes: number | null; duration: number | null; averageScore: number | null; genres: (string | null)[] | null; isFavourite: boolean; coverImage: { extraLarge: string | null; medium: string | null; color: string | null; } | null; source: "OTHER" | "ANIME" | "MANGA" | "NOVEL" | "ORIGINAL" | "LIGHT_NOVEL" | "VISUAL_NOVEL" | "VIDEO_GAME" | "DOUJINSHI" | "WEB_NOVEL" | "LIVE_ACTION" | "GAME" | "COMIC" | "MULTIMEDIA_PROJECT" | "PICTURE_BOOK" | null; countryOfOrigin: unknown; isAdult: boolean | null; bannerImage: string | null; synonyms: (string | null)[] | null; nextAiringEpisode: { id: number; timeUntilAiring: number; episode: number; } | null; startDate: { year: number | null; month: number | null; day: number | null; } | null; trailer: { id: string | null; site: string | null; } | null; mediaListEntry: { id: number; status: "CURRENT" | "PLANNING" | "COMPLETED" | "DROPPED" | "PAUSED" | "REPEATING" | null; progress: number | null; repeat: number | null; score: number | null; customLists: unknown; } | null; studios: { nodes: ({ id: number; name: string; } | null)[] | null; } | null; notaired: { n: ({ a: number; e: number; } | null)[] | null; } | null; aired: { n: ({ a: number; e: number; } | null)[] | null; } | null; relations: { edges: ({ relationType: "ADAPTATION" | "PREQUEL" | "SEQUEL" | "PARENT" | "SIDE_STORY" | "CHARACTER" | "SUMMARY" | "ALTERNATIVE" | "SPIN_OFF" | "OTHER" | "SOURCE" | "COMPILATION" | "CONTAINS" | null; node: { id: number; title: { userPreferred: string | null; } | null; coverImage: { medium: string | null; } | null; type: "ANIME" | "MANGA" | null; status: "FINISHED" | "RELEASING" | "NOT_YET_RELEASED" | "CANCELLED" | "HIATUS" | null; format: "MANGA" | "TV" | "TV_SHORT" | "MOVIE" | "SPECIAL" | "OVA" | "ONA" | "MUSIC" | "NOVEL" | "ONE_SHOT" | null; episodes: number | null; synonyms: (string | null)[] | null; season: "WINTER" | "SPRING" | "SUMMER" | "FALL" | null; seasonYear: number | null; startDate: { year: number | null; month: number | null; day: number | null; } | null; endDate: { year: number | null; month: number | null; day: number | null; } | null; } | null; } | null)[] | null; } | null; } | null; }, { id: number; }, void>;
|
||||
"\n query Viewer {\n Viewer {\n avatar {\n medium\n },\n name,\n id,\n mediaListOptions {\n animeList {\n customLists\n }\n }\n }\n }\n":
|
||||
TadaDocumentNode<{ Viewer: { avatar: { medium: string | null; } | null; name: string; id: number; mediaListOptions: { animeList: { customLists: (string | null)[] | null; } | null; } | null; } | null; }, {}, void>;
|
||||
"\n query Viewer {\n Viewer {\n ...UserFrag,\n mediaListOptions {\n animeList {\n customLists\n }\n }\n }\n }\n":
|
||||
TadaDocumentNode<{ Viewer: { id: number; bannerImage: string | null; about: string | null; isFollowing: boolean | null; isFollower: boolean | null; donatorBadge: string | null; options: { profileColor: string | null; } | null; createdAt: number | null; name: string; avatar: { large: string | null; } | null; statistics: { anime: { count: number; minutesWatched: number; episodesWatched: number; genres: ({ genre: string | null; count: number; } | null)[] | null; } | null; } | null; mediaListOptions: { animeList: { customLists: (string | null)[] | null; } | null; } | null; } | null; }, {}, void>;
|
||||
"\n query UserLists($id: Int) {\n MediaListCollection(userId: $id, type: ANIME, forceSingleCompletedList: true, sort: UPDATED_TIME_DESC) {\n user {\n id\n }\n lists {\n status,\n entries {\n id,\n media {\n id,\n status,\n mediaListEntry {\n ...FullMediaList\n },\n nextAiringEpisode {\n episode\n },\n relations {\n edges {\n relationType(version:2)\n node {\n id\n }\n }\n }\n }\n }\n }\n }\n }\n":
|
||||
TadaDocumentNode<{ MediaListCollection: { user: { id: number; } | null; lists: ({ status: "CURRENT" | "PLANNING" | "COMPLETED" | "DROPPED" | "PAUSED" | "REPEATING" | null; entries: ({ id: number; media: { id: number; status: "FINISHED" | "RELEASING" | "NOT_YET_RELEASED" | "CANCELLED" | "HIATUS" | null; mediaListEntry: { id: number; status: "CURRENT" | "PLANNING" | "COMPLETED" | "DROPPED" | "PAUSED" | "REPEATING" | null; progress: number | null; repeat: number | null; score: number | null; customLists: unknown; } | null; nextAiringEpisode: { episode: number; } | null; relations: { edges: ({ relationType: "ADAPTATION" | "PREQUEL" | "SEQUEL" | "PARENT" | "SIDE_STORY" | "CHARACTER" | "SUMMARY" | "ALTERNATIVE" | "SPIN_OFF" | "OTHER" | "SOURCE" | "COMPILATION" | "CONTAINS" | null; node: { id: number; } | null; } | null)[] | null; } | null; } | null; } | null)[] | null; } | null)[] | null; } | null; }, { id?: number | null | undefined; }, void>;
|
||||
"\n mutation CustomLists($lists: [String]) {\n UpdateUser(animeListOptions: { customLists: $lists }) {\n id\n }\n }\n":
|
||||
|
|
@ -24,10 +26,8 @@ declare module 'gql.tada' {
|
|||
TadaDocumentNode<{ id: number; title: { userPreferred: string | null; } | null; mediaListEntry: { status: "CURRENT" | "PLANNING" | "COMPLETED" | "DROPPED" | "PAUSED" | "REPEATING" | null; id: number; } | null; aired: { n: ({ a: number; e: number; } | null)[] | null; } | null; notaired: { n: ({ a: number; e: number; } | null)[] | null; } | null; }, {}, { fragment: "ScheduleMedia"; on: "Media"; masked: false; }>;
|
||||
"\n query Schedule($seasonCurrent: MediaSeason, $seasonYearCurrent: Int, $seasonLast: MediaSeason, $seasonYearLast: Int, $seasonNext: MediaSeason, $seasonYearNext: Int, $ids: [Int]) {\n curr1: Page(page: 1) {\n media(type: ANIME, season: $seasonCurrent, seasonYear: $seasonYearCurrent, countryOfOrigin: JP, format_not: TV_SHORT, onList: true, id_in: $ids) {\n ...ScheduleMedia\n }\n }\n curr2: Page(page: 2) {\n media(type: ANIME, season: $seasonCurrent, seasonYear: $seasonYearCurrent, countryOfOrigin: JP, format_not: TV_SHORT, onList: true, id_in: $ids) {\n ...ScheduleMedia\n }\n }\n curr3: Page(page: 3) {\n media(type: ANIME, season: $seasonCurrent, seasonYear: $seasonYearCurrent, countryOfOrigin: JP, format_not: TV_SHORT, onList: true, id_in: $ids) {\n ...ScheduleMedia\n }\n }\n residue: Page(page: 1) {\n media(type: ANIME, season: $seasonLast, seasonYear: $seasonYearLast, episodes_greater: 16, countryOfOrigin: JP, format_not: TV_SHORT, onList: true, id_in: $ids) {\n ...ScheduleMedia\n }\n },\n next1: Page(page: 1) {\n media(type: ANIME, season: $seasonNext, seasonYear: $seasonYearNext, sort: [START_DATE], countryOfOrigin: JP, format_not: TV_SHORT, onList: true, id_in: $ids) {\n ...ScheduleMedia\n }\n },\n next2: Page(page: 2) {\n media(type: ANIME, season: $seasonNext, seasonYear: $seasonYearNext, sort: [START_DATE], countryOfOrigin: JP, format_not: TV_SHORT, onList: true, id_in: $ids) {\n ...ScheduleMedia\n }\n }\n }\n":
|
||||
TadaDocumentNode<{ curr1: { media: ({ id: number; title: { userPreferred: string | null; } | null; mediaListEntry: { status: "CURRENT" | "PLANNING" | "COMPLETED" | "DROPPED" | "PAUSED" | "REPEATING" | null; id: number; } | null; aired: { n: ({ a: number; e: number; } | null)[] | null; } | null; notaired: { n: ({ a: number; e: number; } | null)[] | null; } | null; } | null)[] | null; } | null; curr2: { media: ({ id: number; title: { userPreferred: string | null; } | null; mediaListEntry: { status: "CURRENT" | "PLANNING" | "COMPLETED" | "DROPPED" | "PAUSED" | "REPEATING" | null; id: number; } | null; aired: { n: ({ a: number; e: number; } | null)[] | null; } | null; notaired: { n: ({ a: number; e: number; } | null)[] | null; } | null; } | null)[] | null; } | null; curr3: { media: ({ id: number; title: { userPreferred: string | null; } | null; mediaListEntry: { status: "CURRENT" | "PLANNING" | "COMPLETED" | "DROPPED" | "PAUSED" | "REPEATING" | null; id: number; } | null; aired: { n: ({ a: number; e: number; } | null)[] | null; } | null; notaired: { n: ({ a: number; e: number; } | null)[] | null; } | null; } | null)[] | null; } | null; residue: { media: ({ id: number; title: { userPreferred: string | null; } | null; mediaListEntry: { status: "CURRENT" | "PLANNING" | "COMPLETED" | "DROPPED" | "PAUSED" | "REPEATING" | null; id: number; } | null; aired: { n: ({ a: number; e: number; } | null)[] | null; } | null; notaired: { n: ({ a: number; e: number; } | null)[] | null; } | null; } | null)[] | null; } | null; next1: { media: ({ id: number; title: { userPreferred: string | null; } | null; mediaListEntry: { status: "CURRENT" | "PLANNING" | "COMPLETED" | "DROPPED" | "PAUSED" | "REPEATING" | null; id: number; } | null; aired: { n: ({ a: number; e: number; } | null)[] | null; } | null; notaired: { n: ({ a: number; e: number; } | null)[] | null; } | null; } | null)[] | null; } | null; next2: { media: ({ id: number; title: { userPreferred: string | null; } | null; mediaListEntry: { status: "CURRENT" | "PLANNING" | "COMPLETED" | "DROPPED" | "PAUSED" | "REPEATING" | null; id: number; } | null; aired: { n: ({ a: number; e: number; } | null)[] | null; } | null; notaired: { n: ({ a: number; e: number; } | null)[] | null; } | null; } | null)[] | null; } | null; }, { ids?: (number | null)[] | null | undefined; seasonYearNext?: number | null | undefined; seasonNext?: "WINTER" | "SPRING" | "SUMMER" | "FALL" | null | undefined; seasonYearLast?: number | null | undefined; seasonLast?: "WINTER" | "SPRING" | "SUMMER" | "FALL" | null | undefined; seasonYearCurrent?: number | null | undefined; seasonCurrent?: "WINTER" | "SPRING" | "SUMMER" | "FALL" | null | undefined; }, void>;
|
||||
"\n fragment UserFrag on User @_unmask {\n id,\n bannerImage,\n about,\n isFollowing,\n isFollower,\n donatorBadge,\n options {\n profileColor\n },\n createdAt,\n name,\n avatar {\n medium\n },\n statistics {\n anime {\n count,\n minutesWatched,\n episodesWatched,\n genres(limit: 3, sort: COUNT_DESC) {\n genre,\n count\n }\n }\n }\n }\n":
|
||||
TadaDocumentNode<{ id: number; bannerImage: string | null; about: string | null; isFollowing: boolean | null; isFollower: boolean | null; donatorBadge: string | null; options: { profileColor: string | null; } | null; createdAt: number | null; name: string; avatar: { medium: string | null; } | null; statistics: { anime: { count: number; minutesWatched: number; episodesWatched: number; genres: ({ genre: string | null; count: number; } | null)[] | null; } | null; } | null; }, {}, { fragment: "UserFrag"; on: "User"; masked: false; }>;
|
||||
"\n query Following($id: Int!) {\n Page {\n mediaList(mediaId: $id, isFollowing: true, sort: UPDATED_TIME_DESC) {\n id,\n status,\n score,\n progress,\n user {\n ...UserFrag\n }\n }\n }\n }\n":
|
||||
TadaDocumentNode<{ Page: { mediaList: ({ id: number; status: "CURRENT" | "PLANNING" | "COMPLETED" | "DROPPED" | "PAUSED" | "REPEATING" | null; score: number | null; progress: number | null; user: { id: number; bannerImage: string | null; about: string | null; isFollowing: boolean | null; isFollower: boolean | null; donatorBadge: string | null; options: { profileColor: string | null; } | null; createdAt: number | null; name: string; avatar: { medium: string | null; } | null; statistics: { anime: { count: number; minutesWatched: number; episodesWatched: number; genres: ({ genre: string | null; count: number; } | null)[] | null; } | null; } | null; } | null; } | null)[] | null; } | null; }, { id: number; }, void>;
|
||||
TadaDocumentNode<{ Page: { mediaList: ({ id: number; status: "CURRENT" | "PLANNING" | "COMPLETED" | "DROPPED" | "PAUSED" | "REPEATING" | null; score: number | null; progress: number | null; user: { id: number; bannerImage: string | null; about: string | null; isFollowing: boolean | null; isFollower: boolean | null; donatorBadge: string | null; options: { profileColor: string | null; } | null; createdAt: number | null; name: string; avatar: { large: string | null; } | null; statistics: { anime: { count: number; minutesWatched: number; episodesWatched: number; genres: ({ genre: string | null; count: number; } | null)[] | null; } | null; } | null; } | null; } | null)[] | null; } | null; }, { id: number; }, void>;
|
||||
"\n mutation Entry($lists: [String], $id: Int!, $status: MediaListStatus, $progress: Int, $repeat: Int, $score: Int) {\n SaveMediaListEntry(mediaId: $id, status: $status, progress: $progress, repeat: $repeat, scoreRaw: $score, customLists: $lists) {\n id,\n ...FullMediaList,\n media {\n id\n }\n }\n }\n":
|
||||
TadaDocumentNode<{ SaveMediaListEntry: { id: number; status: "CURRENT" | "PLANNING" | "COMPLETED" | "DROPPED" | "PAUSED" | "REPEATING" | null; progress: number | null; repeat: number | null; score: number | null; customLists: unknown; media: { id: number; } | null; } | null; }, { score?: number | null | undefined; repeat?: number | null | undefined; progress?: number | null | undefined; status?: "CURRENT" | "PLANNING" | "COMPLETED" | "DROPPED" | "PAUSED" | "REPEATING" | null | undefined; id: number; lists?: (string | null)[] | null | undefined; }, void>;
|
||||
"\n mutation DeleteEntry($id: Int!) {\n DeleteMediaListEntry(id: $id) {\n deleted\n }\n }\n":
|
||||
|
|
@ -35,19 +35,19 @@ declare module 'gql.tada' {
|
|||
"\n mutation ToggleFavourite($id: Int!) {\n ToggleFavourite(animeId: $id) { anime { nodes { id } } } \n }\n":
|
||||
TadaDocumentNode<{ ToggleFavourite: { anime: { nodes: ({ id: number; } | null)[] | null; } | null; } | null; }, { id: number; }, void>;
|
||||
"\n fragment ThreadFrag on Thread @_unmask {\n id,\n title,\n body,\n userId,\n replyCount,\n viewCount,\n isLocked,\n isSubscribed,\n isLiked,\n likeCount,\n repliedAt,\n createdAt,\n user {\n ...UserFrag\n },\n categories {\n id,\n name\n }\n }\n":
|
||||
TadaDocumentNode<{ id: number; title: string | null; body: string | null; userId: number; replyCount: number | null; viewCount: number | null; isLocked: boolean | null; isSubscribed: boolean | null; isLiked: boolean | null; likeCount: number; repliedAt: number | null; createdAt: number; user: { id: number; bannerImage: string | null; about: string | null; isFollowing: boolean | null; isFollower: boolean | null; donatorBadge: string | null; options: { profileColor: string | null; } | null; createdAt: number | null; name: string; avatar: { medium: string | null; } | null; statistics: { anime: { count: number; minutesWatched: number; episodesWatched: number; genres: ({ genre: string | null; count: number; } | null)[] | null; } | null; } | null; } | null; categories: ({ id: number; name: string; } | null)[] | null; }, {}, { fragment: "ThreadFrag"; on: "Thread"; masked: false; }>;
|
||||
TadaDocumentNode<{ id: number; title: string | null; body: string | null; userId: number; replyCount: number | null; viewCount: number | null; isLocked: boolean | null; isSubscribed: boolean | null; isLiked: boolean | null; likeCount: number; repliedAt: number | null; createdAt: number; user: { id: number; bannerImage: string | null; about: string | null; isFollowing: boolean | null; isFollower: boolean | null; donatorBadge: string | null; options: { profileColor: string | null; } | null; createdAt: number | null; name: string; avatar: { large: string | null; } | null; statistics: { anime: { count: number; minutesWatched: number; episodesWatched: number; genres: ({ genre: string | null; count: number; } | null)[] | null; } | null; } | null; } | null; categories: ({ id: number; name: string; } | null)[] | null; }, {}, { fragment: "ThreadFrag"; on: "Thread"; masked: false; }>;
|
||||
"\n query Threads($id: Int!, $page: Int, $perPage: Int) {\n Page(page: $page, perPage: $perPage) {\n pageInfo {\n hasNextPage,\n total\n },\n threads(mediaCategoryId: $id, sort: ID_DESC) {\n ...ThreadFrag\n }\n }\n }\n":
|
||||
TadaDocumentNode<{ Page: { pageInfo: { hasNextPage: boolean | null; total: number | null; } | null; threads: ({ id: number; title: string | null; body: string | null; userId: number; replyCount: number | null; viewCount: number | null; isLocked: boolean | null; isSubscribed: boolean | null; isLiked: boolean | null; likeCount: number; repliedAt: number | null; createdAt: number; user: { id: number; bannerImage: string | null; about: string | null; isFollowing: boolean | null; isFollower: boolean | null; donatorBadge: string | null; options: { profileColor: string | null; } | null; createdAt: number | null; name: string; avatar: { medium: string | null; } | null; statistics: { anime: { count: number; minutesWatched: number; episodesWatched: number; genres: ({ genre: string | null; count: number; } | null)[] | null; } | null; } | null; } | null; categories: ({ id: number; name: string; } | null)[] | null; } | null)[] | null; } | null; }, { perPage?: number | null | undefined; page?: number | null | undefined; id: number; }, void>;
|
||||
TadaDocumentNode<{ Page: { pageInfo: { hasNextPage: boolean | null; total: number | null; } | null; threads: ({ id: number; title: string | null; body: string | null; userId: number; replyCount: number | null; viewCount: number | null; isLocked: boolean | null; isSubscribed: boolean | null; isLiked: boolean | null; likeCount: number; repliedAt: number | null; createdAt: number; user: { id: number; bannerImage: string | null; about: string | null; isFollowing: boolean | null; isFollower: boolean | null; donatorBadge: string | null; options: { profileColor: string | null; } | null; createdAt: number | null; name: string; avatar: { large: string | null; } | null; statistics: { anime: { count: number; minutesWatched: number; episodesWatched: number; genres: ({ genre: string | null; count: number; } | null)[] | null; } | null; } | null; } | null; categories: ({ id: number; name: string; } | null)[] | null; } | null)[] | null; } | null; }, { perPage?: number | null | undefined; page?: number | null | undefined; id: number; }, void>;
|
||||
"\n query Thread($threadId: Int!) {\n Thread(id: $threadId) {\n ...ThreadFrag\n }\n }\n":
|
||||
TadaDocumentNode<{ Thread: { id: number; title: string | null; body: string | null; userId: number; replyCount: number | null; viewCount: number | null; isLocked: boolean | null; isSubscribed: boolean | null; isLiked: boolean | null; likeCount: number; repliedAt: number | null; createdAt: number; user: { id: number; bannerImage: string | null; about: string | null; isFollowing: boolean | null; isFollower: boolean | null; donatorBadge: string | null; options: { profileColor: string | null; } | null; createdAt: number | null; name: string; avatar: { medium: string | null; } | null; statistics: { anime: { count: number; minutesWatched: number; episodesWatched: number; genres: ({ genre: string | null; count: number; } | null)[] | null; } | null; } | null; } | null; categories: ({ id: number; name: string; } | null)[] | null; } | null; }, { threadId: number; }, void>;
|
||||
TadaDocumentNode<{ Thread: { id: number; title: string | null; body: string | null; userId: number; replyCount: number | null; viewCount: number | null; isLocked: boolean | null; isSubscribed: boolean | null; isLiked: boolean | null; likeCount: number; repliedAt: number | null; createdAt: number; user: { id: number; bannerImage: string | null; about: string | null; isFollowing: boolean | null; isFollower: boolean | null; donatorBadge: string | null; options: { profileColor: string | null; } | null; createdAt: number | null; name: string; avatar: { large: string | null; } | null; statistics: { anime: { count: number; minutesWatched: number; episodesWatched: number; genres: ({ genre: string | null; count: number; } | null)[] | null; } | null; } | null; } | null; categories: ({ id: number; name: string; } | null)[] | null; } | null; }, { threadId: number; }, void>;
|
||||
"\n fragment CommentFrag on ThreadComment @_unmask {\n id,\n comment,\n isLiked,\n likeCount,\n createdAt,\n user {\n ...UserFrag\n },\n childComments,\n isLocked\n }\n":
|
||||
TadaDocumentNode<{ id: number; comment: string | null; isLiked: boolean | null; likeCount: number; createdAt: number; user: { id: number; bannerImage: string | null; about: string | null; isFollowing: boolean | null; isFollower: boolean | null; donatorBadge: string | null; options: { profileColor: string | null; } | null; createdAt: number | null; name: string; avatar: { medium: string | null; } | null; statistics: { anime: { count: number; minutesWatched: number; episodesWatched: number; genres: ({ genre: string | null; count: number; } | null)[] | null; } | null; } | null; } | null; childComments: unknown; isLocked: boolean | null; }, {}, { fragment: "CommentFrag"; on: "ThreadComment"; masked: false; }>;
|
||||
"\n query Comments($threadId: Int, $page: Int) {\n Page(page: $page, perPage: 15) {\n pageInfo {\n hasNextPage,\n total\n }\n threadComments(threadId: $threadId) {\n id,\n comment,\n isLiked,\n likeCount,\n createdAt,\n user {\n id,\n bannerImage,\n about,\n isFollowing,\n isFollower,\n donatorBadge,\n options {\n profileColor\n },\n createdAt,\n name,\n avatar {\n medium\n },\n statistics {\n anime {\n count,\n minutesWatched,\n episodesWatched,\n genres(limit: 3, sort: COUNT_DESC) {\n genre,\n count\n }\n }\n }\n },\n childComments,\n isLocked\n }\n }\n }\n":
|
||||
TadaDocumentNode<{ Page: { pageInfo: { hasNextPage: boolean | null; total: number | null; } | null; threadComments: ({ id: number; comment: string | null; isLiked: boolean | null; likeCount: number; createdAt: number; user: { id: number; bannerImage: string | null; about: string | null; isFollowing: boolean | null; isFollower: boolean | null; donatorBadge: string | null; options: { profileColor: string | null; } | null; createdAt: number | null; name: string; avatar: { medium: string | null; } | null; statistics: { anime: { count: number; minutesWatched: number; episodesWatched: number; genres: ({ genre: string | null; count: number; } | null)[] | null; } | null; } | null; } | null; childComments: unknown; isLocked: boolean | null; } | null)[] | null; } | null; }, { page?: number | null | undefined; threadId?: number | null | undefined; }, void>;
|
||||
TadaDocumentNode<{ id: number; comment: string | null; isLiked: boolean | null; likeCount: number; createdAt: number; user: { id: number; bannerImage: string | null; about: string | null; isFollowing: boolean | null; isFollower: boolean | null; donatorBadge: string | null; options: { profileColor: string | null; } | null; createdAt: number | null; name: string; avatar: { large: string | null; } | null; statistics: { anime: { count: number; minutesWatched: number; episodesWatched: number; genres: ({ genre: string | null; count: number; } | null)[] | null; } | null; } | null; } | null; childComments: unknown; isLocked: boolean | null; }, {}, { fragment: "CommentFrag"; on: "ThreadComment"; masked: false; }>;
|
||||
"\n query Comments($threadId: Int, $page: Int) {\n Page(page: $page, perPage: 15) {\n pageInfo {\n hasNextPage,\n total\n }\n threadComments(threadId: $threadId) {\n id,\n comment,\n isLiked,\n likeCount,\n createdAt,\n user {\n id,\n bannerImage,\n about,\n isFollowing,\n isFollower,\n donatorBadge,\n options {\n profileColor\n },\n createdAt,\n name,\n avatar {\n large\n },\n statistics {\n anime {\n count,\n minutesWatched,\n episodesWatched,\n genres(limit: 3, sort: COUNT_DESC) {\n genre,\n count\n }\n }\n }\n },\n childComments,\n isLocked\n }\n }\n }\n":
|
||||
TadaDocumentNode<{ Page: { pageInfo: { hasNextPage: boolean | null; total: number | null; } | null; threadComments: ({ id: number; comment: string | null; isLiked: boolean | null; likeCount: number; createdAt: number; user: { id: number; bannerImage: string | null; about: string | null; isFollowing: boolean | null; isFollower: boolean | null; donatorBadge: string | null; options: { profileColor: string | null; } | null; createdAt: number | null; name: string; avatar: { large: string | null; } | null; statistics: { anime: { count: number; minutesWatched: number; episodesWatched: number; genres: ({ genre: string | null; count: number; } | null)[] | null; } | null; } | null; } | null; childComments: unknown; isLocked: boolean | null; } | null)[] | null; } | null; }, { page?: number | null | undefined; threadId?: number | null | undefined; }, void>;
|
||||
"\n mutation ToggleLike ($id: Int!, $type: LikeableType!) {\n ToggleLikeV2(id: $id, type: $type) {\n ... on Thread {\n id\n likeCount\n isLiked\n }\n ... on ThreadComment {\n id\n likeCount\n isLiked\n }\n }\n }\n":
|
||||
TadaDocumentNode<{ ToggleLikeV2: { __typename?: "ActivityReply" | undefined; } | { __typename?: "ListActivity" | undefined; } | { __typename?: "MessageActivity" | undefined; } | { __typename?: "TextActivity" | undefined; } | { __typename?: "Thread" | undefined; id: number; likeCount: number; isLiked: boolean | null; } | { __typename?: "ThreadComment" | undefined; id: number; likeCount: number; isLiked: boolean | null; } | null; }, { type: "THREAD" | "THREAD_COMMENT" | "ACTIVITY" | "ACTIVITY_REPLY"; id: number; }, void>;
|
||||
"\n mutation SaveThreadComment ($id: Int, $threadId: Int, $parentCommentId: Int, $comment: String) {\n SaveThreadComment(id: $id, threadId: $threadId, parentCommentId: $parentCommentId, comment: $comment) {\n ...CommentFrag\n }\n }\n":
|
||||
TadaDocumentNode<{ SaveThreadComment: { id: number; comment: string | null; isLiked: boolean | null; likeCount: number; createdAt: number; user: { id: number; bannerImage: string | null; about: string | null; isFollowing: boolean | null; isFollower: boolean | null; donatorBadge: string | null; options: { profileColor: string | null; } | null; createdAt: number | null; name: string; avatar: { medium: string | null; } | null; statistics: { anime: { count: number; minutesWatched: number; episodesWatched: number; genres: ({ genre: string | null; count: number; } | null)[] | null; } | null; } | null; } | null; childComments: unknown; isLocked: boolean | null; } | null; }, { comment?: string | null | undefined; parentCommentId?: number | null | undefined; threadId?: number | null | undefined; id?: number | null | undefined; }, void>;
|
||||
TadaDocumentNode<{ SaveThreadComment: { id: number; comment: string | null; isLiked: boolean | null; likeCount: number; createdAt: number; user: { id: number; bannerImage: string | null; about: string | null; isFollowing: boolean | null; isFollower: boolean | null; donatorBadge: string | null; options: { profileColor: string | null; } | null; createdAt: number | null; name: string; avatar: { large: string | null; } | null; statistics: { anime: { count: number; minutesWatched: number; episodesWatched: number; genres: ({ genre: string | null; count: number; } | null)[] | null; } | null; } | null; } | null; childComments: unknown; isLocked: boolean | null; } | null; }, { comment?: string | null | undefined; parentCommentId?: number | null | undefined; threadId?: number | null | undefined; id?: number | null | undefined; }, void>;
|
||||
"\n mutation DeleteThreadComment ($id: Int) {\n DeleteThreadComment(id: $id) {\n deleted\n }\n }\n":
|
||||
TadaDocumentNode<{ DeleteThreadComment: { deleted: boolean | null; } | null; }, { id?: number | null | undefined; }, void>;
|
||||
"fragment Med on Media {id, isFavourite}":
|
||||
|
|
|
|||
|
|
@ -111,6 +111,36 @@ relations {
|
|||
}
|
||||
}`, [FullMediaList, MediaEdgeFrag])
|
||||
|
||||
export const UserFrag = gql(`
|
||||
fragment UserFrag on User @_unmask {
|
||||
id,
|
||||
bannerImage,
|
||||
about,
|
||||
isFollowing,
|
||||
isFollower,
|
||||
donatorBadge,
|
||||
options {
|
||||
profileColor
|
||||
},
|
||||
createdAt,
|
||||
name,
|
||||
avatar {
|
||||
large
|
||||
},
|
||||
statistics {
|
||||
anime {
|
||||
count,
|
||||
minutesWatched,
|
||||
episodesWatched,
|
||||
genres(limit: 3, sort: COUNT_DESC) {
|
||||
genre,
|
||||
count
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
export const Search = gql(`
|
||||
query Search($page: Int, $perPage: Int, $search: String, $genre: [String], $format: [MediaFormat], $status: [MediaStatus], $statusNot: [MediaStatus], $season: MediaSeason, $seasonYear: Int, $isAdult: Boolean, $sort: [MediaSort], $onList: Boolean, $ids: [Int]) {
|
||||
Page(page: $page, perPage: $perPage) {
|
||||
|
|
@ -135,11 +165,7 @@ export const IDMedia = gql(`
|
|||
export const Viewer = gql(`
|
||||
query Viewer {
|
||||
Viewer {
|
||||
avatar {
|
||||
medium
|
||||
},
|
||||
name,
|
||||
id,
|
||||
...UserFrag,
|
||||
mediaListOptions {
|
||||
animeList {
|
||||
customLists
|
||||
|
|
@ -147,7 +173,7 @@ export const Viewer = gql(`
|
|||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
`, [UserFrag])
|
||||
|
||||
export const UserLists = gql(`
|
||||
query UserLists($id: Int) {
|
||||
|
|
@ -251,36 +277,6 @@ export const Schedule = gql(`
|
|||
}
|
||||
`, [ScheduleMedia])
|
||||
|
||||
export const UserFrag = gql(`
|
||||
fragment UserFrag on User @_unmask {
|
||||
id,
|
||||
bannerImage,
|
||||
about,
|
||||
isFollowing,
|
||||
isFollower,
|
||||
donatorBadge,
|
||||
options {
|
||||
profileColor
|
||||
},
|
||||
createdAt,
|
||||
name,
|
||||
avatar {
|
||||
medium
|
||||
},
|
||||
statistics {
|
||||
anime {
|
||||
count,
|
||||
minutesWatched,
|
||||
episodesWatched,
|
||||
genres(limit: 3, sort: COUNT_DESC) {
|
||||
genre,
|
||||
count
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
export const Following = gql(`
|
||||
query Following($id: Int!) {
|
||||
Page {
|
||||
|
|
@ -412,7 +408,7 @@ export const Comments = gql(`
|
|||
createdAt,
|
||||
name,
|
||||
avatar {
|
||||
medium
|
||||
large
|
||||
},
|
||||
statistics {
|
||||
anime {
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ export function format (media: Pick<Media, 'format'>): string {
|
|||
return 'N/A'
|
||||
}
|
||||
|
||||
export function episodes (media: Pick<Media, 'aired' | 'notaired' | 'episodes' | 'mediaListEntry'>): number | undefined {
|
||||
export function episodes (media: Pick<Media, 'aired' | 'notaired' | 'episodes' | 'mediaListEntry' | 'id'>): number | undefined {
|
||||
if (media.episodes) return media.episodes
|
||||
|
||||
const upcoming = media.aired?.n?.[media.aired.n.length - 1]?.e ?? 0
|
||||
|
|
|
|||
|
|
@ -12,3 +12,7 @@ export async function episodes (id: number, _fetch = fetch) {
|
|||
export async function mappings (id: number, _fetch = fetch) {
|
||||
return await safefetch<MappingsResponse>(_fetch, `https://hayase.ani.zip/v1/mappings?anilist_id=${id}`)
|
||||
}
|
||||
|
||||
export async function mappingsByKitsuId (kitsuId: number, _fetch = fetch) {
|
||||
return await safefetch<MappingsResponse>(_fetch, `https://hayase.ani.zip/v1/mappings?kitsu_id=${kitsuId}`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,21 @@
|
|||
import { readable } from 'simple-store-svelte'
|
||||
import { get } from 'svelte/store'
|
||||
import { derived, get } from 'svelte/store'
|
||||
import { persisted } from 'svelte-persisted-store'
|
||||
|
||||
import { client, episodes, type Media } from '../anilist'
|
||||
|
||||
import kitsu from './kitsu'
|
||||
import local from './local'
|
||||
|
||||
import type { Entry } from '../anilist/queries'
|
||||
import type { VariablesOf } from 'gql.tada'
|
||||
import type { Entry, UserFrag } from '../anilist/queries'
|
||||
import type { ResultOf, VariablesOf } from 'gql.tada'
|
||||
|
||||
export default new class AuthAggregator {
|
||||
hasAuth = readable(this.checkAuth(), set => {
|
||||
// add other subscriptions here for MAL, kitsu, tvdb, etc
|
||||
const unsub = [
|
||||
client.viewer.subscribe(() => set(this.checkAuth()))
|
||||
client.viewer.subscribe(() => set(this.checkAuth())),
|
||||
kitsu.viewer.subscribe(() => set(this.checkAuth()))
|
||||
]
|
||||
|
||||
return () => unsub.forEach(fn => fn())
|
||||
|
|
@ -26,60 +28,81 @@ export default new class AuthAggregator {
|
|||
return !!client.viewer.value?.viewer?.id
|
||||
}
|
||||
|
||||
kitsu () {
|
||||
return !!kitsu.viewer.value?.id
|
||||
}
|
||||
|
||||
checkAuth () {
|
||||
return this.anilist()
|
||||
return this.anilist() || this.kitsu()
|
||||
}
|
||||
|
||||
id () {
|
||||
if (this.anilist()) return client.viewer.value!.viewer?.id
|
||||
if (this.kitsu()) return kitsu.viewer.value?.id
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
profile () {
|
||||
if (this.anilist()) return client.viewer.value?.viewer
|
||||
profile (): ResultOf<typeof UserFrag> | undefined {
|
||||
if (this.anilist()) return client.viewer.value?.viewer ?? undefined
|
||||
if (this.kitsu()) return kitsu.viewer.value
|
||||
}
|
||||
|
||||
mediaListEntry (media: Pick<Media, 'mediaListEntry' | 'id'>) {
|
||||
if (this.anilist()) return media.mediaListEntry
|
||||
if (this.kitsu()) return kitsu.userlist.value[media.id]
|
||||
|
||||
return local.get(media.id)?.mediaListEntry
|
||||
}
|
||||
|
||||
isFavourite (media: Pick<Media, 'isFavourite' | 'id'>) {
|
||||
if (this.anilist()) return media.isFavourite
|
||||
if (this.kitsu()) return kitsu.isFav(media.id)
|
||||
|
||||
return local.get(media.id)?.isFavourite
|
||||
}
|
||||
|
||||
// QUERIES/MUTATIONS
|
||||
|
||||
schedule () {
|
||||
if (this.anilist()) return client.schedule()
|
||||
if (this.kitsu()) return kitsu.schedule()
|
||||
|
||||
return local.schedule()
|
||||
}
|
||||
|
||||
toggleFav (id: number) {
|
||||
if (this.anilist()) client.toggleFav(id)
|
||||
local.toggleFav(id)
|
||||
}
|
||||
|
||||
delete (id: number) {
|
||||
if (this.anilist()) client.deleteEntry(id)
|
||||
|
||||
local.deleteEntry(id)
|
||||
return Promise.allSettled([
|
||||
this.anilist() && client.toggleFav(id),
|
||||
this.kitsu() && kitsu.toggleFav(id),
|
||||
local.toggleFav(id)
|
||||
])
|
||||
}
|
||||
|
||||
following (id: number) {
|
||||
if (this.anilist()) return client.following(id)
|
||||
if (this.kitsu()) return kitsu.following(id)
|
||||
return null
|
||||
}
|
||||
|
||||
planningIDs () {
|
||||
if (this.anilist()) return client.planningIDs
|
||||
planningIDs = derived([client.planningIDs, kitsu.planningIDs, local.planningIDs], ([$client, $kitsu, $local]) => {
|
||||
if (this.anilist()) return $client
|
||||
if (this.kitsu()) return $kitsu
|
||||
if ($local.length) return $local
|
||||
return null
|
||||
})
|
||||
|
||||
return client.planningIDs
|
||||
}
|
||||
continueIDs = derived([client.continueIDs, kitsu.continueIDs, local.continueIDs], ([$client, $kitsu, $local]) => {
|
||||
if (this.anilist()) return $client
|
||||
if (this.kitsu()) return $kitsu
|
||||
if ($local.length) return $local
|
||||
return null
|
||||
})
|
||||
|
||||
continueIDs () {
|
||||
if (this.anilist()) return client.continueIDs
|
||||
|
||||
return client.continueIDs
|
||||
}
|
||||
|
||||
sequelIDs () {
|
||||
if (this.anilist()) return client.sequelIDs
|
||||
|
||||
return client.sequelIDs
|
||||
}
|
||||
sequelIDs = derived([client.sequelIDs], ([$client]) => {
|
||||
if (this.anilist()) return $client
|
||||
return null
|
||||
})
|
||||
|
||||
watch (media: Media, progress: number) {
|
||||
// TODO: auto re-watch status
|
||||
|
|
@ -102,6 +125,16 @@ export default new class AuthAggregator {
|
|||
this.entry({ id: media.id, progress, repeat, status, lists })
|
||||
}
|
||||
|
||||
delete (media: Media) {
|
||||
const syncSettings = get(this.syncSettings)
|
||||
|
||||
return Promise.allSettled([
|
||||
this.anilist() && syncSettings.al && client.deleteEntry(media),
|
||||
this.kitsu() && syncSettings.kitsu && kitsu.deleteEntry(media),
|
||||
syncSettings.local && local.deleteEntry(media)
|
||||
])
|
||||
}
|
||||
|
||||
entry (variables: VariablesOf<typeof Entry>) {
|
||||
const syncSettings = get(this.syncSettings)
|
||||
variables.lists ??= []
|
||||
|
|
@ -109,7 +142,10 @@ export default new class AuthAggregator {
|
|||
variables.lists.push('Watched using Hayase')
|
||||
}
|
||||
|
||||
if (this.anilist() && syncSettings.al) client.entry(variables)
|
||||
if (syncSettings.local) local.entry(variables)
|
||||
return Promise.allSettled([
|
||||
this.anilist() && syncSettings.al && client.entry(variables),
|
||||
this.kitsu() && syncSettings.kitsu && kitsu.entry(variables),
|
||||
syncSettings.local && local.entry(variables)
|
||||
])
|
||||
}
|
||||
}()
|
||||
|
|
|
|||
84
src/lib/modules/auth/kitsu-types.d.ts
vendored
84
src/lib/modules/auth/kitsu-types.d.ts
vendored
|
|
@ -1,4 +1,4 @@
|
|||
export interface KitsuOAuth {
|
||||
export interface OAuth {
|
||||
access_token: string
|
||||
created_at: number
|
||||
expires_in: number // Seconds until the access_token expires (30 days default)
|
||||
|
|
@ -6,3 +6,85 @@ export interface KitsuOAuth {
|
|||
scope: string
|
||||
token_type: string
|
||||
}
|
||||
|
||||
export interface KitsuError {
|
||||
error: string
|
||||
error_description: string
|
||||
}
|
||||
|
||||
export interface Resource<T> {
|
||||
id: string
|
||||
type: string
|
||||
attributes: T
|
||||
relationships?: Record<string, { data: Array<{id: string, type: string }> | {id: string, type: string }}>
|
||||
}
|
||||
|
||||
export interface Res<Attributes, Included = never> {
|
||||
data: Array<Resource<Attributes>>
|
||||
links?: {
|
||||
first?: string
|
||||
next?: string
|
||||
prev?: string
|
||||
last?: string
|
||||
}
|
||||
included?: Included extends never ? never : Array<Resource<Included>>
|
||||
meta?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface ResSingle<Attributes, Included = never> {
|
||||
data: Resource<Attributes>
|
||||
links?: {
|
||||
first?: string
|
||||
next?: string
|
||||
prev?: string
|
||||
last?: string
|
||||
}
|
||||
included?: Included extends never ? never : Array<Resource<Included>>
|
||||
meta?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface User {
|
||||
name: string
|
||||
about?: string
|
||||
avatar?: {
|
||||
original: string // TODO: maybe this can be done better with speficic sizes?
|
||||
}
|
||||
coverImage?: {
|
||||
original: string
|
||||
}
|
||||
createdAt: string // Date
|
||||
}
|
||||
|
||||
type KitsuMediaStatus = 'current' | 'planned' | 'completed' | 'dropped' | 'on_hold'
|
||||
|
||||
export interface KEntry {
|
||||
createdAt: string // Date
|
||||
updatedAt: string // Date
|
||||
status?: KitsuMediaStatus
|
||||
progress?: number
|
||||
volumesOwned?: number
|
||||
reconsuming?: boolean
|
||||
reconsumeCount?: number
|
||||
notes?: string
|
||||
private?: boolean
|
||||
reactionSkipped?: string
|
||||
progressedAt?: string // Date
|
||||
startedAt?: string // Date
|
||||
finishedAt?: string
|
||||
rating?: string
|
||||
ratingTwenty?: null
|
||||
}
|
||||
|
||||
export interface Fav {
|
||||
favRank: number
|
||||
}
|
||||
|
||||
export interface Anime {
|
||||
status?: string
|
||||
episodeCount?: number
|
||||
}
|
||||
|
||||
export interface Mapping {
|
||||
externalSite?: 'anilist/anime' | (string & {})
|
||||
externalId?: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,67 +1,186 @@
|
|||
import { readable, writable } from 'simple-store-svelte'
|
||||
import { writable } from 'simple-store-svelte'
|
||||
import { derived, readable } from 'svelte/store'
|
||||
import { toast } from 'svelte-sonner'
|
||||
|
||||
import type { Media } from '../anilist'
|
||||
import type { KitsuOAuth } from './kitsu-types'
|
||||
import type { Entry } from '../anilist/queries'
|
||||
import type { VariablesOf } from 'gql.tada'
|
||||
import { client, type Media } from '../anilist'
|
||||
import { mappings, mappingsByKitsuId } from '../anizip'
|
||||
import native from '../native'
|
||||
|
||||
import { safeLocalStorage } from '$lib/utils'
|
||||
import type { Anime, Fav, KEntry, KitsuError, KitsuMediaStatus, Mapping, OAuth, Res, Resource, ResSingle, User } from './kitsu-types'
|
||||
import type { Entry, FullMediaList, UserFrag } from '../anilist/queries'
|
||||
import type { ResultOf, VariablesOf } from 'gql.tada'
|
||||
|
||||
import { arrayEqual, safeLocalStorage } from '$lib/utils'
|
||||
|
||||
const ENDPOINTS = {
|
||||
API_OAUTH: 'https://kitsu.app/api/oauth/token',
|
||||
API_USER_FETCH: 'https://kitsu.app/api/edge/users',
|
||||
API_USER_LIBRARY: 'https://kitsu.app/api/edge/library-entries'
|
||||
API_USER_LIBRARY: 'https://kitsu.app/api/edge/library-entries',
|
||||
API_FAVOURITES: 'https://kitsu.app/api/edge/favorites'
|
||||
} as const
|
||||
|
||||
type ALMediaStatus = 'CURRENT' | 'PLANNING' | 'COMPLETED' | 'DROPPED' | 'PAUSED' | 'REPEATING'
|
||||
|
||||
const KITSU_TO_AL_STATUS: Record<KitsuMediaStatus, ALMediaStatus> = {
|
||||
current: 'CURRENT',
|
||||
planned: 'PLANNING',
|
||||
completed: 'COMPLETED',
|
||||
dropped: 'DROPPED',
|
||||
on_hold: 'PAUSED'
|
||||
}
|
||||
|
||||
const AL_TO_KITSU_STATUS: Record<ALMediaStatus, KitsuMediaStatus> = {
|
||||
CURRENT: 'current',
|
||||
PLANNING: 'planned',
|
||||
COMPLETED: 'completed',
|
||||
DROPPED: 'dropped',
|
||||
PAUSED: 'on_hold',
|
||||
REPEATING: 'current'
|
||||
}
|
||||
|
||||
export default new class KitsuSync {
|
||||
auth = writable<KitsuOAuth | undefined>(safeLocalStorage('kitsuAuth'))
|
||||
viewer = writable<{id: number} | undefined>(safeLocalStorage('kitsuViewer'))
|
||||
auth = writable<OAuth | undefined>(safeLocalStorage('kitsuAuth'))
|
||||
viewer = writable<ResultOf<typeof UserFrag> | undefined>(safeLocalStorage('kitsuViewer'))
|
||||
userlist = writable<Record<string, ResultOf<typeof FullMediaList>>>({}) // al id to al mapped kitsu entry
|
||||
favorites = writable<Record<string, string>>({}) // kitsu anime id to kitsu fav id
|
||||
kitsuToAL: Record<string, string> = {}
|
||||
ALToKitsu: Record<string, string> = {}
|
||||
|
||||
continueIDs = readable<number[]>([], set => {
|
||||
let oldvalue: number[] = []
|
||||
const sub = this.userlist.subscribe(values => {
|
||||
const entries = Object.entries(values)
|
||||
if (!entries.length) return []
|
||||
|
||||
const ids: number[] = []
|
||||
|
||||
for (const [alId, entry] of entries) {
|
||||
if (entry.status === 'REPEATING' || entry.status === 'CURRENT') {
|
||||
ids.push(Number(alId))
|
||||
}
|
||||
}
|
||||
|
||||
if (arrayEqual(oldvalue, ids)) return
|
||||
oldvalue = ids
|
||||
set(ids)
|
||||
})
|
||||
return sub
|
||||
})
|
||||
|
||||
planningIDs = readable<number[]>([], set => {
|
||||
let oldvalue: number[] = []
|
||||
const sub = this.userlist.subscribe(values => {
|
||||
const entries = Object.entries(values)
|
||||
if (!entries.length) return []
|
||||
|
||||
const ids: number[] = []
|
||||
|
||||
for (const [alId, entry] of entries) {
|
||||
if (entry.status === 'PLANNING') {
|
||||
ids.push(Number(alId))
|
||||
}
|
||||
}
|
||||
|
||||
if (arrayEqual(oldvalue, ids)) return
|
||||
oldvalue = ids
|
||||
set(ids)
|
||||
})
|
||||
return sub
|
||||
})
|
||||
|
||||
constructor () {
|
||||
this.auth.subscribe((auth) => {
|
||||
if (auth) localStorage.setItem('kitsuAuth', JSON.stringify(auth))
|
||||
this._user()
|
||||
})
|
||||
|
||||
this.viewer.subscribe((viewer) => {
|
||||
if (viewer) localStorage.setItem('kitsuViewer', JSON.stringify(viewer))
|
||||
})
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async _request (url: string, method: string, body?: any): Promise<any> {
|
||||
async _request <T = object> (url: string | URL, method: string, body?: any): Promise<T | KitsuError> {
|
||||
try {
|
||||
if (this.auth.value) {
|
||||
const expiresAt = (this.auth.value.created_at + this.auth.value.expires_in) * 1000
|
||||
|
||||
if (expiresAt < Date.now() - 1000 * 60 * 5) { // 5 minutes before expiry
|
||||
await this._refresh()
|
||||
}
|
||||
}
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/vnd.api+json',
|
||||
Accept: 'application/vnd.api+json',
|
||||
Authorization: this.auth.value ? `Bearer ${this.auth.value.access_token}` : ''
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
})
|
||||
|
||||
if (res.status === 403 && !body?.refresh_token) {
|
||||
await this._refresh()
|
||||
return await this._request(url, method, body)
|
||||
if (!res.ok) {
|
||||
throw new Error(`Kitsu API Error: ${res.status} ${res.statusText}`)
|
||||
}
|
||||
if (method === 'DELETE') return undefined as T
|
||||
|
||||
const json = await res.json() as object | KitsuError
|
||||
|
||||
if ('error' in json) {
|
||||
toast.error('Kitsu Error', { description: json.error_description })
|
||||
console.error(json)
|
||||
}
|
||||
|
||||
return await res.json()
|
||||
return json as T | KitsuError
|
||||
} catch (error) {
|
||||
// TODO: :^)
|
||||
const err = error as Error
|
||||
throw err
|
||||
|
||||
toast.error('Kitsu Error', { description: err.message })
|
||||
console.error(err)
|
||||
|
||||
return {
|
||||
error: err.name,
|
||||
error_description: err.stack ?? 'An unknown error occurred'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _get <T> (target: string, body?: Record<string, unknown>): Promise<T | KitsuError> {
|
||||
const url = new URL(target)
|
||||
|
||||
for (const [key, value] of Object.entries(body ?? {})) url.searchParams.append(key, String(value))
|
||||
|
||||
return await this._request<T>(url, 'GET')
|
||||
}
|
||||
|
||||
async _delete <T> (url: string): Promise<T | KitsuError> {
|
||||
return await this._request<T>(url, 'DELETE')
|
||||
}
|
||||
|
||||
async _post <T> (url: string, body?: Record<string, unknown>): Promise<T | KitsuError> {
|
||||
return await this._request<T>(url, 'POST', body)
|
||||
}
|
||||
|
||||
async _patch <T> (url: string, body?: Record<string, unknown>): Promise<T | KitsuError> {
|
||||
return await this._request<T>(url, 'PATCH', body)
|
||||
}
|
||||
|
||||
async _refresh () {
|
||||
try {
|
||||
const data = await this._request(
|
||||
ENDPOINTS.API_OAUTH,
|
||||
'POST',
|
||||
{
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: this.auth.value?.refresh_token
|
||||
}
|
||||
)
|
||||
this.viewer.value = data
|
||||
await this._user()
|
||||
} catch (error) {
|
||||
this.viewer.value = undefined
|
||||
const data = await this._post<OAuth>(
|
||||
ENDPOINTS.API_OAUTH,
|
||||
{
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: this.auth.value?.refresh_token
|
||||
}
|
||||
)
|
||||
if ('error' in data) {
|
||||
this.auth.value = undefined
|
||||
} else {
|
||||
this.auth.value = data
|
||||
}
|
||||
}
|
||||
|
||||
async _login (username: string, password: string) {
|
||||
const data = await this._request(
|
||||
async login (username: string, password: string) {
|
||||
const data = await this._request<OAuth>(
|
||||
ENDPOINTS.API_OAUTH,
|
||||
'POST',
|
||||
{
|
||||
|
|
@ -71,125 +190,276 @@ export default new class KitsuSync {
|
|||
}
|
||||
)
|
||||
|
||||
this.viewer.value = data
|
||||
await this._user()
|
||||
if ('error' in data) {
|
||||
this.auth.value = undefined
|
||||
} else {
|
||||
this.auth.value = data
|
||||
}
|
||||
}
|
||||
|
||||
logout () {
|
||||
localStorage.removeItem('kitsuViewer')
|
||||
localStorage.removeItem('kitsuAuth')
|
||||
native.restart()
|
||||
}
|
||||
|
||||
async _user () {
|
||||
const data = await this._request(
|
||||
const res = await this._get<Res<User>>(
|
||||
ENDPOINTS.API_USER_FETCH,
|
||||
'GET',
|
||||
{ 'filter[self]': true }
|
||||
)
|
||||
|
||||
const [user] = data
|
||||
|
||||
return user.id
|
||||
}
|
||||
|
||||
async _getEntry (id: number) {
|
||||
const data = await this._request(
|
||||
ENDPOINTS.API_USER_LIBRARY,
|
||||
'GET',
|
||||
{
|
||||
'filter[animeId]': id,
|
||||
'filter[userId]': this.viewer.value?.id,
|
||||
'filter[kind]': 'anime'
|
||||
'filter[self]': true,
|
||||
include: 'favorites.item,libraryEntries.anime,libraryEntries.anime.mappings',
|
||||
'fields[users]': 'name,about,avatar,coverImage,createdAt',
|
||||
'fields[anime]': 'status,episodeCount,mappings',
|
||||
'fields[mappings]': 'externalSite,externalId',
|
||||
'fields[libraryEntries]': 'anime,progress,status,reconsumeCount,reconsuming,rating'
|
||||
}
|
||||
)
|
||||
|
||||
const [anime] = data.data
|
||||
if ('error' in res || !res.data[0]) return
|
||||
|
||||
return anime
|
||||
this._entriesToML(res)
|
||||
|
||||
const { id, attributes } = res.data[0]
|
||||
|
||||
this.viewer.value = {
|
||||
id: Number(id),
|
||||
name: attributes.name ?? '',
|
||||
about: attributes.about ?? '',
|
||||
avatar: {
|
||||
large: attributes.avatar?.original ?? null
|
||||
},
|
||||
bannerImage: attributes.coverImage?.original ?? null,
|
||||
createdAt: +new Date(attributes.createdAt),
|
||||
isFollowing: false,
|
||||
isFollower: false,
|
||||
donatorBadge: null,
|
||||
options: null,
|
||||
statistics: null
|
||||
}
|
||||
}
|
||||
|
||||
async _addEntry (id: number, attributes: Record<string, unknown>) {
|
||||
const data = await this._request(
|
||||
ENDPOINTS.API_USER_LIBRARY,
|
||||
'POST',
|
||||
_kitsuEntryToAl (entry: Resource<KEntry>): ResultOf<typeof FullMediaList> {
|
||||
return {
|
||||
id: Number(entry.id),
|
||||
status: entry.attributes.reconsuming ? 'REPEATING' : entry.attributes.status ? KITSU_TO_AL_STATUS[entry.attributes.status] : null,
|
||||
progress: entry.attributes.progress ?? 0,
|
||||
score: Number(entry.attributes.rating) || 0,
|
||||
repeat: entry.attributes.reconsumeCount ?? 0,
|
||||
customLists: null
|
||||
}
|
||||
}
|
||||
|
||||
_entriesToML (res: Res<KEntry | User, Anime | Mapping | KEntry | Fav>) {
|
||||
const entryMap = this.userlist.value
|
||||
|
||||
const { included } = res
|
||||
|
||||
const relations = {
|
||||
anime: new Map<string, Resource<Anime>>(),
|
||||
mappings: new Map<string, Resource<Mapping>>(),
|
||||
favorites: new Map<string, Resource<Fav>>()
|
||||
}
|
||||
|
||||
const entries: Array<Resource<KEntry>> = []
|
||||
|
||||
if (res.data[0]?.type === 'libraryEntries') {
|
||||
entries.push(...res.data as Array<Resource<KEntry>>)
|
||||
}
|
||||
|
||||
for (const entry of included ?? []) {
|
||||
if (entry.type === 'anime') {
|
||||
relations.anime.set(entry.id, entry as Resource<Anime>)
|
||||
} else if (entry.type === 'mappings') {
|
||||
const e = entry as Resource<Mapping>
|
||||
if (e.attributes.externalSite !== 'anilist/anime') continue
|
||||
relations.mappings.set(entry.id, entry as Resource<Mapping>)
|
||||
} else if (entry.type === 'favorites') {
|
||||
relations.favorites.set(entry.id, entry as Resource<Fav>)
|
||||
} else {
|
||||
entries.push(entry as Resource<KEntry>)
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const animeRes = Array.isArray(entry.relationships?.anime?.data) ? entry.relationships.anime.data[0] : entry.relationships?.anime?.data
|
||||
const anime = relations.anime.get(animeRes?.id ?? '')
|
||||
const ids = Array.isArray(anime?.relationships?.mappings?.data) ? anime.relationships.mappings.data : [anime?.relationships?.mappings?.data]
|
||||
const anilistId = ids.map(i => i && relations.mappings.get(i.id)).filter(i => i)[0]?.attributes.externalId
|
||||
|
||||
if (!anilistId || !animeRes) continue
|
||||
this.kitsuToAL[animeRes.id] = anilistId
|
||||
this.ALToKitsu[anilistId] = animeRes.id
|
||||
entryMap[anilistId] = this._kitsuEntryToAl(entry)
|
||||
}
|
||||
|
||||
for (const [id, fav] of relations.favorites.entries()) {
|
||||
const data = fav.relationships!.item!.data as { id: string }
|
||||
const animeId = data.id
|
||||
this.favorites.value[animeId] = id
|
||||
this._getAlId(+animeId)
|
||||
}
|
||||
|
||||
this.userlist.value = entryMap
|
||||
}
|
||||
|
||||
isFav (alID: number) {
|
||||
const kitsuId = this.ALToKitsu[alID.toString()]
|
||||
if (!kitsuId) return false
|
||||
return !!this.favorites.value[kitsuId]
|
||||
}
|
||||
|
||||
async _makeFavourite (kitsuAnimeId: string) {
|
||||
const data = await this._post<ResSingle<Fav>>(
|
||||
ENDPOINTS.API_FAVOURITES,
|
||||
{
|
||||
data: {
|
||||
attributes: {
|
||||
status: 'planned'
|
||||
},
|
||||
relationships: {
|
||||
anime: {
|
||||
data: {
|
||||
id,
|
||||
type: 'anime'
|
||||
}
|
||||
},
|
||||
user: {
|
||||
data: {
|
||||
id: this.viewer.value?.id,
|
||||
type: 'users'
|
||||
}
|
||||
}
|
||||
user: { data: { type: 'users', id: this.viewer.value?.id.toString() ?? '' } },
|
||||
item: { data: { type: 'anime', id: kitsuAnimeId } }
|
||||
},
|
||||
type: 'favorites'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if ('error' in data) return
|
||||
|
||||
this.favorites.value[kitsuAnimeId] = data.data.id
|
||||
}
|
||||
|
||||
async _addEntry (id: string, attributes: Omit<KEntry, 'createdAt' | 'updatedAt'>, alId: number) {
|
||||
const data = await this._post<ResSingle<KEntry>>(
|
||||
ENDPOINTS.API_USER_LIBRARY,
|
||||
{
|
||||
data: {
|
||||
attributes,
|
||||
relationships: {
|
||||
anime: { data: { id, type: 'anime' } },
|
||||
user: { data: { type: 'users', id: this.viewer.value?.id.toString() ?? '' } }
|
||||
},
|
||||
type: 'library-entries'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return data
|
||||
if ('error' in data) return
|
||||
|
||||
this.userlist.value[alId] = this._kitsuEntryToAl(data.data)
|
||||
}
|
||||
|
||||
async _updateEntry (id: number, attributes: Record<string, unknown>) {
|
||||
const data = await this._request(
|
||||
async _updateEntry (id: number, attributes: Omit<KEntry, 'createdAt' | 'updatedAt'>, alId: number) {
|
||||
const data = await this._patch<ResSingle<KEntry>>(
|
||||
`${ENDPOINTS.API_USER_LIBRARY}/${id}`,
|
||||
'PATCH', {
|
||||
data: {
|
||||
id,
|
||||
attributes,
|
||||
type: 'library-entries'
|
||||
}
|
||||
{
|
||||
data: { id, attributes, type: 'library-entries' }
|
||||
}
|
||||
)
|
||||
return data
|
||||
|
||||
if ('error' in data) return
|
||||
|
||||
this.userlist.value[alId] = this._kitsuEntryToAl(data.data)
|
||||
}
|
||||
|
||||
async _deleteEntry (id: number) {
|
||||
this._request(
|
||||
`${ENDPOINTS.API_USER_LIBRARY}/${id}`,
|
||||
'DELETE'
|
||||
)
|
||||
async _getKitsuId (alId: number) {
|
||||
const kitsuId = this.ALToKitsu[alId.toString()]
|
||||
if (kitsuId) return kitsuId
|
||||
const res = await mappings(alId)
|
||||
if (!res?.kitsu_id) return
|
||||
this.ALToKitsu[alId.toString()] = res.kitsu_id.toString()
|
||||
return res.kitsu_id.toString()
|
||||
}
|
||||
|
||||
hasAuth = readable(false)
|
||||
async _getAlId (kitsuId: number) {
|
||||
const alId = this.kitsuToAL[kitsuId]
|
||||
if (alId) return alId
|
||||
const res = await mappingsByKitsuId(kitsuId)
|
||||
if (!res?.anilist_id) return
|
||||
this.kitsuToAL[kitsuId] = res.anilist_id.toString()
|
||||
return res.anilist_id.toString()
|
||||
}
|
||||
|
||||
hasAuth = derived(this.viewer, (viewer) => {
|
||||
return viewer !== undefined && !!viewer.id
|
||||
})
|
||||
|
||||
id () {
|
||||
return -1
|
||||
return this.viewer.value?.id ?? -1
|
||||
}
|
||||
|
||||
profile () {
|
||||
profile (): ResultOf<typeof UserFrag> | undefined {
|
||||
return this.viewer.value
|
||||
}
|
||||
|
||||
// QUERIES/MUTATIONS
|
||||
|
||||
schedule () {
|
||||
const ids = Object.keys(this.userlist.value).map(id => parseInt(id))
|
||||
return client.schedule(ids.length ? ids : undefined)
|
||||
}
|
||||
|
||||
toggleFav (id: number) {
|
||||
async toggleFav (id: number) {
|
||||
const kitsuId = await this._getKitsuId(id)
|
||||
if (!kitsuId) {
|
||||
toast.error('Kitsu Sync', {
|
||||
description: 'Could not find Kitsu ID for this media.'
|
||||
})
|
||||
return
|
||||
}
|
||||
const favs = this.favorites.value
|
||||
const favId = favs[kitsuId]
|
||||
if (!favId) {
|
||||
await this._makeFavourite(kitsuId)
|
||||
} else {
|
||||
const res = await this._delete<undefined>(`${ENDPOINTS.API_FAVOURITES}/${favId}`)
|
||||
|
||||
if (res && 'error' in res) return
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||
delete this.favorites.value[kitsuId]
|
||||
}
|
||||
}
|
||||
|
||||
delete (id: number) {
|
||||
async deleteEntry (media: Media) {
|
||||
const id = this.userlist.value[media.id]?.id
|
||||
if (!id) return
|
||||
const res = await this._delete<undefined>(`${ENDPOINTS.API_USER_LIBRARY}/${id}`)
|
||||
if (res && 'error' in res) return
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||
delete this.userlist.value[media.id]
|
||||
}
|
||||
|
||||
following (id: number) {
|
||||
// TODO
|
||||
}
|
||||
|
||||
planningIDs () {
|
||||
}
|
||||
async entry (variables: VariablesOf<typeof Entry>) {
|
||||
const targetMediaId = variables.id
|
||||
|
||||
continueIDs () {
|
||||
}
|
||||
const kitsuEntry = this.userlist.value[targetMediaId]
|
||||
|
||||
sequelIDs () {
|
||||
}
|
||||
const kitsuEntryVariables = {
|
||||
status: AL_TO_KITSU_STATUS[variables.status!],
|
||||
progress: variables.progress ?? undefined,
|
||||
rating: (variables.score ?? 0) < 2 ? undefined : variables.score!.toString(),
|
||||
reconsumeCount: variables.repeat ?? undefined,
|
||||
reconsuming: variables.status === 'REPEATING'
|
||||
}
|
||||
|
||||
watch (media: Media, progress: number) {
|
||||
}
|
||||
if (kitsuEntry) {
|
||||
await this._updateEntry(kitsuEntry.id, kitsuEntryVariables, targetMediaId)
|
||||
} else {
|
||||
const kitsuAnimeId = await this._getKitsuId(targetMediaId)
|
||||
|
||||
entry (variables: VariablesOf<typeof Entry>) {
|
||||
if (!kitsuAnimeId) {
|
||||
toast.error('Kitsu Sync', {
|
||||
description: 'Could not find Kitsu ID for this media.'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await this._addEntry(kitsuAnimeId, kitsuEntryVariables, targetMediaId)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
import { createStore, set, get } from 'idb-keyval'
|
||||
import { writable } from 'simple-store-svelte'
|
||||
import { readable } from 'svelte/store'
|
||||
|
||||
import { client, type Media } from '../anilist'
|
||||
|
||||
import type { Entry } from '../anilist/queries'
|
||||
import type { VariablesOf } from 'gql.tada'
|
||||
|
||||
import { arrayEqual } from '$lib/utils'
|
||||
|
||||
type StoredMedia = Pick<Media, 'isFavourite' | 'mediaListEntry' | 'id'>
|
||||
|
||||
export default new class LocalSync {
|
||||
|
|
@ -26,7 +29,7 @@ export default new class LocalSync {
|
|||
return this.entries.value[id]
|
||||
}
|
||||
|
||||
_createEntry (id: number): StoredMedia {
|
||||
_getEntry (id: number): StoredMedia {
|
||||
// const media = client.client.readQuery(IDMedia, { id })?.data?.Media
|
||||
return this.entries.value[id] ?? {
|
||||
id,
|
||||
|
|
@ -43,42 +46,87 @@ export default new class LocalSync {
|
|||
}
|
||||
|
||||
schedule (): ReturnType<typeof client.schedule> {
|
||||
const ids = Object.keys(this.entries.value).map(id => parseInt(id))
|
||||
const ids = Object.values(this.entries.value).map(({ mediaListEntry }) => mediaListEntry?.id).filter(e => e != null)
|
||||
return client.schedule(ids.length ? ids : undefined)
|
||||
}
|
||||
|
||||
toggleFav (id: number) {
|
||||
this.entries.update(entries => {
|
||||
const entry = this._createEntry(id)
|
||||
const entry = this._getEntry(id)
|
||||
|
||||
entry.isFavourite = !entry.isFavourite
|
||||
return { ...entries, [id]: entry }
|
||||
})
|
||||
}
|
||||
|
||||
deleteEntry (id: number) {
|
||||
deleteEntry (media: Media) {
|
||||
const id = media.id
|
||||
this.entries.update(entries => {
|
||||
const entry = this._createEntry(id)
|
||||
const entry = this._getEntry(id)
|
||||
|
||||
entry.mediaListEntry = null
|
||||
return { ...entries, [id]: entry }
|
||||
return { ...entries, [media.id]: entry }
|
||||
})
|
||||
}
|
||||
|
||||
// this is in theory doable, but hard to sync media's airing status
|
||||
// continueIDs () {
|
||||
// }
|
||||
// sequelIDs () {
|
||||
// }
|
||||
continueIDs = readable<number[]>([], set => {
|
||||
let oldvalue: number[] = []
|
||||
const sub = this.entries.subscribe(values => {
|
||||
const entries = Object.entries(values)
|
||||
if (!entries.length) return []
|
||||
|
||||
const ids: number[] = []
|
||||
|
||||
for (const [alId, entry] of entries) {
|
||||
if (entry.mediaListEntry?.status === 'REPEATING' || entry.mediaListEntry?.status === 'CURRENT') {
|
||||
ids.push(Number(alId))
|
||||
}
|
||||
}
|
||||
|
||||
if (arrayEqual(oldvalue, ids)) return
|
||||
oldvalue = ids
|
||||
set(ids)
|
||||
})
|
||||
return sub
|
||||
})
|
||||
|
||||
planningIDs = readable<number[]>([], set => {
|
||||
let oldvalue: number[] = []
|
||||
const sub = this.entries.subscribe(values => {
|
||||
const entries = Object.entries(values)
|
||||
if (!entries.length) return []
|
||||
|
||||
const ids: number[] = []
|
||||
|
||||
for (const [alId, entry] of entries) {
|
||||
if (entry.mediaListEntry?.status === 'PLANNING') {
|
||||
ids.push(Number(alId))
|
||||
}
|
||||
}
|
||||
|
||||
if (arrayEqual(oldvalue, ids)) return
|
||||
oldvalue = ids
|
||||
set(ids)
|
||||
})
|
||||
return sub
|
||||
})
|
||||
|
||||
entry (variables: VariablesOf<typeof Entry>) {
|
||||
this.entries.update(entries => {
|
||||
const entry = this._createEntry(variables.id)
|
||||
const entry = this._getEntry(variables.id)
|
||||
entry.mediaListEntry ??= {
|
||||
id: variables.id,
|
||||
customLists: null,
|
||||
progress: null,
|
||||
repeat: null,
|
||||
score: null,
|
||||
status: null
|
||||
}
|
||||
|
||||
const keys = ['status', 'score', 'repeat', 'progress'] as const
|
||||
for (const key of keys) {
|
||||
// @ts-expect-error idk how to fix this tbf
|
||||
entry.mediaListEntry![key] = variables[key] ?? entry.mediaListEntry![key] ?? null
|
||||
entry.mediaListEntry[key] = variables[key] ?? entry.mediaListEntry[key] ?? null
|
||||
}
|
||||
return { ...entries, [variables.id]: entry }
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,33 +1,34 @@
|
|||
import { episodes, type Media } from '../anilist'
|
||||
|
||||
// TODO: these should probably be in auth aggregator
|
||||
import { authAggregator } from '.'
|
||||
|
||||
import local from './local'
|
||||
|
||||
export function progress ({ mediaListEntry, id }: Pick<Media, 'mediaListEntry' | 'id'>): number | undefined {
|
||||
if (!mediaListEntry?.progress) return local.get(id)?.mediaListEntry?.progress ?? undefined
|
||||
return mediaListEntry.progress
|
||||
export function progress (media: Pick<Media, 'mediaListEntry' | 'id'>): number | undefined {
|
||||
return authAggregator.mediaListEntry(media)?.progress ?? undefined
|
||||
}
|
||||
|
||||
export function fav (media: Pick<Media, 'mediaListEntry' | 'isFavourite' | 'id'>): boolean {
|
||||
// TODO: idk how to handle this properly
|
||||
return media.mediaListEntry ? media.isFavourite : (local.get(media.id)?.isFavourite ?? false)
|
||||
export function fav (media: Pick<Media, 'isFavourite' | 'id'>): boolean {
|
||||
return !!authAggregator.isFavourite(media)
|
||||
}
|
||||
|
||||
export function list (media: Pick<Media, 'mediaListEntry' | 'id'>): 'CURRENT' | 'PLANNING' | 'COMPLETED' | 'DROPPED' | 'PAUSED' | 'REPEATING' | null | undefined {
|
||||
return media.mediaListEntry?.status ?? local.get(media.id)?.mediaListEntry?.status
|
||||
export function list (media: { id: Media['id'], mediaListEntry: Pick<Media['mediaListEntry'] & {}, 'status' | 'id'> | null}): 'CURRENT' | 'PLANNING' | 'COMPLETED' | 'DROPPED' | 'PAUSED' | 'REPEATING' | null | undefined {
|
||||
// HACK: this is unsafe, but it shouldnt be a problem
|
||||
return authAggregator.mediaListEntry(media as Media)?.status
|
||||
}
|
||||
|
||||
export function lists (media: Pick<Media, 'mediaListEntry' | 'id'>): Array<{ enabled: boolean, name: string }> | undefined {
|
||||
return media.mediaListEntry?.customLists as Array<{ enabled: boolean, name: string }> | undefined
|
||||
return authAggregator.mediaListEntry(media)?.customLists as Array<{ enabled: boolean, name: string }> | undefined
|
||||
}
|
||||
|
||||
export function repeat (media: Pick<Media, 'mediaListEntry' | 'id'>): number | null | undefined {
|
||||
return media.mediaListEntry?.repeat ?? local.get(media.id)?.mediaListEntry?.repeat
|
||||
return authAggregator.mediaListEntry(media)?.repeat
|
||||
}
|
||||
|
||||
export function score (media: Pick<Media, 'mediaListEntry' | 'id'>): number | null | undefined {
|
||||
return media.mediaListEntry?.score ?? local.get(media.id)?.mediaListEntry?.score
|
||||
return authAggregator.mediaListEntry(media)?.score
|
||||
}
|
||||
|
||||
export function entry (media: Pick<Media, 'mediaListEntry' | 'id'>): Media['mediaListEntry'] {
|
||||
return authAggregator.mediaListEntry(media) ?? null
|
||||
}
|
||||
|
||||
export function of (media: Pick<Media, 'aired' | 'notaired' | 'episodes' | 'mediaListEntry' | 'id'>): string | undefined {
|
||||
|
|
|
|||
|
|
@ -275,3 +275,7 @@ export const safefetch = async <T> (_fetch: typeof fetch, ...args: Parameters<ty
|
|||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function arrayEqual <T> (a: T[], b: T[]) {
|
||||
return a.length === b.length && a.every((v, i) => v === b[i])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@
|
|||
|
||||
<BannerImage class='absolute top-0 left-0 -z-[1]' />
|
||||
<SearchModal />
|
||||
<div class='flex flex-row grow h-full overflow-clip group/fullscreen' id='episodeListTarget'>
|
||||
<div class='flex flex-row grow h-full overflow-clip group/fullscreen min-h-0' id='episodeListTarget'>
|
||||
<Sidebar>
|
||||
<Sidebarlist />
|
||||
</Sidebar>
|
||||
|
|
|
|||
|
|
@ -36,19 +36,22 @@
|
|||
}
|
||||
|
||||
const sectionQueries = derived<[
|
||||
typeof authAggregator.hasAuth, Readable<number[]>, Readable<number[]>, Readable<number[]>], SectionQuery[]
|
||||
>([authAggregator.hasAuth, authAggregator.continueIDs(), authAggregator.sequelIDs(), authAggregator.planningIDs()], ([hasAuth, continueIDs, sequelIDs, planningIDs], set) => {
|
||||
Readable<number[] | null>, Readable<number[] | null>, Readable<number[] | null>], SectionQuery[]
|
||||
>([authAggregator.continueIDs, authAggregator.sequelIDs, authAggregator.planningIDs], ([continueIDs, sequelIDs, planningIDs], set) => {
|
||||
const sections = [...sectionsQueries]
|
||||
const unsub: Array<() => void> = []
|
||||
if (hasAuth) {
|
||||
|
||||
if (planningIDs) {
|
||||
const planningQuery = client.search({ ids: planningIDs }, true)
|
||||
unsub.push(planningQuery.subscribe(() => undefined))
|
||||
sections.unshift({ title: 'Your List', query: planningQuery, variables: { ids: planningIDs } })
|
||||
|
||||
}
|
||||
if (sequelIDs) {
|
||||
const sequelsQuery = client.search({ ids: sequelIDs, status: ['FINISHED', 'RELEASING'], onList: false }, true)
|
||||
unsub.push(sequelsQuery.subscribe(() => undefined))
|
||||
sections.unshift({ title: 'Sequels You Missed', query: sequelsQuery, variables: { ids: sequelIDs, status: ['FINISHED', 'RELEASING'], onList: false } })
|
||||
|
||||
}
|
||||
if (continueIDs) {
|
||||
const contiueQuery = derived(client.search({ ids: continueIDs.slice(0, 50), sort: ['UPDATED_AT_DESC'] }, false), value => {
|
||||
value.data?.Page?.media?.sort((a, b) => continueIDs.indexOf(a?.id ?? 0) - continueIDs.indexOf(b?.id ?? 0))
|
||||
return value
|
||||
|
|
@ -56,6 +59,7 @@
|
|||
unsub.push(contiueQuery.subscribe(() => undefined))
|
||||
sections.unshift({ title: 'Continue Watching', query: contiueQuery, variables: { ids: continueIDs, sort: ['UPDATED_AT_DESC'] } })
|
||||
}
|
||||
|
||||
set(sections)
|
||||
|
||||
return () => unsub.forEach(fn => fn())
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
import * as Drawer from '$lib/components/ui/drawer'
|
||||
import * as Tooltip from '$lib/components/ui/tooltip'
|
||||
import { dedupeAiring } from '$lib/modules/anilist'
|
||||
import { authAggregator } from '$lib/modules/auth'
|
||||
import { authAggregator, list } from '$lib/modules/auth'
|
||||
import { dragScroll } from '$lib/modules/navigate'
|
||||
import { cn, isMobile } from '$lib/utils'
|
||||
|
||||
|
|
@ -51,7 +51,7 @@
|
|||
function aggregate (data: ResultOf<typeof Schedule>, dayList: Array<{ date: Date, number: number }>) {
|
||||
// join media from all queries into single list, de-duplicate it, and make sure it's not dropped
|
||||
const mediaList = [...data.curr1?.media ?? [], ...data.curr2?.media ?? [], ...data.curr3?.media ?? [], ...data.residue?.media ?? [], ...data.next1?.media ?? [], ...data.next2?.media ?? []]
|
||||
.filter((v, i, a) => v != null && a.findIndex(s => s?.id === v.id) === i && v.mediaListEntry?.status !== 'DROPPED') as Array<ResultOf<typeof ScheduleMedia>>
|
||||
.filter((v, i, a) => v != null && a.findIndex(s => s?.id === v.id) === i && list(v) !== 'DROPPED') as Array<ResultOf<typeof ScheduleMedia>>
|
||||
|
||||
const dayMap: Record<string, DayAirTimes | undefined> = Object.fromEntries(dayList.map(day => [+day.date, { day, episodes: [] }]))
|
||||
|
||||
|
|
@ -73,6 +73,8 @@
|
|||
return Object.values(dayMap) as DayAirTimes[]
|
||||
}
|
||||
|
||||
// very stupid fix, for a very stupid bug
|
||||
const _list = list
|
||||
</script>
|
||||
|
||||
<div class='flex flex-col items-center w-full h-full overflow-y-auto px-5' use:dragScroll>
|
||||
|
|
@ -144,10 +146,11 @@
|
|||
</Drawer.Header>
|
||||
<Drawer.Footer>
|
||||
{#each episodes as episode, i (i)}
|
||||
{@const status = _list(episode)}
|
||||
<ButtonPrimitive.Root class={cn('flex items-center h-4 w-full group mt-1.5 px-3', +episode.airTime < Date.now() && 'opacity-30')} href='/app/anime/{episode.id}'>
|
||||
<div class='font-medium text-nowrap text-ellipsis overflow-hidden pr-2' title={episode.title?.userPreferred}>
|
||||
{#if episode.mediaListEntry?.status}
|
||||
<StatusDot variant={episode.mediaListEntry.status} class='hidden' />
|
||||
{#if status}
|
||||
<StatusDot variant={status} class='hidden' />
|
||||
{/if}
|
||||
{episode.title?.userPreferred}
|
||||
</div>
|
||||
|
|
@ -166,10 +169,11 @@
|
|||
{#if !$isMobile}
|
||||
<div class='mt-auto'>
|
||||
{#each episodes.length > 6 ? episodes.slice(0, 5) : episodes as episode, i (i)}
|
||||
{@const status = _list(episode)}
|
||||
<ButtonPrimitive.Root class={cn('flex items-center h-4 w-full group mt-1.5 px-3', +episode.airTime < Date.now() && 'opacity-30')} href='/app/anime/{episode.id}'>
|
||||
<div class='font-medium text-nowrap text-ellipsis overflow-hidden pr-2' title={episode.title?.userPreferred}>
|
||||
{#if episode.mediaListEntry?.status}
|
||||
<StatusDot variant={episode.mediaListEntry.status} class='hidden lg:inline-flex' />
|
||||
{#if status}
|
||||
<StatusDot variant={status} class='hidden lg:inline-flex' />
|
||||
{/if}
|
||||
{episode.title?.userPreferred}
|
||||
</div>
|
||||
|
|
@ -184,10 +188,11 @@
|
|||
</Tooltip.Trigger>
|
||||
<Tooltip.Content sameWidth={true} class='text-center gap-1.5'>
|
||||
{#each episodes.slice(5) as episode, i (i)}
|
||||
{@const status = _list(episode)}
|
||||
<ButtonPrimitive.Root class={cn('flex items-center h-4 w-full group', +episode.airTime < Date.now() && 'text-neutral-300')} href='/app/anime/{episode.id}'>
|
||||
<div class='font-medium text-nowrap text-ellipsis overflow-hidden pr-2' title={episode.title?.userPreferred}>
|
||||
{#if episode.mediaListEntry?.status}
|
||||
<StatusDot variant={episode.mediaListEntry.status} class='hidden lg:inline-flex' />
|
||||
{#if status}
|
||||
<StatusDot variant={status} class='hidden lg:inline-flex' />
|
||||
{/if}
|
||||
{episode.title?.userPreferred}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,21 +1,35 @@
|
|||
<script lang='ts'>
|
||||
import CloudOff from 'lucide-svelte/icons/cloud-off'
|
||||
import Folder from 'lucide-svelte/icons/folder'
|
||||
import MessagesSquare from 'lucide-svelte/icons/messages-square'
|
||||
|
||||
import Anilist from '$lib/components/icons/Anilist.svelte'
|
||||
import Kitsu from '$lib/components/icons/Kitsu.svelte'
|
||||
import * as Avatar from '$lib/components/ui/avatar'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import * as Dialog from '$lib/components/ui/dialog'
|
||||
import Input from '$lib/components/ui/input/input.svelte'
|
||||
import { Label } from '$lib/components/ui/label'
|
||||
import { Switch } from '$lib/components/ui/switch'
|
||||
import * as Tooltip from '$lib/components/ui/tooltip'
|
||||
import { client } from '$lib/modules/anilist'
|
||||
import { authAggregator } from '$lib/modules/auth'
|
||||
import ksclient from '$lib/modules/auth/kitsu'
|
||||
import native from '$lib/modules/native'
|
||||
import { click } from '$lib/modules/navigate'
|
||||
|
||||
const viewer = client.viewer
|
||||
const alviewer = client.viewer
|
||||
|
||||
$: anilist = $viewer
|
||||
$: anilist = $alviewer
|
||||
|
||||
const kitsuviewer = ksclient.viewer
|
||||
|
||||
$: kitsu = $kitsuviewer
|
||||
|
||||
const syncSettings = authAggregator.syncSettings
|
||||
|
||||
let kitsuLogin = ''
|
||||
let kitsuPassword = ''
|
||||
</script>
|
||||
|
||||
<div class='space-y-3 pb-10 lg:max-w-4xl'>
|
||||
|
|
@ -25,7 +39,7 @@
|
|||
{#if anilist?.viewer?.id}
|
||||
<div use:click={() => native.openURL(`https://anilist.co/user/${anilist.viewer?.name}`)} class='flex flex-row gap-3'>
|
||||
<Avatar.Root class='size-8 rounded-md'>
|
||||
<Avatar.Image src={anilist.viewer.avatar?.medium ?? ''} alt={anilist.viewer.name} />
|
||||
<Avatar.Image src={anilist.viewer.avatar?.large ?? ''} alt={anilist.viewer.name} />
|
||||
<Avatar.Fallback>{anilist.viewer.name}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div class='flex flex-col'>
|
||||
|
|
@ -40,7 +54,7 @@
|
|||
{:else}
|
||||
<div>Not logged in</div>
|
||||
{/if}
|
||||
<Anilist class='size-6 !ml-auto' />
|
||||
<Anilist class='size-6 ml-auto' />
|
||||
</div>
|
||||
<div class='bg-neutral-950 px-6 py-4 rounded-b-md flex justify-between'>
|
||||
{#if anilist?.viewer?.id}
|
||||
|
|
@ -48,9 +62,89 @@
|
|||
{:else}
|
||||
<Button variant='secondary' on:click={() => client.auth()}>Login</Button>
|
||||
{/if}
|
||||
<div class='flex items-center gap-4'>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<MessagesSquare size={16} class='text-muted-foreground' />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
Has Discussions
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<CloudOff size={16} class='text-muted-foreground' />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
Works Offline
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<div class='flex gap-2 items-center'>
|
||||
<Switch hideState={true} id='al-sync-switch' bind:checked={$syncSettings.al} />
|
||||
<Label for='al-sync-switch' class='cursor-pointer'>Enable Sync</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class='bg-neutral-900 px-6 py-4 rounded-t-md flex flex-row gap-3'>
|
||||
{#if kitsu?.id}
|
||||
<div use:click={() => native.openURL(`https://kitsu.app/users/${kitsu.name}`)} class='flex flex-row gap-3'>
|
||||
<Avatar.Root class='size-8 rounded-md'>
|
||||
<Avatar.Image src={kitsu.avatar?.large ?? ''} alt={kitsu.name} />
|
||||
<Avatar.Fallback>{kitsu.name}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div class='flex flex-col'>
|
||||
<div class='text-sm'>
|
||||
{kitsu.name}
|
||||
</div>
|
||||
<div class='text-[9px] text-muted-foreground leading-snug'>
|
||||
Kitsu
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div>Not logged in</div>
|
||||
{/if}
|
||||
<Kitsu class='size-6 !ml-auto' />
|
||||
</div>
|
||||
<div class='bg-neutral-950 px-6 py-4 rounded-b-md flex justify-between'>
|
||||
{#if kitsu?.id}
|
||||
<Button variant='secondary' on:click={() => ksclient.logout()}>Logout</Button>
|
||||
{:else}
|
||||
<Dialog.Root portal='#root'>
|
||||
<Dialog.Trigger let:builder asChild>
|
||||
<Button builders={[builder]} variant='secondary'>Login</Button>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header class='items-center'>
|
||||
<div class='space-y-4 px-4 sm:px-6 max-w-xl w-full'>
|
||||
<div class='font-weight-bold text-xl font-bold'>Kitsu Login</div>
|
||||
<div class='space-y-2'>
|
||||
<Label for='kitsu-login' class='leading-[unset] grow font-bold'>Login</Label>
|
||||
<Input type='text' id='kitsu-login' placeholder='email@website.com' autocomplete='off' bind:value={kitsuLogin} />
|
||||
</div>
|
||||
<div class='space-y-2'>
|
||||
<Label for='kitsu-password' class='leading-[unset] grow font-bold'>Password</Label>
|
||||
<Input type='password' id='kitsu-password' placeholder='**************' autocomplete='off' bind:value={kitsuPassword} />
|
||||
</div>
|
||||
<div class='text-sm text-muted-foreground'>
|
||||
Your password is not stored in the app, it is sent directly to Kitsu for authentication.
|
||||
</div>
|
||||
<div class='py-3 gap-3 mt-auto flex flex-col sm:flex-row-reverse'>
|
||||
<Button variant='secondary' on:click={() => ksclient.login(kitsuLogin, kitsuPassword)}>Login</Button>
|
||||
<Dialog.Close let:builder asChild>
|
||||
<Button variant='destructive' builders={[builder]}>Cancel</Button>
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Header>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
{/if}
|
||||
<div class='flex gap-2 items-center'>
|
||||
<Switch hideState={true} id='al-sync-switch' bind:checked={$syncSettings.al} />
|
||||
<Label for='al-sync-switch' class='cursor-pointer'>Enable Sync</Label>
|
||||
<Switch hideState={true} id='kitsu-sync-switch' bind:checked={$syncSettings.kitsu} />
|
||||
<Label for='kitsu-sync-switch' class='cursor-pointer'>Enable Sync</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -68,10 +162,18 @@
|
|||
</div>
|
||||
<Folder class='size-6 !ml-auto' fill='currentColor' />
|
||||
</div>
|
||||
<div class='bg-neutral-950 px-6 py-4 rounded-b-md flex justify-end h-[68px]'>
|
||||
<div class='bg-neutral-950 px-6 py-4 rounded-b-md flex justify-end h-[68px] gap-4'>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<CloudOff size={16} class='text-muted-foreground' />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
Works Offline
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<div class='flex gap-2 items-center'>
|
||||
<Switch hideState={true} id='al-sync-switch' bind:checked={$syncSettings.al} />
|
||||
<Label for='al-sync-switch' class='cursor-pointer'>Enable Sync</Label>
|
||||
<Switch hideState={true} id='local-sync-switch' bind:checked={$syncSettings.local} />
|
||||
<Label for='local-sync-switch' class='cursor-pointer'>Enable Sync</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue