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:
ThaUnknown 2025-06-06 18:53:43 +02:00
parent 1f82dc8fa9
commit 14720a07c6
No known key found for this signature in database
30 changed files with 864 additions and 264 deletions

28
.vscode/launch.json vendored Normal file
View 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
}
}
]
}

View file

@ -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",

View file

@ -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 () {

View file

@ -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>

View 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

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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'>

View file

@ -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>

View file

@ -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}

View file

@ -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' }

View file

@ -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>

View file

@ -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>

View file

@ -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}

View file

@ -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>) {

View file

@ -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}":

View file

@ -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 {

View file

@ -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

View file

@ -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}`)
}

View file

@ -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)
])
}
}()

View file

@ -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
}

View file

@ -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)
}
}
}()

View file

@ -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 }
})

View file

@ -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 {

View file

@ -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])
}

View file

@ -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>

View file

@ -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())

View file

@ -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>

View file

@ -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>