feat: colored anime pages, clickable genres, and meta

fix: better color contrast text calculation
fix: bad bg color for spoilers
This commit is contained in:
ThaUnknown 2025-07-29 13:04:12 +02:00
parent fde2528df1
commit 4206bef69b
No known key found for this signature in database
14 changed files with 104 additions and 76 deletions

View file

@ -1,6 +1,6 @@
{
"name": "ui",
"version": "6.4.93",
"version": "6.4.94",
"license": "BUSL-1.1",
"private": true,
"packageManager": "pnpm@9.15.5",

View file

@ -48,6 +48,17 @@
}
}
.text-contrast {
--accessible-color: calc(((((var(--red) * 299) + (var(--green) * 587) + (var(--blue) * 114)) / 1000) - 128) * -1000);
color: rgb(var(--accessible-color),
var(--accessible-color),
var(--accessible-color));
fill: rgb(var(--accessible-color),
var(--accessible-color),
var(--accessible-color));
}
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
@ -79,6 +90,8 @@
--ring: 240 10% 3.9%;
--radius: 0.5rem;
--custom: #fff;
}
.dark {
@ -110,6 +123,8 @@
--destructive-foreground: 0 0% 98%;
--ring: 240 4.9% 83.9%;
--custom: #fff;
}
}
@ -246,7 +261,7 @@ details,
align-items: center;
}
.text-contrast {
.text-contrast-filter {
filter: invert(1) grayscale(1) brightness(1.2) contrast(9000);
mix-blend-mode: luminosity;
-webkit-font-smoothing: antialiased;

View file

@ -37,7 +37,7 @@
<Dialog.Root portal='#root'>
<Dialog.Trigger let:builder asChild>
<Button size='icon' class='rounded-l-none bg-primary/85 select:bg-primary/75 shrink-0' builders={[builder]}>
<Button size='icon' class='rounded-l-none bg-custom-400 select:!bg-custom-700 shrink-0 text-contrast' builders={[builder]}>
<PencilLine class='size-4' />
</Button>
</Dialog.Trigger>

View file

@ -57,7 +57,7 @@
<div use:click={() => play(episode)}
class={cn(
'select:scale-[1.05] select:shadow-lg scale-100 transition-[transform,box-shadow] duration-200 shrink-0 ease-out focus-visible:ring-ring focus-visible:ring-1 rounded-md bg-neutral-950 text-secondary-foreground select:bg-neutral-900 flex w-full max-h-28 cursor-pointer relative overflow-hidden group',
target && 'ring-ring ring-1',
target && 'ring-custom ring-1',
filler && '!ring-yellow-400 ring-1'
)}>
{#if image}

View file

@ -26,7 +26,7 @@
font-weight: bold;
cursor: pointer;
list-style: none;
background: #0f0f0f;
background: #0003;
display: inline-block;
padding: 0.4em 0.8em;
border-radius: 0.5em;

View file

@ -40,10 +40,10 @@
{/if}
</div>
<div class='w-full px-4 bg-neutral-950'>
<div class='text-lg font-bold truncate inline-block w-full text-white' title={title(media)}>
<div class='text-lg font-bold truncate inline-block w-full text-white pt-2' title={title(media)}>
{title(media)}
</div>
<div class='flex flex-row pt-1'>
<div class='flex flex-row'>
<PlayButton {media} class='grow' />
<FavoriteButton {media} class='ml-2' />
<BookmarkButton {media} class='ml-2' />

View file

@ -315,16 +315,13 @@
}
let completed = false
function checkCompletion () {
async function checkCompletion () {
if (!completed && $settings.playerAutocomplete) {
checkCompletionByTime(currentTime, safeduration)
}
}
function checkCompletionByTime (currentTime: number, safeduration: number) {
const fromend = Math.max(180, safeduration / 10)
if (safeduration && currentTime && readyState && safeduration - fromend < currentTime) {
authAggregator.watch(mediaInfo.media, mediaInfo.episode)
completed = true
const fromend = Math.max(180, safeduration / 10)
if (safeduration && currentTime && readyState && safeduration - fromend < currentTime) {
authAggregator.watch(mediaInfo.media, mediaInfo.episode)
completed = true
}
}
}

View file

@ -55,7 +55,7 @@
{#if bubble && bubble !== 'Donator'}
<div class='-left-5 -top-11 absolute text-sm'>
<div class='px-4 py-2 rounded-2xl bg-mix bubbles relative leading-tight'>
<span class='text-contrast'>
<span class='text-contrast-filter'>
{bubble}
</span>
</div>

View file

@ -71,7 +71,7 @@
Episodes {entry.episodes}
</div>
{#if entry.videos?.length}
<Button size='icon-sm' class='ml-auto font-bold rounded-full' on:click={() => playVideo(url)}><Play fill='currentColor' size={iconSizes['icon-sm']} /></Button>
<Button size='icon-sm' class='ml-auto font-bold rounded-full bg-custom select:!bg-custom-600 text-contrast' on:click={() => playVideo(url)}><Play fill='currentColor' size={iconSizes['icon-sm']} /></Button>
{/if}
</div>
{#if src === url}

View file

@ -115,12 +115,15 @@ export default new class AuthAggregator {
return null
})
watch (media: Media, progress: number) {
async watch (outdated: Media, progress: number) {
const media = (await client.single(outdated.id)).data?.Media ?? outdated
// TODO: auto re-watch status
const totalEps = episodes(media) ?? 1 // episodes or movie which is single episode
if (totalEps < progress) return // woah, bad data from resolver?!
const currentProgress = media.mediaListEntry?.progress ?? 0
const mediaList = this.mediaListEntry(media)
const currentProgress = mediaList?.progress ?? 0
if (currentProgress >= progress) return
// there's an edge case here that episodes returns 1, because anilist doesn't have episode count for an airing show without an expected end date
@ -130,11 +133,11 @@ export default new class AuthAggregator {
const status =
totalEps === progress && canBeCompleted
? 'COMPLETED'
: media.mediaListEntry?.status === 'REPEATING' ? 'REPEATING' : 'CURRENT'
: mediaList?.status === 'REPEATING' ? 'REPEATING' : 'CURRENT'
const lists = (media.mediaListEntry?.customLists as Array<{enabled: boolean, name: string}> | undefined)?.filter(({ enabled }) => enabled).map(({ name }) => name) ?? []
const lists = (mediaList?.customLists as Array<{enabled: boolean, name: string}> | undefined)?.filter(({ enabled }) => enabled).map(({ name }) => name) ?? []
this.entry({ id: media.id, progress, status, lists })
return await this.entry({ id: media.id, progress, status, lists })
}
delete (media: Media) {

View file

@ -308,3 +308,11 @@ export function arrayEqual <T> (a: T[], b: T[]) {
export function nextTick () {
return new Promise<void>(resolve => queueMicrotask(resolve))
}
export function colors (hex = '#ffffff') {
const bigint = parseInt(hex.slice(1), 16)
const r = (bigint >> 16) & 255
const g = (bigint >> 8) & 255
const b = bigint & 255
return { r, g, b }
}

View file

@ -1,5 +1,5 @@
<script lang='ts'>
import Bell from 'lucide-svelte/icons/bell'
// import Bell from 'lucide-svelte/icons/bell'
import Clapperboard from 'lucide-svelte/icons/clapperboard'
import Maximize2 from 'lucide-svelte/icons/maximize-2'
import Share2 from 'lucide-svelte/icons/share-2'
@ -9,7 +9,7 @@
import type { LayoutData } from './$types'
import { onNavigate } from '$app/navigation'
import { goto, onNavigate } from '$app/navigation'
import Anilist from '$lib/components/icons/Anilist.svelte'
import MyAnimeList from '$lib/components/icons/MyAnimeList.svelte'
import { bannerSrc, hideBanner } from '$lib/components/ui/banner'
@ -22,6 +22,7 @@
import { authAggregator, of } from '$lib/modules/auth'
import native from '$lib/modules/native'
import { dragScroll } from '$lib/modules/navigate'
import { colors } from '$lib/utils'
export let data: LayoutData
@ -51,9 +52,9 @@
}
function getColorForRating (rating: number) {
if (rating >= 75) return 'bg-green-700'
if (rating >= 65) return 'bg-orange-400'
return 'bg-red-400'
if (rating >= 75) return 'bg-green-700 select:bg-green-800'
if (rating >= 65) return 'bg-orange-400 select:bg-orange-500'
return 'bg-red-400 select:bg-red-500'
}
$: mediaId = media.id
@ -71,9 +72,11 @@
behavior: 'smooth'
})
})
$: ({ r, g, b } = colors(media.coverImage?.color ?? undefined))
</script>
<div class='min-w-0 -ml-14 pl-14 grow items-center flex flex-col h-full overflow-y-auto z-10 pointer-events-none pb-10' use:dragScroll on:scroll={handleScroll} bind:this={container}>
<div class='min-w-0 -ml-14 pl-14 grow items-center flex flex-col h-full overflow-y-auto z-10 pointer-events-none pb-10' use:dragScroll on:scroll={handleScroll} bind:this={container} style:--custom={media.coverImage?.color ?? '#fff'} style:--red={r} style:--green={g} style:--blue={b}>
<div class='gap-6 w-full pt-4 md:pt-32 flex flex-col items-center justify-center max-w-[1600px] px-3 xl:px-14 pointer-events-auto'>
<div class='flex flex-col md:flex-row w-full items-center md:items-end gap-5 pt-12'>
<Dialog.Root portal='#root'>
@ -92,34 +95,22 @@
<h2 class='line-clamp-1 text-base md:text-lg font-light text-muted-foreground select-text'>{media.title?.romaji?.toLowerCase().trim() === title(media).toLowerCase().trim() ? nativeTitle : romajiTitle}</h2>
<h1 class='font-black text-3xl md:text-4xl line-clamp-2 text-white select-text'>{title(media)}</h1>
<div class='flex-wrap w-full justify-start md:pt-1 gap-4 hidden md:flex'>
<div class='rounded px-3.5 font-bold' style:background={media.coverImage?.color ?? '#27272a'}>
<div class='text-contrast'>
{of(media) ?? duration(media) ?? 'N/A'}
</div>
</div>
<div class='rounded px-3.5 font-bold' style:background={media.coverImage?.color ?? '#27272a'}>
<div class='text-contrast'>
{format(media)}
</div>
</div>
<div class='rounded px-3.5 font-bold' style:background={media.coverImage?.color ?? '#27272a'}>
<div class='text-contrast'>
{status(media)}
</div>
<div class='rounded px-3.5 font-bold bg-custom text-contrast'>
{of(media) ?? duration(media) ?? 'N/A'}
</div>
<Button class='rounded px-3.5 font-bold bg-custom select:!bg-custom-600 text-contrast h-6 py-0 text-base' on:click={() => goto('/app/search', { state: { search: { format: [media.format] } } })}>
{format(media)} </Button>
<Button class='rounded px-3.5 font-bold bg-custom select:!bg-custom-600 text-contrast h-6 py-0 text-base' on:click={() => goto('/app/search', { state: { search: { status: [media.status] } } })}>
{status(media)} </Button>
{#if season(media)}
<div class='rounded px-3.5 font-bold' style:background={media.coverImage?.color ?? '#27272a'}>
<div class='text-contrast capitalize'>
{season(media)}
</div>
</div>
<Button class='rounded px-3.5 font-bold bg-custom select:!bg-custom-600 text-contrast h-6 py-0 text-base capitalize' on:click={() => goto('/app/search', { state: { search: { season: media.season, seasonYear: media.seasonYear } } })}>
{season(media)}
</Button>
{/if}
{#if media.averageScore}
<div class='rounded px-3.5 font-bold {getColorForRating(media.averageScore)}'>
<div class='text-contrast'>
{media.averageScore}%
</div>
</div>
<Button class='rounded px-3.5 font-bold text-contrast h-6 py-0 text-base {getColorForRating(media.averageScore)}' on:click={() => goto('/app/search', { state: { search: { sort: ['SCORE_DESC'] } } })}>
{media.averageScore}%
</Button>
{/if}
</div>
<div class='md:block hidden relative pb-6 md:pt-2 md:pb-0'>
@ -130,26 +121,18 @@
</div>
<div class='flex gap-2 items-center justify-center md:justify-start md:self-start w-full overflow-x-clip [&>*]:flex-shrink-0'>
<div class='flex md:mr-3 w-full min-[380px]:w-[180px]'>
<PlayButton size='default' {media} class='rounded-r-none w-full' />
<PlayButton size='default' {media} class='rounded-r-none w-full bg-custom select:!bg-custom-600 text-contrast' />
<EntryEditor {media} />
</div>
<FavoriteButton {media} variant='secondary' size='icon' class='min-[380px]:-order-1 md:order-none' />
<BookmarkButton {media} variant='secondary' size='icon' class='min-[380px]:-order-2 md:order-none' />
<Button size='icon' variant='secondary' on:click={share}>
<FavoriteButton {media} variant='secondary' size='icon' class='min-[380px]:-order-1 md:order-none select:!text-custom' />
<BookmarkButton {media} variant='secondary' size='icon' class='min-[380px]:-order-2 md:order-none select:!text-custom' />
<Button size='icon' variant='secondary' on:click={share} class='select:!text-custom'>
<Share2 class='size-4' />
</Button>
<Button size='icon' variant='secondary' class='hidden md:flex' on:click={() => native.openURL(`https://anilist.co/anime/${media.id}`)}>
<Anilist class='size-4' />
</Button>
{#if media.idMal}
<Button size='icon' variant='secondary' class='hidden md:flex' on:click={() => native.openURL(`https://myanimelist.net/anime/${media.idMal}`)}>
<MyAnimeList class='size-4 flex-center' />
</Button>
{/if}
{#if media.trailer?.id}
<Dialog.Root portal='#root'>
<Dialog.Trigger let:builder asChild>
<Button size='icon' variant='secondary' class='hidden md:flex' builders={[builder]}>
<Button size='icon' variant='secondary' class='select:!text-custom' builders={[builder]}>
<Clapperboard class='size-4' />
</Button>
</Dialog.Trigger>
@ -158,9 +141,17 @@
</Dialog.Content>
</Dialog.Root>
{/if}
<Button size='icon' variant='secondary' disabled>
<Bell class='size-4' />
<Button size='icon' variant='secondary' class='hidden md:flex' on:click={() => native.openURL(`https://anilist.co/anime/${media.id}`)}>
<Anilist class='size-4' />
</Button>
{#if media.idMal}
<Button size='icon' variant='secondary' class='hidden md:flex' on:click={() => native.openURL(`https://myanimelist.net/anime/${media.idMal}`)}>
<MyAnimeList class='size-4 flex-center' />
</Button>
{/if}
<!-- <Button size='icon' variant='secondary' disabled>
<Bell class='size-4' />
</Button> -->
<div class='-space-x-1 md:ml-3 hidden md:flex'>
{#each followerEntries as followerEntry, i (followerEntry?.user?.id ?? i)}
{#if followerEntry?.user}
@ -169,13 +160,13 @@
{/each}
</div>
</div>
<!-- <div class='flex gap-2 items-center md:justify-start md:self-start flex-wrap'>
<div class='flex gap-2 items-center md:justify-start md:self-start flex-wrap'>
{#each media.genres ?? [] as genre (genre)}
<div class='bg-secondary text-secondary-foreground text-sm font-medium rounded-md h-9 items-center justify-center flex px-4 text-nowrap'>
<Button variant='secondary' class='select:!text-custom h-7 text-nowrap' on:click={() => goto('/app/search', { state: { search: { genre: [genre] } } })}>
{genre}
</div>
</Button>
{/each}
</div> -->
</div>
<slot />
</div>
</div>

View file

@ -50,7 +50,7 @@
<Load src={media.coverImage?.medium} class='object-cover h-full w-full shrink-0 rounded-l-md' />
</div>
<div class='h-full grid px-3 items-center'>
<div class='text-theme font-bold capitalize'>{relation(rel.relationType)}</div>
<div class='text-custom font-bold capitalize'>{relation(rel.relationType)}</div>
<div class='line-clamp-2'>{media.title?.userPreferred ?? 'N/A'}</div>
<div class='font-thin'>{format(media)}</div>
</div>
@ -63,9 +63,9 @@
<Tabs.Root bind:value class='w-full' activateOnFocus={false}>
<div class='flex justify-between items-center gap-3 sm:flex-row flex-col'>
<Tabs.List class='flex'>
<Tabs.Trigger value='episodes' tabindex={0} class='px-8 data-[state=active]:font-bold'>Episodes</Tabs.Trigger>
<Tabs.Trigger value='threads' tabindex={0} class='px-8 data-[state=active]:font-bold'>Threads</Tabs.Trigger>
<Tabs.Trigger value='themes' tabindex={0} class='px-8 data-[state=active]:font-bold'>Themes</Tabs.Trigger>
<Tabs.Trigger value='episodes' tabindex={0} class='px-8 data-[state=active]:bg-custom data-[state=active]:text-contrast data-[state=active]:font-bold'>Episodes</Tabs.Trigger>
<Tabs.Trigger value='threads' tabindex={0} class='px-8 data-[state=active]:bg-custom data-[state=active]:text-contrast data-[state=active]:font-bold'>Threads</Tabs.Trigger>
<Tabs.Trigger value='themes' tabindex={0} class='px-8 data-[state=active]:bg-custom data-[state=active]:text-contrast data-[state=active]:font-bold'>Themes</Tabs.Trigger>
</Tabs.List>
</div>
<Tabs.Content value='episodes' tabindex={-1}>

View file

@ -58,6 +58,20 @@ const config: Config = {
},
extend: {
colors: {
custom: {
DEFAULT: 'hsl(from var(--custom) h s l / <alpha-value>)',
50: 'hsl(from var(--custom) h s 95% / <alpha-value>)',
100: 'hsl(from var(--custom) h s 90% / <alpha-value>)',
200: 'hsl(from var(--custom) h s 80% / <alpha-value>)',
300: 'hsl(from var(--custom) h s 70% / <alpha-value>)',
400: 'hsl(from var(--custom) h s 60% / <alpha-value>)',
500: 'hsl(from var(--custom) h s l / <alpha-value>)',
600: 'hsl(from var(--custom) h s 40% / <alpha-value>)',
700: 'hsl(from var(--custom) h s 30% / <alpha-value>)',
800: 'hsl(from var(--custom) h s 20% / <alpha-value>)',
900: 'hsl(from var(--custom) h s 10% / <alpha-value>)',
950: 'hsl(from var(--custom) h s 5% / <alpha-value>)'
},
theme: 'hsl(346.6deg 79.12% 51.18% / <alpha-value>)',
border: 'hsl(var(--border) / <alpha-value>)',
input: 'hsl(var(--input) / <alpha-value>)',