append hayase

This commit is contained in:
ThaUnknown 2025-05-22 18:32:40 +02:00
commit 696c32c5a8
No known key found for this signature in database
290 changed files with 21402 additions and 0 deletions

26
.gitignore vendored Normal file
View file

@ -0,0 +1,26 @@
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-*
*.mkv
*.webm
*src/routes/test/**

1
.npmrc Normal file
View file

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

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

@ -0,0 +1,12 @@
{
"recommendations": [
"usernamehw.errorlens",
"dbaeumer.vscode-eslint",
"GraphQL.vscode-graphql-syntax",
"YoavBls.pretty-ts-errors",
"svelte.svelte-vscode", // 109.5.2 or older NOT NEWER
"ardenivanov.svelte-intellisense",
"Gruntfuggly.todo-tree",
"bradlc.vscode-tailwindcss"
]
}

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

@ -0,0 +1,43 @@
{
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "always"
},
"editor.formatOnSave": true,
"editor.linkedEditing": true,
"editor.tabSize": 2,
"eslint.format.enable": true,
"eslint.probe": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact",
"svelte",
"html"
],
"eslint.useESLintClass": true,
"eslint.useFlatConfig": true,
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact",
"svelte",
"html"
],
"javascript.preferences.importModuleSpecifierEnding": "minimal",
"javascript.preferences.quoteStyle": "single",
"javascript.suggest.autoImports": true,
"javascript.updateImportsOnFileMove.enabled": "always",
"javascript.validate.enable": true,
"extensions.ignoreRecommendations": false,
"svelte.plugin.svelte.format.config.singleQuote": true,
"svelte.plugin.svelte.format.enable": false,
"typescript.enablePromptUseWorkspaceTsdk": true,
"typescript.experimental.expandableHover": true,
"typescript.preferences.importModuleSpecifierEnding": "minimal",
"typescript.preferences.quoteStyle": "single",
"typescript.suggest.autoImports": true,
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.updateImportsOnFileMove.enabled": "always",
"typescript.validate.enable": true
}

166
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,166 @@
# Contributing Guidelines
*Pull requests, bug reports, and all other forms of contribution are welcomed and highly encouraged!* :octocat:
### Contents
* [Opening an Issue](#inbox_tray-opening-an-issue)
* [Feature Requests](#love_letter-feature-requests)
* [Triaging Issues](#mag-triaging-issues)
* [Submitting Pull Requests](#repeat-submitting-pull-requests)
* [Writing Commit Messages](#memo-writing-commit-messages)
* [Code Review](#white_check_mark-code-review)
* [Coding Style](#nail_care-coding-style)
* [Certificate of Origin](#medal_sports-certificate-of-origin)
* [Credits](#pray-credits)
> **This guide serves to set clear expectations for everyone involved with the project so that we can improve it together while also creating a welcoming space for everyone to participate. Following these guidelines will help ensure a positive experience for contributors and maintainers.**
## :inbox\_tray: Opening an Issue
Before [creating an issue](https://help.github.com/en/github/managing-your-work-on-github/creating-an-issue), check if you are using the latest version of the project. If you are not up-to-date, see if updating fixes your issue first.
### :lock: Reporting Security Issues
Review our [Security Policy](./SECURITY.md). **Do not** file a public issue for security vulnerabilities.
### :beetle: Bug Reports and Other Issues
A great way to contribute to the project is to send a detailed issue when you encounter a problem. We always appreciate a well-written, thorough bug report. :v:
In short, since you are most likely a developer, **provide a ticket that you would like to receive**.
* **Review the documentation and [Support Guide](./SUPPORT.md)** before opening a new issue.
* **Do not open a duplicate issue!** Search through existing issues to see if your issue has previously been reported. If your issue exists, comment with any additional information you have. You may simply note "I have this problem too", which helps prioritize the most common problems and requests.
* **Prefer using [reactions](https://github.blog/2016-03-10-add-reactions-to-pull-requests-issues-and-comments/)**, not comments, if you simply want to "+1" an existing issue.
* **Fully complete the provided issue template.** The bug report template requests all the information we need to quickly and efficiently address your issue. Be clear, concise, and descriptive. Provide as much information as you can, including steps to reproduce, stack traces, compiler errors, library versions, OS versions, and screenshots (if applicable).
* **Use [GitHub-flavored Markdown](https://help.github.com/en/github/writing-on-github/basic-writing-and-formatting-syntax).** Especially put code blocks and console outputs in backticks (\`\`\`). This improves readability.
## :love\_letter: Feature Requests
Feature requests are welcome! While we will consider all requests, we cannot guarantee your request will be accepted. We want to avoid [feature creep](https://en.wikipedia.org/wiki/Feature_creep). Your idea may be great, but also out-of-scope for the project. If accepted, we cannot make any commitments regarding the timeline for implementation and release. However, you are welcome to submit a pull request to help!
* **Do not open a duplicate feature request.** Search for existing feature requests first. If you find your feature (or one very similar) previously requested, comment on that issue.
* **Fully complete the provided issue template.** The feature request template asks for all necessary information for us to begin a productive conversation.
* Be precise about the proposed outcome of the feature and how it relates to existing features. Include implementation details if possible.
## :mag: Triaging Issues
You can triage issues which may include reproducing bug reports or asking for additional information, such as version numbers or reproduction instructions. Any help you can provide to quickly resolve an issue is very much appreciated!
## :repeat: Submitting Pull Requests
We **love** pull requests! Before [forking the repo](https://help.github.com/en/github/getting-started-with-github/fork-a-repo) and [creating a pull request](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/proposing-changes-to-your-work-with-pull-requests) for non-trivial changes, it is usually best to first open an issue to discuss the changes, or discuss your intended approach for solving the problem in the comments for an existing issue.
For most contributions, after your first pull request is accepted and merged, you will be [invited to the project](https://help.github.com/en/github/setting-up-and-managing-your-github-user-account/inviting-collaborators-to-a-personal-repository) and given **push access**. :tada:
*Note: All contributions will be licensed under the project's license.*
* **Smaller is better.** Submit **one** pull request per bug fix or feature. A pull request should contain isolated changes pertaining to a single bug fix or feature implementation. **Do not** refactor or reformat code that is unrelated to your change. It is better to **submit many small pull requests** rather than a single large one. Enormous pull requests will take enormous amounts of time to review, or may be rejected altogether.
* **Coordinate bigger changes.** For large and non-trivial changes, open an issue to discuss a strategy with the maintainers. Otherwise, you risk doing a lot of work for nothing!
* **Prioritize understanding over cleverness.** Write code clearly and concisely. Remember that source code usually gets written once and read often. Ensure the code is clear to the reader. The purpose and logic should be obvious to a reasonably skilled developer, otherwise you should add a comment that explains it.
* **Follow existing coding style and conventions.** Keep your code consistent with the style, formatting, and conventions in the rest of the code base. When possible, these will be enforced with a linter. Consistency makes it easier to review and modify in the future.
* **Include test coverage.** Add unit tests or UI tests when possible. Follow existing patterns for implementing tests.
* **Update the example project** if one exists to exercise any new functionality you have added.
* **Add documentation.** Document your changes with code doc comments or in existing guides.
* **Use the repo's default branch.** Branch from and [submit your pull request](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request-from-a-fork) to the repo's default branch. Usually this is `main`, but it could be `dev`, `develop`, or `master`.
* **[Resolve any merge conflicts](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/resolving-a-merge-conflict-on-github)** that occur.
* **Promptly address any CI failures**. If your pull request fails to build or pass tests, please push another commit to fix it.
* When writing comments, use properly constructed sentences, including punctuation.
* Use spaces, not tabs.
## :memo: Writing Commit Messages
Please [write a great commit message](https://chris.beams.io/posts/git-commit/).
1. Separate subject from body with a blank line
2. Limit the subject line to 50 characters
3. Capitalize the subject line
4. Do not end the subject line with a period
5. Use the imperative mood in the subject line (example: "Fix networking issue")
6. Wrap the body at about 72 characters
7. Use the body to explain **why**, *not what and how* (the code shows that!)
8. If applicable, prefix the title with the relevant component name in a semantic way. (examples: "fix: typo", "feat: add avatar")
```
docs: Short summary of changes in 50 chars or less
Add a more detailed explanation here, if necessary. Possibly give
some background about the issue being fixed, etc. The body of the
commit message can be several paragraphs. Further paragraphs come
after blank lines and please do proper word-wrap.
Wrap it to about 72 characters or so. In some contexts,
the first line is treated as the subject of the commit and the
rest of the text as the body. The blank line separating the summary
from the body is critical (unless you omit the body entirely);
various tools like `log`, `shortlog` and `rebase` can get confused
if you run the two together.
Explain the problem that this commit is solving. Focus on why you
are making this change as opposed to how or what. The code explains
how or what. Reviewers and your future self can read the patch,
but might not understand why a particular solution was implemented.
Are there side effects or other unintuitive consequences of this
change? Here's the place to explain them.
- Bullet points are okay, too
- A hyphen or asterisk should be used for the bullet, preceded
by a single space, with blank lines in between
Note the fixed or relevant GitHub issues at the end:
Resolves: #123
See also: #456, #789
```
## :white\_check\_mark: Code Review
* **Review the code, not the author.** Look for and suggest improvements without disparaging or insulting the author. Provide actionable feedback and explain your reasoning.
* **You are not your code.** When your code is critiqued, questioned, or constructively criticized, remember that you are not your code. Do not take code review personally.
* **Always do your best.** No one writes bugs on purpose. Do your best, and learn from your mistakes.
* Kindly note any violations to the guidelines specified in this document.
## :nail\_care: Coding Style
Consistency is the most important. Following the existing style, formatting, and naming conventions of the file you are modifying and of the overall project. Failure to do so will result in a prolonged review process that has to focus on updating the superficial aspects of your code, rather than improving its functionality and performance.
For example, if all private properties are prefixed with an underscore `_`, then new ones you add should be prefixed in the same way. Or, if methods are named using camelcase, like `thisIsMyNewMethod`, then do not diverge from that by writing `this_is_my_new_method`. You get the idea. If in doubt, please ask or search the codebase for something similar.
When possible, style and format will be enforced with a linter.
## :medal\_sports: Certificate of Origin
*Developer's Certificate of Origin 1.1*
By making a contribution to this project, I certify that:
> 1. The contribution was created in whole or in part by me and I have the right to submit it under the open source license indicated in the file; or
> 2. The contribution is based upon previous work that, to the best of my knowledge, is covered under an appropriate open source license and I have the right under that license to submit that work with modifications, whether created in whole or in part by me, under the same open source license (unless I am permitted to submit under a different license), as indicated in the file; or
> 3. The contribution was provided directly to me by some other person who certified (1), (2) or (3) and I have not modified it.
> 4. I understand and agree that this project and the contribution are public and that a record of the contribution (including all personal information I submit with it, including my sign-off) is maintained indefinitely and may be redistributed consistent with this project or the source license(s) involved.
## [No Brown M\&M's](https://en.wikipedia.org/wiki/Van_Halen#Contract_riders)
If you are reading this, bravo dear user and (hopefully) contributor for making it this far! You are awesome. :100:

47
LICENSE Normal file
View file

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

13
SECURITY.md Normal file
View file

@ -0,0 +1,13 @@
# Security Policy
If you discover a security issue, please bring it to our attention right away!
## Reporting a Vulnerability
Please **DO NOT** file a public issue to report a security vulberability, instead send your report privately to **casistaken@gmail.com**. This will help ensure that any vulnerabilities that are found can be [disclosed responsibly](https://en.wikipedia.org/wiki/Responsible_disclosure) to any affected parties.
## Supported Versions
Project versions that are currently being supported with security updates vary per project.
Please see specific project repositories for details.
If nothing is specified, only the latest major versions are supported.

31
SUPPORT.md Normal file
View file

@ -0,0 +1,31 @@
# Support and Help
Need help getting started or using a project? Here's how.
## How to get help
Generally, we do not use GitHub as a support forum. For any usage questions that are not specific to the project itself, please ask on [Stack Overflow](https://stackoverflow.com) instead. By doing so, you are more likely to quickly solve your problem, and you will allow anyone else with the same question to find the answer. This also allows maintainers to focus on improving the project for others.
Please seek support in the following ways:
1. :book: **Read the documentation and other guides** for the project to see if you can figure it out on your own. These should be located in a root `docs/` directory. If there is an example project, explore that to learn how it works to see if you can answer your question.
2. :bulb: **Search for answers and ask questions on [Stack Overflow](https://stackoverflow.com).** This is the most appropriate place for debugging issues specific to your use of the project, or figuring out how to use the project in a specific way.
3. :memo: As a **last resort**, you may open an issue on GitHub to ask for help. However, please clearly explain what you are trying to do, and list what you have already attempted to solve the problem. Provide code samples, but **do not** attach your entire project for someone else to debug. Review our [contributing guidelines](./CONTRIBUTING.md).
## What NOT to do
Please **do not** do any the following:
1. :x: Do not reach out to the author or contributor on Twitter (or other social media) by tweeting or sending a direct message.
2. :x: Do not email the author or contributor.
3. :x: Do not open duplicate issues or litter an existing issue with +1's.
These are not appropriate avenues for seeking help or support with an open-source project. Please follow the guidelines in the previous section. Public questions get public answers, which benefits everyone in the community. ✌️
## Customer Support
I do not provide any sort of "customer support" for open-source projects. However, I am available for hire.

BIN
assets/Molot-webfont.woff Normal file

Binary file not shown.

5
assets/logo.svg Normal file
View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="430" height="430" viewBox="0 0 113.50625 113.50625" fill="currentColor">
<path d="M89.693749 72.830671v-19.84375l-18.520833-10.31875-14.552084-8.202083-33.072916 18.520833v19.84375l33.072916-18.520833ZM23.547916 47.695254l23.547916-13.229167L23.547916 21.23692Z" />
<path d="m23.547916 77.990044 33.072916 18.52084 33.072917-18.52084-18.520833-10.318746-14.552084 8.202083-14.552083-8.202083S23.547916 78.122337 23.547916 77.990044z" />
<path d="m56.620832 59.601503 10.054167 5.55625v.000001l-10.054167 5.55625-10.054166-5.55625v-.000001z"/>
</svg>

After

Width:  |  Height:  |  Size: 617 B

5
assets/logo_cropped.svg Normal file
View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="250" height="284.50003" viewBox="0 0 66.145833 75.273966" fill="currentColor">
<path d="M66.145833 51.593751v-19.84375c-11.042319-6.141466-22.077514-12.295645-33.072917-18.520833C22.048611 19.402779 11.024305 25.57639 0 31.750001v19.84375c11.024305-6.173611 22.048611-12.347222 33.072916-18.520833 11.024306 6.173611 22.048611 12.347222 33.072917 18.520833ZM0 26.458334c7.849305-4.409722 15.698611-8.819445 23.547916-13.229167C15.698611 8.819445 7.849305 4.409722 0 0v26.458334Z"/>
<path d="M0 56.753124c11.024305 6.173613 22.048611 12.347227 33.072916 18.52084 11.024306-6.173613 22.048611-12.347227 33.072917-18.52084L47.625 46.434378c-4.850695 2.734028-9.701389 5.468055-14.552084 8.202083-4.850694-2.734028-9.701389-5.468055-14.552083-8.202083C12.352694 49.88272 6.229007 53.417285 0 56.753124Z"/>
<path d="M33.072916 38.364583c3.351389 1.852084 6.70278 3.704166 10.054167 5.556251-3.35139 1.852082-6.702778 3.704167-10.054167 5.55625-3.351388-1.852084-6.702779-3.704166-10.054166-5.556251 3.35139-1.852082 6.702777-3.704167 10.054166-5.55625z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
assets/twemoji.woff2 Normal file

Binary file not shown.

14
components.json Normal file
View file

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

16
eslint.config.js Normal file
View file

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

View file

@ -0,0 +1,17 @@
// @ts-expect-error no types for this, that's fine
import { writeFileSync } from 'node:fs'
import { getIntrospectedSchema, minifyIntrospectionQuery } from '@urql/introspection'
import { getIntrospectionQuery, type IntrospectionQuery } from 'graphql'
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))

82
package.json Normal file
View file

@ -0,0 +1,82 @@
{
"name": "ui",
"version": "6.3.19",
"license": "BUSL-1.1",
"private": true,
"packageManager": "pnpm@9.14.4",
"scripts": {
"dev": "vite dev --open",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --threshold error --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check -threshold error --tsconfig ./tsconfig.json --watch",
"lint": "eslint --quiet -c eslint.config.js",
"lint:fix": "eslint --quiet -c eslint.config.js --fix",
"gql:turbo": "node ./node_modules/gql.tada/bin/cli.js turbo",
"gql:check": "node ./node_modules/gql.tada/bin/cli.js check",
"gql:generate": "node --experimental-strip-types ./generateALIntrospection.ts"
},
"devDependencies": {
"@gql.tada/svelte-support": "^1.0.1",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.21.0",
"@sveltejs/vite-plugin-svelte": "^3.1.2",
"@types/debug": "^4.1.12",
"@types/semver": "^7.7.0",
"@urql/introspection": "^1.2.1",
"autoprefixer": "^10.4.21",
"bits-ui": "^0.22.0",
"cmdk-sv": "^0.0.19",
"eslint-config-standard-universal": "^1.0.6",
"gql.tada": "^1.8.10",
"hayase-extensions": "github:hayase-app/extensions",
"jassub": "^1.7.18",
"svelte": "^4.2.19",
"svelte-check": "^4.2.1",
"svelte-radix": "^1.1.1",
"svelte-sonner": "^0.3.28",
"tailwindcss": "^3.4.17",
"typescript": "^5.8.3",
"vaul-svelte": "^0.3.2",
"vite": "^5.4.11"
},
"type": "module",
"dependencies": {
"@cloudflare/speedtest": "^1.4.1",
"@fontsource-variable/nunito": "^5.2.5",
"@prgm/sveltekit-progress-bar": "2.0.0",
"@thaunknown/web-irc": "^1.0.1",
"@urql/exchange-auth": "^2.2.1",
"@urql/exchange-graphcache": "^7.2.3",
"@urql/exchange-refocus": "^1.1.1",
"@urql/exchange-request-policy": "^1.2.1",
"@urql/exchange-retry": "^1.3.1",
"@urql/svelte": "^4.2.3",
"abslink": "^1.1.0",
"anitomyscript": "github:thaunknown/anitomyscript",
"bittorrent-tracker": "10.0.12",
"bottleneck": "^2.19.5",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"debug": "^4.4.1",
"dompurify": "^3.2.5",
"events": "^3.3.0",
"idb-keyval": "^6.2.2",
"js-levenshtein": "^1.1.6",
"lucide-svelte": "^0.511.0",
"marked": "^15.0.11",
"p2pt": "github:ThaUnknown/p2pt#modernise",
"rollup-plugin-license": "^3.6.0",
"semver": "^7.7.2",
"simple-store-svelte": "^1.0.6",
"svelte-keybinds": "^1.0.9",
"svelte-persisted-store": "^0.12.0",
"tailwind-merge": "^3.3.0",
"tailwind-variants": "^1.0.0",
"uint8-util": "^2.2.5",
"urql": "^4.2.2",
"video-deband": "^1.0.7",
"workbox-core": "^7.3.0",
"workbox-precaching": "^7.3.0"
}
}

5227
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

6
postcss.config.js Normal file
View file

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

361
src/app.css Normal file
View file

@ -0,0 +1,361 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: only dark;
}
@layer base {
/* miniplayer thing, required to work with :hover */
.paused-show {
--padding-right: unset !important;
--padding-left: unset !important;
}
: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-subset.woff') format('woff');
}
@font-face {
font-family: "Twemoji";
src: url("/twemoji-subset.woff2") format("woff2");
}
html,
body {
height: 100vh !important;
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,
a {
-webkit-user-drag: none;
}
:fullscreen {
user-select: none;
}
@keyframes load-in {
from {
transform: translate3d(0, 1.2rem, 0) scale(0.95);
}
to {
transform: none
}
}
.donate {
filter: drop-shadow(0 0 1rem #fa68b6);
animation: glow 1s ease-in-out infinite alternate;
}
@keyframes glow {
from {
transform: translate3d(var(--tw-translate-x), var(--tw-translate-y), 0) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(1) scaleY(1);
}
to {
transform: translate3d(var(--tw-translate-x), var(--tw-translate-y), 0) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(0.85) scaleY(0.85);
}
}
.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);
}
.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);
}
.sr-only {
display: none !important;
}
.flex-center {
display: flex;
justify-content: center;
align-items: center;
}
.text-contrast {
filter: invert(1) grayscale(1) brightness(1.2) contrast(9000);
mix-blend-mode: luminosity;
-webkit-font-smoothing: antialiased;
background-color: inherit;
background-clip: text;
color: transparent
}
.svelte-keybinds {
background: black !important;
}
.svelte-progress-bar {
height: 2px !important;
}
.svelte-progress-bar-leader {
height: 4px !important;
}
/* Backplate related things */
body {
perspective: 3000px;
}
#root:has(+ .backplate-spin) {
animation: idle-spin 120s linear infinite;
}
.backplate-spin {
animation: idle-spin-y-flip 120s linear infinite;
}
#root:has(+ .backplate-fly) {
animation: idle-fly 0.8s forwards cubic-bezier(0.22, 1, 0.36, 1);
}
#root {
transition: transform 0.5s;
transform: perspective(100vw) translate3d(0, 0, 0vw) rotateY(0deg) rotateX(0deg);
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1
}
}
@keyframes idle-fly {
0% {
transform: perspective(100vw) translate3d(0, 0, 0vw) rotateY(0deg) rotateX(0deg);
}
100% {
transform: perspective(100vw) translate3d(0, 0, -66vw) rotateY(0deg) rotateX(-15deg);
}
}
@keyframes idle-spin {
0% {
transform: perspective(100vw) translate3d(0, 0, -66vw) rotateY(0deg) rotateX(-15deg);
}
50% {
transform: perspective(100vw) translate3d(0, 0, -66vw) rotateY(180deg) rotateX(15deg);
}
100% {
transform: perspective(100vw) translate3d(0, 0, -66vw) rotateY(360deg) rotateX(-15deg);
}
}
@keyframes idle-spin-y-flip {
0% {
transform: perspective(100vw) translate3d(0, 0, -66vw) rotateY(-180deg) rotateX(15deg);
}
50% {
transform: perspective(100vw) translate3d(0, 0, -66vw) rotateY(0deg) rotateX(-15deg);
}
100% {
transform: perspective(100vw) translate3d(0, 0, -66vw) rotateY(180deg) rotateX(15deg);
}
}
@keyframes marquee {
from {
transform: translateX(-30%);
}
to {
transform: translateX(-130%);
}
}
.animate-marquee {
animation: marquee 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';
}
.font-twemoji {
font-family: 'Twemoji';
}
.backface-visible {
backface-visibility: visible;
}
.backface-hidden {
backface-visibility: hidden;
}
.preserve-3d {
transform-style: preserve-3d;
}

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

@ -0,0 +1,149 @@
// See https://kit.svelte.dev/docs/types#app
import { SvelteComponentTyped } from 'svelte'
import type { SessionMetadata } from '$lib/components/ui/player/util'
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 Track {
selected: boolean
enabled: boolean
id: string
kind: string
label: string
language: string
}
export interface TorrentFile {
name: string
hash: string
type: string
size: number
path: string
url: string
id: number
}
export interface Attachment {
filename: string
mimetype: string
id: number
url: string
}
export interface TorrentInfo {
peers: number
progress: number
down: number
up: number
name: string
hash: string
seeders: number
leechers: number
size: number
downloaded: number
eta: number
}
export interface TorrentSettings {
torrentPersist: boolean
torrentDHT: boolean
torrentStreamedDownload: boolean
torrentSpeed: number
maxConns: number
torrentPort: number
dhtPort: number
torrentPeX: boolean
}
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>
focus: () => Promise<void>
close: () => Promise<void>
selectPlayer: () => Promise<string>
selectDownload: () => Promise<string>
setAngle: (angle: string) => Promise<void>
getLogs: () => Promise<string>
getDeviceInfo: () => Promise<unknown>
openUIDevtools: () => Promise<void>
openTorrentDevtools: () => Promise<void>
checkUpdate: () => Promise<void>
toggleDiscordDetails: (enabled: boolean) => Promise<void>
setMediaSession: (metadata: SessionMetadata, mediaId: number) => Promise<void>
setPositionState: (state?: MediaPositionState) => Promise<void>
setPlayBackState: (paused: 'none' | 'paused' | 'playing') => Promise<void>
setActionHandler: (action: MediaSessionAction | 'enterpictureinpicture', handler: MediaSessionActionHandler | null) => void
checkAvailableSpace: (_?: unknown) => Promise<number>
checkIncomingConnections: (port: number) => Promise<boolean>
updatePeerCounts: (hashes: string[]) => Promise<Array<{ hash: string, complete: string, downloaded: string, incomplete: string }>>
playTorrent: (id: string) => Promise<TorrentFile[]>
attachments: (hash: string, id: number) => Promise<Attachment[]>
tracks: (hash: string, id: number) => Promise<Array<{ number: string, language?: string, type: string, header?: string, name?: string }>>
subtitles: (hash: string, id: number, cb: (subtitle: { text: string, time: number, duration: number }, trackNumber: number) => void) => Promise<void>
chapters: (hash: string, id: number) => Promise<Array<{ start: number, end: number, text: string }>>
torrentStats: (hash: string) => Promise<TorrentInfo>
torrents: () => Promise<TorrentInfo[]>
setDOH: (dns: string) => Promise<void>
cachedTorrents: () => Promise<string[]>
downloadProgress: (percent: number) => Promise<void>
updateSettings: (settings: TorrentSettings) => Promise<void>
updateProgress: (cb: (progress: number) => void) => Promise<void>
spawnPlayer: (url: string) => Promise<void>
setHideToTray: (enabled: boolean) => Promise<void>
transparency: (enabled: boolean) => Promise<void>
isApp: boolean
version: () => Promise<string>
navigate: (cb: (data: { target: string, value: string | undefined }) => void) => Promise<void>
}
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
interface PageState {
search?: VariablesOf<typeof Search>
}
// interface Platform {}
}
interface HTMLMediaElement {
videoTracks?: Track[]
audioTracks?: Track[]
}
interface ScreenOrientation {
lock: (orientation: 'any' | 'natural' | 'landscape' | 'portrait' | 'portrait-primary' | 'portrait-secondary' | 'landscape-primary' | 'landscape-secondary') => Promise<void>
}
interface Navigator {
userAgentData: {
getHighEntropyValues: (keys: string[]) => Promise<Record<string, string>>
}
}
// declare module '*.svelte' {
// export default SvelteComponentTyped
// }
}
declare module '*.svelte' {
export default SvelteComponentTyped
}
export {}

16
src/app.html Normal file
View file

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

View file

@ -0,0 +1,90 @@
<script lang='ts'>
import { activityState, idleState, isPlaying, lockedState } from '$lib/modules/idle'
import { settings } from '$lib/modules/settings'
import { sleep } from '$lib/utils'
let plate: HTMLDivElement
export let root: HTMLDivElement
let visibilityState: DocumentVisibilityState
let isAnimating = false
let isSpinning = false
let isFlying = false
let showBackplate = false
let timeout: number
// WE LOVE RACE CONDITIONS WOOOO YEAAH MY SANITY
async function start () {
if (isAnimating) return
isAnimating = true
isFlying = true
await sleep(800)
if (!isFlying) return
showBackplate = true
isSpinning = true
isFlying = false
}
async function reset () {
if (!isAnimating) return
root.style.transform = getComputedStyle(root).transform
plate.style.transform = getComputedStyle(plate).transform
isSpinning = isFlying = false
await sleep(10)
root.style.transform = plate.style.transform = ''
await sleep(490)
isAnimating = showBackplate = isSpinning = isFlying = false
}
$: active = $lockedState === 'locked' || visibilityState === 'hidden' || ($idleState === 'active' && $activityState === 'active') || $isPlaying
function checkIdleState (active: boolean, idleAnimation: boolean) {
clearTimeout(timeout)
if (!idleAnimation || active) return reset()
timeout = setTimeout(start, 120_000)
}
$: checkIdleState(active, $settings.idleAnimation)
</script>
<svelte:document bind:visibilityState />
<div class='preserve-3d absolute w-full h-full overflow-clip flip backface-hidden backplate bg-black flex-col justify-center pointer-events-none hidden'
bind:this={plate}
class:!flex={showBackplate}
class:backplate-fly={isFlying}
class:backplate-spin={isSpinning}>
{#each Array.from({ length: 6 }) as _, i (i)}
<div class='flex flex-row w-full font-molot font-bold leading-[0.8] ml-[--ml-offset] -rotate-12 text-white mt-64' style:--ml-offset='calc((-1 * {(i) * 600}px) - 10vw)'>
{#each Array.from({ length: 4 }) as _, i (i)}
<div>
<div class='bg-striped'>
<div class='text-[24rem] tracking-wide animate-marquee bg-black mix-blend-multiply'>
HAYASE.06&nbsp;
</div>
</div>
<div class='bg-striped-muted'>
<div class='flex pl-1 animate-marquee bg-black mix-blend-multiply'>
<div class='rounded py-2 px-3 mt-1 mb-[2.5px] mr-2 ml-1 text-black bg-white flex items-center leading-[0.9]'>
TORRENTING<br />MADE<br />SIMPLE
</div>
<div class='text-[5.44rem] bg-striped-muted tracking-wider'>
MAGNET://SIMPLICITY TOPS EVERYTHING
</div>
</div>
</div>
</div>
{/each}
</div>
{/each}
</div>
<style>
.backplate {
transition: transform 0.5s;
transform: perspective(100vw) translate3d(0, 0, 0vw) rotateY(180deg) rotateX(0deg);
}
</style>

View file

@ -0,0 +1,111 @@
<script lang='ts'>
import PencilLine from 'lucide-svelte/icons/pencil-line'
import { Button } from '$lib/components/ui/button'
import * as Dialog from '$lib/components/ui/dialog'
import { Input } from '$lib/components/ui/input'
import * as Select from '$lib/components/ui/select'
import { cover, title, type Media } from '$lib/modules/anilist'
import { list, progress as _progress, score as _score, repeat as _repeat, authAggregator, lists } from '$lib/modules/auth'
import { dragScroll } from '$lib/modules/navigate'
export let media: Media
const STATUS_LABELS = {
CURRENT: 'Watching',
PLANNING: 'Plan to Watch',
COMPLETED: 'Completed',
PAUSED: 'Paused',
DROPPED: 'Dropped',
REPEATING: 'Re-Watching'
}
let status = { value: list(media) ?? 'CURRENT', label: STATUS_LABELS[list(media) ?? 'CURRENT'] }
let score = { value: Number(_score(media) ?? 0), label: '' + (_score(media) ?? 0) }
let progress = _progress(media) ?? 0
let repeat = _repeat(media) ?? 0
function deleteEntry () {
if (!media.mediaListEntry) return
authAggregator.delete(media.mediaListEntry.id)
}
function saveEntry () {
authAggregator.entry({ id: media.id, score: Number(score.value) * 10, repeat: Number(repeat), progress: Number(progress), status: status.value, lists: lists(media)?.filter(({ enabled }) => enabled).map(({ name }) => name) })
}
</script>
<Dialog.Root portal='#root'>
<Dialog.Trigger let:builder asChild>
<Button size='icon' class='rounded-l-none bg-primary/85 select:bg-primary/75 shrink-0' builders={[builder]}>
<PencilLine class='size-4' />
</Button>
</Dialog.Trigger>
<Dialog.Content class='flex justify-center max-h-[80%] p-0'>
<div class='flex flex-col md:flex-row w-full overflow-y-auto' use:dragScroll>
<div class='relative w-full h-[120px] md:w-[260px] md:h-[400px] shrink-0'>
<img alt='images' loading='lazy' decoding='async' class='object-cover w-full h-full' style:background={media.coverImage?.color ?? '#000'} src={cover(media)} />
</div>
<form class='flex flex-col w-full rounded-r-lg h-full'>
<div class='pt-4 px-5 w-full'>
<h3 class='text-xl leading-6 font-semibold z-30 text-white sm:line-clamp-1 line-clamp-2'>{title(media)}</h3>
</div>
<div class='px-5 py-3 grid grid-cols-1 sm:grid-cols-2 gap-5 w-full'>
<div class='mt-1 flex flex-col'>
<div class='font-bold text-muted-foreground text-sm mb-2'>Status</div>
<Select.Root bind:selected={status}>
<Select.Trigger>
<Select.Value placeholder='Status' />
</Select.Trigger>
<Select.Content sameWidth={true}>
<Select.Item value='CURRENT'>Watching</Select.Item>
<Select.Item value='PLANNING'>Plan to Watch</Select.Item>
<Select.Item value='COMPLETED'>Completed</Select.Item>
<Select.Item value='PAUSED'>Paused</Select.Item>
<Select.Item value='DROPPED'>Dropped</Select.Item>
<Select.Item value='REPEATING'>Re-Watching</Select.Item>
</Select.Content>
</Select.Root>
</div>
<div class='mt-1 flex flex-col'>
<div class='font-bold text-muted-foreground text-sm mb-2'>Score</div>
<Select.Root bind:selected={score}>
<Select.Trigger>
<Select.Value placeholder='Score' />
</Select.Trigger>
<Select.Content sameWidth={true}>
<Select.Item value='0' />
<Select.Item value='1' />
<Select.Item value='2' />
<Select.Item value='3' />
<Select.Item value='4' />
<Select.Item value='5' />
<Select.Item value='6' />
<Select.Item value='7' />
<Select.Item value='8' />
<Select.Item value='9' />
<Select.Item value='10' />
</Select.Content>
</Select.Root>
</div>
<div class='mt-1 flex flex-col'>
<div class='font-bold text-muted-foreground text-sm mb-2'>Progress</div>
<Input type='number' inputmode='numeric' pattern='[0-9]*.?[0-9]*' min='0' max='Infinity' bind:value={progress} />
</div>
<div class='mt-1 flex flex-col'>
<div class='font-bold text-muted-foreground text-sm mb-2'>Rewatched Times</div>
<Input type='number' inputmode='numeric' pattern='[0-9]*.?[0-9]*' min='0' max='Infinity' bind:value={repeat} />
</div>
</div>
<div class='px-4 py-3 gap-3 mt-auto flex flex-col sm:flex-row-reverse sm:px-6'>
<Dialog.Close let:builder asChild>
<Button on:click={saveEntry} builders={[builder]}>Save Changes</Button>
<Button variant='secondary' builders={[builder]}>Cancel</Button>
<Button variant='destructive' on:click={deleteEntry} builders={[builder]}>Delete</Button>
</Dialog.Close>
</div>
</form>
</div>
</Dialog.Content>
</Dialog.Root>

View file

@ -0,0 +1,164 @@
<script context='module' lang='ts'>
export 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 ChevronLeft from 'lucide-svelte/icons/chevron-left'
import ChevronRight from 'lucide-svelte/icons/chevron-right'
import Play from 'lucide-svelte/icons/play'
import Pagination from './Pagination.svelte'
import { Button } from './ui/button'
import { Load } from './ui/img'
import { Profile } from './ui/profile'
import type { EpisodesResponse } from '$lib/modules/anizip/types'
import { searchStore } from '$lib'
import { episodes as _episodes, dedupeAiring, episodeByAirDate, notes, type Media } from '$lib/modules/anilist'
import { authAggregator, list, progress } from '$lib/modules/auth'
import { click, dragScroll } from '$lib/modules/navigate'
import { cn, isMobile, since } from '$lib/utils'
export let eps: EpisodesResponse | null
export let media: Media
$: episodeCount = Math.max(_episodes(media) ?? 0, eps?.episodeCount ?? 0)
$: ({ episodes, specialCount } = eps ?? {})
const alSchedule: Record<number, Date | undefined> = {}
$: {
for (const { a: airingAt, e: episode } of dedupeAiring(media)) {
alSchedule[episode] = new Date(airingAt * 1000)
}
}
$: episodeList = media && 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, list: typeof episodeList = episodeList) {
return list.slice((page - 1) * perPage, page * perPage)
}
$: _progress = progress(media) ?? 0
$: completed = list(media) === 'COMPLETED'
let currentPage = Math.floor((progress(media) ?? 0) / perPage) + 1
function play (episode: number) {
searchStore.set({ media, episode })
}
export let following = authAggregator.following(media.id)
$: followerEntries = $following?.data?.Page?.mediaList?.filter(e => e?.user?.id !== authAggregator.id()) ?? []
</script>
<Pagination count={episodeCount} {perPage} bind:currentPage let:pages let:hasNext let:hasPrev let:range let:setPage siblingCount={1}>
<div class='overflow-y-auto pt-3 -mx-14 px-14 pointer-events-none -mb-3 pb-3' use:dragScroll>
<div class='grid grid-cols-1 sm:grid-cols-[repeat(auto-fit,minmax(500px,1fr))] place-items-center gap-x-10 gap-y-7 justify-center align-middle pointer-events-auto'>
{#each getPage(currentPage, episodeList) 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-[transform,box-shadow] duration-200 shrink-0 ease-out focus-visible:ring-ring focus-visible:ring-1 rounded-md bg-neutral-950 text-secondary-foreground select:bg-neutral-900 flex w-full max-h-28 cursor-pointer relative overflow-hidden group',
target && 'ring-ring ring-1',
filler && '!ring-yellow-400 ring-1'
)}>
{#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-200 text-white transition-[background] ease-out top-0'>
<Play class='size-6 scale-75 opacity-0 group-select:opacity-100 group-select:scale-100 duration-200 transition-[transform,opacity] 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>
<div class='flex w-full justify-between mt-auto'>
{#if airingAt ?? airdate}
<div class='text-[9.6px] pt-2'>
{since(new Date(airingAt ?? airdate ?? 0))}
</div>
{/if}
<div class='-space-x-1 ml-auto inline-flex pt-1 pr-0.5'>
{#each followerEntries.filter(e => e?.progress === episode) as followerEntry, i (followerEntry?.user?.id ?? i)}
{#if followerEntry?.user}
<Profile user={followerEntry.user} class='ring-2 ring-neutral-950 size-4 bg-neutral-950' />
{/if}
{/each}
</div>
</div>
{#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>
<div class='flex flex-row items-center justify-between w-full py-3'>
<p class='text-center text-[13px] text-muted-foreground hidden md:block'>
Showing <span class='font-bold'>{range.start + 1}</span> to <span class='font-bold'>{range.end}</span> of <span class='font-bold'>{episodeCount}</span> episodes
</p>
<div class='w-full md:w-auto gap-2 flex items-center'>
<Button size='icon' variant='ghost' on:click={() => setPage(currentPage - 1)} disabled={!hasPrev}>
<ChevronLeft class='h-4 w-4' />
</Button>
{#if !$isMobile}
{#each pages as { page, type } (page)}
{#if type === 'ellipsis'}
<span class='h-9 w-9 text-center'>...</span>
{:else}
<Button size='icon' variant={page === currentPage ? 'outline' : 'ghost'} on:click={() => setPage(page)}>
{page}
</Button>
{/if}
{/each}
{:else}
<p class='text-center text-[13px] text-muted-foreground w-full block md:hidden'>
Showing <span class='font-bold'>{range.start + 1}</span> to <span class='font-bold'>{range.end}</span> of <span class='font-bold'>{episodeCount}</span> episodes
</p>
{/if}
<Button size='icon' variant='ghost' on:click={() => setPage(currentPage + 1)} disabled={!hasNext}>
<ChevronRight class='h-4 w-4' />
</Button>
</div>
</div>
</Pagination>

View file

@ -0,0 +1,38 @@
<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
type $$Props = HTMLAttributes<HTMLDivElement> & { id: string }
function mount (node: HTMLDivElement) {
const entry = keep.get(id)
if (entry) node.appendChild(entry.node)
}
</script>
<div use:mount {...$$restProps} />

View file

@ -0,0 +1,53 @@
<script lang='ts'>
import CloudOff from 'lucide-svelte/icons/cloud-off'
import online from '$lib/modules/online'
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 shrink-0'>
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 shrink-0'>
<CloudOff size={16} class='me-2' />
Offline
</div>
{/if}
<style>
.online {
animation: hide 300ms forwards 2s;
}
@keyframes hide {
from {
height: 24px;
}
to {
height: 0;
padding-top: 0;
padding-bottom: 0;
}
}
.offline {
height: 0;
padding-top: 0;
padding-bottom: 0;
animation: show 300ms forwards 2s;
}
@keyframes show {
from {
height: 0;
}
to {
height: 24px;
padding-top: 2px;
padding-bottom: 2px;
}
}
</style>

View file

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

View file

@ -0,0 +1,311 @@
<script lang='ts' context='module'>
import BadgeCheck from 'lucide-svelte/icons/badge-check'
import Database from 'lucide-svelte/icons/database'
import Download from 'svelte-radix/Download.svelte'
import MagnifyingGlass from 'svelte-radix/MagnifyingGlass.svelte'
import { SingleCombo } from './ui/combobox'
import { Input } from './ui/input'
import type { AnitomyResult } from 'anitomyscript'
import type { TorrentResult } from 'hayase-extensions'
import * as Dialog from '$lib/components/ui/dialog'
import { title } from '$lib/modules/anilist'
import { extensions } from '$lib/modules/extensions/extensions'
import { click, dragScroll } from '$lib/modules/navigate'
import { settings, videoResolutions } from '$lib/modules/settings'
import { fastPrettyBytes, since } from '$lib/utils'
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: video, audio_term: audio, video_resolution: resolution, source }: AnitomyResult) {
const terms = [...new Set([...video ?? [], ...audio ?? [], ...source ?? []].map(term => termMapping[term.toUpperCase() ?? '']).filter(t => t))] as Array<{text: string, color: string}>
if (resolution.length) terms.unshift({ text: resolution[0]!, color: '#c6ec58' })
return terms
}
function simplifyFilename ({ video_term: video, audio_term: audio, video_resolution: resolution, file_name: name, release_group: group, file_checksum: checksum }: AnitomyResult) {
let simpleName = name[0]!
if (group.length) simpleName = simpleName.replace(group[0]!, '')
if (resolution.length) simpleName = simpleName.replace(resolution[0]!, '')
if (checksum.length) simpleName = simpleName.replace(checksum[0]!, '')
for (const term of video ?? []) simpleName = simpleName.replace(term[0]!, '')
for (const term of audio ?? []) simpleName = simpleName.replace(term[0]!, '')
return simpleName.replace(/[[{(]\s*[\]})]/g, '').replace(/\s+/g, ' ').trim()
}
</script>
<script lang='ts'>
import ProgressButton from './ui/button/progress-button.svelte'
import { Banner } from './ui/img'
import { goto } from '$app/navigation'
import { searchStore } from '$lib'
import { saved } from '$lib/modules/extensions'
import { server } from '$lib/modules/torrent'
$: open = !!$searchStore.media
$: searchResult = !!$searchStore.media && extensions.getResultsFromExtensions({ media: $searchStore.media, episode: $searchStore.episode, batch: $settings.searchBatch, resolution: $settings.searchQuality })
function close (state = false) {
if (!state) {
searchStore.set({})
open = false
inputText = ''
}
}
let inputText = ''
function play (result: Pick<TorrentResult, 'hash'>) {
server.play(result.hash, $searchStore.media!, $searchStore.episode!)
goto('/app/player/')
close()
}
async function playBest () {
if (!searchResult) return
const best = filterAndSortResults((await searchResult).results, inputText, await $downloaded)[0]
if (best) play(best)
}
function filterAndSortResults (results: Array<TorrentResult & { parseObject: AnitomyResult, extension: Set<string> }>, searchText: string, downloaded: Set<string>) {
const preference = $settings.lookupPreference
return results
.filter(({ title }) => title.toLowerCase().includes(searchText.toLowerCase()))
.sort((a, b) => {
// pre-emtively sort by deal breaker conditions
// the higher the rank the worse the result... don't ask
function getRank (res: typeof results[0]) {
if (res.accuracy === 'low') return 3
if (downloaded.has(res.hash)) return 0
if (res.seeders <= 15) return 2
if ((res.type === 'best' || res.type === 'alt') && preference === 'quality') return 0
return 1
}
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 preference, quality is sorted in rank, so quality and seeders is both as seeders here.
if (preference === 'size') return a.size - b.size
return b.seeders - a.seeders
}
return 0
})
}
let animating = false
async function startAnimation (searchRes: typeof searchResult) {
if (!$settings.searchAutoSelect) return
animating = false
await searchRes
if (searchRes === searchResult) animating = true
}
function stopAnimation () {
animating = false
}
const torrentRx = /(^magnet:){1}|(^[A-F\d]{8,40}$){1}|(.*\.torrent$){1}/i
function findTorrentIdentifiers (hash: string) {
if (torrentRx.test(hash)) {
play({ hash })
}
}
$: findTorrentIdentifiers(inputText)
$: searchResult && startAnimation(searchResult)
const downloaded = server.downloaded
</script>
<Dialog.Root bind:open onOpenChange={close} portal='#episodeListTarget'>
<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 z-[100]'>
<div class='absolute top-0 left-0 w-full h-full max-h-28 overflow-hidden'>
{#if $searchStore.media}
<Banner media={$searchStore.media} class='object-cover w-full h-full absolute bottom-[0.5px] left-0 -z-10' />
{/if}
<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'
placeholder='Filter by text, or paste a magnet link or torrent file to specify a torrent manually'
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={videoResolutions} 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} on:pointermove={stopAnimation} use:dragScroll>
{#await Promise.all([searchResult, $downloaded])}
{#each Array.from({ length: 12 }) as _, i (i)}
<div class='p-3 h-[104px] flex cursor-pointer mb-2 relative rounded-md overflow-hidden border border-border flex-col justify-between'>
<div class='h-4 w-40 bg-primary/5 animate-pulse rounded mt-2' />
<div class='bg-primary/5 animate-pulse rounded h-2 w-28 mt-1' />
<div class='flex justify-between mb-1'>
<div class='flex gap-2'>
<div class='mt-2 bg-primary/5 animate-pulse rounded h-2 w-20' />
<div class='mt-2 bg-primary/5 animate-pulse rounded h-2 w-20' />
</div>
<div class='mt-2 bg-primary/5 animate-pulse rounded h-2 w-20' />
</div>
</div>
{/each}
{:then [search, downloaded]}
{@const media = $searchStore.media}
{#if search && media}
{@const { results, errors } = search}
{#each filterAndSortResults(results, inputText, downloaded) 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' class:opacity-40={result.accuracy === 'low'} use:click={() => play(result)} title={result.parseObject.file_name[0]}>
{#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 items-center'>
{#if downloaded.has(result.hash)}
<Download class='mr-2 text-[#53da33]' size='1.2rem' />
{:else if result.type === 'batch'}
<Database class='mr-2' size='1.2rem' />
{:else if result.accuracy === 'high'}
<BadgeCheck class='mr-2 text-[#53da33]' size='1.2rem' />
{/if}
<div class='text-xl font-bold text-nowrap'>{result.parseObject.release_group[0] && result.parseObject.release_group[0].length < 20 ? result.parseObject.release_group[0] : 'No Group'}</div>
<div class='ml-auto flex gap-2 self-start'>
{#each result.extension as id (id)}
{#if $saved[id]}
<img src={$saved[id].icon} alt={id} class='size-4' title='Provided by {id}' decoding='async' loading='lazy' />
{/if}
{/each}
</div>
</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-contrast'>
{text}
</div>
</div>
{/each}
</div>
</div>
</div>
</div>
{:else}
<div class='p-5 flex items-center justify-center w-full h-80'>
<div>
<div class='mb-3 font-bold text-4xl text-center '>
Ooops!
</div>
<div class='text-lg text-center text-muted-foreground'>
No results found.<br />Try specifying a torrent manually by pasting a magnet link or torrent file into the filter bar.
</div>
</div>
</div>
{/each}
{#each errors as error, i (i)}
<div class='p-5 flex items-center justify-center w-full h-80'>
<div>
<div class='mb-1 font-bold text-2xl text-center '>
Extensions {error.extension} encountered an error
</div>
<div class='text-md text-center text-muted-foreground whitespace-pre-wrap'>
{error.error.stack}
</div>
</div>
</div>
{/each}
{/if}
{:catch error}
<div class='p-5 flex items-center justify-center w-full h-80'>
<div>
<div class='mb-3 font-bold text-4xl text-center '>
Ooops!
</div>
<div class='text-lg text-center text-muted-foreground whitespace-pre-wrap'>
{error.message}
</div>
</div>
</div>
{/await}
</div>
</div>
</Dialog.Content>
</Dialog.Root>
<style>
.banner {
background: linear-gradient(90deg, #000 32%, rgba(0, 0, 0, 0.9) 100%);
}
.banner-2 {
background: linear-gradient(#000d 0%, #000d 90%, #000 100%);
}
</style>

View file

@ -0,0 +1,20 @@
<script lang='ts'>
import { Label } from '$lib/components/ui/label'
import { cn } from '$lib/utils'
export let title = ''
export let description = ''
const id = Math.trunc(Math.random() * Number.MAX_SAFE_INTEGER).toString() + title
let className = ''
export { className as class }
</script>
<div class={cn('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', className)}>
<Label for={id} class='space-1 block leading-[unset] grow'>
<div class='font-bold'>{title}</div>
<div class='text-muted-foreground text-xs whitespace-pre-wrap block'>{description}</div>
</Label>
<slot {id} />
</div>

View file

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

View file

@ -0,0 +1,58 @@
<script lang='ts'>
import dompurify from 'dompurify'
import { marked } from 'marked'
marked.setOptions({
gfm: true,
breaks: true,
pedantic: false
})
export let html = ''
let root: ShadowRoot | undefined
const style = new CSSStyleSheet()
style.replaceSync(/* css */`
p {
margin-block-start: .5em;
margin-block-end: .5em;
}
img, video {
max-width: 100%;
-webkit-user-drag: none;
}`)
function sanitize (html: string) {
return dompurify.sanitize(html, { ALLOWED_TAGS: ['a', 'b', 'blockquote', 'br', 'center', 'del', 'div', 'em', 'font', 'h1', 'h2', 'h3', 'h4', 'h5', 'hr', 'i', 'img', 'li', 'ol', 'p', 'pre', 'code', 'span', 'strike', 'strong', 'ul'], ALLOWED_ATTR: ['align', 'height', 'href', 'src', 'target', 'width', 'rel'] })
}
// i mean holy shit anilist, could you have made it any harder on yourself
function shadow (node: HTMLDivElement, html: string) {
root ??= node.attachShadow({ mode: 'closed' })
root.adoptedStyleSheets = [style]
// eslint-disable-next-line no-useless-escape
html = html.replace(/(http)(:([\/|.|\w|\s|-])*\.(?:jpg|.jpeg|gif|png|mp4|webm))/gi, '$1s$2')
.replace(/img\s?(\d+%?)?\s?\((.[\S]+)\)/gi, "<img width='$1' src='$2'>")
.replace(/(^|>| )@([A-Za-z0-9]+)/gm, "$1<a href='#'>@$2</a>")
.replace(/youtube\s?\([^]*?([-_0-9A-Za-z]{10,15})[^]*?\)/gi, 'youtube ($1)')
// eslint-disable-next-line no-useless-escape
.replace(/webm\s?\(h?([A-Za-z0-9-._~:\/?#\[\]@!$&()*+,;=%]+)\)/gi, 'webmv(`$1`)')
.replace(/~{3}([^]*?)~{3}/gm, '+++$1+++')
.replace(/~!([^]*?)!~/gm, '<div rel="spoiler">$1</div>')
html = sanitize(marked.parse(html, { async: false }))
.replace(/\+{3}([^]*?)\+{3}/gm, '<center>$1</center>')
// t = t.replace(/<div rel="spoiler">([\s\S]*?)<\/div>/gm, "<p><span onclick='showSpoiler(this)' class='markdown-spoiler'><i class='hide-spoiler el-icon-circle-close' onclick='hideSpoiler(this)'></i><span>$1</span></span></p>")
// t = t.replace(/youtube\s?\(([-_0-9A-Za-z]{10,15})\)/gi, "<span class='youtube' id='$1' style='width: 500px; height: 200px; max-width: 100%;'><span class='play'></span></span>")
// eslint-disable-next-line no-useless-escape
.replace(/webmv\s?\(<code>([A-Za-z0-9-._~:\/?#\[\]@!$&()*+,;=%]+)<\/code>\)/gi, "<video muted loop controls><source src='h$1' type='video/webm'>Your browser does not support the video tag.</video>")
// t = t.replace(/(?:<a href="https?:\/\/anilist.co\/(anime|manga)\/)([0-9]+).*?>(?:https?:\/\/anilist.co\/(?:anime|manga)\/[0-9]+).*?<\/a>/gm, '<span class="media-embed" data-media-type="$1" data-media-id="$2"></span>')
root.innerHTML = html
}
let className: string | undefined | null
export { className as class }
</script>
<div use:shadow={html} class={className} />

View file

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

View file

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

After

Width:  |  Height:  |  Size: 831 B

View file

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

View file

@ -0,0 +1,5 @@
<svg name='logo' {...$$props} xml:space='preserve' viewBox='0 0 66.145833 75.273966' fill='currentColor'>
<path d='M66.145833 51.593751v-19.84375c-11.042319-6.141466-22.077514-12.295645-33.072917-18.520833C22.048611 19.402779 11.024305 25.57639 0 31.750001v19.84375c11.024305-6.173611 22.048611-12.347222 33.072916-18.520833 11.024306 6.173611 22.048611 12.347222 33.072917 18.520833ZM0 26.458334c7.849305-4.409722 15.698611-8.819445 23.547916-13.229167C15.698611 8.819445 7.849305 4.409722 0 0v26.458334Z' />
<path d='M0 56.753124c11.024305 6.173613 22.048611 12.347227 33.072916 18.52084 11.024306-6.173613 22.048611-12.347227 33.072917-18.52084L47.625 46.434378c-4.850695 2.734028-9.701389 5.468055-14.552084 8.202083-4.850694-2.734028-9.701389-5.468055-14.552083-8.202083C12.352694 49.88272 6.229007 53.417285 0 56.753124Z' />
<path d='M33.072916 38.364583c3.351389 1.852084 6.70278 3.704166 10.054167 5.556251-3.35139 1.852082-6.702778 3.704167-10.054167 5.55625-3.351388-1.852084-6.702779-3.704166-10.054166-5.556251 3.35139-1.852082 6.702777-3.704167 10.054166-5.55625z' />
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

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

View file

@ -0,0 +1,12 @@
<script lang='ts'>
import Icon from 'lucide-svelte/dist/Icon.svelte'
import type { SvelteHTMLElements, SVGAttributes } from 'svelte/elements'
type Attrs = SVGAttributes<SVGSVGElement>
const iconNode: Array<[elementName: keyof SvelteHTMLElements, attrs: Attrs]> = [['path', { d: 'M8 4.5v5H3m-1-6 6 6m13 0v-3c0-1.16-.84-2-2-2h-7m-9 9v2c0 1.05.95 2 2 2h3' }], ['rect', { width: '10', height: '7', x: '12', y: '13', rx: '2', fill: 'currentColor' }]]
</script>
<Icon name='picture-in-picture' {...$$props} {iconNode}>
<slot />
</Icon>

View file

@ -0,0 +1,12 @@
<script lang='ts'>
import Icon from 'lucide-svelte/dist/Icon.svelte'
import type { SvelteHTMLElements, SVGAttributes } from 'svelte/elements'
type Attrs = SVGAttributes<SVGSVGElement>
const iconNode: Array<[elementName: keyof SvelteHTMLElements, attrs: Attrs]> = [['path', { d: 'M21 9V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v10c0 1.1.9 2 2 2h4' }], ['rect', { width: '10', height: '7', x: '12', y: '13', rx: '2', fill: 'currentColor' }]]
</script>
<Icon name='picture-in-picture-exit' {...$$props} {iconNode}>
<slot />
</Icon>

View file

@ -0,0 +1,14 @@
<script lang='ts'>
import Icon from 'lucide-svelte/dist/Icon.svelte'
import type { SvelteHTMLElements, SVGAttributes } from 'svelte/elements'
type Attrs = SVGAttributes<SVGSVGElement>
const iconNode: Array<[elementName: keyof SvelteHTMLElements, attrs: Attrs]> = [
['path', { 'fill-rule': 'evenodd', 'clip-rule': 'evenodd', d: 'M22.99 11.7773C22.9219 10.8388 22.4205 9.92777 21.4859 9.39398L7.48782 1.39936C5.48785 0.25713 3 1.70127 3 4.00443V19.9937C3 22.2968 5.48785 23.741 7.48781 22.5988L21.4859 14.6041C22.4205 14.0703 22.9219 13.1593 22.99 12.2208C23.0226 12.0751 23.0226 11.9231 22.99 11.7773Z' }]
]
</script>
<Icon name='play' {...$$props} {iconNode} viewBox='0 0 24 24' strokeWidth='0'>
<slot />
</Icon>

View file

@ -0,0 +1,15 @@
<script lang='ts'>
import Icon from 'lucide-svelte/dist/Icon.svelte'
import type { SvelteHTMLElements, SVGAttributes } from 'svelte/elements'
type Attrs = SVGAttributes<SVGSVGElement>
const iconNode: Array<[elementName: keyof SvelteHTMLElements, attrs: Attrs]> = [
['path', { fill: 'none', d: 'M0 0h24v24H0z' }],
['path', { d: 'M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zM4 12h4v2H4v-2zm10 6H4v-2h10v2zm6 0h-4v-2h4v2zm0-4H10v-2h10v2z' }]
]
</script>
<Icon name='subtitles' {...$$props} {iconNode} viewBox='0 0 24 24' strokeWidth='0'>
<slot />
</Icon>

View file

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

View file

@ -0,0 +1,19 @@
<script lang='ts'>
import { Avatar as AvatarPrimitive } from 'bits-ui'
import { cn } from '$lib/utils.js'
type $$Props = AvatarPrimitive.ImageProps & { alt: string | undefined | null, src: string | undefined | null }
let className: $$Props['class'] = undefined
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 object-cover', className)}
{...$$restProps}
/>

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,49 @@
<script lang='ts' context='module'>
import { writable } from 'simple-store-svelte'
import type { Media } from '$lib/modules/anilist'
import { cn } from '$lib/utils'
export const bannerSrc = writable<Media | null>(null)
export const hideBanner = writable(false)
</script>
<script lang='ts'>
import { Banner } from '../img'
import type { HTMLAttributes } from 'svelte/elements'
type $$Props = HTMLAttributes<HTMLImageElement>
let className: $$Props['class'] = ''
export { className as class } // TODO: needs nice animations, should update to coverimage on mobile width
</script>
{#if $bannerSrc}
<div class={cn('object-cover w-screen absolute top-0 left-0 h-full overflow-hidden pointer-events-none bg-black banner', className)}>
{#key $bannerSrc}
<Banner media={$bannerSrc} class='min-w-[100vw] w-screen h-[23rem] object-cover {$hideBanner ? 'opacity-10' : 'opacity-100'} transition-opacity duration-500 banner-gr relative' />
{/key}
</div>
{/if}
<style>
:global(div.banner-gr::after) {
content: '';
position: absolute;
left: 0 ; bottom: 0;
width: 100%;
height: 300px;
background: linear-gradient(1turn, rgb(3, 3, 3) 8.98%, rgba(0, 0, 0, 0) 100%);
}
.banner::after {
content: '';
position: absolute;
left: 0 ; top: 0;
width: 100%; height: 23rem;
z-index: 0;
border-image: fill 0 linear-gradient(rgba(0, 0, 0, .4));
}
</style>

View file

@ -0,0 +1,50 @@
<script lang='ts' context='module'>
import { client, currentSeason, currentYear } from '$lib/modules/anilist'
const query = client.search({ sort: ['POPULARITY_DESC'], perPage: 15, 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 { onDestroy } from 'svelte'
import { get } from 'svelte/store'
import FullBanner from './full-banner.svelte'
import SkeletonBanner from './skeleton-banner.svelte'
onDestroy(() => {
query.pause()
})
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-full opacity-0'>.</div>
{#if $query.fetching}
<SkeletonBanner />
{/if}
{#if $query.error}
<div class='p-5 flex items-center justify-center w-full h-72'>
<div>
<div class='mb-1 font-bold text-4xl text-center '>
Ooops!
</div>
<div class='text-lg text-center text-muted-foreground'>
Looks like something went wrong!
</div>
<div class='text-lg text-center text-muted-foreground'>
{$query.error.message}
</div>
</div>
</div>
{/if}
{#if $query.data}
{#if $query.data.Page?.media}
<FullBanner mediaList={$query.data.Page.media} />
{:else}
<SkeletonBanner />
{/if}
{/if}
</div>

View file

@ -0,0 +1,135 @@
<script lang='ts'>
import { onDestroy } from 'svelte'
import { BookmarkButton, FavoriteButton, PlayButton } from '../button/extra'
import { bannerSrc } from './banner-image.svelte'
import { desc, duration, format, season, title, type Media } from '$lib/modules/anilist'
import { of } from '$lib/modules/auth'
import { click } from '$lib/modules/navigate'
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)
// TODO: this assertion is incorrect!
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-clip rounded' class:active style='height: 4px;' style:width={active ? '3rem' : '1.5rem'}>
<div class='progress-content h-full transform-gpu w-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 {
transform: translate3d(-100%, var(--tw-translate-y), 0);
}
to {
transform: translate3d(0%, var(--tw-translate-y), 0);
}
}
.details span + span::before {
content: '•';
padding: 0 .5rem;
font-size: .6rem;
align-self: center;
white-space: normal;
color: #737373 !important;
}
.fade-in {
animation: fade-in ease .8s;
}
</style>

View file

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

View file

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

View file

@ -0,0 +1,30 @@
<script lang='ts'>
import Bookmark from 'lucide-svelte/icons/bookmark'
import type { Media } from '$lib/modules/anilist'
import { Button, iconSizes, type Props } from '$lib/components/ui/button'
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'
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} on:click={clickwrap(toggleBookmark)} on:keydown={keywrap(toggleBookmark)}>
<Bookmark fill={list(media) ? 'currentColor' : 'transparent'} size={iconSizes[size]} />
</Button>

View file

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

View file

@ -0,0 +1,9 @@
import Bookmark from './bookmark.svelte'
import Favorite from './favorite.svelte'
import Play from './play.svelte'
export {
Play as PlayButton,
Favorite as FavoriteButton,
Bookmark as BookmarkButton
}

View file

@ -0,0 +1,21 @@
<script lang='ts'>
import Heart from 'lucide-svelte/icons/heart'
import type { Media } from '$lib/modules/anilist'
import { Button, iconSizes, type Props } from '$lib/components/ui/button'
import { authAggregator, fav } 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'
</script>
<Button {size} {variant} class={className} on:click={clickwrap(() => authAggregator.toggleFav(media.id))} on:keydown={keywrap(() => authAggregator.toggleFav(media.id))}>
<Heart fill={fav(media) ? 'currentColor' : 'transparent'} size={iconSizes[size]} />
</Button>

View file

@ -0,0 +1,61 @@
import { type VariantProps, tv } from 'tailwind-variants'
import Root from './button.svelte'
import type { Button as ButtonPrimitive } from 'bits-ui'
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-secondary-foreground/10 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
}

View file

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

View file

@ -0,0 +1,61 @@
<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
}
if (autoStart) startAnimation()
function handleAnimationEnd () {
animating = false
onclick()
}
</script>
<ButtonPrimitive.Root
class={cn(
buttonVariants({ variant, size, className }),
'relative overflow-hidden'
)}
type='button'
on:click={stopAnimation}
on:click={onclick}>
<slot />
<div
class='absolute inset-0 bg-current opacity-20 pointer-events-none'
class:animate-progress={animating}
style='animation-duration: {duration}ms;'
on:animationend={handleAnimationEnd} />
</ButtonPrimitive.Root>
<style>
@keyframes progressBar {
from { transform: translateX(0%); }
to { transform: translateX(100%); }
}
.animate-progress {
animation: progressBar linear forwards;
}
</style>

View file

@ -0,0 +1,110 @@
<script lang='ts'>
import Volume2 from 'lucide-svelte/icons/volume-2'
import VolumeX from 'lucide-svelte/icons/volume-x'
import { createEventDispatcher, onDestroy } from 'svelte'
import { click } from '$lib/modules/navigate'
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-clip 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-[calc(100%+200px)] top-1/2 transform-gpu -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-clip absolute top-0 rounded-t blur-2xl saturate-200 -z-10 pointer-events-none'>
<iframe
class='w-full border-0 absolute left-0 h-[calc(100%+200px)] top-1/2 transform-gpu -translate-y-1/2'
class:hide
title='trailer'
allow='autoplay'
allowfullscreen
src='https://www.youtube-nocookie.com/embed/{id}?autoplay=1&controls=0&mute=1&disablekb=1&loop=1&playlist={id}&cc_lang_pref=ja'
/>
</div>
<style>
.absolute {
transition: opacity 0.3s;
}
.absolute.hide {
opacity: 0;
}
</style>

View file

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

View file

@ -0,0 +1,79 @@
<script lang='ts'>
import { BookmarkButton, FavoriteButton, PlayButton } from '../button/extra'
import { Banner } from '../img'
import YoutubeIframe from './YoutubeIframe.svelte'
import { desc, duration, format, season, title, type Media } from '$lib/modules/anilist'
import { of } from '$lib/modules/auth'
import { cn } from '$lib/utils'
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 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-[11px]'>
<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>
{#if media.averageScore}
<span class='text-nowrap flex items-center'>
{media.averageScore}%
</span>
{/if}
</div>
<div class='w-full h-full overflow-clip 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(50%, 0, 0) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(1) scaleY(1);
opacity: 1;
}
@keyframes load-in {
from {
opacity: 0;
transform: translate3d(50%, 1.2rem, 0) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(0.95) scaleY(0.95);
}
}
</style>

View file

@ -0,0 +1,71 @@
<script lang='ts'>
import type { client } from '$lib/modules/anilist'
import { SkeletonCard, SmallCard } from '$lib/components/ui/cards'
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) } }
}
</script>
{#if $paused}
<div class='w-0 h-0' use:deferredLoad />
{/if}
{#if $query.fetching || $paused}
{#each Array.from({ length: 50 }) as _, i (i)}
<SkeletonCard />
{/each}
{:else if $query.error}
<div class='p-5 flex items-center justify-center w-full h-80'>
<div>
<div class='mb-1 font-bold text-4xl text-center '>
Ooops!
</div>
<div class='text-lg text-center text-muted-foreground'>
Looks like something went wrong!
</div>
<div class='text-lg text-center text-muted-foreground'>
{$query.error.message}
</div>
</div>
</div>
{:else if $query.data}
{#if $query.data.Page?.media}
{#each $query.data.Page.media as media, i (media?.id ?? '#' + i)}
{#if media}
<SmallCard {media} />
{/if}
{:else}
<div class='p-5 flex items-center justify-center w-full h-80'>
<div>
<div class='mb-1 font-bold text-4xl text-center '>
Ooops!
</div>
<div class='text-lg text-center text-muted-foreground'>
Looks like there's nothing here.
</div>
</div>
</div>
{/each}
{:else}
{#each Array.from({ length: 50 }) as _, i (i)}
<SkeletonCard />
{/each}
{/if}
{:else}
{#each Array.from({ length: 50 }) as _, i (i)}
<SkeletonCard />
{/each}
{/if}

View file

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

View file

@ -0,0 +1,60 @@
<script lang='ts'>
import CalendarDays from 'lucide-svelte/icons/calendar-days'
import Tv from 'lucide-svelte/icons/tv'
import StatusDot from '../../StatusDot.svelte'
import { Load } from '../img'
import PreviewCard from './preview.svelte'
import type { Media } from '$lib/modules/anilist/types'
import { goto } from '$app/navigation'
import { coverMedium, format, title } from '$lib/modules/anilist/util'
import { hover } from '$lib/modules/navigate'
export let media: Media
let hidden = true
function onclick () {
goto(`/app/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={coverMedium(media)} alt='cover' class='object-cover w-full h-full rounded' color={media.coverImage?.color} />
</div>
<div class='pt-3 font-black text-[.8rem] line-clamp-2'>
{#if media.mediaListEntry?.status}
<StatusDot variant={media.mediaListEntry.status} />
{/if}
{title(media)}
</div>
<div class='flex text-neutral-500 mt-auto pt-2 justify-between'>
<div class='flex text-xs font-medium'>
<CalendarDays class='w-[1rem] h-[1rem] mr-1 -ml-0.5' />
{media.seasonYear ?? 'TBA'}
</div>
<div class='flex text-xs font-medium'>
{format(media)}
<Tv class='w-[1rem] h-[1rem] ml-1 -mr-0.5' />
</div>
</div>
</div>
</div>
<style>
.item {
animation: 0.3s ease 0s 1 load-in;
aspect-ratio: 152/290;
}
</style>

View file

@ -0,0 +1,53 @@
<script lang='ts'>
import type { ChatMessage } from '.'
import type { Writable } from 'simple-store-svelte'
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={user.avatar?.medium ?? ''} 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.name}
</div>
<div class='text-muted-foreground pl-2 text-[10px] leading-relaxed'>
{date.toLocaleTimeString()}
</div>
</div>
{#each messages as message, i (i)}
<div class='bg-muted py-2 px-3 rounded-t-xl rounded-r-xl mb-1 select-all text-xs whitespace-pre-wrap max-w-[calc(100%-100px)]'
class:!bg-theme={!incoming}
class:rounded-r-xl={incoming} class:rounded-l-xl={!incoming}>
{message}
</div>
{/each}
</div>
</div>
{/each}
<style>
.message {
--base-border-radius: 1.3rem;
}
.flex-auto {
flex: auto;
}
</style>

View file

@ -0,0 +1,31 @@
<script lang='ts'>
import ExternalLink from 'lucide-svelte/icons/external-link'
import type { ChatUser } from '.'
import native from '$lib/modules/native'
import { click } from '$lib/modules/navigate'
export let users: ChatUser[]
$: processed = Object.entries(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 [key, user] (key)}
<div class='flex items-center pb-2'>
<img src={user.avatar?.medium} alt='ProfilePicture' class='w-10 h-10 rounded-full p-1 mt-auto' loading='lazy' decoding='async' />
<div class='text-md pl-2'>
{user.name}
</div>
<span class='cursor-pointer flex items-center ml-auto text-blue-600' use:click={() => native.openURL('https://anilist.co/user/' + user.id)}>
<ExternalLink size='18' />
</span>
</div>
{/each}
</div>
</div>

View file

@ -0,0 +1,14 @@
import type { Viewer } from '$lib/modules/anilist/queries'
import type { ResultOf } from 'gql.tada'
export type ChatUser = Omit<NonNullable<ResultOf<typeof Viewer>['Viewer']>, 'id'> & { id: string | number }
export interface ChatMessage {
message: string
user: ChatUser
type: 'incoming' | 'outgoing'
date: Date
}
export { default as UserList } from './UserList.svelte'
export { default as Messages } from './Messages.svelte'

View file

@ -0,0 +1,36 @@
<script lang='ts'>
import { Checkbox as CheckboxPrimitive } from 'bits-ui'
import Check from 'svelte-radix/Check.svelte'
import Minus from 'svelte-radix/Minus.svelte'
import { cn } from '$lib/utils.js'
type $$Props = CheckboxPrimitive.Props
type $$Events = CheckboxPrimitive.Events
let className: $$Props['class']
export let checked: $$Props['checked'] = false
export { className as class }
</script>
<CheckboxPrimitive.Root
class={cn(
'border-primary focus-visible:ring-ring data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground peer box-content h-4 w-4 shrink-0 rounded-sm border shadow focus-visible:outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50 data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-50',
className
)}
bind:checked
on:click
{...$$restProps}
>
<CheckboxPrimitive.Indicator
class={cn('flex h-4 w-4 items-center justify-center text-current')}
let:isChecked
let:isIndeterminate
>
{#if isIndeterminate}
<Minus class='h-3.5 w-3.5' />
{:else}
<Check class={cn('h-3.5 w-3.5', !isChecked && 'text-transparent')} />
{/if}
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>

View file

@ -0,0 +1,6 @@
import Root from './checkbox.svelte'
export {
Root,
//
Root as Checkbox
}

View file

@ -0,0 +1,127 @@
<script lang='ts' context='module'>
interface value {
value: string
label: string
}
export function fromobj (object: Record<string, string>, key: string): { items: value[], value: value[] } {
return {
items: Object.entries(object).map(([value, label]) => ({ value, label })),
value: [{ value: '' + key, label: object[key]! }]
}
}
</script>
<script lang='ts'>
import X from 'lucide-svelte/icons/x'
import { tick } from 'svelte'
import CaretSort from 'svelte-radix/CaretSort.svelte'
import Check from 'svelte-radix/Check.svelte'
import { Button } from '$lib/components/ui/button'
import * as Command from '$lib/components/ui/command'
import * as Popover from '$lib/components/ui/popover'
import { intputType } from '$lib/modules/navigate'
import { cn } from '$lib/utils.js'
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 portal='#root'>
<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 z-[1000]')} sameWidth={true}>
<Command.Root>
<Command.Input {placeholder} class='h-9 placeholder:opacity-50' />
<Command.Empty>No results found.</Command.Empty>
{#if $intputType === 'dpad'}
<Command.Group class='shrink-0' alwaysRender={true}>
<Command.Item
alwaysRender={true}
class='cursor-pointer'
onSelect={() => {
closeAndFocusTrigger(ids.trigger)
}}
value='close'>
<X class='mr-2 h-4 w-4' />
Close
</Command.Item>
</Command.Group>
<Command.Separator />
{/if}
<Command.Group class='overflow-y-auto'>
{#each items as item (item.value)}
<Command.Item
class={cn('cursor-pointer', !multiple && 'flex-row-reverse justify-between')}
value={item.value}
onSelect={() => {
handleSelect(item, ids.trigger)
}}>
<div
class={cn(
'flex h-4 w-4 items-center justify-center rounded-sm border-primary',
multiple ? 'border mr-2' : 'ml-2',
value.find(({ value }) => value === item.value)
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
)}>
<Check className={cn('h-4 w-4')} />
</div>
{item.label}
</Command.Item>
{/each}
</Command.Group>
</Command.Root>
</Popover.Content>
</Popover.Root>

View file

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

View file

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

View file

@ -0,0 +1,25 @@
<script lang='ts'>
import Command from './command.svelte'
import type { Dialog as DialogPrimitive } from 'bits-ui'
import type { Command as CommandPrimitive } from 'cmdk-sv'
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 portal='#root' {...$$restProps}>
<Dialog.Content class='overflow-hidden p-0 max-h-[30rem]'>
<Command
class='[&_[data-cmdk-group-heading]]:text-muted-foreground [&_[data-cmdk-group-heading]]:px-2 [&_[data-cmdk-group-heading]]:font-medium [&_[data-cmdk-group]:not([hidden])_~[data-cmdk-group]]:pt-0 [&_[data-cmdk-group]]:px-2 [&_[data-cmdk-input-wrapper]_svg]:h-5 [&_[data-cmdk-input-wrapper]_svg]:w-5 [&_[data-cmdk-input]]:h-12 [&_[data-cmdk-item]]:px-2 [&_[data-cmdk-item]]:py-3 [&_[data-cmdk-item]_svg]:h-5 [&_[data-cmdk-item]_svg]:w-5 max-h-[30%]'
{...$$restProps}
bind:value
>
<slot />
</Command>
</Dialog.Content>
</Dialog.Root>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,36 @@
<script lang='ts'>
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui'
import Check from 'svelte-radix/Check.svelte'
import { cn } from '$lib/utils.js'
type $$Props = ContextMenuPrimitive.CheckboxItemProps
type $$Events = ContextMenuPrimitive.CheckboxItemEvents
let className: $$Props['class']
export { className as class }
export let checked: $$Props['checked']
</script>
<ContextMenuPrimitive.CheckboxItem
bind:checked
class={cn(
'data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...$$restProps}
on:click
on:keydown
on:focusin
on:focusout
on:pointerdown
on:pointerleave
on:pointermove
>
<span class='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
<ContextMenuPrimitive.CheckboxIndicator>
<Check class='h-4 w-4' />
</ContextMenuPrimitive.CheckboxIndicator>
</span>
<slot />
</ContextMenuPrimitive.CheckboxItem>

View file

@ -0,0 +1,25 @@
<script lang='ts'>
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui'
import { cn, flyAndScale } from '$lib/utils.js'
type $$Props = ContextMenuPrimitive.ContentProps
let className: $$Props['class']
export let transition: $$Props['transition'] = flyAndScale
export let transitionConfig: $$Props['transitionConfig']
export { className as class }
</script>
<ContextMenuPrimitive.Content
{transition}
{transitionConfig}
class={cn(
'bg-popover text-popover-foreground z-50 min-w-[8rem] rounded-md border p-1 shadow-md focus:outline-none',
className
)}
{...$$restProps}
on:keydown
>
<slot />
</ContextMenuPrimitive.Content>

View file

@ -0,0 +1,32 @@
<script lang='ts'>
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui'
import { cn } from '$lib/utils.js'
type $$Props = ContextMenuPrimitive.ItemProps & {
inset?: boolean
}
type $$Events = ContextMenuPrimitive.ItemEvents
let className: $$Props['class']
export let inset: $$Props['inset']
export { className as class }
</script>
<ContextMenuPrimitive.Item
class={cn(
'data-[highlighted]:bg-accent data-[highlighted]: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',
inset && 'pl-8',
className
)}
{...$$restProps}
on:click
on:keydown
on:focusin
on:focusout
on:pointerdown
on:pointerleave
on:pointermove
>
<slot />
</ContextMenuPrimitive.Item>

View file

@ -0,0 +1,20 @@
<script lang='ts'>
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui'
import { cn } from '$lib/utils.js'
type $$Props = ContextMenuPrimitive.LabelProps & {
inset?: boolean
}
let className: $$Props['class']
export let inset: $$Props['inset']
export { className as class }
</script>
<ContextMenuPrimitive.Label
class={cn('text-foreground px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
{...$$restProps}
>
<slot />
</ContextMenuPrimitive.Label>

View file

@ -0,0 +1,11 @@
<script lang='ts'>
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui'
type $$Props = ContextMenuPrimitive.RadioGroupProps
export let value: $$Props['value']
</script>
<ContextMenuPrimitive.RadioGroup {...$$restProps} bind:value>
<slot />
</ContextMenuPrimitive.RadioGroup>

View file

@ -0,0 +1,36 @@
<script lang='ts'>
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui'
import DotFilled from 'svelte-radix/DotFilled.svelte'
import { cn } from '$lib/utils.js'
type $$Props = ContextMenuPrimitive.RadioItemProps
type $$Events = ContextMenuPrimitive.RadioItemEvents
let className: $$Props['class']
export let value: $$Props['value']
export { className as class }
</script>
<ContextMenuPrimitive.RadioItem
class={cn(
'data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{value}
{...$$restProps}
on:click
on:keydown
on:focusin
on:focusout
on:pointerdown
on:pointerleave
on:pointermove
>
<span class='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
<ContextMenuPrimitive.RadioIndicator>
<DotFilled class='h-4 w-4 fill-current' />
</ContextMenuPrimitive.RadioIndicator>
</span>
<slot />
</ContextMenuPrimitive.RadioItem>

View file

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

View file

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

View file

@ -0,0 +1,27 @@
<script lang='ts'>
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui'
import { cn, flyAndScale } from '$lib/utils.js'
type $$Props = ContextMenuPrimitive.SubContentProps
let className: $$Props['class']
export let transition: $$Props['transition'] = flyAndScale
export let transitionConfig: $$Props['transitionConfig']
export { className as class }
</script>
<ContextMenuPrimitive.SubContent
{transition}
{transitionConfig}
class={cn(
'bg-popover text-popover-foreground z-50 min-w-[8rem] rounded-md border p-1 shadow-lg focus:outline-none',
className
)}
{...$$restProps}
on:keydown
on:focusout
on:pointermove
>
<slot />
</ContextMenuPrimitive.SubContent>

View file

@ -0,0 +1,33 @@
<script lang='ts'>
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui'
import ChevronRight from 'svelte-radix/ChevronRight.svelte'
import { cn } from '$lib/utils.js'
type $$Props = ContextMenuPrimitive.SubTriggerProps & {
inset?: boolean
}
type $$Events = ContextMenuPrimitive.SubTriggerEvents
let className: $$Props['class']
export let inset: $$Props['inset']
export { className as class }
</script>
<ContextMenuPrimitive.SubTrigger
class={cn(
'data-[highlighted]:bg-accent data-[state=open]:bg-accent data-[highlighted]:text-accent-foreground data-[state=open]:text-accent-foreground flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none',
inset && 'pl-8',
className
)}
{...$$restProps}
on:click
on:keydown
on:focusin
on:focusout
on:pointerleave
on:pointermove
>
<slot />
<ChevronRight class='ml-auto h-4 w-4' />
</ContextMenuPrimitive.SubTrigger>

View file

@ -0,0 +1,49 @@
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui'
import CheckboxItem from './context-menu-checkbox-item.svelte'
import Content from './context-menu-content.svelte'
import Item from './context-menu-item.svelte'
import Label from './context-menu-label.svelte'
import RadioGroup from './context-menu-radio-group.svelte'
import RadioItem from './context-menu-radio-item.svelte'
import Separator from './context-menu-separator.svelte'
import Shortcut from './context-menu-shortcut.svelte'
import SubContent from './context-menu-sub-content.svelte'
import SubTrigger from './context-menu-sub-trigger.svelte'
const Sub = ContextMenuPrimitive.Sub
const Root = ContextMenuPrimitive.Root
const Trigger = ContextMenuPrimitive.Trigger
const Group = ContextMenuPrimitive.Group
export {
Sub,
Root,
Item,
Label,
Group,
Trigger,
Content,
Shortcut,
Separator,
RadioItem,
SubContent,
SubTrigger,
RadioGroup,
CheckboxItem,
//
Root as ContextMenu,
Sub as ContextMenuSub,
Item as ContextMenuItem,
Label as ContextMenuLabel,
Group as ContextMenuGroup,
Content as ContextMenuContent,
Trigger as ContextMenuTrigger,
Shortcut as ContextMenuShortcut,
RadioItem as ContextMenuRadioItem,
Separator as ContextMenuSeparator,
RadioGroup as ContextMenuRadioGroup,
SubContent as ContextMenuSubContent,
SubTrigger as ContextMenuSubTrigger,
CheckboxItem as ContextMenuCheckboxItem
}

View file

@ -0,0 +1,38 @@
<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 absolute top-[50%] left-[50%] z-50 grid w-full translate-y-[-50%] translate-x-[-50%] p-6 shadow-2xl border-neutral-700/60 border-y-4 bg-clip-padding',
className
)}
{...$$restProps}
>
<slot />
<DialogPrimitive.Close
class='ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 transition-opacity select:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none'
>
<Cross2 class='h-4 w-4' />
<span class='sr-only'>Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</Dialog.Portal>

View file

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

View file

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

View file

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

View file

@ -0,0 +1,22 @@
<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 absolute inset-0 z-50 backdrop-blur-sm', className)}
{...$$restProps}
/>

View file

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

View file

@ -0,0 +1,17 @@
<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>

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