Compare commits
148 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a53066b72 | |||
| 9ebc46d7d4 | |||
| 0456eefe3b | |||
| 3b85331f3b | |||
| 56fe1152c5 | |||
| 699f19badb | |||
| e415f77e5a | |||
| 124323cc59 | |||
| ee716a9dc8 | |||
| c4694e577f | |||
| d8f914ddca | |||
| 4d145d593b | |||
| 889aa30150 | |||
| 490192b386 | |||
| 9a9cf1e3fb | |||
| 300b063b9a | |||
| 99be784453 | |||
| 2f6cf1c0db | |||
| 23e686385c | |||
| cbe445b7d9 | |||
| 087071a8af | |||
| a0a74e8cdc | |||
| a16769e0db | |||
| 565d90dbf0 | |||
| b6d6e66f04 | |||
| 0b4f3c8604 | |||
| 0a5a4e39f5 | |||
| 2d8def18ed | |||
| d53f62e96c | |||
| 4dd0b419e0 | |||
| 41948f15ba | |||
| 49f0739503 | |||
| 1c763e9761 | |||
| 18a30639b9 | |||
| a3847ccb3c | |||
| 5f5eb3e9f2 | |||
| 3bf1f64f25 | |||
| 82b5b30e1c | |||
| 303761dc4c | |||
| 16012da267 | |||
| 74bceee57b | |||
| bc0aa56ced | |||
| 84753ce829 | |||
| 780d7c55b1 | |||
| 9318df3e14 | |||
| d0c0ec1f67 | |||
| 32b3b792e5 | |||
| 5018a229c0 | |||
| 6e20ab85c3 | |||
| 9c8a093b2e | |||
| 1870d15c41 | |||
| 06cf9c012f | |||
| fdc415f545 | |||
| e7ff4b7a4e | |||
| 051acf5118 | |||
| a58a3c1bd7 | |||
| 0b04c9b827 | |||
| d05ec6c8fa | |||
| 04594df963 | |||
| dacfd65603 | |||
| 70d37d3555 | |||
| f01f26fd39 | |||
| ea86451686 | |||
| 27699192b4 | |||
| aff86db95e | |||
| bd2e645608 | |||
| 0a22484037 | |||
| aad65a2b4f | |||
| d93fbb0710 | |||
| ea8e435e64 | |||
| 10dcd6d4cc | |||
| b59f8a2850 | |||
| 00f645e001 | |||
| 897bbf54e2 | |||
| e97662314b | |||
| 910695474c | |||
| b91e4f0e28 | |||
| 5ea8f59e6f | |||
| aa71cdd20a | |||
| 64bd3296ab | |||
| c6a66e9a67 | |||
| c862bd6c09 | |||
| 2b5ce07554 | |||
| c0b9074b7e | |||
| 4f0ef3d12b | |||
| 21da15e284 | |||
| ead70c9315 | |||
| 2cfe1d13e7 | |||
| a40f90a6b7 | |||
| 738d48138a | |||
| f858c21c51 | |||
| df8ca2a6e7 | |||
| a029d38b7f | |||
| 9ba1049d7f | |||
| a2ee161cc6 | |||
| 20b170393e | |||
| aa67f7eadb | |||
| 7af39699dd | |||
| 694aefad30 | |||
| 1e908fba38 | |||
| 7acd8681b9 | |||
| 940f719345 | |||
| 76379938eb | |||
| 45ab9b6b1c | |||
| f77893bcc1 | |||
| 646c0586ab | |||
| 64bfbd51a0 | |||
| 31fc6633a2 | |||
| 85c5131dad | |||
| b13da26b8b | |||
| ad3271df7f | |||
| dfb49ee2a7 | |||
| a456701bde | |||
| 2b000c8d2f | |||
| cbd2cfcfbe | |||
| eb2e2e9f93 | |||
| 5dd26a09c0 | |||
| 040297fb2a | |||
| c89e5e5027 | |||
| 4e875b2f8a | |||
| a42b7e8ed3 | |||
| 69bbba9ff4 | |||
| 13f2912009 | |||
| 5d14691b7e | |||
| 7334f9c29e | |||
| 216ebc183c | |||
| 2305a0b7ac | |||
| b18c889777 | |||
| e952011de8 | |||
| cfb6196c2b | |||
| 8eb4f1b297 | |||
| c031b2aaf0 | |||
| 447523edfc | |||
| 1e4d3b4076 | |||
| a8ae54359a | |||
| 52de4d4fe8 | |||
| 6c404877f5 | |||
| bb939ccb36 | |||
| e43e249890 | |||
| 63fee3111a | |||
| af6bead88e | |||
| bc5651905e | |||
| 5b5743210b | |||
| 27218e871b | |||
| 0106f9c763 | |||
| 5dd4c4c6c6 | |||
| 525816a8fc | |||
| acba239b8a |
@@ -1,27 +1,27 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
parser: "@typescript-eslint/parser",
|
||||
extends: ["plugin:svelte/recommended"],
|
||||
plugins: ["@typescript-eslint"],
|
||||
ignorePatterns: ["*.cjs", "*.config.js"],
|
||||
overrides: [
|
||||
{
|
||||
files: ["*.svelte"],
|
||||
parser: "svelte-eslint-parser",
|
||||
parserOptions: {
|
||||
parser: "@typescript-eslint/parser"
|
||||
}
|
||||
}
|
||||
],
|
||||
parserOptions: {
|
||||
project: "./tsconfig.json",
|
||||
extraFileExtensions: [".svelte"],
|
||||
sourceType: "module",
|
||||
ecmaVersion: 2020
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
es2017: true,
|
||||
node: false
|
||||
}
|
||||
root: true,
|
||||
parser: "@typescript-eslint/parser",
|
||||
extends: ["plugin:svelte/recommended"],
|
||||
plugins: ["@typescript-eslint"],
|
||||
ignorePatterns: ["*.cjs", "*.config.js"],
|
||||
overrides: [
|
||||
{
|
||||
files: ["*.svelte"],
|
||||
parser: "svelte-eslint-parser",
|
||||
parserOptions: {
|
||||
parser: "@typescript-eslint/parser",
|
||||
},
|
||||
},
|
||||
],
|
||||
parserOptions: {
|
||||
project: "./tsconfig.json",
|
||||
extraFileExtensions: [".svelte"],
|
||||
sourceType: "module",
|
||||
ecmaVersion: 2020,
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
es2017: true,
|
||||
node: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -8,6 +8,11 @@ on:
|
||||
- v*
|
||||
paths:
|
||||
- ".github/workflows/test-pr-web.yml"
|
||||
- "package*.json"
|
||||
- "svelte.config.json"
|
||||
- "vite.config.json"
|
||||
- "tsconfig.json"
|
||||
- ".prettier*"
|
||||
- "src/**"
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -6,6 +6,10 @@ node_modules
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
/.vscode
|
||||
|
||||
doc/.docusaurus/
|
||||
doc/build/
|
||||
|
||||
# Ignore files for PNPM, NPM and YARN
|
||||
pnpm-lock.yaml
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"printWidth": 100,
|
||||
"useTabs": false,
|
||||
"trailingComma": "none",
|
||||
"arrowParens": "always",
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.svelte",
|
||||
"options": {
|
||||
"parser": "svelte"
|
||||
}
|
||||
}
|
||||
]
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"printWidth": 80,
|
||||
"useTabs": true,
|
||||
"trailingComma": "all",
|
||||
"arrowParens": "always",
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.svelte",
|
||||
"options": {
|
||||
"parser": "svelte"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Launch Package",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
"program": "${fileDirname}"
|
||||
}
|
||||
]
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Launch Package",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
"program": "${fileDirname}"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"editor.insertSpaces": false
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
# Attribution
|
||||
|
||||
We haven't rebuilt the wheel from scratch, here are the tools that have helped this project come to fruition.
|
||||
|
||||
(I am creating this file a bit late, some tools may be missing, hopefully over time I can get everything listed here).
|
||||
|
||||
## Web Client
|
||||
|
||||
Look at our [package.json](./package.json) for the packages we directly use.
|
||||
|
||||
- [Ionicons](https://ionic.io/ionicons) When looking for high quality icons, I go here first. Most of the icons in Watcharr are from here.
|
||||
- [Wikimedia Commons](https://commons.wikimedia.org) For [Streaming provider icons](https://commons.wikimedia.org/wiki/Category:SVG_logos_of_video_streaming_services).
|
||||
- [Penpot](https://github.com/penpot/penpot) For initial UI design and creating the logo.
|
||||
- [Google Fonts](https://fonts.google.com/) For custom fonts and for a few icons too. Don't worry though, fonts/icons are included in the Watcharr download, there are **no** requests made to google.
|
||||
|
||||
## Server
|
||||
|
||||
Look at our [go.mod](./server/go.mod) for the packages we use.
|
||||
|
||||
- [TMDB](https://developer.themoviedb.org) Movie/show data.
|
||||
- [IGDB](https://www.igdb.com/api) Game data.
|
||||
|
||||
## README.md
|
||||
|
||||
The tools I have used to create the projects README.
|
||||
|
||||
- [deviceframes](https://deviceframes.com)
|
||||
Created the devices mockup preview image with this tool (and GIMP).
|
||||
@@ -1,5 +1,7 @@
|
||||
#
|
||||
# Backend
|
||||
FROM golang:1.22-alpine AS server
|
||||
#
|
||||
FROM golang:1.24-alpine AS server
|
||||
|
||||
WORKDIR /server
|
||||
COPY server/*.go server/go.* ./
|
||||
@@ -12,7 +14,9 @@ RUN apk update && apk add --no-cache musl-dev gcc build-base
|
||||
# CGO_CFLAGS: https://github.com/mattn/go-sqlite3/issues/1164#issuecomment-1635253695
|
||||
RUN go mod download && GOOS=linux CGO_ENABLED=1 CGO_CFLAGS="-D_LARGEFILE64_SOURCE" go build -o ./watcharr
|
||||
|
||||
#
|
||||
# Frontend
|
||||
#
|
||||
FROM node:20-alpine AS ui
|
||||
|
||||
WORKDIR /app
|
||||
@@ -22,13 +26,19 @@ COPY ./static ./static
|
||||
|
||||
RUN npm install && npm run build
|
||||
|
||||
#
|
||||
# Production
|
||||
#
|
||||
FROM node:20-alpine AS runner
|
||||
|
||||
COPY --from=server /server/watcharr /
|
||||
COPY --from=ui /app/build /ui
|
||||
COPY --from=ui /app/package.json /app/package-lock.json /ui
|
||||
RUN cd /ui && npm ci --omit=dev
|
||||
|
||||
# Install just the prod dependencies for final step.
|
||||
# We --ignore-scripts, to stop the `prepare` script from auto-
|
||||
# running, which is only meant for development (will error).
|
||||
RUN cd /ui && npm ci --omit=dev --ignore-scripts=true
|
||||
|
||||
EXPOSE 3080
|
||||
|
||||
|
||||
@@ -18,35 +18,37 @@ I am built with Go and Svelte(Kit).
|
||||
|
||||
Feel free to abuse this demo instance (nicely), which runs on the latest `dev` build (there may be bugs, as new features are tested on here too): [https://beta.watcharr.app/](https://beta.watcharr.app/)
|
||||
|
||||
[Track Progress Until Next Version](https://github.com/orgs/sbondCo/projects/9/views/3)
|
||||
[Track progress for the next version](https://github.com/orgs/sbondCo/projects/9/views/3).
|
||||
|
||||
### Contents
|
||||
|
||||
- [Screenshots](#screenshots)
|
||||
- [Setup](#set-up)
|
||||
- [Contributing](CONTRIBUTING.md)
|
||||
- [Set Up](#set-up)
|
||||
- [Community Made Tools](#community-made-tools)
|
||||
- [Getting Help](#getting-help)
|
||||
- [Contributing](#contributing)
|
||||
|
||||
# Screenshots
|
||||
|
||||
<p align="center">
|
||||
|
||||
| Homepage | Watched Show Hover |
|
||||
| ---------------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| <img src="./screenshot/homepage.png" alt="Watched List" /> | <img src="./screenshot/homepage-poster-hover.png" alt="Watched List" /> |
|
||||
<img src="./screenshot/devices-mock.png" alt="Overview" />
|
||||
|
||||
| Watched Show Status Change | Movie Details |
|
||||
| --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
|
||||
| <img src="./screenshot/homepage-poster-change-status.png" alt="Changing Show Status" /> | <img src="./screenshot/content-details-page.png" alt="Content Details Page" /> |
|
||||
| Homepage | Watched Show Hover |
|
||||
| -------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
||||
| <img src="./screenshot/homepage.png?v=2" alt="Watched List" /> | <img src="./screenshot/homepage-poster-hover.png?v=2" alt="Watched List" /> |
|
||||
|
||||
| User Profile | Discover |
|
||||
| ------------------------------------------------------------------- | ---------------------------------------------------------------- |
|
||||
| <img src="./screenshot/user-profile.png" alt="User Profile Page" /> | <img src="./screenshot/discover-page.png" alt="Discover Page" /> |
|
||||
| Watched Show Status Change | Movie Details |
|
||||
| ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
|
||||
| <img src="./screenshot/homepage-poster-change-status.png?v=2" alt="Changing Show Status" /> | <img src="./screenshot/content-details-page.png?v=2" alt="Content Details Page" /> |
|
||||
|
||||
| Dark Homepage | Dark Content Details |
|
||||
| ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
|
||||
| <img src="./screenshot/homepage-dark.png" alt="Dark Theme Homepage" /> | <img src="./screenshot/content-details-page-dark.png" alt="Dark Theme Content Page" /> |
|
||||
| User Profile | Discover |
|
||||
| ----------------------------------------------------------------------- | -------------------------------------------------------------------- |
|
||||
| <img src="./screenshot/user-profile.png?v=2" alt="User Profile Page" /> | <img src="./screenshot/discover-page.png?v=2" alt="Discover Page" /> |
|
||||
|
||||
| Dark Homepage | Dark Content Details |
|
||||
| -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| <img src="./screenshot/homepage-dark.png?v=2" alt="Dark Theme Homepage" /> | <img src="./screenshot/content-details-page-dark.png?v=2" alt="Dark Theme Content Page" /> |
|
||||
|
||||
</p>
|
||||
|
||||
@@ -69,3 +71,7 @@ Thanks to anyone that has made a script or tool for Watcharr. Feel free to add y
|
||||
If something isn't working for you or you are stuck, [creating an issue](https://github.com/sbondCo/Watcharr/issues/new) is the best way to get help! Every type of issue is accepted, so don't be afraid to ask anything!
|
||||
|
||||
You can also [join our space on Matrix](https://matrix.to/#/#watcharr:matrix.org) for support.
|
||||
|
||||
# Contributing
|
||||
|
||||
Please continue to our [contributing guide](CONTRIBUTING.md).
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
module.exports = {
|
||||
presets: [require.resolve("@docusaurus/core/lib/babel/preset")]
|
||||
presets: [require.resolve("@docusaurus/core/lib/babel/preset")],
|
||||
};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"label": "Importing",
|
||||
"position": 3,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "Guides for importing your data from other services."
|
||||
}
|
||||
"label": "Importing",
|
||||
"position": 3,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "Guides for importing your data from other services."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
sidebar_position: 2
|
||||
---
|
||||
|
||||
# MyAnimeList
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
# Text File (.txt list)
|
||||
|
||||
:::info Backup?
|
||||
You may consider a backup of your server before starting any import. They are not easily reversible, though we do our best to ensure they are accurate and bug free!
|
||||
:::
|
||||
|
||||
The text file (.txt) import is of an arbitrary format (the one I used for years before creating Watcharr).
|
||||
|
||||
Hopefully it is useful for others with similar files or in scenarios where its the easiest to generate for an import (though if possible, when generating a backup from another service data manually, matching a Watcharr export would enable keeping more data).
|
||||
|
||||
## Format
|
||||
|
||||
Each line is a new entry. The name of the content (show/movie) must be provided, the year can be optionally provided surrounded by brackets. Doesn't support specifying if name is for a show or movie, the importer will only automatically match on full search matches, if there are multiple results, you will be asked to pick the correct one.
|
||||
|
||||
```
|
||||
<name> [(<year>)]
|
||||
```
|
||||
|
||||
## An example
|
||||
|
||||
```
|
||||
The Terminator (1984)
|
||||
Breaking Bad
|
||||
A Fistful of Dollars
|
||||
Reacher (2022)
|
||||
```
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
sidebar_position: 3
|
||||
---
|
||||
|
||||
# Trakt
|
||||
|
||||
:::info Backup?
|
||||
You may consider a backup of your server before starting any import. They are not easily reversible, though we do our best to ensure they are accurate and bug free!
|
||||
:::
|
||||
|
||||
**Note:** Your Trakt profile must be public _during_ this process. You are free to private it again once it completes.
|
||||
|
||||
1. Provide your Trakt username in the text box.
|
||||
2. Press `Start Import`.
|
||||
|
||||
This will be a long process, possibly a couple hours depending on how large of a Trakt history you have. If you think it has frozen or isn't working, try checking your server logs to see if it is doing anything.
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
sidebar_position: 0
|
||||
---
|
||||
|
||||
# Watcharr
|
||||
|
||||
:::info Backup?
|
||||
You may consider a backup of your server before starting any import. They are not easily reversible, though we do our best to ensure they are accurate and bug free!
|
||||
:::
|
||||
|
||||
:::warning May not be what you want
|
||||
Watcharr import/export is a feature intended for end users. If you are the server admin, it is recommended that you copy your server files directly to any new server you are creating (it'll be much faster and easier). [See backup guide](/docs/server_config/backup).
|
||||
:::
|
||||
|
||||
1. Provide the Watcharr export file (that you generated from your profile page).
|
||||
2. Review the import table, if everything looks good, start the import.
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"label": "Installation",
|
||||
"position": 2,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "Choose how you would like to install Watcharr."
|
||||
}
|
||||
"label": "Installation",
|
||||
"position": 2,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "Choose how you would like to install Watcharr."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"label": "Server Configuration",
|
||||
"position": 2,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "Learn how to configure your server."
|
||||
}
|
||||
"label": "Server Configuration",
|
||||
"position": 2,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "Learn how to configure your server."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"label": "Usage",
|
||||
"position": 3,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "Hopefully Watcharr is easy enough to get started with and use, but incase you are stuck, one of these usage guides may help."
|
||||
}
|
||||
"label": "Usage",
|
||||
"position": 3,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "Hopefully Watcharr is easy enough to get started with and use, but incase you are stuck, one of these usage guides may help."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,104 +7,104 @@ const darkTheme = themes.dracula;
|
||||
|
||||
/** @type {import('@docusaurus/types').Config} */
|
||||
const config = {
|
||||
title: "Watcharr Docs",
|
||||
tagline:
|
||||
"Open source, self-hostable watched list for all your content with user authentication, modern and clean UI and a very simple setup. ",
|
||||
favicon: "img/favicon.png",
|
||||
title: "Watcharr Docs",
|
||||
tagline:
|
||||
"Open source, self-hostable watched list for all your content with user authentication, modern and clean UI and a very simple setup. ",
|
||||
favicon: "img/favicon.png",
|
||||
|
||||
// Set the production url of your site here
|
||||
url: "https://watcharr.app",
|
||||
// Set the /<baseUrl>/ pathname under which your site is served
|
||||
// For GitHub pages deployment, it is often '/<projectName>/'
|
||||
baseUrl: "/",
|
||||
// Set the production url of your site here
|
||||
url: "https://watcharr.app",
|
||||
// Set the /<baseUrl>/ pathname under which your site is served
|
||||
// For GitHub pages deployment, it is often '/<projectName>/'
|
||||
baseUrl: "/",
|
||||
|
||||
// GitHub pages deployment config.
|
||||
// If you aren't using GitHub pages, you don't need these.
|
||||
organizationName: "sbondCo", // Usually your GitHub org/user name.
|
||||
projectName: "Watcharr", // Usually your repo name.
|
||||
// GitHub pages deployment config.
|
||||
// If you aren't using GitHub pages, you don't need these.
|
||||
organizationName: "sbondCo", // Usually your GitHub org/user name.
|
||||
projectName: "Watcharr", // Usually your repo name.
|
||||
|
||||
onBrokenLinks: "throw",
|
||||
onBrokenMarkdownLinks: "warn",
|
||||
onBrokenLinks: "throw",
|
||||
onBrokenMarkdownLinks: "warn",
|
||||
|
||||
trailingSlash: false,
|
||||
trailingSlash: false,
|
||||
|
||||
// Even if you don't use internalization, you can use this field to set useful
|
||||
// metadata like html lang. For example, if your site is Chinese, you may want
|
||||
// to replace "en" with "zh-Hans".
|
||||
i18n: {
|
||||
defaultLocale: "en",
|
||||
locales: ["en"]
|
||||
},
|
||||
// Even if you don't use internalization, you can use this field to set useful
|
||||
// metadata like html lang. For example, if your site is Chinese, you may want
|
||||
// to replace "en" with "zh-Hans".
|
||||
i18n: {
|
||||
defaultLocale: "en",
|
||||
locales: ["en"],
|
||||
},
|
||||
|
||||
presets: [
|
||||
[
|
||||
"classic",
|
||||
/** @type {import('@docusaurus/preset-classic').Options} */
|
||||
({
|
||||
docs: {
|
||||
sidebarPath: require.resolve("./sidebars.js"),
|
||||
// Please change this to your repo.
|
||||
// Remove this to remove the "edit this page" links.
|
||||
editUrl: "https://github.com/sbondCo/Watcharr/tree/dev/doc"
|
||||
},
|
||||
blog: {
|
||||
showReadingTime: true,
|
||||
// Please change this to your repo.
|
||||
// Remove this to remove the "edit this page" links.
|
||||
editUrl: "https://github.com/sbondCo/Watcharr/tree/dev/doc"
|
||||
},
|
||||
theme: {
|
||||
customCss: [
|
||||
"./node_modules/viewerjs/dist/viewer.css",
|
||||
require.resolve("./src/css/custom.css")
|
||||
]
|
||||
}
|
||||
})
|
||||
]
|
||||
],
|
||||
presets: [
|
||||
[
|
||||
"classic",
|
||||
/** @type {import('@docusaurus/preset-classic').Options} */
|
||||
({
|
||||
docs: {
|
||||
sidebarPath: require.resolve("./sidebars.js"),
|
||||
// Please change this to your repo.
|
||||
// Remove this to remove the "edit this page" links.
|
||||
editUrl: "https://github.com/sbondCo/Watcharr/tree/dev/doc",
|
||||
},
|
||||
blog: {
|
||||
showReadingTime: true,
|
||||
// Please change this to your repo.
|
||||
// Remove this to remove the "edit this page" links.
|
||||
editUrl: "https://github.com/sbondCo/Watcharr/tree/dev/doc",
|
||||
},
|
||||
theme: {
|
||||
customCss: [
|
||||
"./node_modules/viewerjs/dist/viewer.css",
|
||||
require.resolve("./src/css/custom.css"),
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
],
|
||||
|
||||
themeConfig:
|
||||
/** @type {import('@docusaurus/preset-classic').ThemeConfig} */
|
||||
({
|
||||
image: "img/social-card.png",
|
||||
navbar: {
|
||||
title: "Watcharr",
|
||||
logo: {
|
||||
alt: "Watcharr Logo",
|
||||
src: "img/favicon.png"
|
||||
},
|
||||
items: [
|
||||
{
|
||||
type: "docSidebar",
|
||||
sidebarId: "tutorialSidebar",
|
||||
position: "left",
|
||||
label: "Docs"
|
||||
},
|
||||
{
|
||||
type: "docsVersionDropdown"
|
||||
},
|
||||
{
|
||||
href: "https://beta.watcharr.app",
|
||||
label: "Demo",
|
||||
position: "right"
|
||||
},
|
||||
{
|
||||
href: "https://github.com/sbondCo/Watcharr",
|
||||
label: "GitHub",
|
||||
position: "right"
|
||||
}
|
||||
]
|
||||
},
|
||||
prism: {
|
||||
theme: lightTheme,
|
||||
darkTheme: darkTheme
|
||||
},
|
||||
colorMode: {
|
||||
defaultMode: "light",
|
||||
// Dark theme currently disabled.. no time to fix the icons etc.
|
||||
disableSwitch: true
|
||||
}
|
||||
})
|
||||
themeConfig:
|
||||
/** @type {import('@docusaurus/preset-classic').ThemeConfig} */
|
||||
({
|
||||
image: "img/social-card.png",
|
||||
navbar: {
|
||||
title: "Watcharr",
|
||||
logo: {
|
||||
alt: "Watcharr Logo",
|
||||
src: "img/favicon.png",
|
||||
},
|
||||
items: [
|
||||
{
|
||||
type: "docSidebar",
|
||||
sidebarId: "tutorialSidebar",
|
||||
position: "left",
|
||||
label: "Docs",
|
||||
},
|
||||
{
|
||||
type: "docsVersionDropdown",
|
||||
},
|
||||
{
|
||||
href: "https://beta.watcharr.app",
|
||||
label: "Demo",
|
||||
position: "right",
|
||||
},
|
||||
{
|
||||
href: "https://github.com/sbondCo/Watcharr",
|
||||
label: "GitHub",
|
||||
position: "right",
|
||||
},
|
||||
],
|
||||
},
|
||||
prism: {
|
||||
theme: lightTheme,
|
||||
darkTheme: darkTheme,
|
||||
},
|
||||
colorMode: {
|
||||
defaultMode: "light",
|
||||
// Dark theme currently disabled.. no time to fix the icons etc.
|
||||
disableSwitch: true,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
|
||||
@@ -1,44 +1,44 @@
|
||||
{
|
||||
"name": "watcharr-doc",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"docusaurus": "docusaurus",
|
||||
"start": "docusaurus start",
|
||||
"build": "docusaurus build",
|
||||
"swizzle": "docusaurus swizzle",
|
||||
"deploy": "docusaurus deploy",
|
||||
"clear": "docusaurus clear",
|
||||
"serve": "docusaurus serve",
|
||||
"write-translations": "docusaurus write-translations",
|
||||
"write-heading-ids": "docusaurus write-heading-ids"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "3.1.1",
|
||||
"@docusaurus/preset-classic": "3.1.1",
|
||||
"@mdx-js/react": "^3.0.0",
|
||||
"clsx": "^2.1.0",
|
||||
"prism-react-renderer": "^2.3.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"viewerjs": "^1.11.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "3.1.1"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.5%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0"
|
||||
}
|
||||
"name": "watcharr-doc",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"docusaurus": "docusaurus",
|
||||
"start": "docusaurus start",
|
||||
"build": "docusaurus build",
|
||||
"swizzle": "docusaurus swizzle",
|
||||
"deploy": "docusaurus deploy",
|
||||
"clear": "docusaurus clear",
|
||||
"serve": "docusaurus serve",
|
||||
"write-translations": "docusaurus write-translations",
|
||||
"write-heading-ids": "docusaurus write-heading-ids"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "3.1.1",
|
||||
"@docusaurus/preset-classic": "3.1.1",
|
||||
"@mdx-js/react": "^3.0.0",
|
||||
"clsx": "^2.1.0",
|
||||
"prism-react-renderer": "^2.3.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"viewerjs": "^1.11.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "3.1.1"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.5%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,11 +13,11 @@
|
||||
|
||||
/** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */
|
||||
const sidebars = {
|
||||
// By default, Docusaurus generates a sidebar from the docs folder structure
|
||||
tutorialSidebar: [{ type: "autogenerated", dirName: "." }]
|
||||
// By default, Docusaurus generates a sidebar from the docs folder structure
|
||||
tutorialSidebar: [{ type: "autogenerated", dirName: "." }],
|
||||
|
||||
// But you can create a sidebar manually
|
||||
/*
|
||||
// But you can create a sidebar manually
|
||||
/*
|
||||
tutorialSidebar: [
|
||||
'intro',
|
||||
'hello',
|
||||
|
||||
@@ -3,59 +3,62 @@ import clsx from "clsx";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
const FeatureList = [
|
||||
{
|
||||
title: "Free and Open",
|
||||
Svg: require("@site/static/img/icon/code-slash.svg").default,
|
||||
description: (
|
||||
<>
|
||||
Watcharr is free and open source software distributed under the MIT license. Feel free to
|
||||
browse, modify or contribute!
|
||||
</>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: "Simple",
|
||||
Svg: require("@site/static/img/icon/happy.svg").default,
|
||||
description: (
|
||||
<>
|
||||
Watcharr is incredibly easy to dive into. Install without hassle and get started with the
|
||||
seamless and intuitive user experience.
|
||||
</>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: "Built With Go and Svelte",
|
||||
Svg: require("@site/static/img/icon/go.svg").default,
|
||||
description: (
|
||||
<>Don't know what else to add here, so now you know Watcharr is built with Go and Svelte.</>
|
||||
)
|
||||
}
|
||||
{
|
||||
title: "Free and Open",
|
||||
Svg: require("@site/static/img/icon/code-slash.svg").default,
|
||||
description: (
|
||||
<>
|
||||
Watcharr is free and open source software distributed under the MIT
|
||||
license. Feel free to browse, modify or contribute!
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Simple",
|
||||
Svg: require("@site/static/img/icon/happy.svg").default,
|
||||
description: (
|
||||
<>
|
||||
Watcharr is incredibly easy to dive into. Install without hassle and get
|
||||
started with the seamless and intuitive user experience.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Built With Go and Svelte",
|
||||
Svg: require("@site/static/img/icon/go.svg").default,
|
||||
description: (
|
||||
<>
|
||||
Don't know what else to add here, so now you know Watcharr is built with
|
||||
Go and Svelte.
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
function Feature({ Svg, title, description }) {
|
||||
return (
|
||||
<div className={clsx("col col--4")}>
|
||||
<div className="text--center">
|
||||
<Svg className={styles.featureSvg} role="img" />
|
||||
</div>
|
||||
<div className="text--center padding-horiz--md">
|
||||
<h3>{title}</h3>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className={clsx("col col--4")}>
|
||||
<div className="text--center">
|
||||
<Svg className={styles.featureSvg} role="img" />
|
||||
</div>
|
||||
<div className="text--center padding-horiz--md">
|
||||
<h3>{title}</h3>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HomepageFeatures() {
|
||||
return (
|
||||
<section className={styles.features}>
|
||||
<div className="container">
|
||||
<div className="row">
|
||||
{FeatureList.map((props, idx) => (
|
||||
<Feature key={idx} {...props} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
return (
|
||||
<section className={styles.features}>
|
||||
<div className="container">
|
||||
<div className="row">
|
||||
{FeatureList.map((props, idx) => (
|
||||
<Feature key={idx} {...props} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
.features {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 2rem 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 2rem 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.featureSvg {
|
||||
height: 200px;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
@@ -4,68 +4,68 @@ import clsx from "clsx";
|
||||
import Viewer from "viewerjs";
|
||||
|
||||
function TwoColFeat({ title, description, imgSrc }) {
|
||||
return (
|
||||
<div className={clsx(styles.twoCol)}>
|
||||
<div>
|
||||
<h2>{title}</h2>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<div>
|
||||
<img src={imgSrc} alt="" loading="lazy" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className={clsx(styles.twoCol)}>
|
||||
<div>
|
||||
<h2>{title}</h2>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<div>
|
||||
<img src={imgSrc} alt="" loading="lazy" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HomepageTwoColFeats() {
|
||||
useEffect(() => {
|
||||
const ctr = document.getElementById("homepage-two-col-feats");
|
||||
console.debug("Starting image viewer", ctr);
|
||||
new Viewer(ctr, {
|
||||
navbar: 0,
|
||||
toolbar: {
|
||||
zoomIn: 2,
|
||||
zoomOut: 2,
|
||||
next: 2,
|
||||
prev: 2
|
||||
},
|
||||
title: 0,
|
||||
className: "twoColViewer"
|
||||
});
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const ctr = document.getElementById("homepage-two-col-feats");
|
||||
console.debug("Starting image viewer", ctr);
|
||||
new Viewer(ctr, {
|
||||
navbar: 0,
|
||||
toolbar: {
|
||||
zoomIn: 2,
|
||||
zoomOut: 2,
|
||||
next: 2,
|
||||
prev: 2,
|
||||
},
|
||||
title: 0,
|
||||
className: "twoColViewer",
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section id="homepage-two-col-feats" className={styles.twoColFeats}>
|
||||
<TwoColFeat
|
||||
title="Neat UI"
|
||||
description="We just look lightweight."
|
||||
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/homepage.png"
|
||||
/>
|
||||
<TwoColFeat
|
||||
title="Easy To Use"
|
||||
description="Not much to it, other than ease of use."
|
||||
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/homepage-poster-change-status.png"
|
||||
/>
|
||||
<TwoColFeat
|
||||
title="In-Depth Details"
|
||||
description="About your favourite content."
|
||||
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/content-details-page.png"
|
||||
/>
|
||||
<TwoColFeat
|
||||
title="Personal Stats and Configuration"
|
||||
description="Make it how you like."
|
||||
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/user-profile.png"
|
||||
/>
|
||||
<TwoColFeat
|
||||
title="Discovery"
|
||||
description="Find the latest trending content."
|
||||
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/discover-page.png"
|
||||
/>
|
||||
<TwoColFeat
|
||||
title="Dark Theme"
|
||||
description="For you who are still unsatisfied. ;("
|
||||
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/content-details-page-dark.png"
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
return (
|
||||
<section id="homepage-two-col-feats" className={styles.twoColFeats}>
|
||||
<TwoColFeat
|
||||
title="Neat UI"
|
||||
description="We just look lightweight."
|
||||
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/homepage.png?v=2"
|
||||
/>
|
||||
<TwoColFeat
|
||||
title="Easy To Use"
|
||||
description="Not much to it, other than ease of use."
|
||||
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/homepage-poster-change-status.png?v=2"
|
||||
/>
|
||||
<TwoColFeat
|
||||
title="In-Depth Details"
|
||||
description="About your favourite content."
|
||||
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/content-details-page.png?v=2"
|
||||
/>
|
||||
<TwoColFeat
|
||||
title="Personal Stats and Configuration"
|
||||
description="Make it how you like."
|
||||
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/user-profile.png?v=2"
|
||||
/>
|
||||
<TwoColFeat
|
||||
title="Discovery"
|
||||
description="Find the latest trending content."
|
||||
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/discover-page.png?v=2"
|
||||
/>
|
||||
<TwoColFeat
|
||||
title="Dark Theme"
|
||||
description="For you who are still unsatisfied. ;("
|
||||
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/content-details-page-dark.png?v=2"
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,53 +1,53 @@
|
||||
.twoColFeats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
flex-flow: column;
|
||||
width: 100%;
|
||||
max-width: 1300px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
flex-flow: column;
|
||||
width: 100%;
|
||||
max-width: 1300px;
|
||||
}
|
||||
|
||||
.twoCol {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
width: 100%;
|
||||
padding: 2.5rem 5rem;
|
||||
gap: 50px;
|
||||
background-color: rgb(236, 236, 236);
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
width: 100%;
|
||||
padding: 2.5rem 5rem;
|
||||
gap: 50px;
|
||||
background-color: rgb(236, 236, 236);
|
||||
}
|
||||
|
||||
.twoCol:nth-child(even) {
|
||||
flex-flow: row-reverse;
|
||||
background-color: transparent;
|
||||
flex-flow: row-reverse;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.twoCol > div {
|
||||
width: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-flow: column;
|
||||
width: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-flow: column;
|
||||
}
|
||||
|
||||
.twoCol > div > img {
|
||||
width: 100%;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0px 0px 5px rgb(121, 121, 121);
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0px 0px 5px rgb(121, 121, 121);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 850px) {
|
||||
.twoCol,
|
||||
.twoCol:nth-child(even) {
|
||||
flex-flow: column;
|
||||
}
|
||||
.twoCol,
|
||||
.twoCol:nth-child(even) {
|
||||
flex-flow: column;
|
||||
}
|
||||
|
||||
.twoCol > div {
|
||||
width: 100%;
|
||||
}
|
||||
.twoCol > div {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.twoCol > div > img {
|
||||
max-width: 500px;
|
||||
}
|
||||
.twoCol > div > img {
|
||||
max-width: 500px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,39 +6,39 @@
|
||||
|
||||
/* You can override the default Infima variables here. */
|
||||
:root {
|
||||
--ifm-color-primary: #2e8555;
|
||||
--ifm-color-primary-dark: #29784c;
|
||||
--ifm-color-primary-darker: #277148;
|
||||
--ifm-color-primary-darkest: #205d3b;
|
||||
--ifm-color-primary-light: #33925d;
|
||||
--ifm-color-primary-lighter: #359962;
|
||||
--ifm-color-primary-lightest: #3cad6e;
|
||||
--ifm-code-font-size: 95%;
|
||||
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1);
|
||||
--ifm-color-primary: #2e8555;
|
||||
--ifm-color-primary-dark: #29784c;
|
||||
--ifm-color-primary-darker: #277148;
|
||||
--ifm-color-primary-darkest: #205d3b;
|
||||
--ifm-color-primary-light: #33925d;
|
||||
--ifm-color-primary-lighter: #359962;
|
||||
--ifm-color-primary-lightest: #3cad6e;
|
||||
--ifm-code-font-size: 95%;
|
||||
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* For readability concerns, you should choose a lighter palette in dark mode. */
|
||||
[data-theme="dark"] {
|
||||
--ifm-color-primary: #25c2a0;
|
||||
--ifm-color-primary-dark: #21af90;
|
||||
--ifm-color-primary-darker: #1fa588;
|
||||
--ifm-color-primary-darkest: #1a8870;
|
||||
--ifm-color-primary-light: #29d5b0;
|
||||
--ifm-color-primary-lighter: #32d8b4;
|
||||
--ifm-color-primary-lightest: #4fddbf;
|
||||
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3);
|
||||
--ifm-color-primary: #25c2a0;
|
||||
--ifm-color-primary-dark: #21af90;
|
||||
--ifm-color-primary-darker: #1fa588;
|
||||
--ifm-color-primary-darkest: #1a8870;
|
||||
--ifm-color-primary-light: #29d5b0;
|
||||
--ifm-color-primary-lighter: #32d8b4;
|
||||
--ifm-color-primary-lightest: #4fddbf;
|
||||
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.twoColViewer .viewer-close {
|
||||
right: 10px;
|
||||
top: 10px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
right: 10px;
|
||||
top: 10px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
/* Stupid poopin fix for viewerjs because whatever is bundling the css
|
||||
isn't keeping the order of this correctly. All this time wasted and it
|
||||
was just opaque ;( BUT ONLY IN THE BUILD AAAAAAAAAAAAAAAAAAAAAA */
|
||||
.viewer-in {
|
||||
opacity: 1 !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
@@ -10,44 +10,47 @@ import clsx from "clsx";
|
||||
import { HomepageTwoColFeats } from "../components/TwoColFeat";
|
||||
|
||||
function HomepageHeader() {
|
||||
const { siteConfig } = useDocusaurusContext();
|
||||
return (
|
||||
<header className="hero shadow--lw">
|
||||
<div className={styles.heroContainer}>
|
||||
<div className={styles.heroWrapper}>
|
||||
<img src={LogoCol} />
|
||||
<h1 className="hero__title">{siteConfig.title}</h1>
|
||||
<p className="hero__subtitle">{siteConfig.tagline}</p>
|
||||
<div className={styles.buttons}>
|
||||
<Link className="button button--secondary button--lg" to="/docs/introduction">
|
||||
Get Started
|
||||
</Link>
|
||||
<Link
|
||||
className="button button--secondary button--outline button--lg"
|
||||
to="https://beta.watcharr.app"
|
||||
target="_blank"
|
||||
>
|
||||
Demo
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
const { siteConfig } = useDocusaurusContext();
|
||||
return (
|
||||
<header className="hero shadow--lw">
|
||||
<div className={styles.heroContainer}>
|
||||
<div className={styles.heroWrapper}>
|
||||
<img src={LogoCol} />
|
||||
<h1 className="hero__title">{siteConfig.title}</h1>
|
||||
<p className="hero__subtitle">{siteConfig.tagline}</p>
|
||||
<div className={styles.buttons}>
|
||||
<Link
|
||||
className="button button--secondary button--lg"
|
||||
to="/docs/introduction"
|
||||
>
|
||||
Get Started
|
||||
</Link>
|
||||
<Link
|
||||
className="button button--secondary button--outline button--lg"
|
||||
to="https://beta.watcharr.app"
|
||||
target="_blank"
|
||||
>
|
||||
Demo
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const { siteConfig } = useDocusaurusContext();
|
||||
return (
|
||||
<Layout
|
||||
title={`${siteConfig.title}`}
|
||||
description="Description will go into a meta tag in <head />"
|
||||
>
|
||||
<HomepageHeader />
|
||||
<main>
|
||||
<HomepageFeatures />
|
||||
<HomepageTwoColFeats />
|
||||
</main>
|
||||
</Layout>
|
||||
);
|
||||
const { siteConfig } = useDocusaurusContext();
|
||||
return (
|
||||
<Layout
|
||||
title={`${siteConfig.title}`}
|
||||
description="Description will go into a meta tag in <head />"
|
||||
>
|
||||
<HomepageHeader />
|
||||
<main>
|
||||
<HomepageFeatures />
|
||||
<HomepageTwoColFeats />
|
||||
</main>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,31 +4,31 @@
|
||||
*/
|
||||
|
||||
.heroContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.heroWrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-flow: column;
|
||||
max-width: 600px;
|
||||
padding-left: 2rem;
|
||||
padding-right: 2rem;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-flow: column;
|
||||
max-width: 600px;
|
||||
padding-left: 2rem;
|
||||
padding-right: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.heroWrapper img {
|
||||
width: 30vh;
|
||||
max-width: 250px;
|
||||
width: 30vh;
|
||||
max-width: 250px;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@@ -1,41 +1,42 @@
|
||||
{
|
||||
"name": "watcharr",
|
||||
"version": "1.44.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"format": "prettier --write .",
|
||||
"server": "cd ./server && MODE=DEV go run ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^5.2.3",
|
||||
"@sveltejs/kit": "^2.5.27",
|
||||
"@types/papaparse": "^5.3.14",
|
||||
"@typescript-eslint/eslint-plugin": "^7.14.1",
|
||||
"@typescript-eslint/parser": "^7.8.0",
|
||||
"@vite-pwa/sveltekit": "^0.5.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-svelte": "^2.43.0",
|
||||
"prettier": "^3.3.2",
|
||||
"prettier-plugin-svelte": "^3.2.6",
|
||||
"svelte": "^4.2.19",
|
||||
"svelte-check": "^4.0.2",
|
||||
"svelte-eslint-parser": "^0.41.0",
|
||||
"svelte-preprocess": "^6.0.2",
|
||||
"tslib": "^2.7.0",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.6"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"axios": "^1.7.4",
|
||||
"blurhash": "^2.0.5",
|
||||
"papaparse": "^5.4.1"
|
||||
}
|
||||
"name": "watcharr",
|
||||
"version": "2.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"prepare": "svelte-kit sync",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"format": "prettier --write .",
|
||||
"server": "cd ./server && MODE=DEV go run ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^5.2.12",
|
||||
"@sveltejs/kit": "^2.21.0",
|
||||
"@types/papaparse": "^5.3.15",
|
||||
"@typescript-eslint/eslint-plugin": "^8.32.1",
|
||||
"@typescript-eslint/parser": "^8.32.1",
|
||||
"@vite-pwa/sveltekit": "^0.6.6",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^10.1.2",
|
||||
"eslint-plugin-svelte": "^2.45.1",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-svelte": "^3.4.0",
|
||||
"sass": "^1.89.0",
|
||||
"svelte": "^5.17.3",
|
||||
"svelte-check": "^4.1.3",
|
||||
"svelte-eslint-parser": "^0.42.0",
|
||||
"svelte-preprocess": "^6.0.3",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.3.5"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"axios": "^1.9.0",
|
||||
"blurhash": "^2.0.5",
|
||||
"papaparse": "^5.4.1"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 430 KiB After Width: | Height: | Size: 488 KiB |
|
Before Width: | Height: | Size: 305 KiB After Width: | Height: | Size: 342 KiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
Before Width: | Height: | Size: 661 KiB After Width: | Height: | Size: 900 KiB |
|
Before Width: | Height: | Size: 820 KiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 743 KiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 752 KiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 816 KiB After Width: | Height: | Size: 1.1 MiB |
@@ -0,0 +1 @@
|
||||
These screenshots were first uploaded Dec 10, 2023. They were retired on Jan 19, 2025.
|
||||
|
After Width: | Height: | Size: 430 KiB |
|
After Width: | Height: | Size: 305 KiB |
|
After Width: | Height: | Size: 661 KiB |
|
After Width: | Height: | Size: 820 KiB |
|
After Width: | Height: | Size: 743 KiB |
|
After Width: | Height: | Size: 752 KiB |
|
After Width: | Height: | Size: 816 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 75 KiB |
@@ -26,9 +26,11 @@ import (
|
||||
type UserType uint8
|
||||
|
||||
var (
|
||||
// Assume watcharr user if none of these...
|
||||
WATCHARR_USER UserType = 0
|
||||
JELLYFIN_USER UserType = 1
|
||||
PLEX_USER UserType = 2
|
||||
// Registered via trusted header auth
|
||||
PROXY_USER UserType = 3
|
||||
)
|
||||
|
||||
// User Perms
|
||||
@@ -160,6 +162,7 @@ type AvailableAuthProvidersResponse struct {
|
||||
SignupEnabled bool `json:"signupEnabled"`
|
||||
IsInSetup bool `json:"isInSetup"`
|
||||
UseEmby bool `json:"useEmby"`
|
||||
HeaderAuthAutoLogin bool `json:"headerAuthAutoLogin"`
|
||||
}
|
||||
|
||||
type ArgonParams struct {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// auth_proxy contains the logic for Trusted Header Authentication/SSO.
|
||||
//
|
||||
// This code is inherently dangerous since we are implicitly trusting
|
||||
// a header for auth, so this should only be configured if you are
|
||||
// certain your watcharr instance is only available behind your proxy.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type TrustedHeaderAuthSetting struct {
|
||||
// Required: Should header auth be enabled?
|
||||
// This bool exists so header auth can be toggled
|
||||
// easily without having to remove configuration.
|
||||
// To be actually enabled, HEADER_NAME must also
|
||||
// be set.
|
||||
Enabled bool `json:"enabled"`
|
||||
// Required: What is the name of the trusted header
|
||||
// that will contain the logged in users username?
|
||||
HeaderName string `json:"headerName"`
|
||||
// Should the frontend attempt auto login if
|
||||
// trusted header auth is enabled.
|
||||
AutoLogin bool `json:"autoLogin"`
|
||||
// Where can we redirect the user to logout
|
||||
// of the auth service?
|
||||
LogoutUrl string `json:"logoutUrl"`
|
||||
}
|
||||
|
||||
type TrustedHeaderAuthLogoutDetailsResponse struct {
|
||||
LogoutUrl string `json:"logoutUrl,omitempty"`
|
||||
}
|
||||
|
||||
// Is trusted header auth configured on this server?
|
||||
func trustedHeaderAuthIsEnabled() bool {
|
||||
return Config.HEADER_AUTH.Enabled && Config.HEADER_AUTH.HeaderName != ""
|
||||
}
|
||||
|
||||
func setTrustedHeaderAuthSetting(has TrustedHeaderAuthSetting) error {
|
||||
slog.Debug("setTrustedHeaderAuthSetting: Attempting to update to new provided value", "new_value", has)
|
||||
Config.HEADER_AUTH = has
|
||||
err := writeConfig()
|
||||
if err != nil {
|
||||
slog.Error("setTrustedHeaderAuthSetting: Failed to write updated config!", "error", err)
|
||||
return errors.New("failed to write config")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Gets proxy logout details.
|
||||
// Details are accessible to any user for the logout flow.
|
||||
// If proxy configured should be checked before using this.
|
||||
func getTrustedHeaderAuthLogoutDetails() *TrustedHeaderAuthLogoutDetailsResponse {
|
||||
return &TrustedHeaderAuthLogoutDetailsResponse{
|
||||
LogoutUrl: Config.HEADER_AUTH.LogoutUrl,
|
||||
}
|
||||
}
|
||||
|
||||
// Login via header sso
|
||||
func loginTrustedHeaderAuth(user *User, db *gorm.DB) (AuthResponse, error) {
|
||||
slog.Debug("loginTrustedHeaderAuth: A user is logging in", "username_from_header", user.Username)
|
||||
dbUser := new(User)
|
||||
res := db.Where("username = ? AND type = ?", user.Username, PROXY_USER).Take(&dbUser)
|
||||
if res.Error != nil {
|
||||
if errors.Is(res.Error, gorm.ErrRecordNotFound) {
|
||||
slog.Info("loginTrustedHeaderAuth: Creating new User from authentication header", "username_from_header", user.Username)
|
||||
// Record not found, so we should create the user (if configured to do so)
|
||||
// dbUser will be empty, so we can just reuse it for this purpose.
|
||||
dbUser.Username = user.Username
|
||||
dbUser.Type = PROXY_USER
|
||||
dbUser.Country = &Config.DEFAULT_COUNTRY
|
||||
|
||||
res = db.Create(&dbUser)
|
||||
if res.Error != nil {
|
||||
slog.Error("loginTrustedHeaderAuth: Failed to create new user in db", "error", res.Error)
|
||||
return AuthResponse{}, errors.New("failed to create new user")
|
||||
}
|
||||
} else {
|
||||
slog.Error("loginTrustedHeaderAuth: An error occurred when looking up user in db", "error", res.Error)
|
||||
return AuthResponse{}, errors.New("error locating user in db")
|
||||
}
|
||||
}
|
||||
token, err := signJWT(dbUser)
|
||||
if err != nil {
|
||||
slog.Error("loginTrustedHeaderAuth: Failed to sign new jwt", "error", err)
|
||||
return AuthResponse{}, errors.New("failed to get auth token")
|
||||
}
|
||||
return AuthResponse{Token: token}, nil
|
||||
}
|
||||
@@ -54,6 +54,10 @@ type ServerConfig struct {
|
||||
// Will be fetched automatically when PLEX_HOST is provided via web ui.
|
||||
PLEX_MACHINE_ID string `json:",omitempty"`
|
||||
|
||||
// Optional: Trusted header authentication configuration.
|
||||
// VERY DANGEROUS if access is not controlled correctly!
|
||||
HEADER_AUTH TrustedHeaderAuthSetting `json:",omitempty"`
|
||||
|
||||
SONARR []SonarrSettings `json:",omitempty"`
|
||||
RADARR []RadarrSettings `json:",omitempty"`
|
||||
TWITCH game.IGDB `json:",omitempty"`
|
||||
@@ -93,6 +97,35 @@ func (c *ServerConfig) GetSafe() ServerConfig {
|
||||
}
|
||||
}
|
||||
|
||||
type ServerConfigGetByName struct {
|
||||
Value any `json:"value"`
|
||||
}
|
||||
|
||||
// Get config item by name.
|
||||
func (c *ServerConfig) Get(s string) (ServerConfigGetByName, error) {
|
||||
switch s {
|
||||
case "DEFAULT_COUNTRY":
|
||||
return ServerConfigGetByName{Value: c.DEFAULT_COUNTRY}, nil
|
||||
case "JELLYFIN_HOST":
|
||||
return ServerConfigGetByName{Value: c.JELLYFIN_HOST}, nil
|
||||
case "USE_EMBY":
|
||||
return ServerConfigGetByName{Value: c.USE_EMBY}, nil
|
||||
case "SIGNUP_ENABLED":
|
||||
return ServerConfigGetByName{Value: c.SIGNUP_ENABLED}, nil
|
||||
case "TMDB_KEY":
|
||||
return ServerConfigGetByName{Value: c.TMDB_KEY}, nil
|
||||
case "PLEX_HOST":
|
||||
return ServerConfigGetByName{Value: c.PLEX_HOST}, nil
|
||||
case "PLEX_MACHINE_ID":
|
||||
return ServerConfigGetByName{Value: c.PLEX_MACHINE_ID}, nil
|
||||
case "HEADER_AUTH":
|
||||
return ServerConfigGetByName{Value: c.HEADER_AUTH}, nil
|
||||
case "DEBUG":
|
||||
return ServerConfigGetByName{Value: c.DEBUG}, nil
|
||||
}
|
||||
return ServerConfigGetByName{}, errors.New("invalid setting")
|
||||
}
|
||||
|
||||
var (
|
||||
// Our server config.. `readConfig` will overwrite from watcharr.json cfg file.
|
||||
Config = ServerConfig{}
|
||||
@@ -171,7 +204,12 @@ func updateConfig(k string, v any) error {
|
||||
} else {
|
||||
return errors.New("invalid setting")
|
||||
}
|
||||
return writeConfig()
|
||||
err := writeConfig()
|
||||
if err != nil {
|
||||
slog.Error("updateConfig: Failed to write updated config!", "error", err)
|
||||
return errors.New("failed to write config")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Write current Config to file
|
||||
|
||||
@@ -18,6 +18,8 @@ type ContentType string
|
||||
const (
|
||||
MOVIE ContentType = "movie"
|
||||
SHOW ContentType = "tv"
|
||||
// Show episode
|
||||
SHOW_EPISODE ContentType = "tv_episode"
|
||||
)
|
||||
|
||||
var ContentStore = persistence.NewInMemoryStore(time.Hour * 24)
|
||||
@@ -304,6 +306,33 @@ func searchPeople(query string, pageNum int) (TMDBSearchPeopleResponse, error) {
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
// Search for content by an external id (imdb, etc).
|
||||
// Defaults to imdb if no source if provided (probably most common).
|
||||
func searchByExternalId(id string, source string) (TMDBSearchMultiResponse, error) {
|
||||
resp := new(TMDBFindByExternalIdResponse)
|
||||
if source == "" {
|
||||
source = "imdb"
|
||||
}
|
||||
err := tmdbRequest("/find/"+id, map[string]string{"external_source": source + "_id"}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete find/external_id request!", "error", err.Error())
|
||||
return TMDBSearchMultiResponse{}, errors.New("failed to complete find/external_id request")
|
||||
}
|
||||
comb := []TMDBSearchMultiResults{}
|
||||
comb = append(comb, resp.MovieResults...)
|
||||
comb = append(comb, resp.TvResults...)
|
||||
comb = append(comb, resp.PersonResults...)
|
||||
comb = append(comb, resp.TvSeasonResults...)
|
||||
comb = append(comb, resp.TvEpisodeResults...)
|
||||
return TMDBSearchMultiResponse{TMDBSearchResponse: TMDBSearchResponse[TMDBSearchMultiResults]{
|
||||
Results: comb,
|
||||
TotalResults: len(comb),
|
||||
// Just providing these so we don't break frontend pagination logic.
|
||||
TotalPages: 1,
|
||||
Page: 1,
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func movieDetails(db *gorm.DB, id string, country string, rParams map[string]string) (TMDBMovieDetails, error) {
|
||||
resp := new(TMDBMovieDetails)
|
||||
err := tmdbRequest("/movie/"+id, rParams, &resp)
|
||||
|
||||
@@ -33,6 +33,8 @@ func (i *IGDB) req(host string, ep string, p map[string]string, b string, resp i
|
||||
return errors.New("using igdbHost without a clientID or accessToken")
|
||||
}
|
||||
|
||||
slog.Debug("IGDB->req: Creating a request.", "ep", ep, "body", b)
|
||||
|
||||
base, err := url.Parse(host)
|
||||
if err != nil {
|
||||
return errors.New("failed to parse api uri")
|
||||
@@ -181,6 +183,24 @@ func (i *IGDB) Search(q string) (GameSearchResponse, error) {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Should return same details as `Search`, we both are for search page only minimal details required.
|
||||
func (i *IGDB) SearchById(id string) (GameSearchResponse, error) {
|
||||
slog.Debug("IGDB Search called", "id", id)
|
||||
var resp GameSearchResponse
|
||||
err := i.req(
|
||||
igdbHost,
|
||||
"/games",
|
||||
map[string]string{},
|
||||
"fields name, cover.image_id, version_title, summary, first_release_date; where id = "+id+";",
|
||||
&resp,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("IGDB Search request failed!", "error", err)
|
||||
return GameSearchResponse{}, errors.New("request failed")
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (i *IGDB) GameDetails(id string) (GameDetailsResponse, error) {
|
||||
slog.Debug("IGDB GameDetails called", "id", id)
|
||||
var resp []GameDetailsResponse
|
||||
|
||||
@@ -1,56 +1,55 @@
|
||||
module github.com/sbondCo/Watcharr
|
||||
|
||||
go 1.22
|
||||
go 1.24
|
||||
|
||||
require (
|
||||
github.com/buckket/go-blurhash v1.1.0
|
||||
github.com/gin-contrib/cache v1.3.0
|
||||
github.com/gin-contrib/cors v1.7.2
|
||||
github.com/gin-contrib/cache v1.3.1
|
||||
github.com/gin-contrib/cors v1.7.5
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/go-co-op/gocron/v2 v2.11.0
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/go-co-op/gocron/v2 v2.16.2
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||
github.com/joho/godotenv v1.5.1
|
||||
golang.org/x/crypto v0.27.0
|
||||
golang.org/x/crypto v0.38.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
gorm.io/driver/sqlite v1.5.6
|
||||
gorm.io/driver/sqlite v1.5.7
|
||||
gorm.io/gorm v1.25.12
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 // indirect
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/bytedance/sonic v1.13.2 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/gin-contrib/sse v1.0.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/gomodule/redigo v1.8.9 // indirect
|
||||
github.com/go-playground/validator/v10 v10.26.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/gomodule/redigo v1.9.2 // indirect
|
||||
github.com/google/go-cmp v0.6.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/jonboulle/clockwork v0.4.0 // indirect
|
||||
github.com/jonboulle/clockwork v0.5.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||
github.com/memcachier/mc/v3 v3.0.3 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect
|
||||
golang.org/x/net v0.25.0 // indirect
|
||||
golang.org/x/sys v0.25.0 // indirect
|
||||
golang.org/x/text v0.18.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
golang.org/x/arch v0.15.0 // indirect
|
||||
golang.org/x/net v0.38.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/text v0.25.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -2,43 +2,43 @@ github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 h1:N7oVaKyGp8b
|
||||
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c=
|
||||
github.com/buckket/go-blurhash v1.1.0 h1:X5M6r0LIvwdvKiUtiNcRL2YlmOfMzYobI3VCKCZc9Do=
|
||||
github.com/buckket/go-blurhash v1.1.0/go.mod h1:aT2iqo5W9vu9GpyoLErKfTHwgODsZp3bQfXjXJUxNb8=
|
||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
|
||||
github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
|
||||
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
|
||||
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gin-contrib/cache v1.3.0 h1:wEEw38uvb4rTraQJVpd9ex4ZotXNlc0fSaSUsuPXS/w=
|
||||
github.com/gin-contrib/cache v1.3.0/go.mod h1:EA63LrWGI5vwSI95TS5fgBrtxZ1tM2NKx+NrEeyEDcU=
|
||||
github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=
|
||||
github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/gin-contrib/cache v1.3.1 h1:EWjkOaLocs5fGt9feQaI7rt1GZbDyatFXEUh2/s3ZI8=
|
||||
github.com/gin-contrib/cache v1.3.1/go.mod h1:6Tme0p3QEF/Ck/KUcq7h/OAqZvUDjHRH1DtQbNgfIX0=
|
||||
github.com/gin-contrib/cors v1.7.5 h1:cXC9SmofOrRg0w9PigwGlHG3ztswH6bqq4vJVXnvYMk=
|
||||
github.com/gin-contrib/cors v1.7.5/go.mod h1:4q3yi7xBEDDWKapjT2o1V7mScKDDr8k+jZ0fSquGoy0=
|
||||
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
|
||||
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-co-op/gocron/v2 v2.11.0 h1:IOowNA6SzwdRFnD4/Ol3Kj6G2xKfsoiiGq2Jhhm9bvE=
|
||||
github.com/go-co-op/gocron/v2 v2.11.0/go.mod h1:xY7bJxGazKam1cz04EebrlP4S9q4iWdiAylMGP3jY9w=
|
||||
github.com/go-co-op/gocron/v2 v2.16.2 h1:r08P663ikXiulLT9XaabkLypL/W9MoCIbqgQoAutyX4=
|
||||
github.com/go-co-op/gocron/v2 v2.16.2/go.mod h1:4YTLGCCAH75A5RlQ6q+h+VacO7CgjkgP0EJ+BEOXRSI=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/gomodule/redigo v1.8.9 h1:Sl3u+2BI/kk+VEatbj0scLdrFhjPmbxOc1myhDP41ws=
|
||||
github.com/gomodule/redigo v1.8.9/go.mod h1:7ArFNvsTjH8GMMzB4uy1snslv2BwmginuMs06a1uzZE=
|
||||
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
|
||||
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/gomodule/redigo v1.9.2 h1:HrutZBLhSIU8abiSfW8pj8mPhOyMYjZT/wcA4/L9L9s=
|
||||
github.com/gomodule/redigo v1.9.2/go.mod h1:KsU3hiK/Ay8U42qpaJk+kuNa3C+spxapWpM+ywhcgtw=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
@@ -50,13 +50,13 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4=
|
||||
github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc=
|
||||
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
|
||||
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
@@ -75,8 +75,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
@@ -95,31 +95,27 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
|
||||
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
|
||||
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY=
|
||||
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
|
||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/arch v0.15.0 h1:QtOrQd0bTUnhNVNndMpLHNWrDmYzZ2KDqSrEymqInZw=
|
||||
golang.org/x/arch v0.15.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
|
||||
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
||||
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
|
||||
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
|
||||
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
@@ -128,9 +124,8 @@ gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYs
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/sqlite v1.5.6 h1:fO/X46qn5NUEEOZtnjJRWRzZMe8nqJiQ9E+0hi+hKQE=
|
||||
gorm.io/driver/sqlite v1.5.6/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
|
||||
gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
|
||||
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
|
||||
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
||||
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
|
||||
@@ -39,6 +39,7 @@ type ImportRequest struct {
|
||||
WatchedEpisodes []WatchedEpisode `json:"watchedEpisodes"`
|
||||
WatchedSeason []WatchedSeason `json:"watchedSeasons"`
|
||||
Tags []TagAddRequest `json:"tags"`
|
||||
ImdbID string `json:"imdbId"`
|
||||
}
|
||||
|
||||
type ImportResponse struct {
|
||||
@@ -71,6 +72,51 @@ func importContent(db *gorm.DB, userId uint, ar ImportRequest) (ImportResponse,
|
||||
return successfulImport(db, userId, cr.ID, SHOW, ar)
|
||||
}
|
||||
}
|
||||
// If imdb id passed, attempt to get content with it
|
||||
if ar.ImdbID != "" && (ar.Type == MOVIE || ar.Type == SHOW || ar.Type == SHOW_EPISODE) {
|
||||
if imdbResp, err := searchByExternalId(ar.ImdbID, "imdb"); err == nil {
|
||||
if len(imdbResp.Results) == 1 {
|
||||
onlyResult := imdbResp.Results[0]
|
||||
if onlyResult.MediaType == string(MOVIE) || onlyResult.MediaType == string(SHOW) {
|
||||
// Will only be one result
|
||||
slog.Debug("import: importing imdb match", "imdb_id", ar.ImdbID, "tmdb_id_thatwasfound", onlyResult.ID)
|
||||
return successfulImport(db, userId, onlyResult.ID, ContentType(onlyResult.MediaType), ar)
|
||||
} else if onlyResult.MediaType == string(SHOW_EPISODE) {
|
||||
// Handle episodes differently.
|
||||
// Clients must import tv episodes last so that the actual show can be imported first
|
||||
// will fail if watched entry isn't imported first or already exists (we won't make it here).
|
||||
w, e := getWatchedItemByTmdbId(db, userId, uint(onlyResult.ShowId), "tv")
|
||||
if e != nil {
|
||||
slog.Error("import: imdb match: Failed to add watched episode (failed to find watched item, it must exist!).", "rq", ar, "error", err)
|
||||
return ImportResponse{Type: IMPORT_FAILED}, nil
|
||||
}
|
||||
ws, err := addWatchedEpisodes(db, userId, WatchedEpisodeAddRequest{
|
||||
WatchedID: w.ID,
|
||||
SeasonNumber: onlyResult.SeasonNumber,
|
||||
EpisodeNumber: onlyResult.EpisodeNumber,
|
||||
Status: ar.Status,
|
||||
Rating: int8(ar.Rating),
|
||||
addActivityDate: *ar.RatingCustomDate,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("import: imdb match: Failed to add watched episode.", "rq", ar, "error", err)
|
||||
return ImportResponse{Type: IMPORT_FAILED}, nil
|
||||
} else {
|
||||
w.WatchedEpisodes = ws.WatchedEpisodes
|
||||
return ImportResponse{Type: IMPORT_SUCCESS, WatchedEntry: w}, nil
|
||||
}
|
||||
} else {
|
||||
slog.Error("import: imdb match has unsupported media type.", "media_type", imdbResp.Results[0].MediaType, "rq", ar)
|
||||
return ImportResponse{Type: IMPORT_FAILED}, nil
|
||||
}
|
||||
} else {
|
||||
// Content in tmdb may just be missing a related imdb id, so allow search to continue by name below.
|
||||
slog.Warn("import: No results for search by imdb id.. search will contiue by content name.", "rq", ar)
|
||||
}
|
||||
} else {
|
||||
slog.Warn("import: Failed to get content by imdb id.. search will contiue by content name.", "rq", ar)
|
||||
}
|
||||
}
|
||||
// tmdbId not passed.. search for the content by name.
|
||||
sr, err := searchContent(ar.Name, 1)
|
||||
if err != nil {
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -79,8 +80,9 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
exp := time.Hour * 24
|
||||
|
||||
// Search for content
|
||||
content.GET("/search/multi/:query", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
if c.Param("query") == "" {
|
||||
content.GET("/search/multi", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "a query was not provided"})
|
||||
return
|
||||
}
|
||||
@@ -94,7 +96,7 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
}
|
||||
pageNum = num
|
||||
}
|
||||
content, err := searchContent(c.Param("query"), pageNum)
|
||||
content, err := searchContent(query, pageNum)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
@@ -103,8 +105,9 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
}))
|
||||
|
||||
// Search for movies
|
||||
content.GET("/search/movie/:query", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
if c.Param("query") == "" {
|
||||
content.GET("/search/movie", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "a query was not provided"})
|
||||
return
|
||||
}
|
||||
@@ -118,7 +121,7 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
}
|
||||
pageNum = num
|
||||
}
|
||||
content, err := searchMovies(c.Param("query"), pageNum)
|
||||
content, err := searchMovies(query, pageNum)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
@@ -127,8 +130,9 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
}))
|
||||
|
||||
// Search for shows
|
||||
content.GET("/search/tv/:query", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
if c.Param("query") == "" {
|
||||
content.GET("/search/tv", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "a query was not provided"})
|
||||
return
|
||||
}
|
||||
@@ -142,7 +146,7 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
}
|
||||
pageNum = num
|
||||
}
|
||||
content, err := searchTv(c.Param("query"), pageNum)
|
||||
content, err := searchTv(query, pageNum)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
@@ -151,8 +155,9 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
}))
|
||||
|
||||
// Search for people
|
||||
content.GET("/search/person/:query", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
if c.Param("query") == "" {
|
||||
content.GET("/search/person", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "a query was not provided"})
|
||||
return
|
||||
}
|
||||
@@ -166,7 +171,21 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
}
|
||||
pageNum = num
|
||||
}
|
||||
content, err := searchPeople(c.Param("query"), pageNum)
|
||||
content, err := searchPeople(query, pageNum)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}))
|
||||
|
||||
// Search for content with external id
|
||||
content.GET("/search/ext/:id/:source", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
if c.Param("id") == "" {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "an id was not provided"})
|
||||
return
|
||||
}
|
||||
content, err := searchByExternalId(c.Param("id"), c.Param("source"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
@@ -375,12 +394,32 @@ func (b *BaseRouter) addGameRoutes() {
|
||||
}
|
||||
|
||||
// Search for games
|
||||
gamer.GET("/search/:query", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
if c.Param("query") == "" {
|
||||
gamer.GET("/search", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "a query was not provided"})
|
||||
return
|
||||
}
|
||||
decodedQuery, err := url.QueryUnescape(query)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "query parameter invalid"})
|
||||
return
|
||||
}
|
||||
games, err := igdb.Search(decodedQuery)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, games)
|
||||
}))
|
||||
|
||||
// Search for game by id (for search page, same minimal details as /search returned)
|
||||
gamer.GET("/search/:id", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
if c.Param("id") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
games, err := igdb.Search(c.Param("query"))
|
||||
games, err := igdb.SearchById(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
@@ -742,6 +781,28 @@ func (b *BaseRouter) addAuthRoutes() {
|
||||
c.Status(400)
|
||||
})
|
||||
|
||||
// Proxy Login
|
||||
auth.POST("/proxy", func(c *gin.Context) {
|
||||
var user User
|
||||
if !trustedHeaderAuthIsEnabled() {
|
||||
slog.Error("ProxyLogin: SSO has not been configured.")
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: "proxy authentication is disabled"})
|
||||
return
|
||||
}
|
||||
user.Username = c.GetHeader(Config.HEADER_AUTH.HeaderName)
|
||||
if user.Username == "" {
|
||||
slog.Error("ProxyLogin: Authentication header is missing.")
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: "authentication header missing"})
|
||||
return
|
||||
}
|
||||
response, err := loginTrustedHeaderAuth(&user, b.db)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
// Register
|
||||
auth.POST("/register", func(c *gin.Context) {
|
||||
var user UserRegisterRequest
|
||||
@@ -759,24 +820,45 @@ func (b *BaseRouter) addAuthRoutes() {
|
||||
|
||||
// Get available auth providers
|
||||
auth.GET("/available", func(c *gin.Context) {
|
||||
availableAuthProviders := []string{}
|
||||
if Config.JELLYFIN_HOST != "" {
|
||||
availableAuthProviders = append(availableAuthProviders, "jellyfin")
|
||||
}
|
||||
if Config.PLEX_HOST != "" && Config.PLEX_MACHINE_ID != "" {
|
||||
availableAuthProviders = append(availableAuthProviders, "plex")
|
||||
}
|
||||
c.JSON(http.StatusOK, &AvailableAuthProvidersResponse{
|
||||
AvailableAuthProviders: availableAuthProviders,
|
||||
resp := &AvailableAuthProvidersResponse{
|
||||
AvailableAuthProviders: []string{},
|
||||
SignupEnabled: Config.SIGNUP_ENABLED,
|
||||
IsInSetup: ServerInSetup,
|
||||
UseEmby: Config.USE_EMBY,
|
||||
})
|
||||
}
|
||||
if Config.JELLYFIN_HOST != "" {
|
||||
resp.AvailableAuthProviders = append(resp.AvailableAuthProviders, "jellyfin")
|
||||
}
|
||||
if Config.PLEX_HOST != "" && Config.PLEX_MACHINE_ID != "" {
|
||||
resp.AvailableAuthProviders = append(resp.AvailableAuthProviders, "plex")
|
||||
}
|
||||
if trustedHeaderAuthIsEnabled() {
|
||||
resp.AvailableAuthProviders = append(resp.AvailableAuthProviders, "header")
|
||||
resp.HeaderAuthAutoLogin = Config.HEADER_AUTH.AutoLogin
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
})
|
||||
|
||||
// IMPORTANT: Routes below here must be authenticated.
|
||||
auth.Use(AuthRequired(nil))
|
||||
{
|
||||
// Request details for logout process for proxy users.
|
||||
// Any proxy user can request this for logout.
|
||||
auth.GET("/proxy_logout_details", func(c *gin.Context) {
|
||||
if !trustedHeaderAuthIsEnabled() {
|
||||
slog.Error("GetProxy: SSO has not been configured.")
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: "proxy authentication is disabled"})
|
||||
return
|
||||
}
|
||||
userType := c.MustGet("userType").(UserType)
|
||||
if userType != PROXY_USER {
|
||||
slog.Error("GetProxy: Non proxy user attempted to fetch proxy logout details.")
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: "you are not a proxy user"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, getTrustedHeaderAuthLogoutDetails())
|
||||
})
|
||||
|
||||
// Request admin token
|
||||
auth.GET("/admin_token", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
@@ -932,9 +1014,14 @@ func (b *BaseRouter) addUserRoutes() {
|
||||
})
|
||||
|
||||
// Search users
|
||||
u.GET("/search/:query", func(c *gin.Context) {
|
||||
u.GET("/search", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
response, err := userSearch(b.db, userId, c.Param("query"))
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "a query was not provided"})
|
||||
return
|
||||
}
|
||||
response, err := userSearch(b.db, userId, query)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
@@ -1092,18 +1179,53 @@ func (b *BaseRouter) addServerRoutes() {
|
||||
|
||||
// Get server config (minus very sensitive fields, like JWT_SECRET)
|
||||
server.GET("/config", func(c *gin.Context) {
|
||||
// s should be provided when asking for the value of just one setting.
|
||||
s := c.Query("s")
|
||||
if s != "" {
|
||||
val, err := Config.Get(s)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, val)
|
||||
return
|
||||
}
|
||||
// Return new ServerConfig with only the fields we want to show in settings ui
|
||||
c.JSON(http.StatusOK, Config.GetSafe())
|
||||
})
|
||||
|
||||
// Update config
|
||||
server.POST("/config", func(c *gin.Context) {
|
||||
// If query param `s` provided, handle specific setting.
|
||||
// In this case, request body should be new setting value.
|
||||
s := c.Query("s")
|
||||
if s != "" {
|
||||
switch s {
|
||||
case "HEADER_AUTH":
|
||||
var ur TrustedHeaderAuthSetting
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
err = setTrustedHeaderAuthSetting(ur)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: "unsupported setting"})
|
||||
return
|
||||
}
|
||||
// No `s` param.. handle normally with `updateConfig` func.
|
||||
var ur KeyValueRequest
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
err := updateConfig(ur.Key, ur.Value)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
|
||||
@@ -10,11 +10,11 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type TMDBSearchMultiResponse struct {
|
||||
Page int `json:"page"`
|
||||
Results []TMDBSearchMultiResults `json:"results"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TotalResults int `json:"total_results"`
|
||||
type TMDBSearchResponse[R any] struct {
|
||||
Page int `json:"page"`
|
||||
Results []R `json:"results"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TotalResults int `json:"total_results"`
|
||||
}
|
||||
|
||||
type TMDBSearchMultiResults struct {
|
||||
@@ -38,86 +38,108 @@ type TMDBSearchMultiResults struct {
|
||||
OriginalName string `json:"original_name,omitempty"`
|
||||
FirstAirDate string `json:"first_air_date,omitempty"`
|
||||
OriginCountry []string `json:"origin_country,omitempty"`
|
||||
// Below are for tv episode results
|
||||
AirDate string `json:"air_date,omitempty"`
|
||||
EpisodeNumber int `json:"episode_number,omitempty"`
|
||||
EpisodeType string `json:"episode_type,omitempty"`
|
||||
ProductionCode string `json:"production_code,omitempty"`
|
||||
Runtime int `json:"runtime,omitempty"`
|
||||
SeasonNumber int `json:"season_number,omitempty"`
|
||||
ShowId int `json:"show_id,omitempty"`
|
||||
StillPath string `json:"still_path,omitempty"`
|
||||
}
|
||||
|
||||
type TMDBSearchMultiResponse struct {
|
||||
TMDBSearchResponse[TMDBSearchMultiResults]
|
||||
}
|
||||
|
||||
type TMDBSearchMovieResult struct {
|
||||
Adult bool `json:"adult"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
ID int `json:"id"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalTitle string `json:"original_title"`
|
||||
Overview string `json:"overview"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Title string `json:"title"`
|
||||
Video bool `json:"video"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
MediaType string `json:"media_type"` // API req doesn't include this, we will add it in our service.
|
||||
}
|
||||
|
||||
type TMDBSearchMoviesResponse struct {
|
||||
Page int `json:"page"`
|
||||
Results []struct {
|
||||
Adult bool `json:"adult"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
ID int `json:"id"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalTitle string `json:"original_title"`
|
||||
Overview string `json:"overview"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Title string `json:"title"`
|
||||
Video bool `json:"video"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
MediaType string `json:"media_type"` // API req doesn't include this, we will add it in our service.
|
||||
} `json:"results"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TotalResults int `json:"total_results"`
|
||||
TMDBSearchResponse[TMDBSearchMovieResult]
|
||||
}
|
||||
|
||||
type TMDBSearchShowsResult struct {
|
||||
Adult bool `json:"adult"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
ID int `json:"id"`
|
||||
OriginCountry []string `json:"origin_country"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalName string `json:"original_name"`
|
||||
Overview string `json:"overview"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
FirstAirDate string `json:"first_air_date"`
|
||||
Name string `json:"name"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
MediaType string `json:"media_type"` // API req doesn't include this, we will add it in our service.
|
||||
}
|
||||
|
||||
type TMDBSearchShowsResponse struct {
|
||||
Page int `json:"page"`
|
||||
Results []struct {
|
||||
Adult bool `json:"adult"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
ID int `json:"id"`
|
||||
OriginCountry []string `json:"origin_country"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalName string `json:"original_name"`
|
||||
Overview string `json:"overview"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
FirstAirDate string `json:"first_air_date"`
|
||||
Name string `json:"name"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
MediaType string `json:"media_type"` // API req doesn't include this, we will add it in our service.
|
||||
} `json:"results"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TotalResults int `json:"total_results"`
|
||||
TMDBSearchResponse[TMDBSearchShowsResult]
|
||||
}
|
||||
|
||||
type TMDBSearchPeopleResult struct {
|
||||
Adult bool `json:"adult"`
|
||||
Gender int `json:"gender"`
|
||||
ID int `json:"id"`
|
||||
KnownForDepartment string `json:"known_for_department"`
|
||||
Name string `json:"name"`
|
||||
OriginalName string `json:"original_name"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
ProfilePath string `json:"profile_path"`
|
||||
MediaType string `json:"media_type"` // API req doesn't include this, we will add it in our service.
|
||||
KnownFor []struct {
|
||||
Adult bool `json:"adult"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalTitle string `json:"original_title"`
|
||||
Overview string `json:"overview"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
MediaType string `json:"media_type"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Video bool `json:"video"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
} `json:"known_for"`
|
||||
}
|
||||
|
||||
type TMDBSearchPeopleResponse struct {
|
||||
Page int `json:"page"`
|
||||
Results []struct {
|
||||
Adult bool `json:"adult"`
|
||||
Gender int `json:"gender"`
|
||||
ID int `json:"id"`
|
||||
KnownForDepartment string `json:"known_for_department"`
|
||||
Name string `json:"name"`
|
||||
OriginalName string `json:"original_name"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
ProfilePath string `json:"profile_path"`
|
||||
MediaType string `json:"media_type"` // API req doesn't include this, we will add it in our service.
|
||||
KnownFor []struct {
|
||||
Adult bool `json:"adult"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalTitle string `json:"original_title"`
|
||||
Overview string `json:"overview"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
MediaType string `json:"media_type"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Video bool `json:"video"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
} `json:"known_for"`
|
||||
} `json:"results"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TotalResults int `json:"total_results"`
|
||||
TMDBSearchResponse[TMDBSearchPeopleResult]
|
||||
}
|
||||
|
||||
type TMDBFindByExternalIdResponse struct {
|
||||
// These are all a TMDBSearchMultiResults so our search func can easily
|
||||
// combine all of them into one []TMDBSearchMultiResults for response
|
||||
// to client (seems not easy to convert to TMDBSearchMultiResults for
|
||||
// concatenation after unmarshalling to correct type).
|
||||
MovieResults []TMDBSearchMultiResults `json:"movie_results"`
|
||||
PersonResults []TMDBSearchMultiResults `json:"person_results"`
|
||||
TvResults []TMDBSearchMultiResults `json:"tv_results"`
|
||||
TvSeasonResults []TMDBSearchMultiResults `json:"tv_season_results"`
|
||||
TvEpisodeResults []TMDBSearchMultiResults `json:"tv_episode_results"`
|
||||
}
|
||||
|
||||
type TMDBContentDetails struct {
|
||||
|
||||
@@ -19,7 +19,8 @@ type ManagedUser struct {
|
||||
}
|
||||
|
||||
type UpdateUserRequest struct {
|
||||
Permissions *int `json:"permissions"`
|
||||
Permissions *int `json:"permissions"`
|
||||
Type *UserType `json:"type"`
|
||||
}
|
||||
|
||||
func getAllUsers(db *gorm.DB) ([]ManagedUser, error) {
|
||||
@@ -34,7 +35,7 @@ func getAllUsers(db *gorm.DB) ([]ManagedUser, error) {
|
||||
// Update a user. For management views, for admin to update another user.
|
||||
func manageUser(db *gorm.DB, userId uint, ur UpdateUserRequest) error {
|
||||
// Error now if no userId or any UpdateUserRequest property was provided.
|
||||
if userId == 0 || (ur.Permissions == nil) {
|
||||
if userId == 0 || (ur.Permissions == nil && ur.Type == nil) {
|
||||
slog.Error("manageUser: invalid arguments", "user_id", userId)
|
||||
return errors.New("invalid arguments, ensure a valid userId and at least one property has been provided for updating")
|
||||
}
|
||||
@@ -49,6 +50,16 @@ func manageUser(db *gorm.DB, userId uint, ur UpdateUserRequest) error {
|
||||
toUpdate["permissions"] = *ur.Permissions
|
||||
}
|
||||
}
|
||||
if ur.Type != nil {
|
||||
t := *ur.Type
|
||||
if t == WATCHARR_USER || t == PROXY_USER {
|
||||
// Currently only swapping between watcharr/proxy user is supported.
|
||||
slog.Debug("manageUser: User type is being updated.", "new_type", t)
|
||||
toUpdate["type"] = t
|
||||
} else {
|
||||
slog.Warn("manageUser: User type will not be updated. Only watcharr/proxy types are supported for swapping.", "tried_type", t)
|
||||
}
|
||||
}
|
||||
if res := db.Model(&User{}).Where("id = ?", userId).Updates(toUpdate); res.Error != nil {
|
||||
slog.Error("manageUser: failed to update user in database", "user_id", userId, "error", res.Error)
|
||||
return errors.New("failed to update user in database")
|
||||
|
||||
@@ -104,6 +104,22 @@ func getWatchedItemById(db *gorm.DB, userId uint, id uint) (Watched, error) {
|
||||
return *watched, nil
|
||||
}
|
||||
|
||||
// Get a watched list item by content (tmdb) id (must be for `userId`).
|
||||
func getWatchedItemByTmdbId(db *gorm.DB, userId uint, tmdbId uint, contentType ContentType) (Watched, error) {
|
||||
slog.Debug("getWatchedItemByTmdbId: Running.", "userId", userId, "tmdbId", tmdbId)
|
||||
watched := new(Watched)
|
||||
res := db.Model(&Watched{}).
|
||||
Joins("Content").
|
||||
Where("user_id = ? AND Content.tmdb_id = ? AND Content.type = ?", userId, tmdbId, contentType).
|
||||
Take(&watched)
|
||||
if res.Error != nil {
|
||||
slog.Error("getWatchedItemByTmdbId: Failed!", "error", res.Error)
|
||||
return Watched{}, res.Error
|
||||
}
|
||||
slog.Debug("getWatchedItemByTmdbId: Done.", "userId", userId, "tmdbId", tmdbId, "watched_item", watched)
|
||||
return *watched, nil
|
||||
}
|
||||
|
||||
// Get another users **public** watchlist.
|
||||
func getPublicWatched(db *gorm.DB, userId uint, username string) ([]Watched, error) {
|
||||
slog.Debug("getPublicWatched running", "user_id", userId, "username", username)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
// See https://kit.svelte.dev/docs/types#app
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface Platform {}
|
||||
}
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<link rel="apple-touch-icon" href="%sveltekit.assets%/logo-col.png" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<title>Watcharr</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<link rel="apple-touch-icon" href="%sveltekit.assets%/logo-col.png" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<title>Watcharr</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { ClientInit } from "@sveltejs/kit";
|
||||
|
||||
export const init: ClientInit = async () => {
|
||||
console.info(
|
||||
`%cWATCHARR v${__WATCHARR_VERSION__}`,
|
||||
"background: white;color: black;font-size: large;padding: 3px 5px;",
|
||||
);
|
||||
};
|
||||
@@ -1,320 +1,326 @@
|
||||
<script lang="ts">
|
||||
import type { Activity } from "@/types";
|
||||
import { getOrdinalSuffix, months, seasonAndEpToReadable } from "./util/helpers";
|
||||
import ActivityEditor from "./ActivityEditor.svelte";
|
||||
import Icon from "./Icon.svelte";
|
||||
import tooltip from "./actions/tooltip";
|
||||
import type { Activity } from "@/types";
|
||||
import {
|
||||
getOrdinalSuffix,
|
||||
months,
|
||||
seasonAndEpToReadable,
|
||||
} from "./util/helpers";
|
||||
import ActivityEditor from "./ActivityEditor.svelte";
|
||||
import Icon from "./Icon.svelte";
|
||||
import tooltip from "./actions/tooltip";
|
||||
|
||||
export let activity: Activity[] | undefined;
|
||||
export let wListId: number;
|
||||
interface Props {
|
||||
activity: Activity[] | undefined;
|
||||
wListId: number;
|
||||
}
|
||||
|
||||
let clickedActivity: Activity;
|
||||
let groupedActivities: { [index: string]: any };
|
||||
let isActivityEditorVisible: boolean;
|
||||
let { activity, wListId }: Props = $props();
|
||||
|
||||
$: {
|
||||
groupedActivities = getGroupedActivity(activity);
|
||||
}
|
||||
let clickedActivity: Activity | undefined = $state();
|
||||
let groupedActivities: { [index: string]: any } = $derived(
|
||||
getGroupedActivity(activity),
|
||||
);
|
||||
|
||||
function getMsg(a: Activity) {
|
||||
switch (a?.type) {
|
||||
case "ADDED_WATCHED":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `Added to Watched List${data?.status ? ` as ${toFullTitleCase(data.status)}` : ""}${
|
||||
data?.rating ? ` with ${data.rating} Stars` : ""
|
||||
}`;
|
||||
}
|
||||
return "Added to Watched List";
|
||||
case "REMOVED_WATCHED":
|
||||
return "Removed from Watched List";
|
||||
case "RATING_CHANGED":
|
||||
if (a.data) {
|
||||
return `Rating Changed to ${a.data}`;
|
||||
}
|
||||
return "Rating Changed";
|
||||
case "STATUS_CHANGED":
|
||||
if (a.data) {
|
||||
return `Status Changed to ${toFullTitleCase(a.data)}`;
|
||||
}
|
||||
return "Status Changed";
|
||||
case "STATUS_CHANGED_AUTO":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `Status Changed to ${toFullTitleCase(data.status)}`;
|
||||
}
|
||||
return "Status Changed";
|
||||
case "THOUGHTS_CHANGED":
|
||||
return "Thoughts Changed";
|
||||
case "THOUGHTS_REMOVED":
|
||||
return "Thoughts Removed";
|
||||
case "IMPORTED_WATCHED":
|
||||
return "Imported";
|
||||
case "IMPORTED_WATCHED_JF":
|
||||
case "IMPORTED_WATCHED_PLEX":
|
||||
return "Synced";
|
||||
case "IMPORTED_RATING":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
if (data.rating) {
|
||||
return `Rating Changed to ${data.rating}`;
|
||||
} else {
|
||||
return "Added to Watchlist with No Rating";
|
||||
}
|
||||
}
|
||||
return "Imported Rating";
|
||||
case "IMPORTED_ADDED_WATCHED":
|
||||
case "IMPORTED_ADDED_WATCHED_JF":
|
||||
case "IMPORTED_ADDED_WATCHED_PLEX":
|
||||
return "Imported Watch Date";
|
||||
case "SEASON_ADDED":
|
||||
case "SEASON_ADDED_AUTO":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `Season ${data.season} Added as ${toFullTitleCase(data.status)}`;
|
||||
}
|
||||
return "Season Added";
|
||||
case "SEASON_ADDED_JF":
|
||||
case "SEASON_ADDED_PLEX":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `Season ${data.season} Synced as ${toFullTitleCase(data.status)}`;
|
||||
}
|
||||
return "Season Synced";
|
||||
case "SEASON_RATING_CHANGED":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `Changed Season ${data.season} Rating to ${data.rating}`;
|
||||
}
|
||||
return "Season Rating Changed";
|
||||
case "SEASON_STATUS_CHANGED":
|
||||
case "SEASON_STATUS_CHANGED_AUTO":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `Changed Season ${data.season} Status to ${toFullTitleCase(data.status)}`;
|
||||
}
|
||||
return "Season Status Changed";
|
||||
case "SEASON_REMOVED":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `Season ${data.season} Status Removed`;
|
||||
}
|
||||
return "Season Removed";
|
||||
case "EPISODE_ADDED":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `${seasonAndEpToReadable(data.season, data.episode)} Added ${data.status ? `as ${toFullTitleCase(data.status)}` : data.rating ? `with Rating ${data.rating}` : ""}`;
|
||||
}
|
||||
return "Episode Added";
|
||||
case "EPISODE_ADDED_JF":
|
||||
case "EPISODE_ADDED_PLEX":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `${seasonAndEpToReadable(data.season, data.episode)} Synced ${data.status ? `as ${toFullTitleCase(data.status)}` : data.rating ? `with Rating ${data.rating}` : ""}`;
|
||||
}
|
||||
return "Episode Synced";
|
||||
case "EPISODE_RATING_CHANGED":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `${seasonAndEpToReadable(data.season, data.episode)} Rating Changed to ${data.rating}`;
|
||||
}
|
||||
return "Episode Rating Changed";
|
||||
case "EPISODE_STATUS_CHANGED":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `${seasonAndEpToReadable(data.season, data.episode)} Status Changed to ${toFullTitleCase(data.status)}`;
|
||||
}
|
||||
return "Episode Status Changed";
|
||||
case "EPISODE_REMOVED":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `${seasonAndEpToReadable(data.season, data.episode)} Removed`;
|
||||
}
|
||||
return "Episode Removed";
|
||||
default:
|
||||
return a.type;
|
||||
}
|
||||
}
|
||||
function getMsg(a: Activity) {
|
||||
switch (a?.type) {
|
||||
case "ADDED_WATCHED":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `Added to Watched List${data?.status ? ` as ${toFullTitleCase(data.status)}` : ""}${
|
||||
data?.rating ? ` with ${data.rating} Stars` : ""
|
||||
}`;
|
||||
}
|
||||
return "Added to Watched List";
|
||||
case "REMOVED_WATCHED":
|
||||
return "Removed from Watched List";
|
||||
case "RATING_CHANGED":
|
||||
if (a.data) {
|
||||
return `Rating Changed to ${a.data}`;
|
||||
}
|
||||
return "Rating Changed";
|
||||
case "STATUS_CHANGED":
|
||||
if (a.data) {
|
||||
return `Status Changed to ${toFullTitleCase(a.data)}`;
|
||||
}
|
||||
return "Status Changed";
|
||||
case "STATUS_CHANGED_AUTO":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `Status Changed to ${toFullTitleCase(data.status)}`;
|
||||
}
|
||||
return "Status Changed";
|
||||
case "THOUGHTS_CHANGED":
|
||||
return "Thoughts Changed";
|
||||
case "THOUGHTS_REMOVED":
|
||||
return "Thoughts Removed";
|
||||
case "IMPORTED_WATCHED":
|
||||
return "Imported";
|
||||
case "IMPORTED_WATCHED_JF":
|
||||
case "IMPORTED_WATCHED_PLEX":
|
||||
return "Synced";
|
||||
case "IMPORTED_RATING":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
if (data.rating) {
|
||||
return `Rating Changed to ${data.rating}`;
|
||||
} else {
|
||||
return "Added to Watchlist with No Rating";
|
||||
}
|
||||
}
|
||||
return "Imported Rating";
|
||||
case "IMPORTED_ADDED_WATCHED":
|
||||
case "IMPORTED_ADDED_WATCHED_JF":
|
||||
case "IMPORTED_ADDED_WATCHED_PLEX":
|
||||
return "Imported Watch Date";
|
||||
case "SEASON_ADDED":
|
||||
case "SEASON_ADDED_AUTO":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `Season ${data.season} Added as ${toFullTitleCase(data.status)}`;
|
||||
}
|
||||
return "Season Added";
|
||||
case "SEASON_ADDED_JF":
|
||||
case "SEASON_ADDED_PLEX":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `Season ${data.season} Synced as ${toFullTitleCase(data.status)}`;
|
||||
}
|
||||
return "Season Synced";
|
||||
case "SEASON_RATING_CHANGED":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `Changed Season ${data.season} Rating to ${data.rating}`;
|
||||
}
|
||||
return "Season Rating Changed";
|
||||
case "SEASON_STATUS_CHANGED":
|
||||
case "SEASON_STATUS_CHANGED_AUTO":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `Changed Season ${data.season} Status to ${toFullTitleCase(data.status)}`;
|
||||
}
|
||||
return "Season Status Changed";
|
||||
case "SEASON_REMOVED":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `Season ${data.season} Status Removed`;
|
||||
}
|
||||
return "Season Removed";
|
||||
case "EPISODE_ADDED":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `${seasonAndEpToReadable(data.season, data.episode)} Added ${data.status ? `as ${toFullTitleCase(data.status)}` : data.rating ? `with Rating ${data.rating}` : ""}`;
|
||||
}
|
||||
return "Episode Added";
|
||||
case "EPISODE_ADDED_JF":
|
||||
case "EPISODE_ADDED_PLEX":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `${seasonAndEpToReadable(data.season, data.episode)} Synced ${data.status ? `as ${toFullTitleCase(data.status)}` : data.rating ? `with Rating ${data.rating}` : ""}`;
|
||||
}
|
||||
return "Episode Synced";
|
||||
case "EPISODE_RATING_CHANGED":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `${seasonAndEpToReadable(data.season, data.episode)} Rating Changed to ${data.rating}`;
|
||||
}
|
||||
return "Episode Rating Changed";
|
||||
case "EPISODE_STATUS_CHANGED":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `${seasonAndEpToReadable(data.season, data.episode)} Status Changed to ${toFullTitleCase(data.status)}`;
|
||||
}
|
||||
return "Episode Status Changed";
|
||||
case "EPISODE_REMOVED":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `${seasonAndEpToReadable(data.season, data.episode)} Removed`;
|
||||
}
|
||||
return "Episode Removed";
|
||||
default:
|
||||
return a.type;
|
||||
}
|
||||
}
|
||||
|
||||
function toFullTitleCase(text: string | undefined) {
|
||||
if (text) {
|
||||
return text
|
||||
.split(" ")
|
||||
.map((l) => l[0].toUpperCase() + l.substring(1).toLowerCase())
|
||||
.join(" ");
|
||||
}
|
||||
return "Unknown";
|
||||
}
|
||||
function toFullTitleCase(text: string | undefined) {
|
||||
if (text) {
|
||||
return text
|
||||
.split(" ")
|
||||
.map((l) => l[0].toUpperCase() + l.substring(1).toLowerCase())
|
||||
.join(" ");
|
||||
}
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
function toDayTime(d: Date) {
|
||||
return `${d.getDate()}${getOrdinalSuffix(d.getDate())} at ${d.toLocaleTimeString()}`;
|
||||
}
|
||||
function toDayTime(d: Date) {
|
||||
return `${d.getDate()}${getOrdinalSuffix(d.getDate())} at ${d.toLocaleTimeString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get what will be the visible date the activity was 'created'.
|
||||
* @returns customDate if defined or createdAt if not.
|
||||
*/
|
||||
function getCreatedAtVis(a: Activity) {
|
||||
return Date.parse(a.customDate ?? a.createdAt);
|
||||
}
|
||||
/**
|
||||
* Get what will be the visible date the activity was 'created'.
|
||||
* @returns customDate if defined or createdAt if not.
|
||||
*/
|
||||
function getCreatedAtVis(a: Activity) {
|
||||
return Date.parse(a.customDate ?? a.createdAt);
|
||||
}
|
||||
|
||||
function getGroupedActivity(activities?: Activity[]) {
|
||||
activities = activities?.filter((a) => a.type);
|
||||
const a = activities?.sort((a, b) => getCreatedAtVis(b) - getCreatedAtVis(a));
|
||||
let grouped: { [index: string]: any } = {};
|
||||
if (a) {
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const activity = a[i];
|
||||
const date = new Date(getCreatedAtVis(activity));
|
||||
const key = `${months[date.getMonth()]} ${date.getFullYear()}`;
|
||||
if (grouped[key]) {
|
||||
grouped[key].push(activity);
|
||||
} else {
|
||||
grouped[key] = [activity];
|
||||
}
|
||||
}
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
function getGroupedActivity(activities?: Activity[]) {
|
||||
activities = activities?.filter((a) => a.type);
|
||||
const a = activities?.sort(
|
||||
(a, b) => getCreatedAtVis(b) - getCreatedAtVis(a),
|
||||
);
|
||||
let grouped: { [index: string]: any } = {};
|
||||
if (a) {
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const activity = a[i];
|
||||
const date = new Date(getCreatedAtVis(activity));
|
||||
const key = `${months[date.getMonth()]} ${date.getFullYear()}`;
|
||||
if (grouped[key]) {
|
||||
grouped[key].push(activity);
|
||||
} else {
|
||||
grouped[key] = [activity];
|
||||
}
|
||||
}
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
|
||||
function openEditor(a: Activity) {
|
||||
clickedActivity = a;
|
||||
isActivityEditorVisible = true;
|
||||
return;
|
||||
}
|
||||
function openEditor(a: Activity) {
|
||||
clickedActivity = a;
|
||||
return;
|
||||
}
|
||||
|
||||
function getActivityDataParsed(a: Activity) {
|
||||
try {
|
||||
if (a.data) {
|
||||
return JSON.parse(a.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("getActivityDataParsed: Failed!", err);
|
||||
}
|
||||
}
|
||||
function getActivityDataParsed(a: Activity) {
|
||||
try {
|
||||
if (a.data) {
|
||||
return JSON.parse(a.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("getActivityDataParsed: Failed!", err);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isActivityEditorVisible}
|
||||
<ActivityEditor
|
||||
watchedId={wListId}
|
||||
activity={clickedActivity}
|
||||
activityMessage={getMsg(clickedActivity)}
|
||||
onClose={() => (isActivityEditorVisible = false)}
|
||||
/>
|
||||
{#if clickedActivity}
|
||||
<ActivityEditor
|
||||
watchedId={wListId}
|
||||
activity={clickedActivity}
|
||||
activityMessage={getMsg(clickedActivity)}
|
||||
onClose={() => (clickedActivity = undefined)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="activity">
|
||||
<h2>Activity</h2>
|
||||
{#if groupedActivities && Object.keys(groupedActivities).length > 0}
|
||||
<ul>
|
||||
{#each Object.keys(groupedActivities) as k}
|
||||
<h3>{k}</h3>
|
||||
<h2>Activity</h2>
|
||||
{#if groupedActivities && Object.keys(groupedActivities).length > 0}
|
||||
<ul>
|
||||
{#each Object.keys(groupedActivities) as k}
|
||||
<h3>{k}</h3>
|
||||
|
||||
{#each groupedActivities[k] as a}
|
||||
{@const d = new Date(getCreatedAtVis(a))}
|
||||
<li>
|
||||
<button class="unset" on:click={() => openEditor(a)}>
|
||||
<span title={d.toDateString()}>{toDayTime(d)}</span>
|
||||
<span>{getMsg(a)}</span>
|
||||
</button>
|
||||
{#if a.type?.endsWith("_AUTO")}
|
||||
{@const data = getActivityDataParsed(a)}
|
||||
<i
|
||||
use:tooltip={{
|
||||
text:
|
||||
data && data.reason
|
||||
? `Automated because ${data.reason}`
|
||||
: "Completed by an automation.",
|
||||
pos: "bot"
|
||||
}}
|
||||
style="width: 20px; height: 20px;"
|
||||
>
|
||||
<Icon i="sparkles" wh={20} />
|
||||
</i>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<span>You Have No Activity!</span>
|
||||
{/if}
|
||||
{#each groupedActivities[k] as a}
|
||||
{@const d = new Date(getCreatedAtVis(a))}
|
||||
<li>
|
||||
<button class="plain" onclick={() => openEditor(a)}>
|
||||
<span title={d.toDateString()}>{toDayTime(d)}</span>
|
||||
<span>{getMsg(a)}</span>
|
||||
</button>
|
||||
{#if a.type?.endsWith("_AUTO")}
|
||||
{@const data = getActivityDataParsed(a)}
|
||||
<i
|
||||
use:tooltip={{
|
||||
text:
|
||||
data && data.reason
|
||||
? `Automated because ${data.reason}`
|
||||
: "Completed by an automation.",
|
||||
pos: "bot",
|
||||
}}
|
||||
style="width: 20px; height: 20px;"
|
||||
>
|
||||
<Icon i="sparkles" wh={20} />
|
||||
</i>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<span>You Have No Activity!</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.activity {
|
||||
width: 100%;
|
||||
.activity {
|
||||
width: 100%;
|
||||
|
||||
ul {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
margin-left: calc(30px + 8px);
|
||||
list-style: none;
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
ul {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
margin-left: calc(30px + 8px);
|
||||
list-style: none;
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
|
||||
h3 {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
font-size: 16px;
|
||||
font-family:
|
||||
sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont;
|
||||
padding-bottom: 1px;
|
||||
background-color: $bg-color;
|
||||
}
|
||||
h3 {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
font-size: 16px;
|
||||
font-family:
|
||||
sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont;
|
||||
padding-bottom: 1px;
|
||||
background-color: $bg-color;
|
||||
}
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
li {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
button {
|
||||
all: unset;
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
button {
|
||||
all: unset;
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
span {
|
||||
width: max-content;
|
||||
margin-left: 0px;
|
||||
span {
|
||||
width: max-content;
|
||||
margin-left: 0px;
|
||||
|
||||
&:first-child {
|
||||
min-width: max-content;
|
||||
}
|
||||
&:first-child {
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
background-color: $accent-color;
|
||||
color: $text-color;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
&:last-of-type {
|
||||
background-color: $accent-color;
|
||||
color: $text-color;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
margin-left: calc(30px);
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
span {
|
||||
margin-left: calc(30px);
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
margin-left: 30px;
|
||||
}
|
||||
h2 {
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
margin-left: 30px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,131 +1,140 @@
|
||||
<script lang="ts">
|
||||
import { updateActivity, removeActivity } from "@/lib/util/api";
|
||||
import Modal from "./Modal.svelte";
|
||||
import type { Activity } from "@/types";
|
||||
import { notify } from "./util/notify";
|
||||
import { updateActivity, removeActivity } from "@/lib/util/api";
|
||||
import Modal from "./Modal.svelte";
|
||||
import type { Activity } from "@/types";
|
||||
import { notify } from "./util/notify";
|
||||
|
||||
export let watchedId: number;
|
||||
export let activity: Activity;
|
||||
export let activityMessage: string;
|
||||
export let onClose: () => void;
|
||||
interface Props {
|
||||
watchedId: number;
|
||||
activity: Activity;
|
||||
activityMessage: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let isDateTimeChanged: boolean;
|
||||
let isDateTimeValid = true;
|
||||
let currentDateObject = new Date(Date.parse(activity.customDate ?? activity.createdAt));
|
||||
let currentDateString = dateToInputDateString(currentDateObject);
|
||||
let currentTimeString = dateToInputTimeString(currentDateObject);
|
||||
let selectedDateString = currentDateString;
|
||||
let selectedTimeString = currentTimeString;
|
||||
let { watchedId, activity, activityMessage, onClose }: Props = $props();
|
||||
|
||||
$: {
|
||||
isDateTimeChanged =
|
||||
currentDateString != selectedDateString || currentTimeString != selectedTimeString;
|
||||
}
|
||||
let isDateTimeChanged: boolean = $derived(
|
||||
currentDateString != selectedDateString ||
|
||||
currentTimeString != selectedTimeString,
|
||||
);
|
||||
let isDateTimeValid = $state(true);
|
||||
let currentDateObject = new Date(
|
||||
Date.parse(activity.customDate ?? activity.createdAt),
|
||||
);
|
||||
let currentDateString = dateToInputDateString(currentDateObject);
|
||||
let currentTimeString = dateToInputTimeString(currentDateObject);
|
||||
let selectedDateString = $state(currentDateString);
|
||||
let selectedTimeString = $state(currentTimeString);
|
||||
|
||||
function dateToInputDateString(date: Date) {
|
||||
const year = date.getFullYear();
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, "0");
|
||||
const day = date.getDate().toString().padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
function dateToInputDateString(date: Date) {
|
||||
const year = date.getFullYear();
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, "0");
|
||||
const day = date.getDate().toString().padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function dateToInputTimeString(date: Date) {
|
||||
const hours = date.getHours().toString().padStart(2, "0");
|
||||
const minutes = date.getMinutes().toString().padStart(2, "0");
|
||||
return `${hours}:${minutes}`;
|
||||
}
|
||||
function dateToInputTimeString(date: Date) {
|
||||
const hours = date.getHours().toString().padStart(2, "0");
|
||||
const minutes = date.getMinutes().toString().padStart(2, "0");
|
||||
return `${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
function validateNewDate() {
|
||||
try {
|
||||
const epochMillis = Date.parse(`${selectedDateString} ${selectedTimeString}`);
|
||||
const dateObj = new Date(epochMillis);
|
||||
if (isNaN(dateObj.getTime())) {
|
||||
isDateTimeValid = false;
|
||||
return;
|
||||
}
|
||||
isDateTimeValid = true;
|
||||
return dateObj;
|
||||
} catch (err) {
|
||||
console.error("ActivityEditor: validateNewDate failed!", err);
|
||||
isDateTimeValid = false;
|
||||
}
|
||||
}
|
||||
function validateNewDate() {
|
||||
try {
|
||||
const epochMillis = Date.parse(
|
||||
`${selectedDateString} ${selectedTimeString}`,
|
||||
);
|
||||
const dateObj = new Date(epochMillis);
|
||||
if (isNaN(dateObj.getTime())) {
|
||||
isDateTimeValid = false;
|
||||
return;
|
||||
}
|
||||
isDateTimeValid = true;
|
||||
return dateObj;
|
||||
} catch (err) {
|
||||
console.error("ActivityEditor: validateNewDate failed!", err);
|
||||
isDateTimeValid = false;
|
||||
}
|
||||
}
|
||||
|
||||
function update() {
|
||||
const dateObj = validateNewDate();
|
||||
if (dateObj && isDateTimeValid && isDateTimeChanged) {
|
||||
updateActivity(watchedId, activity.id, dateObj);
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
notify({ text: "Unable to try updating!", type: "error" });
|
||||
console.error(
|
||||
"ActivityEditor: Can't try updating, data missing/invalid:",
|
||||
dateObj,
|
||||
isDateTimeValid,
|
||||
isDateTimeChanged
|
||||
);
|
||||
}
|
||||
function update() {
|
||||
const dateObj = validateNewDate();
|
||||
if (dateObj && isDateTimeValid && isDateTimeChanged) {
|
||||
updateActivity(watchedId, activity.id, dateObj);
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
notify({ text: "Unable to try updating!", type: "error" });
|
||||
console.error(
|
||||
"ActivityEditor: Can't try updating, data missing/invalid:",
|
||||
dateObj,
|
||||
isDateTimeValid,
|
||||
isDateTimeChanged,
|
||||
);
|
||||
}
|
||||
|
||||
function remove() {
|
||||
removeActivity(watchedId, activity.id);
|
||||
onClose();
|
||||
}
|
||||
function remove() {
|
||||
removeActivity(watchedId, activity.id);
|
||||
onClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal title="Edit Activity" desc={activityMessage} maxWidth="400px" {onClose}>
|
||||
<div class="centered">
|
||||
<h3>Date</h3>
|
||||
<input
|
||||
id="activity-date"
|
||||
type="date"
|
||||
bind:value={selectedDateString}
|
||||
on:change={validateNewDate}
|
||||
class:invalid={!isDateTimeValid}
|
||||
/>
|
||||
<h3>Time</h3>
|
||||
<input
|
||||
id="activity-time"
|
||||
type="time"
|
||||
bind:value={selectedTimeString}
|
||||
on:change={validateNewDate}
|
||||
/>
|
||||
<div class="centered">
|
||||
<h3>Date</h3>
|
||||
<input
|
||||
id="activity-date"
|
||||
type="date"
|
||||
bind:value={selectedDateString}
|
||||
onchange={validateNewDate}
|
||||
class:invalid={!isDateTimeValid}
|
||||
/>
|
||||
<h3>Time</h3>
|
||||
<input
|
||||
id="activity-time"
|
||||
type="time"
|
||||
bind:value={selectedTimeString}
|
||||
onchange={validateNewDate}
|
||||
/>
|
||||
|
||||
<div class="button-row">
|
||||
<button class="danger" on:click={remove}>Delete</button>
|
||||
<div>
|
||||
<button on:click={update} disabled={!(isDateTimeChanged && isDateTimeValid)}>Update</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button class="danger" onclick={remove}>Delete</button>
|
||||
<div>
|
||||
<button
|
||||
onclick={update}
|
||||
disabled={!(isDateTimeChanged && isDateTimeValid)}>Update</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<style lang="scss">
|
||||
.centered {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 10px;
|
||||
height: 100%;
|
||||
.centered {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 10px;
|
||||
height: 100%;
|
||||
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
font-family:
|
||||
sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont;
|
||||
}
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
font-family:
|
||||
sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
justify-content: space-between;
|
||||
margin-top: 10px;
|
||||
.button-row {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
justify-content: space-between;
|
||||
margin-top: 10px;
|
||||
|
||||
button {
|
||||
margin-top: auto;
|
||||
width: max-content;
|
||||
}
|
||||
}
|
||||
}
|
||||
button {
|
||||
margin-top: auto;
|
||||
width: max-content;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,87 +1,96 @@
|
||||
<script lang="ts">
|
||||
export let name: string;
|
||||
export let value: boolean = false;
|
||||
export let toggled: (on: boolean) => void = () => {};
|
||||
export let disabled = false;
|
||||
interface Props {
|
||||
name: string;
|
||||
value?: boolean;
|
||||
toggled?: (on: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let actualDisabled = false;
|
||||
let {
|
||||
name,
|
||||
value = $bindable(),
|
||||
toggled = () => {},
|
||||
disabled = false,
|
||||
}: Props = $props();
|
||||
|
||||
$: {
|
||||
// In cases where we disable and enable the checkbox super fast
|
||||
// it ends up looking like a jittery mess. To circumvent this
|
||||
// issue, we add a small delay before undisabling.
|
||||
// (eg. fast internet and net request dis and re enables it quickly)
|
||||
if (disabled) {
|
||||
actualDisabled = true;
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
actualDisabled = disabled;
|
||||
}, 150);
|
||||
}
|
||||
}
|
||||
let actualDisabled = $state(false);
|
||||
|
||||
function checkboxChange(e: Event) {
|
||||
toggled((e.target as HTMLInputElement).checked);
|
||||
}
|
||||
$effect(() => {
|
||||
// In cases where we disable and enable the checkbox super fast
|
||||
// it ends up looking like a jittery mess. To circumvent this
|
||||
// issue, we add a small delay before undisabling.
|
||||
// (eg. fast internet and net request dis and re enables it quickly)
|
||||
if (disabled) {
|
||||
actualDisabled = true;
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
actualDisabled = disabled;
|
||||
}, 150);
|
||||
}
|
||||
});
|
||||
|
||||
function checkboxChange(e: Event) {
|
||||
toggled((e.target as HTMLInputElement).checked);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="toggle-pill-color">
|
||||
<input
|
||||
bind:checked={value}
|
||||
disabled={actualDisabled}
|
||||
type="checkbox"
|
||||
id={name}
|
||||
on:change={checkboxChange}
|
||||
/>
|
||||
<label for={name}></label>
|
||||
<input
|
||||
bind:checked={value}
|
||||
disabled={actualDisabled}
|
||||
type="checkbox"
|
||||
id={name}
|
||||
onchange={checkboxChange}
|
||||
/>
|
||||
<label for={name}></label>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.toggle-pill-color {
|
||||
width: min-content;
|
||||
.toggle-pill-color {
|
||||
width: min-content;
|
||||
|
||||
input {
|
||||
display: none;
|
||||
input {
|
||||
display: none;
|
||||
|
||||
&:checked + label {
|
||||
background: $success;
|
||||
&:checked + label {
|
||||
background: $success;
|
||||
|
||||
&::before {
|
||||
left: 1.6em;
|
||||
}
|
||||
}
|
||||
&::before {
|
||||
left: 1.6em;
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled + label {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
&:disabled + label {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
position: relative;
|
||||
width: 3em;
|
||||
height: 1.6em;
|
||||
border-radius: 1em;
|
||||
background: $error;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition:
|
||||
opacity 100ms ease-in-out,
|
||||
opacity 100ms ease-in-out;
|
||||
label {
|
||||
display: block;
|
||||
position: relative;
|
||||
width: 3em;
|
||||
height: 1.6em;
|
||||
border-radius: 1em;
|
||||
background: $error;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition:
|
||||
opacity 100ms ease-in-out,
|
||||
opacity 100ms ease-in-out;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 1.2em;
|
||||
height: 1.2em;
|
||||
border-radius: 1em;
|
||||
background: #fff;
|
||||
position: absolute;
|
||||
left: 0.2em;
|
||||
top: 0.2em;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
}
|
||||
}
|
||||
&::before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 1.2em;
|
||||
height: 1.2em;
|
||||
border-radius: 1em;
|
||||
background: #fff;
|
||||
position: absolute;
|
||||
left: 0.2em;
|
||||
top: 0.2em;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,25 +1,33 @@
|
||||
<!-- Uses Native Color Input -->
|
||||
|
||||
<script lang="ts">
|
||||
export let value = "#000000";
|
||||
export let style = "";
|
||||
interface Props {
|
||||
value?: string;
|
||||
style?: string;
|
||||
}
|
||||
|
||||
let { value = $bindable("#000000"), style = "" }: Props = $props();
|
||||
</script>
|
||||
|
||||
<button class="plain" style={`background-color: ${value};${style}`}>
|
||||
<input type="color" bind:value />
|
||||
<button
|
||||
aria-label="Open color picker"
|
||||
class="plain"
|
||||
style={`background-color: ${value};${style}`}
|
||||
>
|
||||
<input type="color" bind:value />
|
||||
</button>
|
||||
|
||||
<style lang="scss">
|
||||
button {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 10px;
|
||||
outline: 1px solid $accent-color;
|
||||
cursor: pointer;
|
||||
}
|
||||
button {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 10px;
|
||||
outline: 1px solid $accent-color;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input {
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
input {
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,181 +1,202 @@
|
||||
<script lang="ts">
|
||||
import type { DropDownItem } from "@/types";
|
||||
import Icon from "./Icon.svelte";
|
||||
import { onMount } from "svelte";
|
||||
import type { DropDownItem } from "@/types";
|
||||
import Icon from "./Icon.svelte";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
export let options: string[] | DropDownItem[];
|
||||
// If we are using DropDownItems[] as options.
|
||||
export let isDropDownItem = false;
|
||||
export let active: string | number | undefined = undefined;
|
||||
export let placeholder: string;
|
||||
export let blendIn: boolean = false;
|
||||
export let disabled = false;
|
||||
export let onChange: () => void = () => {};
|
||||
interface Props {
|
||||
options: string[] | DropDownItem[];
|
||||
// If we are using DropDownItems[] as options.
|
||||
isDropDownItem?: boolean;
|
||||
active?: string | number | undefined;
|
||||
placeholder: string;
|
||||
blendIn?: boolean;
|
||||
disabled?: boolean;
|
||||
onChange?: () => void;
|
||||
showActiveElementsInOptions?: boolean;
|
||||
}
|
||||
|
||||
let activeValue: string;
|
||||
let open = false;
|
||||
let ulElement: HTMLUListElement;
|
||||
let mainElement: HTMLDivElement;
|
||||
let {
|
||||
options,
|
||||
isDropDownItem = false,
|
||||
active = $bindable(undefined),
|
||||
placeholder,
|
||||
blendIn = false,
|
||||
disabled = false,
|
||||
onChange = () => {},
|
||||
showActiveElementsInOptions = false,
|
||||
}: Props = $props();
|
||||
|
||||
function handleKeyPress(event: KeyboardEvent) {
|
||||
if (!open || disabled) return; // Don't handle if closed or disabled
|
||||
let activeValue: string | undefined = $state();
|
||||
let open = $state(false);
|
||||
let ulElement: HTMLUListElement | undefined = $state();
|
||||
let mainElement: HTMLDivElement | undefined = $state();
|
||||
|
||||
const pressedLetter = event.key.toLowerCase();
|
||||
function handleKeyPress(event: KeyboardEvent) {
|
||||
if (!open || disabled) return; // Don't handle if closed or disabled
|
||||
|
||||
// Filter options that start with the pressed letter (ignoring case)
|
||||
const filteredOptions = options.filter((o) =>
|
||||
typeof o === "string"
|
||||
? o.toLowerCase().startsWith(pressedLetter)
|
||||
: o.value.toLowerCase().startsWith(pressedLetter)
|
||||
);
|
||||
const pressedLetter = event.key.toLowerCase();
|
||||
|
||||
// If there are filtered options, select the first one
|
||||
let f: string | number | undefined;
|
||||
if (filteredOptions.length > 0) {
|
||||
f = typeof filteredOptions[0] === "string" ? filteredOptions[0] : filteredOptions[0].id;
|
||||
}
|
||||
// Filter options that start with the pressed letter (ignoring case)
|
||||
const filteredOptions = options.filter((o) =>
|
||||
typeof o === "string"
|
||||
? o.toLowerCase().startsWith(pressedLetter)
|
||||
: o.value.toLowerCase().startsWith(pressedLetter),
|
||||
);
|
||||
|
||||
// Find first button with text content starting with letter pressed and scroll it into view.
|
||||
const btns = ulElement?.querySelectorAll("button");
|
||||
for (let i = 0; i < btns.length; i++) {
|
||||
const btn = btns[i];
|
||||
if (btn.textContent?.toLowerCase()?.startsWith(pressedLetter)) {
|
||||
btn.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// If there are filtered options, select the first one
|
||||
let f: string | number | undefined;
|
||||
if (filteredOptions.length > 0) {
|
||||
f =
|
||||
typeof filteredOptions[0] === "string"
|
||||
? filteredOptions[0]
|
||||
: filteredOptions[0].id;
|
||||
}
|
||||
|
||||
$: {
|
||||
if (typeof active === "string" && !isDropDownItem) {
|
||||
activeValue = active;
|
||||
} else {
|
||||
const v = options.find((o) => (typeof o !== "string" ? o.id === active : false));
|
||||
if (v && typeof v !== "string") activeValue = v.value;
|
||||
}
|
||||
}
|
||||
// Find first button with text content starting with letter pressed and scroll it into view.
|
||||
const btns = ulElement?.querySelectorAll("button");
|
||||
if (btns) {
|
||||
for (let i = 0; i < btns.length; i++) {
|
||||
const btn = btns[i];
|
||||
if (btn.textContent?.toLowerCase()?.startsWith(pressedLetter)) {
|
||||
btn.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
mainElement.addEventListener("keypress", handleKeyPress);
|
||||
$effect(() => {
|
||||
if (typeof active === "string" && !isDropDownItem) {
|
||||
activeValue = active;
|
||||
} else {
|
||||
const v = options.find((o) =>
|
||||
typeof o !== "string" ? o.id === active : false,
|
||||
);
|
||||
if (v && typeof v !== "string") activeValue = v.value;
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
mainElement.removeEventListener("keypress", handleKeyPress);
|
||||
};
|
||||
});
|
||||
onMount(() => {
|
||||
mainElement?.addEventListener("keypress", handleKeyPress);
|
||||
|
||||
return () => {
|
||||
mainElement?.removeEventListener("keypress", handleKeyPress);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={[
|
||||
open ? "is-open" : "",
|
||||
typeof active === "undefined" ? "placeholder-shown" : "",
|
||||
blendIn ? "blend-in" : ""
|
||||
].join(" ")}
|
||||
bind:this={mainElement}
|
||||
class={[
|
||||
open ? "is-open" : "",
|
||||
typeof active === "undefined" ? "placeholder-shown" : "",
|
||||
blendIn ? "blend-in" : "",
|
||||
].join(" ")}
|
||||
bind:this={mainElement}
|
||||
>
|
||||
<button on:click={() => (open = !open)} {disabled}>
|
||||
{activeValue ? activeValue : placeholder}
|
||||
<Icon i="chevron" facing={open ? "up" : "down"} />
|
||||
</button>
|
||||
<ul bind:this={ulElement}>
|
||||
{#each options.filter((o) => (typeof o === "string" ? o !== active : o.id !== active)) as o}
|
||||
<li>
|
||||
<button
|
||||
class="plain"
|
||||
on:click={() => {
|
||||
active = typeof o == "string" ? o : o.id;
|
||||
open = false;
|
||||
onChange();
|
||||
}}
|
||||
>
|
||||
{typeof o == "string" ? o : o.value}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<button onclick={() => (open = !open)} {disabled}>
|
||||
{activeValue ? activeValue : placeholder}
|
||||
<Icon i="chevron" facing={open ? "up" : "down"} />
|
||||
</button>
|
||||
<ul bind:this={ulElement}>
|
||||
{#each showActiveElementsInOptions ? options : options.filter( (o) => (typeof o === "string" ? o !== active : o.id !== active), ) as o}
|
||||
<li>
|
||||
<button
|
||||
class="plain"
|
||||
onclick={() => {
|
||||
active = typeof o == "string" ? o : o.id;
|
||||
open = false;
|
||||
onChange();
|
||||
}}
|
||||
>
|
||||
{typeof o == "string" ? o : o.value}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
div {
|
||||
position: relative;
|
||||
div {
|
||||
position: relative;
|
||||
|
||||
&.placeholder-shown {
|
||||
& > button {
|
||||
color: #8e8e8e;
|
||||
&.placeholder-shown {
|
||||
& > button {
|
||||
color: #8e8e8e;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: $bg-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
&:hover:not(:disabled) {
|
||||
color: $bg-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.blend-in {
|
||||
& > button {
|
||||
border-color: transparent;
|
||||
background-color: transparent;
|
||||
&.blend-in {
|
||||
& > button {
|
||||
border-color: transparent;
|
||||
background-color: transparent;
|
||||
|
||||
&:hover:not(:disabled),
|
||||
&:focus-visible:not(:disabled) {
|
||||
background-color: $text-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
&:hover:not(:disabled),
|
||||
&:focus-visible:not(:disabled) {
|
||||
background-color: $text-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.is-open {
|
||||
ul {
|
||||
display: flex;
|
||||
}
|
||||
&.is-open {
|
||||
ul {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
& > button {
|
||||
border-color: $text-color;
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
}
|
||||
& > button {
|
||||
border-color: $text-color;
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
gap: 3px;
|
||||
text-transform: capitalize;
|
||||
button {
|
||||
gap: 3px;
|
||||
text-transform: capitalize;
|
||||
|
||||
:global(svg) {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
:global(svg) {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
display: none;
|
||||
flex-flow: column;
|
||||
position: absolute;
|
||||
list-style: none;
|
||||
width: 100%;
|
||||
font-size: 14px;
|
||||
border: 2px solid $text-color;
|
||||
border-top: 0;
|
||||
border-bottom-left-radius: 5px;
|
||||
border-bottom-right-radius: 5px;
|
||||
background-color: $bg-color;
|
||||
z-index: 99;
|
||||
max-height: 20vh;
|
||||
overflow-y: auto;
|
||||
ul {
|
||||
display: none;
|
||||
flex-flow: column;
|
||||
position: absolute;
|
||||
list-style: none;
|
||||
width: 100%;
|
||||
font-size: 14px;
|
||||
border: 2px solid $text-color;
|
||||
border-top: 0;
|
||||
border-bottom-left-radius: 5px;
|
||||
border-bottom-right-radius: 5px;
|
||||
background-color: $bg-color;
|
||||
z-index: 99;
|
||||
max-height: 20vh;
|
||||
overflow-y: auto;
|
||||
|
||||
li {
|
||||
width: 100%;
|
||||
li {
|
||||
width: 100%;
|
||||
|
||||
button {
|
||||
padding: 5px 10px;
|
||||
width: 100%;
|
||||
text-align: start;
|
||||
text-transform: capitalize;
|
||||
transition: background-color 100ms ease;
|
||||
button {
|
||||
padding: 5px 10px;
|
||||
width: 100%;
|
||||
text-align: start;
|
||||
text-transform: capitalize;
|
||||
transition: background-color 100ms ease;
|
||||
|
||||
&:hover:not(:disabled),
|
||||
&:focus-visible:not(:disabled) {
|
||||
background-color: $text-color;
|
||||
color: $bg-color;
|
||||
fill: $bg-color;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
&:hover:not(:disabled),
|
||||
&:focus-visible:not(:disabled) {
|
||||
background-color: $text-color;
|
||||
color: $bg-color;
|
||||
fill: $bg-color;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,121 +1,134 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import type { Icon as IconT } from "@/types";
|
||||
import Icon from "./Icon.svelte";
|
||||
import { onMount } from "svelte";
|
||||
import type { Icon as IconT } from "@/types";
|
||||
import Icon from "./Icon.svelte";
|
||||
|
||||
export let text: string;
|
||||
export let icon: IconT = "document";
|
||||
export let filesSelected: (f?: FileList | null) => void;
|
||||
export let allowSelectMultipleFiles = false;
|
||||
interface Props {
|
||||
text: string;
|
||||
icon?: IconT;
|
||||
filesSelected: (f?: FileList | null) => void;
|
||||
allowSelectMultipleFiles?: boolean;
|
||||
}
|
||||
|
||||
let fileInput: HTMLInputElement;
|
||||
let dragEnterTarget: EventTarget | null;
|
||||
let isDragOver = false;
|
||||
let {
|
||||
text,
|
||||
icon = "document",
|
||||
filesSelected,
|
||||
allowSelectMultipleFiles = false,
|
||||
}: Props = $props();
|
||||
|
||||
function importFile() {
|
||||
fileInput.click();
|
||||
}
|
||||
let fileInput: HTMLInputElement = $state();
|
||||
let dragEnterTarget: EventTarget | null = $state();
|
||||
let isDragOver = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener("change", () => {
|
||||
filesSelected(fileInput.files);
|
||||
});
|
||||
}
|
||||
});
|
||||
function importFile() {
|
||||
fileInput.click();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener("change", () => {
|
||||
filesSelected(fileInput.files);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="drop-file-btn">
|
||||
<button
|
||||
on:click={importFile}
|
||||
on:dragover={(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
}}
|
||||
on:dragenter={(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
dragEnterTarget = ev.target;
|
||||
console.log("enter");
|
||||
isDragOver = true;
|
||||
}}
|
||||
on:dragleave={(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
if (dragEnterTarget === ev.target) {
|
||||
console.log("leave");
|
||||
isDragOver = false;
|
||||
}
|
||||
}}
|
||||
on:drop={(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
filesSelected(ev.dataTransfer?.files);
|
||||
}}
|
||||
class={isDragOver ? "dragging-over" : ""}
|
||||
>
|
||||
<Icon i={isDragOver ? "add" : icon} wh="100%" />
|
||||
<div>
|
||||
<h4 class="norm">
|
||||
{#if isDragOver}
|
||||
Import {text}
|
||||
{:else}
|
||||
{text}
|
||||
{/if}
|
||||
</h4>
|
||||
</div>
|
||||
</button>
|
||||
<input type="file" multiple={allowSelectMultipleFiles} bind:this={fileInput} />
|
||||
<button
|
||||
onclick={importFile}
|
||||
ondragover={(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
}}
|
||||
ondragenter={(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
dragEnterTarget = ev.target;
|
||||
console.log("enter");
|
||||
isDragOver = true;
|
||||
}}
|
||||
ondragleave={(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
if (dragEnterTarget === ev.target) {
|
||||
console.log("leave");
|
||||
isDragOver = false;
|
||||
}
|
||||
}}
|
||||
ondrop={(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
filesSelected(ev.dataTransfer?.files);
|
||||
}}
|
||||
class={isDragOver ? "dragging-over" : ""}
|
||||
>
|
||||
<Icon i={isDragOver ? "add" : icon} wh="100%" />
|
||||
<div>
|
||||
<h4 class="norm">
|
||||
{#if isDragOver}
|
||||
Import {text}
|
||||
{:else}
|
||||
{text}
|
||||
{/if}
|
||||
</h4>
|
||||
</div>
|
||||
</button>
|
||||
<input
|
||||
type="file"
|
||||
multiple={allowSelectMultipleFiles}
|
||||
bind:this={fileInput}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.drop-file-btn {
|
||||
button {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 180px;
|
||||
padding: 20px;
|
||||
background-color: $accent-color;
|
||||
border: unset;
|
||||
border-radius: 10px;
|
||||
user-select: none;
|
||||
transition: 180ms ease-in-out;
|
||||
.drop-file-btn {
|
||||
button {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 180px;
|
||||
padding: 20px;
|
||||
background-color: $accent-color;
|
||||
border: unset;
|
||||
border-radius: 10px;
|
||||
user-select: none;
|
||||
transition: 180ms ease-in-out;
|
||||
|
||||
:global {
|
||||
#reel path {
|
||||
transition: 180ms ease-in-out;
|
||||
:global {
|
||||
#reel path {
|
||||
transition: 180ms ease-in-out;
|
||||
|
||||
&:first-of-type {
|
||||
fill: transparent;
|
||||
}
|
||||
&:first-of-type {
|
||||
fill: transparent;
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
fill: $text-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
&:last-of-type {
|
||||
fill: $text-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&.dragging-over {
|
||||
color: $bg-color;
|
||||
background-color: $accent-color-hover;
|
||||
&:hover,
|
||||
&.dragging-over {
|
||||
color: $bg-color;
|
||||
background-color: $accent-color-hover;
|
||||
|
||||
:global(#reel path:last-of-type) {
|
||||
fill: $bg-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
:global(#reel path:last-of-type) {
|
||||
fill: $bg-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
input[type="file"] {
|
||||
width: 0px;
|
||||
overflow: hidden;
|
||||
border: unset;
|
||||
background-color: transparent;
|
||||
position: absolute;
|
||||
top: -500px;
|
||||
}
|
||||
}
|
||||
input[type="file"] {
|
||||
width: 0px;
|
||||
overflow: hidden;
|
||||
border: unset;
|
||||
background-color: transparent;
|
||||
position: absolute;
|
||||
top: -500px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,44 +1,48 @@
|
||||
<script lang="ts">
|
||||
export let pretty: string;
|
||||
export let error: any;
|
||||
export let onRetry: () => void | undefined = undefined!;
|
||||
interface Props {
|
||||
pretty: string;
|
||||
error: any;
|
||||
onRetry?: () => void | undefined;
|
||||
}
|
||||
|
||||
let { pretty, error, onRetry = undefined! }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div>
|
||||
<strong>{pretty}</strong>
|
||||
{#if error?.message}
|
||||
<p>{error.message}</p>
|
||||
{#if error.response?.data?.error}
|
||||
<p>{error.response.data.error}</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<p>{JSON.stringify(error)}</p>
|
||||
{/if}
|
||||
{#if onRetry}
|
||||
<button on:click={onRetry}>Try Again</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<strong>{pretty}</strong>
|
||||
{#if error?.message}
|
||||
<p>{error.message}</p>
|
||||
{#if error.response?.data?.error}
|
||||
<p>{error.response.data.error}</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<p>{JSON.stringify(error)}</p>
|
||||
{/if}
|
||||
{#if onRetry}
|
||||
<button onclick={onRetry}>Try Again</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
div {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
div {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-flow: column;
|
||||
max-width: 500px;
|
||||
gap: 5px;
|
||||
text-transform: capitalize;
|
||||
font-size: 16px;
|
||||
div {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-flow: column;
|
||||
max-width: 500px;
|
||||
gap: 5px;
|
||||
text-transform: capitalize;
|
||||
font-size: 16px;
|
||||
|
||||
p {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
p {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,41 +1,46 @@
|
||||
<script lang="ts">
|
||||
export let title: string | undefined;
|
||||
interface Props {
|
||||
title: string | undefined;
|
||||
children?: import("svelte").Snippet;
|
||||
}
|
||||
|
||||
let { title, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div>
|
||||
{#if title}
|
||||
<h2>{title}</h2>
|
||||
{/if}
|
||||
<ul>
|
||||
<slot />
|
||||
</ul>
|
||||
{#if title}
|
||||
<h2>{title}</h2>
|
||||
{/if}
|
||||
<ul>
|
||||
{@render children?.()}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
div {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
div {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-family:
|
||||
sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont;
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
margin-left: 30px;
|
||||
position: sticky;
|
||||
left: 0;
|
||||
}
|
||||
h2 {
|
||||
font-family:
|
||||
sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont;
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
margin-left: 30px;
|
||||
position: sticky;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
ul {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 10px;
|
||||
list-style: none;
|
||||
padding: 15px 30px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
ul {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 10px;
|
||||
list-style: none;
|
||||
padding: 15px 30px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,212 +1,241 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@/lib/Icon.svelte";
|
||||
import Modal from "@/lib/Modal.svelte";
|
||||
import Spinner from "@/lib/Spinner.svelte";
|
||||
import { notify } from "@/lib/util/notify";
|
||||
import { JobStatus, type GetJobResponse, type JobCreatedResponse } from "@/types";
|
||||
import axios from "axios";
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { watchedList } from "@/store";
|
||||
import Icon from "@/lib/Icon.svelte";
|
||||
import Modal from "@/lib/Modal.svelte";
|
||||
import Spinner from "@/lib/Spinner.svelte";
|
||||
import { notify } from "@/lib/util/notify";
|
||||
import {
|
||||
JobStatus,
|
||||
type GetJobResponse,
|
||||
type JobCreatedResponse,
|
||||
} from "@/types";
|
||||
import axios from "axios";
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { store } from "@/store.svelte";
|
||||
|
||||
export let modalTitle: string;
|
||||
// Promise that returns the job id to watch.
|
||||
export let getJobId: () => Promise<{ jobId: string } | undefined>;
|
||||
export let onClose: () => void;
|
||||
export let messages: {
|
||||
starting: string;
|
||||
};
|
||||
interface Props {
|
||||
modalTitle: string;
|
||||
// Promise that returns the job id to watch.
|
||||
getJobId: () => Promise<{ jobId: string } | undefined>;
|
||||
onClose: () => void;
|
||||
messages: {
|
||||
starting: string;
|
||||
};
|
||||
}
|
||||
|
||||
let step: "starting" | "errored" | "job-running" | "done" | "modal-closing" = "starting";
|
||||
let jobId: string | undefined;
|
||||
let currentTask: string | undefined;
|
||||
let latestJobStatus: GetJobResponse | undefined;
|
||||
let jobFailError: string | undefined;
|
||||
let { modalTitle, getJobId, onClose, messages }: Props = $props();
|
||||
|
||||
async function startSync() {
|
||||
try {
|
||||
const r = await getJobId();
|
||||
console.log("startSync: Response:", r);
|
||||
if (!r?.jobId) {
|
||||
step = "errored";
|
||||
console.error("startSync: No jobId returned!");
|
||||
return;
|
||||
}
|
||||
jobId = r.jobId;
|
||||
step = "job-running";
|
||||
startJobWatcher();
|
||||
} catch (err: any) {
|
||||
console.error("startSync failed!", err);
|
||||
step = "errored";
|
||||
jobFailError = err?.response ? err?.response?.data?.error : String(err);
|
||||
}
|
||||
}
|
||||
let step: "starting" | "errored" | "job-running" | "done" | "modal-closing" =
|
||||
$state("starting");
|
||||
let jobId: string | undefined;
|
||||
let currentTask: string | undefined = $state();
|
||||
let latestJobStatus: GetJobResponse | undefined = $state();
|
||||
let jobFailError: string | undefined = $state();
|
||||
|
||||
async function startJobWatcher() {
|
||||
if (!jobId) {
|
||||
console.error("startJobWatcher: No Job Id");
|
||||
notify({ text: "Unable to start job watcher, no job id.", type: "error" });
|
||||
return;
|
||||
}
|
||||
console.log("startJobWatcher: Starting..");
|
||||
let seqfailedJobReqs = 0;
|
||||
while (step === "job-running") {
|
||||
try {
|
||||
const r = await axios.get<GetJobResponse>(`/job/${jobId}`);
|
||||
console.log("jobWatcher: Got job data:", r.data);
|
||||
latestJobStatus = r.data;
|
||||
currentTask = r.data?.currentTask;
|
||||
if (r.data?.status === JobStatus.DONE) {
|
||||
step = "done";
|
||||
} else if (r.data?.status === JobStatus.CANCELLED) {
|
||||
step = "errored";
|
||||
}
|
||||
// If we get here without erroring, we can reset it to 0.
|
||||
seqfailedJobReqs = 0;
|
||||
} catch (err) {
|
||||
console.error("jobWatcher: Get job request failed!", seqfailedJobReqs, err);
|
||||
seqfailedJobReqs++;
|
||||
}
|
||||
if (seqfailedJobReqs >= 10) {
|
||||
console.error("jobWatcher: Failed 10 times in a row!");
|
||||
notify({
|
||||
text: "Status checker has failed 10 times in a row!",
|
||||
type: "error",
|
||||
time: 30000
|
||||
});
|
||||
step = "errored";
|
||||
break;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
if (step !== "modal-closing") {
|
||||
// Update our watched list
|
||||
const nid = notify({ text: "Fetching updated watched list.", type: "loading" });
|
||||
try {
|
||||
const w = await axios.get("/watched");
|
||||
if (w?.data?.length > 0) {
|
||||
watchedList.update((wl) => (wl = w.data));
|
||||
}
|
||||
notify({ id: nid, text: "Fetched updated watched list.", type: "success" });
|
||||
} catch (err) {
|
||||
console.error("jobWatcher: Getting updated watched list failed!", err);
|
||||
notify({ id: nid, text: "Getting updated watched list failed!", type: "error" });
|
||||
}
|
||||
}
|
||||
}
|
||||
async function startSync() {
|
||||
try {
|
||||
const r = await getJobId();
|
||||
console.log("startSync: Response:", r);
|
||||
if (!r?.jobId) {
|
||||
step = "errored";
|
||||
console.error("startSync: No jobId returned!");
|
||||
return;
|
||||
}
|
||||
jobId = r.jobId;
|
||||
step = "job-running";
|
||||
startJobWatcher();
|
||||
} catch (err: any) {
|
||||
console.error("startSync failed!", err);
|
||||
step = "errored";
|
||||
jobFailError = err?.response ? err?.response?.data?.error : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
function modalClose() {
|
||||
if (step === "job-running") {
|
||||
notify({
|
||||
text: "Sync will continue in the background.. please refresh the page periodically to view your updated list or come back later.",
|
||||
time: 10000
|
||||
});
|
||||
}
|
||||
step = "modal-closing";
|
||||
onClose();
|
||||
}
|
||||
async function startJobWatcher() {
|
||||
if (!jobId) {
|
||||
console.error("startJobWatcher: No Job Id");
|
||||
notify({
|
||||
text: "Unable to start job watcher, no job id.",
|
||||
type: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
console.log("startJobWatcher: Starting..");
|
||||
let seqfailedJobReqs = 0;
|
||||
while (step === "job-running") {
|
||||
try {
|
||||
const r = await axios.get<GetJobResponse>(`/job/${jobId}`);
|
||||
console.log("jobWatcher: Got job data:", r.data);
|
||||
latestJobStatus = r.data;
|
||||
currentTask = r.data?.currentTask;
|
||||
if (r.data?.status === JobStatus.DONE) {
|
||||
step = "done";
|
||||
} else if (r.data?.status === JobStatus.CANCELLED) {
|
||||
step = "errored";
|
||||
}
|
||||
// If we get here without erroring, we can reset it to 0.
|
||||
seqfailedJobReqs = 0;
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"jobWatcher: Get job request failed!",
|
||||
seqfailedJobReqs,
|
||||
err,
|
||||
);
|
||||
seqfailedJobReqs++;
|
||||
}
|
||||
if (seqfailedJobReqs >= 10) {
|
||||
console.error("jobWatcher: Failed 10 times in a row!");
|
||||
notify({
|
||||
text: "Status checker has failed 10 times in a row!",
|
||||
type: "error",
|
||||
time: 30000,
|
||||
});
|
||||
step = "errored";
|
||||
break;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
if (step !== "modal-closing") {
|
||||
// Update our watched list
|
||||
const nid = notify({
|
||||
text: "Fetching updated watched list.",
|
||||
type: "loading",
|
||||
});
|
||||
try {
|
||||
const w = await axios.get("/watched");
|
||||
if (w?.data?.length > 0) {
|
||||
store.watchedList = w.data;
|
||||
}
|
||||
notify({
|
||||
id: nid,
|
||||
text: "Fetched updated watched list.",
|
||||
type: "success",
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("jobWatcher: Getting updated watched list failed!", err);
|
||||
notify({
|
||||
id: nid,
|
||||
text: "Getting updated watched list failed!",
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
startSync();
|
||||
});
|
||||
function modalClose() {
|
||||
if (step === "job-running") {
|
||||
notify({
|
||||
text: "Sync will continue in the background.. please refresh the page periodically to view your updated list or come back later.",
|
||||
time: 10000,
|
||||
});
|
||||
}
|
||||
step = "modal-closing";
|
||||
onClose();
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
step = "starting";
|
||||
jobId = undefined;
|
||||
currentTask = undefined;
|
||||
latestJobStatus = undefined;
|
||||
});
|
||||
onMount(() => {
|
||||
startSync();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
step = "starting";
|
||||
jobId = undefined;
|
||||
currentTask = undefined;
|
||||
latestJobStatus = undefined;
|
||||
});
|
||||
</script>
|
||||
|
||||
<Modal title={modalTitle} maxWidth="700px" onClose={modalClose}>
|
||||
<div class="ctr">
|
||||
{#if step === "done"}
|
||||
<Icon i="check" wh={60} />
|
||||
{:else if step === "errored"}
|
||||
<Icon i="close" wh={70} />
|
||||
{:else}
|
||||
<Spinner />
|
||||
{/if}
|
||||
<div>
|
||||
{#if step === "starting"}
|
||||
<h4 class="norm">Starting</h4>
|
||||
<span>{messages.starting}</span>
|
||||
{:else if step === "job-running"}
|
||||
<h4 class="norm">Running</h4>
|
||||
{#if currentTask}
|
||||
<span>{currentTask}</span>
|
||||
{/if}
|
||||
{:else if step === "done"}
|
||||
{#if !latestJobStatus?.errors || latestJobStatus?.errors?.length <= 0}
|
||||
<h4 class="norm">Finished</h4>
|
||||
<span>We have finished. Looks like there were no errors!</span>
|
||||
{:else}
|
||||
<h4 class="norm">
|
||||
Finished With {latestJobStatus?.errors?.length} Error{latestJobStatus?.errors
|
||||
?.length === 1
|
||||
? ""
|
||||
: "s"}
|
||||
</h4>
|
||||
<span>Job finished, but with errors:</span>
|
||||
<ul>
|
||||
{#each latestJobStatus?.errors as e}
|
||||
<li>{e}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{:else if step === "errored"}
|
||||
<h4 class="norm">We Errored!</h4>
|
||||
{#if jobFailError}
|
||||
<span>{jobFailError}</span>
|
||||
{:else}
|
||||
<span>We errored before starting the job or the job was cancelled.</span>
|
||||
{/if}
|
||||
{#if latestJobStatus?.errors && latestJobStatus?.errors?.length > 0}
|
||||
<ul>
|
||||
{#each latestJobStatus?.errors as e}
|
||||
<li>{e}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{:else}
|
||||
<h4 class="norm">Unknown State!</h4>
|
||||
<span>We're not sure of the current job status.</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ctr">
|
||||
{#if step === "done"}
|
||||
<Icon i="check" wh={60} />
|
||||
{:else if step === "errored"}
|
||||
<Icon i="close" wh={70} />
|
||||
{:else}
|
||||
<Spinner />
|
||||
{/if}
|
||||
<div>
|
||||
{#if step === "starting"}
|
||||
<h4 class="norm">Starting</h4>
|
||||
<span>{messages.starting}</span>
|
||||
{:else if step === "job-running"}
|
||||
<h4 class="norm">Running</h4>
|
||||
{#if currentTask}
|
||||
<span>{currentTask}</span>
|
||||
{/if}
|
||||
{:else if step === "done"}
|
||||
{#if !latestJobStatus?.errors || latestJobStatus?.errors?.length <= 0}
|
||||
<h4 class="norm">Finished</h4>
|
||||
<span>We have finished. Looks like there were no errors!</span>
|
||||
{:else}
|
||||
<h4 class="norm">
|
||||
Finished With {latestJobStatus?.errors?.length} Error{latestJobStatus
|
||||
?.errors?.length === 1
|
||||
? ""
|
||||
: "s"}
|
||||
</h4>
|
||||
<span>Job finished, but with errors:</span>
|
||||
<ul>
|
||||
{#each latestJobStatus?.errors as e}
|
||||
<li>{e}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{:else if step === "errored"}
|
||||
<h4 class="norm">We Errored!</h4>
|
||||
{#if jobFailError}
|
||||
<span>{jobFailError}</span>
|
||||
{:else}
|
||||
<span
|
||||
>We errored before starting the job or the job was cancelled.</span
|
||||
>
|
||||
{/if}
|
||||
{#if latestJobStatus?.errors && latestJobStatus?.errors?.length > 0}
|
||||
<ul>
|
||||
{#each latestJobStatus?.errors as e}
|
||||
<li>{e}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{:else}
|
||||
<h4 class="norm">Unknown State!</h4>
|
||||
<span>We're not sure of the current job status.</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<style lang="scss">
|
||||
.ctr {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 20px;
|
||||
justify-content: start;
|
||||
align-items: start;
|
||||
margin-top: 25px;
|
||||
margin-bottom: 15px;
|
||||
margin-left: 15px;
|
||||
.ctr {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 20px;
|
||||
justify-content: start;
|
||||
align-items: start;
|
||||
margin-top: 25px;
|
||||
margin-bottom: 15px;
|
||||
margin-left: 15px;
|
||||
|
||||
& > div:last-of-type {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
& > div:last-of-type {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
|
||||
& > span {
|
||||
font-style: italic;
|
||||
& > span {
|
||||
font-style: italic;
|
||||
|
||||
&::first-letter {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
&::first-letter {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
& > ul {
|
||||
padding-left: 25px;
|
||||
& > ul {
|
||||
padding-left: 25px;
|
||||
|
||||
li::first-letter {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
li::first-letter {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,65 +1,68 @@
|
||||
<script lang="ts">
|
||||
import type { ListBoxItem } from "@/types";
|
||||
import Checkbox from "./Checkbox.svelte";
|
||||
import type { ListBoxItem } from "@/types";
|
||||
import Checkbox from "./Checkbox.svelte";
|
||||
|
||||
export let options: ListBoxItem[];
|
||||
interface Props {
|
||||
options: ListBoxItem[];
|
||||
/**
|
||||
* If a checkbox to select all checkboxes is wanted,
|
||||
* provide the text to display besides it.
|
||||
*/
|
||||
allCheckBox?: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* If a checkbox to select all checkboxes is wanted,
|
||||
* provide the text to display besides it.
|
||||
*/
|
||||
export let allCheckBox: string | undefined = undefined;
|
||||
let { options = $bindable(), allCheckBox = undefined }: Props = $props();
|
||||
|
||||
let allCheckBoxValue: boolean = false;
|
||||
let allCheckBoxValue: boolean = $state(false);
|
||||
</script>
|
||||
|
||||
<div>
|
||||
{#if allCheckBox}
|
||||
<div>
|
||||
<Checkbox
|
||||
name={allCheckBox}
|
||||
bind:value={allCheckBoxValue}
|
||||
toggled={(on) => {
|
||||
for (let i = 0; i < options.length; i++) {
|
||||
const e = options[i];
|
||||
e.value = on;
|
||||
}
|
||||
options = options;
|
||||
}}
|
||||
/>
|
||||
<span>{allCheckBox}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#each options as o}
|
||||
<div>
|
||||
<Checkbox
|
||||
name={o.displayValue}
|
||||
bind:value={o.value}
|
||||
toggled={(on) => {
|
||||
if (!on) {
|
||||
allCheckBoxValue = false;
|
||||
} else if (!allCheckBoxValue) {
|
||||
if (!options.some((o) => !o.value)) {
|
||||
allCheckBoxValue = true;
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>{o.displayValue}</span>
|
||||
</div>
|
||||
{/each}
|
||||
{#if allCheckBox}
|
||||
<div>
|
||||
<Checkbox
|
||||
name={allCheckBox}
|
||||
bind:value={allCheckBoxValue}
|
||||
toggled={(on) => {
|
||||
for (let i = 0; i < options.length; i++) {
|
||||
const e = options[i];
|
||||
e.value = on;
|
||||
}
|
||||
options = options;
|
||||
}}
|
||||
/>
|
||||
<span>{allCheckBox}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#each options as o}
|
||||
<div>
|
||||
<Checkbox
|
||||
name={o.displayValue}
|
||||
bind:value={o.value}
|
||||
toggled={(on) => {
|
||||
if (!on) {
|
||||
allCheckBoxValue = false;
|
||||
} else if (!allCheckBoxValue) {
|
||||
if (!options.some((o) => !o.value)) {
|
||||
allCheckBoxValue = true;
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>{o.displayValue}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
div {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 10px;
|
||||
div {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 10px;
|
||||
|
||||
& > div {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
& > div {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
<!-- A menu designed to pop up around a
|
||||
button after it is pressed. -->
|
||||
<script lang="ts">
|
||||
import stayInView from "./actions/stayInView";
|
||||
|
||||
export interface MenuConfig {
|
||||
width?: string;
|
||||
top?: string;
|
||||
right?: string;
|
||||
arrowRight?: string;
|
||||
arrowLeft?: string;
|
||||
arrowColor?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
children: import("svelte").Snippet;
|
||||
conf?: MenuConfig;
|
||||
}
|
||||
|
||||
let { children, conf }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="menu"
|
||||
style={`--w: ${conf?.width || "125px"}; --r: ${
|
||||
conf?.right || "10px"
|
||||
}; --t: ${conf?.top || "55px"}; --ar: ${
|
||||
conf?.arrowRight || "unset"
|
||||
}; --al: ${conf?.arrowLeft || "unset"}; --ac: ${conf?.arrowColor || "unset"};`}
|
||||
use:stayInView={{ elToShiftSelector: "& > .arrow" }}
|
||||
>
|
||||
<i class="arrow"></i>
|
||||
<div>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.menu {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
position: absolute;
|
||||
right: var(--r);
|
||||
top: var(--t);
|
||||
width: var(--w);
|
||||
border: 3px solid $text-color;
|
||||
border-radius: 10px;
|
||||
background-color: $bg-color;
|
||||
list-style: none;
|
||||
z-index: 50;
|
||||
|
||||
// The lil arrow, adjust its pos under each specific menu.
|
||||
&:not(:has(> .arrow)):before,
|
||||
:global(.arrow) {
|
||||
// Works by showing arrow in ::before pseudoelement,
|
||||
// or if more control is needed, applies to any .arrow
|
||||
// element.
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
width: 0;
|
||||
border-top: 10px solid transparent;
|
||||
border-left: 10px solid transparent;
|
||||
border-right: 10px solid transparent;
|
||||
border-bottom: 10px solid var(--ac, $text-color);
|
||||
right: var(--ar);
|
||||
left: var(--al);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
& > div {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
padding: 10px;
|
||||
width: 100%;
|
||||
max-height: calc(100dvh - 65px);
|
||||
overflow: auto;
|
||||
|
||||
:global {
|
||||
h5 {
|
||||
margin-bottom: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
button,
|
||||
a {
|
||||
font-size: 14px;
|
||||
padding: 8px 16px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: background-color 200ms ease;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background-color: $text-color;
|
||||
color: $bg-color;
|
||||
}
|
||||
}
|
||||
|
||||
a.menu-footer,
|
||||
button.menu-footer {
|
||||
font-size: 11px;
|
||||
padding: 2px;
|
||||
text-align: center;
|
||||
transition: inherit;
|
||||
color: $text-color-accent;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
display: inline;
|
||||
font-weight: initial;
|
||||
width: auto;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
text-decoration: underline;
|
||||
background-color: $bg-color;
|
||||
color: $text-color;
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
color: $text-color-accent;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,97 +1,110 @@
|
||||
<script lang="ts">
|
||||
import Icon from "./Icon.svelte";
|
||||
import Icon from "./Icon.svelte";
|
||||
|
||||
export let title: string;
|
||||
export let desc: string | undefined = undefined;
|
||||
export let onClose: (() => void) | undefined = undefined;
|
||||
export let maxWidth = "1000px";
|
||||
export let error: string | undefined = undefined; // TODO This property is new, mimics what we do with other modals by showing an error at top.. we could migrate to use this in other places.
|
||||
interface Props {
|
||||
title: string;
|
||||
desc?: string | undefined;
|
||||
onClose?: (() => void) | undefined;
|
||||
maxWidth?: string;
|
||||
error?: string | undefined; // TODO This property is new, mimics what we do with other modals by showing an error at top.. we could migrate to use this in other places.
|
||||
children?: import("svelte").Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
title,
|
||||
desc = undefined,
|
||||
onClose = undefined,
|
||||
maxWidth = "1000px",
|
||||
error = undefined,
|
||||
children,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="backdrop"></div>
|
||||
<div class="modal">
|
||||
<div style="max-width:{maxWidth};">
|
||||
{#if typeof onClose !== "undefined"}
|
||||
<button class="close" on:click={onClose}><Icon i="close" wh="20" /></button>
|
||||
{/if}
|
||||
<h3 class="norm">{title}</h3>
|
||||
{#if desc}
|
||||
<h5 class="norm">{desc}</h5>
|
||||
{/if}
|
||||
{#if error}
|
||||
<span class="error">{error}</span>
|
||||
{/if}
|
||||
<slot />
|
||||
</div>
|
||||
<div style="max-width:{maxWidth};">
|
||||
{#if typeof onClose !== "undefined"}
|
||||
<button class="close" onclick={onClose}><Icon i="close" wh="20" /></button
|
||||
>
|
||||
{/if}
|
||||
<h3 class="norm">{title}</h3>
|
||||
{#if desc}
|
||||
<h5 class="norm">{desc}</h5>
|
||||
{/if}
|
||||
{#if error}
|
||||
<span class="error">{error}</span>
|
||||
{/if}
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.backdrop {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100dvw;
|
||||
height: 100dvh;
|
||||
backdrop-filter: blur(2px) saturate(180%);
|
||||
background-color: color-mix(in srgb, black 85%, transparent);
|
||||
position: fixed;
|
||||
z-index: 99998;
|
||||
}
|
||||
.backdrop {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100dvw;
|
||||
height: 100dvh;
|
||||
backdrop-filter: blur(2px) saturate(180%);
|
||||
background-color: color-mix(in srgb, black 85%, transparent);
|
||||
position: fixed;
|
||||
z-index: 99998;
|
||||
}
|
||||
|
||||
.modal {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100dvw;
|
||||
height: 100dvh;
|
||||
top: 0;
|
||||
left: 0;
|
||||
position: fixed;
|
||||
z-index: 99998;
|
||||
.modal {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100dvw;
|
||||
height: 100dvh;
|
||||
top: 0;
|
||||
left: 0;
|
||||
position: fixed;
|
||||
z-index: 99998;
|
||||
|
||||
& > div {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
min-width: 300px;
|
||||
width: 100%;
|
||||
max-height: calc(100% - 20px);
|
||||
background-color: $bg-color;
|
||||
border-radius: 10px;
|
||||
padding: 15px 20px;
|
||||
margin: 20px 100px;
|
||||
transition: margin 100ms ease;
|
||||
outline: 2px solid $text-color;
|
||||
overflow: auto;
|
||||
& > div {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
min-width: 300px;
|
||||
width: 100%;
|
||||
max-height: calc(100% - 20px);
|
||||
background-color: $bg-color;
|
||||
border-radius: 10px;
|
||||
padding: 15px 20px;
|
||||
margin: 20px 100px;
|
||||
transition: margin 100ms ease;
|
||||
outline: 2px solid $text-color;
|
||||
overflow: auto;
|
||||
|
||||
h5 {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
h5 {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
button.close {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: max-content;
|
||||
padding: 3px 5px;
|
||||
}
|
||||
button.close {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: max-content;
|
||||
padding: 3px 5px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 680px) {
|
||||
margin: 20px;
|
||||
}
|
||||
@media screen and (max-width: 680px) {
|
||||
margin: 20px;
|
||||
}
|
||||
|
||||
.error {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background-color: rgb(221, 48, 48);
|
||||
text-transform: capitalize;
|
||||
color: white;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.error {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background-color: rgb(221, 48, 48);
|
||||
text-transform: capitalize;
|
||||
color: white;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
title: string;
|
||||
desc: string;
|
||||
type: "warn";
|
||||
}
|
||||
|
||||
let { title, desc, type }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class={type}>
|
||||
<h4 class="norm">{title}</h4>
|
||||
<p>{desc}</p>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
div {
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
|
||||
&.warn {
|
||||
background-color: $warn;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,42 +1,46 @@
|
||||
<script lang="ts">
|
||||
export let pretty: string;
|
||||
export let error: any;
|
||||
interface Props {
|
||||
pretty: string;
|
||||
error: any;
|
||||
}
|
||||
|
||||
let { pretty, error }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div>
|
||||
<strong>{pretty}</strong>
|
||||
{#if error?.message}
|
||||
<p>{error.message}</p>
|
||||
{#if error.response}
|
||||
<p>{error.response.data.error}</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<p>{JSON.stringify(error)}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<strong>{pretty}</strong>
|
||||
{#if error?.message}
|
||||
<p>{error.message}</p>
|
||||
{#if error.response}
|
||||
<p>{error.response.data.error}</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<p>{JSON.stringify(error)}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
div {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
div {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-flow: column;
|
||||
max-width: 500px;
|
||||
gap: 15px;
|
||||
div {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-flow: column;
|
||||
max-width: 500px;
|
||||
gap: 15px;
|
||||
|
||||
p {
|
||||
color: white;
|
||||
background-color: rgb(66, 60, 60);
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
}
|
||||
}
|
||||
p {
|
||||
color: white;
|
||||
background-color: rgb(66, 60, 60);
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,37 +1,45 @@
|
||||
<script lang="ts">
|
||||
import axios from "axios";
|
||||
import DropDown from "./DropDown.svelte";
|
||||
import type { DropDownItem, TMDBRegions } from "@/types";
|
||||
import Error from "./Error.svelte";
|
||||
import axios from "axios";
|
||||
import DropDown from "./DropDown.svelte";
|
||||
import type { DropDownItem, TMDBRegions } from "@/types";
|
||||
import Error from "./Error.svelte";
|
||||
|
||||
export let selectedCountry: string = "US";
|
||||
export let disabled = false;
|
||||
export let onChange: (country: string) => void;
|
||||
interface Props {
|
||||
selectedCountry?: string;
|
||||
disabled?: boolean;
|
||||
onChange: (country: string) => void;
|
||||
}
|
||||
|
||||
let mappedCountries: DropDownItem[];
|
||||
let {
|
||||
selectedCountry = $bindable("US"),
|
||||
disabled = false,
|
||||
onChange,
|
||||
}: Props = $props();
|
||||
|
||||
async function getCountries() {
|
||||
const c = (await axios.get(`/content/regions`)).data as TMDBRegions;
|
||||
mappedCountries = c.results
|
||||
.map((cc) => {
|
||||
return {
|
||||
id: cc.iso_3166_1,
|
||||
value: cc.english_name
|
||||
} as DropDownItem;
|
||||
})
|
||||
.sort((a, b) => a.value.localeCompare(b.value));
|
||||
}
|
||||
let mappedCountries: DropDownItem[] = $state();
|
||||
|
||||
async function getCountries() {
|
||||
const c = (await axios.get(`/content/regions`)).data as TMDBRegions;
|
||||
mappedCountries = c.results
|
||||
.map((cc) => {
|
||||
return {
|
||||
id: cc.iso_3166_1,
|
||||
value: cc.english_name,
|
||||
} as DropDownItem;
|
||||
})
|
||||
.sort((a, b) => a.value.localeCompare(b.value));
|
||||
}
|
||||
</script>
|
||||
|
||||
{#await getCountries() then}
|
||||
<DropDown
|
||||
placeholder="Select a country"
|
||||
bind:active={selectedCountry}
|
||||
options={mappedCountries}
|
||||
onChange={() => onChange(selectedCountry)}
|
||||
isDropDownItem={true}
|
||||
{disabled}
|
||||
/>
|
||||
<DropDown
|
||||
placeholder="Select a country"
|
||||
bind:active={selectedCountry}
|
||||
options={mappedCountries}
|
||||
onChange={() => onChange(selectedCountry)}
|
||||
isDropDownItem={true}
|
||||
{disabled}
|
||||
/>
|
||||
{:catch err}
|
||||
<Error error={err} pretty="Failed to load countries!" />
|
||||
<Error error={err} pretty="Failed to load countries!" />
|
||||
{/await}
|
||||
|
||||
@@ -1,354 +1,486 @@
|
||||
<script lang="ts">
|
||||
import type {
|
||||
TMDBSeasonDetails,
|
||||
TMDBShowSeason,
|
||||
Watched,
|
||||
WatchedSeasonAddResponse,
|
||||
WatchedStatus
|
||||
} from "@/types";
|
||||
import axios from "axios";
|
||||
import Spinner from "./Spinner.svelte";
|
||||
import Error from "./Error.svelte";
|
||||
import SeasonsListEpisode from "./SeasonsListEpisode.svelte";
|
||||
import PosterStatus from "./poster/PosterStatus.svelte";
|
||||
import { notify } from "./util/notify";
|
||||
import { get } from "svelte/store";
|
||||
import { watchedList } from "@/store";
|
||||
import PosterRating from "./poster/PosterRating.svelte";
|
||||
import type {
|
||||
TMDBSeasonDetails,
|
||||
TMDBShowSeason,
|
||||
Watched,
|
||||
WatchedSeason,
|
||||
WatchedSeasonAddResponse,
|
||||
WatchedStatus,
|
||||
} from "@/types";
|
||||
import axios from "axios";
|
||||
import Spinner from "./Spinner.svelte";
|
||||
import Error from "./Error.svelte";
|
||||
import SeasonsListEpisode from "./SeasonsListEpisode.svelte";
|
||||
import PosterStatus from "./poster/PosterStatus.svelte";
|
||||
import { notify } from "./util/notify";
|
||||
import { store } from "@/store.svelte";
|
||||
import PosterRating from "./poster/PosterRating.svelte";
|
||||
import Icon from "./Icon.svelte";
|
||||
import { watchedStatuses } from "./util/helpers";
|
||||
|
||||
export let tvId: number;
|
||||
export let seasons: TMDBShowSeason[];
|
||||
export let watchedItem: Watched | undefined;
|
||||
interface Props {
|
||||
tvId: number;
|
||||
seasons: TMDBShowSeason[];
|
||||
watchedItem: Watched | undefined;
|
||||
}
|
||||
|
||||
let activeSeason =
|
||||
typeof watchedItem?.lastViewedSeason === "number" ? watchedItem?.lastViewedSeason : 1;
|
||||
let seasonDetailsReq: Promise<TMDBSeasonDetails>;
|
||||
let { tvId, seasons, watchedItem = $bindable() }: Props = $props();
|
||||
|
||||
async function sdr(seasonNum: number) {
|
||||
const resp = await axios.get(`/content/tv/${tvId}/season/${seasonNum}`, {
|
||||
params: {
|
||||
watchedId: watchedItem?.id
|
||||
}
|
||||
});
|
||||
try {
|
||||
if (watchedItem?.id) {
|
||||
// If we sent a watched id, expect a 'watcharr-lastviewedseason-saved' header in the response.
|
||||
const hVal = resp.headers["watcharr-lastviewedseason-saved"];
|
||||
if (hVal) {
|
||||
watchedItem.lastViewedSeason = seasonNum;
|
||||
watchedList.update((w) => w);
|
||||
} else {
|
||||
console.error(
|
||||
"SeasonList: sdr: No header in response indicating that the lastviewedseason was saved."
|
||||
);
|
||||
notify({ type: "error", text: "Failed when saving last viewed season" });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("SeasonList: sdr: Failed to process lastviewedseason-saved header.", err);
|
||||
}
|
||||
return resp.data as TMDBSeasonDetails;
|
||||
}
|
||||
let activeSeason = $state(
|
||||
typeof watchedItem?.lastViewedSeason === "number"
|
||||
? watchedItem?.lastViewedSeason
|
||||
: 1,
|
||||
);
|
||||
let seasonDetailsReq: Promise<TMDBSeasonDetails> = $derived(
|
||||
sdr(activeSeason),
|
||||
);
|
||||
|
||||
// Add/update watched season
|
||||
function updateWatchedSeason(seasonNumber: number, status?: WatchedStatus, rating?: number) {
|
||||
if (!watchedItem) {
|
||||
console.error("updateWatchedSeason: No watched item.");
|
||||
return;
|
||||
}
|
||||
const nid = notify({ text: `Saving`, type: "loading" });
|
||||
axios
|
||||
.post<WatchedSeasonAddResponse>(`/watched/season`, {
|
||||
watchedId: watchedItem.id,
|
||||
seasonNumber: seasonNumber,
|
||||
status,
|
||||
rating
|
||||
})
|
||||
.then((r) => {
|
||||
const wList = get(watchedList);
|
||||
const wEntry = wList.find((w) => w.id === watchedItem.id);
|
||||
if (!wEntry) {
|
||||
notify({
|
||||
id: nid,
|
||||
text: `Request succeeded, but failed to find local data. Please refresh.`,
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (r.status === 200) {
|
||||
wEntry.watchedSeasons = r.data.watchedSeasons;
|
||||
if (wEntry.activity?.length > 0) {
|
||||
wEntry.activity.push(r.data.addedActivity);
|
||||
} else {
|
||||
wEntry.activity = [r.data.addedActivity];
|
||||
}
|
||||
watchedList.update((w) => w);
|
||||
notify({ id: nid, text: `Saved!`, type: "success" });
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
notify({ id: nid, text: "Failed To Update!", type: "error" });
|
||||
});
|
||||
}
|
||||
async function sdr(seasonNum: number) {
|
||||
const resp = await axios.get(`/content/tv/${tvId}/season/${seasonNum}`, {
|
||||
params: {
|
||||
watchedId: watchedItem?.id,
|
||||
},
|
||||
});
|
||||
try {
|
||||
if (watchedItem?.id) {
|
||||
// If we sent a watched id, expect a 'watcharr-lastviewedseason-saved' header in the response.
|
||||
const hVal = resp.headers["watcharr-lastviewedseason-saved"];
|
||||
if (hVal) {
|
||||
watchedItem.lastViewedSeason = seasonNum;
|
||||
// watchedList.update((w) => w);
|
||||
} else {
|
||||
console.error(
|
||||
"SeasonList: sdr: No header in response indicating that the lastviewedseason was saved.",
|
||||
);
|
||||
notify({
|
||||
type: "error",
|
||||
text: "Failed when saving last viewed season",
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"SeasonList: sdr: Failed to process lastviewedseason-saved header.",
|
||||
err,
|
||||
);
|
||||
}
|
||||
return resp.data as TMDBSeasonDetails;
|
||||
}
|
||||
|
||||
function handleStatusClick(type: WatchedStatus | "DELETE", seasonNumber: number) {
|
||||
if (!watchedItem) {
|
||||
console.error("handleStatusClick: No watched item.");
|
||||
return;
|
||||
}
|
||||
if (type === "DELETE") {
|
||||
const ws = watchedItem.watchedSeasons?.find((s) => s.seasonNumber === seasonNumber);
|
||||
if (!ws) {
|
||||
notify({ text: "Failed to find watched season id. Please try refreshing.", type: "error" });
|
||||
return;
|
||||
}
|
||||
const nid = notify({ text: `Saving`, type: "loading" });
|
||||
axios
|
||||
.delete(`/watched/season/${ws.id}`)
|
||||
.then((r) => {
|
||||
const wList = get(watchedList);
|
||||
const wEntry = wList.find((w) => w.id === watchedItem.id);
|
||||
if (!wEntry) {
|
||||
notify({
|
||||
id: nid,
|
||||
text: `Request succeeded, but failed to find local data. Please refresh.`,
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (r.status === 200) {
|
||||
wEntry.watchedSeasons = wEntry.watchedSeasons?.filter((s) => s.id !== ws.id);
|
||||
if (r.data) {
|
||||
if (wEntry.activity?.length > 0) {
|
||||
wEntry.activity.push(r.data);
|
||||
} else {
|
||||
wEntry.activity = [r.data];
|
||||
}
|
||||
}
|
||||
watchedList.update((w) => w);
|
||||
notify({ id: nid, text: `Removed!`, type: "success" });
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
notify({ id: nid, text: "Failed To Remove!", type: "error" });
|
||||
});
|
||||
return;
|
||||
}
|
||||
updateWatchedSeason(seasonNumber, type);
|
||||
}
|
||||
// Add/update watched season
|
||||
function updateWatchedSeason(
|
||||
seasonNumber: number,
|
||||
status?: WatchedStatus,
|
||||
rating?: number,
|
||||
) {
|
||||
if (!watchedItem) {
|
||||
console.error("updateWatchedSeason: No watched item.");
|
||||
return;
|
||||
}
|
||||
const nid = notify({ text: `Saving`, type: "loading" });
|
||||
axios
|
||||
.post<WatchedSeasonAddResponse>(`/watched/season`, {
|
||||
watchedId: watchedItem.id,
|
||||
seasonNumber: seasonNumber,
|
||||
status,
|
||||
rating,
|
||||
})
|
||||
.then((r) => {
|
||||
// const wList = get(watchedList);
|
||||
const wEntry = store.watchedList.find((w) => w.id === watchedItem.id);
|
||||
if (!wEntry) {
|
||||
notify({
|
||||
id: nid,
|
||||
text: `Request succeeded, but failed to find local data. Please refresh.`,
|
||||
type: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (r.status === 200) {
|
||||
wEntry.watchedSeasons = r.data.watchedSeasons;
|
||||
if (wEntry.activity?.length > 0) {
|
||||
wEntry.activity.push(r.data.addedActivity);
|
||||
} else {
|
||||
wEntry.activity = [r.data.addedActivity];
|
||||
}
|
||||
notify({ id: nid, text: `Saved!`, type: "success" });
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
notify({ id: nid, text: "Failed To Update!", type: "error" });
|
||||
});
|
||||
}
|
||||
|
||||
function handleStarClick(rating: number, seasonNumber: number) {
|
||||
updateWatchedSeason(seasonNumber, undefined, rating);
|
||||
}
|
||||
function handleStatusClick(
|
||||
type: WatchedStatus | "DELETE",
|
||||
seasonNumber: number,
|
||||
) {
|
||||
if (!watchedItem) {
|
||||
console.error("handleStatusClick: No watched item.");
|
||||
return;
|
||||
}
|
||||
if (type === "DELETE") {
|
||||
const ws = watchedItem.watchedSeasons?.find(
|
||||
(s) => s.seasonNumber === seasonNumber,
|
||||
);
|
||||
if (!ws) {
|
||||
notify({
|
||||
text: "Failed to find watched season id. Please try refreshing.",
|
||||
type: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const nid = notify({ text: `Saving`, type: "loading" });
|
||||
axios
|
||||
.delete(`/watched/season/${ws.id}`)
|
||||
.then((r) => {
|
||||
const wEntry = store.watchedList.find((w) => w.id === watchedItem.id);
|
||||
if (!wEntry) {
|
||||
notify({
|
||||
id: nid,
|
||||
text: `Request succeeded, but failed to find local data. Please refresh.`,
|
||||
type: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (r.status === 200) {
|
||||
wEntry.watchedSeasons = wEntry.watchedSeasons?.filter(
|
||||
(s) => s.id !== ws.id,
|
||||
);
|
||||
if (r.data) {
|
||||
if (wEntry.activity?.length > 0) {
|
||||
wEntry.activity.push(r.data);
|
||||
} else {
|
||||
wEntry.activity = [r.data];
|
||||
}
|
||||
}
|
||||
notify({ id: nid, text: `Removed!`, type: "success" });
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
notify({ id: nid, text: "Failed To Remove!", type: "error" });
|
||||
});
|
||||
return;
|
||||
}
|
||||
updateWatchedSeason(seasonNumber, type);
|
||||
}
|
||||
|
||||
$: {
|
||||
seasonDetailsReq = sdr(activeSeason);
|
||||
}
|
||||
function handleStarClick(rating: number, seasonNumber: number) {
|
||||
updateWatchedSeason(seasonNumber, undefined, rating);
|
||||
}
|
||||
|
||||
function checkSeasonStatus(
|
||||
watchedSeasons: WatchedSeason[] | undefined,
|
||||
currentSeason: TMDBShowSeason,
|
||||
): WatchedStatus | undefined {
|
||||
if (watchedSeasons) {
|
||||
const watchedSeason = watchedSeasons.find(
|
||||
(ws) => ws.seasonNumber === currentSeason.season_number,
|
||||
);
|
||||
console.debug(
|
||||
"checkSeasonStatus:",
|
||||
currentSeason.season_number,
|
||||
watchedSeason?.status,
|
||||
);
|
||||
return watchedSeason?.status;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="ctr">
|
||||
<ul class="seasons">
|
||||
{#each seasons as season}
|
||||
<button
|
||||
class={`plain${activeSeason === season.season_number ? " active" : ""}`}
|
||||
on:click={() => {
|
||||
activeSeason = season.season_number;
|
||||
}}
|
||||
>
|
||||
<h1>{season.name}</h1>
|
||||
{#if season.air_date}
|
||||
<h2>{new Date(Date.parse(season.air_date)).getFullYear()}</h2>
|
||||
{:else if season.season_number > 0}
|
||||
<h2>TBD</h2>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
<div class="last" />
|
||||
</ul>
|
||||
<ul class="seasons">
|
||||
{#each seasons as season}
|
||||
<button
|
||||
class={`plain${activeSeason === season.season_number ? " active" : ""}`}
|
||||
onclick={() => {
|
||||
activeSeason = season.season_number;
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h1 class="season-name">{season.name}</h1>
|
||||
{#if season.episode_count > 0}
|
||||
<h2 class="season-episodes">{season.episode_count} Episodes</h2>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
{#if season.air_date}
|
||||
<h2 class="season-date">
|
||||
{new Date(Date.parse(season.air_date)).getFullYear()}
|
||||
</h2>
|
||||
{:else if season.season_number > 0}
|
||||
<h2>TBD</h2>
|
||||
{/if}
|
||||
|
||||
<div class="episodes">
|
||||
{#await seasonDetailsReq}
|
||||
<Spinner />
|
||||
{:then season}
|
||||
<div class="episodes-topbar">
|
||||
<h3>{season.name}</h3>
|
||||
{#if watchedItem}
|
||||
{@const ws = watchedItem?.watchedSeasons?.find(
|
||||
(s) => s.seasonNumber === season.season_number
|
||||
)}
|
||||
{#if ws}
|
||||
<div class="rating">
|
||||
<PosterRating
|
||||
rating={ws?.rating}
|
||||
btnTooltip="Season Rating"
|
||||
handleStarClick={(r) => handleStarClick(r, season.season_number)}
|
||||
minimal={true}
|
||||
direction="bot"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="status">
|
||||
<PosterStatus
|
||||
status={ws?.status}
|
||||
btnTooltip="Season Status"
|
||||
handleStatusClick={(t) => handleStatusClick(t, season.season_number)}
|
||||
direction="bot"
|
||||
width="100%"
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if season?.episodes?.length > 0}
|
||||
<ul>
|
||||
{#each season.episodes as ep}
|
||||
<SeasonsListEpisode {ep} {watchedItem} />
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<h3 class="norm">No episodes in this season yet!</h3>
|
||||
{/if}
|
||||
{:catch err}
|
||||
<Error pretty="Failed to load season details!" error={err} />
|
||||
{/await}
|
||||
</div>
|
||||
{#if watchedItem}
|
||||
{@const status = checkSeasonStatus(
|
||||
watchedItem.watchedSeasons,
|
||||
season,
|
||||
)}
|
||||
{#if status}
|
||||
<div class="plain season-status">
|
||||
<Icon i={watchedStatuses[status]} />
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
<div class="last"></div>
|
||||
</ul>
|
||||
|
||||
<div class="episodes">
|
||||
{#await seasonDetailsReq}
|
||||
<Spinner />
|
||||
{:then season}
|
||||
<div class="episodes-topbar">
|
||||
<h3>{season.name}</h3>
|
||||
{#if watchedItem}
|
||||
{@const ws = watchedItem?.watchedSeasons?.find(
|
||||
(s) => s.seasonNumber === season.season_number,
|
||||
)}
|
||||
{#if ws}
|
||||
<div class="rating">
|
||||
<PosterRating
|
||||
rating={ws?.rating}
|
||||
btnTooltip="Season Rating"
|
||||
handleStarClick={(r) =>
|
||||
handleStarClick(r, season.season_number)}
|
||||
minimal={true}
|
||||
direction="bot"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="status">
|
||||
<PosterStatus
|
||||
status={ws?.status}
|
||||
btnTooltip="Season Status"
|
||||
handleStatusClick={(t) =>
|
||||
handleStatusClick(t, season.season_number)}
|
||||
direction="bot"
|
||||
width="100%"
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if season?.episodes?.length > 0}
|
||||
<ul>
|
||||
{#each season.episodes as ep}
|
||||
<SeasonsListEpisode {ep} {watchedItem} />
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<h3 class="norm">No episodes in this season yet!</h3>
|
||||
{/if}
|
||||
{:catch err}
|
||||
<Error pretty="Failed to load season details!" error={err} />
|
||||
{/await}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.ctr {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 20px;
|
||||
width: 100%;
|
||||
}
|
||||
.ctr {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 20px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.episodes {
|
||||
width: 100%;
|
||||
.episodes {
|
||||
width: 100%;
|
||||
|
||||
ul {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
list-style: none;
|
||||
gap: 20px;
|
||||
}
|
||||
}
|
||||
ul {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
list-style: none;
|
||||
gap: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
ul.seasons {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
list-style: none;
|
||||
gap: 8px;
|
||||
min-width: fit-content;
|
||||
height: 100vh;
|
||||
overflow: auto;
|
||||
position: sticky;
|
||||
top: 0px;
|
||||
transition: top 200ms ease-in-out;
|
||||
ul.seasons {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
list-style: none;
|
||||
gap: 8px;
|
||||
min-width: fit-content;
|
||||
height: 100vh;
|
||||
overflow: auto;
|
||||
position: sticky;
|
||||
top: 0px;
|
||||
transition: top 200ms ease-in-out;
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 0 18px;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
border: 2px solid #302d2d;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
max-width: 220px;
|
||||
transition: background-color 100ms ease;
|
||||
button {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 0 18px;
|
||||
align-items: center;
|
||||
border: 2px solid #302d2d;
|
||||
border-radius: 8px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
min-width: 160px;
|
||||
max-width: 220px;
|
||||
transition: background-color 100ms ease;
|
||||
|
||||
&:first-of-type {
|
||||
margin-top: 10px;
|
||||
}
|
||||
& > div {
|
||||
&:first-of-type {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 18px;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
&:last-of-type {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
margin-left: auto;
|
||||
margin-bottom: auto;
|
||||
padding-top: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 12px;
|
||||
font-family: sans-serif;
|
||||
margin-left: auto;
|
||||
}
|
||||
&:first-of-type {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&.active {
|
||||
color: white;
|
||||
background-color: black;
|
||||
}
|
||||
.season-name {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&.active {
|
||||
position: sticky;
|
||||
top: 10px;
|
||||
bottom: 10px;
|
||||
}
|
||||
}
|
||||
.season-name,
|
||||
.season-episodes {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
/* hack to get extra scroll space under last el */
|
||||
.last {
|
||||
padding: 1px;
|
||||
}
|
||||
}
|
||||
.season-date,
|
||||
.season-status {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.episodes-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
min-height: 40px;
|
||||
.season-episodes {
|
||||
color: $text-color-accent;
|
||||
}
|
||||
|
||||
div {
|
||||
transition: width 100ms ease;
|
||||
.season-status {
|
||||
fill: $text-color;
|
||||
|
||||
&:first-of-type {
|
||||
margin-left: auto;
|
||||
}
|
||||
:global(svg) {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
&.rating {
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
&.status {
|
||||
width: 45px;
|
||||
min-height: 40px;
|
||||
height: 40px;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
h2 {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 960px) {
|
||||
:global(body.nav-shown) ul.seasons {
|
||||
top: $nav-height;
|
||||
height: calc(100vh - $nav-height);
|
||||
}
|
||||
}
|
||||
h1,
|
||||
h2 {
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 960px) {
|
||||
.ctr {
|
||||
flex-flow: column;
|
||||
}
|
||||
&:hover,
|
||||
&.active {
|
||||
color: $bg-color;
|
||||
background-color: $text-color;
|
||||
|
||||
ul.seasons {
|
||||
flex-flow: row;
|
||||
flex-wrap: wrap;
|
||||
position: unset;
|
||||
height: unset;
|
||||
justify-content: center;
|
||||
.season-status {
|
||||
fill: $bg-color;
|
||||
}
|
||||
|
||||
button {
|
||||
&:first-of-type {
|
||||
margin-top: unset;
|
||||
}
|
||||
&.active {
|
||||
position: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.season-episodes {
|
||||
color: $bg-color-accent;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
position: sticky;
|
||||
top: 10px;
|
||||
bottom: 10px;
|
||||
|
||||
.season-status {
|
||||
fill: $bg-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* hack to get extra scroll space under last el */
|
||||
.last {
|
||||
padding: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.episodes-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
min-height: 40px;
|
||||
|
||||
div {
|
||||
transition: width 100ms ease;
|
||||
|
||||
&:first-of-type {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
&.rating {
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
&.status {
|
||||
width: 45px;
|
||||
min-height: 40px;
|
||||
height: 40px;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 960px) {
|
||||
:global(body.nav-shown) ul.seasons {
|
||||
top: $nav-height;
|
||||
height: calc(100vh - $nav-height);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 960px) {
|
||||
.ctr {
|
||||
flex-flow: column;
|
||||
}
|
||||
|
||||
:global(body.nav-shown) ul.seasons {
|
||||
top: $nav-height;
|
||||
}
|
||||
|
||||
ul.seasons {
|
||||
flex-flow: row;
|
||||
flex-wrap: nowrap;
|
||||
position: unset;
|
||||
height: unset;
|
||||
justify-content: unset;
|
||||
overflow: auto;
|
||||
min-width: unset;
|
||||
position: sticky;
|
||||
top: 0px;
|
||||
padding: 10px 0;
|
||||
z-index: 5;
|
||||
@include nav-blur;
|
||||
|
||||
button {
|
||||
&:first-of-type {
|
||||
margin-top: unset;
|
||||
}
|
||||
&.active {
|
||||
position: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,408 +1,423 @@
|
||||
<script lang="ts">
|
||||
import type {
|
||||
TMDBSeasonDetailsEpisode,
|
||||
WatchedStatus,
|
||||
WatchedEpisodeAddResponse,
|
||||
Watched
|
||||
} from "@/types";
|
||||
import Icon from "./Icon.svelte";
|
||||
import { userSettings } from "@/store";
|
||||
import PosterRating from "./poster/PosterRating.svelte";
|
||||
import PosterStatus from "./poster/PosterStatus.svelte";
|
||||
import axios from "axios";
|
||||
import { notify } from "./util/notify";
|
||||
import { get } from "svelte/store";
|
||||
import { watchedList } from "@/store";
|
||||
import type {
|
||||
TMDBSeasonDetailsEpisode,
|
||||
WatchedStatus,
|
||||
WatchedEpisodeAddResponse,
|
||||
Watched,
|
||||
} from "@/types";
|
||||
import Icon from "./Icon.svelte";
|
||||
import PosterRating from "./poster/PosterRating.svelte";
|
||||
import PosterStatus from "./poster/PosterStatus.svelte";
|
||||
import axios from "axios";
|
||||
import { notify } from "./util/notify";
|
||||
import { get } from "svelte/store";
|
||||
import { store } from "@/store.svelte";
|
||||
|
||||
$: settings = $userSettings;
|
||||
// let settings = $derived($userSettings);
|
||||
|
||||
export let ep: TMDBSeasonDetailsEpisode;
|
||||
export let watchedItem: Watched | undefined;
|
||||
interface Props {
|
||||
ep: TMDBSeasonDetailsEpisode;
|
||||
watchedItem: Watched | undefined;
|
||||
}
|
||||
|
||||
let isHidden = false;
|
||||
let { ep, watchedItem }: Props = $props();
|
||||
|
||||
$: {
|
||||
if (settings) isHidden = settings.hideSpoilers;
|
||||
}
|
||||
let isHidden: boolean = $state(!!store?.userSettings?.hideSpoilers);
|
||||
|
||||
function updateWatchedEpisode(status?: WatchedStatus, rating?: number) {
|
||||
if (!watchedItem) {
|
||||
console.error("SeasonListEpisode: updateWatchedEpisode: No watched item.");
|
||||
return;
|
||||
}
|
||||
const nid = notify({ text: `Saving`, type: "loading" });
|
||||
axios
|
||||
.post<WatchedEpisodeAddResponse>(`/watched/episode`, {
|
||||
watchedId: watchedItem.id,
|
||||
seasonNumber: ep.season_number,
|
||||
episodeNumber: ep.episode_number,
|
||||
status,
|
||||
rating
|
||||
})
|
||||
.then((r) => {
|
||||
const wList = get(watchedList);
|
||||
const wEntry = wList.find((w) => w.id === watchedItem.id);
|
||||
if (!wEntry) {
|
||||
notify({
|
||||
id: nid,
|
||||
text: `Request succeeded, but failed to find local data. Please refresh.`,
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (r.status === 200) {
|
||||
wEntry.watchedEpisodes = r.data.watchedEpisodes;
|
||||
if (wEntry.activity?.length > 0) {
|
||||
wEntry.activity.push(r.data.addedActivity);
|
||||
} else {
|
||||
wEntry.activity = [r.data.addedActivity];
|
||||
}
|
||||
try {
|
||||
const epHookResp = r?.data?.episodeStatusChangedHookResponse;
|
||||
if (epHookResp && Object.keys(epHookResp).length > 0) {
|
||||
if (epHookResp.errors && epHookResp.errors.length > 0) {
|
||||
console.error(
|
||||
"episodeStatusChangedHookResponse contained errors! All possible automations may not have been completed.",
|
||||
epHookResp.errors
|
||||
);
|
||||
notify({
|
||||
type: "error",
|
||||
text: "Some automations have failed, check console for more info."
|
||||
});
|
||||
}
|
||||
if (epHookResp.addedActivities && epHookResp.addedActivities.length > 0) {
|
||||
wEntry.activity.push(...epHookResp.addedActivities);
|
||||
}
|
||||
if (epHookResp.watchedSeason) {
|
||||
if (!wEntry.watchedSeasons) {
|
||||
wEntry.watchedSeasons = [epHookResp.watchedSeason];
|
||||
} else {
|
||||
const watchedSeasonIdx = wEntry.watchedSeasons.findIndex(
|
||||
(s) => s.id === epHookResp.watchedSeason?.id
|
||||
);
|
||||
if (watchedSeasonIdx === -1) {
|
||||
wEntry.watchedSeasons.push(epHookResp.watchedSeason);
|
||||
} else {
|
||||
wEntry.watchedSeasons[watchedSeasonIdx] = epHookResp.watchedSeason;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (epHookResp.newShowStatus) {
|
||||
wEntry.status = epHookResp.newShowStatus;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to process episodeStatusChangedHookResponse", err);
|
||||
notify({
|
||||
type: "error",
|
||||
text: "Failed to process automation response, check console for more info."
|
||||
});
|
||||
}
|
||||
watchedList.update((w) => w);
|
||||
notify({ id: nid, text: `Saved!`, type: "success" });
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
notify({ id: nid, text: "Failed To Update!", type: "error" });
|
||||
});
|
||||
}
|
||||
function updateWatchedEpisode(status?: WatchedStatus, rating?: number) {
|
||||
if (!watchedItem) {
|
||||
console.error(
|
||||
"SeasonListEpisode: updateWatchedEpisode: No watched item.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const nid = notify({ text: `Saving`, type: "loading" });
|
||||
axios
|
||||
.post<WatchedEpisodeAddResponse>(`/watched/episode`, {
|
||||
watchedId: watchedItem.id,
|
||||
seasonNumber: ep.season_number,
|
||||
episodeNumber: ep.episode_number,
|
||||
status,
|
||||
rating,
|
||||
})
|
||||
.then((r) => {
|
||||
const wEntry = store.watchedList.find((w) => w.id === watchedItem.id);
|
||||
if (!wEntry) {
|
||||
notify({
|
||||
id: nid,
|
||||
text: `Request succeeded, but failed to find local data. Please refresh.`,
|
||||
type: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (r.status === 200) {
|
||||
wEntry.watchedEpisodes = r.data.watchedEpisodes;
|
||||
if (wEntry.activity?.length > 0) {
|
||||
wEntry.activity.push(r.data.addedActivity);
|
||||
} else {
|
||||
wEntry.activity = [r.data.addedActivity];
|
||||
}
|
||||
try {
|
||||
const epHookResp = r?.data?.episodeStatusChangedHookResponse;
|
||||
if (epHookResp && Object.keys(epHookResp).length > 0) {
|
||||
if (epHookResp.errors && epHookResp.errors.length > 0) {
|
||||
console.error(
|
||||
"episodeStatusChangedHookResponse contained errors! All possible automations may not have been completed.",
|
||||
epHookResp.errors,
|
||||
);
|
||||
notify({
|
||||
type: "error",
|
||||
text: "Some automations have failed, check console for more info.",
|
||||
});
|
||||
}
|
||||
if (
|
||||
epHookResp.addedActivities &&
|
||||
epHookResp.addedActivities.length > 0
|
||||
) {
|
||||
wEntry.activity.push(...epHookResp.addedActivities);
|
||||
}
|
||||
if (epHookResp.watchedSeason) {
|
||||
if (!wEntry.watchedSeasons) {
|
||||
wEntry.watchedSeasons = [epHookResp.watchedSeason];
|
||||
} else {
|
||||
const watchedSeasonIdx = wEntry.watchedSeasons.findIndex(
|
||||
(s) => s.id === epHookResp.watchedSeason?.id,
|
||||
);
|
||||
if (watchedSeasonIdx === -1) {
|
||||
wEntry.watchedSeasons.push(epHookResp.watchedSeason);
|
||||
} else {
|
||||
wEntry.watchedSeasons[watchedSeasonIdx] =
|
||||
epHookResp.watchedSeason;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (epHookResp.newShowStatus) {
|
||||
wEntry.status = epHookResp.newShowStatus;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"Failed to process episodeStatusChangedHookResponse",
|
||||
err,
|
||||
);
|
||||
notify({
|
||||
type: "error",
|
||||
text: "Failed to process automation response, check console for more info.",
|
||||
});
|
||||
}
|
||||
notify({ id: nid, text: `Saved!`, type: "success" });
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
notify({ id: nid, text: "Failed To Update!", type: "error" });
|
||||
});
|
||||
}
|
||||
|
||||
function handleStatusClick(type: WatchedStatus | "DELETE") {
|
||||
if (!watchedItem) {
|
||||
console.error("SeasonListEpisode: handleStatusClick: No watched item.");
|
||||
return;
|
||||
}
|
||||
if (type === "DELETE") {
|
||||
const ws = watchedItem.watchedEpisodes?.find(
|
||||
(s) => s.seasonNumber === ep.season_number && s.episodeNumber === ep.episode_number
|
||||
);
|
||||
if (!ws) {
|
||||
notify({
|
||||
text: "Failed to find watched episode id. Please try refreshing.",
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
const nid = notify({ text: `Saving`, type: "loading" });
|
||||
axios
|
||||
.delete(`/watched/episode/${ws.id}`)
|
||||
.then((r) => {
|
||||
const wList = get(watchedList);
|
||||
const wEntry = wList.find((w) => w.id === watchedItem.id);
|
||||
if (!wEntry) {
|
||||
notify({
|
||||
id: nid,
|
||||
text: `Request succeeded, but failed to find local data. Please refresh.`,
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (r.status === 200) {
|
||||
wEntry.watchedEpisodes = wEntry.watchedEpisodes?.filter((s) => s.id !== ws.id);
|
||||
if (r.data) {
|
||||
if (wEntry.activity?.length > 0) {
|
||||
wEntry.activity.push(r.data);
|
||||
} else {
|
||||
wEntry.activity = [r.data];
|
||||
}
|
||||
}
|
||||
watchedList.update((w) => w);
|
||||
notify({ id: nid, text: `Removed!`, type: "success" });
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
notify({ id: nid, text: "Failed To Remove!", type: "error" });
|
||||
});
|
||||
return;
|
||||
}
|
||||
updateWatchedEpisode(type);
|
||||
}
|
||||
function handleStatusClick(type: WatchedStatus | "DELETE") {
|
||||
if (!watchedItem) {
|
||||
console.error("SeasonListEpisode: handleStatusClick: No watched item.");
|
||||
return;
|
||||
}
|
||||
if (type === "DELETE") {
|
||||
const ws = watchedItem.watchedEpisodes?.find(
|
||||
(s) =>
|
||||
s.seasonNumber === ep.season_number &&
|
||||
s.episodeNumber === ep.episode_number,
|
||||
);
|
||||
if (!ws) {
|
||||
notify({
|
||||
text: "Failed to find watched episode id. Please try refreshing.",
|
||||
type: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const nid = notify({ text: `Saving`, type: "loading" });
|
||||
axios
|
||||
.delete(`/watched/episode/${ws.id}`)
|
||||
.then((r) => {
|
||||
const wEntry = store.watchedList.find((w) => w.id === watchedItem.id);
|
||||
if (!wEntry) {
|
||||
notify({
|
||||
id: nid,
|
||||
text: `Request succeeded, but failed to find local data. Please refresh.`,
|
||||
type: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (r.status === 200) {
|
||||
wEntry.watchedEpisodes = wEntry.watchedEpisodes?.filter(
|
||||
(s) => s.id !== ws.id,
|
||||
);
|
||||
if (r.data) {
|
||||
if (wEntry.activity?.length > 0) {
|
||||
wEntry.activity.push(r.data);
|
||||
} else {
|
||||
wEntry.activity = [r.data];
|
||||
}
|
||||
}
|
||||
notify({ id: nid, text: `Removed!`, type: "success" });
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
notify({ id: nid, text: "Failed To Remove!", type: "error" });
|
||||
});
|
||||
return;
|
||||
}
|
||||
updateWatchedEpisode(type);
|
||||
}
|
||||
|
||||
function handleStarClick(rating: number) {
|
||||
updateWatchedEpisode(undefined, rating);
|
||||
}
|
||||
function handleStarClick(rating: number) {
|
||||
updateWatchedEpisode(undefined, rating);
|
||||
}
|
||||
</script>
|
||||
|
||||
<li class={isHidden ? "dont-spoil" : ""}>
|
||||
{#if ep.still_path}
|
||||
<img src={`https://www.themoviedb.org/t/p/w227_and_h127_bestv2/${ep.still_path}`} alt="" />
|
||||
{:else}
|
||||
<div class="no-still" />
|
||||
{/if}
|
||||
<div class="info">
|
||||
<div>
|
||||
<span>
|
||||
<b>{ep.episode_number}</b>
|
||||
<span class="episode-name">{ep.name}</span>
|
||||
{#if ep.runtime}
|
||||
<span class="episode-runtime" title="This episode has a runtime of {ep.runtime} minutes."
|
||||
>{ep.runtime} min</span
|
||||
>
|
||||
{/if}
|
||||
</span>
|
||||
<span
|
||||
class="rating"
|
||||
title={`TMDB Rating: ${ep.vote_average} out of 10 (based on ${ep.vote_count} votes)`}
|
||||
>
|
||||
<span>*</span>
|
||||
{Math.round(ep.vote_average * 10) / 10}
|
||||
</span>
|
||||
</div>
|
||||
<span class="overview">{ep.overview}</span>
|
||||
</div>
|
||||
{#if watchedItem}
|
||||
{@const we = watchedItem.watchedEpisodes?.find(
|
||||
(s) => s.seasonNumber === ep.season_number && s.episodeNumber === ep.episode_number
|
||||
)}
|
||||
<div class="status-rating-ctr">
|
||||
<div class="rating" style={"width: 45px"}>
|
||||
<PosterRating
|
||||
rating={we?.rating}
|
||||
btnTooltip={`Episode ${ep.episode_number} Rating`}
|
||||
handleStarClick={(r) => handleStarClick(r)}
|
||||
minimal={true}
|
||||
direction="bot"
|
||||
hideStarWhenRated
|
||||
/>
|
||||
</div>
|
||||
<div class="status">
|
||||
<PosterStatus
|
||||
status={we?.status}
|
||||
btnTooltip={`Episode ${ep.episode_number} Status`}
|
||||
handleStatusClick={(t) => handleStatusClick(t)}
|
||||
direction="bot"
|
||||
width="100%"
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if isHidden}
|
||||
<button class="plain spoiler-text" on:click={() => (isHidden = false)}>
|
||||
<Icon i="eye-closed" wh={34} />
|
||||
<span>Click To Reveal</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if ep.still_path}
|
||||
<img
|
||||
src={`https://www.themoviedb.org/t/p/w227_and_h127_bestv2/${ep.still_path}`}
|
||||
alt=""
|
||||
/>
|
||||
{:else}
|
||||
<div class="no-still"></div>
|
||||
{/if}
|
||||
<div class="info">
|
||||
<div>
|
||||
<span>
|
||||
<b>{ep.episode_number}</b>
|
||||
<span class="episode-name">{ep.name}</span>
|
||||
{#if ep.runtime}
|
||||
<span
|
||||
class="episode-runtime"
|
||||
title="This episode has a runtime of {ep.runtime} minutes."
|
||||
>{ep.runtime} min</span
|
||||
>
|
||||
{/if}
|
||||
</span>
|
||||
<span
|
||||
class="rating"
|
||||
title={`TMDB Rating: ${ep.vote_average} out of 10 (based on ${ep.vote_count} votes)`}
|
||||
>
|
||||
<span>*</span>
|
||||
{Math.round(ep.vote_average * 10) / 10}
|
||||
</span>
|
||||
</div>
|
||||
<span class="overview">{ep.overview}</span>
|
||||
</div>
|
||||
{#if watchedItem}
|
||||
{@const we = watchedItem.watchedEpisodes?.find(
|
||||
(s) =>
|
||||
s.seasonNumber === ep.season_number &&
|
||||
s.episodeNumber === ep.episode_number,
|
||||
)}
|
||||
<div class="status-rating-ctr">
|
||||
<div class="rating" style={"width: 45px"}>
|
||||
<PosterRating
|
||||
rating={we?.rating}
|
||||
btnTooltip={`Episode ${ep.episode_number} Rating`}
|
||||
handleStarClick={(r) => handleStarClick(r)}
|
||||
minimal={true}
|
||||
direction="bot"
|
||||
hideStarWhenRated
|
||||
/>
|
||||
</div>
|
||||
<div class="status">
|
||||
<PosterStatus
|
||||
status={we?.status}
|
||||
btnTooltip={`Episode ${ep.episode_number} Status`}
|
||||
handleStatusClick={(t) => handleStatusClick(t)}
|
||||
direction="bot"
|
||||
width="100%"
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if isHidden}
|
||||
<button class="plain spoiler-text" onclick={() => (isHidden = false)}>
|
||||
<Icon i="eye-closed" wh={34} />
|
||||
<span>Click To Reveal</span>
|
||||
</button>
|
||||
{/if}
|
||||
</li>
|
||||
|
||||
<style lang="scss">
|
||||
li {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
li {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
|
||||
img,
|
||||
.no-still {
|
||||
width: 227px;
|
||||
min-width: 227px;
|
||||
height: 127px;
|
||||
min-height: 127px;
|
||||
border-radius: 10px;
|
||||
background-color: rgb(0, 0, 0);
|
||||
object-fit: fill;
|
||||
img,
|
||||
.no-still {
|
||||
width: 227px;
|
||||
min-width: 227px;
|
||||
height: 127px;
|
||||
min-height: 127px;
|
||||
border-radius: 10px;
|
||||
background-color: rgb(0, 0, 0);
|
||||
object-fit: fill;
|
||||
|
||||
@media screen and (max-width: 590px) {
|
||||
width: 80%;
|
||||
height: auto;
|
||||
}
|
||||
@media screen and (max-width: 590px) {
|
||||
width: 80%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 450px) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@media screen and (max-width: 450px) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.info {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
.info {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
|
||||
& > div {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
align-items: center;
|
||||
& > div {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
align-items: center;
|
||||
|
||||
.episode-name,
|
||||
.episode-runtime {
|
||||
text-transform: lowercase;
|
||||
font-variant: small-caps;
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
}
|
||||
.episode-name,
|
||||
.episode-runtime {
|
||||
text-transform: lowercase;
|
||||
font-variant: small-caps;
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.episode-runtime {
|
||||
font-size: 14px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.episode-runtime {
|
||||
font-size: 14px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.rating {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: center;
|
||||
font-size: 15px;
|
||||
color: $rating-color;
|
||||
font-weight: bolder;
|
||||
overflow: hidden;
|
||||
.rating {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: center;
|
||||
font-size: 15px;
|
||||
color: $rating-color;
|
||||
font-weight: bolder;
|
||||
overflow: hidden;
|
||||
|
||||
span {
|
||||
margin-top: 2px;
|
||||
font-family: "Rampart One";
|
||||
-webkit-text-stroke: 1px $rating-color;
|
||||
font-size: 25px;
|
||||
line-height: 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
span {
|
||||
margin-top: 2px;
|
||||
font-family: "Rampart One";
|
||||
-webkit-text-stroke: 1px $rating-color;
|
||||
font-size: 25px;
|
||||
line-height: 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.status-rating-ctr {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-flow: column-reverse;
|
||||
gap: 10px;
|
||||
margin-bottom: auto;
|
||||
min-height: 40px;
|
||||
margin-left: auto;
|
||||
.status-rating-ctr {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-flow: column-reverse;
|
||||
gap: 10px;
|
||||
margin-bottom: auto;
|
||||
min-height: 40px;
|
||||
margin-left: auto;
|
||||
|
||||
div {
|
||||
transition: width 100ms ease;
|
||||
div {
|
||||
transition: width 100ms ease;
|
||||
|
||||
&:first-of-type {
|
||||
margin-left: auto;
|
||||
}
|
||||
&:first-of-type {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
&.rating {
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
}
|
||||
&.rating {
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
&.status {
|
||||
width: 45px;
|
||||
min-height: 40px;
|
||||
height: 40px;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
&.status {
|
||||
width: 45px;
|
||||
min-height: 40px;
|
||||
height: 40px;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
padding: 3px 5px;
|
||||
span {
|
||||
padding: 3px 5px;
|
||||
|
||||
@media screen and (max-width: 590px) {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
@media screen and (max-width: 590px) {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.spoiler-text {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-weight: bolder;
|
||||
font-size: 20px;
|
||||
fill: $text-color;
|
||||
opacity: 0;
|
||||
transition:
|
||||
visibility 150ms ease-in,
|
||||
opacity 150ms ease-in;
|
||||
cursor: pointer;
|
||||
.spoiler-text {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-weight: bolder;
|
||||
font-size: 20px;
|
||||
fill: $text-color;
|
||||
opacity: 0;
|
||||
transition:
|
||||
visibility 150ms ease-in,
|
||||
opacity 150ms ease-in;
|
||||
cursor: pointer;
|
||||
|
||||
span {
|
||||
text-shadow: 0 0 6px $bg-color;
|
||||
}
|
||||
span {
|
||||
text-shadow: 0 0 6px $bg-color;
|
||||
}
|
||||
|
||||
:global(svg) {
|
||||
filter: drop-shadow(0 0 8px $bg-color);
|
||||
}
|
||||
:global(svg) {
|
||||
filter: drop-shadow(0 0 8px $bg-color);
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
img,
|
||||
.episode-name,
|
||||
.rating,
|
||||
.overview {
|
||||
transition: filter 150ms ease-out;
|
||||
}
|
||||
img,
|
||||
.episode-name,
|
||||
.rating,
|
||||
.overview {
|
||||
transition: filter 150ms ease-out;
|
||||
}
|
||||
|
||||
&.dont-spoil {
|
||||
.episode-name,
|
||||
.rating,
|
||||
.overview {
|
||||
filter: blur(4px);
|
||||
}
|
||||
&.dont-spoil {
|
||||
.episode-name,
|
||||
.rating,
|
||||
.overview {
|
||||
filter: blur(4px);
|
||||
}
|
||||
|
||||
img {
|
||||
filter: blur(6px);
|
||||
}
|
||||
}
|
||||
}
|
||||
img {
|
||||
filter: blur(6px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 590px) {
|
||||
li {
|
||||
align-items: center;
|
||||
flex-flow: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@media screen and (max-width: 590px) {
|
||||
li {
|
||||
align-items: center;
|
||||
flex-flow: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.status-rating-ctr {
|
||||
flex-flow: row;
|
||||
justify-content: center;
|
||||
margin-left: unset;
|
||||
}
|
||||
}
|
||||
.status-rating-ctr {
|
||||
flex-flow: row;
|
||||
justify-content: center;
|
||||
margin-left: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.rating {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
.rating {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,38 +1,41 @@
|
||||
<div class="container">
|
||||
<div class="spinner" />
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 80px;
|
||||
aspect-ratio: 1;
|
||||
border: 10px solid $bg-color;
|
||||
background:
|
||||
radial-gradient(farthest-side, $text-color 98%, $bg-color) 0 0/20px 20px,
|
||||
radial-gradient(farthest-side, $text-color 98%, $bg-color) 100% 0/20px 20px,
|
||||
radial-gradient(farthest-side, $text-color 98%, $bg-color) 100% 100%/20px 20px,
|
||||
radial-gradient(farthest-side, $text-color 98%, $bg-color) 0 100%/20px 20px,
|
||||
linear-gradient($text-color 0 0) center/40px 40px,
|
||||
$bg-color;
|
||||
background-repeat: no-repeat;
|
||||
filter: blur(4px) contrast(10);
|
||||
animation: spin 800ms infinite;
|
||||
}
|
||||
.spinner {
|
||||
width: 80px;
|
||||
aspect-ratio: 1;
|
||||
border: 10px solid $bg-color;
|
||||
background:
|
||||
radial-gradient(farthest-side, $text-color 98%, $bg-color) 0 0/20px 20px,
|
||||
radial-gradient(farthest-side, $text-color 98%, $bg-color) 100% 0/20px
|
||||
20px,
|
||||
radial-gradient(farthest-side, $text-color 98%, $bg-color) 100% 100%/20px
|
||||
20px,
|
||||
radial-gradient(farthest-side, $text-color 98%, $bg-color) 0 100%/20px
|
||||
20px,
|
||||
linear-gradient($text-color 0 0) center/40px 40px,
|
||||
$bg-color;
|
||||
background-repeat: no-repeat;
|
||||
filter: blur(4px) contrast(10);
|
||||
animation: spin 800ms infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
100% {
|
||||
background-position:
|
||||
100% 0,
|
||||
100% 100%,
|
||||
0 100%,
|
||||
0 0,
|
||||
center;
|
||||
}
|
||||
}
|
||||
@keyframes spin {
|
||||
100% {
|
||||
background-position:
|
||||
100% 0,
|
||||
100% 100%,
|
||||
0 100%,
|
||||
0 0,
|
||||
center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,27 +1,31 @@
|
||||
<script lang="ts">
|
||||
export let style: string = "";
|
||||
interface Props {
|
||||
style?: string;
|
||||
}
|
||||
|
||||
let { style = "" }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="loader" {style}></div>
|
||||
|
||||
<style lang="scss">
|
||||
.loader {
|
||||
width: 16px;
|
||||
height: 12px;
|
||||
border: 2px solid #000;
|
||||
border-bottom-color: transparent;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
animation: rotation 1s linear infinite;
|
||||
}
|
||||
.loader {
|
||||
width: 16px;
|
||||
height: 12px;
|
||||
border: 2px solid #000;
|
||||
border-bottom-color: transparent;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
animation: rotation 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotation {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@keyframes rotation {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,68 +1,87 @@
|
||||
<script lang="ts">
|
||||
import Icon from "./Icon.svelte";
|
||||
import type { WatchedStatus } from "../types";
|
||||
import tooltip from "./actions/tooltip";
|
||||
import { toUnderstandableStatus } from "./util/helpers";
|
||||
import Icon from "./Icon.svelte";
|
||||
import type { WatchedStatus } from "../types";
|
||||
import tooltip from "./actions/tooltip";
|
||||
import { toUnderstandableStatus } from "./util/helpers";
|
||||
|
||||
export let status: WatchedStatus | undefined;
|
||||
export let isForGame: boolean = false;
|
||||
export let onChange: (newStatus: WatchedStatus) => void;
|
||||
interface Props {
|
||||
status: WatchedStatus | undefined;
|
||||
isForGame?: boolean;
|
||||
onChange: (newStatus: WatchedStatus) => void;
|
||||
}
|
||||
|
||||
function handleStatusClick(s: WatchedStatus) {
|
||||
if (s === status) return;
|
||||
onChange(s);
|
||||
}
|
||||
let { status, isForGame = false, onChange }: Props = $props();
|
||||
|
||||
function handleStatusClick(s: WatchedStatus) {
|
||||
if (s === status) return;
|
||||
onChange(s);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="status">
|
||||
<button
|
||||
class={status && status !== "PLANNED" ? "not-active" : ""}
|
||||
on:click={() => handleStatusClick("PLANNED")}
|
||||
use:tooltip={{ text: toUnderstandableStatus("PLANNED", isForGame), pos: "top" }}
|
||||
>
|
||||
<Icon i="calendar" />
|
||||
</button>
|
||||
<button
|
||||
class={status && status !== "WATCHING" ? "not-active" : ""}
|
||||
on:click={() => handleStatusClick("WATCHING")}
|
||||
use:tooltip={{ text: toUnderstandableStatus("WATCHING", isForGame), pos: "top" }}
|
||||
>
|
||||
<Icon i="clock" />
|
||||
</button>
|
||||
<button
|
||||
class={status && status !== "FINISHED" ? "not-active" : ""}
|
||||
on:click={() => handleStatusClick("FINISHED")}
|
||||
use:tooltip={{ text: toUnderstandableStatus("FINISHED", isForGame), pos: "top" }}
|
||||
>
|
||||
<Icon i="check" />
|
||||
</button>
|
||||
<button
|
||||
class={status && status !== "HOLD" ? "not-active" : ""}
|
||||
on:click={() => handleStatusClick("HOLD")}
|
||||
use:tooltip={{ text: toUnderstandableStatus("HOLD", isForGame), pos: "top" }}
|
||||
>
|
||||
<Icon i="pause" />
|
||||
</button>
|
||||
<button
|
||||
class={status && status !== "DROPPED" ? "not-active" : ""}
|
||||
on:click={() => handleStatusClick("DROPPED")}
|
||||
use:tooltip={{ text: toUnderstandableStatus("DROPPED", isForGame), pos: "top" }}
|
||||
>
|
||||
<Icon i="thumb-down" />
|
||||
</button>
|
||||
<button
|
||||
class={status && status !== "PLANNED" ? "not-active" : ""}
|
||||
onclick={() => handleStatusClick("PLANNED")}
|
||||
use:tooltip={{
|
||||
text: toUnderstandableStatus("PLANNED", isForGame),
|
||||
pos: "top",
|
||||
}}
|
||||
>
|
||||
<Icon i="calendar" />
|
||||
</button>
|
||||
<button
|
||||
class={status && status !== "WATCHING" ? "not-active" : ""}
|
||||
onclick={() => handleStatusClick("WATCHING")}
|
||||
use:tooltip={{
|
||||
text: toUnderstandableStatus("WATCHING", isForGame),
|
||||
pos: "top",
|
||||
}}
|
||||
>
|
||||
<Icon i="clock" />
|
||||
</button>
|
||||
<button
|
||||
class={status && status !== "FINISHED" ? "not-active" : ""}
|
||||
onclick={() => handleStatusClick("FINISHED")}
|
||||
use:tooltip={{
|
||||
text: toUnderstandableStatus("FINISHED", isForGame),
|
||||
pos: "top",
|
||||
}}
|
||||
>
|
||||
<Icon i="check" />
|
||||
</button>
|
||||
<button
|
||||
class={status && status !== "HOLD" ? "not-active" : ""}
|
||||
onclick={() => handleStatusClick("HOLD")}
|
||||
use:tooltip={{
|
||||
text: toUnderstandableStatus("HOLD", isForGame),
|
||||
pos: "top",
|
||||
}}
|
||||
>
|
||||
<Icon i="pause" />
|
||||
</button>
|
||||
<button
|
||||
class={status && status !== "DROPPED" ? "not-active" : ""}
|
||||
onclick={() => handleStatusClick("DROPPED")}
|
||||
use:tooltip={{
|
||||
text: toUnderstandableStatus("DROPPED", isForGame),
|
||||
pos: "top",
|
||||
}}
|
||||
>
|
||||
<Icon i="thumb-down" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.status {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
.status {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
button {
|
||||
font-size: 10px;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
}
|
||||
button {
|
||||
font-size: 10px;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,66 +1,70 @@
|
||||
<script lang="ts">
|
||||
import type { PublicUser } from "@/types";
|
||||
import type { PublicUser } from "@/types";
|
||||
|
||||
export let users: PublicUser[];
|
||||
interface Props {
|
||||
users: PublicUser[];
|
||||
}
|
||||
|
||||
let { users }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<h2>Users</h2>
|
||||
<ul>
|
||||
{#each users as user}
|
||||
<li title={user.username}>
|
||||
<a href="/lists/{user.id}/{user.username}">
|
||||
<span>{user.username}</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<h2>Users</h2>
|
||||
<ul>
|
||||
{#each users as user}
|
||||
<li title={user.username}>
|
||||
<a href="/lists/{user.id}/{user.username}">
|
||||
<span>{user.username}</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
div {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
margin: 5px 15px;
|
||||
}
|
||||
div {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
margin: 5px 15px;
|
||||
}
|
||||
|
||||
ul {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 10px;
|
||||
list-style: none;
|
||||
overflow: auto;
|
||||
padding: 15px;
|
||||
ul {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 10px;
|
||||
list-style: none;
|
||||
overflow: auto;
|
||||
padding: 15px;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
max-width: 200px;
|
||||
li {
|
||||
display: flex;
|
||||
max-width: 200px;
|
||||
|
||||
a {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 3px 8px;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
background-color: $accent-color;
|
||||
transition:
|
||||
background-color 150ms ease,
|
||||
color 150ms ease;
|
||||
a {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 3px 8px;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
background-color: $accent-color;
|
||||
transition:
|
||||
background-color 150ms ease,
|
||||
color 150ms ease;
|
||||
|
||||
span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: $bg-color;
|
||||
background-color: $accent-color-hover;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
color: $bg-color;
|
||||
background-color: $accent-color-hover;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,264 +1,328 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import Icon from "@/lib/Icon.svelte";
|
||||
import Poster from "@/lib/poster/Poster.svelte";
|
||||
import PosterList from "@/lib/poster/PosterList.svelte";
|
||||
import {
|
||||
activeFilters,
|
||||
activeSort,
|
||||
clearActiveFilters,
|
||||
serverFeatures,
|
||||
userSettings
|
||||
} from "@/store";
|
||||
import type { Watched } from "@/types";
|
||||
import GamePoster from "./poster/GamePoster.svelte";
|
||||
import { get } from "svelte/store";
|
||||
import { getLatestWatchedInTv } from "./util/helpers";
|
||||
import { notify } from "./util/notify";
|
||||
import { goto } from "$app/navigation";
|
||||
import Icon from "@/lib/Icon.svelte";
|
||||
import Poster from "@/lib/poster/Poster.svelte";
|
||||
import PosterList from "@/lib/poster/PosterList.svelte";
|
||||
import { store, clearActiveFilters } from "@/store.svelte";
|
||||
import type { Watched } from "@/types";
|
||||
import GamePoster from "./poster/GamePoster.svelte";
|
||||
import { getLatestWatchedInTv } from "./util/helpers";
|
||||
import { notify } from "./util/notify";
|
||||
import { untrack } from "svelte";
|
||||
|
||||
export let list: Watched[];
|
||||
export let isPublicList: boolean = false;
|
||||
interface Props {
|
||||
list: Watched[];
|
||||
isPublicList?: boolean;
|
||||
}
|
||||
|
||||
$: sort = $activeSort;
|
||||
$: filters = $activeFilters;
|
||||
$: watched = list;
|
||||
$: settings = $userSettings;
|
||||
$: features = $serverFeatures;
|
||||
let { list, isPublicList = false }: Props = $props();
|
||||
|
||||
/**
|
||||
* Checks if content has been watched previously
|
||||
* by analyzing the watched entrys activity (with
|
||||
* the latest AI improvements added in of course.)
|
||||
*/
|
||||
function contentWatchedPreviously(w: Watched) {
|
||||
let wp = false;
|
||||
const relatedActivity = w.activity.filter(
|
||||
(a) =>
|
||||
a.type === "ADDED_WATCHED" ||
|
||||
a.type === "IMPORTED_ADDED_WATCHED" ||
|
||||
a.type === "IMPORTED_WATCHED" ||
|
||||
a.type === "STATUS_CHANGED"
|
||||
);
|
||||
for (let i = 0; i < relatedActivity.length; i++) {
|
||||
const ra = relatedActivity[i];
|
||||
if (ra.type === "IMPORTED_ADDED_WATCHED") {
|
||||
wp = true;
|
||||
break;
|
||||
} else if (ra.type === "ADDED_WATCHED" || ra.type === "IMPORTED_WATCHED") {
|
||||
const data = JSON.parse(ra.data);
|
||||
if (data?.status == "FINISHED") {
|
||||
wp = true;
|
||||
break;
|
||||
}
|
||||
} else if (ra.type === "STATUS_CHANGED") {
|
||||
if (ra.data === "FINISHED") {
|
||||
wp = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return wp;
|
||||
}
|
||||
let sort = $derived(store.activeSort);
|
||||
let filters = $derived(store.activeFilters);
|
||||
let settings = $derived(store.userSettings);
|
||||
let watched: Watched[] = $state([]);
|
||||
|
||||
// Monsterous code for filters. Soz.
|
||||
$: (watched, filters, sort), filt();
|
||||
$effect(() => {
|
||||
if (list) {
|
||||
watched = list;
|
||||
}
|
||||
});
|
||||
|
||||
function filt() {
|
||||
try {
|
||||
// Set watched to list and sort it.
|
||||
watched = list
|
||||
.sort((a, b) => {
|
||||
if (sort[0] === "DATEADDED" && sort[1] === "UP") {
|
||||
return Date.parse(a.createdAt) - Date.parse(b.createdAt);
|
||||
} else if (sort[0] === "ALPHA") {
|
||||
const atitle = a.content ? a.content.title : a.game ? a.game.name : "";
|
||||
const btitle = b.content ? b.content.title : b.game ? b.game.name : "";
|
||||
if (sort[1] === "UP") {
|
||||
return atitle.localeCompare(btitle);
|
||||
} else if (sort[1] === "DOWN") {
|
||||
return btitle.localeCompare(atitle);
|
||||
}
|
||||
} else if (sort[0] === "LASTCHANGED") {
|
||||
if (sort[1] === "UP") return Date.parse(a.updatedAt) - Date.parse(b.updatedAt);
|
||||
else if (sort[1] === "DOWN") return Date.parse(b.updatedAt) - Date.parse(a.updatedAt);
|
||||
} else if (sort[0] === "LASTFIN") {
|
||||
const aLastFinishActivity = a.activity
|
||||
?.sort(
|
||||
(aa, bb) =>
|
||||
Date.parse(bb.customDate ?? bb.updatedAt) -
|
||||
Date.parse(aa.customDate ?? aa.updatedAt)
|
||||
)
|
||||
?.find(
|
||||
(aa) =>
|
||||
(aa.type === "STATUS_CHANGED" && aa.data === "FINISHED") ||
|
||||
(aa.type === "ADDED_WATCHED" && aa.data?.includes("FINISHED"))
|
||||
);
|
||||
const bLastFinishActivity = b.activity
|
||||
?.sort(
|
||||
(aa, bb) =>
|
||||
Date.parse(bb.customDate ?? bb.updatedAt) -
|
||||
Date.parse(aa.customDate ?? aa.updatedAt)
|
||||
)
|
||||
?.find(
|
||||
(aa) =>
|
||||
(aa.type === "STATUS_CHANGED" && aa.data === "FINISHED") ||
|
||||
(aa.type === "ADDED_WATCHED" && aa.data?.includes("FINISHED"))
|
||||
);
|
||||
if (!aLastFinishActivity) return 1;
|
||||
if (!bLastFinishActivity) return -1;
|
||||
const alfaDate = aLastFinishActivity.customDate ?? aLastFinishActivity.updatedAt;
|
||||
const blfaDate = bLastFinishActivity.customDate ?? bLastFinishActivity.updatedAt;
|
||||
if (sort[1] === "UP") return Date.parse(alfaDate) - Date.parse(blfaDate);
|
||||
else if (sort[1] === "DOWN") return Date.parse(blfaDate) - Date.parse(alfaDate);
|
||||
} else if (sort[0] === "RATING") {
|
||||
if (sort[1] === "UP") return (a.rating ?? 0) - (b.rating ?? 0);
|
||||
else if (sort[1] === "DOWN") return (b.rating ?? 0) - (a.rating ?? 0);
|
||||
}
|
||||
// default DATEADDED DOWN
|
||||
return Date.parse(b.createdAt) - Date.parse(a.createdAt);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.pinned && !b.pinned) return -1;
|
||||
if (!a.pinned && b.pinned) return 1;
|
||||
return 0;
|
||||
});
|
||||
// If games type filter enabled, but games disabled on server, make sure we remove it from active filters.
|
||||
if (!features.games) {
|
||||
const af = get(activeFilters);
|
||||
af.type = af.type?.filter((a) => a !== "game");
|
||||
filters.type = filters.type.filter((f) => f !== "game");
|
||||
}
|
||||
// Now apply filters to watch list.
|
||||
if (filters.status.length > 0 && filters.type.length > 0) {
|
||||
// If status and type filters applied, combine both.
|
||||
if (settings?.includePreviouslyWatched && filters.status.includes("finished")) {
|
||||
watched = watched.filter(
|
||||
(w) =>
|
||||
(filters.status.includes(w.status?.toLowerCase()) || contentWatchedPreviously(w)) &&
|
||||
filters.type.includes(w.content ? w.content.type : w.game ? "game" : "")
|
||||
);
|
||||
} else {
|
||||
watched = watched.filter(
|
||||
(w) =>
|
||||
filters.status.includes(w.status?.toLowerCase()) &&
|
||||
filters.type.includes(w.content ? w.content.type : w.game ? "game" : "")
|
||||
);
|
||||
}
|
||||
} else if (filters.type.length > 0) {
|
||||
// Only filter type
|
||||
watched = watched.filter((w) =>
|
||||
filters.type.includes(w.content ? w.content.type : w.game ? "game" : "")
|
||||
);
|
||||
} else if (filters.status.length > 0) {
|
||||
// Only filter status
|
||||
if (settings?.includePreviouslyWatched && filters.status.includes("finished")) {
|
||||
watched = watched.filter(
|
||||
(w) => filters.status.includes(w.status?.toLowerCase()) || contentWatchedPreviously(w)
|
||||
);
|
||||
} else {
|
||||
watched = watched.filter((w) => filters.status.includes(w.status?.toLowerCase()));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("filt: Failed to filter/sort current list!", err);
|
||||
notify({ text: "Failed to filter/sort list!", type: "error", time: 6000 });
|
||||
}
|
||||
}
|
||||
$effect(() => {
|
||||
if (list && filters.status && filters.type && sort) {
|
||||
untrack(() => {
|
||||
filt();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Checks if content has been watched previously
|
||||
* by analyzing the watched entrys activity (with
|
||||
* the latest AI improvements added in of course.)
|
||||
*/
|
||||
function contentWatchedPreviously(w: Watched) {
|
||||
let wp = false;
|
||||
const relatedActivity = w.activity.filter(
|
||||
(a) =>
|
||||
a.type === "ADDED_WATCHED" ||
|
||||
a.type === "IMPORTED_ADDED_WATCHED" ||
|
||||
a.type === "IMPORTED_WATCHED" ||
|
||||
a.type === "STATUS_CHANGED",
|
||||
);
|
||||
for (let i = 0; i < relatedActivity.length; i++) {
|
||||
const ra = relatedActivity[i];
|
||||
if (ra.type === "IMPORTED_ADDED_WATCHED") {
|
||||
wp = true;
|
||||
break;
|
||||
} else if (
|
||||
ra.type === "ADDED_WATCHED" ||
|
||||
ra.type === "IMPORTED_WATCHED"
|
||||
) {
|
||||
const data = JSON.parse(ra.data);
|
||||
if (data?.status == "FINISHED") {
|
||||
wp = true;
|
||||
break;
|
||||
}
|
||||
} else if (ra.type === "STATUS_CHANGED") {
|
||||
if (ra.data === "FINISHED") {
|
||||
wp = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return wp;
|
||||
}
|
||||
|
||||
// Monsterous code for filters. Soz.
|
||||
function filt() {
|
||||
console.debug("WatchedList: filt()");
|
||||
try {
|
||||
// Set watched to list and sort it.
|
||||
watched = list
|
||||
.sort((a, b) => {
|
||||
if (sort[0] === "DATEADDED" && sort[1] === "UP") {
|
||||
return Date.parse(a.createdAt) - Date.parse(b.createdAt);
|
||||
} else if (sort[0] === "ALPHA") {
|
||||
const atitle = a.content
|
||||
? a.content.title
|
||||
: a.game
|
||||
? a.game.name
|
||||
: "";
|
||||
const btitle = b.content
|
||||
? b.content.title
|
||||
: b.game
|
||||
? b.game.name
|
||||
: "";
|
||||
if (sort[1] === "UP") {
|
||||
return atitle.localeCompare(btitle);
|
||||
} else if (sort[1] === "DOWN") {
|
||||
return btitle.localeCompare(atitle);
|
||||
}
|
||||
} else if (sort[0] === "LASTCHANGED") {
|
||||
if (sort[1] === "UP")
|
||||
return Date.parse(a.updatedAt) - Date.parse(b.updatedAt);
|
||||
else if (sort[1] === "DOWN")
|
||||
return Date.parse(b.updatedAt) - Date.parse(a.updatedAt);
|
||||
} else if (sort[0] === "LASTFIN") {
|
||||
const aLastFinishActivity = a.activity
|
||||
?.sort(
|
||||
(aa, bb) =>
|
||||
Date.parse(bb.customDate ?? bb.updatedAt) -
|
||||
Date.parse(aa.customDate ?? aa.updatedAt),
|
||||
)
|
||||
?.find(
|
||||
(aa) =>
|
||||
(aa.type === "STATUS_CHANGED" && aa.data === "FINISHED") ||
|
||||
(aa.type === "ADDED_WATCHED" &&
|
||||
aa.data?.includes("FINISHED")),
|
||||
);
|
||||
const bLastFinishActivity = b.activity
|
||||
?.sort(
|
||||
(aa, bb) =>
|
||||
Date.parse(bb.customDate ?? bb.updatedAt) -
|
||||
Date.parse(aa.customDate ?? aa.updatedAt),
|
||||
)
|
||||
?.find(
|
||||
(aa) =>
|
||||
(aa.type === "STATUS_CHANGED" && aa.data === "FINISHED") ||
|
||||
(aa.type === "ADDED_WATCHED" &&
|
||||
aa.data?.includes("FINISHED")),
|
||||
);
|
||||
if (!aLastFinishActivity) return 1;
|
||||
if (!bLastFinishActivity) return -1;
|
||||
const alfaDate =
|
||||
aLastFinishActivity.customDate ?? aLastFinishActivity.updatedAt;
|
||||
const blfaDate =
|
||||
bLastFinishActivity.customDate ?? bLastFinishActivity.updatedAt;
|
||||
if (sort[1] === "UP")
|
||||
return Date.parse(alfaDate) - Date.parse(blfaDate);
|
||||
else if (sort[1] === "DOWN")
|
||||
return Date.parse(blfaDate) - Date.parse(alfaDate);
|
||||
} else if (sort[0] === "RATING") {
|
||||
if (sort[1] === "UP") return (a.rating ?? 0) - (b.rating ?? 0);
|
||||
else if (sort[1] === "DOWN")
|
||||
return (b.rating ?? 0) - (a.rating ?? 0);
|
||||
}
|
||||
// default DATEADDED DOWN
|
||||
return Date.parse(b.createdAt) - Date.parse(a.createdAt);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.pinned && !b.pinned) return -1;
|
||||
if (!a.pinned && b.pinned) return 1;
|
||||
return 0;
|
||||
});
|
||||
// Now apply filters to watch list.
|
||||
if (filters.status.length > 0 && filters.type.length > 0) {
|
||||
// If status and type filters applied, combine both.
|
||||
if (
|
||||
settings?.includePreviouslyWatched &&
|
||||
filters.status.includes("finished")
|
||||
) {
|
||||
watched = watched.filter(
|
||||
(w) =>
|
||||
(filters.status.includes(w.status?.toLowerCase()) ||
|
||||
contentWatchedPreviously(w)) &&
|
||||
filters.type.includes(
|
||||
w.content ? w.content.type : w.game ? "game" : "",
|
||||
),
|
||||
);
|
||||
} else {
|
||||
watched = watched.filter(
|
||||
(w) =>
|
||||
filters.status.includes(w.status?.toLowerCase()) &&
|
||||
filters.type.includes(
|
||||
w.content ? w.content.type : w.game ? "game" : "",
|
||||
),
|
||||
);
|
||||
}
|
||||
} else if (filters.type.length > 0) {
|
||||
// Only filter type
|
||||
watched = watched.filter((w) =>
|
||||
filters.type.includes(
|
||||
w.content ? w.content.type : w.game ? "game" : "",
|
||||
),
|
||||
);
|
||||
} else if (filters.status.length > 0) {
|
||||
// Only filter status
|
||||
if (
|
||||
settings?.includePreviouslyWatched &&
|
||||
filters.status.includes("finished")
|
||||
) {
|
||||
watched = watched.filter(
|
||||
(w) =>
|
||||
filters.status.includes(w.status?.toLowerCase()) ||
|
||||
contentWatchedPreviously(w),
|
||||
);
|
||||
} else {
|
||||
watched = watched.filter((w) =>
|
||||
filters.status.includes(w.status?.toLowerCase()),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("filt: Failed to filter/sort current list!", err);
|
||||
notify({
|
||||
text: "Failed to filter/sort list!",
|
||||
type: "error",
|
||||
time: 6000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for when a watched list item is updated through poster,
|
||||
* this allows us to run the filt() func again so the sorting is
|
||||
* updated.
|
||||
*/
|
||||
function itemUpdated() {
|
||||
console.debug("itemUpdated");
|
||||
filt();
|
||||
}
|
||||
</script>
|
||||
|
||||
<PosterList>
|
||||
{#if watched?.length > 0}
|
||||
{#each watched as w (w.id)}
|
||||
{#if w.game}
|
||||
<GamePoster
|
||||
id={w.id}
|
||||
rating={w.rating}
|
||||
status={w.status}
|
||||
media={{
|
||||
id: w.game.igdbId,
|
||||
coverId: w.game.coverId,
|
||||
name: w.game.name,
|
||||
summary: w.game.summary,
|
||||
firstReleaseDate: w.game.releaseDate,
|
||||
poster: w.game.poster
|
||||
}}
|
||||
disableInteraction={isPublicList}
|
||||
extraDetails={{
|
||||
dateAdded: w.createdAt,
|
||||
dateModified: w.updatedAt
|
||||
}}
|
||||
fluidSize={true}
|
||||
pinned={w.pinned}
|
||||
/>
|
||||
{:else if w.content}
|
||||
<Poster
|
||||
id={w.id}
|
||||
media={{
|
||||
id: w.content.tmdbId,
|
||||
poster_path: w.content.poster_path,
|
||||
title: w.content.title,
|
||||
overview: w.content.overview,
|
||||
media_type: w.content.type,
|
||||
release_date: w.content.release_date,
|
||||
first_air_date: w.content.first_air_date
|
||||
}}
|
||||
rating={w.rating}
|
||||
status={w.status}
|
||||
disableInteraction={isPublicList}
|
||||
extraDetails={{
|
||||
dateAdded: w.createdAt,
|
||||
dateModified: w.updatedAt,
|
||||
lastWatched: getLatestWatchedInTv(w.watchedSeasons, w.watchedEpisodes)
|
||||
}}
|
||||
fluidSize={true}
|
||||
pinned={w.pinned}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="empty-list">
|
||||
{#if list?.length > 0}
|
||||
<!-- `watched` (filtered list) is empty, but `list` (unfiltered) isn't,
|
||||
{#if watched?.length > 0}
|
||||
{#each watched as w (w.id)}
|
||||
{#if w.game}
|
||||
<GamePoster
|
||||
id={w.id}
|
||||
rating={w.rating}
|
||||
status={w.status}
|
||||
media={{
|
||||
id: w.game.igdbId,
|
||||
coverId: w.game.coverId,
|
||||
name: w.game.name,
|
||||
summary: w.game.summary,
|
||||
firstReleaseDate: w.game.releaseDate,
|
||||
poster: w.game.poster,
|
||||
}}
|
||||
disableInteraction={isPublicList}
|
||||
extraDetails={{
|
||||
dateAdded: w.createdAt,
|
||||
dateModified: w.updatedAt,
|
||||
}}
|
||||
fluidSize={true}
|
||||
pinned={w.pinned}
|
||||
onUpdated={itemUpdated}
|
||||
/>
|
||||
{:else if w.content}
|
||||
<Poster
|
||||
id={w.id}
|
||||
media={{
|
||||
id: w.content.tmdbId,
|
||||
poster_path: w.content.poster_path,
|
||||
title: w.content.title,
|
||||
overview: w.content.overview,
|
||||
media_type: w.content.type,
|
||||
release_date: w.content.release_date,
|
||||
first_air_date: w.content.first_air_date,
|
||||
}}
|
||||
rating={w.rating}
|
||||
status={w.status}
|
||||
disableInteraction={isPublicList}
|
||||
extraDetails={{
|
||||
dateAdded: w.createdAt,
|
||||
dateModified: w.updatedAt,
|
||||
lastWatched: getLatestWatchedInTv(
|
||||
w.watchedSeasons,
|
||||
w.watchedEpisodes,
|
||||
),
|
||||
}}
|
||||
fluidSize={true}
|
||||
pinned={w.pinned}
|
||||
onUpdated={itemUpdated}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="empty-list">
|
||||
{#if list?.length > 0}
|
||||
<!-- `watched` (filtered list) is empty, but `list` (unfiltered) isn't,
|
||||
so we should let the user know why there is nothing to show. -->
|
||||
<Icon i="filter-circle" wh={80} />
|
||||
<h2 class="norm">Filters are hiding all results!</h2>
|
||||
<h4 class="norm">Try changing or removing your active filters.</h4>
|
||||
<button on:click={() => clearActiveFilters()}>Clear Filters</button>
|
||||
{:else}
|
||||
<Icon i="reel" wh={80} />
|
||||
{#if isPublicList}
|
||||
<h2 class="norm">This watched list is empty!</h2>
|
||||
<h4 class="norm">Come back later to see if they have added anything.</h4>
|
||||
{:else}
|
||||
<h2 class="norm">Your watched list is empty!</h2>
|
||||
<h4 class="norm">Try searching for something you would like to add.</h4>
|
||||
<button on:click={() => goto("/import")}>Import</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<Icon i="filter-circle" wh={80} />
|
||||
<h2 class="norm">Filters are hiding all results!</h2>
|
||||
<h4 class="norm">Try changing or removing your active filters.</h4>
|
||||
<button onclick={() => clearActiveFilters()}>Clear Filters</button>
|
||||
{:else}
|
||||
<Icon i="reel" wh={80} />
|
||||
{#if isPublicList}
|
||||
<h2 class="norm">This watched list is empty!</h2>
|
||||
<h4 class="norm">
|
||||
Come back later to see if they have added anything.
|
||||
</h4>
|
||||
{:else}
|
||||
<h2 class="norm">Your watched list is empty!</h2>
|
||||
<h4 class="norm">
|
||||
Try searching for something you would like to add.
|
||||
</h4>
|
||||
<button onclick={() => goto("/import")}>Import</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</PosterList>
|
||||
|
||||
<style lang="scss">
|
||||
.empty-list {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
.empty-list {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
|
||||
h2 {
|
||||
margin-top: 10px;
|
||||
}
|
||||
h2 {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-weight: normal;
|
||||
}
|
||||
h4 {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
button {
|
||||
width: max-content;
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
}
|
||||
button {
|
||||
width: max-content;
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,65 +1,65 @@
|
||||
export interface StayInViewOptions {
|
||||
/**
|
||||
* If the `node` contains an element (like an arrow for menus),
|
||||
* that should be shifted over to account for any shifting
|
||||
* of the `node` itself, then pass a selector for it here.
|
||||
*/
|
||||
elToShiftSelector?: string;
|
||||
/**
|
||||
* If the `node` contains an element (like an arrow for menus),
|
||||
* that should be shifted over to account for any shifting
|
||||
* of the `node` itself, then pass a selector for it here.
|
||||
*/
|
||||
elToShiftSelector?: string;
|
||||
}
|
||||
|
||||
export default function stayInView(node: HTMLElement, opts: StayInViewOptions) {
|
||||
console.debug("stayInView: Initial opts:", opts);
|
||||
let { elToShiftSelector } = opts;
|
||||
let viewDeb: ReturnType<typeof setTimeout>;
|
||||
console.debug("stayInView: Initial opts:", opts);
|
||||
let { elToShiftSelector } = opts;
|
||||
let viewDeb: ReturnType<typeof setTimeout>;
|
||||
|
||||
/**
|
||||
* Move element to in view, if it isn't.
|
||||
*
|
||||
* Currently only supports moving `node` back into view if oob
|
||||
* on the left. May need to support other sides and/or resizing
|
||||
* when `node` still wont fit after shifting it in the future.
|
||||
*/
|
||||
const getInView = () => {
|
||||
const nrect = node.getBoundingClientRect();
|
||||
const brect = document.body.getBoundingClientRect();
|
||||
console.debug("stayInView->getInView: Called.", nrect, brect);
|
||||
if (nrect.x <= brect.x) {
|
||||
const diff = nrect.x - brect.x + 10;
|
||||
console.debug(
|
||||
"stayInView->getInView: Node is out of bounds on the left, shifting forwards to:",
|
||||
diff
|
||||
);
|
||||
node.style.left = `${diff}px`;
|
||||
if (elToShiftSelector) {
|
||||
const elToShift = node.querySelector(elToShiftSelector) as HTMLElement;
|
||||
if (elToShift) {
|
||||
console.debug("stayInView->getInView: Shifting elToShift.");
|
||||
const nrectNew = node.getBoundingClientRect();
|
||||
const arrowDiff = nrectNew.left - nrect.left;
|
||||
elToShift.style.left = `${elToShift.offsetLeft - arrowDiff}px`;
|
||||
} else {
|
||||
console.warn("elToShift not found.", elToShiftSelector);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
/**
|
||||
* Move element to in view, if it isn't.
|
||||
*
|
||||
* Currently only supports moving `node` back into view if oob
|
||||
* on the left. May need to support other sides and/or resizing
|
||||
* when `node` still wont fit after shifting it in the future.
|
||||
*/
|
||||
const getInView = () => {
|
||||
const nrect = node.getBoundingClientRect();
|
||||
const brect = document.body.getBoundingClientRect();
|
||||
console.debug("stayInView->getInView: Called.", nrect, brect);
|
||||
if (nrect.x <= brect.x) {
|
||||
const diff = nrect.x - brect.x + 10;
|
||||
console.debug(
|
||||
"stayInView->getInView: Node is out of bounds on the left, shifting forwards to:",
|
||||
diff,
|
||||
);
|
||||
node.style.left = `${diff}px`;
|
||||
if (elToShiftSelector) {
|
||||
const elToShift = node.querySelector(elToShiftSelector) as HTMLElement;
|
||||
if (elToShift) {
|
||||
console.debug("stayInView->getInView: Shifting elToShift.");
|
||||
const nrectNew = node.getBoundingClientRect();
|
||||
const arrowDiff = nrectNew.left - nrect.left;
|
||||
elToShift.style.left = `${elToShift.offsetLeft - arrowDiff}px`;
|
||||
} else {
|
||||
console.warn("elToShift not found.", elToShiftSelector);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getInViewDeb = () => {
|
||||
clearTimeout(viewDeb);
|
||||
viewDeb = setTimeout(getInView, 200);
|
||||
};
|
||||
const getInViewDeb = () => {
|
||||
clearTimeout(viewDeb);
|
||||
viewDeb = setTimeout(getInView, 200);
|
||||
};
|
||||
|
||||
window.addEventListener("resize", getInViewDeb);
|
||||
getInView();
|
||||
window.addEventListener("resize", getInViewDeb);
|
||||
getInView();
|
||||
|
||||
return {
|
||||
update(opts: StayInViewOptions) {
|
||||
console.debug("stayInView: Opts updated", opts);
|
||||
elToShiftSelector = opts.elToShiftSelector;
|
||||
getInView();
|
||||
},
|
||||
destroy() {
|
||||
window.removeEventListener("resize", getInViewDeb);
|
||||
}
|
||||
};
|
||||
return {
|
||||
update(opts: StayInViewOptions) {
|
||||
console.debug("stayInView: Opts updated", opts);
|
||||
elToShiftSelector = opts.elToShiftSelector;
|
||||
getInView();
|
||||
},
|
||||
destroy() {
|
||||
window.removeEventListener("resize", getInViewDeb);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,62 +1,66 @@
|
||||
export interface ToolTipOptions {
|
||||
text: string;
|
||||
pos?: "left" | "top" | "bot";
|
||||
text: string;
|
||||
pos?: "left" | "top" | "bot";
|
||||
|
||||
/**
|
||||
* Only show tooltip if this condition is true.
|
||||
*/
|
||||
condition?: boolean;
|
||||
/**
|
||||
* Only show tooltip if this condition is true.
|
||||
*/
|
||||
condition?: boolean;
|
||||
}
|
||||
|
||||
export default function tooltip(node: HTMLElement, opts: ToolTipOptions) {
|
||||
let { text, pos = "left", condition = true } = opts;
|
||||
const tooltip = document.getElementById("tooltip");
|
||||
let { text, pos = "left", condition = true } = opts;
|
||||
const tooltip = document.getElementById("tooltip");
|
||||
|
||||
const show = () => {
|
||||
const trimmedText = text?.trim();
|
||||
if (!condition || !trimmedText) return;
|
||||
if (tooltip) {
|
||||
tooltip.innerHTML = trimmedText;
|
||||
const nrect = node.getBoundingClientRect();
|
||||
const trect = tooltip.getBoundingClientRect();
|
||||
nrect.y += window.scrollY; // Add scrollY to node dom rect so tooltip shows correcting when page is scrolled down
|
||||
if (pos === "left") {
|
||||
tooltip.style.left = `${nrect.x - trect.width - 10}px`;
|
||||
tooltip.style.top = `${nrect.y + trect.height / 2 - 19.5}px`;
|
||||
} else if (pos === "top") {
|
||||
tooltip.style.left = `${nrect.x - trect.width / 2 + nrect.width / 2}px`;
|
||||
tooltip.style.top = `${nrect.y - trect.height - 5}px`;
|
||||
} else if (pos === "bot") {
|
||||
tooltip.style.left = `${nrect.x - trect.width / 2 + nrect.width / 2}px`;
|
||||
tooltip.style.top = `${nrect.y + trect.height + 5}px`;
|
||||
}
|
||||
tooltip.style.visibility = "visible";
|
||||
}
|
||||
};
|
||||
const show = () => {
|
||||
const trimmedText = text?.trim();
|
||||
if (!condition || !trimmedText) return;
|
||||
if (tooltip) {
|
||||
tooltip.innerHTML = trimmedText;
|
||||
const nrect = node.getBoundingClientRect();
|
||||
const trect = tooltip.getBoundingClientRect();
|
||||
nrect.y += window.scrollY; // Add scrollY to node dom rect so tooltip shows correcting when page is scrolled down
|
||||
if (pos === "left") {
|
||||
tooltip.style.left = `${nrect.x - trect.width - 10}px`;
|
||||
tooltip.style.top = `${nrect.y + trect.height / 2 - 19.5}px`;
|
||||
} else if (pos === "top") {
|
||||
tooltip.style.left = `${nrect.x - trect.width / 2 + nrect.width / 2}px`;
|
||||
tooltip.style.top = `${nrect.y - trect.height - 5}px`;
|
||||
} else if (pos === "bot") {
|
||||
tooltip.style.left = `${nrect.x - trect.width / 2 + nrect.width / 2}px`;
|
||||
tooltip.style.top = `${nrect.y + trect.height + 5}px`;
|
||||
}
|
||||
tooltip.style.visibility = "visible";
|
||||
}
|
||||
};
|
||||
|
||||
const hide = () => {
|
||||
if (tooltip) {
|
||||
tooltip.style.visibility = "hidden";
|
||||
}
|
||||
};
|
||||
const hide = () => {
|
||||
if (tooltip) {
|
||||
tooltip.style.visibility = "hidden";
|
||||
}
|
||||
};
|
||||
|
||||
node.addEventListener("mouseover", show);
|
||||
node.addEventListener("touchstart", show);
|
||||
node.addEventListener("mouseout", hide);
|
||||
node.addEventListener("touchend", hide);
|
||||
node.addEventListener("click", hide);
|
||||
node.addEventListener("mouseover", show);
|
||||
node.addEventListener("touchstart", show);
|
||||
node.addEventListener("mouseout", hide);
|
||||
node.addEventListener("touchend", hide);
|
||||
node.addEventListener("click", hide);
|
||||
|
||||
return {
|
||||
update(opts: ToolTipOptions) {
|
||||
text = opts.text;
|
||||
condition = opts.condition ?? true;
|
||||
},
|
||||
destroy() {
|
||||
node.removeEventListener("mouseover", show);
|
||||
node.removeEventListener("touchstart", show);
|
||||
node.removeEventListener("mouseout", hide);
|
||||
node.removeEventListener("touchend", hide);
|
||||
node.removeEventListener("click", hide);
|
||||
}
|
||||
};
|
||||
return {
|
||||
update(opts: ToolTipOptions) {
|
||||
text = opts.text;
|
||||
condition = opts.condition ?? true;
|
||||
},
|
||||
destroy() {
|
||||
node.removeEventListener("mouseover", show);
|
||||
node.removeEventListener("touchstart", show);
|
||||
node.removeEventListener("mouseout", hide);
|
||||
node.removeEventListener("touchend", hide);
|
||||
node.removeEventListener("click", hide);
|
||||
// Running hide() here fixes scenario where we (eg) hide `node`
|
||||
// after it is pressed, but tooltip persists and there is no way
|
||||
// to remove it.
|
||||
hide();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,154 +1,164 @@
|
||||
<script lang="ts">
|
||||
import type { ContentType, PublicUser, WatchedStatus } from "@/types";
|
||||
import HorizontalList from "../HorizontalList.svelte";
|
||||
import Modal from "../Modal.svelte";
|
||||
import axios from "axios";
|
||||
import Spinner from "../Spinner.svelte";
|
||||
import Error from "../Error.svelte";
|
||||
import Icon from "../Icon.svelte";
|
||||
import { watchedStatuses } from "../util/helpers";
|
||||
import type { ContentType, PublicUser, WatchedStatus } from "@/types";
|
||||
import HorizontalList from "../HorizontalList.svelte";
|
||||
import Modal from "../Modal.svelte";
|
||||
import axios from "axios";
|
||||
import Spinner from "../Spinner.svelte";
|
||||
import Error from "../Error.svelte";
|
||||
import Icon from "../Icon.svelte";
|
||||
import { watchedStatuses } from "../util/helpers";
|
||||
|
||||
interface FollowThoughts {
|
||||
followedUser: PublicUser;
|
||||
thoughts: string;
|
||||
status: WatchedStatus;
|
||||
rating: number;
|
||||
}
|
||||
interface FollowThoughts {
|
||||
followedUser: PublicUser;
|
||||
thoughts: string;
|
||||
status: WatchedStatus;
|
||||
rating: number;
|
||||
}
|
||||
|
||||
export let mediaType: ContentType | "game";
|
||||
// The tmdbId for movie/tv, igdbId for games.
|
||||
export let mediaId: number;
|
||||
interface Props {
|
||||
mediaType: ContentType | "game";
|
||||
// The tmdbId for movie/tv, igdbId for games.
|
||||
mediaId: number;
|
||||
}
|
||||
|
||||
let modalShownFor: FollowThoughts | undefined = undefined;
|
||||
let { mediaType, mediaId }: Props = $props();
|
||||
|
||||
async function getFollowsThoughts() {
|
||||
return (await axios.get<FollowThoughts[]>(`/follow/thoughts/${mediaType}/${mediaId}`)).data;
|
||||
}
|
||||
let modalShownFor: FollowThoughts | undefined = $state(undefined);
|
||||
|
||||
async function getFollowsThoughts() {
|
||||
return (
|
||||
await axios.get<FollowThoughts[]>(
|
||||
`/follow/thoughts/${mediaType}/${mediaId}`,
|
||||
)
|
||||
).data;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#await getFollowsThoughts()}
|
||||
<Spinner />
|
||||
<Spinner />
|
||||
{:then fts}
|
||||
{#if fts?.length > 0}
|
||||
<HorizontalList title="Followed Thoughts">
|
||||
{#each fts as ft}
|
||||
<button
|
||||
class={["thoughts-card plain", ft.thoughts ? "" : "no-thoughts"].join(" ")}
|
||||
on:click={() => (modalShownFor = ft)}
|
||||
>
|
||||
<div>
|
||||
<h4 title={ft.followedUser.username}>{ft.followedUser.username}</h4>
|
||||
{#if ft.status}
|
||||
<div class="status-icon">
|
||||
<Icon i={watchedStatuses[ft.status]} wh={22} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if ft.rating}
|
||||
<span class="rating">
|
||||
<span>*</span>
|
||||
{ft.rating}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="thought">
|
||||
{ft.thoughts || "No thoughts yet."}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</HorizontalList>
|
||||
{/if}
|
||||
{#if fts?.length > 0}
|
||||
<HorizontalList title="Followed Thoughts">
|
||||
{#each fts as ft}
|
||||
<button
|
||||
class={["thoughts-card plain", ft.thoughts ? "" : "no-thoughts"].join(
|
||||
" ",
|
||||
)}
|
||||
onclick={() => (modalShownFor = ft)}
|
||||
>
|
||||
<div>
|
||||
<h4 title={ft.followedUser.username}>{ft.followedUser.username}</h4>
|
||||
{#if ft.status}
|
||||
<div class="status-icon">
|
||||
<Icon i={watchedStatuses[ft.status]} wh={22} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if ft.rating}
|
||||
<span class="rating">
|
||||
<span>*</span>
|
||||
{ft.rating}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="thought">
|
||||
{ft.thoughts || "No thoughts yet."}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</HorizontalList>
|
||||
{/if}
|
||||
{:catch err}
|
||||
<Error error={err} pretty="Failed to load followed thoughts!" />
|
||||
<Error error={err} pretty="Failed to load followed thoughts!" />
|
||||
{/await}
|
||||
|
||||
{#if modalShownFor}
|
||||
<Modal
|
||||
title={`${modalShownFor.followedUser.username}'s Thoughts`}
|
||||
onClose={() => (modalShownFor = undefined)}
|
||||
>
|
||||
<span>{modalShownFor.thoughts}</span>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={`${modalShownFor.followedUser.username}'s Thoughts`}
|
||||
onClose={() => (modalShownFor = undefined)}
|
||||
>
|
||||
<span>{modalShownFor.thoughts}</span>
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.thoughts-card {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
padding: 15px 20px;
|
||||
background-color: $accent-color;
|
||||
fill: $text-color;
|
||||
border-radius: 10px;
|
||||
min-width: 250px;
|
||||
max-width: 250px;
|
||||
font-size: 16px;
|
||||
transition: background-color 200ms ease;
|
||||
text-align: unset;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
.thoughts-card {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
padding: 15px 20px;
|
||||
background-color: $accent-color;
|
||||
fill: $text-color;
|
||||
border-radius: 10px;
|
||||
min-width: 250px;
|
||||
max-width: 250px;
|
||||
font-size: 16px;
|
||||
transition: background-color 200ms ease;
|
||||
text-align: unset;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
|
||||
& > div {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 3px;
|
||||
& > div {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 3px;
|
||||
|
||||
h4 {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
h4 {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.rating {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
font-size: 18px;
|
||||
font-weight: bolder;
|
||||
.rating {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
font-size: 18px;
|
||||
font-weight: bolder;
|
||||
|
||||
span {
|
||||
font-family: "Rampart One";
|
||||
-webkit-text-stroke: 1px $text-color;
|
||||
font-size: 32px;
|
||||
line-height: 0.55;
|
||||
margin-top: 7px;
|
||||
}
|
||||
}
|
||||
span {
|
||||
font-family: "Rampart One";
|
||||
-webkit-text-stroke: 1px $text-color;
|
||||
font-size: 32px;
|
||||
line-height: 0.55;
|
||||
margin-top: 7px;
|
||||
}
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
margin-left: auto;
|
||||
margin-right: 2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
min-width: 20px;
|
||||
min-height: 20px;
|
||||
}
|
||||
}
|
||||
.status-icon {
|
||||
margin-left: auto;
|
||||
margin-right: 2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
min-width: 20px;
|
||||
min-height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.thought {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 9;
|
||||
-webkit-box-orient: vertical;
|
||||
hyphens: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
.thought {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 9;
|
||||
-webkit-box-orient: vertical;
|
||||
hyphens: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&.no-thoughts {
|
||||
pointer-events: none;
|
||||
&.no-thoughts {
|
||||
pointer-events: none;
|
||||
|
||||
.thought {
|
||||
font-weight: lighter;
|
||||
}
|
||||
}
|
||||
.thought {
|
||||
font-weight: lighter;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: $bg-color;
|
||||
fill: $bg-color;
|
||||
background-color: $accent-color-hover;
|
||||
&:hover {
|
||||
color: $bg-color;
|
||||
fill: $bg-color;
|
||||
background-color: $accent-color-hover;
|
||||
|
||||
.rating span {
|
||||
-webkit-text-stroke: 1px $bg-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
.rating span {
|
||||
-webkit-text-stroke: 1px $bg-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,123 +1,130 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import Modal from "../Modal.svelte";
|
||||
import Icon from "../Icon.svelte";
|
||||
import { notify } from "../util/notify";
|
||||
import Modal from "../Modal.svelte";
|
||||
import Icon from "../Icon.svelte";
|
||||
import { notify } from "../util/notify";
|
||||
|
||||
export let contentTitle: string;
|
||||
export let thoughts: string;
|
||||
export let onChange: (newThoughts: string) => Promise<boolean>;
|
||||
interface Props {
|
||||
contentTitle: string;
|
||||
thoughts: string;
|
||||
onChange: (newThoughts: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
let modalOpen = false;
|
||||
let textarea: HTMLTextAreaElement | undefined;
|
||||
$: thoughtsToDisplay = thoughts ? thoughts : `Set thoughts on ${contentTitle}`;
|
||||
let { contentTitle, thoughts, onChange }: Props = $props();
|
||||
|
||||
function resizeTextarea() {
|
||||
if (!textarea) {
|
||||
return;
|
||||
}
|
||||
textarea.style.height = "";
|
||||
textarea.style.height = textarea.scrollHeight + "px";
|
||||
}
|
||||
let modalOpen = $state(false);
|
||||
let textarea: HTMLTextAreaElement | undefined = $state();
|
||||
let thoughtsToDisplay = $derived(
|
||||
thoughts ? thoughts : `Set thoughts on ${contentTitle}`,
|
||||
);
|
||||
|
||||
$: textarea, resizeTextarea();
|
||||
function resizeTextarea() {
|
||||
if (!textarea) {
|
||||
return;
|
||||
}
|
||||
textarea.style.height = "";
|
||||
textarea.style.height = textarea.scrollHeight + "px";
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (textarea) resizeTextarea();
|
||||
});
|
||||
</script>
|
||||
|
||||
<button
|
||||
class={`plain thoughts${thoughtsToDisplay?.length > 100 ? " long" : ""}${thoughts ? "" : " placeholdered"}`}
|
||||
on:click={() => {
|
||||
modalOpen = !modalOpen;
|
||||
if (modalOpen) {
|
||||
resizeTextarea();
|
||||
}
|
||||
}}
|
||||
class={`plain thoughts${thoughtsToDisplay?.length > 100 ? " long" : ""}${thoughts ? "" : " placeholdered"}`}
|
||||
onclick={() => {
|
||||
modalOpen = !modalOpen;
|
||||
if (modalOpen) {
|
||||
resizeTextarea();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<i><Icon i="pencil" wh={24} /></i>
|
||||
<p>{thoughtsToDisplay}</p>
|
||||
<i><Icon i="pencil" wh={24} /></i>
|
||||
<p>{thoughtsToDisplay}</p>
|
||||
</button>
|
||||
|
||||
{#if modalOpen}
|
||||
<Modal
|
||||
title="Your Thoughts"
|
||||
desc="View or modify your thoughts on {contentTitle}"
|
||||
onClose={async () => {
|
||||
if (!textarea) {
|
||||
notify({
|
||||
text: "Failed to find the text box! Please copy your changes to avoid losing them and try again!"
|
||||
});
|
||||
return;
|
||||
}
|
||||
// If thoughts weren't changed or changes saved successfully.
|
||||
if (thoughts === textarea.value || (await onChange(textarea.value))) {
|
||||
modalOpen = false;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
name="Thoughts"
|
||||
rows="3"
|
||||
placeholder={`My thoughts on ${contentTitle}`}
|
||||
value={thoughts}
|
||||
bind:this={textarea}
|
||||
on:input={resizeTextarea}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
title="Your Thoughts"
|
||||
desc="View or modify your thoughts on {contentTitle}"
|
||||
onClose={async () => {
|
||||
if (!textarea) {
|
||||
notify({
|
||||
text: "Failed to find the text box! Please copy your changes to avoid losing them and try again!",
|
||||
});
|
||||
return;
|
||||
}
|
||||
// If thoughts weren't changed or changes saved successfully.
|
||||
if (thoughts === textarea.value || (await onChange(textarea.value))) {
|
||||
modalOpen = false;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
name="Thoughts"
|
||||
rows="3"
|
||||
placeholder={`My thoughts on ${contentTitle}`}
|
||||
value={thoughts}
|
||||
bind:this={textarea}
|
||||
oninput={resizeTextarea}
|
||||
></textarea>
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
button.thoughts {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
text-align: start;
|
||||
padding: 7px 10px;
|
||||
border: 2px solid $text-color;
|
||||
border-radius: 5px;
|
||||
max-height: 100px;
|
||||
opacity: 0.5;
|
||||
transition: opacity 150ms ease-in-out;
|
||||
button.thoughts {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
text-align: start;
|
||||
padding: 7px 10px;
|
||||
border: 2px solid $text-color;
|
||||
border-radius: 5px;
|
||||
max-height: 100px;
|
||||
opacity: 0.5;
|
||||
transition: opacity 150ms ease-in-out;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
i {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
right: 5px;
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
transition:
|
||||
opacity 150ms ease-in-out,
|
||||
transform 150ms ease-in-out;
|
||||
}
|
||||
i {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
right: 5px;
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
transition:
|
||||
opacity 150ms ease-in-out,
|
||||
transform 150ms ease-in-out;
|
||||
}
|
||||
|
||||
&:hover i {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
&:hover i {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.placeholdered {
|
||||
padding: 12px 12px;
|
||||
}
|
||||
&.placeholdered {
|
||||
padding: 12px 12px;
|
||||
}
|
||||
|
||||
p {
|
||||
max-height: 90px;
|
||||
overflow: hidden;
|
||||
}
|
||||
p {
|
||||
max-height: 90px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&.long p {
|
||||
mask: linear-gradient(
|
||||
to bottom,
|
||||
rgba(0, 0, 0, 1) 0,
|
||||
rgba(0, 0, 0, 1) 40%,
|
||||
rgba(0, 0, 0, 0) 95%,
|
||||
rgba(0, 0, 0, 0) 0
|
||||
);
|
||||
}
|
||||
}
|
||||
&.long p {
|
||||
mask: linear-gradient(
|
||||
to bottom,
|
||||
rgba(0, 0, 0, 1) 0,
|
||||
rgba(0, 0, 0, 1) 40%,
|
||||
rgba(0, 0, 0, 0) 95%,
|
||||
rgba(0, 0, 0, 0) 0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
border: 0;
|
||||
}
|
||||
textarea {
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,138 +4,180 @@
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
export let i: string;
|
||||
export let wh = 24;
|
||||
interface Props {
|
||||
i: string;
|
||||
wh?: number;
|
||||
}
|
||||
|
||||
let { i, wh = 24 }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if i === "Apple TV Plus"}
|
||||
<a href="https://tv.apple.com/" target="_blank">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 45.046 17.091" width={wh} height={wh}>
|
||||
<path
|
||||
d="M9.436 2.742A3.857 3.857 0 0 0 10.316 0a3.769 3.769 0 0 0-2.51 1.311 3.622 3.622 0 0 0-.9 2.631 3.138 3.138 0 0 0 2.53-1.2m.82 1.381c-1.4-.081-2.58.8-3.25.8s-1.69-.756-2.79-.736a4.117 4.117 0 0 0-3.5 2.147c-1.5 2.6-.4 6.473 1.06 8.59.71 1.006 1.56 2.205 2.69 2.166s1.48-.7 2.77-.7 1.67.7 2.79.675 1.9-1.008 2.6-2.1a9.317 9.317 0 0 0 1.17-2.42 3.814 3.814 0 0 1-2.27-3.468 3.9 3.9 0 0 1 1.83-3.256 3.991 3.991 0 0 0-3.1-1.7m8.93-2.016V4.96h2.28v1.885h-2.28V13.6c0 1.008.45 1.522 1.45 1.522a7.482 7.482 0 0 0 .82-.06v1.9a7.823 7.823 0 0 1-1.35.1c-2.36 0-3.27-.917-3.27-3.216V6.89h-1.79V5h1.74V2.107Zm10.25 14.853h-2.5L22.736 5h2.49l2.95 9.608h.06L31.186 5h2.44Zm10.98 0h-2.16v-4.9h-4.64V9.9h4.63V5h2.16v4.9h4.64v2.158h-4.63Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a aria-label={i} href="https://tv.apple.com/" target="_blank">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 45.046 17.091"
|
||||
width={wh}
|
||||
height={wh}
|
||||
>
|
||||
<path
|
||||
d="M9.436 2.742A3.857 3.857 0 0 0 10.316 0a3.769 3.769 0 0 0-2.51 1.311 3.622 3.622 0 0 0-.9 2.631 3.138 3.138 0 0 0 2.53-1.2m.82 1.381c-1.4-.081-2.58.8-3.25.8s-1.69-.756-2.79-.736a4.117 4.117 0 0 0-3.5 2.147c-1.5 2.6-.4 6.473 1.06 8.59.71 1.006 1.56 2.205 2.69 2.166s1.48-.7 2.77-.7 1.67.7 2.79.675 1.9-1.008 2.6-2.1a9.317 9.317 0 0 0 1.17-2.42 3.814 3.814 0 0 1-2.27-3.468 3.9 3.9 0 0 1 1.83-3.256 3.991 3.991 0 0 0-3.1-1.7m8.93-2.016V4.96h2.28v1.885h-2.28V13.6c0 1.008.45 1.522 1.45 1.522a7.482 7.482 0 0 0 .82-.06v1.9a7.823 7.823 0 0 1-1.35.1c-2.36 0-3.27-.917-3.27-3.216V6.89h-1.79V5h1.74V2.107Zm10.25 14.853h-2.5L22.736 5h2.49l2.95 9.608h.06L31.186 5h2.44Zm10.98 0h-2.16v-4.9h-4.64V9.9h4.63V5h2.16v4.9h4.64v2.158h-4.63Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
{:else if i === "Apple TV"}
|
||||
<a href="https://tv.apple.com/" target="_blank">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width={wh} height={wh}>
|
||||
<path
|
||||
d="M20.57 17.735h-1.815l-3.34-9.203h1.633l2.02 5.987c.075.231.273.9.586 2.012l.297-.997.33-1.006 2.094-6.004H24zm-5.344-.066a5.76 5.76 0 0 1-1.55.207c-1.23 0-1.84-.693-1.84-2.087V9.646h-1.063V8.532h1.121V7.081l1.476-.602v2.062h1.707v1.113H13.38v5.805c0 .446.074.75.214.932.14.182.396.264.75.264.207 0 .495-.041.883-.115zm-7.29-5.343c.017 1.764 1.55 2.358 1.567 2.366-.017.042-.248.842-.808 1.658-.487.71-.99 1.418-1.79 1.435-.783.016-1.03-.462-1.93-.462-.89 0-1.17.445-1.913.478-.758.025-1.344-.775-1.838-1.484-.998-1.451-1.765-4.098-.734-5.88.51-.89 1.426-1.451 2.416-1.46.75-.016 1.468.512 1.93.512.461 0 1.327-.627 2.234-.536.38.016 1.452.157 2.136 1.154-.058.033-1.278.743-1.27 2.219M6.468 7.988c.404-.495.685-1.18.61-1.864-.585.025-1.294.388-1.723.883-.38.437-.71 1.138-.619 1.806.652.05 1.328-.338 1.732-.825Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a aria-label={i} href="https://tv.apple.com/" target="_blank">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={wh}
|
||||
height={wh}
|
||||
>
|
||||
<path
|
||||
d="M20.57 17.735h-1.815l-3.34-9.203h1.633l2.02 5.987c.075.231.273.9.586 2.012l.297-.997.33-1.006 2.094-6.004H24zm-5.344-.066a5.76 5.76 0 0 1-1.55.207c-1.23 0-1.84-.693-1.84-2.087V9.646h-1.063V8.532h1.121V7.081l1.476-.602v2.062h1.707v1.113H13.38v5.805c0 .446.074.75.214.932.14.182.396.264.75.264.207 0 .495-.041.883-.115zm-7.29-5.343c.017 1.764 1.55 2.358 1.567 2.366-.017.042-.248.842-.808 1.658-.487.71-.99 1.418-1.79 1.435-.783.016-1.03-.462-1.93-.462-.89 0-1.17.445-1.913.478-.758.025-1.344-.775-1.838-1.484-.998-1.451-1.765-4.098-.734-5.88.51-.89 1.426-1.451 2.416-1.46.75-.016 1.468.512 1.93.512.461 0 1.327-.627 2.234-.536.38.016 1.452.157 2.136 1.154-.058.033-1.278.743-1.27 2.219M6.468 7.988c.404-.495.685-1.18.61-1.864-.585.025-1.294.388-1.723.883-.38.437-.71 1.138-.619 1.806.652.05 1.328-.338 1.732-.825Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
{:else if i === "Amazon Prime Video"}
|
||||
<a href="https://www.amazon.com/gp/video/storefront/" target="_blank">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width={wh} height={wh}>
|
||||
<path
|
||||
d="M22.787 15.292c-.336-.43-2.222-.204-3.069-.103-.257.031-.296-.193-.065-.356 1.504-1.056 3.968-.75 4.255-.397.288.357-.076 2.827-1.485 4.007-.217.18-.423.084-.327-.155.317-.792 1.027-2.566.69-2.996m-1.093 1.248c-2.627 1.94-6.437 2.97-9.717 2.97-4.597 0-8.737-1.7-11.87-4.528-.246-.222-.026-.525.27-.353 3.38 1.967 7.559 3.151 11.876 3.151a23.63 23.63 0 0 0 9.06-1.854c.444-.188.816.293.381.614m.482-5.038c-.761 0-1.346-.209-1.755-.626-.409-.418-.613-1.017-.613-1.797 0-.799.209-1.425.627-1.88.418-.454.998-.682 1.741-.682.572 0 1.019.138 1.341.415.323.276.484.645.484 1.105 0 .461-.174.81-.52 1.046-.348.237-.86.355-1.535.355-.35 0-.654-.034-.912-.101.037.411.161.706.373.884.212.178.533.268.963.268.172 0 .34-.011.502-.033a6.208 6.208 0 0 0 .733-.157.304.304 0 0 1 .046-.004c.104 0 .156.07.156.212v.424c0 .098-.013.167-.04.207a.341.341 0 0 1-.162.106 3.954 3.954 0 0 1-1.429.258m-.304-2.893c.314 0 .541-.048.682-.143.142-.095.212-.241.212-.438 0-.387-.23-.58-.69-.58-.59 0-.931.362-1.024 1.087.246.05.52.074.82.074m-9.84 2.755c-.08 0-.139-.018-.176-.055-.036-.037-.055-.096-.055-.175V6.886c0-.086.019-.146.055-.18.037-.034.096-.05.176-.05h.663c.141 0 .227.067.258.202l.074.249c.325-.215.619-.367.88-.456.26-.09.53-.134.806-.134.553 0 .943.197 1.17.59a3.77 3.77 0 0 1 .885-.452c.276-.092.562-.138.857-.138.43 0 .763.12 1 .36.236.239.354.574.354 1.004v3.253c0 .08-.017.138-.05.175-.034.037-.094.055-.18.055h-.885c-.08 0-.138-.018-.175-.055-.037-.037-.055-.096-.055-.175V8.176c0-.418-.188-.627-.562-.627-.332 0-.667.08-1.005.24v3.345c0 .08-.017.138-.05.175-.034.037-.094.055-.18.055h-.884c-.08 0-.139-.018-.176-.055-.036-.037-.055-.096-.055-.175V8.176c0-.418-.187-.627-.562-.627-.344 0-.682.083-1.013.249v3.336c0 .08-.017.138-.051.175-.034.037-.094.055-.18.055zM9.987 5.927c-.234 0-.42-.064-.562-.193-.142-.129-.212-.304-.212-.525 0-.221.07-.397.212-.526.141-.129.328-.193.562-.193.233 0 .42.064.562.193a.676.676 0 0 1 .212.526c0 .22-.07.396-.212.525-.141.129-.329.193-.562.193m-.443 5.437c-.08 0-.138-.019-.175-.055-.037-.037-.055-.096-.055-.176V6.886c0-.086.018-.146.055-.18.037-.034.096-.05.175-.05h.885c.086 0 .146.016.18.05s.05.094.05.18v4.247c0 .08-.017.139-.05.176-.034.036-.094.055-.18.055zm-3.681 0c-.08 0-.139-.018-.176-.055-.036-.037-.055-.096-.055-.175V6.886c0-.086.019-.146.055-.18.037-.034.096-.05.176-.05h.663c.141 0 .227.067.258.202l.12.497c.245-.27.477-.462.695-.575.219-.114.45-.17.696-.17h.13c.085 0 .147.016.183.05.037.034.056.094.056.18v.773c0 .08-.017.139-.051.176-.034.036-.094.055-.18.055a1.93 1.93 0 0 1-.166-.01 2.968 2.968 0 0 0-.258-.009c-.14 0-.313.02-.516.06-.202.04-.374.091-.515.152v3.097c0 .08-.018.138-.051.175-.034.037-.094.055-.18.055zM.344 13.262c-.08 0-.138-.017-.175-.05-.037-.034-.055-.095-.055-.18V6.886c0-.086.018-.146.055-.18.037-.034.095-.05.175-.05h.664c.14 0 .227.067.258.202l.064.24a2.03 2.03 0 0 1 .668-.424 2.13 2.13 0 0 1 .797-.157c.596 0 1.067.218 1.414.654.348.437.521 1.026.521 1.77 0 .51-.086.955-.258 1.336-.172.38-.405.674-.7.88a1.727 1.727 0 0 1-1.014.308c-.252 0-.491-.04-.719-.12a1.74 1.74 0 0 1-.58-.331v2.018c0 .085-.017.146-.05.18-.034.033-.095.05-.18.05zm2.018-2.81c.344 0 .597-.117.76-.35.163-.234.245-.603.245-1.106 0-.51-.08-.882-.24-1.115-.16-.234-.415-.35-.765-.35-.32 0-.62.083-.903.248v2.424c.27.166.571.249.903.249Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
aria-label={i}
|
||||
href="https://www.amazon.com/gp/video/storefront/"
|
||||
target="_blank"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={wh}
|
||||
height={wh}
|
||||
>
|
||||
<path
|
||||
d="M22.787 15.292c-.336-.43-2.222-.204-3.069-.103-.257.031-.296-.193-.065-.356 1.504-1.056 3.968-.75 4.255-.397.288.357-.076 2.827-1.485 4.007-.217.18-.423.084-.327-.155.317-.792 1.027-2.566.69-2.996m-1.093 1.248c-2.627 1.94-6.437 2.97-9.717 2.97-4.597 0-8.737-1.7-11.87-4.528-.246-.222-.026-.525.27-.353 3.38 1.967 7.559 3.151 11.876 3.151a23.63 23.63 0 0 0 9.06-1.854c.444-.188.816.293.381.614m.482-5.038c-.761 0-1.346-.209-1.755-.626-.409-.418-.613-1.017-.613-1.797 0-.799.209-1.425.627-1.88.418-.454.998-.682 1.741-.682.572 0 1.019.138 1.341.415.323.276.484.645.484 1.105 0 .461-.174.81-.52 1.046-.348.237-.86.355-1.535.355-.35 0-.654-.034-.912-.101.037.411.161.706.373.884.212.178.533.268.963.268.172 0 .34-.011.502-.033a6.208 6.208 0 0 0 .733-.157.304.304 0 0 1 .046-.004c.104 0 .156.07.156.212v.424c0 .098-.013.167-.04.207a.341.341 0 0 1-.162.106 3.954 3.954 0 0 1-1.429.258m-.304-2.893c.314 0 .541-.048.682-.143.142-.095.212-.241.212-.438 0-.387-.23-.58-.69-.58-.59 0-.931.362-1.024 1.087.246.05.52.074.82.074m-9.84 2.755c-.08 0-.139-.018-.176-.055-.036-.037-.055-.096-.055-.175V6.886c0-.086.019-.146.055-.18.037-.034.096-.05.176-.05h.663c.141 0 .227.067.258.202l.074.249c.325-.215.619-.367.88-.456.26-.09.53-.134.806-.134.553 0 .943.197 1.17.59a3.77 3.77 0 0 1 .885-.452c.276-.092.562-.138.857-.138.43 0 .763.12 1 .36.236.239.354.574.354 1.004v3.253c0 .08-.017.138-.05.175-.034.037-.094.055-.18.055h-.885c-.08 0-.138-.018-.175-.055-.037-.037-.055-.096-.055-.175V8.176c0-.418-.188-.627-.562-.627-.332 0-.667.08-1.005.24v3.345c0 .08-.017.138-.05.175-.034.037-.094.055-.18.055h-.884c-.08 0-.139-.018-.176-.055-.036-.037-.055-.096-.055-.175V8.176c0-.418-.187-.627-.562-.627-.344 0-.682.083-1.013.249v3.336c0 .08-.017.138-.051.175-.034.037-.094.055-.18.055zM9.987 5.927c-.234 0-.42-.064-.562-.193-.142-.129-.212-.304-.212-.525 0-.221.07-.397.212-.526.141-.129.328-.193.562-.193.233 0 .42.064.562.193a.676.676 0 0 1 .212.526c0 .22-.07.396-.212.525-.141.129-.329.193-.562.193m-.443 5.437c-.08 0-.138-.019-.175-.055-.037-.037-.055-.096-.055-.176V6.886c0-.086.018-.146.055-.18.037-.034.096-.05.175-.05h.885c.086 0 .146.016.18.05s.05.094.05.18v4.247c0 .08-.017.139-.05.176-.034.036-.094.055-.18.055zm-3.681 0c-.08 0-.139-.018-.176-.055-.036-.037-.055-.096-.055-.175V6.886c0-.086.019-.146.055-.18.037-.034.096-.05.176-.05h.663c.141 0 .227.067.258.202l.12.497c.245-.27.477-.462.695-.575.219-.114.45-.17.696-.17h.13c.085 0 .147.016.183.05.037.034.056.094.056.18v.773c0 .08-.017.139-.051.176-.034.036-.094.055-.18.055a1.93 1.93 0 0 1-.166-.01 2.968 2.968 0 0 0-.258-.009c-.14 0-.313.02-.516.06-.202.04-.374.091-.515.152v3.097c0 .08-.018.138-.051.175-.034.037-.094.055-.18.055zM.344 13.262c-.08 0-.138-.017-.175-.05-.037-.034-.055-.095-.055-.18V6.886c0-.086.018-.146.055-.18.037-.034.095-.05.175-.05h.664c.14 0 .227.067.258.202l.064.24a2.03 2.03 0 0 1 .668-.424 2.13 2.13 0 0 1 .797-.157c.596 0 1.067.218 1.414.654.348.437.521 1.026.521 1.77 0 .51-.086.955-.258 1.336-.172.38-.405.674-.7.88a1.727 1.727 0 0 1-1.014.308c-.252 0-.491-.04-.719-.12a1.74 1.74 0 0 1-.58-.331v2.018c0 .085-.017.146-.05.18-.034.033-.095.05-.18.05zm2.018-2.81c.344 0 .597-.117.76-.35.163-.234.245-.603.245-1.106 0-.51-.08-.882-.24-1.115-.16-.234-.415-.35-.765-.35-.32 0-.62.083-.903.248v2.424c.27.166.571.249.903.249Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
{:else if i === "Disney Plus"}
|
||||
<a href="https://disneyplus.com/" target="_blank">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1041 565" width={wh} height={wh}
|
||||
><path
|
||||
d="M736 366c-15 3-52 5-52 5l-5 15s19-2 32 0c0 0 5-1 5 5v11s0 3-5 4l-41 2-6 20s-2 4 3 3 42-8 47-7 11 8 9 14c-2 8-39 32-62 30 0 0-12 1-22-15-9-16 4-45 4-45s-6-13-2-18c0 0 3-2 10-3l9-19s-10 1-16-6c-6-8-7-11-2-13s48-10 78-9c0 0 10-1 19 17 0 0 4 7-3 9Zm-112 72c-4 9-14 19-27 13-12-6-32-46-32-46s-7-15-9-15c0 0-1-3-2 13-1 17 0 49-7 54-6 5-13 3-17-3s-5-20-3-44 8-50 15-58 13-2 15 0c0 0 10 9 26 34l2 5 16 24h2c2 0 1-8 1-8s-3-26-16-71c0 0-2-5 0-11 1-5 6-2 6-2s21 10 30 43c10 33 4 63 0 72Zm-100-85c-2 3-3 8-12 10 0 0-82 5-86 11 0 0-3 3 2 4 4 1 23 4 32 4s42 1 53 15c0 0 7 7 7 23s-3 21-9 27c-7 6-63 31-99-8 0 0-16-18 6-32 0 0 16-10 57 1 0 0 12 5 12 9-1 5-10 10-24 10s-23-7-21-6c1 1-15-8-20-2-5 5-4 9 1 12 13 7 61 5 75-11 0 0 6-7-3-12-8-5-33-8-43-9-9 0-44 0-49-9 0 0-5-6 1-24s46-25 63-27c0 0 48-1 57 8v6ZM388 461c-6 4-19 2-22-2-4-5-5-22-4-49s1-60 7-66c6-5 10 0 12 3 3 4 6 8 7 16l2 54s3 40-2 44Zm12-144c-17 6-28 4-38 0-5 7-7 9-10 10-5 0-9-7-10-10-1-2-3-5-1-12-9-9-10-21-8-28 2-9 18-44 68-48 0 0 24-1 28 11h1s23 1 22 21c0 21-26 47-52 56Zm-46-46c-5 8-5 13-3 16 6-9 16-22 32-33-12 1-22 6-29 17Zm68-14c-15 3-39 24-51 41 18 3 49 2 62-26 0 0 7-18-11-15Zm421 162c-9 16-35 50-70 42-12 28-21 56-27 98 0 0-1 8-8 5-7-2-18-13-20-29-2-20 7-55 25-94-5-9-9-22-6-40 0 0 5-33 38-63 0 0 4-3 7-2 2 1 1 12-1 17l-17 31s-9 17-7 31c18-27 58-81 82-64 9 6 12 19 12 33 0 12-3 25-8 35Zm-7-43s-2-11-12 1c-9 10-25 29-38 54 13-2 27-9 31-13 6-6 21-21 19-42Zm-486 14c-2 24-11 64-77 85-43 13-84 6-107 1 0 9-1 12-3 14s-16 10-24-2c-3-5-5-15-6-24-50-23-74-57-74-58-2-1-13-13-2-28 11-13 47-27 78-32 1-27 5-48 8-57 5-11 11-1 16 6 4 6 7 29 7 48 21-1 33 1 56 5 30 6 50 21 49 38-2 18-17 25-23 25-7 1-17-4-17-4-6-3 0-6 8-9 9-5 7-9 7-9-3-10-43-16-82-16l2 78c27 5 47 4 47 4s100-3 103-66c3-64-99-125-175-145-76-19-118-6-122-4s0 3 0 3 4 0 11 3c7 2 2 6 2 6-13 4-28 2-31-4-2-6 2-11 8-19 5-8 11-8 11-8 93-32 207 26 207 26 107 55 125 118 123 143Zm-282-4c-11 5-3 13-3 13 20 21 44 35 67 43 3-37 3-50 3-68-36 2-57 8-67 12ZM1041 379v13c0 3-2 5-5 5h-63v9c0 19-1 35-3 53 0 3-2 5-5 5h-13a5 5 0 0 1-5-5c2-18 3-34 3-53l-1-9h-62c-3 0-5-2-5-5v-13c0-3 2-6 5-6h62a461 461 0 0 0-8-66l4-2h14c3 0 5 2 5 4 4 22 7 43 8 64h64c3 0 5 3 5 6Z"
|
||||
/>
|
||||
<path d="M199 204h1-1Zm0 0h1-1Z" fill="none" />
|
||||
<defs>
|
||||
<radialGradient
|
||||
id="a"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
cx="942.5"
|
||||
cy="279.9"
|
||||
r="760.1"
|
||||
fx="942.5"
|
||||
fy="279.9"
|
||||
>
|
||||
<stop offset="0" stop-color="#021192" /><stop offset="0" stop-color="#021096" /><stop
|
||||
offset=".1"
|
||||
stop-color="#010cb4"
|
||||
/>
|
||||
<stop offset=".1" stop-color="#0008ce" /><stop offset=".1" stop-color="#0006d7" /><stop
|
||||
offset=".1"
|
||||
stop-color="#0004e1"
|
||||
/>
|
||||
<stop offset=".2" stop-color="#0001fa" /><stop offset=".2" stop-color="#0000fe" /><stop
|
||||
offset=".2"
|
||||
stop-color="#0f1eff"
|
||||
/>
|
||||
<stop offset="1" stop-color="#0ff" stop-opacity="0" /></radialGradient
|
||||
>
|
||||
</defs>
|
||||
<path
|
||||
d="M955 274a439 439 0 0 0-772-74c0 2 2 3 3 4l11 4h2c3 0 5-1 6-3A419 419 0 0 1 549 23a414 414 0 0 1 383 254c1 2 3 3 5 3h14c1 0 3 0 4-2v-4Zm-756-70h1-1Zm735 75h1-1Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a aria-label={i} href="https://disneyplus.com/" target="_blank">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 1041 565"
|
||||
width={wh}
|
||||
height={wh}
|
||||
><path
|
||||
d="M736 366c-15 3-52 5-52 5l-5 15s19-2 32 0c0 0 5-1 5 5v11s0 3-5 4l-41 2-6 20s-2 4 3 3 42-8 47-7 11 8 9 14c-2 8-39 32-62 30 0 0-12 1-22-15-9-16 4-45 4-45s-6-13-2-18c0 0 3-2 10-3l9-19s-10 1-16-6c-6-8-7-11-2-13s48-10 78-9c0 0 10-1 19 17 0 0 4 7-3 9Zm-112 72c-4 9-14 19-27 13-12-6-32-46-32-46s-7-15-9-15c0 0-1-3-2 13-1 17 0 49-7 54-6 5-13 3-17-3s-5-20-3-44 8-50 15-58 13-2 15 0c0 0 10 9 26 34l2 5 16 24h2c2 0 1-8 1-8s-3-26-16-71c0 0-2-5 0-11 1-5 6-2 6-2s21 10 30 43c10 33 4 63 0 72Zm-100-85c-2 3-3 8-12 10 0 0-82 5-86 11 0 0-3 3 2 4 4 1 23 4 32 4s42 1 53 15c0 0 7 7 7 23s-3 21-9 27c-7 6-63 31-99-8 0 0-16-18 6-32 0 0 16-10 57 1 0 0 12 5 12 9-1 5-10 10-24 10s-23-7-21-6c1 1-15-8-20-2-5 5-4 9 1 12 13 7 61 5 75-11 0 0 6-7-3-12-8-5-33-8-43-9-9 0-44 0-49-9 0 0-5-6 1-24s46-25 63-27c0 0 48-1 57 8v6ZM388 461c-6 4-19 2-22-2-4-5-5-22-4-49s1-60 7-66c6-5 10 0 12 3 3 4 6 8 7 16l2 54s3 40-2 44Zm12-144c-17 6-28 4-38 0-5 7-7 9-10 10-5 0-9-7-10-10-1-2-3-5-1-12-9-9-10-21-8-28 2-9 18-44 68-48 0 0 24-1 28 11h1s23 1 22 21c0 21-26 47-52 56Zm-46-46c-5 8-5 13-3 16 6-9 16-22 32-33-12 1-22 6-29 17Zm68-14c-15 3-39 24-51 41 18 3 49 2 62-26 0 0 7-18-11-15Zm421 162c-9 16-35 50-70 42-12 28-21 56-27 98 0 0-1 8-8 5-7-2-18-13-20-29-2-20 7-55 25-94-5-9-9-22-6-40 0 0 5-33 38-63 0 0 4-3 7-2 2 1 1 12-1 17l-17 31s-9 17-7 31c18-27 58-81 82-64 9 6 12 19 12 33 0 12-3 25-8 35Zm-7-43s-2-11-12 1c-9 10-25 29-38 54 13-2 27-9 31-13 6-6 21-21 19-42Zm-486 14c-2 24-11 64-77 85-43 13-84 6-107 1 0 9-1 12-3 14s-16 10-24-2c-3-5-5-15-6-24-50-23-74-57-74-58-2-1-13-13-2-28 11-13 47-27 78-32 1-27 5-48 8-57 5-11 11-1 16 6 4 6 7 29 7 48 21-1 33 1 56 5 30 6 50 21 49 38-2 18-17 25-23 25-7 1-17-4-17-4-6-3 0-6 8-9 9-5 7-9 7-9-3-10-43-16-82-16l2 78c27 5 47 4 47 4s100-3 103-66c3-64-99-125-175-145-76-19-118-6-122-4s0 3 0 3 4 0 11 3c7 2 2 6 2 6-13 4-28 2-31-4-2-6 2-11 8-19 5-8 11-8 11-8 93-32 207 26 207 26 107 55 125 118 123 143Zm-282-4c-11 5-3 13-3 13 20 21 44 35 67 43 3-37 3-50 3-68-36 2-57 8-67 12ZM1041 379v13c0 3-2 5-5 5h-63v9c0 19-1 35-3 53 0 3-2 5-5 5h-13a5 5 0 0 1-5-5c2-18 3-34 3-53l-1-9h-62c-3 0-5-2-5-5v-13c0-3 2-6 5-6h62a461 461 0 0 0-8-66l4-2h14c3 0 5 2 5 4 4 22 7 43 8 64h64c3 0 5 3 5 6Z"
|
||||
/>
|
||||
<path d="M199 204h1-1Zm0 0h1-1Z" fill="none" />
|
||||
<defs>
|
||||
<radialGradient
|
||||
id="a"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
cx="942.5"
|
||||
cy="279.9"
|
||||
r="760.1"
|
||||
fx="942.5"
|
||||
fy="279.9"
|
||||
>
|
||||
<stop offset="0" stop-color="#021192" /><stop
|
||||
offset="0"
|
||||
stop-color="#021096"
|
||||
/><stop offset=".1" stop-color="#010cb4" />
|
||||
<stop offset=".1" stop-color="#0008ce" /><stop
|
||||
offset=".1"
|
||||
stop-color="#0006d7"
|
||||
/><stop offset=".1" stop-color="#0004e1" />
|
||||
<stop offset=".2" stop-color="#0001fa" /><stop
|
||||
offset=".2"
|
||||
stop-color="#0000fe"
|
||||
/><stop offset=".2" stop-color="#0f1eff" />
|
||||
<stop offset="1" stop-color="#0ff" stop-opacity="0" /></radialGradient
|
||||
>
|
||||
</defs>
|
||||
<path
|
||||
d="M955 274a439 439 0 0 0-772-74c0 2 2 3 3 4l11 4h2c3 0 5-1 6-3A419 419 0 0 1 549 23a414 414 0 0 1 383 254c1 2 3 3 5 3h14c1 0 3 0 4-2v-4Zm-756-70h1-1Zm735 75h1-1Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
{:else if i === "Netflix"}
|
||||
<a href="https://netflix.com/" target="_blank">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width="30">
|
||||
<path
|
||||
d="M5.398 0v.006c3.028 8.556 5.37 15.175 8.348 23.596 2.344.058 4.85.398 4.854.398-2.8-7.924-5.923-16.747-8.487-24zm8.489 0v9.63L18.6 22.951c-.043-7.86-.004-15.913.002-22.95zM5.398 1.05V24c1.873-.225 2.81-.312 4.715-.398v-9.22z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a aria-label={i} href="https://netflix.com/" target="_blank">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width="30">
|
||||
<path
|
||||
d="M5.398 0v.006c3.028 8.556 5.37 15.175 8.348 23.596 2.344.058 4.85.398 4.854.398-2.8-7.924-5.923-16.747-8.487-24zm8.489 0v9.63L18.6 22.951c-.043-7.86-.004-15.913.002-22.95zM5.398 1.05V24c1.873-.225 2.81-.312 4.715-.398v-9.22z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
{:else if i === "Sky Go"}
|
||||
<a href="https://go.sky.com/" target="_blank">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width={wh} height={wh}>
|
||||
<path
|
||||
d="M7.387 13.656c0 1.423-.933 2.454-2.823 2.675-1.35.147-3.337-.025-4.294-.148-.025-.147-.074-.343-.074-.49 0-1.252.663-1.522 1.3-1.522.664 0 1.694.123 2.455.123.834 0 1.104-.295 1.104-.565 0-.368-.343-.515-1.006-.638l-1.767-.343C.785 12.453 0 11.423 0 10.343c0-1.325.933-2.454 2.798-2.65 1.398-.148 3.116.024 4.049.122.024.172.049.32.049.491 0 1.252-.663 1.522-1.276 1.522-.491 0-1.227-.099-2.086-.099-.884 0-1.227.246-1.227.54 0 .32.343.442.883.54l1.718.32c1.742.294 2.479 1.3 2.479 2.527m3.092 1.521c0 .761-.295 1.203-1.792 1.203-.196 0-.368-.025-.54-.05V6.22c0-.76.27-1.57 1.767-1.57.196 0 .393.024.565.049zm6.085 3.927c.197.098.59.22 1.105.245.859.025 1.325-.319 1.693-1.08L24 7.913a2.5 2.5 0 0 0-.957-.22c-.589 0-1.399.122-1.914 1.325l-1.497 3.534-2.945-4.81c-.196-.05-.662-.148-1.006-.148-1.03 0-1.62.393-2.233 1.031l-2.871 3.141 2.306 3.632c.418.663.982 1.006 1.89 1.006.589 0 1.104-.147 1.325-.245l-2.773-4.196 1.963-2.086 3.24 5.08Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a aria-label={i} href="https://go.sky.com/" target="_blank">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={wh}
|
||||
height={wh}
|
||||
>
|
||||
<path
|
||||
d="M7.387 13.656c0 1.423-.933 2.454-2.823 2.675-1.35.147-3.337-.025-4.294-.148-.025-.147-.074-.343-.074-.49 0-1.252.663-1.522 1.3-1.522.664 0 1.694.123 2.455.123.834 0 1.104-.295 1.104-.565 0-.368-.343-.515-1.006-.638l-1.767-.343C.785 12.453 0 11.423 0 10.343c0-1.325.933-2.454 2.798-2.65 1.398-.148 3.116.024 4.049.122.024.172.049.32.049.491 0 1.252-.663 1.522-1.276 1.522-.491 0-1.227-.099-2.086-.099-.884 0-1.227.246-1.227.54 0 .32.343.442.883.54l1.718.32c1.742.294 2.479 1.3 2.479 2.527m3.092 1.521c0 .761-.295 1.203-1.792 1.203-.196 0-.368-.025-.54-.05V6.22c0-.76.27-1.57 1.767-1.57.196 0 .393.024.565.049zm6.085 3.927c.197.098.59.22 1.105.245.859.025 1.325-.319 1.693-1.08L24 7.913a2.5 2.5 0 0 0-.957-.22c-.589 0-1.399.122-1.914 1.325l-1.497 3.534-2.945-4.81c-.196-.05-.662-.148-1.006-.148-1.03 0-1.62.393-2.233 1.031l-2.871 3.141 2.306 3.632c.418.663.982 1.006 1.89 1.006.589 0 1.104-.147 1.325-.245l-2.773-4.196 1.963-2.086 3.24 5.08Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
{:else if i === "Paramount Plus"}
|
||||
<a href="https://paramountplus.com" target="_blank">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="100"
|
||||
viewBox="-161.6 95.4 1000 230.4"
|
||||
xml:space="preserve"
|
||||
>
|
||||
<path
|
||||
d="M94 154c0-40-42-59-92-59-53 0-102 24-120 61a69 69 0 0 0-8 31 47 47 0 0 0 8 27c7 11 21 19 41 19 25 0 45-14 45-38 0 0 0-6-8-6-6 0-7 4-7 6 1 19-10 34-30 34-22 0-31-19-31-39 0-41 31-68 63-80a128 128 0 0 1 47-8c36 0 66 14 66 51 0 32-26 58-58 60l1-4c7-23 15-48 28-66a71 71 0 0 1 3-5l-2-2a76 76 0 0 0-8 5c-60 47-62 179-142 179a62 62 0 0 1-8 0c-17-3-26-14-26-30l1-10a8 8 0 0 0-7-8h-1c-7 0-10 6-11 14 0 23 17 37 44 39l9 1c62 0 101-48 117-105a138 138 0 0 0 24-5c29-8 62-28 62-62zm596 14h-22l-5 11-9 20h-10l-3 6h11l-13 29c-9 20-24 39-32 39-2 0-3 0-3-2l3-9 10-21c4-9 12-24 12-31s-5-13-14-13c-10 0-19 6-28 15l6-13h-21l-17 38c-7 14-20 36-29 36-2 0-3-1-3-2a16 16 0 0 1 2-5l11-26 18-42h-22l-18 41c-6 13-20 34-29 34a3 3 0 0 1-3-2v-1l3-9 9-21 20-42h-22l-5 10c-3 6-9 14-18 14-4 0-6-2-7-3-1-14-10-22-23-22-19 0-32 11-40 24a123 123 0 0 0-13 29c-8 13-17 23-23 23-2 0-3-1-3-3l3-9 15-31c4-9 6-15 6-22 0-6-4-11-12-11-10 0-22 6-31 16l1-5c0-7-4-11-11-11-10 0-20 6-29 16l6-14h-20l-17 38c-8 18-21 36-29 36-1 0-2-1-2-3l5-15 22-52c2-3-9-6-22-6-15 0-29 7-40 16-7 6-13 9-16 9a2 2 0 0 1-2-1c0-3 8-12 8-19 0-3-2-5-6-5-8 0-18 8-25 16l6-14h-19l-18 38c-8 18-21 37-28 37-2 0-3-1-3-3 0-3 4-12 6-16l22-52c2-3-9-6-23-6-20 0-40 14-50 28a83 83 0 0 0-20 45c0 10 5 17 16 17 13 0 23-10 28-17a36 36 0 0 0-1 4c0 8 3 13 12 13 8 0 19-7 27-17l-7 15h22l21-47c7-16 15-24 17-24a1 1 0 0 1 1 1c0 1-3 6-3 10s2 7 8 7c4 0 10-2 14-5a79 79 0 0 0-18 43c0 10 5 17 16 17 12 0 23-11 29-18a45 45 0 0 0-1 5c0 7 4 13 13 13s16-6 25-17l-7 15h22l21-45c8-20 20-31 26-31a3 3 0 0 1 3 3 20 20 0 0 1-2 7l-29 67h23l21-47c8-20 18-30 25-30 3 0 4 1 4 4a20 20 0 0 1-3 7l-19 42a39 39 0 0 0-4 13c0 7 4 12 14 12 13 0 22-9 33-24v3c1 12 8 22 25 22 20 0 36-14 46-36a78 78 0 0 0 7-25 14 14 0 0 0 7 1 19 19 0 0 0 10-2l-3 7-12 26a41 41 0 0 0-4 15c0 8 4 14 13 14s21-9 30-21a34 34 0 0 0 0 8c0 6 2 13 11 13 10 0 19-8 27-18l-7 16h22l18-41c10-23 20-36 29-36a3 3 0 0 1 4 2v1c0 4-6 14-11 25l-10 22a43 43 0 0 0-4 16c0 7 4 12 12 12 13 0 26-13 33-22a46 46 0 0 0-2 14c0 13 7 19 18 19a34 34 0 0 0 20-6c9-6 15-15 21-23l-4-4c-5 8-11 15-17 19a19 19 0 0 1-11 5c-5 0-8-3-8-9s3-13 7-24l12-27 10-24h15l3-6h-15l14-31zM64 274c-3 0-5-1-5-5 0-11 9-31 20-46 8-11 18-18 29-18l-14 31c-10 23-21 38-30 38zm137 0c-3 0-5-1-5-5 0-11 9-31 20-46 8-11 18-18 29-18l-14 31c-10 24-21 38-30 38zm244-61c0 12-11 41-23 58-5 7-9 9-13 9-5 0-7-4-6-11 1-10 10-36 23-53 5-8 9-11 14-11 4 0 5 3 5 8zm343-14 20-47h-24l-20 47h-51l-9 19h51l-20 47h24l20-47h51l8-19h-50z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a aria-label={i} href="https://paramountplus.com" target="_blank">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="100"
|
||||
viewBox="-161.6 95.4 1000 230.4"
|
||||
xml:space="preserve"
|
||||
>
|
||||
<path
|
||||
d="M94 154c0-40-42-59-92-59-53 0-102 24-120 61a69 69 0 0 0-8 31 47 47 0 0 0 8 27c7 11 21 19 41 19 25 0 45-14 45-38 0 0 0-6-8-6-6 0-7 4-7 6 1 19-10 34-30 34-22 0-31-19-31-39 0-41 31-68 63-80a128 128 0 0 1 47-8c36 0 66 14 66 51 0 32-26 58-58 60l1-4c7-23 15-48 28-66a71 71 0 0 1 3-5l-2-2a76 76 0 0 0-8 5c-60 47-62 179-142 179a62 62 0 0 1-8 0c-17-3-26-14-26-30l1-10a8 8 0 0 0-7-8h-1c-7 0-10 6-11 14 0 23 17 37 44 39l9 1c62 0 101-48 117-105a138 138 0 0 0 24-5c29-8 62-28 62-62zm596 14h-22l-5 11-9 20h-10l-3 6h11l-13 29c-9 20-24 39-32 39-2 0-3 0-3-2l3-9 10-21c4-9 12-24 12-31s-5-13-14-13c-10 0-19 6-28 15l6-13h-21l-17 38c-7 14-20 36-29 36-2 0-3-1-3-2a16 16 0 0 1 2-5l11-26 18-42h-22l-18 41c-6 13-20 34-29 34a3 3 0 0 1-3-2v-1l3-9 9-21 20-42h-22l-5 10c-3 6-9 14-18 14-4 0-6-2-7-3-1-14-10-22-23-22-19 0-32 11-40 24a123 123 0 0 0-13 29c-8 13-17 23-23 23-2 0-3-1-3-3l3-9 15-31c4-9 6-15 6-22 0-6-4-11-12-11-10 0-22 6-31 16l1-5c0-7-4-11-11-11-10 0-20 6-29 16l6-14h-20l-17 38c-8 18-21 36-29 36-1 0-2-1-2-3l5-15 22-52c2-3-9-6-22-6-15 0-29 7-40 16-7 6-13 9-16 9a2 2 0 0 1-2-1c0-3 8-12 8-19 0-3-2-5-6-5-8 0-18 8-25 16l6-14h-19l-18 38c-8 18-21 37-28 37-2 0-3-1-3-3 0-3 4-12 6-16l22-52c2-3-9-6-23-6-20 0-40 14-50 28a83 83 0 0 0-20 45c0 10 5 17 16 17 13 0 23-10 28-17a36 36 0 0 0-1 4c0 8 3 13 12 13 8 0 19-7 27-17l-7 15h22l21-47c7-16 15-24 17-24a1 1 0 0 1 1 1c0 1-3 6-3 10s2 7 8 7c4 0 10-2 14-5a79 79 0 0 0-18 43c0 10 5 17 16 17 12 0 23-11 29-18a45 45 0 0 0-1 5c0 7 4 13 13 13s16-6 25-17l-7 15h22l21-45c8-20 20-31 26-31a3 3 0 0 1 3 3 20 20 0 0 1-2 7l-29 67h23l21-47c8-20 18-30 25-30 3 0 4 1 4 4a20 20 0 0 1-3 7l-19 42a39 39 0 0 0-4 13c0 7 4 12 14 12 13 0 22-9 33-24v3c1 12 8 22 25 22 20 0 36-14 46-36a78 78 0 0 0 7-25 14 14 0 0 0 7 1 19 19 0 0 0 10-2l-3 7-12 26a41 41 0 0 0-4 15c0 8 4 14 13 14s21-9 30-21a34 34 0 0 0 0 8c0 6 2 13 11 13 10 0 19-8 27-18l-7 16h22l18-41c10-23 20-36 29-36a3 3 0 0 1 4 2v1c0 4-6 14-11 25l-10 22a43 43 0 0 0-4 16c0 7 4 12 12 12 13 0 26-13 33-22a46 46 0 0 0-2 14c0 13 7 19 18 19a34 34 0 0 0 20-6c9-6 15-15 21-23l-4-4c-5 8-11 15-17 19a19 19 0 0 1-11 5c-5 0-8-3-8-9s3-13 7-24l12-27 10-24h15l3-6h-15l14-31zM64 274c-3 0-5-1-5-5 0-11 9-31 20-46 8-11 18-18 29-18l-14 31c-10 23-21 38-30 38zm137 0c-3 0-5-1-5-5 0-11 9-31 20-46 8-11 18-18 29-18l-14 31c-10 24-21 38-30 38zm244-61c0 12-11 41-23 58-5 7-9 9-13 9-5 0-7-4-6-11 1-10 10-36 23-53 5-8 9-11 14-11 4 0 5 3 5 8zm343-14 20-47h-24l-20 47h-51l-9 19h51l-20 47h24l20-47h51l8-19h-50z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
{:else if i === "BBC iPlayer"}
|
||||
<a href="https://www.bbc.co.uk/iplayer" target="_blank">
|
||||
<svg width={80} height={wh} viewBox="0 0 605 107" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M.022 34.586h22.003V106.3H.022zM108.188 9.069C100.852 3.025 90.348 0 76.67 0H38.461v106.299h22.598V70.024h15.462c9.118 0 16.848-1.362 23.192-4.088 6.342-2.725 11.174-6.691 14.496-11.894 3.319-5.204 4.98-11.523 4.98-18.956 0-11.299-3.668-19.97-11.001-26.017Zm-17.47 38.877c-3.519 2.728-8.648 4.088-15.387 4.088H61.059V17.989H75.48c6.639 0 11.719 1.39 15.238 4.163 3.518 2.776 5.279 7.088 5.279 12.935 0 5.848-1.761 10.135-5.279 12.859ZM156.058 0h-22.895v106.299h73.295V86.378h-50.4zm127.661 0H259.04c-7.136 16.751-14.198 33.997-21.186 51.737-6.987 17.743-13.852 35.93-20.59 54.562h24.53c2.576-7.731 5.152-15.41 7.731-23.044h43.114c2.576 7.533 5.152 15.216 7.731 23.044h25.126c-6.541-17.641-13.306-35.358-20.294-53.149-6.987-17.789-14.149-35.506-21.483-53.15Zm-27.652 64.225c4.855-13.775 9.862-27.452 15.016-41.033 2.476 6.642 4.98 13.406 7.507 20.294 2.528 6.89 4.981 13.803 7.36 20.739h-29.883Zm106.708-12.121c-4.362-7.631-21.508-44.273-25.571-52.103h-26.018l39.843 71.961V106.3h22.895V71.962L413.917.001h-25.423c-4.163 7.83-21.259 44.372-25.719 52.103Zm84.238 9h44.601v-19.03h-44.601V19.03h50.25V0h-73.145v106.299h75.97V87.27h-53.075zm144.253 23.415c-4.362-7.085-8.772-14.147-13.232-21.186 6.144-2.676 10.876-6.49 14.199-11.447 3.319-4.955 4.98-10.951 4.98-17.989 0-10.902-3.742-19.276-11.224-25.126C578.504 2.924 567.777 0 553.802 0h-38.804v106.299h22.598V67.645h18.287c7.631 12.688 15.015 25.571 22.151 38.654h26.315c-4.363-7.433-8.723-14.692-13.083-21.78Zm-22.747-38.654c-3.668 2.528-9.02 3.791-16.056 3.791h-14.867V17.989h15.016c7.036 0 12.362 1.29 15.982 3.865 3.616 2.579 5.427 6.593 5.427 12.043-.001 5.452-1.836 9.441-5.502 11.968ZM0 0h22.025v18.15H0z"
|
||||
></path>
|
||||
</svg>
|
||||
</a>
|
||||
<a aria-label={i} href="https://www.bbc.co.uk/iplayer" target="_blank">
|
||||
<svg
|
||||
width={80}
|
||||
height={wh}
|
||||
viewBox="0 0 605 107"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M.022 34.586h22.003V106.3H.022zM108.188 9.069C100.852 3.025 90.348 0 76.67 0H38.461v106.299h22.598V70.024h15.462c9.118 0 16.848-1.362 23.192-4.088 6.342-2.725 11.174-6.691 14.496-11.894 3.319-5.204 4.98-11.523 4.98-18.956 0-11.299-3.668-19.97-11.001-26.017Zm-17.47 38.877c-3.519 2.728-8.648 4.088-15.387 4.088H61.059V17.989H75.48c6.639 0 11.719 1.39 15.238 4.163 3.518 2.776 5.279 7.088 5.279 12.935 0 5.848-1.761 10.135-5.279 12.859ZM156.058 0h-22.895v106.299h73.295V86.378h-50.4zm127.661 0H259.04c-7.136 16.751-14.198 33.997-21.186 51.737-6.987 17.743-13.852 35.93-20.59 54.562h24.53c2.576-7.731 5.152-15.41 7.731-23.044h43.114c2.576 7.533 5.152 15.216 7.731 23.044h25.126c-6.541-17.641-13.306-35.358-20.294-53.149-6.987-17.789-14.149-35.506-21.483-53.15Zm-27.652 64.225c4.855-13.775 9.862-27.452 15.016-41.033 2.476 6.642 4.98 13.406 7.507 20.294 2.528 6.89 4.981 13.803 7.36 20.739h-29.883Zm106.708-12.121c-4.362-7.631-21.508-44.273-25.571-52.103h-26.018l39.843 71.961V106.3h22.895V71.962L413.917.001h-25.423c-4.163 7.83-21.259 44.372-25.719 52.103Zm84.238 9h44.601v-19.03h-44.601V19.03h50.25V0h-73.145v106.299h75.97V87.27h-53.075zm144.253 23.415c-4.362-7.085-8.772-14.147-13.232-21.186 6.144-2.676 10.876-6.49 14.199-11.447 3.319-4.955 4.98-10.951 4.98-17.989 0-10.902-3.742-19.276-11.224-25.126C578.504 2.924 567.777 0 553.802 0h-38.804v106.299h22.598V67.645h18.287c7.631 12.688 15.015 25.571 22.151 38.654h26.315c-4.363-7.433-8.723-14.692-13.083-21.78Zm-22.747-38.654c-3.668 2.528-9.02 3.791-16.056 3.791h-14.867V17.989h15.016c7.036 0 12.362 1.29 15.982 3.865 3.616 2.579 5.427 6.593 5.427 12.043-.001 5.452-1.836 9.441-5.502 11.968ZM0 0h22.025v18.15H0z"
|
||||
></path>
|
||||
</svg>
|
||||
</a>
|
||||
{:else if i === "Hulu"}
|
||||
<a href="https://hulu.com" target="_blank">
|
||||
<svg width={wh} height={wh} viewBox="0 0 64 23" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M10.0599 6.54678H6.7997C5.65659 6.54678 5.08178 6.85375 5.08178 6.85375V0H0V20.98H5.08178V12.5141C5.08178 11.7654 5.69215 11.1591 6.44385 11.1591H9.40148C10.1544 11.1591 10.763 11.7654 10.763 12.5141V20.98H15.8462V11.8589C15.8462 8.02298 13.2767 6.54678 10.0599 6.54678ZM58.9167 6.54678V15.0115C58.9167 15.7614 58.3078 16.368 57.5556 16.368H54.5982C53.8453 16.368 53.2367 15.7614 53.2367 15.0115V6.54678H48.1526V15.4338C48.1526 19.0662 50.4883 20.98 53.9404 20.98H58.9173V20.9541C62.0966 20.9541 64 18.7023 64 15.4338V6.54678H58.9167ZM30.2441 15.0115C30.2441 15.7614 29.6347 16.368 28.8824 16.368H25.9247C25.1719 16.368 24.563 15.7614 24.563 15.0115V6.54678H19.4797V15.4338C19.4797 19.0662 21.8154 20.98 25.2673 20.98H30.2441V20.9541C33.4237 20.9541 35.3274 18.7023 35.3274 15.4338V6.54678H30.2441V15.0115ZM39.2015 20.98H44.2853V0H39.2015V20.98Z"
|
||||
></path>
|
||||
</svg>
|
||||
</a>
|
||||
<a aria-label={i} href="https://hulu.com" target="_blank">
|
||||
<svg
|
||||
width={wh}
|
||||
height={wh}
|
||||
viewBox="0 0 64 23"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M10.0599 6.54678H6.7997C5.65659 6.54678 5.08178 6.85375 5.08178 6.85375V0H0V20.98H5.08178V12.5141C5.08178 11.7654 5.69215 11.1591 6.44385 11.1591H9.40148C10.1544 11.1591 10.763 11.7654 10.763 12.5141V20.98H15.8462V11.8589C15.8462 8.02298 13.2767 6.54678 10.0599 6.54678ZM58.9167 6.54678V15.0115C58.9167 15.7614 58.3078 16.368 57.5556 16.368H54.5982C53.8453 16.368 53.2367 15.7614 53.2367 15.0115V6.54678H48.1526V15.4338C48.1526 19.0662 50.4883 20.98 53.9404 20.98H58.9173V20.9541C62.0966 20.9541 64 18.7023 64 15.4338V6.54678H58.9167ZM30.2441 15.0115C30.2441 15.7614 29.6347 16.368 28.8824 16.368H25.9247C25.1719 16.368 24.563 15.7614 24.563 15.0115V6.54678H19.4797V15.4338C19.4797 19.0662 21.8154 20.98 25.2673 20.98H30.2441V20.9541C33.4237 20.9541 35.3274 18.7023 35.3274 15.4338V6.54678H30.2441V15.0115ZM39.2015 20.98H44.2853V0H39.2015V20.98Z"
|
||||
></path>
|
||||
</svg>
|
||||
</a>
|
||||
{:else}
|
||||
<span>{i}</span>
|
||||
<span>{i}</span>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
a {
|
||||
display: contents;
|
||||
}
|
||||
a {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
svg {
|
||||
fill: white;
|
||||
opacity: 0.7;
|
||||
svg {
|
||||
fill: white;
|
||||
opacity: 0.7;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
span {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,32 +1,36 @@
|
||||
<script lang="ts">
|
||||
import type { TMDBContentWatchProviders } from "@/types";
|
||||
import ProviderIcon from "./ProviderIcon.svelte";
|
||||
import type { TMDBContentWatchProviders } from "@/types";
|
||||
import ProviderIcon from "./ProviderIcon.svelte";
|
||||
|
||||
export let providers: TMDBContentWatchProviders;
|
||||
interface Props {
|
||||
providers: TMDBContentWatchProviders;
|
||||
}
|
||||
|
||||
let { providers }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if providers?.flatrate?.length > 0 || providers?.free?.length > 0}
|
||||
<div class="streaming-providers">
|
||||
{#if providers?.flatrate?.length > 0}
|
||||
{#each providers.flatrate as provider}
|
||||
<ProviderIcon i={provider.provider_name} wh={40} />
|
||||
{/each}
|
||||
{/if}
|
||||
{#if providers?.free?.length > 0}
|
||||
{#each providers.free as provider}
|
||||
<ProviderIcon i={provider.provider_name} wh={40} />
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="streaming-providers">
|
||||
{#if providers?.flatrate?.length > 0}
|
||||
{#each providers.flatrate as provider}
|
||||
<ProviderIcon i={provider.provider_name} wh={40} />
|
||||
{/each}
|
||||
{/if}
|
||||
{#if providers?.free?.length > 0}
|
||||
{#each providers.free as provider}
|
||||
<ProviderIcon i={provider.provider_name} wh={40} />
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.streaming-providers {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
margin-top: auto;
|
||||
padding-top: 10px;
|
||||
}
|
||||
.streaming-providers {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
margin-top: auto;
|
||||
padding-top: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
<script lang="ts">
|
||||
import type { ContentType, TMDBMovieSimilar, TMDBShowSimilar } from "@/types";
|
||||
import HorizontalList from "../HorizontalList.svelte";
|
||||
import { watchedList } from "@/store";
|
||||
import { getWatchedDependedProps } from "@/lib/util/helpers";
|
||||
import Poster from "../poster/Poster.svelte";
|
||||
import type { ContentType, TMDBMovieSimilar, TMDBShowSimilar } from "@/types";
|
||||
import HorizontalList from "../HorizontalList.svelte";
|
||||
import { store } from "@/store.svelte";
|
||||
import { getWatchedDependedProps } from "@/lib/util/helpers";
|
||||
import Poster from "../poster/Poster.svelte";
|
||||
|
||||
$: wList = $watchedList;
|
||||
interface Props {
|
||||
type: ContentType;
|
||||
similar: TMDBShowSimilar | TMDBMovieSimilar;
|
||||
}
|
||||
|
||||
export let type: ContentType;
|
||||
export let similar: TMDBShowSimilar | TMDBMovieSimilar;
|
||||
let { type, similar }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if similar?.results?.length > 0}
|
||||
<HorizontalList title="Similar">
|
||||
{#each similar.results as content}
|
||||
<Poster
|
||||
media={{ ...content, media_type: type }}
|
||||
{...getWatchedDependedProps(content.id, type, wList)}
|
||||
small={true}
|
||||
/>
|
||||
{/each}
|
||||
</HorizontalList>
|
||||
<HorizontalList title="Similar">
|
||||
{#each similar.results as content}
|
||||
<Poster
|
||||
media={{ ...content, media_type: type }}
|
||||
{...getWatchedDependedProps(content.id, type, store.watchedList)}
|
||||
small={true}
|
||||
/>
|
||||
{/each}
|
||||
</HorizontalList>
|
||||
{/if}
|
||||
|
||||
@@ -1,68 +1,77 @@
|
||||
<script lang="ts">
|
||||
export let homepage: string | undefined;
|
||||
export let title: string;
|
||||
export let releaseYear: string | number;
|
||||
export let voteAverage: number;
|
||||
export let voteCount: number;
|
||||
interface Props {
|
||||
homepage: string | undefined;
|
||||
title: string;
|
||||
releaseYear: string | number;
|
||||
voteAverage: number;
|
||||
voteCount: number;
|
||||
}
|
||||
|
||||
// if voteAvg bigger than 10, it is out of 100, so no need to * by 10
|
||||
const vote = Math.round(voteAverage > 10 ? voteAverage : voteAverage * 10) / 10;
|
||||
let { homepage, title, releaseYear, voteAverage, voteCount }: Props =
|
||||
$props();
|
||||
|
||||
// if voteAvg bigger than 10, it is out of 100, so no need to * by 10
|
||||
const vote =
|
||||
Math.round(voteAverage > 10 ? voteAverage : voteAverage * 10) / 10;
|
||||
</script>
|
||||
|
||||
<span class="title-container">
|
||||
<span class="title">
|
||||
{#if homepage}
|
||||
<a href={homepage} target="_blank">{title}</a>
|
||||
{:else}
|
||||
<span class="t">{title}</span>
|
||||
{/if}
|
||||
<span class="year">{releaseYear}</span>
|
||||
</span>
|
||||
<span class="rating" title={`TMDB Rating: ${vote} out of 10 (based on ${voteCount} votes)`}>
|
||||
<span>*</span>
|
||||
{vote}
|
||||
</span>
|
||||
<span class="title">
|
||||
{#if homepage}
|
||||
<a href={homepage} target="_blank">{title}</a>
|
||||
{:else}
|
||||
<span class="t">{title}</span>
|
||||
{/if}
|
||||
<span class="year">{releaseYear}</span>
|
||||
</span>
|
||||
<span
|
||||
class="rating"
|
||||
title={`TMDB Rating: ${vote} out of 10 (based on ${voteCount} votes)`}
|
||||
>
|
||||
<span>*</span>
|
||||
{vote}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<style lang="scss">
|
||||
.title-container {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
.title-container {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
|
||||
.title {
|
||||
a,
|
||||
span.t {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
padding-right: 3px;
|
||||
}
|
||||
.title {
|
||||
a,
|
||||
span.t {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
padding-right: 3px;
|
||||
}
|
||||
|
||||
span.year {
|
||||
font-size: 20px;
|
||||
color: rgba($color: #fff, $alpha: 0.7);
|
||||
}
|
||||
}
|
||||
span.year {
|
||||
font-size: 20px;
|
||||
color: rgba($color: #fff, $alpha: 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
.rating {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
color: green;
|
||||
margin-left: auto;
|
||||
font-size: 22px;
|
||||
color: gold;
|
||||
font-weight: bolder;
|
||||
.rating {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
color: green;
|
||||
margin-left: auto;
|
||||
font-size: 22px;
|
||||
color: gold;
|
||||
font-weight: bolder;
|
||||
|
||||
span {
|
||||
font-family: "Rampart One";
|
||||
-webkit-text-stroke: 1px gold;
|
||||
font-size: 40px;
|
||||
line-height: 0.7;
|
||||
margin-top: 7px;
|
||||
}
|
||||
}
|
||||
}
|
||||
span {
|
||||
font-family: "Rampart One";
|
||||
-webkit-text-stroke: 1px gold;
|
||||
font-size: 40px;
|
||||
line-height: 0.7;
|
||||
margin-top: 7px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,63 +1,73 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
export let embed: string;
|
||||
export let closed: () => void;
|
||||
interface Props {
|
||||
embed: string;
|
||||
closed: () => void;
|
||||
}
|
||||
|
||||
let modalDiv: HTMLDivElement;
|
||||
let { embed, closed }: Props = $props();
|
||||
|
||||
onMount(() => {
|
||||
// For better experience on keyboard.
|
||||
// If modal shown by btn click, the btn will still be
|
||||
// focused, so clicking `esc` wont trigger until tab
|
||||
// is clicked first... which I count as not intuitive.
|
||||
if (modalDiv) {
|
||||
modalDiv.focus();
|
||||
}
|
||||
});
|
||||
let modalDiv: HTMLDivElement = $state();
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
closed();
|
||||
}
|
||||
}
|
||||
onMount(() => {
|
||||
// For better experience on keyboard.
|
||||
// If modal shown by btn click, the btn will still be
|
||||
// focused, so clicking `esc` wont trigger until tab
|
||||
// is clicked first... which I count as not intuitive.
|
||||
if (modalDiv) {
|
||||
modalDiv.focus();
|
||||
}
|
||||
});
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
closed();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={modalDiv}
|
||||
class="modal"
|
||||
on:click={closed}
|
||||
on:keydown={handleKeyDown}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
bind:this={modalDiv}
|
||||
class="modal"
|
||||
onclick={closed}
|
||||
onkeydown={handleKeyDown}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
{#if embed}
|
||||
<div class="wrapper">
|
||||
<iframe title="Video Embed" src={embed} frameborder="0" width="100%" height="100%" />
|
||||
</div>
|
||||
{/if}
|
||||
{#if embed}
|
||||
<div class="wrapper">
|
||||
<iframe
|
||||
title="Video Embed"
|
||||
src={embed}
|
||||
frameborder="0"
|
||||
width="100%"
|
||||
height="100%"
|
||||
></iframe>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.modal {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100dvw;
|
||||
height: 100dvh;
|
||||
background-color: rgba($color: #000000, $alpha: 0.5);
|
||||
z-index: 99998;
|
||||
.modal {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100dvw;
|
||||
height: 100dvh;
|
||||
background-color: rgba($color: #000000, $alpha: 0.5);
|
||||
z-index: 99998;
|
||||
|
||||
.wrapper {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
border-radius: 15px;
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
margin: 20px;
|
||||
}
|
||||
}
|
||||
.wrapper {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
border-radius: 15px;
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
margin: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,95 +1,111 @@
|
||||
<script lang="ts">
|
||||
import { decode } from "blurhash";
|
||||
import Icon from "../Icon.svelte";
|
||||
import { baseURL } from "../util/api";
|
||||
import { onMount } from "svelte";
|
||||
import type { Image } from "@/types";
|
||||
import { run } from "svelte/legacy";
|
||||
|
||||
/**
|
||||
* Users avatar.
|
||||
*/
|
||||
export let img: Image | undefined;
|
||||
export let avatarDropped: ((ev: Event) => void) | undefined = undefined;
|
||||
import { decode } from "blurhash";
|
||||
import Icon from "../Icon.svelte";
|
||||
import { baseURL } from "../util/api";
|
||||
import { onMount } from "svelte";
|
||||
import type { Image } from "@/types";
|
||||
|
||||
let bhCanvas: HTMLCanvasElement;
|
||||
let avatarInput: HTMLInputElement;
|
||||
interface Props {
|
||||
/**
|
||||
* Users avatar.
|
||||
*/
|
||||
img: Image | undefined;
|
||||
avatarDropped?: ((ev: Event) => void) | undefined;
|
||||
}
|
||||
|
||||
function avatarLoaded() {
|
||||
console.log("avatar loaded.. removing canvas");
|
||||
bhCanvas?.remove();
|
||||
}
|
||||
let { img, avatarDropped = undefined }: Props = $props();
|
||||
|
||||
$: {
|
||||
if (img?.path && img?.blurHash && bhCanvas) {
|
||||
const pixels = decode(img.blurHash, 80, 80);
|
||||
const ctx = bhCanvas.getContext("2d");
|
||||
if (ctx) {
|
||||
const imageData = ctx.createImageData(80, 80);
|
||||
imageData.data.set(pixels);
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
let bhCanvas: HTMLCanvasElement | undefined = $state();
|
||||
let avatarInput: HTMLInputElement | undefined = $state();
|
||||
|
||||
onMount(() => {
|
||||
// Ignore rest if avatarDropped not defined
|
||||
if (typeof avatarDropped !== "function") return;
|
||||
function avatarLoaded() {
|
||||
console.log("avatar loaded.. removing canvas");
|
||||
bhCanvas?.remove();
|
||||
}
|
||||
|
||||
avatarInput?.addEventListener("input", avatarDropped);
|
||||
run(() => {
|
||||
if (img?.path && img?.blurHash && bhCanvas) {
|
||||
const pixels = decode(img.blurHash, 80, 80);
|
||||
const ctx = bhCanvas.getContext("2d");
|
||||
if (ctx) {
|
||||
const imageData = ctx.createImageData(80, 80);
|
||||
imageData.data.set(pixels);
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
avatarInput?.removeEventListener("input", avatarDropped!);
|
||||
};
|
||||
});
|
||||
onMount(() => {
|
||||
// Ignore rest if avatarDropped not defined
|
||||
if (typeof avatarDropped !== "function") return;
|
||||
|
||||
avatarInput?.addEventListener("input", avatarDropped);
|
||||
|
||||
return () => {
|
||||
avatarInput?.removeEventListener("input", avatarDropped!);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class={["img-ctr", typeof avatarDropped === "function" ? "" : "no-click"].join(" ")}>
|
||||
{#if img?.path}
|
||||
<canvas bind:this={bhCanvas} />
|
||||
<img src={`${baseURL}/${img.path}`} alt="" on:load={avatarLoaded} />
|
||||
{:else}
|
||||
<Icon i="person" wh="100%" />
|
||||
{/if}
|
||||
{#if typeof avatarDropped === "function"}
|
||||
<input bind:this={avatarInput} type="file" title="" accept=".jpg,.png,.gif,.webp" />
|
||||
{/if}
|
||||
<div
|
||||
class={[
|
||||
"img-ctr",
|
||||
typeof avatarDropped === "function" ? "" : "no-click",
|
||||
].join(" ")}
|
||||
>
|
||||
{#if img?.path}
|
||||
<canvas bind:this={bhCanvas}></canvas>
|
||||
<img src={`${baseURL}/${img.path}`} alt="" onload={avatarLoaded} />
|
||||
{:else}
|
||||
<Icon i="person" wh="100%" />
|
||||
{/if}
|
||||
{#if typeof avatarDropped === "function"}
|
||||
<input
|
||||
bind:this={avatarInput}
|
||||
type="file"
|
||||
title=""
|
||||
accept=".jpg,.png,.gif,.webp"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.img-ctr {
|
||||
width: 80px;
|
||||
min-width: 80px;
|
||||
height: 80px;
|
||||
min-height: 80px;
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
fill: $text-color;
|
||||
.img-ctr {
|
||||
width: 80px;
|
||||
min-width: 80px;
|
||||
height: 80px;
|
||||
min-height: 80px;
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
fill: $text-color;
|
||||
|
||||
img {
|
||||
width: 80px;
|
||||
min-width: 80px;
|
||||
height: 80px;
|
||||
min-height: 80px;
|
||||
object-fit: cover;
|
||||
}
|
||||
img {
|
||||
width: 80px;
|
||||
min-width: 80px;
|
||||
height: 80px;
|
||||
min-height: 80px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
canvas {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
}
|
||||
canvas {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
&:not(.no-click):hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
&:not(.no-click):hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
input[type="file"] {
|
||||
opacity: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
input[type="file"] {
|
||||
opacity: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import Modal from "../Modal.svelte";
|
||||
import axios from "axios";
|
||||
import type { TrustedHeaderAuthLogoutDetailsResponse } from "@/types";
|
||||
import Spinner from "../Spinner.svelte";
|
||||
import { clearWatcharrData } from ".";
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { onClose }: Props = $props();
|
||||
|
||||
let loadingBtns = $state(true);
|
||||
let logoutUrl: string | undefined = $state(undefined);
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const r = await axios.get<TrustedHeaderAuthLogoutDetailsResponse>(
|
||||
"/auth/proxy_logout_details",
|
||||
);
|
||||
logoutUrl = r?.data?.logoutUrl;
|
||||
if (!logoutUrl?.toLowerCase()?.startsWith("http")) {
|
||||
// If no protocol in logoutUrl, set https
|
||||
logoutUrl = `https://${logoutUrl}`;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to get proxy logout details!", err);
|
||||
}
|
||||
loadingBtns = false;
|
||||
});
|
||||
|
||||
function logout() {
|
||||
clearWatcharrData();
|
||||
goto("/login?noAuto=1");
|
||||
}
|
||||
|
||||
function proxyLogout() {
|
||||
if (!logoutUrl) {
|
||||
console.error(
|
||||
"proxyLogout: Not supported without a configured logoutUrl.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
clearWatcharrData();
|
||||
window.location.replace(logoutUrl);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
title="Logout"
|
||||
desc="You logged in via single sign-on. Did you click log out by mistake?"
|
||||
{onClose}
|
||||
>
|
||||
{#if loadingBtns}
|
||||
<Spinner />
|
||||
{:else}
|
||||
<div>
|
||||
<p>
|
||||
{#if logoutUrl}
|
||||
This is likely what you want: <b
|
||||
>Fully logout of your single sign-on service and Watcharr</b
|
||||
>.
|
||||
{:else}
|
||||
Single sign-on logout has not been configured on this server. If you
|
||||
are not the server operator, let them know!
|
||||
{/if}
|
||||
</p>
|
||||
<button disabled={!logoutUrl} onclick={proxyLogout}
|
||||
>Log out of Single Sign-On Service</button
|
||||
>
|
||||
<p>
|
||||
Logging out of Watcharr will clear your local credentials and data, but <b
|
||||
>you will still be logged in to your single sign-on service</b
|
||||
>! Do <b>not</b> do this on a public machine and assume you are logged out,
|
||||
this account could still be accessible.
|
||||
</p>
|
||||
<button onclick={logout}>Log out of Watcharr</button>
|
||||
</div>
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
<style lang="scss">
|
||||
div {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
|
||||
p {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
button:first-of-type {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,14 @@
|
||||
import { clearAllStores } from "@/store.svelte";
|
||||
|
||||
/**
|
||||
* Helper to clear local data in client for logout
|
||||
* process. Logic is here so it's not duplciated,
|
||||
* this should help avoid forgetting to copy any
|
||||
* new logic to other places, which could break stuff.
|
||||
* Since this is reusable, technically unrelated logic
|
||||
* should not be included here (eg: redirecting to /login).
|
||||
*/
|
||||
export function clearWatcharrData() {
|
||||
localStorage.removeItem("token");
|
||||
clearAllStores();
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<script lang="ts">
|
||||
import Icon from "../Icon.svelte";
|
||||
import Modal from "../Modal.svelte";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { onClose }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Modal title="About" {onClose} maxWidth="400px">
|
||||
<div class="content">
|
||||
<div class="inner">
|
||||
<p>
|
||||
An open source project that helps you keep the content you are watching
|
||||
or playing organized and places you in control.
|
||||
</p>
|
||||
<div class="horizontal-icon-list">
|
||||
<a
|
||||
href="https://github.com/sbondCo/Watcharr"
|
||||
target="_blank"
|
||||
title="Github"
|
||||
>
|
||||
<Icon i="github" wh={60} />
|
||||
Source<br />
|
||||
Code
|
||||
</a>
|
||||
<a
|
||||
href="https://watcharr.app/"
|
||||
target="_blank"
|
||||
title="Watcharr Website & Documentation"
|
||||
>
|
||||
<Icon i="website" wh={60} />
|
||||
Website<br />
|
||||
& Docs
|
||||
</a>
|
||||
</div>
|
||||
<h5 class="norm">Watcharr uses the following media databases:</h5>
|
||||
<div class="horizontal-icon-list">
|
||||
<a href="https://www.themoviedb.org/" target="_blank" title="TMDB">
|
||||
<Icon i="tmdb" wh={60} />
|
||||
</a>
|
||||
<a href="https://www.igdb.com/" target="_blank" title="IGDB">
|
||||
<Icon i="igdb" wh={60} />
|
||||
</a>
|
||||
</div>
|
||||
<h5 class="norm">
|
||||
<!-- Linking to the file in `dev` branch would break every old version of
|
||||
Watcharr if the file were ever moved in the future. Using the current version
|
||||
as the branch should ensure this file is always accessible (but only as up to
|
||||
date as the current version) -->
|
||||
<a
|
||||
href="https://github.com/sbondCo/Watcharr/blob/v{__WATCHARR_VERSION__}/ATTRIBUTION.md"
|
||||
target="_blank"
|
||||
>
|
||||
See our attribution file.
|
||||
</a>
|
||||
</h5>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<style lang="scss">
|
||||
p {
|
||||
margin: 8px 0px;
|
||||
}
|
||||
|
||||
h5.norm {
|
||||
margin: 8px 0px;
|
||||
|
||||
> a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
div.horizontal-icon-list {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding-bottom: 20px;
|
||||
padding-top: 5px;
|
||||
|
||||
> a {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin: 0 10px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
transition: opacity 150ms ease-in;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,93 +1,79 @@
|
||||
<script lang="ts">
|
||||
import { wlDetailedView } from "@/store";
|
||||
import type { WLDetailedViewOption } from "@/types";
|
||||
import { get } from "svelte/store";
|
||||
import { page } from "$app/stores";
|
||||
import { store } from "@/store.svelte";
|
||||
import type { WLDetailedViewOption } from "@/types";
|
||||
import { page } from "$app/state";
|
||||
import Menu from "../Menu.svelte";
|
||||
|
||||
function detailClicked(d: WLDetailedViewOption) {
|
||||
let dv = get(wlDetailedView);
|
||||
if (dv.includes(d)) {
|
||||
dv = dv.filter((a) => a !== d);
|
||||
} else {
|
||||
dv.push(d);
|
||||
}
|
||||
wlDetailedView.update((a) => (a = dv));
|
||||
}
|
||||
|
||||
$: dve = $wlDetailedView;
|
||||
function detailClicked(d: WLDetailedViewOption) {
|
||||
if (store.wlDetailedView.includes(d)) {
|
||||
store.wlDetailedView = store.wlDetailedView.filter((a) => a !== d);
|
||||
} else {
|
||||
store.wlDetailedView.push(d);
|
||||
store.wlDetailedView = store.wlDetailedView;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={`menu${$page.url?.pathname.startsWith("/search/") ? " on-search-page" : ""}`}>
|
||||
<div class="inner">
|
||||
<h4 class="norm sm-caps">Shown Details</h4>
|
||||
<button
|
||||
class={`plain ${dve?.includes("statusRating") ? "on" : ""}`}
|
||||
on:click={() => detailClicked("statusRating")}
|
||||
>
|
||||
Status & Rating
|
||||
</button>
|
||||
<button
|
||||
class={`plain ${dve?.includes("lastWatched") ? "on" : ""}`}
|
||||
on:click={() => detailClicked("lastWatched")}
|
||||
>
|
||||
Watching Season
|
||||
</button>
|
||||
<button
|
||||
class={`plain ${dve?.includes("dateAdded") ? "on" : ""}`}
|
||||
on:click={() => detailClicked("dateAdded")}
|
||||
>
|
||||
Date Added
|
||||
</button>
|
||||
<button
|
||||
class={`plain ${dve?.includes("dateModified") ? "on" : ""}`}
|
||||
on:click={() => detailClicked("dateModified")}
|
||||
>
|
||||
Date Modified
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Menu
|
||||
conf={{
|
||||
width: "200px",
|
||||
right: "92px",
|
||||
arrowLeft: page.url?.pathname.startsWith("/search") ? "84px" : "3px",
|
||||
}}
|
||||
>
|
||||
<h4 class="norm sm-caps">Shown Details</h4>
|
||||
<button
|
||||
class={`plain ${store.wlDetailedView?.includes("statusRating") ? "on" : ""}`}
|
||||
onclick={() => detailClicked("statusRating")}
|
||||
>
|
||||
Status & Rating
|
||||
</button>
|
||||
<button
|
||||
class={`plain ${store.wlDetailedView?.includes("lastWatched") ? "on" : ""}`}
|
||||
onclick={() => detailClicked("lastWatched")}
|
||||
>
|
||||
Watching Season
|
||||
</button>
|
||||
<button
|
||||
class={`plain ${store.wlDetailedView?.includes("dateAdded") ? "on" : ""}`}
|
||||
onclick={() => detailClicked("dateAdded")}
|
||||
>
|
||||
Date Added
|
||||
</button>
|
||||
<button
|
||||
class={`plain ${store.wlDetailedView?.includes("dateModified") ? "on" : ""}`}
|
||||
onclick={() => detailClicked("dateModified")}
|
||||
>
|
||||
Date Modified
|
||||
</button>
|
||||
</Menu>
|
||||
|
||||
<style lang="scss">
|
||||
div.menu {
|
||||
width: 200px;
|
||||
right: 92px;
|
||||
h4 {
|
||||
margin-bottom: 8px;
|
||||
|
||||
&:before {
|
||||
left: 3px;
|
||||
}
|
||||
}
|
||||
&:not(:first-of-type) {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
div.menu.on-search-page:before {
|
||||
left: 86px;
|
||||
}
|
||||
button.plain {
|
||||
text-transform: capitalize;
|
||||
position: relative;
|
||||
|
||||
div.inner {
|
||||
h4 {
|
||||
margin-bottom: 8px;
|
||||
&.on::before {
|
||||
content: "\2713";
|
||||
}
|
||||
|
||||
&:not(:first-of-type) {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
& > button {
|
||||
text-transform: capitalize;
|
||||
position: relative;
|
||||
|
||||
&.on::before {
|
||||
content: "\2713";
|
||||
}
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 12px;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont;
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 12px;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont;
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||