mirror of
https://github.com/ThaUnknown/miru.git
synced 2026-04-20 18:52:05 +00:00
feat: read-only forums
This commit is contained in:
parent
5dbe5821dc
commit
0e6270ac88
16 changed files with 696 additions and 181 deletions
|
|
@ -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: {}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
)}>
|
||||
|
|
|
|||
15
src/lib/components/Shadow.svelte
Normal file
15
src/lib/components/Shadow.svelte
Normal 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} />
|
||||
49
src/lib/components/ui/forums/Comment.svelte
Normal file
49
src/lib/components/ui/forums/Comment.svelte
Normal 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>
|
||||
135
src/lib/components/ui/forums/Threads.svelte
Normal file
135
src/lib/components/ui/forums/Threads.svelte
Normal 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>
|
||||
2
src/lib/components/ui/forums/index.ts
Normal file
2
src/lib/components/ui/forums/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { default as Comment } from './Comment.svelte'
|
||||
export { default as Threads } from './Threads.svelte'
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
14
src/lib/modules/anilist/graphql-turbo.d.ts
vendored
14
src/lib/modules/anilist/graphql-turbo.d.ts
vendored
|
|
@ -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 }}":
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
|
|
|||
156
src/routes/app/anime/[id]/+layout.svelte
Normal file
156
src/routes/app/anime/[id]/+layout.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
12
src/routes/app/anime/[id]/thread/[threadId]/+layout.ts
Normal file
12
src/routes/app/anime/[id]/thread/[threadId]/+layout.ts
Normal 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' })
|
||||
}
|
||||
}
|
||||
70
src/routes/app/anime/[id]/thread/[threadId]/+page.svelte
Normal file
70
src/routes/app/anime/[id]/thread/[threadId]/+page.svelte
Normal 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}
|
||||
|
|
@ -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' />
|
||||
|
|
|
|||
Loading…
Reference in a new issue