feat: read-only forums

This commit is contained in:
ThaUnknown 2025-04-25 03:14:09 +02:00
parent 5dbe5821dc
commit 0e6270ac88
No known key found for this signature in database
16 changed files with 696 additions and 181 deletions

View file

@ -56,6 +56,9 @@ importers:
debug:
specifier: ^4.3.7
version: 4.3.7
dompurify:
specifier: ^3.2.5
version: 3.2.5
events:
specifier: ^3.3.0
version: 3.3.0
@ -670,6 +673,9 @@ packages:
'@types/semver@7.7.0':
resolution: {integrity: sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
'@typescript-eslint/eslint-plugin@8.18.0':
resolution: {integrity: sha512-NR2yS7qUqCL7AIxdJUQf2MKKNDVNaig/dEB0GBLU7D+ZdHgK1NoH/3wsgO3OnPVipn51tG3MAwaODEGil70WEw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@ -1135,6 +1141,9 @@ packages:
resolution: {integrity: sha512-raigMkn7CJNNo6Ihro1fzG7wr3fHuYVytzquZKX5n0yizGsTcYgzdIUwj1X9pK0VvjeihV+XiclP+DjwbsSKug==}
deprecated: Use your platform's native DOMException instead
dompurify@3.2.5:
resolution: {integrity: sha512-mLPd29uoRe9HpvwP2TxClGQBzGXeEC/we/q+bFlmPPmj2p2Ugl3r6ATu/UU1v77DXNcehiBg9zsr1dREyA/dJQ==}
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
@ -2961,6 +2970,9 @@ snapshots:
'@types/semver@7.7.0': {}
'@types/trusted-types@2.0.7':
optional: true
'@typescript-eslint/eslint-plugin@8.18.0(@typescript-eslint/parser@8.18.0(eslint@9.17.0(jiti@1.21.6))(typescript@5.7.2))(eslint@9.17.0(jiti@1.21.6))(typescript@5.7.2)':
dependencies:
'@eslint-community/regexpp': 4.12.1
@ -3514,6 +3526,10 @@ snapshots:
webidl-conversions: 4.0.2
optional: true
dompurify@3.2.5:
optionalDependencies:
'@types/trusted-types': 2.0.7
eastasianwidth@0.2.0: {}
electron-to-chromium@1.5.36: {}

View file

@ -8,7 +8,7 @@
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover" class="bg-transparent relative" data-vaul-drawer-wrapper>
<body data-sveltekit-preload-data="off" class="bg-transparent relative" data-vaul-drawer-wrapper>
%sveltekit.body%
</body>

View file

@ -7,8 +7,7 @@
</script>
<script lang='ts'>
import { ChevronLeft, Play } from 'lucide-svelte'
import { ChevronRight } from 'svelte-radix'
import { ChevronLeft, Play, ChevronRight } from 'lucide-svelte'
import Pagination from './Pagination.svelte'
import { Button } from './ui/button'
@ -82,7 +81,7 @@
{@const target = _progress + 1 === episode}
<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 pointer relative overflow-hidden group',
'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',
filler && '!ring-yellow-400 ring-1'
)}>

View file

@ -0,0 +1,15 @@
<script lang='ts'>
import dompurify from 'dompurify'
export let html = ''
let root: ShadowRoot | undefined
function shadow (node: HTMLDivElement, html: string) {
root ??= node.attachShadow({ mode: 'closed' })
root.innerHTML = dompurify.sanitize(html)
}
let className: string | undefined | null
export { className as class }
</script>
<div use:shadow={html} class={className} />

View file

@ -0,0 +1,49 @@
<script lang='ts'>
import { Heart } from 'lucide-svelte'
import Shadow from '../../Shadow.svelte'
import type { ResultOf } from 'gql.tada'
import type { CommentFrag } from '$lib/modules/anilist/queries'
import * as Avatar from '$lib/components/ui/avatar'
import { since } from '$lib/utils'
export let comment: ResultOf<typeof CommentFrag>
export let depth = 0
let childComments: Array<ResultOf<typeof CommentFrag>>
$: childComments = comment.childComments as Array<ResultOf<typeof CommentFrag>> | null ?? []
</script>
<div class='rounded-md {depth % 2 === 1 ? 'bg-black' : 'bg-neutral-950'} text-secondary-foreground flex w-full py-6 px-8 flex-col'>
<div class='flex w-full justify-between text-xl'>
<div class='font-bold mb-2 line-clamp-1 flex leading-none items-center'>
<Avatar.Root class='inline-block size-8 mr-4'>
<Avatar.Image src={comment.user?.avatar?.medium ?? ''} alt={comment.user?.name ?? 'N/A'} />
<Avatar.Fallback>{comment.user?.name ?? 'N/A'}</Avatar.Fallback>
</Avatar.Root>
{comment.user?.name ?? 'N/A'}
</div>
<div class='flex ml-2 text-[12.8px] leading-none mt-0.5'>
<Heart size='12' class='mr-1' />
{comment.likeCount}
</div>
</div>
<Shadow html={comment.comment ?? ''} class='my-3 text-muted-foreground leading-relaxed [&_*]:flex [&_*]:flex-col [&_br]:hidden w-full overflow-clip' />
{#each childComments as comment (comment.id)}
{#if comment}
<div class='py-2'>
<svelte:self {comment} depth={depth + 1} />
</div>
{/if}
{/each}
<div class='flex w-full justify-between mt-auto text-[9.6px]'>
<div class='pt-2 flex items-end'>
{since(new Date(comment.createdAt * 1000))}
</div>
<div class='ml-auto inline-flex flex-wrap gap-2 items-end'>
Reply
</div>
</div>
</div>

View file

@ -0,0 +1,135 @@
<script lang='ts'>
import { ChevronLeft, ChevronRight, Eye, Heart, MessagesSquare } from 'lucide-svelte'
import Pagination from '../../Pagination.svelte'
import { Button } from '../button'
import { client, type Media } from '$lib/modules/anilist'
import { isMobile, since } from '$lib/utils'
import { dragScroll } from '$lib/modules/navigate'
import * as Tooltip from '$lib/components/ui/tooltip'
import * as Avatar from '$lib/components/ui/avatar'
export let media: Media
let threads = [client.threads(media.id)]
function getPage (page: number): ReturnType<typeof client.threads> {
const index = page - 1
const slice = threads[index]
if (slice) return slice
const missing = client.threads(media.id, page)
threads[index] = missing
// eslint-disable-next-line no-self-assign
threads = threads
return missing
}
let currentPage = 1
$: currentPageStore = getPage(currentPage)
$: hasNextPage = $currentPageStore.data?.Page?.pageInfo?.hasNextPage ?? false
const perPage = 16
$: count = (threads.length + Number(hasNextPage)) * perPage
</script>
<Pagination {count} {perPage} bind:currentPage let:pages let:hasNext let:hasPrev let:range let:setPage siblingCount={1}>
<div class='overflow-y-auto pt-3 -mx-14 px-14 pointer-events-none -mb-3 pb-3' use:dragScroll>
<div class='grid grid-cols-1 sm:grid-cols-[repeat(auto-fit,minmax(500px,1fr))] place-items-center gap-x-10 gap-y-7 justify-center align-middle pointer-events-auto'>
{#if $currentPageStore.fetching}
Loading threads...
{:else if $currentPageStore.error}
<div class='p-5 flex items-center justify-center w-full h-80'>
<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'>
{$currentPageStore.error.message}
</div>
</div>
</div>
{:else}
{#each $currentPageStore.data?.Page?.threads ?? [] as thread, i (thread?.id ?? i)}
{#if thread}
<a href='./thread/{thread.id}' class= '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 relative overflow-hidden cursor-pointer'>
<div class='flex-grow py-3 px-4 flex flex-col'>
<div class='flex w-full justify-between text-[12.8px]'>
<div class='font-bold mb-2 line-clamp-1'>
{thread.title ?? 'Thread ' + (thread.id)}
</div>
<div class='flex ml-2 leading-none mt-0.5'>
<Heart size='12' class='mr-1' />
{thread.likeCount}
<Eye size='12' class='mr-1 ml-2' />
{thread.viewCount ?? 0}
<MessagesSquare size='12' class='mr-1 ml-2' />
{thread.replyCount ?? 0}
</div>
</div>
<div class='flex w-full justify-between mt-auto text-[9.6px]'>
<div class='pt-2 flex items-end'>
<Tooltip.Root>
<Tooltip.Trigger class='inline-block size-4 cursor-default mr-2'>
<Avatar.Root class='inline-block size-4'>
<Avatar.Image src={thread.user?.avatar?.medium ?? ''} alt={thread.user?.name ?? 'N/A'} />
<Avatar.Fallback>{thread.user?.name ?? 'N/A'}</Avatar.Fallback>
</Avatar.Root>
</Tooltip.Trigger>
<Tooltip.Content>
<p class='font-extrabold'>{thread.user?.name}</p>
</Tooltip.Content>
</Tooltip.Root>
{since(new Date(thread.createdAt * 1000))}
</div>
<div class='ml-auto inline-flex flex-wrap gap-2 items-end'>
{#each thread.categories?.filter(category => category?.name !== 'Anime') ?? [] as category, i (category?.id ?? i)}
<div class='rounded px-3 py-0.5 font-bold flex items-center' style:background={media.coverImage?.color ?? '#27272a'}>
<div class='text-contrast'>
{category?.name}
</div>
</div>
{/each}
</div>
</div>
</div>
</a>
{/if}
{/each}
{/if}
</div>
</div>
<div class='flex flex-row items-center justify-between w-full py-3'>
<p class='text-center text-[13px] text-muted-foreground hidden md:block'>
Showing <span class='font-bold'>{range.start + 1}</span> to <span class='font-bold'>{range.end}</span> of <span class='font-bold'>{count}</span> threads
</p>
<div class='w-full md:w-auto gap-2 flex items-center'>
<Button size='icon' variant='ghost' on:click={() => setPage(currentPage - 1)} disabled={!hasPrev}>
<ChevronLeft class='h-4 w-4' />
</Button>
{#if !$isMobile}
{#each pages as { page, type } (page)}
{#if type === 'ellipsis'}
<span class='h-9 w-9 text-center'>...</span>
{:else}
<Button size='icon' variant={page === currentPage ? 'outline' : 'ghost'} on:click={() => setPage(page)}>
{page}
</Button>
{/if}
{/each}
{:else}
<p class='text-center text-[13px] text-muted-foreground w-full block md:hidden'>
Showing <span class='font-bold'>{range.start + 1}</span> to <span class='font-bold'>{range.end}</span> of <span class='font-bold'>{count}</span> threads
</p>
{/if}
<Button size='icon' variant='ghost' on:click={() => setPage(currentPage + 1)} disabled={!hasNext}>
<ChevronRight class='h-4 w-4' />
</Button>
</div>
</div>
</Pagination>

View file

@ -0,0 +1,2 @@
export { default as Comment } from './Comment.svelte'
export { default as Threads } from './Threads.svelte'

View file

@ -9,12 +9,12 @@ import { derived } from 'svelte/store'
import lavenshtein from 'js-levenshtein'
import schema from './schema.json' with { type: 'json' }
import { CustomLists, DeleteEntry, Entry, Following, FullMedia, FullMediaList, IDMedia, Schedule, Search, ToggleFavourite, UserLists, Viewer } from './queries'
import { CommentFrag, Comments, CustomLists, DeleteEntry, Entry, Following, FullMedia, FullMediaList, IDMedia, Schedule, Search, ThreadFrag, Threads, ToggleFavourite, UserLists, Viewer } from './queries'
import { currentSeason, currentYear, lastSeason, lastYear, nextSeason, nextYear } from './util'
import gql from './gql'
import type { ResultOf, VariablesOf } from 'gql.tada'
import type { AnyVariables, RequestPolicy, TypedDocumentNode } from 'urql'
import type { AnyVariables, OperationContext, RequestPolicy, TypedDocumentNode } from 'urql'
import type { Media } from './types'
import { safeLocalStorage, sleep } from '$lib/utils'
@ -36,13 +36,6 @@ class FetchError extends Error {
interface ViewerData { viewer: ResultOf<typeof Viewer>['Viewer'], token: string, expires: string }
function deferred () {
let resolve: () => void
const promise = new Promise<void>(_resolve => { resolve = _resolve })
// @ts-expect-error resolve is always defined
return { resolve, promise }
}
function getDistanceFromTitle (media: Media & {lavenshtein?: number}, name: string) {
const titles = Object.values(media.title ?? {}).filter(v => v).map(title => lavenshtein(title!.toLowerCase(), name.toLowerCase()))
const synonyms = (media.synonyms ?? []).filter(v => v).map(title => lavenshtein(title!.toLowerCase(), name.toLowerCase()) + 2)
@ -53,7 +46,8 @@ function getDistanceFromTitle (media: Media & {lavenshtein?: number}, name: stri
}
class AnilistClient {
storagePromise = deferred()
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
storagePromise = Promise.withResolvers<void>()
storage = makeDefaultStorage({
idbName: 'graphcache-v3',
onCacheHydrated: () => this.storagePromise.resolve(),
@ -137,7 +131,8 @@ class AnilistClient {
},
resolvers: {
Query: {
Media: (parent, { id }) => ({ __typename: 'Media', id })
Media: (parent, { id }) => ({ __typename: 'Media', id }),
Thread: (parent, { id }) => ({ __typename: 'Thread', id })
}
},
optimistic: {
@ -178,6 +173,21 @@ class AnilistClient {
media,
__typename: 'MediaList'
}
},
ToggleLikeV2 ({ id, type }, cache, info) {
const threadOrCommentId = id as number
const likable = type as 'THREAD' | 'THREAD_COMMENT' | 'ACTIVITY' | 'ACTIVITY_REPLY'
// @ts-expect-error idk whats wrong here but it works correctly
const likableUnion = cache.readFragment(likable === 'THREAD' ? ThreadFrag : CommentFrag, { id: threadOrCommentId })
if (!likableUnion) return {}
return {
id: threadOrCommentId,
isLiked: !likableUnion.isLiked,
__typename: likable === 'THREAD' ? 'Thread' : 'ThreadComment'
}
}
},
keys: {
@ -405,8 +415,16 @@ class AnilistClient {
return await this.client.query(IDMedia, { id }, { requestPolicy })
}
following (id: number) {
return queryStore({ client: this.client, query: Following, variables: { id } })
following (animeID: number) {
return queryStore({ client: this.client, query: Following, variables: { id: animeID } })
}
threads (animeID: number, page = 1) {
return queryStore({ client: this.client, query: Threads, variables: { id: animeID, page, perPage: 16 } })
}
comments (threadId: number, page = 1) {
return queryStore({ client: this.client, query: Comments, variables: { threadId, page } })
}
}
@ -419,11 +437,11 @@ await client.storagePromise?.promise
export default client
export function asyncStore<Result, Variables = AnyVariables> (query: TypedDocumentNode<Result, Variables>, variables: AnyVariables): Promise<Writable<Result>> {
export function asyncStore<Result, Variables = AnyVariables> (query: TypedDocumentNode<Result, Variables>, variables: AnyVariables, context?: Partial<OperationContext>): Promise<Writable<Result>> {
return new Promise((resolve, reject) => {
const store = writable<Result>(undefined, () => () => subscription.unsubscribe())
const subscription = client.client.query(query, variables).subscribe(value => {
const subscription = client.client.query(query, variables, context).subscribe(value => {
if (value.error) {
reject(value.error)
} else if (value.data) {

View file

@ -32,6 +32,20 @@ declare module 'gql.tada' {
TadaDocumentNode<{ DeleteMediaListEntry: { deleted: boolean | null; } | null; }, { id: number; }, void>;
"\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(asHtml: true),\n userId,\n replyCount,\n viewCount,\n isLocked,\n isSubscribed,\n isLiked,\n likeCount,\n repliedAt,\n createdAt,\n user {\n id,\n name,\n avatar {\n medium\n }\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; name: string; avatar: { medium: string | 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 },\n threads(mediaCategoryId: $id, sort: ID_DESC) {\n ...ThreadFrag\n }\n }\n }\n":
TadaDocumentNode<{ Page: { pageInfo: { hasNextPage: boolean | 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; name: string; avatar: { medium: string | null; } | null; } | null; categories: ({ id: number; name: string; } | null)[] | null; } | null)[] | null; } | null; }, { perPage?: number | null | undefined; page?: number | null | undefined; id?: number | null | undefined; }, 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; name: string; avatar: { medium: string | null; } | null; } | null; categories: ({ id: number; name: string; } | null)[] | null; } | null; }, { threadId?: number | null | undefined; }, void>;
"\n fragment CommentFrag on ThreadComment @_unmask {\n id,\n comment(asHtml: true),\n isLiked,\n likeCount,\n createdAt,\n user {\n id,\n name,\n moderatorRoles,\n avatar {\n medium\n },\n },\n childComments,\n isLocked\n }\n":
TadaDocumentNode<{ id: number; comment: string | null; isLiked: boolean | null; likeCount: number; createdAt: number; user: { id: number; name: string; moderatorRoles: ("ADMIN" | "LEAD_DEVELOPER" | "DEVELOPER" | "LEAD_COMMUNITY" | "COMMUNITY" | "DISCORD_COMMUNITY" | "LEAD_ANIME_DATA" | "ANIME_DATA" | "LEAD_MANGA_DATA" | "MANGA_DATA" | "LEAD_SOCIAL_MEDIA" | "SOCIAL_MEDIA" | "RETIRED" | "CHARACTER_DATA" | "STAFF_DATA" | null)[] | null; avatar: { medium: string | 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 }\n threadComments(threadId: $threadId) {\n id,\n comment(asHtml: true),\n isLiked,\n likeCount,\n createdAt,\n user {\n id,\n name,\n moderatorRoles,\n avatar {\n medium\n },\n },\n childComments,\n isLocked\n }\n }\n }\n":
TadaDocumentNode<{ Page: { pageInfo: { hasNextPage: boolean | null; } | null; threadComments: ({ id: number; comment: string | null; isLiked: boolean | null; likeCount: number; createdAt: number; user: { id: number; name: string; moderatorRoles: ("ADMIN" | "LEAD_DEVELOPER" | "DEVELOPER" | "LEAD_COMMUNITY" | "COMMUNITY" | "DISCORD_COMMUNITY" | "LEAD_ANIME_DATA" | "ANIME_DATA" | "LEAD_MANGA_DATA" | "MANGA_DATA" | "LEAD_SOCIAL_MEDIA" | "SOCIAL_MEDIA" | "RETIRED" | "CHARACTER_DATA" | "STAFF_DATA" | null)[] | null; avatar: { medium: string | null; } | null; } | null; childComments: unknown; isLocked: boolean | null; } | null)[] | null; } | null; }, { page?: number | null | undefined; threadId?: number | null | undefined; }, void>;
"\n query User ($id: Int) {\n User(id: $id) {\n id,\n name,\n avatar {\n large\n },\n bannerImage,\n about(asHtml: true),\n isFollowing,\n isFollower,\n donatorBadge,\n createdAt,\n options {\n profileColor\n },\n statistics {\n anime {\n count,\n minutesWatched,\n episodesWatched,\n genrePreview: genres(limit: 10, sort: COUNT_DESC) {\n genre,\n count\n }\n }\n }\n }\n }\n":
TadaDocumentNode<{ User: { id: number; name: string; avatar: { large: string | null; } | null; bannerImage: string | null; about: string | null; isFollowing: boolean | null; isFollower: boolean | null; donatorBadge: string | null; createdAt: number | null; options: { profileColor: string | null; } | null; statistics: { anime: { count: number; minutesWatched: number; episodesWatched: number; genrePreview: ({ genre: string | null; count: number; } | null)[] | null; } | null; } | null; } | null; }, { id?: 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" | null | undefined; id?: number | null | undefined; }, void>;
"fragment Med on Media {id, isFavourite}":
TadaDocumentNode<{ id: number; isFavourite: boolean; }, {}, { fragment: "Med"; on: "Media"; masked: true; }>;
"fragment Med on Media {id, mediaListEntry {status, progress, repeat, score, customLists }}":

View file

@ -301,3 +301,149 @@ export const ToggleFavourite = gql(`
ToggleFavourite(animeId: $id) { anime { nodes { id } } }
}
`)
export const ThreadFrag = gql(`
fragment ThreadFrag on Thread @_unmask {
id,
title,
body(asHtml: true),
userId,
replyCount,
viewCount,
isLocked,
isSubscribed,
isLiked,
likeCount,
repliedAt,
createdAt,
user {
id,
name,
avatar {
medium
}
},
categories {
id,
name
}
}
`)
export const Threads = gql(`
query Threads($id: Int, $page: Int, $perPage: Int) {
Page(page: $page, perPage: $perPage) {
pageInfo {
hasNextPage
},
threads(mediaCategoryId: $id, sort: ID_DESC) {
...ThreadFrag
}
}
}
`, [ThreadFrag])
export const Thread = gql(`
query Thread($threadId: Int) {
Thread(id: $threadId) {
...ThreadFrag
}
}
`, [ThreadFrag])
export const CommentFrag = gql(`
fragment CommentFrag on ThreadComment @_unmask {
id,
comment(asHtml: true),
isLiked,
likeCount,
createdAt,
user {
id,
name,
moderatorRoles,
avatar {
medium
},
},
childComments,
isLocked
}
`)
// AL in their infinite wisdom decided to make childComments infer the schema of the parent comment, so we can't use the CommentFrag here
export const Comments = gql(`
query Comments($threadId: Int, $page: Int) {
Page(page: $page, perPage: 15) {
pageInfo {
hasNextPage
}
threadComments(threadId: $threadId) {
id,
comment(asHtml: true),
isLiked,
likeCount,
createdAt,
user {
id,
name,
moderatorRoles,
avatar {
medium
},
},
childComments,
isLocked
}
}
}
`)
export const User = gql(`
query User ($id: Int) {
User(id: $id) {
id,
name,
avatar {
large
},
bannerImage,
about(asHtml: true),
isFollowing,
isFollower,
donatorBadge,
createdAt,
options {
profileColor
},
statistics {
anime {
count,
minutesWatched,
episodesWatched,
genrePreview: genres(limit: 10, sort: COUNT_DESC) {
genre,
count
}
}
}
}
}
`)
export const ToggleLike = gql(`
mutation ToggleLike ($id: Int, $type: LikeableType) {
ToggleLikeV2(id: $id, type: $type) {
... on Thread {
id
likeCount
isLiked
}
... on ThreadComment {
id
likeCount
isLiked
}
}
}
`)

View file

@ -0,0 +1,156 @@
<script lang='ts'>
import { Bell, Clapperboard, Maximize2, Share2 } from 'lucide-svelte'
import { onDestroy } from 'svelte'
import EntryEditor from '../../../../lib/components/EntryEditor.svelte'
import type { LayoutData } from './$types'
import * as Dialog from '$lib/components/ui/dialog'
import * as Tooltip from '$lib/components/ui/tooltip'
import * as Avatar from '$lib/components/ui/avatar'
import { bannerSrc, hideBanner } from '$lib/components/ui/banner'
import { PlayButton, Button, BookmarkButton, FavoriteButton } from '$lib/components/ui/button'
import { dragScroll } from '$lib/modules/navigate'
import { cover, desc, duration, format, season, status, title } from '$lib/modules/anilist'
import { authAggregator, of } from '$lib/modules/auth'
import native from '$lib/modules/native'
import MyAnimeList from '$lib/components/icons/MyAnimeList.svelte'
import Anilist from '$lib/components/icons/Anilist.svelte'
import { Load } from '$lib/components/ui/img'
export let data: LayoutData
const oldBanner = bannerSrc.value
$: bannerSrc.value = data.anime.value.Media
hideBanner.value = false
onDestroy(() => {
bannerSrc.value = oldBanner
})
$: anime = data.anime
$: media = $anime.Media!
function share () {
native.share({
title: `Watch on Hayase - ${media.title?.romaji ?? ''}`,
text: desc(media),
url: `https://hayas.ee/anime/${media.id}`
})
}
function handleScroll (e: Event) {
const target = e.target as HTMLDivElement
hideBanner.value = target.scrollTop > 100
}
$: mediaID = media.id
$: following = authAggregator.following(mediaID)
$: followerEntries = $following?.data?.Page?.mediaList?.filter(e => e?.user?.id !== authAggregator.id()) ?? []
</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' use:dragScroll on:scroll={handleScroll}>
<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'>
<Dialog.Trigger class='shrink-0 w-[180px] h-[256px] rounded overflow-hidden relative group focus-visible:ring-1 focus-visible:ring-ring select:scale-[1.02] transition-transform duration-200'>
<div class='absolute flex-center w-full h-full bg-black group-select:bg-opacity-50 bg-opacity-0 duration-300 text-white transition-all ease-out'>
<Maximize2 class='size-10 scale-75 opacity-0 group-select:opacity-100 group-select:scale-100 duration-300 transition-all ease-out' />
</div>
<Load src={cover(media)} color={media.coverImage?.color} class='w-full h-full object-cover' />
</Dialog.Trigger>
<Dialog.Content class='flex justify-center'>
<Load src={cover(media)} color={media.coverImage?.color} class='h-full object-cover rounded' />
</Dialog.Content>
</Dialog.Root>
<div class='flex flex-col gap-4 items-center md:items-start justify-end w-full'>
<div class='flex flex-col gap-1 text-center md:text-start w-full'>
<h3 class='text-lg capitalize leading-none text-muted-foreground'>
{season(media)}
</h3>
<h1 class='font-black text-2xl md:text-4xl line-clamp-2 text-white'>{title(media)}</h1>
<h2 class='line-clamp-1 text-sm md:text-lg font-light text-muted-foreground'>{media.title?.romaji ?? ''}</h2>
<div class='flex-wrap w-full justify-start md:pt-1 gap-4 hidden md:flex'>
<div class='rounded px-3 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 font-bold' style:background={media.coverImage?.color ?? '#27272a'}>
<div class='text-contrast'>
{format(media)}
</div>
</div>
{#if media.averageScore}
<div class='rounded px-3 font-bold' style:background={media.coverImage?.color ?? '#27272a'}>
<div class='text-contrast'>
{media.averageScore}%
</div>
</div>
{/if}
<div class='rounded px-3 font-bold' style:background={media.coverImage?.color ?? '#27272a'}>
<div class='text-contrast'>
{status(media)}
</div>
</div>
</div>
<div class='md:block hidden relative pb-6 md:pt-2 md:pb-0'>
<div class='line-clamp-4 md:text-start text-center text-xs md:text-md leading-2 font-light antialiased whitespace-pre-wrap text-muted-foreground'>{desc(media)}</div>
</div>
</div>
</div>
</div>
<div class='flex gap-2 items-center justify-center md:justify-start w-full lex-wrap'>
<div class='flex md:mr-3 w-full min-[380px]:w-[180px]'>
<PlayButton size='default' {media} class='rounded-r-none w-full' />
<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}>
<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]}>
<Clapperboard class='size-4' />
</Button>
</Dialog.Trigger>
<Dialog.Content class='flex justify-center max-h-[80%] h-full'>
<iframe class='h-full max-w-full aspect-video max-h-full rounded' src={`https://www.youtube-nocookie.com/embed/${media.trailer.id}?autoplay=1`} frameborder='0' allow='autoplay' allowfullscreen title={media.title?.userPreferred ?? ''} />
</Dialog.Content>
</Dialog.Root>
{/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)}
<Tooltip.Root>
<Tooltip.Trigger class='inline-block size-8 cursor-default'>
<Avatar.Root class='inline-block ring-4 ring-black size-8 bg-black'>
<Avatar.Image src={followerEntry?.user?.avatar?.medium ?? ''} alt={followerEntry?.user?.name ?? 'N/A'} />
<Avatar.Fallback>{followerEntry?.user?.name ?? 'N/A'}</Avatar.Fallback>
</Avatar.Root>
</Tooltip.Trigger>
<Tooltip.Content>
<p class='font-extrabold'>{followerEntry?.user?.name}</p>
<p class='capitalize'>{followerEntry?.status?.toLowerCase()}</p>
</Tooltip.Content>
</Tooltip.Root>
{/each}
</div>
</div>
<slot />
</div>
</div>

View file

@ -1,24 +1,16 @@
<script lang='ts'>
import { Bell, Clapperboard, Maximize2, Share2 } from 'lucide-svelte'
import { onDestroy } from 'svelte'
import EntryEditor from './EntryEditor.svelte'
import type { PageData } from './$types'
import * as Dialog from '$lib/components/ui/dialog'
import * as Tooltip from '$lib/components/ui/tooltip'
import * as Avatar from '$lib/components/ui/avatar'
import { bannerSrc, hideBanner } from '$lib/components/ui/banner'
import { PlayButton, Button, BookmarkButton, FavoriteButton } from '$lib/components/ui/button'
import { Button } from '$lib/components/ui/button'
import { dragScroll } from '$lib/modules/navigate'
import { cover, desc, duration, format, relation, season, status, title } from '$lib/modules/anilist'
import { authAggregator, of } from '$lib/modules/auth'
import native from '$lib/modules/native'
import { format, relation } from '$lib/modules/anilist'
import { authAggregator } from '$lib/modules/auth'
import EpisodesList from '$lib/components/EpisodesList.svelte'
import MyAnimeList from '$lib/components/icons/MyAnimeList.svelte'
import Anilist from '$lib/components/icons/Anilist.svelte'
import { Load } from '$lib/components/ui/img'
import { Threads } from '$lib/components/ui/forums'
export let data: PageData
@ -35,167 +27,58 @@
$: relations = media.relations?.edges?.filter(edge => edge?.node?.type === 'ANIME')
function share () {
native.share({
title: `Watch on Hayase - ${media.title?.romaji ?? ''}`,
text: desc(media),
url: `https://hayas.ee/anime/${media.id}`
})
}
let showRelations = false
function showMore () {
showRelations = !showRelations
}
function handleScroll (e: Event) {
const target = e.target as HTMLDivElement
hideBanner.value = target.scrollTop > 100
}
$: mediaID = media.id
$: following = authAggregator.following(mediaID)
$: followerEntries = $following?.data?.Page?.mediaList?.filter(e => e?.user?.id !== authAggregator.id()) ?? []
$: eps = data.eps
</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' use:dragScroll on:scroll={handleScroll}>
<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'>
<Dialog.Trigger class='shrink-0 w-[180px] h-[256px] rounded overflow-hidden relative group focus-visible:ring-1 focus-visible:ring-ring select:scale-[1.02] transition-transform duration-200'>
<div class='absolute flex-center w-full h-full bg-black group-select:bg-opacity-50 bg-opacity-0 duration-300 text-white transition-all ease-out'>
<Maximize2 class='size-10 scale-75 opacity-0 group-select:opacity-100 group-select:scale-100 duration-300 transition-all ease-out' />
</div>
<Load src={cover(media)} color={media.coverImage?.color} class='w-full h-full object-cover' />
</Dialog.Trigger>
<Dialog.Content class='flex justify-center'>
<Load src={cover(media)} color={media.coverImage?.color} class='h-full object-cover rounded' />
</Dialog.Content>
</Dialog.Root>
<div class='flex flex-col gap-4 items-center md:items-start justify-end w-full'>
<div class='flex flex-col gap-1 text-center md:text-start w-full'>
<h3 class='text-lg capitalize leading-none text-muted-foreground'>
{season(media)}
</h3>
<h1 class='font-black text-2xl md:text-4xl line-clamp-2 text-white'>{title(media)}</h1>
<h2 class='line-clamp-1 text-sm md:text-lg font-light text-muted-foreground'>{media.title?.romaji ?? ''}</h2>
<div class='flex-wrap w-full justify-start md:pt-1 gap-4 hidden md:flex'>
<div class='rounded px-3 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 font-bold' style:background={media.coverImage?.color ?? '#27272a'}>
<div class='text-contrast'>
{format(media)}
</div>
</div>
{#if media.averageScore}
<div class='rounded px-3 font-bold' style:background={media.coverImage?.color ?? '#27272a'}>
<div class='text-contrast'>
{media.averageScore}%
</div>
</div>
{/if}
<div class='rounded px-3 font-bold' style:background={media.coverImage?.color ?? '#27272a'}>
<div class='text-contrast'>
{status(media)}
</div>
</div>
</div>
<div class='md:block hidden relative pb-6 md:pt-2 md:pb-0'>
<div class='line-clamp-4 md:text-start text-center text-xs md:text-md leading-2 font-light antialiased whitespace-pre-wrap text-muted-foreground'>{desc(media)}</div>
</div>
</div>
</div>
</div>
<div class='flex gap-2 items-center justify-center md:justify-start w-full lex-wrap'>
<div class='flex md:mr-3 w-full min-[380px]:w-[180px]'>
<PlayButton size='default' {media} class='rounded-r-none w-full' />
<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}>
<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 relations?.length}
<div class='w-full'>
<div class='flex justify-between items-center'>
<div class='text-[20px] md:text-2xl font-bold'>Relations</div>
{#if relations.length > 3}
<Button variant='ghost' class='text-muted-foreground font-bold text-sm' on:click={showMore}>{showRelations ? 'Show Less' : 'Show More'}</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]}>
<Clapperboard class='size-4' />
</Button>
</Dialog.Trigger>
<Dialog.Content class='flex justify-center max-h-[80%] h-full'>
<iframe class='h-full max-w-full aspect-video max-h-full rounded' src={`https://www.youtube-nocookie.com/embed/${media.trailer.id}?autoplay=1`} frameborder='0' allow='autoplay' allowfullscreen title={media.title?.userPreferred ?? ''} />
</Dialog.Content>
</Dialog.Root>
{/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)}
<Tooltip.Root>
<Tooltip.Trigger class='inline-block size-8 cursor-default'>
<Avatar.Root class='inline-block ring-4 ring-black size-8 bg-black'>
<Avatar.Image src={followerEntry?.user?.avatar?.medium ?? ''} alt={followerEntry?.user?.name ?? 'N/A'} />
<Avatar.Fallback>{followerEntry?.user?.name ?? 'N/A'}</Avatar.Fallback>
</Avatar.Root>
</Tooltip.Trigger>
<Tooltip.Content>
<p class='font-extrabold'>{followerEntry?.user?.name}</p>
<p class='capitalize'>{followerEntry?.status?.toLowerCase()}</p>
</Tooltip.Content>
</Tooltip.Root>
{/each}
</div>
</div>
{#if relations?.length}
<div class='w-full'>
<div class='flex justify-between items-center'>
<div class='text-[20px] md:text-2xl font-bold'>Relations</div>
{#if relations.length > 3}
<Button variant='ghost' class='text-muted-foreground font-bold text-sm' on:click={showMore}>{showRelations ? 'Show Less' : 'Show More'}</Button>
{/if}
</div>
<div class='md:w-full flex gap-5 overflow-x-scroll md:overflow-x-visible md:grid md:grid-cols-3 justify-items-center py-3' use:dragScroll>
{#each showRelations ? relations : relations.slice(0, 3) as rel (rel?.node?.id)}
{@const media = rel?.node}
{#if media}
<a class='select:scale-[1.02] select:shadow-lg scale-100 transition-all duration-200 shrink-0 ease-out focus-visible:ring-ring focus-visible:ring-1 rounded-md w-96 md:w-full h-[126px] bg-neutral-950 text-secondary-foreground select:bg-neutral-900 flex' style:-webkit-user-drag='none' href='/app/anime/{media.id}'>
<div class='w-[90px] bg-image rounded-l-md shrink-0'>
<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='line-clamp-2'>{media.title?.userPreferred ?? 'N/A'}</div>
<div class='font-thin'>{format(media)}</div>
</div>
</a>
{/if}
{/each}
</div>
</div>
{/if}
<div class='w-full'>
<div class='flex justify-between items-center'>
<div class='text-[20px] md:text-2xl font-bold'>Episodes</div>
</div>
{#key mediaID}
<EpisodesList {media} {eps} {following} />
{/key}
<div class='md:w-full flex gap-5 overflow-x-scroll md:overflow-x-visible md:grid md:grid-cols-3 justify-items-center py-3' use:dragScroll>
{#each showRelations ? relations : relations.slice(0, 3) as rel (rel?.node?.id)}
{@const media = rel?.node}
{#if media}
<a class='select:scale-[1.02] select:shadow-lg scale-100 transition-all duration-200 shrink-0 ease-out focus-visible:ring-ring focus-visible:ring-1 rounded-md w-96 md:w-full h-[126px] bg-neutral-950 text-secondary-foreground select:bg-neutral-900 flex' style:-webkit-user-drag='none' href='/app/anime/{media.id}'>
<div class='w-[90px] bg-image rounded-l-md shrink-0'>
<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='line-clamp-2'>{media.title?.userPreferred ?? 'N/A'}</div>
<div class='font-thin'>{format(media)}</div>
</div>
</a>
{/if}
{/each}
</div>
</div>
{/if}
<div class='w-full'>
<div class='flex justify-between items-center'>
<div class='text-[20px] md:text-2xl font-bold'>Episodes</div>
</div>
{#key mediaID}
<EpisodesList {media} {eps} {following} />
{/key}
</div>
<div class='w-full'>
<div class='flex justify-between items-center'>
<div class='text-[20px] md:text-2xl font-bold'>Threads</div>
</div>
{#key mediaID}
<Threads {media} />
{/key}
</div>

View file

@ -0,0 +1,12 @@
import type { LayoutLoad } from './$types'
import { asyncStore } from '$lib/modules/anilist/client'
import { Comments, Thread } from '$lib/modules/anilist/queries'
export const load: LayoutLoad = async ({ params }) => {
const commentsStore = asyncStore(Comments, { threadId: Number(params.threadId) })
return {
comments: await commentsStore,
thread: await asyncStore(Thread, { threadId: Number(params.threadId) }, { requestPolicy: 'cache-first' })
}
}

View file

@ -0,0 +1,70 @@
<script lang='ts'>
import { ChevronLeft, Eye, MessagesSquare } from 'lucide-svelte'
import type { PageData } from './$types'
import * as Avatar from '$lib/components/ui/avatar'
import { since } from '$lib/utils'
import { Button } from '$lib/components/ui/button'
import { Comment } from '$lib/components/ui/forums'
import { ToggleLike } from '$lib/modules/anilist/queries'
import { client } from '$lib/modules/anilist'
import Shadow from '$lib/components/Shadow.svelte'
export let data: PageData
$: threadStore = data.thread
$: thread = $threadStore.Thread!
$: commentsStore = data.comments
$: comments = $commentsStore.Page
$: anime = data.anime
$: media = $anime.Media!
const x = async () => await client.client.mutation(ToggleLike, { id: thread.id, type: 'THREAD' })
</script>
<div class='flex items-center w-full'>
<Button size='icon' variant='ghost' href='/app/anime/{media.id}' class='mr-2'>
<ChevronLeft class='h-4 w-4' />
</Button>
<div class='text-[20px] md:text-2xl font-bold line-clamp-1'>{thread.title ?? 'No thread title...'}</div>
</div>
<div class='rounded-md bg-neutral-950 text-secondary-foreground flex w-full mb-10 py-6 px-8 flex-col'>
<div class='flex w-full justify-between text-xl'>
<div class='font-bold mb-2 line-clamp-1 flex leading-none items-center'>
<Avatar.Root class='inline-block size-8 mr-4'>
<Avatar.Image src={thread.user?.avatar?.medium ?? ''} alt={thread?.user?.name ?? 'N/A'} />
<Avatar.Fallback>{thread.user?.name ?? 'N/A'}</Avatar.Fallback>
</Avatar.Root>
{thread.user?.name ?? 'N/A'}
</div>
<div class='flex ml-2 text-[12.8px] leading-none mt-0.5'>
<Eye size='12' class='mr-1' />
{thread.viewCount ?? 0}
<MessagesSquare size='12' class='mr-1 ml-2' />
{thread.replyCount ?? 0}
</div>
</div>
<Shadow html={thread.body ?? ''} class='my-3 text-muted-foreground leading-relaxed [&_*]:flex [&_*]:flex-col [&_br]:hidden overflow-clip' />
<div class='flex w-full justify-between mt-auto text-[9.6px]'>
<div class='pt-2 flex items-end'>
{since(new Date((thread.createdAt ?? 0) * 1000))}
</div>
<div class='ml-auto inline-flex flex-wrap gap-2 items-end'>
{#each thread.categories?.filter(category => category?.name !== 'Anime') ?? [] as category, i (category?.id ?? i)}
<div class='rounded px-3 py-0.5 font-bold flex items-center' style:background={media.coverImage?.color ?? '#27272a'}>
<div class='text-contrast'>
{category?.name}
</div>
</div>
{/each}
</div>
</div>
</div>
{#each comments?.threadComments ?? [] as comment, i (comment?.id ?? i)}
{#if comment}
<Comment {comment} />
{/if}
{/each}

View file

@ -142,7 +142,7 @@
</Drawer.Header>
<Drawer.Footer>
{#each episodes as episode, i (i)}
<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}' data-sveltekit-preload-data='tap'>
<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' />
@ -164,7 +164,7 @@
{#if !$isMobile}
<div class='mt-auto'>
{#each episodes.length > 6 ? episodes.slice(0, 5) : episodes as episode, i (i)}
<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}' data-sveltekit-preload-data='tap'>
<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' />
@ -182,7 +182,7 @@
</Tooltip.Trigger>
<Tooltip.Content sameWidth={true} class='text-center gap-1.5'>
{#each episodes.slice(5) as episode, i (i)}
<ButtonPrimitive.Root class={cn('flex items-center h-4 w-full group', +episode.airTime < Date.now() && 'text-neutral-300')} href='/app/anime/{episode.id}'data-sveltekit-preload-data='tap'>
<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' />