mirror of
https://github.com/ThaUnknown/miru.git
synced 2026-01-12 00:03:44 +00:00
feat: initial
This commit is contained in:
commit
9b1ec67e69
182 changed files with 13255 additions and 0 deletions
22
.gitignore
vendored
Normal file
22
.gitignore
vendored
Normal 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
1
.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
engine-strict=true
|
||||
11
.vscode/extensions.json
vendored
Normal file
11
.vscode/extensions.json
vendored
Normal 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
28
.vscode/settings.json
vendored
Normal 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
47
LICENSE
Normal 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
14
components.json
Normal 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
13
eslint.config.js
Normal 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
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
17
generateALIntrospection.ts
Normal file
17
generateALIntrospection.ts
Normal 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
67
package.json
Normal 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
4682
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
}
|
||||
338
src/app.css
Normal file
338
src/app.css
Normal 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
63
src/app.d.ts
vendored
Normal 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
15
src/app.html
Normal 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>
|
||||
56
src/lib/components/Backplate.svelte
Normal file
56
src/lib/components/Backplate.svelte
Normal 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
|
||||
</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>
|
||||
143
src/lib/components/EpisodesList.svelte
Normal file
143
src/lib/components/EpisodesList.svelte
Normal 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>
|
||||
42
src/lib/components/KeepAlive.svelte
Normal file
42
src/lib/components/KeepAlive.svelte
Normal 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} />
|
||||
52
src/lib/components/Online.svelte
Normal file
52
src/lib/components/Online.svelte
Normal 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>
|
||||
53
src/lib/components/Pagination.svelte
Normal file
53
src/lib/components/Pagination.svelte
Normal 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} />
|
||||
237
src/lib/components/SearchModal.svelte
Normal file
237
src/lib/components/SearchModal.svelte
Normal 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>
|
||||
15
src/lib/components/SettingCard.svelte
Normal file
15
src/lib/components/SettingCard.svelte
Normal 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>
|
||||
32
src/lib/components/SettingsNav.svelte
Normal file
32
src/lib/components/SettingsNav.svelte
Normal 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>
|
||||
31
src/lib/components/StatusDot.svelte
Normal file
31
src/lib/components/StatusDot.svelte
Normal 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)} />
|
||||
11
src/lib/components/icons/Anilist.svelte
Normal file
11
src/lib/components/icons/Anilist.svelte
Normal 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 |
10
src/lib/components/icons/Hub.svelte
Normal file
10
src/lib/components/icons/Hub.svelte
Normal 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>
|
||||
14
src/lib/components/icons/Logo.svelte
Normal file
14
src/lib/components/icons/Logo.svelte
Normal 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>
|
||||
14
src/lib/components/icons/MyAnimeList.svelte
Normal file
14
src/lib/components/icons/MyAnimeList.svelte
Normal 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>
|
||||
16
src/lib/components/ui/avatar/avatar-fallback.svelte
Normal file
16
src/lib/components/ui/avatar/avatar-fallback.svelte
Normal 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>
|
||||
18
src/lib/components/ui/avatar/avatar-image.svelte
Normal file
18
src/lib/components/ui/avatar/avatar-image.svelte
Normal 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}
|
||||
/>
|
||||
18
src/lib/components/ui/avatar/avatar.svelte
Normal file
18
src/lib/components/ui/avatar/avatar.svelte
Normal 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>
|
||||
13
src/lib/components/ui/avatar/index.ts
Normal file
13
src/lib/components/ui/avatar/index.ts
Normal 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
|
||||
}
|
||||
18
src/lib/components/ui/badge/badge.svelte
Normal file
18
src/lib/components/ui/badge/badge.svelte
Normal 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>
|
||||
22
src/lib/components/ui/badge/index.ts
Normal file
22
src/lib/components/ui/badge/index.ts
Normal 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']
|
||||
27
src/lib/components/ui/banner/banner-image.svelte
Normal file
27
src/lib/components/ui/banner/banner-image.svelte
Normal 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}
|
||||
44
src/lib/components/ui/banner/banner.svelte
Normal file
44
src/lib/components/ui/banner/banner.svelte
Normal 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>
|
||||
140
src/lib/components/ui/banner/full-banner.svelte
Normal file
140
src/lib/components/ui/banner/full-banner.svelte
Normal 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>
|
||||
3
src/lib/components/ui/banner/index.ts
Normal file
3
src/lib/components/ui/banner/index.ts
Normal 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'
|
||||
11
src/lib/components/ui/banner/skeleton-banner.svelte
Normal file
11
src/lib/components/ui/banner/skeleton-banner.svelte
Normal 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>
|
||||
30
src/lib/components/ui/button/bookmark.svelte
Normal file
30
src/lib/components/ui/button/bookmark.svelte
Normal 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>
|
||||
23
src/lib/components/ui/button/button.svelte
Normal file
23
src/lib/components/ui/button/button.svelte
Normal 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>
|
||||
21
src/lib/components/ui/button/favorite.svelte
Normal file
21
src/lib/components/ui/button/favorite.svelte
Normal 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>
|
||||
65
src/lib/components/ui/button/index.ts
Normal file
65
src/lib/components/ui/button/index.ts
Normal 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
|
||||
}
|
||||
33
src/lib/components/ui/button/play.svelte
Normal file
33
src/lib/components/ui/button/play.svelte
Normal 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>
|
||||
60
src/lib/components/ui/button/progress-button.svelte
Normal file
60
src/lib/components/ui/button/progress-button.svelte
Normal 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>
|
||||
108
src/lib/components/ui/cards/YoutubeIframe.svelte
Normal file
108
src/lib/components/ui/cards/YoutubeIframe.svelte
Normal 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>
|
||||
3
src/lib/components/ui/cards/index.ts
Normal file
3
src/lib/components/ui/cards/index.ts
Normal 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'
|
||||
78
src/lib/components/ui/cards/preview.svelte
Normal file
78
src/lib/components/ui/cards/preview.svelte
Normal 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>
|
||||
80
src/lib/components/ui/cards/query.svelte
Normal file
80
src/lib/components/ui/cards/query.svelte
Normal 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}
|
||||
14
src/lib/components/ui/cards/skeleton.svelte
Normal file
14
src/lib/components/ui/cards/skeleton.svelte
Normal 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>
|
||||
55
src/lib/components/ui/cards/small.svelte
Normal file
55
src/lib/components/ui/cards/small.svelte
Normal 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>
|
||||
53
src/lib/components/ui/chat/Messages.svelte
Normal file
53
src/lib/components/ui/chat/Messages.svelte
Normal 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>
|
||||
39
src/lib/components/ui/chat/UserList.svelte
Normal file
39
src/lib/components/ui/chat/UserList.svelte
Normal 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>
|
||||
26
src/lib/components/ui/chat/index.ts
Normal file
26
src/lib/components/ui/chat/index.ts
Normal 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'
|
||||
125
src/lib/components/ui/combobox/combobox.svelte
Normal file
125
src/lib/components/ui/combobox/combobox.svelte
Normal 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>
|
||||
3
src/lib/components/ui/combobox/index.ts
Normal file
3
src/lib/components/ui/combobox/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { default as ComboBox } from './combobox.svelte'
|
||||
export * from './combobox.svelte'
|
||||
export { default as SingleCombo } from './singlecombo.svelte'
|
||||
9
src/lib/components/ui/combobox/singlecombo.svelte
Normal file
9
src/lib/components/ui/combobox/singlecombo.svelte
Normal 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} />
|
||||
23
src/lib/components/ui/command/command-dialog.svelte
Normal file
23
src/lib/components/ui/command/command-dialog.svelte
Normal 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>
|
||||
12
src/lib/components/ui/command/command-empty.svelte
Normal file
12
src/lib/components/ui/command/command-empty.svelte
Normal 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>
|
||||
18
src/lib/components/ui/command/command-group.svelte
Normal file
18
src/lib/components/ui/command/command-group.svelte
Normal 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>
|
||||
23
src/lib/components/ui/command/command-input.svelte
Normal file
23
src/lib/components/ui/command/command-input.svelte
Normal 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>
|
||||
24
src/lib/components/ui/command/command-item.svelte
Normal file
24
src/lib/components/ui/command/command-item.svelte
Normal 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>
|
||||
15
src/lib/components/ui/command/command-list.svelte
Normal file
15
src/lib/components/ui/command/command-list.svelte
Normal 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>
|
||||
10
src/lib/components/ui/command/command-separator.svelte
Normal file
10
src/lib/components/ui/command/command-separator.svelte
Normal 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} />
|
||||
16
src/lib/components/ui/command/command-shortcut.svelte
Normal file
16
src/lib/components/ui/command/command-shortcut.svelte
Normal 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>
|
||||
22
src/lib/components/ui/command/command.svelte
Normal file
22
src/lib/components/ui/command/command.svelte
Normal 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>
|
||||
37
src/lib/components/ui/command/index.ts
Normal file
37
src/lib/components/ui/command/index.ts
Normal 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
|
||||
}
|
||||
36
src/lib/components/ui/dialog/dialog-content.svelte
Normal file
36
src/lib/components/ui/dialog/dialog-content.svelte
Normal 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>
|
||||
16
src/lib/components/ui/dialog/dialog-description.svelte
Normal file
16
src/lib/components/ui/dialog/dialog-description.svelte
Normal 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>
|
||||
16
src/lib/components/ui/dialog/dialog-footer.svelte
Normal file
16
src/lib/components/ui/dialog/dialog-footer.svelte
Normal 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>
|
||||
13
src/lib/components/ui/dialog/dialog-header.svelte
Normal file
13
src/lib/components/ui/dialog/dialog-header.svelte
Normal 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>
|
||||
21
src/lib/components/ui/dialog/dialog-overlay.svelte
Normal file
21
src/lib/components/ui/dialog/dialog-overlay.svelte
Normal 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}
|
||||
/>
|
||||
11
src/lib/components/ui/dialog/dialog-portal.svelte
Normal file
11
src/lib/components/ui/dialog/dialog-portal.svelte
Normal 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>
|
||||
16
src/lib/components/ui/dialog/dialog-title.svelte
Normal file
16
src/lib/components/ui/dialog/dialog-title.svelte
Normal 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>
|
||||
37
src/lib/components/ui/dialog/index.ts
Normal file
37
src/lib/components/ui/dialog/index.ts
Normal 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
|
||||
}
|
||||
22
src/lib/components/ui/drawer/drawer-content.svelte
Normal file
22
src/lib/components/ui/drawer/drawer-content.svelte
Normal 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>
|
||||
18
src/lib/components/ui/drawer/drawer-description.svelte
Normal file
18
src/lib/components/ui/drawer/drawer-description.svelte
Normal 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>
|
||||
16
src/lib/components/ui/drawer/drawer-footer.svelte
Normal file
16
src/lib/components/ui/drawer/drawer-footer.svelte
Normal 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>
|
||||
19
src/lib/components/ui/drawer/drawer-header.svelte
Normal file
19
src/lib/components/ui/drawer/drawer-header.svelte
Normal 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>
|
||||
12
src/lib/components/ui/drawer/drawer-nested.svelte
Normal file
12
src/lib/components/ui/drawer/drawer-nested.svelte
Normal 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>
|
||||
18
src/lib/components/ui/drawer/drawer-overlay.svelte
Normal file
18
src/lib/components/ui/drawer/drawer-overlay.svelte
Normal 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>
|
||||
18
src/lib/components/ui/drawer/drawer-title.svelte
Normal file
18
src/lib/components/ui/drawer/drawer-title.svelte
Normal 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>
|
||||
12
src/lib/components/ui/drawer/drawer.svelte
Normal file
12
src/lib/components/ui/drawer/drawer.svelte
Normal 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>
|
||||
40
src/lib/components/ui/drawer/index.ts
Normal file
40
src/lib/components/ui/drawer/index.ts
Normal 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
|
||||
}
|
||||
30
src/lib/components/ui/img/banner.svelte
Normal file
30
src/lib/components/ui/img/banner.svelte
Normal 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}
|
||||
2
src/lib/components/ui/img/index.ts
Normal file
2
src/lib/components/ui/img/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { default as Banner } from './banner.svelte'
|
||||
export { default as Load } from './load.svelte'
|
||||
26
src/lib/components/ui/img/load.svelte
Normal file
26
src/lib/components/ui/img/load.svelte
Normal 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>
|
||||
29
src/lib/components/ui/input/index.ts
Normal file
29
src/lib/components/ui/input/index.ts
Normal 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
|
||||
}
|
||||
42
src/lib/components/ui/input/input.svelte
Normal file
42
src/lib/components/ui/input/input.svelte
Normal 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}
|
||||
/>
|
||||
1
src/lib/components/ui/irc/index.ts
Normal file
1
src/lib/components/ui/irc/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as IRC } from './irc.svelte'
|
||||
92
src/lib/components/ui/irc/irc.svelte
Normal file
92
src/lib/components/ui/irc/irc.svelte
Normal 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}
|
||||
7
src/lib/components/ui/label/index.ts
Normal file
7
src/lib/components/ui/label/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import Root from "./label.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Label,
|
||||
};
|
||||
19
src/lib/components/ui/label/label.svelte
Normal file
19
src/lib/components/ui/label/label.svelte
Normal 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>
|
||||
3
src/lib/components/ui/menubar/index.ts
Normal file
3
src/lib/components/ui/menubar/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import Root from './menubar.svelte'
|
||||
|
||||
export { Root as Menubar }
|
||||
54
src/lib/components/ui/menubar/menubar.svelte
Normal file
54
src/lib/components/ui/menubar/menubar.svelte
Normal 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>
|
||||
17
src/lib/components/ui/popover/index.ts
Normal file
17
src/lib/components/ui/popover/index.ts
Normal 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
|
||||
}
|
||||
27
src/lib/components/ui/popover/popover-content.svelte
Normal file
27
src/lib/components/ui/popover/popover-content.svelte
Normal 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>
|
||||
34
src/lib/components/ui/select/index.ts
Normal file
34
src/lib/components/ui/select/index.ts
Normal 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
|
||||
}
|
||||
37
src/lib/components/ui/select/select-content.svelte
Normal file
37
src/lib/components/ui/select/select-content.svelte
Normal 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>
|
||||
39
src/lib/components/ui/select/select-item.svelte
Normal file
39
src/lib/components/ui/select/select-item.svelte
Normal 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>
|
||||
13
src/lib/components/ui/select/select-label.svelte
Normal file
13
src/lib/components/ui/select/select-label.svelte
Normal 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
Loading…
Reference in a new issue