mirror of
https://github.com/ThaUnknown/miru.git
synced 2026-04-21 10:51:57 +00:00
Some checks failed
Check / check (push) Has been cancelled
fix: improve airing schedule results
235 lines
12 KiB
Svelte
235 lines
12 KiB
Svelte
<script lang='ts'>
|
|
import { Button as ButtonPrimitive } from 'bits-ui'
|
|
import { addMonths, endOfMonth, endOfWeek, format, isSameMonth, isToday, startOfMonth, startOfWeek, subMonths } from 'date-fns'
|
|
import { persisted } from 'svelte-persisted-store'
|
|
import Cross2 from 'svelte-radix/Cross2.svelte'
|
|
|
|
import type { Schedule, ScheduleMedia } from '$lib/modules/anilist/queries'
|
|
import type { ResultOf } from 'gql.tada'
|
|
|
|
import StatusDot from '$lib/components/StatusDot.svelte'
|
|
import { ChevronLeft, ChevronRight } from '$lib/components/icons/animated'
|
|
import { Button } from '$lib/components/ui/button'
|
|
import * as Drawer from '$lib/components/ui/drawer'
|
|
import { Label } from '$lib/components/ui/label'
|
|
import { Switch } from '$lib/components/ui/switch'
|
|
import * as Tooltip from '$lib/components/ui/tooltip'
|
|
import { dedupeAiring } from '$lib/modules/anilist'
|
|
import { authAggregator, list } from '$lib/modules/auth'
|
|
import { dragScroll } from '$lib/modules/navigate'
|
|
import { cn, breakpoints } from '$lib/utils'
|
|
|
|
const onList = persisted('schedule-on-list', true)
|
|
|
|
$: query = authAggregator.schedule($onList || null)
|
|
|
|
let now = new Date()
|
|
$: monthName = now.toLocaleString('en-US', { month: 'long' })
|
|
|
|
$: firstDay = startOfWeek(startOfMonth(now), { weekStartsOn: 1 })
|
|
$: lastDay = endOfWeek(endOfMonth(now), { weekStartsOn: 1 })
|
|
function prevMonth () {
|
|
now = subMonths(now, 1)
|
|
}
|
|
function nextMonth () {
|
|
now = addMonths(now, 1)
|
|
}
|
|
|
|
function listDays (firstDay: Date, lastDay: Date) {
|
|
// create an array of days with start and end time for given day
|
|
const days = []
|
|
// eslint-disable-next-line no-unmodified-loop-condition
|
|
for (let start = new Date(firstDay); start <= lastDay; start.setDate(start.getDate() + 1)) {
|
|
days.push({ date: new Date(start), number: start.getDate() })
|
|
}
|
|
return days
|
|
}
|
|
|
|
$: dayList = listDays(firstDay, lastDay)
|
|
|
|
interface DayAirTimes { day: { date: Date, number: number }, episodes: Array<ResultOf<typeof ScheduleMedia> & { episode: number, airTime: Date }> }
|
|
|
|
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 && list(v) !== 'DROPPED') as Array<ResultOf<typeof ScheduleMedia>>
|
|
|
|
const dayMap: Record<string, DayAirTimes | undefined> = Object.fromEntries(dayList.map(day => [+day.date, { day, episodes: [] }]))
|
|
|
|
for (const media of mediaList) {
|
|
// dedupe airing lists
|
|
const episodes = dedupeAiring(media)
|
|
for (const { a: airingAt, e: episode } of episodes) {
|
|
const airTime = new Date(airingAt * 1000)
|
|
airTime.setHours(0, 0, 0, 0)
|
|
const day = dayMap[+airTime]
|
|
if (day) day.episodes.push({ ...media, episode, airTime: new Date(airingAt * 1000) })
|
|
}
|
|
}
|
|
|
|
for (const { episodes } of Object.values(dayMap)as DayAirTimes[]) {
|
|
episodes.sort((a, b) => +a.airTime - +b.airTime)
|
|
}
|
|
|
|
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 p-3 md:p-10 min-w-0' use:dragScroll>
|
|
<div class='space-y-0.5 self-start mb-6'>
|
|
<h2 class='text-2xl font-bold'>Airing Calendar</h2>
|
|
<p class='text-muted-foreground'>
|
|
View upcoming episodes and their air times for the current season.
|
|
</p>
|
|
</div>
|
|
<div class='grid grid-cols-7 border rounded-lg [&>*:not(:nth-child(7n+1)):nth-child(n+8)]:border-r [&>*:nth-last-child(n+8)]:border-b [&>*:nth-child(-n+8)]:border-b w-full max-w-[1800px]'>
|
|
<div class='col-span-full flex justify-between items-center p-4'>
|
|
<Button size='icon' on:click={prevMonth} variant='outline' class='bg-transparent animated-icon'>
|
|
<ChevronLeft class='h-6 w-6' />
|
|
</Button>
|
|
<div class='text-center font-bold text-xl'>
|
|
{monthName}
|
|
<div class='self-start flex items-center space-x-2 mt-1 text-muted-foreground'>
|
|
<Switch bind:checked={$onList} id='schedule-on-list' hideState={true} />
|
|
<Label for='schedule-on-list'>My list</Label>
|
|
</div>
|
|
</div>
|
|
<Button size='icon' on:click={nextMonth} variant='outline' class='bg-transparent animated-icon'>
|
|
<ChevronRight class='h-6 w-6' />
|
|
</Button>
|
|
</div>
|
|
<div class='text-center py-2'>Mon</div>
|
|
<div class='text-center py-2'>Tue</div>
|
|
<div class='text-center py-2'>Wed</div>
|
|
<div class='text-center py-2'>Thu</div>
|
|
<div class='text-center py-2'>Fri</div>
|
|
<div class='text-center py-2'>Sat</div>
|
|
<div class='text-center py-2'>Sun</div>
|
|
{#if $query.fetching}
|
|
{#each dayList as { date, number } (date)}
|
|
{@const sameMonth = isSameMonth(now, date)}
|
|
<div>
|
|
<div class='flex flex-col text-xs py-3 h-24 lg:h-48' class:opacity-30={!sameMonth}>
|
|
<div class={cn('w-6 h-6 flex items-center justify-center font-bold mx-3', isToday(date) && 'bg-[rgb(61,180,242)] rounded-full')}>
|
|
{number}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
{:else if $query.error}
|
|
<div class='p-5 flex items-center justify-center h-96 col-span-full'>
|
|
<div>
|
|
<div class='mb-1 font-bold text-4xl text-center '>
|
|
Ooops!
|
|
</div>
|
|
<div class='text-lg text-center text-muted-foreground'>
|
|
Looks like something went wrong!
|
|
</div>
|
|
<div class='text-lg text-center text-muted-foreground'>
|
|
{$query.error.message}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{:else if $query.data?.curr1?.media}
|
|
{#each aggregate($query.data, dayList) as { day, episodes } (day.date)}
|
|
{@const sameMonth = isSameMonth(now, day.date)}
|
|
<div>
|
|
<div class='flex flex-col text-xs py-3 h-24 lg:h-48' class:opacity-30={!sameMonth}>
|
|
{#if !$breakpoints.lg}
|
|
<Drawer.Root shouldScaleBackground portal='html'>
|
|
<Drawer.Trigger class='h-full flex flex-col'>
|
|
<div class={cn('w-6 h-6 flex items-center justify-center font-bold mx-3', isToday(day.date) && 'bg-[rgb(61,180,242)] rounded-full')}>
|
|
{day.number}
|
|
</div>
|
|
{#if episodes.length}
|
|
<div class='px-3 mt-auto text-ellipsis overflow-hidden text-nowrap w-full'>
|
|
{episodes.length} ep{episodes.length > 1 ? 's' : ''}
|
|
</div>
|
|
{/if}
|
|
</Drawer.Trigger>
|
|
<Drawer.Content tabindex={null}>
|
|
<Drawer.Header>
|
|
<Drawer.Close class='ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 transition-opacity select:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none'>
|
|
<Cross2 class='h-4 w-4' />
|
|
<span class='sr-only'>Close</span>
|
|
</Drawer.Close>
|
|
</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 status}
|
|
<StatusDot variant={status} class='hidden' />
|
|
{/if}
|
|
{episode.title?.userPreferred}
|
|
</div>
|
|
<div class='ml-auto mr-1 text-nowrap'>#{episode.episode}</div>
|
|
<div class='text-neutral-400 group-select:text-neutral-200'>{format(episode.airTime, 'HH:mm')}</div>
|
|
</ButtonPrimitive.Root>
|
|
{/each}
|
|
</Drawer.Footer>
|
|
</Drawer.Content>
|
|
</Drawer.Root>
|
|
{:else}
|
|
<div class={cn('w-6 h-6 flex items-center justify-center font-bold mx-3', isToday(day.date) && 'bg-[rgb(61,180,242)] rounded-full')}>
|
|
{day.number}
|
|
</div>
|
|
<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 status}
|
|
<StatusDot variant={status} class='hidden xl:inline-flex' />
|
|
{/if}
|
|
{episode.title?.userPreferred}
|
|
</div>
|
|
<div class='ml-auto mr-1 text-nowrap hidden xl:inline-flex'>#{episode.episode}</div>
|
|
<div class='text-neutral-400 group-select:text-neutral-200 ml-auto xl:ml-0'>{format(episode.airTime, 'HH:mm')}</div>
|
|
</ButtonPrimitive.Root>
|
|
{/each}
|
|
{#if episodes.length > 6}
|
|
<Tooltip.Root openDelay={100}>
|
|
<Tooltip.Trigger class='text-neutral-500 w-full text-left px-3 mt-1.5'>
|
|
+ {episodes.length - 5} more...
|
|
</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-400')} href='/app/anime/{episode.id}'>
|
|
<div class='font-medium text-nowrap text-ellipsis overflow-hidden pr-2' title={episode.title?.userPreferred}>
|
|
{#if status}
|
|
<StatusDot variant={status} class='hidden xl:inline-flex' />
|
|
{/if}
|
|
{episode.title?.userPreferred}
|
|
</div>
|
|
<div class='ml-auto mr-1 text-nowrap hidden xl:inline-flex'>#{episode.episode}</div>
|
|
<div class='text-neutral-400 group-select:text-neutral-900 ml-auto xl:ml-0'>{format(episode.airTime, 'HH:mm')}</div>
|
|
</ButtonPrimitive.Root>
|
|
{/each}
|
|
</Tooltip.Content>
|
|
</Tooltip.Root>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
{:else}
|
|
{#each dayList as { date, number } (date)}
|
|
{@const sameMonth = isSameMonth(now, date)}
|
|
<div>
|
|
<div class='flex flex-col text-xs py-3 h-48' class:opacity-30={!sameMonth}>
|
|
<div class={cn('w-6 h-6 flex items-center justify-center font-bold mx-3', isToday(date) && 'bg-[rgb(61,180,242)] rounded-full')}>
|
|
{number}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
</div>
|