feat: initial

This commit is contained in:
ThaUnknown 2025-02-12 16:24:52 +01:00
commit 9b1ec67e69
No known key found for this signature in database
182 changed files with 13255 additions and 0 deletions

22
.gitignore vendored Normal file
View file

@ -0,0 +1,22 @@
node_modules
# Output
.output
.vercel
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
ideas.todo
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
.npmrc Normal file
View file

@ -0,0 +1 @@
engine-strict=true

11
.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,11 @@
{
"recommendations": [
"usernamehw.errorlens",
"dbaeumer.vscode-eslint",
"GraphQL.vscode-graphql-syntax",
"YoavBls.pretty-ts-errors",
"svelte.svelte-vscode",
"ardenivanov.svelte-intellisense",
"Gruntfuggly.todo-tree"
]
}

28
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,28 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "always"
},
"editor.formatOnSave": true,
"extensions.ignoreRecommendations": false,
"eslint.useESLintClass": true,
"eslint.useFlatConfig": true,
"eslint.format.enable": true,
"eslint.probe": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact",
"svelte",
"html"
],
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact",
"svelte",
"html"
],
}

47
LICENSE Normal file
View file

@ -0,0 +1,47 @@
Business Source License 1.1
Terms
The Licensor hereby grants you the right to copy, modify, create derivative
works, redistribute, and make non-production use of the Licensed Work. The
Licensor may make an Additional Use Grant, above, permitting limited
production use.
Effective on the Change Date, or the fourth anniversary of the first publicly
available distribution of a specific version of the Licensed Work under this
License, whichever comes first, the Licensor hereby grants you rights under
the terms of the Change License, and the rights granted in the paragraph
above terminate.
If your use of the Licensed Work does not comply with the requirements
currently in effect as described in this License, you must purchase a
commercial license from the Licensor, its affiliated entities, or authorized
resellers, or you must refrain from using the Licensed Work.
All copies of the original and modified Licensed Work, and derivative works
of the Licensed Work, are subject to this License. This License applies
separately for each version of the Licensed Work and the Change Date may vary
for each version of the Licensed Work released by Licensor.
You must conspicuously display this License on each original or modified copy
of the Licensed Work. If you receive the Licensed Work in original or
modified form from a third party, the terms and conditions set forth in this
License apply to your use of that work.
Any use of the Licensed Work in violation of this License will automatically
terminate your rights under this License for the current and all other
versions of the Licensed Work.
This License does not grant you any right in any trademark or logo of
Licensor or its affiliates (provided that you may use a trademark or logo of
Licensor as expressly required by this License).
TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
AN “AS IS” BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
TITLE.
Change Date: 2029-04-01
On the date above, in accordance with the Business Source License, use of this software will be governed by the open source license GPL-3.0.

14
components.json Normal file
View file

@ -0,0 +1,14 @@
{
"$schema": "https://shadcn-svelte.com/schema.json",
"style": "new-york",
"tailwind": {
"config": "tailwind.config.ts",
"css": "src\\app.css",
"baseColor": "zinc"
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/utils"
},
"typescript": true
}

13
eslint.config.js Normal file
View file

@ -0,0 +1,13 @@
import config from 'eslint-config-standard-universal'
import tseslint from 'typescript-eslint'
export default tseslint.config(
...config(),
{
languageOptions: {
parserOptions: {
tsconfigRootDir: import.meta.dirname
}
},
}
)

View file

@ -0,0 +1,17 @@
// @ts-expect-error no types for this, that's fine
import { writeFileSync } from 'node:fs'
import { getIntrospectionQuery, type IntrospectionQuery } from 'graphql'
import { getIntrospectedSchema, minifyIntrospectionQuery } from '@urql/introspection'
const res = await fetch('https://graphql.anilist.co', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
variables: {},
query: getIntrospectionQuery({ descriptions: false })
})
})
const { data } = (await res.json()) as { data: IntrospectionQuery }
const minified = minifyIntrospectionQuery(getIntrospectedSchema(data), { includeScalars: false, includeEnums: true, includeInputs: true, includeDirectives: true })
writeFileSync('./src/lib/modules/anilist/schema.json', JSON.stringify(minified))

67
package.json Normal file
View file

@ -0,0 +1,67 @@
{
"name": "ui",
"version": "0.0.1",
"license": "BUSL-1.1",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "eslint --print-config eslint.config.js",
"gql:turbo": "node ./node_modules/gql.tada/bin/cli.js turbo"
},
"devDependencies": {
"@gql.tada/svelte-support": "^1.0.1",
"@sveltejs/adapter-auto": "^3.2.5",
"@sveltejs/adapter-static": "^3.0.5",
"@sveltejs/kit": "^2.8.1",
"@sveltejs/vite-plugin-svelte": "^3.1.2",
"@types/debug": "^4.1.12",
"@types/events": "^3.0.3",
"@urql/introspection": "^1.1.0",
"autoprefixer": "^10.4.20",
"bits-ui": "^0.21.16",
"cmdk-sv": "^0.0.18",
"eslint-config-standard-universal": "^1.0.1",
"globals": "^15.11.0",
"gql.tada": "^1.8.10",
"hayase-extensions": "github:hayase-app/extensions",
"svelte": "^4.2.7",
"svelte-check": "^4.0.5",
"svelte-eslint-parser": "^0.41.1",
"svelte-radix": "^1.1.1",
"svelte-sonner": "^0.3.28",
"tailwindcss": "^3.4.13",
"vaul-svelte": "^0.3.2",
"vite": "^5.4.11"
},
"type": "module",
"dependencies": {
"@fontsource-variable/nunito": "^5.1.0",
"@prgm/sveltekit-progress-bar": "2.0.0",
"@thaunknown/web-irc": "^1.0.1",
"@urql/exchange-auth": "^2.2.0",
"@urql/exchange-graphcache": "^7.2.1",
"@urql/exchange-request-policy": "^1.2.0",
"@urql/exchange-retry": "^1.3.0",
"@urql/svelte": "^4.2.1",
"abslink": "^1.0.9",
"anitomyscript": "github:thaunknown/anitomyscript",
"bottleneck": "^2.19.5",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"debug": "^4.3.7",
"idb-keyval": "^6.2.1",
"js-levenshtein": "^1.1.6",
"lucide-svelte": "^0.452.0",
"p2pt": "^1.5.1",
"simple-store-svelte": "^1.0.6",
"svelte-persisted-store": "^0.12.0",
"tailwind-merge": "^2.5.4",
"tailwind-variants": "^0.2.1",
"uint8-util": "^2.2.5",
"urql": "^4.2.1"
}
}

4682
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

6
postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
}

338
src/app.css Normal file
View file

@ -0,0 +1,338 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
/* opt out of auto-dark-mode plugins, which break discord css, such as force-dark, dark-reader etc */
color-scheme: only light;
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 72.2% 50.6%;
--destructive-foreground: 0 0% 98%;
--ring: 240 10% 3.9%;
--radius: 0.5rem;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--ring: 240 4.9% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
@font-face {
font-family: 'molotregular';
src: url('/Molot-webfont.woff') format('woff');
}
html,
body {
height: 100vh;
width: 100%;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
scroll-behavior: smooth;
overscroll-behavior: none;
user-select: none;
font-family: 'Nunito Variable'
}
img {
-webkit-user-drag: none;
}
@keyframes load-in {
from {
transform: translateY(1.2rem) scale(0.95);
}
to {
transform: none
}
}
.custom-bg {
/* this is very hacky, but removes jagged edges */
background: repeating-linear-gradient(40deg, #1114 0, #5554 1px, #5554 5px, #1114 6px, #1114 10px);
/* repeating-linear-gradient(40deg, #5554 0 5px, #1114 0 10px); */
}
*:focus-visible {
outline: none;
border-image: fill 0 linear-gradient(#8883, #8883);
}
*::-webkit-scrollbar {
display: none;
}
.details span+span::before {
content: '•';
padding: 0 .3rem;
font-size: .4rem;
align-self: center;
white-space: normal;
color: #737373 !important;
}
a[href]:active,
button:not([disabled], .no-scale):active,
.scale-parent:has(.no-scale:active),
fieldset:not([disabled]):active,
input:not([disabled], [type='range'], .no-scale):active,
optgroup:not([disabled]):active,
option:not([disabled]):active,
select:not([disabled]):active,
textarea:not([disabled]):active,
details:active,
[tabindex]:not([tabindex="-1"]):active,
[contenteditable]:active,
[controls]:active {
transition: all 0.1s ease-in-out;
transform: scale(0.98) !important;
}
/* should contain:strict be used here?*/
.overflow-y-scroll,
.overflow-y-auto,
.overflow-x-scroll,
.overflow-x-auto,
.overflow-auto,
.overflow-scroll {
will-change: scroll-position;
}
.bg-url {
background-image: var(--bg);
}
.border-gradient-to-t {
border-image: fill 0 linear-gradient(#0008, #000);
}
.border-gradient-to-l {
border-image: fill 0 linear-gradient(90deg, hsl(var(--background) / 1) 32%, hsl(var(--background) / 0.9) 100%);
}
@keyframes spin3d {
from {
transform: rotateY(0deg) translateZ(-5000px);
}
to {
transform: rotateY(360deg) translateZ(-5000px);
}
}
body {
perspective: 3000px;
}
.spin>div {
animation: idle-spin 120s linear infinite;
}
.spin>.backplate {
animation: idle-spin-y-flip 120s linear infinite;
display: flex !important;
}
.fly>.root {
animation: idle-fly 0.8s forwards cubic-bezier(0.22, 1, 0.36, 1);
}
.show-backplate>.backplate {
display: flex !important;
}
body.spin>.root,
body.show-backplate>.root {
transition: transform 0.5s;
transform-style: preserve-3d;
transform: perspective(100vw) translateZ(0vw) rotateY(0deg) rotateX(0deg);
}
body>div.backplate {
transition: transform 0.5s;
transform-style: preserve-3d;
transform: perspective(100vw) translateZ(0vw) rotateY(180deg) rotateX(0deg);
}
@keyframes idle-fly {
0% {
transform: perspective(100vw) translateZ(0vw) rotateY(0deg) rotateX(0deg);
}
100% {
transform: perspective(100vw) translateZ(-66vw) rotateY(0deg) rotateX(-15deg);
}
}
@keyframes idle-spin {
0% {
transform: perspective(100vw) translateZ(-66vw) rotateY(0deg) rotateX(-15deg);
}
50% {
transform: perspective(100vw) translateZ(-66vw) rotateY(180deg) rotateX(15deg);
}
100% {
transform: perspective(100vw) translateZ(-66vw) rotateY(360deg) rotateX(-15deg);
}
}
@keyframes idle-spin-y-flip {
0% {
transform: perspective(100vw) translateZ(-66vw) rotateY(-180deg) rotateX(15deg);
}
50% {
transform: perspective(100vw) translateZ(-66vw) rotateY(0deg) rotateX(-15deg);
}
100% {
transform: perspective(100vw) translateZ(-66vw) rotateY(180deg) rotateX(15deg);
}
}
@keyframes marquee {
from {
transform: translateX(0);
}
to {
transform: translateX(-100%);
}
}
@keyframes marquee-bg {
from {
background-position: -2111.74px 0;
}
to {
background-position: 0 0;
}
}
.animate-marquee {
animation: marquee 80s infinite linear;
}
.animate-marquee-bg {
animation: marquee-bg 80s infinite linear;
}
.bg-striped {
background: repeating-linear-gradient(45deg,
#202020,
#202020 6px,
#2a2a2a 6px,
#2a2a2a 12px);
background-attachment: fixed;
background-size: 119px;
}
.bg-striped-muted {
background: repeating-linear-gradient(45deg,
#1e1e1e,
#1e1e1e 6px,
#161616 6px,
#161616 12px);
background-attachment: fixed;
background-size: 119px;
}
.font-molot {
font-family: 'molotregular';
}
.backface-visible {
backface-visibility: visible;
}
.backface-hidden {
backface-visibility: hidden;
}
.sr-only {
display: none !important;
}
.flex-center {
display: flex;
justify-content: center;
align-items: center;
}
.text-contrast {
filter: invert(1) grayscale(1) contrast(100)
}

63
src/app.d.ts vendored Normal file
View file

@ -0,0 +1,63 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// See https://kit.svelte.dev/docs/types#app
import type { Search } from '$lib/modules/anilist/queries'
import type { VariablesOf } from 'gql.tada'
// for information about these interfaces
export interface AuthResponse {
access_token: string
expires_in: string // seconds
token_type: 'Bearer'
}
export interface Native {
authAL: (url: string) => Promise<AuthResponse>
restart: () => Promise<void>
openURL: (url: string) => Promise<void>
share: Navigator['share']
minimise: () => Promise<void>
maximise: () => Promise<void>
close: () => Promise<void>
selectPlayer: () => Promise<string>
selectDownload: () => Promise<string>
setAngle: (angle: string) => Promise<void>
getLogs: () => Promise<string>
getDeviceInfo: () => Promise<any>
openUIDevtools: () => Promise<void>
openTorrentDevtools: () => Promise<void>
checkUpdate: () => Promise<void>
toggleDiscordDetails: (enabled: boolean) => Promise<void>
}
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
interface PageState {
search?: VariablesOf<typeof Search>
}
// interface Platform {}
}
function authAL (url: string): Promise<AuthResponse>
function restart (): Promise<void>
function openURL (url: string): Promise<void>
function share (...args: Parameters<Navigator['share']>): ReturnType<Navigator['share']>
function minimise (): Promise<void>
function maximise (): Promise<void>
function close (): Promise<void>
function selectPlayer (): Promise<string>
function selectDownload (): Promise<string>
function setAngle (angle: string): Promise<void>
function getLogs (): Promise<string>
function getDeviceInfo (): Promise<any>
function openUIDevtools (): Promise<void>
function openTorrentDevtools (): Promise<void>
function checkUpdate (): Promise<void>
function toggleDiscordDetails (enabled: boolean): Promise<void>
function setTimeout (handler: TimerHandler, timeout?: number): number & { unref?: () => void }
}
export {}

15
src/app.html Normal file
View file

@ -0,0 +1,15 @@
<!doctype html>
<html lang="en" class="dark bg-transparent" style="color-scheme: dark;">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover" class="bg-transparent" data-vaul-drawer-wrapper>
%sveltekit.body%
</body>
</html>

View file

@ -0,0 +1,56 @@
<script lang='ts' context='module'>
import { sleep } from '$lib/utils'
let isSpinning = false
const html = document.body
globalThis.start = () => {
if (!isSpinning) {
isSpinning = true
html.classList.add('fly')
setTimeout(() => {
html.classList.remove('fly')
html.classList.add('spin')
}, 800)
}
}
globalThis.reset = async () => {
for (const child of html.children) {
const computedStyle = getComputedStyle(child).transform
child.style.transform = computedStyle
}
html.classList.remove('spin')
html.classList.add('show-backplate')
await sleep(10)
for (const child of html.children) {
child.style.transform = ''
}
isSpinning = false
await sleep(790)
html.classList.remove('show-backplate')
}
// TODO: finish :^)
</script>
<div class='absolute w-full h-full overflow-hidden flip backface-hidden backplate bg-black flex-col justify-center pointer-events-none hidden'>
{#each Array.from({ length: 5 }) as _, i (i)}
<div class='flex flex-row w-full font-molot font-bold -rotate-12' style:padding-left='{(4 - i) * 600 - 1000}px'>
{#each Array.from({ length: 3 }) as _, i (i)}
<div class='animate-marquee mt-32 leading-[0.8]'>
<div class='text-[24rem] bg-striped !bg-clip-text text-transparent animate-marquee-bg tracking-wide'>
HAYASE.06&nbsp;
</div>
<div class='flex pl-1'>
<div class='bg-striped-muted rounded py-2 px-3 mt-1 mb-[2.5px] mr-2 ml-1 text-black animate-marquee-bg flex items-center leading-[0.9]'>
TORRENTING<br />MADE<br />SIMPLE
</div>
<div class='text-[5.44rem] bg-striped-muted !bg-clip-text text-transparent animate-marquee-bg tracking-wider'>
MAGNET://SIMPLICITY TOPS EVERYTHING
</div>
</div>
</div>
{/each}
</div>
{/each}
</div>

View file

@ -0,0 +1,143 @@
<script context='module' lang='ts'>
let fillerEpisodes: Record<number, number[] | undefined> = {}
fetch('https://raw.githubusercontent.com/ThaUnknown/filler-scrape/master/filler.json').then(async res => {
fillerEpisodes = await res.json()
})
</script>
<script lang='ts'>
import { episodes as _episodes, dedupeAiring, episodeByAirDate, notes, type Media } from '$lib/modules/anilist'
import type { EpisodesResponse } from '$lib/modules/anizip/types'
import { cn, isMobile, since } from '$lib/utils'
import { ChevronLeft, Play } from 'lucide-svelte'
import Pagination from './Pagination.svelte'
import { Button } from './ui/button'
import { ChevronRight } from 'svelte-radix'
import { list, progress } from '$lib/modules/auth'
import { click } from '$lib/modules/navigate'
import { searchStore } from './SearchModal.svelte'
import { Load } from './ui/img'
export let eps: EpisodesResponse | null
export let media: Media
// TODO: add watch progress from local sync
const episodeCount = Math.max(_episodes(media) ?? 0, eps?.episodeCount ?? 0)
const { episodes, specialCount } = eps ?? {}
const alSchedule: Record<number, Date | undefined> = {}
for (const { a: airingAt, e: episode } of dedupeAiring(media)) {
alSchedule[episode] = new Date(airingAt * 1000)
}
const episodeList = Array.from({ length: episodeCount }, (_, i) => {
const episode = i + 1
const airingAt = alSchedule[episode]
// TODO handle special cases where anilist reports that 3 episodes aired at the same time because of pre-releases, simply don't allow the same episode to be re-used
const hasSpecial = !!specialCount
const hasEpisode = episodes?.[Number(episode)]
const hasCountMatch = (_episodes(media) ?? 0) === (eps?.episodeCount ?? 0)
const needsValidation = !(!hasSpecial || (hasEpisode && hasCountMatch))
const { image, summary, overview, rating, title, length, airdate } = (needsValidation ? episodeByAirDate(airingAt, episodes ?? {}, episode) : episodes?.[Number(episode)]) ?? {}
return {
episode, image, summary: summary ?? overview, rating, title, length, airdate, airingAt, filler: !!fillerEpisodes[media.id]?.includes(i + 1)
}
})
const perPage = 16
function getPage (page: number) {
return episodeList.slice((page - 1) * perPage, page * perPage)
}
let currentPage = 1
const _progress = progress(media) ?? 0
const completed = list(media) === 'COMPLETED'
function play (episode: number) {
searchStore.set({ media, episode })
}
</script>
<Pagination count={episodeCount} {perPage} bind:currentPage let:pages let:hasNext let:hasPrev let:range let:setPage siblingCount={1}>
<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 py-3'>
{#each getPage(currentPage) as { episode, image, title, summary, airingAt, airdate, filler, length } (episode)}
{@const watched = _progress >= episode}
{@const target = _progress + 1 === episode}
<div use:click={() => play(episode)}
class={cn(
'select:scale-[1.05] select:shadow-lg scale-100 transition-all 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',
target && 'ring-ring ring-1',
filler && '!ring-yellow-400 ring-1'
)}>
{#if image}
<div class='w-52 shrink-0 relative'>
<Load src={image} class={cn('object-cover h-full w-full', watched && 'opacity-20')} />
{#if length ?? media.duration}
<div class='absolute bottom-1 left-1 bg-neutral-900/80 text-secondary-foreground text-[9.6px] px-1 py-0.5 rounded'>
{length ?? media.duration}m
</div>
{/if}
<div class='absolute flex items-center justify-center w-full h-full bg-black group-select:bg-opacity-50 bg-opacity-0 duration-300 text-white transition-all ease-out top-0'>
<Play class='size-6 scale-75 opacity-0 group-select:opacity-100 group-select:scale-100 duration-300 transition-all ease-out' fill='currentColor' />
</div>
</div>
{/if}
<div class='flex-grow py-3 px-4 flex flex-col'>
<div class='font-bold mb-2 line-clamp-1 shrink-0 text-[12.8px]'>
{episode}. {title?.en ?? 'Episode ' + episode}
</div>
{#if watched || completed}
<div class='mb-2 h-0.5 overflow-hidden w-full bg-blue-600 shrink-0' />
{/if}
<div class='text-[9.6px] text-muted-foreground overflow-hidden'>
{notes(summary ?? '')}
</div>
{#if airingAt ?? airdate}
<div class='pt-2 text-[9.6px] mt-auto'>
{since(new Date(airingAt ?? airdate ?? 0))}
</div>
{/if}
{#if filler}
<div class='rounded-tl bg-yellow-400 py-1 px-2 text-primary-foreground absolute bottom-0 right-0 text-[9.6px] font-bold'>Filler</div>
{/if}
</div>
</div>
{/each}
</div>
<div class='flex flex-row items-center justify-between w-full pb-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'>{episodeCount}</span> episodes
</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'>{episodeCount}</span> episodes
</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,42 @@
<script lang='ts' context='module'>
import type { ComponentType, SvelteComponent } from 'svelte'
const keep = new Map<string, { component: SvelteComponent, node: HTMLElement}>()
export function register (id: string, Component: ComponentType) {
if (keep.has(id)) throw new Error(`KeepAlive: duplicate id ${id}`)
const wrapper = document.createDocumentFragment() as unknown as HTMLElement
const instance = new Component({ target: wrapper })
keep.set(id, { component: instance, node: wrapper.children[0] as HTMLElement })
}
export function unregister (id: string) {
const entry = keep.get(id)
if (entry) {
entry.component.$destroy()
entry.node.remove()
keep.delete(id)
}
}
</script>
<script lang='ts'>
import type { HTMLAttributes } from 'svelte/elements'
export let id: string
// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
type $$Props = HTMLAttributes<HTMLDivElement> & { id: string }
function mount (node: HTMLDivElement) {
const entry = keep.get(id)
if (entry) node.appendChild(entry.node)
}
console.log($$props.$$slots)
</script>
<div use:mount {...$$restProps} />

View file

@ -0,0 +1,52 @@
<script lang='ts'>
import online from '$lib/modules/online.ts'
import { CloudOff } from 'lucide-svelte'
let hideFirst = false
$: if (!$online && !hideFirst) {
hideFirst = true
}
</script>
{#if $online && hideFirst}
<div class='bg-green-600 text-white justify-center items-center flex flex-row online overflow-hidden relative z-40 px-4'>
Back online
</div>
{:else if !$online}
<div class='bg-neutral-950 text-white justify-center items-center flex flex-row top-0 offline overflow-hidden relative z-40 px-4'>
<CloudOff size={16} class='me-2' />
Offline
</div>
{/if}
<style>
.online {
animation: hide 300ms forwards 2s;
}
@keyframes hide {
from {
height: 24px;
}
to {
height: 0;
padding-top: 0;
padding-bottom: 0;
}
}
.offline {
height: 0;
padding-top: 0;
padding-bottom: 0;
animation: show 300ms forwards 2s;
}
@keyframes show {
from {
height: 0;
}
to {
height: 24px;
padding-top: 2px;
padding-bottom: 2px;
}
}
</style>

View file

@ -0,0 +1,53 @@
<script lang='ts'>
export let currentPage = 1
export let count = 0
export let perPage = 15
export let siblingCount = 1
let pages: Array<{
page: number
type: string
}> = []
$: {
const edgeSize = 4 * siblingCount
const totalPages = Math.ceil(count / perPage)
const startPage = Math.max(1, totalPages - currentPage < edgeSize ? totalPages - edgeSize : currentPage - siblingCount)
const endPage = Math.min(totalPages, currentPage < edgeSize ? 1 + edgeSize : currentPage + siblingCount)
const paginationItems = []
if (startPage > 1) {
paginationItems.push({ page: 1, type: 'page' })
if (startPage > 2) {
paginationItems.push({ page: startPage - 1, type: 'ellipsis' })
}
}
for (let i = startPage; i <= endPage; i++) {
paginationItems.push({ page: i, type: 'page' })
}
if (endPage < totalPages) {
if (endPage < totalPages - 1) {
paginationItems.push({ page: endPage + 1, type: 'ellipsis' })
}
paginationItems.push({ page: totalPages, type: 'page' })
}
pages = paginationItems
}
$: range = {
start: (currentPage - 1) * perPage,
end: Math.min(currentPage * perPage, count)
}
$: hasNext = currentPage < Math.ceil(count / perPage)
$: hasPrev = currentPage > 1
function setPage (page: number) {
currentPage = Math.min(Math.max(1, page), Math.ceil(count / perPage))
}
</script>
<slot {pages} {range} {hasNext} {hasPrev} {setPage} />

View file

@ -0,0 +1,237 @@
<script lang='ts' context='module'>
import { extensions } from '$lib/modules/extensions/extensions'
import { writable } from 'simple-store-svelte'
import * as Dialog from '$lib/components/ui/dialog'
import { Input } from './ui/input'
import { MagnifyingGlass } from 'svelte-radix'
import { settings } from '$lib/modules/settings'
import { SingleCombo } from './ui/combobox'
import { title, type Media } from '$lib/modules/anilist'
import type { AnitomyResult } from 'anitomyscript'
import { click } from '$lib/modules/navigate'
import { fastPrettyBytes, since } from '$lib/utils'
import { BadgeCheck, Database } from 'lucide-svelte'
import type { TorrentResult } from 'hayase-extensions'
const resolutions = {
1080: '1080p',
720: '720p',
480: '480p',
'': 'Any'
}
const termMapping: Record<string, {text: string, color: string}> = {}
termMapping['5.1'] = termMapping['5.1CH'] = { text: '5.1', color: '#f67255' }
termMapping['TRUEHD5.1'] = { text: 'TrueHD 5.1', color: '#f67255' }
termMapping.AAC = termMapping.AACX2 = termMapping.AACX3 = termMapping.AACX4 = { text: 'AAC', color: '#f67255' }
termMapping.AC3 = { text: 'AC3', color: '#f67255' }
termMapping.EAC3 = termMapping['E-AC-3'] = { text: 'EAC3', color: '#f67255' }
termMapping.FLAC = termMapping.FLACX2 = termMapping.FLACX3 = termMapping.FLACX4 = { text: 'FLAC', color: '#f67255' }
termMapping.VORBIS = { text: 'Vorbis', color: '#f67255' }
termMapping.DUALAUDIO = termMapping['DUAL AUDIO'] = { text: 'Dual Audio', color: '#ffcb3b' }
termMapping['10BIT'] = termMapping['10BITS'] = termMapping['10-BIT'] = termMapping['10-BITS'] = termMapping.HI10 = termMapping.HI10P = { text: '10 Bit', color: '#0c8ce9' }
termMapping.HI444 = termMapping.HI444P = termMapping.HI444PP = { text: 'HI444', color: '#0c8ce9' }
termMapping.HEVC = termMapping.H265 = termMapping['H.265'] = termMapping.X265 = { text: 'HEVC', color: '#0c8ce9' }
termMapping.AV1 = { text: 'AV1', color: '#0c8ce9' }
termMapping.BD = termMapping.BDRIP = termMapping.BLURAY = termMapping['BLU-RAY'] = { text: 'BD', color: '#ab1b31' }
termMapping.DVD5 = termMapping.DVD9 = termMapping['DVD-R2J'] = termMapping.DVDRIP = termMapping.DVD = termMapping['DVD-RIP'] = termMapping.R2DVD = termMapping.R2J = termMapping.R2JDVD = termMapping.R2JDVDRIP = { text: 'DVD', color: '#ab1b31' }
// termMapping.HDTV = termMapping.HDTVRIP = termMapping.TVRIP = termMapping['TV-RIP'] = { text: 'TV', color: '#ab1b31' }
// termMapping.WEBCAST = termMapping.WEBRIP = { text: 'WEB', color: '#ab1b31' }
function sanitiseTerms ({ video_term: vid, audio_term: aud, video_resolution: resolution, source: src }: AnitomyResult) {
const video = !Array.isArray(vid) ? [vid] : vid
const audio = !Array.isArray(aud) ? [aud] : aud
const source = !Array.isArray(src) ? [src] : src
const terms = [...new Set([...video, ...audio, ...source].map(term => termMapping[term?.toUpperCase()]).filter(t => t))] as Array<{text: string, color: string}>
if (resolution) terms.unshift({ text: resolution, color: '#c6ec58' })
return terms
}
function simplifyFilename ({ video_term: vid, audio_term: aud, video_resolution: resolution, file_name: name, release_group: group, file_checksum: checksum }: AnitomyResult) {
const video = !Array.isArray(vid) ? [vid] : vid
const audio = !Array.isArray(aud) ? [aud] : aud
let simpleName = name
if (group) simpleName = simpleName.replace(group, '')
if (resolution) simpleName = simpleName.replace(resolution, '')
if (checksum) simpleName = simpleName.replace(checksum, '')
for (const term of video) simpleName = simpleName.replace(term, '')
for (const term of audio) simpleName = simpleName.replace(term, '')
return simpleName.replace(/[[{(]\s*[\]})]/g, '').replace(/\s+/g, ' ').trim()
}
export const searchStore = writable<{episode?: number, media?: Media}>({})
</script>
<script lang='ts'>
import ProgressButton from './ui/button/progress-button.svelte'
import { Banner } from './ui/img'
$: open = !!$searchStore.media
$: searchResult = !!$searchStore.media && extensions.getResultsFromExtensions({ media: $searchStore.media, episode: $searchStore.episode, batch: $settings.searchBatch, resolution: $settings.searchQuality as '' | '1080' | '720' | '2160' | '540' | '480' })
function close (state: boolean) {
if (!state) searchStore.set({})
}
let inputText = ''
function play (result: TorrentResult & { parseObject: AnitomyResult, extension: string[] }) {
close(false)
// TODO
}
async function playBest () {
if (!searchResult) return
const best = filterAndSortResults((await searchResult).results, inputText)[0]
if (best) play(best)
}
function filterAndSortResults (results: Array<TorrentResult & { parseObject: AnitomyResult, extension: string[] }>, searchText: string) {
// TODO: sort preference such as size, quality, seeders, etc.
return results
.filter(({ title }) => title.toLowerCase().includes(searchText.toLowerCase()))
.sort((a, b) => {
function getRank (res: typeof results[0]) {
if (res.accuracy === 'low') return 3
if ((res.type === 'best' || res.type === 'alt') && res.seeders > 15) return 0
if (res.seeders > 15) return 1
return 2
}
const rankA = getRank(a)
const rankB = getRank(b)
if (rankA !== rankB) return rankA - rankB
if (rankA === 1) {
const scoreA = a.accuracy === 'high' ? 1 : 0
const scoreB = b.accuracy === 'high' ? 1 : 0
const diff = scoreB - scoreA
if (diff !== 0) return diff
// sort by seeders
return b.seeders - a.seeders
}
return 0
})
}
let animating = false
function startAnimation () {
if (!$settings.searchAutoSelect) return
animating = true
}
function stopAnimation () {
animating = false
}
$: searchResult && searchResult.then(startAnimation)
</script>
<Dialog.Root bind:open onOpenChange={close}>
<Dialog.Content class='bg-black h-full lg:border-x-4 border-b-0 max-w-5xl w-full max-h-[calc(100%-1rem)] mt-2 p-0 items-center flex lg:rounded-t-xl overflow-hidden'>
<div class='absolute top-0 left-0 w-full h-full max-h-28 overflow-hidden'>
<Banner media={$searchStore.media} class='object-cover w-full h-full absolute bottom-[0.5px] left-0 -z-10' />
<div class='w-full h-full banner-2' />
</div>
<div class='gap-4 w-full relative h-full flex flex-col pt-6'>
<div class='px-4 sm:px-6 space-y-4'>
<div class='font-weight-bold text-2xl font-bold text-ellipsis text-nowrap overflow-hidden pb-2'>{$searchStore.media ? title($searchStore.media) : ''}</div>
<div class='flex items-center relative scale-parent'>
<Input
class='pl-9 bg-background select:bg-accent select:text-accent-foreground shadow-sm no-scale placeholder:opacity-50 capitalize'
placeholder='Any'
bind:value={inputText} />
<MagnifyingGlass class='h-4 w-4 shrink-0 opacity-50 absolute left-3 text-muted-foreground z-10 pointer-events-none' />
</div>
<div class='flex items-center gap-4 justify-around flex-wrap'>
<div class='flex items-center space-x-2 grow'>
<span>Episode</span>
<Input type='number' inputmode='numeric' pattern='[0-9]*' min='0' max='65536' bind:value={$searchStore.episode} class='w-32 shrink-0 bg-background grow' />
</div>
<div class='flex items-center space-x-2 grow'>
<span>Resolution</span>
<SingleCombo bind:value={$settings.searchQuality} items={resolutions} class='w-32 shrink-0 grow border-border border' />
</div>
</div>
<ProgressButton
onclick={playBest}
size='default'
class='w-full font-bold'
bind:animating>
Auto Select Torrent
</ProgressButton>
</div>
<div class='h-full overflow-y-auto px-4 sm:px-6 pt-2' role='menu' tabindex='-1' on:keydown={stopAnimation} on:pointerenter={stopAnimation}>
{#await searchResult}
Loading...
{:then search}
{@const media = $searchStore.media}
{#if search && media}
{@const { results, errors } = search}
{#each filterAndSortResults(results, inputText) as result (result.hash)}
<div class='p-3 flex cursor-pointer mb-2 relative rounded-md overflow-hidden border border-border select:ring-1 select:ring-ring select:bg-accent select:text-accent-foreground select:scale-[1.02] select:shadow-lg scale-100 transition-all' use:click={() => play(result)} title={result.parseObject.file_name}>
{#if result.accuracy === 'high'}
<div class='absolute top-0 left-0 w-full h-full -z-10'>
<Banner {media} class='object-cover w-full h-full' />
<div class='absolute top-0 left-0 w-full h-full banner' />
</div>
{/if}
<div class='flex pl-2 flex-col justify-between w-full h-20 relative min-w-0 text-[.7rem]'>
<div class='flex w-full'>
<div class='text-xl font-bold text-nowrap'>{result.parseObject.release_group && result.parseObject.release_group.length < 20 ? result.parseObject.release_group : 'No Group'}</div>
{#if result.type === 'batch'}
<Database size='1.2rem' class='ml-auto' />
{:else if result.accuracy === 'high'}
<BadgeCheck size='1.2rem' class='ml-auto' style='color: #53da33' />
{/if}
</div>
<div class='text-muted-foreground text-ellipsis text-nowrap overflow-hidden'>{simplifyFilename(result.parseObject)}</div>
<div class='flex flex-row leading-none'>
<div class='details text-light flex'>
<span class='text-nowrap flex items-center'>{fastPrettyBytes(result.size)}</span>
<span class='text-nowrap flex items-center'>{result.seeders} Seeders</span>
<span class='text-nowrap flex items-center'>{since(new Date(result.date))}</span>
</div>
<div class='flex ml-auto flex-row-reverse'>
{#if result.type === 'best'}
<div class='rounded px-3 py-1 ml-2 border text-nowrap flex items-center' style='background: #1d2d1e; border-color: #53da33 !important; color: #53da33'>
Best Release
</div>
{:else if result.type === 'alt'}
<div class='rounded px-3 py-1 ml-2 border text-nowrap flex items-center' style='background: #391d20; border-color: #c52d2d !important; color: #c52d2d'>
Alt Release
</div>
{/if}
{#each sanitiseTerms(result.parseObject) as { text, color }, i (i)}
<div class='rounded px-3 py-1 ml-2 text-nowrap font-bold flex items-center' style:background={color}>
<div class='text-transparent bg-clip-text text-contrast bg-inherit'>
{text}
</div>
</div>
{/each}
</div>
</div>
</div>
</div>
{/each}
{/if}
{/await}
</div>
</div>
</Dialog.Content>
</Dialog.Root>
<style>
.banner {
background: linear-gradient(90deg, #000 32%, rgba(0, 0, 0, 0.9) 100%);
}
.banner-2 {
background: linear-gradient(#000d 0%, #000d 90%, #000 100%);
}
</style>

View file

@ -0,0 +1,15 @@
<script lang='ts'>
import { Label } from '$lib/components/ui/label'
export let title = ''
export let description = ''
const id = Math.trunc(Math.random() * Number.MAX_SAFE_INTEGER).toString() + title
</script>
<div class='flex flex-col md:flex-row md:items-center justify-between bg-neutral-950 rounded-md px-6 py-4 space-y-3 md:space-y-0 md:space-x-3'>
<Label for={id} class='space-1 block leading-[unset] grow'>
<div class='font-bold'>{title}</div>
<div class='text-muted-foreground text-xs whitespace-pre-wrap block'>{description}</div>
</Label>
<slot {id} />
</div>

View file

@ -0,0 +1,32 @@
<script lang='ts'>
import { cubicInOut } from 'svelte/easing'
import { crossfade } from 'svelte/transition'
import { cn } from '$lib/utils.js'
import { page } from '$app/stores'
import { Button } from './ui/button'
let className: string | undefined | null = ''
export let items: Array<{ href: string, title: string }>
export { className as class }
const [send, receive] = crossfade({
duration: 150,
easing: cubicInOut
})
const key = 'active-settings-tab'
</script>
<nav class={cn('flex flex-col md:flex-row space-x-0 md:space-x-2 lg:flex-col lg:space-x-0 lg:space-y-1', className)}>
{#each items as { href, title }, i (i)}
{@const isActive = $page.url.pathname === href}
<Button {href} variant='ghost' data-sveltekit-noscroll class='relative font-semibold justify-start'>
{#if isActive}
<div class='bg-white absolute inset-0 rounded-md' in:send={{ key }} out:receive={{ key }} />
{/if}
<div class='relative text-white transition-colors duration-300' class:!text-black={isActive}>
{title}
</div>
</Button>
{/each}
</nav>

View file

@ -0,0 +1,31 @@
<script lang='ts'>
import { cn } from '$lib/utils'
import type { HTMLAttributes } from 'svelte/elements'
import { tv, type VariantProps } from 'tailwind-variants'
const dotvariants = tv({
base: 'inline-flex w-[0.55rem] h-[0.55rem] me-1 bg-blue-600 rounded-full',
variants: {
variant: {
CURRENT: 'bg-[rgb(61,180,242)]',
PLANNING: 'bg-[rgb(247,154,99)]',
COMPLETED: 'bg-[rgb(123,213,85)]',
PAUSED: 'bg-[rgb(250,122,122)]',
REPEATING: 'bg-[#3baeea]',
DROPPED: 'bg-[rgb(232,93,117)]'
}
},
defaultVariants: {
variant: 'CURRENT'
}
})
export let variant: VariantProps<typeof dotvariants>['variant'] = 'CURRENT'
type $$Props = HTMLAttributes<HTMLSpanElement> & { variant: VariantProps<typeof dotvariants>['variant']}
let className: $$Props['class'] = ''
export { className as class }
</script>
<span class={cn(dotvariants({ variant }), className)} />

View file

@ -0,0 +1,11 @@
<svg xmlns='http://www.w3.org/2000/svg' width='41' height='30' fill='none' viewBox='0 0 41 30' {...$$props}>
<g clip-path='url(#clip0_312_151)'>
<path fill='#00A8FF' d='M27.825 21.773V2.977c0-1.077-.613-1.672-1.725-1.672h-3.795c-1.111 0-1.725.595-1.725 1.672v8.927c0 .251 2.5 1.418 2.565 1.665 1.904 7.21.414 12.982-1.392 13.251 2.952.142 3.277 1.517 1.078.578.337-3.848 1.65-3.84 5.422-.142.032.032.774 1.539.82 1.539h8.91c1.113 0 1.726-.594 1.726-1.672v-3.677c0-1.078-.614-1.672-1.725-1.672H27.825z' />
<path fill='#fff' d='M12.07 1.306l-9.966 27.49h7.743l1.687-4.756h8.433l1.649 4.755h7.705l-9.929-27.49H12.07zm1.227 16.642l2.415-7.615 2.645 7.615h-5.06z' />
</g>
<defs>
<clipPath id='clip0_312_151'>
<path fill='#fff' d='M0 0H40V29H0z' transform='translate(.957 .5)' />
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 831 B

View file

@ -0,0 +1,10 @@
<script lang='ts'>
import { Icon, type Attrs } from 'lucide-svelte'
import type { SvelteHTMLElements } from 'svelte/elements'
const iconNode: Array<[elementName: keyof SvelteHTMLElements, attrs: Attrs]> = [['path', { d: 'M240-44q-48.5 0-82.25-33.75T124-160q0-48.5 33.75-82.25T240-276q14 0 24.25 2.25t21.25 7.25L353-351q-25.5-29-34.25-63.25T314-482.5L216-515q-17 23.5-41 37.25T120-464q-48.5 0-82.25-33.75T4-580q0-48.5 33.75-82.25T120-696q48.5 0 82.25 33.75T236-580v5.5l97.5 34q16.5-31 47.25-54t68.25-30V-728q-39.5-12.5-62.25-43T364-840q0-48.5 33.75-82.25T480-956q48.5 0 82.25 33.75T596-840q0 38.5-23.25 69T511-728v103.5q37.5 7 68 30t47.5 54l97.5-34v-5.5q0-48.5 33.75-82.25T840-696q48.5 0 82.25 33.75T956-580q0 48.5-33.75 82.25T840-464q-31 0-55.5-13.75T744-515l-98 32.5q4 34.5-5 68.25T607-351l67.5 84q11-5 21.25-7t24.25-2q48.5 0 82.25 33.75T836-160q0 48.5-33.75 82.25T720-44q-48.5 0-82.25-33.75T604-160q0-19.5 5.75-36.25T626-228l-67-84.5q-36.5 20-79.25 20.25T400.5-312.5L334-228q10.5 15 16.25 31.75T356-160q0 48.5-33.75 82.25T240-44ZM120-539q17 0 29-12t12-29q0-17-12-29t-29-12q-17 0-29 12t-12 29q0 17 12 29t29 12Zm120 420q17 0 29-12t12-29q0-17-12-29t-29-12q-17 0-29 12t-12 29q0 17 12 29t29 12Zm240-680q17 0 29-12t12-29q0-17-12-29t-29-12q-17 0-29 12t-12 29q0 17 12 29t29 12Zm0 431.5q38.5 0 65.5-27t27-65.5q0-38.5-27-65.5t-65.5-27q-38.5 0-65.5 27t-27 65.5q0 38.5 27 65.5t65.5 27ZM720-119q17 0 29-12t12-29q0-17-12-29t-29-12q-17 0-29 12t-12 29q0 17 12 29t29 12Zm120-420q17 0 29-12t12-29q0-17-12-29t-29-12q-17 0-29 12t-12 29q0 17 12 29t29 12ZM480-840ZM120-580Zm360 120Zm360-120ZM240-160Zm480 0Z' }]]
</script>
<Icon name='hub' {...$$props} {iconNode} viewBox='0 -960 960 960'>
<slot />
</Icon>

View file

@ -0,0 +1,14 @@
<script lang='ts'>
import { Icon, type Attrs } from 'lucide-svelte'
import type { SvelteHTMLElements } from 'svelte/elements'
const iconNode: Array<[elementName: keyof SvelteHTMLElements, attrs: Attrs]> = [
['path', { d: 'M378.5 234.9c-12.9 3.7-24.9 10.7-49.5 28.9-53 39.4-92.7 50.9-143.5 41.6-1.6-.3 2.2 2 8.5 5.1 57 28.4 92.4 20.5 152.8-34 23.9-21.5 50-33.1 78.2-34.7 4.5-.3 4.5-.3-4-3.5-15-5.8-30.1-7-42.5-3.4' }],
['path', { d: 'M437.6 250.6c-24.6 4.8-49.4 17.6-100.1 51.4-44.4 29.6-66.6 41.8-83.4 46.1-30.1 7.6-84.9-6.8-140-37-12.4-6.7-12.2-6.3 2.2 6 61.4 52.7 82.6 64.2 118.2 64.3 34.7.1 62.5-12.6 119.7-54.7 57.6-42.4 76.7-51.9 106.8-53.3 24-1.2 53 6.8 69.8 19.3 5.3 4 4.7 2.1-2.1-5.8-26.8-31.3-56.6-43.2-91.1-36.3' }],
['path', { d: 'M459 286.4c-41.9 9.8-88.1 45.4-141.4 108.8-4.5 5.4-5.3 8.5-1 3.7 2.7-3 23.9-20.1 37.9-30.6 69.4-51.9 129.5-72.6 164-56.6 3.3 1.5 6.4 2.9 7 3.2 1.6.7-5.7-8-10.8-12.8-14.5-13.8-37-20.1-55.7-15.7' }]
]
</script>
<Icon name='logo' {...$$props} {iconNode} viewBox='0 0 640 640'>
<slot />
</Icon>

View file

@ -0,0 +1,14 @@
<script lang='ts'>
import { cn } from '$lib/utils'
import { Icon, type Attrs } from 'lucide-svelte'
import type { SvelteHTMLElements } from 'svelte/elements'
const iconNode: Array<[elementName: keyof SvelteHTMLElements, attrs: Attrs]> = [['path', { fill: 'currentColor', d: 'M8.273 7.247v8.423l-2.103-.003v-5.216l-2.03 2.404l-1.989-2.458l-.02 5.285H.001L0 7.247h2.203l1.865 2.545l2.015-2.546zm8.628 2.069l.025 6.335h-2.365l-.008-2.871h-2.8c.07.499.21 1.266.417 1.779c.155.381.298.751.583 1.128l-1.705 1.125c-.349-.636-.622-1.337-.878-2.082a9.3 9.3 0 0 1-.507-2.179c-.085-.75-.097-1.471.107-2.212a3.9 3.9 0 0 1 1.161-1.866c.313-.293.749-.5 1.1-.687s.743-.264 1.107-.359a7.4 7.4 0 0 1 1.191-.183c.398-.034 1.107-.066 2.39-.028l.545 1.749H14.51c-.593.008-.878.001-1.341.209a2.24 2.24 0 0 0-1.278 1.92l2.663.033l.038-1.81zm3.992-2.099v6.627l3.107.032l-.43 1.775h-4.807V7.187z' }]]
let className = ''
export { className as class }
</script>
<Icon name='myanimelist' class={cn(className, 'bg-[#3557a5] rounded px-[2px] text-white')} color='none' {...$$restProps} {iconNode} strokeWidth='1' viewBox='0 0 24 24'>
<slot />
</Icon>

View file

@ -0,0 +1,16 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
type $$Props = AvatarPrimitive.FallbackProps;
let className: $$Props["class"] = undefined;
export { className as class };
</script>
<AvatarPrimitive.Fallback
class={cn("bg-muted flex h-full w-full items-center justify-center rounded-full", className)}
{...$$restProps}
>
<slot />
</AvatarPrimitive.Fallback>

View file

@ -0,0 +1,18 @@
<script lang='ts'>
import { Avatar as AvatarPrimitive } from 'bits-ui'
import { cn } from '$lib/utils.js'
type $$Props = AvatarPrimitive.ImageProps
let className: $$Props['class']
export let src: $$Props['src']
export let alt: $$Props['alt']
export { className as class }
</script>
<AvatarPrimitive.Image
{src}
{alt}
class={cn('aspect-square h-full w-full', className)}
{...$$restProps}
/>

View file

@ -0,0 +1,18 @@
<script lang='ts'>
import { Avatar as AvatarPrimitive } from 'bits-ui'
import { cn } from '$lib/utils.js'
type $$Props = AvatarPrimitive.Props
let className: $$Props['class']
export let delayMs: $$Props['delayMs'] = 0
export { className as class }
</script>
<AvatarPrimitive.Root
{delayMs}
class={cn('relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full', className)}
{...$$restProps}
>
<slot />
</AvatarPrimitive.Root>

View file

@ -0,0 +1,13 @@
import Root from './avatar.svelte'
import Image from './avatar-image.svelte'
import Fallback from './avatar-fallback.svelte'
export {
Root,
Image,
Fallback,
//
Root as Avatar,
Image as AvatarImage,
Fallback as AvatarFallback
}

View file

@ -0,0 +1,18 @@
<script lang='ts'>
import { type Variant, badgeVariants } from './index.js'
import { cn } from '$lib/utils.js'
let className: string | undefined | null
export let href: string | undefined
export let variant: Variant = 'default'
export { className as class }
</script>
<svelte:element
this={href ? 'a' : 'span'}
{href}
class={cn(badgeVariants({ variant, className }))}
{...$$restProps}
>
<slot />
</svelte:element>

View file

@ -0,0 +1,22 @@
import { type VariantProps, tv } from 'tailwind-variants'
export { default as Badge } from './badge.svelte'
export const badgeVariants = tv({
base: 'focus:ring-ring inline-flex select-none items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2',
variants: {
variant: {
default:
'bg-primary text-primary-foreground select:bg-primary/80 border-transparent shadow',
secondary:
'bg-secondary text-secondary-foreground select:bg-secondary/80 border-transparent',
destructive:
'bg-destructive text-destructive-foreground select:bg-destructive/80 border-transparent shadow',
outline: 'text-foreground'
}
},
defaultVariants: {
variant: 'default'
}
})
export type Variant = VariantProps<typeof badgeVariants>['variant']

View file

@ -0,0 +1,27 @@
<script lang='ts' context='module'>
import { safeBanner, type Media } from '$lib/modules/anilist'
import { cn } from '$lib/utils'
import { writable } from 'simple-store-svelte'
export const bannerSrc = writable<Media | null>(null)
export const hideBanner = writable(false)
</script>
<script lang='ts'>
import type { HTMLAttributes } from 'svelte/elements'
type $$Props = HTMLAttributes<HTMLImageElement>
$: src = $bannerSrc && safeBanner($bannerSrc)
let className: $$Props['class'] = ''
export { className as class } // TODO: needs nice animations, should update to coverimage on mobile width
</script>
{#await src then src}
<div class={cn('object-cover w-screen absolute top-0 left-0 h-full overflow-hidden pointer-events-none bg-black', className)}>
{#if src}
<div class='min-w-[100vw] w-screen h-[30rem] bg-url bg-center bg-cover opacity-90 transition-opacity duration-500 border-gradient-to-t' style:--bg='url({src})' class:!opacity-15={$hideBanner} />
{/if}
</div>
{/await}

View file

@ -0,0 +1,44 @@
<script lang='ts' context='module'>
import { client, currentSeason, currentYear } from '$lib/modules/anilist'
const query = client.search({ sort: ['POPULARITY_DESC'], perPage: 15, onList: false, season: currentSeason, seasonYear: currentYear, statusNot: ['NOT_YET_RELEASED'] }, true)
query.subscribe(() => undefined) // this is hacky as shit, but prevents query from re-running
</script>
<script lang='ts'>
import FullBanner from './full-banner.svelte'
import SkeletonBanner from './skeleton-banner.svelte'
import { get } from 'svelte/store'
if (get(query.isPaused$)) query.resume()
</script>
<div class='w-full h-[450px] relative'>
<!-- really shit and hacky way of fixing scroll position jumping when banner changes height -->
<div class='absolute top-0 transparent h-[450px] opacity-0'>.</div>
{#if $query.fetching}
<SkeletonBanner />
{/if}
{#if $query.error}
<div class='p-5 flex items-center justify-center w-full h-72'>
<div>
<div class='mb-1 font-bold text-4xl text-center '>
Ooops!
</div>
<div class='text-lg text-center text-muted-foreground'>
Looks like something went wrong!.
</div>
<div class='text-lg text-center text-muted-foreground'>
{$query.error.message}
</div>
</div>
</div>
{/if}
{#if $query.data}
{#if $query.data.Page?.media}
<FullBanner mediaList={$query.data.Page.media} />
{:else}
<SkeletonBanner />
{/if}
{/if}
</div>

View file

@ -0,0 +1,140 @@
<script lang='ts'>
import { desc, duration, format, season, title, type Media } from '$lib/modules/anilist'
import { click } from '$lib/modules/navigate'
import { onDestroy } from 'svelte'
import { BookmarkButton, FavoriteButton, PlayButton } from '../button'
import { bannerSrc } from './banner-image.svelte'
import { of } from '$lib/modules/auth'
export let mediaList: Array<Media | null>
function shuffle <T extends unknown[]> (array: T): T {
let currentIndex = array.length
let randomIndex
while (currentIndex > 0) {
randomIndex = Math.floor(Math.random() * currentIndex--);
[array[currentIndex], array[randomIndex]] = [array[randomIndex], array[currentIndex]]
}
return array
}
function shuffleAndFilter (media: Array<Media | null>) {
return shuffle(media).filter(media => media?.bannerImage ?? media?.trailer?.id).slice(0, 5) as Media[]
}
const shuffled = shuffleAndFilter(mediaList)
let current = shuffled[0]
const initial = bannerSrc.value
$: bannerSrc.value = current
onDestroy(() => {
bannerSrc.value = initial
})
function currentIndex () {
return shuffled.indexOf(current)
}
function schedule (index: number) {
return setTimeout(() => {
current = shuffled[index % shuffled.length]
timeout = schedule(index + 1)
}, 15000)
}
let timeout = schedule(currentIndex() + 1)
function setCurrent (media: Media) {
if (current === media) return
clearTimeout(timeout)
current = media
timeout = schedule(currentIndex() + 1)
}
</script>
<div class='pl-5 pb-5 justify-end flex flex-col h-full max-w-full'>
{#key current}
<div class='text-white font-black text-4xl line-clamp-2 w-[800px] max-w-full leading-tight fade-in'>
{title(current)}
</div>
<div class='details text-white capitalize pt-3 pb-2 flex w-[600px] max-w-full text-xs fade-in'>
<span class='text-nowrap flex items-center'>
{format(current)}
</span>
<span class='text-nowrap flex items-center'>
{of(current) ?? duration(current) ?? 'N/A'}
</span>
<span class='text-nowrap flex items-center'>
{season(current)}
</span>
</div>
<div class='text-muted-foreground line-clamp-2 w-[600px] max-w-full text-sm fade-in'>
{desc(current)}
</div>
<div class='details text-white text-capitalize py-3 flex w-[600px] max-w-full text-xs fade-in'>
{#each current.genres ?? [] as genre (genre)}
<span class='text-nowrap flex items-center'>
{genre}
</span>
{/each}
</div>
<div class='flex flex-row pb-2 w-[230px] max-w-full'>
<PlayButton media={current} class='grow' />
<FavoriteButton media={current} class='ml-2' />
<BookmarkButton media={current} class='ml-2' />
</div>
{/key}
<div class='flex'>
{#each shuffled as media (media.id)}
{@const active = current === media}
<div class='pt-2 pb-1' class:cursor-pointer={!active} use:click={() => setCurrent(media)}>
<div class='bg-neutral-800 mr-2 progress-badge overflow-hidden rounded' class:active style='height: 4px;' style:width={active ? '3rem' : '1.5rem'}>
<div class='progress-content h-full' class:bg-white={active} />
</div>
</div>
{/each}
</div>
</div>
<style>
.progress-badge {
transition: width .7s ease;
}
.progress-badge.active .progress-content {
animation: fill 15s linear;
}
@keyframes fill {
from {
width: 0;
}
to {
width: 100%;
}
}
.details span + span::before {
content: '•';
padding: 0 .5rem;
font-size: .6rem;
align-self: center;
white-space: normal;
color: #737373 !important;
}
.fade-in {
animation: fadeIn ease .8s;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</style>

View file

@ -0,0 +1,3 @@
export { default as BannerImage } from './banner-image.svelte'
export { default as Banner } from './banner.svelte'
export * from './banner-image.svelte'

View file

@ -0,0 +1,11 @@
<div class='pl-5 pb-5 justify-end flex flex-col h-full max-w-full'>
<div class='bg-primary/5 animate-pulse rounded w-[500px] h-6 mb-1' />
<div class='my-5 h-1.5 w-[250px] bg-primary/5 animate-pulse rounded' />
<div class='h-2.5 w-[450px] bg-primary/5 animate-pulse rounded mb-2' />
<div class='h-2.5 w-[350px] bg-primary/5 animate-pulse rounded mb-2' />
<div class='h-2.5 w-[300px] bg-primary/5 animate-pulse rounded mb-2' />
<div class='h-2.5 w-[250px] bg-primary/5 animate-pulse rounded mb-2' />
<div class='my-3 h-1.5 w-[150px] bg-primary/5 animate-pulse rounded' />
<div class='mb-4 h-6 w-[160px] bg-primary/5 animate-pulse rounded' />
<div class='mb-3' />
</div>

View file

@ -0,0 +1,30 @@
<script lang='ts'>
import { Bookmark } from 'lucide-svelte'
import { Button, iconSizes, type Props } from '$lib/components/ui/button'
import type { Media } from '$lib/modules/anilist'
import { list, authAggregator, lists } from '$lib/modules/auth'
import { clickwrap, keywrap } from '$lib/modules/navigate'
type $$Props = Props & { media: Media }
let className: $$Props['class'] = ''
export { className as class }
export let media: Media
export let size: NonNullable<$$Props['size']> = 'icon-sm'
export let variant: NonNullable<$$Props['variant']> = 'ghost'
const hasAuth = authAggregator.hasAuth
function toggleBookmark () {
if (!media.mediaListEntry?.status) {
authAggregator.entry({ id: media.id, status: 'PLANNING', lists: lists(media)?.filter(({ enabled }) => enabled).map(({ name }) => name) })
} else {
authAggregator.delete(media.mediaListEntry.id)
}
}
</script>
<Button {size} {variant} class={className} disabled={!$hasAuth} on:click={clickwrap(toggleBookmark)} on:keydown={keywrap(toggleBookmark)}>
<Bookmark fill={list(media) ? 'currentColor' : 'transparent'} size={iconSizes[size]} />
</Button>

View file

@ -0,0 +1,23 @@
<script lang='ts'>
import { Button as ButtonPrimitive } from 'bits-ui'
import { type Props, buttonVariants } from './index.js'
import { cn } from '$lib/utils.js'
type $$Props = Props
let className: $$Props['class'] = ''
export let variant: $$Props['variant'] = 'default'
export let size: $$Props['size'] = 'default'
export let builders: $$Props['builders'] = []
export { className as class }
</script>
<ButtonPrimitive.Root
{builders}
class={cn(buttonVariants({ variant, size, className }))}
type='button'
{...$$restProps}
on:click
on:keydown>
<slot />
</ButtonPrimitive.Root>

View file

@ -0,0 +1,21 @@
<script lang='ts'>
import { Heart } from 'lucide-svelte'
import { Button, iconSizes, type Props } from '$lib/components/ui/button'
import { authAggregator, fav } from '$lib/modules/auth'
import type { Media } from '$lib/modules/anilist'
import { clickwrap, keywrap } from '$lib/modules/navigate'
type $$Props = Props & { media: Media }
let className: $$Props['class'] = ''
export { className as class }
export let media: Media
export let size: NonNullable<$$Props['size']> = 'icon-sm'
export let variant: NonNullable<$$Props['variant']> = 'ghost'
const hasAuth = authAggregator.hasAuth
</script>
<Button {size} {variant} class={className} disabled={!$hasAuth} on:click={clickwrap(() => authAggregator.toggleFav(media.id))} on:keydown={keywrap(() => authAggregator.toggleFav(media.id))}>
<Heart fill={fav(media) ? 'currentColor' : 'transparent'} size={iconSizes[size]} />
</Button>

View file

@ -0,0 +1,65 @@
import type { Button as ButtonPrimitive } from 'bits-ui'
import { type VariantProps, tv } from 'tailwind-variants'
import Root from './button.svelte'
import Play from './play.svelte'
import Favorite from './favorite.svelte'
import Bookmark from './bookmark.svelte'
const buttonVariants = tv({
base: 'focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50',
variants: {
variant: {
default: 'bg-primary text-primary-foreground select:bg-primary/90 shadow',
destructive: 'bg-destructive text-destructive-foreground select:bg-destructive/90 shadow-sm',
outline: 'border-input bg-background select:bg-accent select:text-accent-foreground border shadow-sm',
secondary: 'bg-secondary text-secondary-foreground select:bg-secondary/80 shadow-sm',
ghost: 'select:bg-accent select:text-accent-foreground',
link: 'text-primary underline-offset-4 select:underline'
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
xs: 'h-[1.6rem] rounded-sm px-2 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
'icon-sm': 'h-[1.6rem] w-[1.6rem] rounded-sm text-xs'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
})
const iconSizes = {
xs: '0.6rem',
sm: '0.7rem',
default: '0.8rem',
lg: '1.2rem',
icon: '1rem',
'icon-sm': '0.7rem'
}
type Variant = VariantProps<typeof buttonVariants>['variant']
type Size = VariantProps<typeof buttonVariants>['size']
type Props = ButtonPrimitive.Props & {
variant?: Variant
size?: Size
}
type Events = ButtonPrimitive.Events
export {
Root,
type Props,
type Events,
Root as Button,
type Props as ButtonProps,
type Events as ButtonEvents,
buttonVariants,
iconSizes,
Play as PlayButton,
Favorite as FavoriteButton,
Bookmark as BookmarkButton
}

View file

@ -0,0 +1,33 @@
<script lang='ts'>
import { Play } from 'lucide-svelte'
import { Button, iconSizes, type Props } from '$lib/components/ui/button'
import { cn } from '$lib/utils'
import { list, progress } from '$lib/modules/auth'
import type { Media } from '$lib/modules/anilist'
import { clickwrap, keywrap } from '$lib/modules/navigate'
import { searchStore } from '$lib/components/SearchModal.svelte'
type $$Props = Props & { media: Media }
let className: $$Props['class'] = ''
export { className as class }
export let media: Media
export let size: NonNullable<$$Props['size']> = 'xs'
function play () {
const episode = progress(media) ?? 1
// TODO: set rewatch state
searchStore.set({ media, episode })
}
</script>
<Button class={cn(className, 'font-bold flex items-center justify-center')} {size} on:click={clickwrap(play)} on:keydown={keywrap(play)}>
<Play fill='currentColor' class='mr-2' size={iconSizes[size]} />
{@const status = list(media)}
{#if status === 'COMPLETED'}
Rewatch Now
{:else if status === 'CURRENT' || status === 'REPEATING' || status === 'PAUSED'}
Continue
{:else}
Watch Now
{/if}
</Button>

View file

@ -0,0 +1,60 @@
<script lang='ts'>
import { Button as ButtonPrimitive } from 'bits-ui'
import { type Props, buttonVariants } from './index.js'
import { cn } from '$lib/utils.js'
type $$Props = Props & { duration?: number, autoStart?: boolean, onclick: () => void, animating?: boolean }
export let duration = 5000 // timeout duration in ms
export let autoStart = false
export let variant: Props['variant'] = 'default'
export let size: NonNullable<$$Props['size']> = 'xs'
export let onclick: () => void
let className: $$Props['class'] = ''
export { className as class }
export let animating = false
function startAnimation () {
animating = true
}
function stopAnimation () {
animating = false
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (autoStart) startAnimation()
function handleAnimationEnd () {
animating = false
onclick()
}
</script>
<ButtonPrimitive.Root
class={cn(
buttonVariants({ variant, size, className }),
'relative overflow-hidden'
)}
type='button'
on:click={stopAnimation}
on:click={onclick}>
<slot />
<div
class='absolute inset-0 bg-current opacity-20 pointer-events-none'
class:animate-progress={animating}
style='animation-duration: {duration}ms;'
on:animationend={handleAnimationEnd} />
</ButtonPrimitive.Root>
<style>
@keyframes progressBar {
from { transform: translateX(0%); }
to { transform: translateX(100%); }
}
.animate-progress {
animation: progressBar linear forwards;
}
</style>

View file

@ -0,0 +1,108 @@
<script lang='ts'>
import { click } from '$lib/modules/navigate'
import { VolumeX, Volume2 } from 'lucide-svelte'
import { createEventDispatcher, onDestroy } from 'svelte'
export let id: string
const dispatch = createEventDispatcher<{hide: boolean}>()
function ytMessage (e: MessageEvent) {
if (e.origin !== 'https://www.youtube-nocookie.com') return
clearInterval(timeout)
const json = JSON.parse(e.data as string) as { event: string, info: {videoData: {isPlayable: boolean}, playerState?: number} }
if (json.event === 'onReady') ytCall('setVolume', '[30]')
if (json.event === 'initialDelivery' && !json.info.videoData.isPlayable) {
dispatch('hide', true)
}
if (json.event === 'infoDelivery' && json.info.playerState === 1) {
hide = false
dispatch('hide', false)
}
}
let muted = true
function toggleMute () {
if (muted) {
ytCall('unMute')
} else {
ytCall('mute')
}
muted = !muted
}
let hide = true
let frame: HTMLIFrameElement
function ytCall (action: string, arg: string | null = null) {
frame.contentWindow?.postMessage('{"event":"command", "func":"' + action + '", "args":' + arg + '}', '*')
}
let timeout: ReturnType<typeof setInterval>
function initFrame () {
timeout = setInterval(() => {
frame.contentWindow?.postMessage('{"event":"listening","id":1,"channel":"widget"}', '*')
}, 100)
frame.contentWindow?.postMessage('{"event":"listening","id":1,"channel":"widget"}', '*')
}
onDestroy(() => {
clearInterval(timeout)
})
</script>
<svelte:window on:message={ytMessage} />
<!-- indivious is nice because its faster, but not reliable -->
<!-- <video src={`https://inv.tux.pizza/latest_version?id=${media.trailer.id}&itag=18`}
class='w-full h-full position-absolute left-0'
class:d-none={hide}
playsinline
preload='none'
loop
use:volume
bind:muted
on:loadeddata={() => { hide = false }}
autoplay /> -->
<div class='h-full w-full overflow-hidden absolute top-0 rounded-t'>
<div class='absolute z-10 top-0 right-0 p-3' class:hide use:click={toggleMute}>
{#if muted}
<VolumeX size='1rem' fill='currentColor' class='pointer-events-none' />
{:else}
<Volume2 size='1rem' fill='currentColor' class='pointer-events-none' />
{/if}
</div>
<iframe
class='w-full border-0 absolute left-0 h-[200%] top-1/2 transform -translate-y-1/2 pointer-events-none'
class:hide
title='trailer'
allow='autoplay'
allowfullscreen
bind:this={frame}
on:load={initFrame}
src='https://www.youtube-nocookie.com/embed/{id}?enablejsapi=1&autoplay=1&controls=0&mute=1&disablekb=1&loop=1&playlist={id}&cc_lang_pref=ja'
/>
</div>
<div class='h-full w-full overflow-hidden absolute top-0 rounded-t blur-2xl saturate-200 -z-10 pointer-events-none'>
<iframe
class='w-full border-0 absolute left-0 h-[200%] top-1/2 transform -translate-y-1/2'
class:hide
title='trailer'
allow='autoplay'
allowfullscreen
src='https://www.youtube-nocookie.com/embed/{id}?autoplay=1&controls=0&mute=1&disablekb=1&loop=1&playlist={id}&cc_lang_pref=ja'
/>
</div>
<style>
.absolute {
transition: opacity 0.3s;
}
.absolute.hide {
opacity: 0;
}
</style>

View file

@ -0,0 +1,3 @@
export { default as SmallCard } from './small.svelte'
export { default as SkeletonCard } from './skeleton.svelte'
export { default as QueryCard } from './query.svelte'

View file

@ -0,0 +1,78 @@
<script lang='ts'>
import { BookmarkButton, FavoriteButton, PlayButton } from '../button'
import { desc, duration, format, season, title, type Media } from '$lib/modules/anilist'
import YoutubeIframe from './YoutubeIframe.svelte'
import { cn } from '$lib/utils'
import { of } from '$lib/modules/auth'
import { Banner } from '../img'
export let media: Media
let hideFrame: boolean | null = null
function hide (e: CustomEvent<boolean>) {
hideFrame = e.detail
}
</script>
<div class='!absolute w-[17.5rem] h-80 top-0 bottom-0 m-auto bg-neutral-950 z-30 rounded cursor-pointer absolute-container -left-full -right-full'>
<div class='h-[45%] banner relative bg-black rounded-t'>
<Banner {media} class={cn('object-cover w-full h-full blur-2xl saturate-200 absolute -z-10', hideFrame === false && 'hidden')} />
<Banner {media} class='object-cover w-full h-full rounded-t' />
{#if media.trailer?.id && !hideFrame}
<YoutubeIframe id={media.trailer.id} on:hide={hide} />
{/if}
</div>
<div class='w-full px-4 bg-neutral-950'>
<div class='text-lg font-bold truncate inline-block w-full text-white' title={title(media)}>
{title(media)}
</div>
<div class='flex flex-row pt-1'>
<PlayButton {media} class='grow' />
<FavoriteButton {media} class='ml-2' />
<BookmarkButton {media} class='ml-2' />
</div>
<div class='details text-white capitalize pt-3 pb-2 flex text-[.7rem]'>
<span class='text-nowrap flex items-center'>
{format(media)}
</span>
<span class='text-nowrap flex items-center'>
{of(media) ?? duration(media) ?? 'N/A' }
</span>
<span class='text-nowrap flex items-center'>
{season(media)}
</span>
</div>
<div class='w-full h-full overflow-hidden text-[.7rem] text-muted-foreground line-clamp-4'>
{desc(media)}
</div>
</div>
</div>
<style>
.banner::after {
content: '';
position: absolute;
left: 0 ; bottom: 0;
/* when clicking, translate fucks up the position, and video might leak down 1 or 2 pixels, stickig under the gradient, look bad */
margin-bottom: -2px;
width: 100%; height: 100% ;
background: linear-gradient(180deg, #0000 0%, #0a0a0a00 80%, #0a0a0ae3 95%, #0a0a0a 100%);
}
.absolute-container {
animation: 0.3s ease 0s 1 load-in;
transform: translate3d(0,0,0);
}
@keyframes load-in {
from {
bottom: -1.2rem;
opacity: 0;
transform: scale(0.95);
}
to {
bottom: 0;
opacity: 1;
transform: scale(1);
}
}
</style>

View file

@ -0,0 +1,80 @@
<script lang='ts'>
import { SkeletonCard, SmallCard } from '$lib/components/ui/cards'
import type { client } from '$lib/modules/anilist'
export let query: ReturnType<typeof client.search>
$: paused = query.isPaused$
function deferredLoad (element: HTMLDivElement) {
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
query.resume()
observer.unobserve(element)
}
}, { threshold: 0 })
observer.observe(element)
return { destroy () { observer.unobserve(element) } }
}
// TODO: each block sometimes errors with duplicate keys, why?
// function dedupe (media: Array<Media|null|undefined>) {
// const seen = new Set()
// return media.filter((m) => {
// if (seen.has(m?.id)) return false
// seen.add(m?.id)
// return true
// })
// }
</script>
{#if $paused}
<div class='w-0 h-0' use:deferredLoad />
{/if}
{#if $query.fetching || $paused}
{#each Array.from({ length: 50 }) as _, i (i)}
<SkeletonCard />
{/each}
{:else if $query.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'>
{$query.error.message}
</div>
</div>
</div>
{:else if $query.data}
{#if $query.data.Page?.media}
{#each $query.data.Page.media as media, i (media?.id ?? '#' + i)}
{#if media}
<SmallCard {media} />
{/if}
{:else}
<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 there's nothing here.
</div>
</div>
</div>
{/each}
{:else}
{#each Array.from({ length: 50 }) as _, i (i)}
<SkeletonCard />
{/each}
{/if}
{:else}
{#each Array.from({ length: 50 }) as _, i (i)}
<SkeletonCard />
{/each}
{/if}

View file

@ -0,0 +1,14 @@
<div class='p-4 shrink-0'>
<div class='item w-[9.5rem] flex flex-col'>
<div class='h-[13.5rem] w-full bg-primary/5 animate-pulse rounded' />
<div class='mt-4 bg-primary/5 animate-pulse rounded h-2 w-28' />
<div class='mt-2 bg-primary/5 animate-pulse rounded h-2 w-20' />
</div>
</div>
<style>
.item {
animation: 0.3s ease 0s 1 load-in;
aspect-ratio: 152/290;
}
</style>

View file

@ -0,0 +1,55 @@
<script lang='ts'>
import { CalendarDays, Tv } from 'lucide-svelte'
import PreviewCard from './preview.svelte'
import { cover, format, title } from '$lib/modules/anilist/util'
import type { Media } from '$lib/modules/anilist/types'
import { hover } from '$lib/modules/navigate'
import { goto } from '$app/navigation'
import StatusDot from '../../StatusDot.svelte'
import { Load } from '../img'
export let media: Media
let hidden = true
function onclick () {
goto(`/anime/${media.id}`)
}
function onhover (state: boolean) {
hidden = !state
}
</script>
<div class='text-white p-4 cursor-pointer shrink-0 relative pointer-events-auto' class:z-40={!hidden} use:hover={[onclick, onhover]}>
{#if !hidden}
<PreviewCard {media} />
{/if}
<div class='item w-[9.5rem] flex flex-col'>
<div class='h-[13.5rem]'>
<Load src={cover(media)} alt='cover' class='object-cover w-full h-full rounded' color={media.coverImage?.color} />
</div>
<div class='pt-3 font-black text-[.8rem] line-clamp-2'>
{#if media.mediaListEntry?.status}
<StatusDot variant={media.mediaListEntry.status} />
{/if}
{title(media)}
</div>
<div class='flex text-neutral-500 mt-auto pt-2 justify-between'>
<div class='flex text-xs font-medium'>
<CalendarDays class='w-[1rem] h-[1rem] mr-1 -ml-0.5' />
{media.seasonYear ?? 'TBA'}
</div>
<div class='flex text-xs font-medium'>
{format(media)}
<Tv class='w-[1rem] h-[1rem] ml-1 -mr-0.5' />
</div>
</div>
</div>
</div>
<style>
.item {
animation: 0.3s ease 0s 1 load-in;
aspect-ratio: 152/290;
}
</style>

View file

@ -0,0 +1,53 @@
<script lang='ts'>
import type { Writable } from 'simple-store-svelte'
import { getPFP, type ChatMessage } from '.'
export let messages: Writable<ChatMessage[]>
function groupMessages (messages: ChatMessage[]) {
if (!messages.length) return []
const grouped = []
for (const { message, user, type, date } of messages) {
const last = grouped[grouped.length - 1]
if (grouped.length && last.user.id === user.id) {
last.messages.push(message)
} else {
grouped.push({ user, messages: [message], type, date })
}
}
return grouped
}
</script>
{#each groupMessages($messages) as { type, user, date, messages }, i (i)}
{@const incoming = type === 'incoming'}
<div class='message flex flex-row mt-3' class:flex-row={incoming} class:flex-row-reverse={!incoming}>
<img src={getPFP(user)} alt='ProfilePicture' class='w-10 h-10 rounded-full p-1 mt-auto' loading='lazy' decoding='async' />
<div class='flex flex-col px-2 items-start flex-auto' class:items-start={incoming} class:items-end={!incoming}>
<div class='pb-1 flex flex-row items-center px-1'>
<div class='font-bold text-sm'>
{user.nick}
</div>
<div class='text-muted-foreground pl-2 text-[10px] leading-relaxed'>
{date.toLocaleTimeString()}
</div>
</div>
{#each messages as message, i (i)}
<div class='bg-muted py-2 px-3 rounded-t-xl rounded-r-xl mb-1 select-all text-xs whitespace-pre-wrap max-w-[calc(100%-100px)]'
class:!bg-theme={!incoming}
class:rounded-r-xl={incoming} class:rounded-l-xl={!incoming}>
{message}
</div>
{/each}
</div>
</div>
{/each}
<style>
.message {
--base-border-radius: 1.3rem;
}
.flex-auto {
flex: auto;
}
</style>

View file

@ -0,0 +1,39 @@
<script lang='ts'>
import type { Writable } from 'svelte/store'
import { getPFP, type ChatUser } from '.'
import { ExternalLink } from 'lucide-svelte'
import { click } from '$lib/modules/navigate'
import native from '$lib/modules/native'
export let users: Writable<Record<string, ChatUser>>
function processUsers (users: ChatUser[]) {
return users.map(user => {
return {
...user,
pfp: getPFP(user)
}
})
}
$: processed = processUsers(Object.values($users))
</script>
<div class='flex flex-col w-72 max-w-full px-5 overflow-hidden'>
<div class='text-md font-bold pl-1 pb-2'>
{processed.length} Member(s)
</div>
<div>
{#each processed as { id, pfp, nick } (id)}
<div class='flex items-center pb-2'>
<img src={pfp} alt='ProfilePicture' class='w-10 h-10 rounded-full p-1 mt-auto' loading='lazy' decoding='async' />
<div class='text-md pl-2'>
{nick}
</div>
<span class='cursor-pointer flex items-center ml-auto text-blue-600' use:click={() => native.openURL('https://anilist.co/user/' + id)}>
<ExternalLink size='18' />
</span>
</div>
{/each}
</div>
</div>

View file

@ -0,0 +1,26 @@
export type UserType = 'al' | 'guest'
export interface ChatUser {
nick: string
id: string
pfpid: string
type: UserType
}
export interface ChatMessage {
message: string
user: ChatUser
type: 'incoming' | 'outgoing'
date: Date
}
export function getPFP (user: ChatUser) {
if (user.type === 'al') {
return `https://s4.anilist.co/file/anilistcdn/user/avatar/medium/b${user.id}-${user.pfpid}`
} else {
return 'https://s4.anilist.co/file/anilistcdn/user/avatar/medium/default.png'
}
}
export { default as UserList } from './UserList.svelte'
export { default as Messages } from './Messages.svelte'

View file

@ -0,0 +1,125 @@
<script lang='ts' context='module'>
export function fromobj (object: Record<string, string>, key: string): { items: Array<{ value: string, label: string }>, value: Array<{ value: string, label: string }> } {
return {
items: Object.entries(object).map(([value, label]) => ({ value, label })),
value: [{ value: '' + key, label: object[key] }]
}
}
</script>
<script lang='ts'>
import { CaretSort, Check } from 'svelte-radix'
import { tick } from 'svelte'
import * as Command from '$lib/components/ui/command'
import * as Popover from '$lib/components/ui/popover'
import { Button } from '$lib/components/ui/button'
import { cn } from '$lib/utils.js'
import { X } from 'lucide-svelte'
import { intputType } from '$lib/modules/navigate'
interface value {
value: string
label: string
}
export let items: value[] = []
export let placeholder = 'Any'
let open = false
export let value: value[] = []
export let multiple = false
$: selectedValue = value.map(({ label }) => label).join(', ') || placeholder
// We want to refocus the trigger button when the user selects
// an item from the list so users can continue navigating the
// rest of the form with the keyboard.
function closeAndFocusTrigger (triggerId: string) {
open = false
tick().then(() => {
document.getElementById(triggerId)?.focus()
})
}
export let onSelect: (value: value) => void = () => undefined
function handleSelect (selected: value, triggerId: string) {
onSelect(selected)
if (!multiple) {
value = [selected]
closeAndFocusTrigger(triggerId)
return
}
if (value.includes(selected)) {
value = value.filter(item => item !== selected)
} else {
value = [...value, selected]
}
}
let className = ''
export { className as class }
</script>
<Popover.Root bind:open let:ids>
<Popover.Trigger asChild let:builder>
<Button
builders={[builder]}
variant='outline'
role='combobox'
aria-expanded={open}
class={cn('justify-between border-0 min-w-0', className)}>
<div class='w-full text-ellipsis overflow-hidden text-left' class:text-muted-foreground={!value.length} class:opacity-50={!value.length}>
{#key value}
{selectedValue}
{/key}
</div>
<CaretSort class='ml-2 h-4 w-4 shrink-0 opacity-50' />
</Button>
</Popover.Trigger>
<Popover.Content class={cn('p-0 border-0')} sameWidth={true}>
<Command.Root>
<Command.Input {placeholder} class='h-9 placeholder:opacity-50' />
<Command.Empty>No results found.</Command.Empty>
{#if $intputType === 'dpad'}
<Command.Group class='shrink-0' alwaysRender={true}>
<Command.Item
alwaysRender={true}
class='cursor-pointer'
onSelect={() => {
closeAndFocusTrigger(ids.trigger)
}}
value='close'>
<X class='mr-2 h-4 w-4' />
Close
</Command.Item>
</Command.Group>
<Command.Separator />
{/if}
<Command.Group class='overflow-y-auto'>
{#each items as item (item.value)}
<Command.Item
class={cn('cursor-pointer', !multiple && 'flex-row-reverse justify-between')}
value={item.value}
onSelect={() => {
handleSelect(item, ids.trigger)
}}>
<div
class={cn(
'flex h-4 w-4 items-center justify-center rounded-sm border-primary',
multiple ? 'border mr-2' : 'ml-2',
value.find(({ value }) => value === item.value)
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
)}>
<Check className={cn('h-4 w-4')} />
</div>
{item.label}
</Command.Item>
{/each}
</Command.Group>
</Command.Root>
</Popover.Content>
</Popover.Root>

View file

@ -0,0 +1,3 @@
export { default as ComboBox } from './combobox.svelte'
export * from './combobox.svelte'
export { default as SingleCombo } from './singlecombo.svelte'

View file

@ -0,0 +1,9 @@
<script lang='ts'>
import Combobox, { fromobj } from './combobox.svelte'
export let value: string
export let items: Record<string, string>
export let onSelected: (item: string) => void = () => undefined
</script>
<Combobox onSelect={item => { value = item.value; onSelected(item.value) }} {...fromobj(items, value)} {...$$restProps} />

View file

@ -0,0 +1,23 @@
<script lang='ts'>
import type { Dialog as DialogPrimitive } from 'bits-ui'
import type { Command as CommandPrimitive } from 'cmdk-sv'
import Command from './command.svelte'
import * as Dialog from '$lib/components/ui/dialog/index.js'
type $$Props = DialogPrimitive.Props & CommandPrimitive.CommandProps
export let open: $$Props['open'] = false
export let value: $$Props['value'] = ''
</script>
<Dialog.Root bind:open {...$$restProps}>
<Dialog.Content class='overflow-hidden p-0 max-h-[30rem]'>
<Command
class='[&_[data-cmdk-group-heading]]:text-muted-foreground [&_[data-cmdk-group-heading]]:px-2 [&_[data-cmdk-group-heading]]:font-medium [&_[data-cmdk-group]:not([hidden])_~[data-cmdk-group]]:pt-0 [&_[data-cmdk-group]]:px-2 [&_[data-cmdk-input-wrapper]_svg]:h-5 [&_[data-cmdk-input-wrapper]_svg]:w-5 [&_[data-cmdk-input]]:h-12 [&_[data-cmdk-item]]:px-2 [&_[data-cmdk-item]]:py-3 [&_[data-cmdk-item]_svg]:h-5 [&_[data-cmdk-item]_svg]:w-5 max-h-[30%]'
{...$$restProps}
bind:value
>
<slot />
</Command>
</Dialog.Content>
</Dialog.Root>

View file

@ -0,0 +1,12 @@
<script lang='ts'>
import { Command as CommandPrimitive } from 'cmdk-sv'
import { cn } from '$lib/utils.js'
type $$Props = CommandPrimitive.EmptyProps
let className: $$Props['class'] = ''
export { className as class }
</script>
<CommandPrimitive.Empty class={cn('py-6 text-center text-sm', className)} {...$$restProps}>
<slot />
</CommandPrimitive.Empty>

View file

@ -0,0 +1,18 @@
<script lang='ts'>
import { Command as CommandPrimitive } from 'cmdk-sv'
import { cn } from '$lib/utils.js'
type $$Props = CommandPrimitive.GroupProps
let className: $$Props['class'] = ''
export { className as class }
</script>
<CommandPrimitive.Group
class={cn(
'text-foreground [&_[data-cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[data-cmdk-group-heading]]:px-2 [&_[data-cmdk-group-heading]]:py-1.5 [&_[data-cmdk-group-heading]]:text-xs [&_[data-cmdk-group-heading]]:font-medium',
className
)}
{...$$restProps}
>
<slot />
</CommandPrimitive.Group>

View file

@ -0,0 +1,23 @@
<script lang='ts'>
import { Command as CommandPrimitive } from 'cmdk-sv'
import MagnifyingGlass from 'svelte-radix/MagnifyingGlass.svelte'
import { cn } from '$lib/utils.js'
type $$Props = CommandPrimitive.InputProps
let className: $$Props['class'] = ''
export { className as class }
export let value = ''
</script>
<div class='flex items-center border-b relative' data-cmdk-input-wrapper="">
<MagnifyingGlass class='mr-2 h-4 w-4 shrink-0 opacity-50 absolute left-3' />
<CommandPrimitive.Input
class={cn(
'placeholder:text-muted-foreground flex h-10 w-full rounded-sm bg-transparent py-3 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50 pl-9 pr-3 overflow-hidden ![border-image:none]',
className
)}
{...$$restProps}
bind:value
/>
</div>

View file

@ -0,0 +1,24 @@
<script lang='ts'>
import { Command as CommandPrimitive } from 'cmdk-sv'
import { cn } from '$lib/utils.js'
type $$Props = CommandPrimitive.ItemProps
export let asChild = false
let className: $$Props['class'] = ''
export { className as class }
</script>
<CommandPrimitive.Item
{asChild}
class={cn(
'aria-selected:bg-accent aria-selected:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...$$restProps}
let:action
let:attrs
>
<slot {action} {attrs} />
</CommandPrimitive.Item>

View file

@ -0,0 +1,15 @@
<script lang='ts'>
import { Command as CommandPrimitive } from 'cmdk-sv'
import { cn } from '$lib/utils.js'
type $$Props = CommandPrimitive.ListProps
let className: $$Props['class'] = ''
export { className as class }
</script>
<CommandPrimitive.List
class={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
{...$$restProps}
>
<slot />
</CommandPrimitive.List>

View file

@ -0,0 +1,10 @@
<script lang='ts'>
import { Command as CommandPrimitive } from 'cmdk-sv'
import { cn } from '$lib/utils.js'
type $$Props = CommandPrimitive.SeparatorProps
let className: $$Props['class'] = ''
export { className as class }
</script>
<CommandPrimitive.Separator class={cn('bg-border -mx-1 h-px', className)} {...$$restProps} />

View file

@ -0,0 +1,16 @@
<script lang='ts'>
import type { HTMLAttributes } from 'svelte/elements'
import { cn } from '$lib/utils.js'
type $$Props = HTMLAttributes<HTMLSpanElement>
let className: $$Props['class'] = ''
export { className as class }
</script>
<span
class={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
{...$$restProps}
>
<slot />
</span>

View file

@ -0,0 +1,22 @@
<script lang='ts'>
import { Command as CommandPrimitive } from 'cmdk-sv'
import { cn } from '$lib/utils.js'
type $$Props = CommandPrimitive.CommandProps
export let value: $$Props['value'] = ''
let className: $$Props['class'] = ''
export { className as class }
</script>
<CommandPrimitive.Root
class={cn(
'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md max-h-[clamp(0px,20rem,60lvh)]',
className
)}
bind:value
{...$$restProps}
>
<slot />
</CommandPrimitive.Root>

View file

@ -0,0 +1,37 @@
import { Command as CommandPrimitive } from 'cmdk-sv'
import Root from './command.svelte'
import Dialog from './command-dialog.svelte'
import Empty from './command-empty.svelte'
import Group from './command-group.svelte'
import Item from './command-item.svelte'
import Input from './command-input.svelte'
import List from './command-list.svelte'
import Separator from './command-separator.svelte'
import Shortcut from './command-shortcut.svelte'
const Loading = CommandPrimitive.Loading
export {
Root,
Dialog,
Empty,
Group,
Item,
Input,
List,
Separator,
Shortcut,
Loading,
//
Root as Command,
Dialog as CommandDialog,
Empty as CommandEmpty,
Group as CommandGroup,
Item as CommandItem,
Input as CommandInput,
List as CommandList,
Separator as CommandSeparator,
Shortcut as CommandShortcut,
Loading as CommandLoading
}

View file

@ -0,0 +1,36 @@
<script lang='ts'>
import { Dialog as DialogPrimitive } from 'bits-ui'
import Cross2 from 'svelte-radix/Cross2.svelte'
import * as Dialog from './index.js'
import { cn, flyAndScale } from '$lib/utils.js'
type $$Props = DialogPrimitive.ContentProps
let className: $$Props['class'] = ''
export let transition: $$Props['transition'] = flyAndScale
export let transitionConfig: $$Props['transitionConfig'] = {
duration: 200
}
export { className as class }
</script>
<Dialog.Portal>
<Dialog.Overlay />
<DialogPrimitive.Content
{transition}
{transitionConfig}
class={cn(
'bg-background fixed top-[50%] left-[50%] z-50 grid w-full translate-y-[-50%] translate-x-[-50%] p-6 shadow-2xl border-neutral-700/60 border-y-4 bg-clip-padding',
className
)}
{...$$restProps}
>
<slot />
<DialogPrimitive.Close
class='ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 transition-opacity select:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none'
>
<Cross2 class='h-4 w-4' />
<span class='sr-only'>Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</Dialog.Portal>

View file

@ -0,0 +1,16 @@
<script lang='ts'>
import { Dialog as DialogPrimitive } from 'bits-ui'
import { cn } from '$lib/utils.js'
type $$Props = DialogPrimitive.DescriptionProps
let className: $$Props['class'] = ''
export { className as class }
</script>
<DialogPrimitive.Description
class={cn('text-muted-foreground text-sm', className)}
{...$$restProps}
>
<slot />
</DialogPrimitive.Description>

View file

@ -0,0 +1,16 @@
<script lang='ts'>
import type { HTMLAttributes } from 'svelte/elements'
import { cn } from '$lib/utils.js'
type $$Props = HTMLAttributes<HTMLDivElement>
let className: $$Props['class'] = ''
export { className as class }
</script>
<div
class={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...$$restProps}
>
<slot />
</div>

View file

@ -0,0 +1,13 @@
<script lang='ts'>
import type { HTMLAttributes } from 'svelte/elements'
import { cn } from '$lib/utils.js'
type $$Props = HTMLAttributes<HTMLDivElement>
let className: $$Props['class'] = ''
export { className as class }
</script>
<div class={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...$$restProps}>
<slot />
</div>

View file

@ -0,0 +1,21 @@
<script lang='ts'>
import { Dialog as DialogPrimitive } from 'bits-ui'
import { fade } from 'svelte/transition'
import { cn } from '$lib/utils.js'
type $$Props = DialogPrimitive.OverlayProps
let className: $$Props['class'] = ''
export let transition: $$Props['transition'] = fade
export let transitionConfig: $$Props['transitionConfig'] = {
duration: 150
}
export { className as class }
</script>
<DialogPrimitive.Overlay
{transition}
{transitionConfig}
class={cn('custom-bg fixed inset-0 z-50 backdrop-blur-sm', className)}
{...$$restProps}
/>

View file

@ -0,0 +1,11 @@
<script lang='ts'>
import { Dialog as DialogPrimitive } from 'bits-ui'
// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
type $$Props = DialogPrimitive.PortalProps
</script>
<DialogPrimitive.Portal {...$$restProps}>
<slot />
</DialogPrimitive.Portal>

View file

@ -0,0 +1,16 @@
<script lang='ts'>
import { Dialog as DialogPrimitive } from 'bits-ui'
import { cn } from '$lib/utils.js'
type $$Props = DialogPrimitive.TitleProps
let className: $$Props['class'] = ''
export { className as class }
</script>
<DialogPrimitive.Title
class={cn('text-lg font-semibold leading-none tracking-tight', className)}
{...$$restProps}
>
<slot />
</DialogPrimitive.Title>

View file

@ -0,0 +1,37 @@
import { Dialog as DialogPrimitive } from 'bits-ui'
import Title from './dialog-title.svelte'
import Portal from './dialog-portal.svelte'
import Footer from './dialog-footer.svelte'
import Header from './dialog-header.svelte'
import Overlay from './dialog-overlay.svelte'
import Content from './dialog-content.svelte'
import Description from './dialog-description.svelte'
const Root = DialogPrimitive.Root
const Trigger = DialogPrimitive.Trigger
const Close = DialogPrimitive.Close
export {
Root,
Title,
Portal,
Footer,
Header,
Trigger,
Overlay,
Content,
Description,
Close,
//
Root as Dialog,
Title as DialogTitle,
Portal as DialogPortal,
Footer as DialogFooter,
Header as DialogHeader,
Trigger as DialogTrigger,
Overlay as DialogOverlay,
Content as DialogContent,
Description as DialogDescription,
Close as DialogClose
}

View file

@ -0,0 +1,22 @@
<script lang='ts'>
import { Drawer as DrawerPrimitive } from 'vaul-svelte'
import DrawerOverlay from './drawer-overlay.svelte'
import { cn } from '$lib/utils.js'
type $$Props = DrawerPrimitive.ContentProps
let className: $$Props['class'] = ''
export { className as class }
</script>
<DrawerPrimitive.Portal>
<DrawerOverlay />
<DrawerPrimitive.Content
class={cn(
'bg-background fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col border-b border-b-background shadow-2xl border-t-neutral-700/60 border-t-4 bg-clip-padding',
className
)}
{...$$restProps}>
<slot />
</DrawerPrimitive.Content>
</DrawerPrimitive.Portal>

View file

@ -0,0 +1,18 @@
<script lang='ts'>
import { Drawer as DrawerPrimitive } from 'vaul-svelte'
import { cn } from '$lib/utils.js'
type $$Props = DrawerPrimitive.DescriptionProps
export let el: $$Props['el']
let className: $$Props['class'] = ''
export { className as class }
</script>
<DrawerPrimitive.Description
bind:el
class={cn('text-muted-foreground text-sm', className)}
{...$$restProps}
>
<slot />
</DrawerPrimitive.Description>

View file

@ -0,0 +1,16 @@
<script lang='ts'>
import type { HTMLAttributes } from 'svelte/elements'
import { cn } from '$lib/utils.js'
type $$Props = HTMLAttributes<HTMLDivElement> & {
el?: HTMLDivElement
}
export let el: $$Props['el']
let className: $$Props['class'] = ''
export { className as class }
</script>
<div bind:this={el} class={cn('mt-auto flex flex-col gap-2 p-4', className)} {...$$restProps}>
<slot />
</div>

View file

@ -0,0 +1,19 @@
<script lang='ts'>
import type { HTMLAttributes } from 'svelte/elements'
import { cn } from '$lib/utils.js'
type $$Props = HTMLAttributes<HTMLDivElement> & {
el?: HTMLDivElement
}
export let el: $$Props['el']
let className: $$Props['class'] = ''
export { className as class }
</script>
<div
bind:this={el}
class={cn('grid gap-1.5 p-4 text-center sm:text-left', className)}
{...$$restProps}
>
<slot />
</div>

View file

@ -0,0 +1,12 @@
<script lang='ts'>
import { Drawer as DrawerPrimitive } from 'vaul-svelte'
type $$Props = DrawerPrimitive.Props
export let shouldScaleBackground: $$Props['shouldScaleBackground'] = true
export let open: $$Props['open'] = false
export let activeSnapPoint: $$Props['activeSnapPoint'] = null
</script>
<DrawerPrimitive.NestedRoot {shouldScaleBackground} bind:open bind:activeSnapPoint {...$$restProps}>
<slot />
</DrawerPrimitive.NestedRoot>

View file

@ -0,0 +1,18 @@
<script lang='ts'>
import { Drawer as DrawerPrimitive } from 'vaul-svelte'
import { cn } from '$lib/utils.js'
type $$Props = DrawerPrimitive.OverlayProps
export let el: $$Props['el']
let className: $$Props['class'] = ''
export { className as class }
</script>
<DrawerPrimitive.Overlay
bind:el
class={cn('custom-bg fixed inset-0 z-50 backdrop-blur-sm', className)}
{...$$restProps}
>
<slot />
</DrawerPrimitive.Overlay>

View file

@ -0,0 +1,18 @@
<script lang='ts'>
import { Drawer as DrawerPrimitive } from 'vaul-svelte'
import { cn } from '$lib/utils.js'
type $$Props = DrawerPrimitive.TitleProps
export let el: $$Props['el']
let className: $$Props['class'] = ''
export { className as class }
</script>
<DrawerPrimitive.Title
bind:el
class={cn('text-lg font-semibold leading-none tracking-tight', className)}
{...$$restProps}
>
<slot />
</DrawerPrimitive.Title>

View file

@ -0,0 +1,12 @@
<script lang='ts'>
import { Drawer as DrawerPrimitive } from 'vaul-svelte'
type $$Props = DrawerPrimitive.Props
export let shouldScaleBackground: $$Props['shouldScaleBackground'] = true
export let open: $$Props['open'] = false
export let activeSnapPoint: $$Props['activeSnapPoint'] = null
</script>
<DrawerPrimitive.Root {shouldScaleBackground} bind:open bind:activeSnapPoint {...$$restProps}>
<slot />
</DrawerPrimitive.Root>

View file

@ -0,0 +1,40 @@
import { Drawer as DrawerPrimitive } from 'vaul-svelte'
import Root from './drawer.svelte'
import Content from './drawer-content.svelte'
import Description from './drawer-description.svelte'
import Overlay from './drawer-overlay.svelte'
import Footer from './drawer-footer.svelte'
import Header from './drawer-header.svelte'
import Title from './drawer-title.svelte'
import NestedRoot from './drawer-nested.svelte'
const Trigger = DrawerPrimitive.Trigger
const Portal = DrawerPrimitive.Portal
const Close = DrawerPrimitive.Close
export {
Root,
NestedRoot,
Content,
Description,
Overlay,
Footer,
Header,
Title,
Trigger,
Portal,
Close,
//
Root as Drawer,
NestedRoot as DrawerNestedRoot,
Content as DrawerContent,
Description as DrawerDescription,
Overlay as DrawerOverlay,
Footer as DrawerFooter,
Header as DrawerHeader,
Title as DrawerTitle,
Trigger as DrawerTrigger,
Portal as DrawerPortal,
Close as DrawerClose
}

View file

@ -0,0 +1,30 @@
<script lang='ts'>
import { banner, type Media } from '$lib/modules/anilist'
import type { HTMLAttributes } from 'svelte/elements'
import { Load } from '.'
export let media: Media
type $$Props = HTMLAttributes<HTMLImageElement> & { media: Media }
const src = banner(media)
const isYoutube = src?.startsWith('https://i.ytimg.com/')
let className: $$Props['class'] = ''
export { className as class }
// hq720 or maxresdefault is highest, but default so omitted
const sizes = ['sddefault', 'hqdefault', 'mqdefault', 'default']
let sizeAttempt = 0
function verifyThumbnail (e: Event) {
if (!isYoutube) return
const img = e.target as HTMLImageElement
if (img.naturalWidth === 120 && img.naturalHeight === 90) {
img.src = `https://i.ytimg.com/vi/${media.trailer?.id}/${sizes[sizeAttempt++]}.jpg`
}
}
</script>
{#if src}
<Load {src} alt={media.title?.english} class={className} on:load={verifyThumbnail} />
{/if}

View file

@ -0,0 +1,2 @@
export { default as Banner } from './banner.svelte'
export { default as Load } from './load.svelte'

View file

@ -0,0 +1,26 @@
<script lang='ts'>
import { cn } from '$lib/utils'
import type { HTMLImgAttributes } from 'svelte/elements'
type $$Props = HTMLImgAttributes & { color?: string | null | undefined }
export let src: $$Props['src'] = ''
export let alt: $$Props['alt'] = ''
let className: $$Props['class'] = ''
export { className as class }
export let color: string | null | undefined = 'transparent'
let loaded = false
async function test (e: Event & { currentTarget: EventTarget & Element }) {
const target = e.currentTarget as HTMLImageElement
await target.decode()
loaded = true
}
</script>
<div style:background={color ?? '#1890ff'} class={className}>
<img {src} {alt} on:load on:load={test} class={cn(className, 'transition-opacity opacity-0 duration-300 ease-out')} class:!opacity-100={loaded} decoding='async' loading='lazy' style:background={color ?? '#1890ff'} />
</div>

View file

@ -0,0 +1,29 @@
import Root from './input.svelte'
export type FormInputEvent<T extends Event = Event> = T & {
currentTarget: EventTarget & HTMLInputElement
}
export interface InputEvents {
blur: FormInputEvent<FocusEvent>
change: FormInputEvent
click: FormInputEvent<MouseEvent>
focus: FormInputEvent<FocusEvent>
focusin: FormInputEvent<FocusEvent>
focusout: FormInputEvent<FocusEvent>
keydown: FormInputEvent<KeyboardEvent>
keypress: FormInputEvent<KeyboardEvent>
keyup: FormInputEvent<KeyboardEvent>
mouseover: FormInputEvent<MouseEvent>
mouseenter: FormInputEvent<MouseEvent>
mouseleave: FormInputEvent<MouseEvent>
mousemove: FormInputEvent<MouseEvent>
paste: FormInputEvent<ClipboardEvent>
input: FormInputEvent<InputEvent>
wheel: FormInputEvent<WheelEvent>
}
export {
Root,
//
Root as Input
}

View file

@ -0,0 +1,42 @@
<script lang='ts'>
import type { HTMLInputAttributes } from 'svelte/elements'
import type { InputEvents } from './index.js'
import { cn } from '$lib/utils.js'
type $$Props = HTMLInputAttributes
type $$Events = InputEvents
let className: $$Props['class'] = ''
export let value: $$Props['value'] = ''
export { className as class }
// Workaround for https://github.com/sveltejs/svelte/issues/9305
// Fixed in Svelte 5, but not backported to 4.x.
export let readonly: $$Props['readonly'] = false
</script>
<input
class={cn(
'border-input placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
bind:value
{readonly}
on:blur
on:change
on:click
on:focus
on:focusin
on:focusout
on:keydown
on:keypress
on:keyup
on:mouseover
on:mouseenter
on:mouseleave
on:mousemove
on:paste
on:input
on:wheel|passive
{...$$restProps}
/>

View file

@ -0,0 +1 @@
export { default as IRC } from './irc.svelte'

View file

@ -0,0 +1,92 @@
<script lang='ts' context='module'>
import MessageClient from '$lib/modules/irc'
import { client } from '$lib/modules/anilist'
import { writable, type Writable } from 'simple-store-svelte'
import { Messages, UserList } from '../chat'
import { SendHorizontal } from 'lucide-svelte'
import { Textarea } from '$lib/components/ui/textarea'
const irc: Writable<Promise<MessageClient> | null> = writable(null)
</script>
<script lang='ts'>
import { Button } from '../button'
const viewer = client.viewer.value
let ident: { nick: string, id: string, pfpid: string, type: 'al' | 'guest' }
if (viewer?.viewer) {
const url = viewer.viewer.avatar?.medium ?? ''
const id = '' + viewer.viewer.id
const pfpid = url.slice(url.lastIndexOf('/') + 2 + id.length + 1)
ident = { nick: viewer.viewer.name, id, pfpid, type: 'al' }
} else {
ident = { nick: 'Guest-' + crypto.randomUUID().slice(0, 6), id: crypto.randomUUID().slice(0, 6), pfpid: '0', type: 'guest' }
}
if (!irc.value) irc.value = MessageClient.new(ident)
let message = ''
let rows = 1
function sendMessage (client: MessageClient) {
if (message.trim()) {
client.say(message.trim())
message = ''
rows = 1
}
}
async function checkInput (e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey && message.trim()) {
e.preventDefault()
sendMessage(await irc.value!)
} else {
rows = message.split('\n').length || 1
}
}
function updateRows () {
rows = message.split('\n').length || 1
}
</script>
{#if $irc}
{#await $irc}
<div class='w-full h-full flex items-center justify-center flex-col text-muted-foreground text-lg'>
<svg
xmlns='http://www.w3.org/2000/svg'
width='24'
height='24'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
class='animate-spin mb-2'>
<path d='M21 12a9 9 0 1 1-6.219-8.56' />
</svg>
Loading...
</div>
{:then client}
<div class='flex flex-col w-full relative px-md-4 h-full overflow-hidden'>
<div class='flex md:flex-row flex-col-reverse w-full h-full pt-4'>
<div class='flex flex-col justify-end overflow-hidden flex-grow px-4 md:pb-4'>
<Messages messages={client.messages} />
<div class='flex mt-4'>
<Textarea
bind:value={message}
class='h-auto px-3 w-full flex-grow-1 resize-none min-h-0 border-0 bg-background select:bg-accent select:text-accent-foreground'
{rows}
autocomplete='off'
maxlength={256}
placeholder='Message' on:keydown={checkInput} on:input={updateRows} />
<Button on:click={() => sendMessage(client)} size='icon' class='mt-auto ml-2 border-0' variant='outline'>
<SendHorizontal size={18} />
</Button>
</div>
</div>
<UserList users={client.users} />
</div>
</div>
<!-- <Chat {client} /> -->
{/await}
{/if}

View file

@ -0,0 +1,7 @@
import Root from "./label.svelte";
export {
Root,
//
Root as Label,
};

View file

@ -0,0 +1,19 @@
<script lang='ts'>
import { Label as LabelPrimitive } from 'bits-ui'
import { cn } from '$lib/utils.js'
type $$Props = LabelPrimitive.Props
let className: $$Props['class']
export { className as class }
</script>
<LabelPrimitive.Root
class={cn(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
className
)}
{...$$restProps}
>
<slot />
</LabelPrimitive.Root>

View file

@ -0,0 +1,3 @@
import Root from './menubar.svelte'
export { Root as Menubar }

View file

@ -0,0 +1,54 @@
<script lang='ts'>
import native from '$lib/modules/native'
import { click } from '$lib/modules/navigate'
const debug = false // TODO
</script>
<div class='w-[calc(100%-3.5rem)] left-[3.5rem] z-[2000] flex navbar absolute h-8'>
<div class='draggable w-full' />
<div class='window-controls flex text-white backdrop-blur'>
<button class='max-button flex items-center justify-center h-8 w-[46px]' use:click={native.minimise}>
<svg class='svg-controls w-3 h-3' role='img' viewBox='0 0 12 12'><rect fill='currentColor' height='1' width='10' x='1' y='6' />
</button>
<button class='restore-button flex items-center justify-center h-8 w-[46px]' use:click={native.maximise}>
<svg class='svg-controls w-3 h-3' role='img' viewBox='0 0 12 12'><rect fill='none' height='9' stroke='currentColor' width='9' x='1.5' y='1.5' />
</button>
<button class='close-button flex items-center justify-center h-8 w-[46px]' use:click={native.close}>
<svg class='svg-controls w-3 h-3' role='img' viewBox='0 0 12 12'><polygon fill='currentColor' fill-rule='evenodd' points='11 1.576 6.583 6 11 10.424 10.424 11 6 6.583 1.576 11 1 10.424 5.417 6 1 1.576 1.576 1 6 5.417 10.424 1' />
</button>
</div>
</div>
{#if debug}
<div class='ribbon z-10 text-center fixed font-bold pointer-events-none'>Debug Mode!</div>
{/if}
<style>
.ribbon {
background: #f63220;
box-shadow: 0 0 0 999px #f63220;
clip-path: inset(0 -100%);
inset: 0 auto auto 0;
transform-origin: 100% 0;
transform: translate(-29.3%) rotate(-45deg);
}
.draggable {
-webkit-app-region: drag;
}
.window-controls {
-webkit-app-region: no-drag;
background: rgba(24, 24, 24, 0.1);
}
.window-controls button:hover {
background: rgba(128, 128, 128, 0.2);
}
.window-controls button:active {
background: rgba(128, 128, 128, 0.4);
}
.close-button:hover {
background: #e81123 !important;
}
.close-button:active {
background: #f1707a !important;
}
</style>

View file

@ -0,0 +1,17 @@
import { Popover as PopoverPrimitive } from 'bits-ui'
import Content from './popover-content.svelte'
const Root = PopoverPrimitive.Root
const Trigger = PopoverPrimitive.Trigger
const Close = PopoverPrimitive.Close
export {
Root,
Content,
Trigger,
Close,
//
Root as Popover,
Content as PopoverContent,
Trigger as PopoverTrigger,
Close as PopoverClose
}

View file

@ -0,0 +1,27 @@
<script lang='ts'>
import { Popover as PopoverPrimitive } from 'bits-ui'
import { cn, flyAndScale } from '$lib/utils.js'
type $$Props = PopoverPrimitive.ContentProps
let className: $$Props['class'] = ''
export let transition: $$Props['transition'] = flyAndScale
export let transitionConfig: $$Props['transitionConfig'] = {}
export let align: $$Props['align'] = 'center'
export let sideOffset: $$Props['sideOffset'] = 4
export { className as class }
</script>
<PopoverPrimitive.Content
{transition}
{transitionConfig}
{align}
{sideOffset}
{...$$restProps}
class={cn(
'bg-popover text-popover-foreground z-50 w-72 rounded-md border p-4 shadow-md outline-none',
className
)}
>
<slot />
</PopoverPrimitive.Content>

View file

@ -0,0 +1,34 @@
import { Select as SelectPrimitive } from 'bits-ui'
import Label from './select-label.svelte'
import Item from './select-item.svelte'
import Content from './select-content.svelte'
import Trigger from './select-trigger.svelte'
import Separator from './select-separator.svelte'
const Root = SelectPrimitive.Root
const Group = SelectPrimitive.Group
const Input = SelectPrimitive.Input
const Value = SelectPrimitive.Value
export {
Root,
Item,
Group,
Input,
Label,
Value,
Content,
Trigger,
Separator,
//
Root as Select,
Item as SelectItem,
Group as SelectGroup,
Input as SelectInput,
Label as SelectLabel,
Value as SelectValue,
Content as SelectContent,
Trigger as SelectTrigger,
Separator as SelectSeparator
}

View file

@ -0,0 +1,37 @@
<script lang='ts'>
import { Select as SelectPrimitive } from 'bits-ui'
import { scale } from 'svelte/transition'
import { cn, flyAndScale } from '$lib/utils.js'
type $$Props = SelectPrimitive.ContentProps
let className: $$Props['class'] = ''
export let sideOffset: $$Props['sideOffset'] = 4
export let inTransition: $$Props['inTransition'] = flyAndScale
export let inTransitionConfig: $$Props['inTransitionConfig']
export let outTransition: $$Props['outTransition'] = scale
export let outTransitionConfig: $$Props['outTransitionConfig'] = {
start: 0.95,
opacity: 0,
duration: 50
}
export { className as class }
</script>
<SelectPrimitive.Content
{inTransition}
{inTransitionConfig}
{outTransition}
{outTransitionConfig}
{sideOffset}
class={cn(
'bg-popover text-popover-foreground relative z-50 min-w-[8rem] overflow-hidden rounded-md border shadow-md focus:outline-none',
className
)}
{...$$restProps}
on:keydown
>
<div class='w-full p-1'>
<slot />
</div>
</SelectPrimitive.Content>

View file

@ -0,0 +1,39 @@
<script lang='ts'>
import { Select as SelectPrimitive } from 'bits-ui'
import Check from 'svelte-radix/Check.svelte'
import { cn } from '$lib/utils.js'
type $$Props = SelectPrimitive.ItemProps
type $$Events = Required<SelectPrimitive.ItemEvents>
let className: $$Props['class'] = ''
export let value: $$Props['value']
export let label: $$Props['label']
export let disabled: $$Props['disabled']
export { className as class }
</script>
<SelectPrimitive.Item
{value}
{disabled}
{label}
class={cn(
'data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...$$restProps}
on:click
on:pointermove
on:focusin
on:keydown
tabindex={0}
>
<span class='absolute right-2 flex h-3.5 w-3.5 items-center justify-center'>
<SelectPrimitive.ItemIndicator>
<Check class='h-4 w-4' />
</SelectPrimitive.ItemIndicator>
</span>
<slot>
{label ?? value}
</slot>
</SelectPrimitive.Item>

View file

@ -0,0 +1,13 @@
<script lang='ts'>
import { Select as SelectPrimitive } from 'bits-ui'
import { cn } from '$lib/utils.js'
type $$Props = SelectPrimitive.LabelProps
let className: $$Props['class'] = ''
export { className as class }
</script>
<SelectPrimitive.Label class={cn('px-2 py-1.5 text-sm font-semibold', className)} {...$$restProps}>
<slot />
</SelectPrimitive.Label>

Some files were not shown because too many files have changed in this diff Show more