Compare commits
278 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| d21eb06600 | |||
| 1cc8acd839 | |||
| 99ea97a160 | |||
| 77a0ba39ba | |||
| e584cf22f7 | |||
| c7bb7a264c | |||
| f84a04d29b | |||
| 31737baf75 | |||
| 9f18893149 | |||
| 7c20414229 | |||
| a33f7cc999 | |||
| b38991a7d5 | |||
| 94ef4cd4da | |||
| 09f6739686 | |||
| c3b5f44045 | |||
| b023763f63 | |||
| c24fb54c44 | |||
| 3f40bca286 | |||
| e9cc54bd73 | |||
| fd3e57e7bf | |||
| 66faf7a741 | |||
| ba28ae14f1 | |||
| 674c1a0e86 | |||
| aa754210a4 | |||
| 2fbdd5a16a | |||
| 09954d2370 | |||
| 8d34513ab7 | |||
| ed9a9aae7e | |||
| 48814c213a | |||
| 5a70b8a240 | |||
| 31ce74545a | |||
| 2b78581062 | |||
| 6d5aa0feff | |||
| 7cfbc6c344 | |||
| 64e7929714 | |||
| b2f817b639 | |||
| 7a3e5f4354 | |||
| f033a50632 | |||
| e3ace9f2fe | |||
| f07c2c797b | |||
| 1a8da9137a | |||
| 2476833a19 | |||
| 9f89d2101c | |||
| c3db5054b4 | |||
| f96562e2df | |||
| 22163950ef | |||
| c80d5cabcb | |||
| d3cfc9258f | |||
| 74ff9f04dd | |||
| 28f13836a1 | |||
| 242e70c9ec | |||
| 4bf2e0b376 | |||
| 144244bd5e | |||
| 473f8f6319 | |||
| b86ab4229f | |||
| 8f20589faa | |||
| 33c2000182 | |||
| 0505d265fe | |||
| f8e5a668f8 | |||
| 447ee2e82f | |||
| fe7730dbcc | |||
| 09ac804730 | |||
| 0f24623384 | |||
| 964c71382a | |||
| 65f8f67e4d | |||
| 8b92d42634 | |||
| 1cab95370b | |||
| b5fac676e9 | |||
| b67b0acc9a | |||
| c2fe4486d9 | |||
| 88fea6a8bd | |||
| 6b64aa0d89 | |||
| df3955cfe1 | |||
| 8359be7bee | |||
| 15f4a98ccb | |||
| 02e9654791 | |||
| 8fd570ad5c | |||
| bd10d6854a | |||
| 6249134dd6 | |||
| 5d425cadd6 | |||
| 695015b481 | |||
| 8d81b96692 | |||
| 0bc9aa612c | |||
| 968212e189 | |||
| 4ab2267867 | |||
| 2cfe39f276 | |||
| a155e1eeea | |||
| 81299f30de | |||
| 17349e8367 | |||
| f2faad3886 | |||
| 451a5923d4 | |||
| 5e3ed9ceed | |||
| 57f29c054a | |||
| be10df1c57 | |||
| baba2fad4d | |||
| bad4f505ef | |||
| 4390f02f4d | |||
| a790e0d4e0 | |||
| 3ccd983a5e | |||
| 760de9dead | |||
| b997b421fa | |||
| 208a66f303 | |||
| d431f4a434 | |||
| b571239312 | |||
| 126717ecdc | |||
| 374fc59499 | |||
| c8db3093be | |||
| e182799ca9 | |||
| 870f2614b5 | |||
| 0e88f40a99 | |||
| faead2ebd3 | |||
| 416098b53d | |||
| 73c756a780 | |||
| b7d261bad4 | |||
| c8203187a7 | |||
| 3014019692 | |||
| 9e2f60fa56 | |||
| 7642655ef0 | |||
| a28d199431 | |||
| cf387d1c0a | |||
| 64f31a234e | |||
| 09d1c513d6 | |||
| 89f703ae9b | |||
| ae09de6f71 | |||
| 7c39a0649a | |||
| 035d68a103 | |||
| 3b501e73c8 | |||
| d7353fc2a7 | |||
| c65579c0f6 | |||
| 0a886f0062 | |||
| a347e6dc97 | |||
| a9088c9626 | |||
| a19d61a16d | |||
| 003c8e724c | |||
| 053fc28371 | |||
| 9d8ae819a7 | |||
| 658b9f356c | |||
| becf9dd601 | |||
| b74ecbddf7 | |||
| 38e52bd821 | |||
| c3e08b2dc0 | |||
| f0019b540a | |||
| b0a72bf636 | |||
| 1701dd560e | |||
| 7ddb880843 | |||
| f0c929d743 | |||
| a7fc789056 | |||
| 11cf23acb8 | |||
| c05c271e1b | |||
| 9dd7ecbff1 | |||
| 96ec6f2c8e | |||
| ae707df709 | |||
| 4bbc7ad869 | |||
| 953dcf0034 |
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ""
|
||||
labels: bug
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
<!-- It's harder to track bugs when multiple are reported in one issue. If you have more than one (unrelated) bugs to report, please make one issue per bug. -->
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Platform (please complete the following information):**
|
||||
|
||||
- OS: [e.g. Linux, Windows, Android, iOS]
|
||||
- Browser (latest version): [e.g. chrome, safari, firefox]
|
||||
- Watcharr Version: [e.g. v1.45.0]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ""
|
||||
labels: enhancement
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
<!-- Feature requests can be hard to track when two or more unrelated ideas are included in one issue. Please make a new issue for each idea (make as many as you need to). -->
|
||||
|
||||
**Idea Overview**
|
||||
A quick overview of the idea you are proposing.
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
name: Documentation
|
||||
about: Help us with our documentation. Is something important missing?
|
||||
title: ""
|
||||
labels: documentation
|
||||
assignees: IRHM
|
||||
---
|
||||
|
||||
<!-- Did you know: You can click `Edit this page` at the bottom of any page on the docs to propose exact changes? If this doesn't apply to you, continue below and fill out the issue. -->
|
||||
|
||||
**What is missing or needs updating on the documentation site?**
|
||||
Please describe in detail what is missing or needs updating on the documentation site.
|
||||
|
||||
**(optional) How would you write/update this doc?**
|
||||
How would you write out this documentation?
|
||||
@@ -0,0 +1,31 @@
|
||||
name: Test PR (server)
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- "**"
|
||||
tags-ignore:
|
||||
- v*
|
||||
paths:
|
||||
- ".github/workflows/test-pr-server.yml"
|
||||
- "server/**"
|
||||
|
||||
jobs:
|
||||
format_check_go:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
|
||||
- name: gofmt test
|
||||
run: |
|
||||
outp=$(gofmt -l .)
|
||||
if [ -n "$outp" ]; then
|
||||
echo "Need to fix formatting!"
|
||||
echo "$outp"
|
||||
exit 1
|
||||
else
|
||||
echo "Everything looks formatted"
|
||||
exit 0
|
||||
fi
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Test PR Changes
|
||||
name: Test PR (web)
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -6,6 +6,14 @@ on:
|
||||
- "**"
|
||||
tags-ignore:
|
||||
- v*
|
||||
paths:
|
||||
- ".github/workflows/test-pr-web.yml"
|
||||
- "package*.json"
|
||||
- "svelte.config.json"
|
||||
- "vite.config.json"
|
||||
- "tsconfig.json"
|
||||
- ".prettier*"
|
||||
- "src/**"
|
||||
|
||||
jobs:
|
||||
linter_check:
|
||||
@@ -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.21-alpine AS server
|
||||
#
|
||||
FROM golang:1.22-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).
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
**Please do not report security vulnerabilities through public GitHub issues.**
|
||||
|
||||
You can reach out directly to [@irhm:matrix.org](https://matrix.to/#/@irhm:matrix.org) on matrix to discuss the vulnerability or you may [draft a new security advisory on this repository](https://github.com/sbondCo/Watcharr/security/advisories/new).
|
||||
@@ -1,4 +1,6 @@
|
||||
# Website
|
||||
# Watcharr Docs
|
||||
|
||||
View the live site at [watcharr.app](https://watcharr.app/).
|
||||
|
||||
This website is built using [Docusaurus 2](https://docusaurus.io/), a modern static website generator.
|
||||
|
||||
|
||||
@@ -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,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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
sidebar_position: 100
|
||||
description: Backing up your server.
|
||||
---
|
||||
|
||||
# Backing Up
|
||||
|
||||
Watcharr cannot handle backing up for you, but its simple structure should make it easy to slide it into your existing backup routine.
|
||||
|
||||
Once you have created your first backup, it's recommended that you try restoring from that backup to make sure everything is correct and will work in case you need to restore from it in the future.
|
||||
|
||||
:::danger Shutdown your server!
|
||||
Always shutdown Watcharr before backing up its data, otherwise you run the risk of corrupting files (mainly your database).
|
||||
:::
|
||||
|
||||
## Simple Steps
|
||||
|
||||
:::success Recommended
|
||||
This is the recommended way to backup.
|
||||
:::
|
||||
|
||||
Here are the steps that are run daily for the `beta.watcharr.app` instance.
|
||||
|
||||
1. Shutdown Watcharr.
|
||||
2. Copy and backup the entire `data` folder (default location is `./data`, backup whichever folder your server is configured to store its data in).
|
||||
3. Start Watcharr.
|
||||
|
||||
I call these the simple steps because they simply backup your entire server, if you ever need to restore your server, it will start up from its exact state at backup.
|
||||
|
||||
## Advanced Steps
|
||||
|
||||
:::danger Discouraged
|
||||
Backing up this way is discouraged because new important files could be added later and missed.
|
||||
:::
|
||||
|
||||
If you don't care about the warning not to backup this way, here are the "important" files that you can single out for backup:
|
||||
|
||||
- `watcharr.db` Your database, holds all users, their watchlists, etc.
|
||||
- `watcharr.json` Server config.
|
||||
- `img/up` Profile picture uploads.
|
||||
- `img/games` Game posters (not exactly important, but scenarios in which this folder is not backed up have not been tested, only relevant for servers with game support enabled).
|
||||
@@ -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.41.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",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"format": "prettier --write .",
|
||||
"server": "cd ./server && MODE=DEV go run ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^5.0.1",
|
||||
"@sveltejs/kit": "^2.5.10",
|
||||
"@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.41.0",
|
||||
"prettier": "^3.3.2",
|
||||
"prettier-plugin-svelte": "^3.2.3",
|
||||
"svelte": "^4.2.17",
|
||||
"svelte-check": "^3.7.1",
|
||||
"svelte-eslint-parser": "^0.39.2",
|
||||
"svelte-preprocess": "^5.1.4",
|
||||
"tslib": "^2.6.2",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.2.11"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"axios": "^1.7.2",
|
||||
"blurhash": "^2.0.5",
|
||||
"papaparse": "^5.4.1"
|
||||
}
|
||||
"name": "watcharr",
|
||||
"version": "2.0.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",
|
||||
"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.16.0",
|
||||
"@types/papaparse": "^5.3.15",
|
||||
"@typescript-eslint/eslint-plugin": "^8.18.2",
|
||||
"@typescript-eslint/parser": "^8.18.2",
|
||||
"@vite-pwa/sveltekit": "^0.6.6",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-svelte": "^2.45.1",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-svelte": "^3.2.6",
|
||||
"sass": "^1.83.0",
|
||||
"svelte": "^5.17.3",
|
||||
"svelte-check": "^4.1.3",
|
||||
"svelte-eslint-parser": "^0.42.0",
|
||||
"svelte-preprocess": "^6.0.3",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.7"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"axios": "^1.7.4",
|
||||
"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 |
@@ -13,7 +13,6 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -27,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
|
||||
@@ -62,7 +63,10 @@ type User struct {
|
||||
// Users third party integrations (minus jellyfin for now)
|
||||
UserServices []UserServices `json:"-"`
|
||||
Watched []Watched
|
||||
Permissions int `gorm:"default:1" json:"-"`
|
||||
// All Tags
|
||||
Tags []Tag `json:"-"`
|
||||
// Users permissions
|
||||
Permissions int `gorm:"default:1" json:"-"`
|
||||
// All user settings cols, in another struct for reusability
|
||||
UserSettings
|
||||
}
|
||||
@@ -93,6 +97,11 @@ type UserSettings struct {
|
||||
Country *string `gorm:"default:'US'" json:"country"`
|
||||
// Does the user want show, season and episode automations enabled.
|
||||
AutomateShowStatuses *bool `gorm:"default:true" json:"automateShowStatuses"`
|
||||
// Rating system user wants to use (frontend only).
|
||||
// RatingSystem enum in frontend maxes out at 3, so just max=3 on this and we should be gut.
|
||||
RatingSystem *int `json:"ratingSystem" binding:"omitempty,max=3"`
|
||||
// Rating step for supported rating systems (frontend only, enum goes up to 2).
|
||||
RatingStep *int `json:"ratingStep" binding:"omitempty,max=2"`
|
||||
}
|
||||
|
||||
// Holds third party service auth tokens for users.
|
||||
@@ -153,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 {
|
||||
@@ -199,7 +209,7 @@ func AuthRequired(db *gorm.DB) gin.HandlerFunc {
|
||||
}
|
||||
// Parse token
|
||||
token, err := jwt.ParseWithClaims(atoken, &TokenClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
return []byte(os.Getenv("JWT_SECRET")), nil
|
||||
return []byte(Config.JWT_SECRET), nil
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("AuthRequired failed to parse token", "error", err)
|
||||
@@ -579,7 +589,7 @@ func signJWT(user *User) (token string, err error) {
|
||||
})
|
||||
|
||||
// Sign and get the complete encoded token as a string using the secret
|
||||
return jwt.SignedString([]byte(os.Getenv("JWT_SECRET")))
|
||||
return jwt.SignedString([]byte(Config.JWT_SECRET))
|
||||
}
|
||||
|
||||
func hashPassword(password string, p *ArgonParams) (encodedHash string, err error) {
|
||||
|
||||
@@ -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)
|
||||
@@ -90,7 +92,7 @@ func saveContent(db *gorm.DB, c *Content, onlyUpdate bool) error {
|
||||
// If row created, download the image
|
||||
if res.RowsAffected > 0 {
|
||||
slog.Debug("saveContent: Downloading poster.")
|
||||
err := download("https://image.tmdb.org/t/p/w500"+c.PosterPath, path.Join(DataPath, "img", c.PosterPath))
|
||||
err := download("https://image.tmdb.org/t/p/w500"+c.PosterPath, path.Join(DataPath, "img", c.PosterPath), false)
|
||||
if err != nil {
|
||||
slog.Error("saveContent: Failed to download content image!", "error", err.Error())
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -28,7 +28,7 @@ type FollowThoughts struct {
|
||||
FollowedUser PublicUser `json:"followedUser"`
|
||||
Thoughts string `json:"thoughts"`
|
||||
Status WatchedStatus `json:"status"`
|
||||
Rating int8 `json:"rating"`
|
||||
Rating float64 `json:"rating"`
|
||||
}
|
||||
|
||||
func followUser(db *gorm.DB, currentUserId uint, toFollowUserId uint) (FollowPublic, error) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -38,7 +38,7 @@ type Game struct {
|
||||
|
||||
type PlayedAddRequest struct {
|
||||
Status WatchedStatus `json:"status"`
|
||||
Rating int8 `json:"rating" binding:"max=10"`
|
||||
Rating float64 `json:"rating" binding:"max=10"`
|
||||
IgdbID int `json:"igdbId" binding:"required"`
|
||||
}
|
||||
|
||||
|
||||
@@ -1,57 +1,56 @@
|
||||
module github.com/sbondCo/Watcharr
|
||||
|
||||
go 1.21
|
||||
go 1.22
|
||||
|
||||
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.3
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/go-co-op/gocron/v2 v2.7.0
|
||||
github.com/go-co-op/gocron/v2 v2.14.2
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/joho/godotenv v1.5.1
|
||||
golang.org/x/crypto v0.24.0
|
||||
golang.org/x/crypto v0.32.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
gorm.io/driver/sqlite v1.5.6
|
||||
gorm.io/gorm v1.25.10
|
||||
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/bytedance/sonic v1.12.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.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/gabriel-vasile/mimetype v1.4.7 // indirect
|
||||
github.com/gin-contrib/sse v0.1.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/google/go-cmp v0.5.9 // indirect
|
||||
github.com/go-playground/validator/v10 v10.23.0 // indirect
|
||||
github.com/goccy/go-json v0.10.4 // indirect
|
||||
github.com/gomodule/redigo v1.9.2 // 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/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.9 // 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-20240416160154-fe59bbe5cc7f // indirect
|
||||
golang.org/x/net v0.25.0 // indirect
|
||||
golang.org/x/sys v0.21.0 // indirect
|
||||
golang.org/x/text v0.16.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
golang.org/x/arch v0.12.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect
|
||||
golang.org/x/net v0.33.0 // indirect
|
||||
golang.org/x/sys v0.29.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
google.golang.org/protobuf v1.36.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -2,10 +2,11 @@ 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.12.6 h1:/isNmCUF2x3Sh8RAp/4mh4ZGkcFAX/hLrzrK3AvpRzk=
|
||||
github.com/bytedance/sonic v1.12.6/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/bytedance/sonic/loader v0.2.1 h1:1GgorWTqf12TA8mma4DDSbaQigE2wOgQo7iCjjJv3+E=
|
||||
github.com/bytedance/sonic/loader v0.2.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=
|
||||
@@ -13,34 +14,34 @@ github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQ
|
||||
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/gabriel-vasile/mimetype v1.4.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA=
|
||||
github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU=
|
||||
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.3 h1:hV+a5xp8hwJoTw7OY+a70FsL8JkVVFTXw9EcfrYUdns=
|
||||
github.com/gin-contrib/cors v1.7.3/go.mod h1:M3bcKZhxzsvI+rlRSkkxHyljJt1ESd93COUvemZ79j4=
|
||||
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/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.7.0 h1:dFwVZx+M+7p3brj5JPrqmvmlt/X45DiQi6lFZ0xLIQc=
|
||||
github.com/go-co-op/gocron/v2 v2.7.0/go.mod h1:ckPQw96ZuZLRUGu88vVpd9a6d9HakI14KWahFZtGvNw=
|
||||
github.com/go-co-op/gocron/v2 v2.14.2 h1:S6CbI7MVfD3S/aPJNLoSg2YcGyEqzEMwUopDejuT4Oc=
|
||||
github.com/go-co-op/gocron/v2 v2.14.2/go.mod h1:ZF70ZwEqz0OO4RBXE1sNxnANy/zvwLcattWEFsqpKig=
|
||||
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/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o=
|
||||
github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
|
||||
github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
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/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
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=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
@@ -55,8 +56,8 @@ github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK
|
||||
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.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
|
||||
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
|
||||
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 +76,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=
|
||||
@@ -88,38 +89,34 @@ github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6po
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
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.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
|
||||
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
|
||||
golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f h1:99ci1mjWVBWwJiEKYY6jWa4d2nTQVIEhZIptnrVb1XY=
|
||||
golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI=
|
||||
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.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg=
|
||||
golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||
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.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
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.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
|
||||
google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
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 +125,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/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s=
|
||||
gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
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=
|
||||
|
||||
@@ -30,7 +30,7 @@ type ImportRequest struct {
|
||||
Name string `json:"name"`
|
||||
TmdbID int `json:"tmdbId"`
|
||||
Type ContentType `json:"type"`
|
||||
Rating int8 `json:"rating" binding:"max=10"`
|
||||
Rating float64 `json:"rating" binding:"max=10"`
|
||||
RatingCustomDate *time.Time `json:"ratingCustomDate"`
|
||||
Status WatchedStatus `json:"status"`
|
||||
Thoughts string `json:"thoughts"`
|
||||
@@ -38,6 +38,8 @@ type ImportRequest struct {
|
||||
Activity []Activity `json:"activity"`
|
||||
WatchedEpisodes []WatchedEpisode `json:"watchedEpisodes"`
|
||||
WatchedSeason []WatchedSeason `json:"watchedSeasons"`
|
||||
Tags []TagAddRequest `json:"tags"`
|
||||
ImdbID string `json:"imdbId"`
|
||||
}
|
||||
|
||||
type ImportResponse struct {
|
||||
@@ -70,6 +72,52 @@ 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 {
|
||||
slog.Error("import: content search failed", "error", err)
|
||||
@@ -227,5 +275,39 @@ func successfulImport(db *gorm.DB, userId uint, contentId int, contentType Conte
|
||||
w.WatchedEpisodes = ws.WatchedEpisodes
|
||||
}
|
||||
}
|
||||
// Import tags, if any
|
||||
if len(ar.Tags) > 0 {
|
||||
// Create tags if they dont exist
|
||||
slog.Debug("successfulImport: Importing tags")
|
||||
for _, v := range ar.Tags {
|
||||
// Check if tag exists
|
||||
var t Tag
|
||||
t, err := getTagByNameAndColor(db, userId, v.Name, v.Color, v.BgColor)
|
||||
if err != nil && err.Error() != "tag does not exist" {
|
||||
slog.Error("successfulImport: Failed to check for an existing tag", "name", v.Name, "error", err)
|
||||
continue
|
||||
}
|
||||
if t.ID == 0 {
|
||||
tag, err := addTag(db, userId, TagAddRequest{
|
||||
Name: v.Name,
|
||||
Color: v.Color,
|
||||
BgColor: v.BgColor,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("successfulImport: Failed to add a tag.", "name", v.Name, "error", err)
|
||||
continue
|
||||
}
|
||||
t = tag
|
||||
}
|
||||
|
||||
// Associate the watched entry with the tag
|
||||
err = addWatchedToTag(db, userId, t.ID, w.ID)
|
||||
if err != nil {
|
||||
slog.Error("successfulImport: Failed to associate watched entry with tag.", "error", err)
|
||||
continue
|
||||
}
|
||||
w.Tags = append(w.Tags, t)
|
||||
}
|
||||
}
|
||||
return ImportResponse{Type: IMPORT_SUCCESS, WatchedEntry: w}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
// Trakt.tv importer.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type TraktImportRequest struct {
|
||||
// Username of public trakt user to import from.
|
||||
Username string `json:"username" binding:"required"`
|
||||
}
|
||||
|
||||
type TraktUser struct {
|
||||
Username string `json:"username"`
|
||||
Private bool `json:"private"`
|
||||
IDs struct {
|
||||
Slug string `json:"slug"`
|
||||
} `json:"ids"`
|
||||
}
|
||||
|
||||
type TraktHistory struct {
|
||||
ID int64 `json:"id"`
|
||||
WatchedAt time.Time `json:"watched_at"`
|
||||
Action string `json:"action"`
|
||||
Type string `json:"type"`
|
||||
Show TraktListShow `json:"show,omitempty"`
|
||||
Episode TraktListEpisode `json:"episode,omitempty"`
|
||||
Movie TraktListMovie `json:"movie,omitempty"`
|
||||
}
|
||||
|
||||
type TraktWatchlist []struct {
|
||||
Rank int `json:"rank"`
|
||||
ID int `json:"id"`
|
||||
ListedAt time.Time `json:"listed_at"`
|
||||
Notes string `json:"notes"`
|
||||
Type string `json:"type"`
|
||||
Show TraktListShow `json:"show,omitempty"`
|
||||
Episode TraktListEpisode `json:"episode,omitempty"`
|
||||
Movie TraktListMovie `json:"movie,omitempty"`
|
||||
}
|
||||
|
||||
type TraktRatings []struct {
|
||||
Rating int `json:"rating"`
|
||||
Type string `json:"type"`
|
||||
Show TraktListShow `json:"show,omitempty"`
|
||||
Episode TraktListEpisode `json:"episode,omitempty"`
|
||||
Movie TraktListMovie `json:"movie,omitempty"`
|
||||
}
|
||||
|
||||
type TraktListShow struct {
|
||||
Title string `json:"title"`
|
||||
Year int `json:"year"`
|
||||
Ids struct {
|
||||
Trakt int `json:"trakt"`
|
||||
Slug string `json:"slug"`
|
||||
Tmdb int `json:"tmdb"`
|
||||
} `json:"ids"`
|
||||
}
|
||||
|
||||
type TraktListEpisode struct {
|
||||
Season int `json:"season"`
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
Ids struct {
|
||||
Trakt int `json:"trakt"`
|
||||
Slug string `json:"slug"`
|
||||
Tmdb int `json:"tmdb"`
|
||||
} `json:"ids"`
|
||||
}
|
||||
|
||||
type TraktListMovie struct {
|
||||
Title string `json:"title"`
|
||||
Year int `json:"year"`
|
||||
Ids struct {
|
||||
Trakt int `json:"trakt"`
|
||||
Slug string `json:"slug"`
|
||||
Tmdb int `json:"tmdb"`
|
||||
} `json:"ids"`
|
||||
}
|
||||
|
||||
type TraktImportResponse struct {
|
||||
JobId string `json:"jobId"`
|
||||
}
|
||||
|
||||
// TODO we could support trakt list imports when we support a similar feature (tags will function as custom lists when done #199)
|
||||
func startTraktImport(db *gorm.DB, jobId string, userId uint, traktUsername string) {
|
||||
// Get trakt user. We want to get their profile `slug` for use in
|
||||
// next requests and we can check their profile isn't private while here.
|
||||
var traktUser TraktUser
|
||||
_, err := traktAPIRequest("users/"+traktUsername, map[string]string{}, &traktUser)
|
||||
if err != nil {
|
||||
slog.Error("startTraktImport: Failed to get users profile", "error", err, "trakt_user", traktUser)
|
||||
addJobError(jobId, userId, "failed to request trakt profile from api")
|
||||
updateJobStatus(jobId, userId, JOB_CANCELLED)
|
||||
return
|
||||
}
|
||||
if traktUser.Private {
|
||||
slog.Error("startTraktImport: Users profile is private. Cannot continue with import.")
|
||||
addJobError(jobId, userId, "trakt profile is private")
|
||||
updateJobStatus(jobId, userId, JOB_CANCELLED)
|
||||
return
|
||||
}
|
||||
userSlug := traktUser.IDs.Slug
|
||||
// Everything will be added to this map for importing at the end.
|
||||
toImport := map[string]ImportRequest{}
|
||||
// Process all history for this user (in chunks of 1000).
|
||||
var history []TraktHistory
|
||||
slog.Debug("startTraktImport: Getting first history page")
|
||||
historyHeaders, err := traktAPIRequest("users/"+userSlug+"/history", map[string]string{"limit": "1000"}, &history)
|
||||
if err != nil {
|
||||
// FATAL if we can't get the users history, we probably shouldn't continue (to ratings/watchlist below).
|
||||
slog.Error("startTraktImport: Failed to get users history", "error", err)
|
||||
addJobError(jobId, userId, "failed to get your history")
|
||||
return
|
||||
} else {
|
||||
pageCount := historyHeaders.Get("x-pagination-page-count")
|
||||
slog.Debug("startTraktImport: Got first history page", "page_count", pageCount)
|
||||
if pageCount == "" {
|
||||
slog.Error("startTraktImport: Failed to get history page count!", "page_count", pageCount)
|
||||
addJobError(jobId, userId, "Failed to get history page count")
|
||||
return
|
||||
}
|
||||
pageCountNum, err := strconv.Atoi(pageCount)
|
||||
if err != nil {
|
||||
slog.Error("startTraktImport: Failed to parse history page count into an int!", "error", err)
|
||||
addJobError(jobId, userId, "Failed to parse history page count: "+pageCount)
|
||||
return
|
||||
}
|
||||
rProc := func(v TraktHistory) {
|
||||
var collectingText string
|
||||
if v.Type == "episode" {
|
||||
collectingText = fmt.Sprintf("%s S%dE%d", v.Show.Title, v.Episode.Season, v.Episode.Number)
|
||||
} else if v.Type == "show" {
|
||||
collectingText = v.Show.Title
|
||||
} else if v.Type == "movie" {
|
||||
collectingText = v.Movie.Title
|
||||
}
|
||||
if collectingText != "" {
|
||||
updateJobCurrentTask(jobId, userId, "collecting "+collectingText)
|
||||
}
|
||||
err = processTraktHistoryItem(v, toImport)
|
||||
if err != nil {
|
||||
addJobError(jobId, userId, err.Error())
|
||||
}
|
||||
}
|
||||
// Process first page of history (next pages processed below)
|
||||
for _, v := range history {
|
||||
rProc(v)
|
||||
}
|
||||
for i := range pageCountNum {
|
||||
slog.Debug("startTraktImport: Getting history page", "page_num", i)
|
||||
_, err := traktAPIRequest("users/"+userSlug+"/history", map[string]string{"limit": "1000", "page": strconv.Itoa(i)}, &history)
|
||||
if err != nil {
|
||||
slog.Error("startTraktImport: Failed to get a history page", "page_num", i, "error", err)
|
||||
addJobError(jobId, userId, "Failed to get history page: "+strconv.Itoa(i))
|
||||
} else {
|
||||
for _, v := range history {
|
||||
rProc(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
slog.Info("startTraktImport: Finished processing all history")
|
||||
history = nil // clear whatever is lingering in the history slice
|
||||
}
|
||||
// Get watchlist for PLANNED items
|
||||
slog.Info("startTraktImport: Getting whole watchlist")
|
||||
var watchlist TraktWatchlist
|
||||
_, err = traktAPIRequest("users/"+userSlug+"/watchlist", map[string]string{}, &watchlist)
|
||||
if err != nil {
|
||||
slog.Error("startTraktImport: Failed to get users watchlist! Cannot import planned content.", "error", err)
|
||||
addJobError(jobId, userId, "failed to get your watchlist (planned items cannot be imported)")
|
||||
} else {
|
||||
slog.Debug("startTraktImport: Successfully got whole watchlist")
|
||||
for _, v := range watchlist {
|
||||
slog.Debug("startTraktImport: Processing watchlist item", "item", v)
|
||||
var (
|
||||
title string
|
||||
contentType ContentType
|
||||
tmdbId int
|
||||
)
|
||||
if v.Type == "show" || v.Type == "episode" {
|
||||
title = v.Show.Title
|
||||
tmdbId = v.Show.Ids.Tmdb
|
||||
contentType = SHOW
|
||||
if v.Type == "episode" {
|
||||
title = v.Episode.Title
|
||||
}
|
||||
} else if v.Type == "movie" {
|
||||
title = v.Movie.Title
|
||||
tmdbId = v.Movie.Ids.Tmdb
|
||||
contentType = MOVIE
|
||||
}
|
||||
updateJobCurrentTask(jobId, userId, "setting status for "+title)
|
||||
mapKey := makeTraktMapKey(contentType, tmdbId)
|
||||
if mv, ok := toImport[mapKey]; ok {
|
||||
// If item already exists in toImport, set its status to planned.
|
||||
if v.Type == "episode" {
|
||||
// For episode entries, we have to find the WatchedEpisode to set its status to planned.
|
||||
weFound := false
|
||||
for i, we := range mv.WatchedEpisodes {
|
||||
if we.SeasonNumber == v.Episode.Season && we.EpisodeNumber == v.Episode.Number {
|
||||
we.Status = PLANNED
|
||||
mv.WatchedEpisodes[i] = we
|
||||
weFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !weFound {
|
||||
mv.WatchedEpisodes = append(mv.WatchedEpisodes, WatchedEpisode{
|
||||
SeasonNumber: v.Episode.Season,
|
||||
EpisodeNumber: v.Episode.Number,
|
||||
Status: PLANNED,
|
||||
GormModel: GormModel{
|
||||
CreatedAt: v.ListedAt,
|
||||
},
|
||||
})
|
||||
}
|
||||
toImport[mapKey] = mv
|
||||
} else {
|
||||
mv.Status = PLANNED
|
||||
if v.Notes != "" {
|
||||
// episodes dont support notes in watcharr
|
||||
mv.Thoughts = v.Notes
|
||||
}
|
||||
toImport[mapKey] = mv
|
||||
}
|
||||
} else {
|
||||
// If the item does not exist in toImport, create it and set it to planned.
|
||||
ti := ImportRequest{
|
||||
Type: contentType,
|
||||
TmdbID: tmdbId,
|
||||
Status: PLANNED,
|
||||
}
|
||||
if v.Type == "episode" {
|
||||
ti.WatchedEpisodes = []WatchedEpisode{{
|
||||
SeasonNumber: v.Episode.Season,
|
||||
EpisodeNumber: v.Episode.Number,
|
||||
Status: PLANNED,
|
||||
GormModel: GormModel{
|
||||
CreatedAt: v.ListedAt,
|
||||
},
|
||||
}}
|
||||
} else {
|
||||
// episodes dont support notes in watcharr
|
||||
ti.Thoughts = v.Notes
|
||||
}
|
||||
toImport[mapKey] = ti
|
||||
}
|
||||
}
|
||||
}
|
||||
// Process ratings
|
||||
slog.Info("startTraktImport: Getting all ratings")
|
||||
var ratings TraktRatings
|
||||
_, err = traktAPIRequest("users/"+userSlug+"/ratings", map[string]string{}, &ratings)
|
||||
if err != nil {
|
||||
slog.Error("startTraktImport: Failed to get users ratings!", "error", err)
|
||||
addJobError(jobId, userId, "failed to get your ratings (content ratings cannot be imported)")
|
||||
} else {
|
||||
slog.Debug("startTraktImport: Successfully got all ratings")
|
||||
for _, v := range ratings {
|
||||
slog.Debug("startTraktImport: Processing rating item", "item", v)
|
||||
var (
|
||||
title string
|
||||
contentType ContentType
|
||||
tmdbId int
|
||||
traktSlug string
|
||||
)
|
||||
if v.Type == "show" || v.Type == "episode" {
|
||||
title = v.Show.Title
|
||||
tmdbId = v.Show.Ids.Tmdb
|
||||
traktSlug = v.Show.Ids.Slug
|
||||
contentType = SHOW
|
||||
if v.Type == "episode" {
|
||||
title = v.Episode.Title
|
||||
traktSlug = v.Episode.Ids.Slug
|
||||
}
|
||||
} else if v.Type == "movie" {
|
||||
title = v.Movie.Title
|
||||
tmdbId = v.Movie.Ids.Tmdb
|
||||
contentType = MOVIE
|
||||
traktSlug = v.Movie.Ids.Slug
|
||||
}
|
||||
updateJobCurrentTask(jobId, userId, fmt.Sprintf("setting rating of %d for %s", v.Rating, title))
|
||||
mapKey := makeTraktMapKey(contentType, tmdbId)
|
||||
if mv, ok := toImport[mapKey]; ok {
|
||||
if v.Type == "episode" {
|
||||
// For episode entries, we have to find the WatchedEpisode to set its rating.
|
||||
epFound := false
|
||||
for i, we := range mv.WatchedEpisodes {
|
||||
if we.SeasonNumber == v.Episode.Season && we.EpisodeNumber == v.Episode.Number {
|
||||
we.Rating = int8(v.Rating)
|
||||
mv.WatchedEpisodes[i] = we
|
||||
epFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
toImport[mapKey] = mv
|
||||
if !epFound {
|
||||
addJobError(jobId, userId, fmt.Sprintf("episode rating of %d for %s not imported. The episode does not exist in your history or watchlist.", v.Rating, title))
|
||||
}
|
||||
} else {
|
||||
mv.Rating = float64(v.Rating)
|
||||
toImport[mapKey] = mv
|
||||
}
|
||||
} else {
|
||||
// Item should be in toImport by now (from history or watchlist) if it has a rating, otherwise we won't import it
|
||||
addJobError(jobId, userId, fmt.Sprintf("cannot import rating of %d for %s. The main content does not exist in your history or watchlist. type: %s traktSlug: %s", v.Rating, title, v.Type, traktSlug))
|
||||
}
|
||||
}
|
||||
}
|
||||
// Loop over `toImport` and finally import everything.
|
||||
for _, v := range toImport {
|
||||
_, err := importContent(db, userId, v)
|
||||
if err != nil {
|
||||
slog.Error("startTraktImport: Failed to do import on content!", "error", err, "import_obj", v)
|
||||
addJobError(jobId, userId, fmt.Sprintf("Failed to import %s as %s. tmdbId: %d", v.Type, v.Status, v.TmdbID))
|
||||
}
|
||||
}
|
||||
// We are donezo
|
||||
updateJobStatus(jobId, userId, JOB_DONE)
|
||||
}
|
||||
|
||||
func processTraktHistoryItem(v TraktHistory, toImport map[string]ImportRequest) error {
|
||||
var (
|
||||
title string
|
||||
traktId int
|
||||
tmdbId int
|
||||
contentType ContentType
|
||||
watchedEpisode WatchedEpisode
|
||||
)
|
||||
if v.Type == "show" || v.Type == "episode" {
|
||||
title = v.Show.Title
|
||||
traktId = v.Show.Ids.Trakt
|
||||
tmdbId = v.Show.Ids.Tmdb
|
||||
contentType = SHOW
|
||||
if v.Type == "episode" {
|
||||
traktId = v.Episode.Ids.Trakt
|
||||
watchedEpisode = WatchedEpisode{
|
||||
SeasonNumber: v.Episode.Season,
|
||||
EpisodeNumber: v.Episode.Number,
|
||||
Status: FINISHED,
|
||||
// Rating: ,
|
||||
GormModel: GormModel{
|
||||
CreatedAt: v.WatchedAt,
|
||||
},
|
||||
}
|
||||
slog.Debug("processTraktHistoryItem: Processing an episode.", "showTitle", title, "season", v.Episode.Season, "episode", v.Episode.Number)
|
||||
} else {
|
||||
slog.Debug("processTraktHistoryItem: Processing a show.", "contentTitle", title, "contentTmdbId", tmdbId)
|
||||
}
|
||||
} else if v.Type == "movie" {
|
||||
title = v.Movie.Title
|
||||
traktId = v.Movie.Ids.Trakt
|
||||
tmdbId = v.Movie.Ids.Tmdb
|
||||
contentType = MOVIE
|
||||
slog.Debug("processTraktHistoryItem: Processing a movie.", "contentTitle", title, "contentTmdbId", tmdbId)
|
||||
}
|
||||
if tmdbId == 0 {
|
||||
slog.Debug("processTraktHistoryItem: Item had no tmdbId. Cannot process.")
|
||||
return errors.New("Failed to process history: " + title + " type:" + v.Type + " trakt id:" + strconv.Itoa(traktId) + " tmdb id:" + strconv.Itoa(tmdbId) + " error:" + "item had no tmdb id")
|
||||
}
|
||||
mapKey := makeTraktMapKey(contentType, tmdbId)
|
||||
if e, ok := toImport[mapKey]; ok {
|
||||
e.WatchedEpisodes = append(toImport[mapKey].WatchedEpisodes, watchedEpisode)
|
||||
toImport[mapKey] = e
|
||||
} else {
|
||||
toImport[mapKey] = ImportRequest{
|
||||
Type: contentType,
|
||||
TmdbID: tmdbId,
|
||||
Status: FINISHED,
|
||||
DatesWatched: []time.Time{v.WatchedAt},
|
||||
WatchedEpisodes: []WatchedEpisode{watchedEpisode},
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// `tmdbId` is for the movie or show (not for episodes).
|
||||
func makeTraktMapKey(ct ContentType, tmdbId int) string {
|
||||
return string(ct) + strconv.Itoa(tmdbId)
|
||||
}
|
||||
|
||||
func traktAPIRequest(ep string, p map[string]string, resp interface{}) (http.Header, error) {
|
||||
base, err := url.Parse("https://api.trakt.tv")
|
||||
if err != nil {
|
||||
return map[string][]string{}, errors.New("failed to parse api uri")
|
||||
}
|
||||
base.Path += ep
|
||||
if len(p) > 0 {
|
||||
params := url.Values{}
|
||||
for k, v := range p {
|
||||
params.Add(k, v)
|
||||
}
|
||||
base.RawQuery = params.Encode()
|
||||
}
|
||||
slog.Debug("traktAPIRequest", "request_url", base.String())
|
||||
req, err := http.NewRequest("GET", base.String(), nil)
|
||||
if err != nil {
|
||||
return map[string][]string{}, err
|
||||
}
|
||||
req.Header.Add("trakt-api-key", "c481cb044dcd58d83f3fde113741d1e28d19c1bef1bcbfcb9acedee222f3a673")
|
||||
req.Header.Add("trakt-api-version", "2")
|
||||
req.Header.Add("Content-type", "application/json")
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return map[string][]string{}, err
|
||||
}
|
||||
body, err := io.ReadAll(res.Body)
|
||||
res.Body.Close()
|
||||
if err != nil {
|
||||
return map[string][]string{}, err
|
||||
}
|
||||
if !(res.StatusCode >= 200 && res.StatusCode <= 299) {
|
||||
slog.Error("traktAPIRequest: non 2xx status code:", "status_code", res.StatusCode)
|
||||
return map[string][]string{}, errors.New("non success status code")
|
||||
}
|
||||
err = json.Unmarshal([]byte(body), &resp)
|
||||
if err != nil {
|
||||
return map[string][]string{}, err
|
||||
}
|
||||
return res.Header, nil
|
||||
}
|
||||
|
||||
func traktImportWatched(
|
||||
db *gorm.DB,
|
||||
userId uint,
|
||||
traktUsername string,
|
||||
) (TraktImportResponse, error) {
|
||||
jobId, err := addUniqueJob("trakt_import", userId)
|
||||
if err != nil {
|
||||
slog.Error("traktSyncWatched: Failed to create a job", "error", err)
|
||||
return TraktImportResponse{}, err
|
||||
}
|
||||
|
||||
updateJobStatus(jobId, userId, JOB_RUNNING)
|
||||
|
||||
go startTraktImport(
|
||||
db,
|
||||
jobId,
|
||||
userId,
|
||||
traktUsername,
|
||||
)
|
||||
|
||||
return TraktImportResponse{JobId: jobId}, nil
|
||||
}
|
||||
@@ -38,10 +38,12 @@ var activeJobs = make(map[string]*Job)
|
||||
|
||||
// Add a job to our activeJobs map.
|
||||
// Returns id of job on success, or error if failed to add.
|
||||
// Only return safe errors for display to users, log serious errors.
|
||||
func addJob(name string, userId uint) (string, error) {
|
||||
idk, err := generateString(8)
|
||||
if err != nil {
|
||||
return "", err
|
||||
slog.Error("addJob: Failed to generate a job id!", "error", err)
|
||||
return "", errors.New("failed to generate a job id, please try again")
|
||||
}
|
||||
_, ok := activeJobs[idk]
|
||||
if ok {
|
||||
@@ -56,6 +58,22 @@ func addJob(name string, userId uint) (string, error) {
|
||||
return idk, nil
|
||||
}
|
||||
|
||||
// Add a job, but only if one with the same `name` isn't already running.
|
||||
// Only return safe errors for display to users.
|
||||
func addUniqueJob(name string, userId uint) (string, error) {
|
||||
found := false
|
||||
for _, v := range activeJobs {
|
||||
if v.UserId == userId && v.Name == name && (v.Status == JOB_CREATED || v.Status == JOB_RUNNING) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if found {
|
||||
return "", errors.New("a job of this type is already running, please wait for the existing job to finish")
|
||||
}
|
||||
return addJob(name, userId)
|
||||
}
|
||||
|
||||
func rmJob(id string, userId uint) {
|
||||
slog.Debug("rmJob: Removing a job.", "id", id)
|
||||
v, ok := activeJobs[id]
|
||||
@@ -91,11 +109,11 @@ func updateJobStatus(id string, userId uint, status JobStatus) error {
|
||||
}
|
||||
j.Status = status
|
||||
// If job is set to done, remove it after 1 minute.
|
||||
if status == JOB_DONE {
|
||||
slog.Debug("updateJobStatus: Job set to done. Will be removed after 1m.", "id", id)
|
||||
if status == JOB_DONE || status == JOB_CANCELLED {
|
||||
slog.Debug("updateJobStatus: Job set to done or cancelled. Will be removed after 30m.", "id", id, "status", status)
|
||||
go func() {
|
||||
time.Sleep(1 * time.Minute)
|
||||
slog.Debug("updateJobStatus: Job done. waited 1m.. removing job now.", "id", id)
|
||||
time.Sleep(30 * time.Minute)
|
||||
slog.Debug("updateJobStatus: Job done. waited 30m.. removing job now.", "id", id)
|
||||
rmJob(id, userId)
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ func startPlexSync(
|
||||
Status: FINISHED,
|
||||
ContentID: tmdbId,
|
||||
ContentType: MOVIE,
|
||||
Rating: int8(movie.UserRating),
|
||||
Rating: float64(movie.UserRating),
|
||||
WatchedDate: lastViewedAt,
|
||||
}, IMPORTED_WATCHED_PLEX)
|
||||
if err != nil {
|
||||
@@ -146,7 +146,7 @@ func startPlexSync(
|
||||
Status: FINISHED,
|
||||
ContentID: tmdbId,
|
||||
ContentType: SHOW,
|
||||
Rating: int8(show.UserRating),
|
||||
Rating: float64(show.UserRating),
|
||||
WatchedDate: lastViewedAt,
|
||||
}, IMPORTED_WATCHED_PLEX)
|
||||
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
|
||||
@@ -231,6 +250,7 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
}))
|
||||
|
||||
// Get season details
|
||||
// Supports `watchedId` query parameter for saving the requested season as `LastViewedSeason`.
|
||||
content.GET("/tv/:id/season/:num", func(c *gin.Context) {
|
||||
if c.Param("id") == "" || c.Param("num") == "" {
|
||||
c.Status(400)
|
||||
@@ -241,6 +261,28 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
// If a `watchedId` is passed, we should update it with this season
|
||||
// number, so the LastViewedSeason field is up to date (this seemed
|
||||
// better than making a new request for just saving this).
|
||||
// We will attach a `watcharr-lastviewedseason-saved` header if
|
||||
// this part succeeds so the client can decide on showing an error.
|
||||
if watchedIdQ := c.Query("watchedId"); watchedIdQ != "" {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
watchedId, err := strconv.ParseUint(watchedIdQ, 10, 64)
|
||||
if err != nil {
|
||||
slog.Error("get season details route: Processing watchedId param failed", "error", err.Error(), "id", watchedIdQ)
|
||||
} else {
|
||||
if seasonNum, err := strconv.ParseInt(c.Param("num"), 10, 64); err == nil {
|
||||
if err = updateWatchedLastViewedSeason(b.db, userId, uint(watchedId), int(seasonNum)); err == nil {
|
||||
c.Header("watcharr-lastviewedseason-saved", "1")
|
||||
}
|
||||
} else {
|
||||
slog.Error("get season details route: Parsing season number as int failed", "error", err.Error(), "season_num", c.Param("num"))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
slog.Debug("get season details route: No watchedId parameter found.. not doing anything.")
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
})
|
||||
|
||||
@@ -352,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
|
||||
@@ -553,6 +615,50 @@ func (b *BaseRouter) addWatchedRoutes() {
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
watched.POST(":id/tag/:tagId", func(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
slog.Error("tag watched route failed to convert id param to int", "error", err)
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
tagId, err := strconv.Atoi(c.Param("tagId"))
|
||||
if err != nil {
|
||||
slog.Error("tag watched route failed to convert tagId param to int", "error", err)
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
userId := c.MustGet("userId").(uint)
|
||||
err = addWatchedToTag(b.db, userId, uint(tagId), uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
|
||||
watched.DELETE(":id/tag/:tagId", func(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
slog.Error("tag watched route failed to convert id param to int", "error", err)
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
tagId, err := strconv.Atoi(c.Param("tagId"))
|
||||
if err != nil {
|
||||
slog.Error("tag watched route failed to convert tagId param to int", "error", err)
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
userId := c.MustGet("userId").(uint)
|
||||
err = rmWatchedFromTag(b.db, userId, uint(tagId), uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addActivityRoutes() {
|
||||
@@ -675,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
|
||||
@@ -692,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)
|
||||
@@ -865,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
|
||||
@@ -1002,6 +1156,22 @@ func (b *BaseRouter) addImportRoutes() {
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
|
||||
imprt.POST("/trakt", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
var ar TraktImportRequest
|
||||
err := c.ShouldBindJSON(&ar)
|
||||
if err == nil {
|
||||
response, err := traktImportWatched(b.db, userId, ar.Username)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addServerRoutes() {
|
||||
@@ -1009,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)
|
||||
@@ -1503,3 +1708,91 @@ func (b *BaseRouter) addTaskRoutes() {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addTagRoutes() {
|
||||
tag := b.rg.Group("/tag").Use(AuthRequired(nil))
|
||||
|
||||
// Get list of all our tags.
|
||||
tag.GET("", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
tags, err := getTags(b.db, userId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, tags)
|
||||
})
|
||||
|
||||
// // Get all items within one of our tags.
|
||||
// tag.GET(":id", func(c *gin.Context) {
|
||||
// id, err := strconv.Atoi(c.Param("id"))
|
||||
// if err != nil {
|
||||
// slog.Error("getTag route failed to convert id param to int", "error", err)
|
||||
// c.Status(http.StatusBadRequest)
|
||||
// return
|
||||
// }
|
||||
// userId := c.MustGet("userId").(uint)
|
||||
// tags, err := getTag(b.db, userId, uint(id))
|
||||
// if err != nil {
|
||||
// c.JSON(http.StatusInternalServerError, ErrorResponse{Error: err.Error()})
|
||||
// return
|
||||
// }
|
||||
// c.JSON(http.StatusOK, tags)
|
||||
// })
|
||||
|
||||
// Create a tag.
|
||||
tag.POST("", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
var tr TagAddRequest
|
||||
err := c.ShouldBindJSON(&tr)
|
||||
if err == nil {
|
||||
response, err := addTag(b.db, userId, tr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
|
||||
// Update a tag.
|
||||
tag.PUT(":id", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.Status(400)
|
||||
slog.Error("tag update rote: failed to process tag id.", "error", err.Error(), "id", c.Param("id"))
|
||||
return
|
||||
}
|
||||
var tr TagAddRequest
|
||||
err = c.ShouldBindJSON(&tr)
|
||||
if err == nil {
|
||||
err := updateTag(b.db, userId, uint(id), tr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
|
||||
tag.DELETE(":id", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.Status(400)
|
||||
slog.Error("tag delete rote: failed to process tag id.", "error", err.Error(), "id", c.Param("id"))
|
||||
return
|
||||
}
|
||||
err = deleteTag(b.db, userId, uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// I think tags will be private for the user.
|
||||
// If the user wants to make a public list, they should make a custom view.
|
||||
|
||||
type Tag struct {
|
||||
GormModel
|
||||
// ID of user that own this tag.
|
||||
UserID uint `json:"-" gorm:"not null"`
|
||||
// Name of the tag.
|
||||
Name string `json:"name" gorm:"not null"`
|
||||
// Hex of text color.
|
||||
Color string `json:"color"`
|
||||
// Hex of background color.
|
||||
BgColor string `json:"bgColor"`
|
||||
// All watched items.
|
||||
Watched []Watched `json:"watched,omitempty" gorm:"many2many:watched_tags;"`
|
||||
}
|
||||
|
||||
type TagAddRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Color string `json:"color"`
|
||||
BgColor string `json:"bgColor"`
|
||||
}
|
||||
|
||||
func getTags(db *gorm.DB, userId uint) ([]Tag, error) {
|
||||
tags := new([]Tag)
|
||||
res := db.Model(&Tag{}).Where("user_id = ?", userId).Find(&tags)
|
||||
if res.Error != nil {
|
||||
slog.Error("getTags: Failed getting tags from database", "error", res.Error.Error())
|
||||
return []Tag{}, errors.New("failed getting tags")
|
||||
}
|
||||
return *tags, nil
|
||||
}
|
||||
|
||||
// func getTag(db *gorm.DB, userId uint, tagId uint) (Tag, error) {
|
||||
// tag := new(Tag)
|
||||
// res := db.Model(&Tag{}).Where("id = ? AND user_id = ?", tagId, userId).Preload("Watched").Find(&tag)
|
||||
// if res.Error != nil {
|
||||
// slog.Error("getTag: Failed getting tag from database", "error", res.Error.Error())
|
||||
// return Tag{}, errors.New("failed getting tag")
|
||||
// }
|
||||
// if tag.ID == 0 {
|
||||
// slog.Error("getTag: Tag does not exist for this user.", "user_id", userId)
|
||||
// return Tag{}, errors.New("tag does not exist")
|
||||
// }
|
||||
// return *tag, nil
|
||||
// }
|
||||
|
||||
// This method should only be used when we don't have the tagId
|
||||
// (eg: when we are importing data) because this is not technically
|
||||
// reliable, since users can have multiple tags with the same name/colors
|
||||
// (realistically they probably won't, but...).
|
||||
func getTagByNameAndColor(db *gorm.DB, userId uint, tagName string, tagColor string, tagBgColor string) (Tag, error) {
|
||||
tag := new(Tag)
|
||||
res := db.Model(&Tag{}).Where("name = ? AND user_id = ? AND color = ? AND bg_color = ?", tagName, userId, tagColor, tagBgColor).Preload("Watched").Find(&tag)
|
||||
if res.Error != nil {
|
||||
slog.Error("getTagByNameAndColor: Failed getting tag from database", "error", res.Error.Error())
|
||||
return Tag{}, errors.New("failed getting tag")
|
||||
}
|
||||
if tag.ID == 0 {
|
||||
slog.Error("getTagByNameAndColor: Tag does not exist for this user.", "user_id", userId)
|
||||
return Tag{}, errors.New("tag does not exist")
|
||||
}
|
||||
return *tag, nil
|
||||
}
|
||||
|
||||
// Let user create a tag.
|
||||
func addTag(db *gorm.DB, userId uint, tr TagAddRequest) (Tag, error) {
|
||||
if tr.Name == "" {
|
||||
return Tag{}, errors.New("tag must have a name")
|
||||
}
|
||||
tag := Tag{UserID: userId, Name: tr.Name, Color: tr.Color, BgColor: tr.BgColor}
|
||||
res := db.Create(&tag)
|
||||
if res.Error != nil {
|
||||
slog.Error("Error adding tag to database", "error", res.Error.Error())
|
||||
return Tag{}, errors.New("failed adding new tag to database")
|
||||
}
|
||||
slog.Debug("Adding tag", "added_tag", tag)
|
||||
return tag, nil
|
||||
}
|
||||
|
||||
// Let user update one of their tags (replaces).
|
||||
func updateTag(db *gorm.DB, userId uint, tagId uint, tr TagAddRequest) error {
|
||||
if tr.Name == "" {
|
||||
return errors.New("tag must have a name")
|
||||
}
|
||||
tag := Tag{Name: tr.Name, Color: tr.Color, BgColor: tr.BgColor}
|
||||
res := db.Where("id = ? AND user_id = ?", tagId, userId).Updates(&tag)
|
||||
if res.Error != nil {
|
||||
slog.Error("Error updating tag in database", "error", res.Error.Error())
|
||||
return errors.New("failed updating tag in database")
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
slog.Error("updateTag: Zero rows affected.. tag likely does not exist", "tag_id", tagId, "user_id", userId)
|
||||
return errors.New("tag does not exist")
|
||||
}
|
||||
slog.Debug("updateTag:", "updated_tag", tag)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Let user delete their own tag.
|
||||
func deleteTag(db *gorm.DB, userId uint, tagId uint) error {
|
||||
if tagId == 0 {
|
||||
return errors.New("no tag id provided")
|
||||
}
|
||||
slog.Debug("deleteTag:", "tag_id", tagId, "user_id", userId)
|
||||
// Select("Watched") so relations in watched_tags table are removed too.
|
||||
// ID is passed in the .Delete param so the .Select call can do it's job (relies on the primary key).
|
||||
res := db.Unscoped().Where("id = ? AND user_id = ?", tagId, userId).Select("Watched").Delete(&Tag{GormModel: GormModel{ID: tagId}})
|
||||
if res.Error != nil {
|
||||
slog.Error("deleteTag: Error deleting tag from database", "error", res.Error.Error(), "tag_id", tagId, "user_id", userId)
|
||||
return errors.New("failed deleting tag from database")
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
slog.Error("deleteTag: Zero rows affected.. tag must not exist for user", "tag_id", tagId, "user_id", userId)
|
||||
return errors.New("tag does not exist")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Add watched content to a tag (user must own the tag and watched entry).
|
||||
func addWatchedToTag(db *gorm.DB, userId uint, tagId uint, watchedId uint) error {
|
||||
slog.Debug("addWatchedToTag: Adding", "userId", userId, "watchedID", watchedId, "tagId", tagId)
|
||||
// 1. Make sure watched item exists and is owned by this user
|
||||
var w Watched
|
||||
if resp := db.Where("id = ? AND user_id = ?", watchedId, userId).Preload("Tags").Find(&w); resp.Error != nil {
|
||||
slog.Error("addWatchedToTag: failed to get watched item from db", "error", resp.Error)
|
||||
return errors.New("failed when retrieving watched item")
|
||||
}
|
||||
if w.ID == 0 {
|
||||
slog.Error("addWatchedToTag", "error", "watched item does not exist in db", "watchedID", watchedId)
|
||||
return errors.New("watched entry does not exist")
|
||||
}
|
||||
// 2. Make sure tag exists
|
||||
var t Tag
|
||||
if resp := db.Where("id = ? AND user_id = ?", tagId, userId).Find(&t); resp.Error != nil {
|
||||
slog.Error("addWatchedToTag: Failed to get tag from db", "error", resp.Error)
|
||||
return errors.New("failed when retrieving tag")
|
||||
}
|
||||
if t.ID == 0 {
|
||||
slog.Error("addWatchedToTag", "error", "tag does not exist in db", "tagId", tagId)
|
||||
return errors.New("tag does not exist")
|
||||
}
|
||||
// 3. Save relation (unique restraint will fail if it already exists)
|
||||
w.Tags = append(w.Tags, t)
|
||||
resp := db.Save(&w)
|
||||
if resp.Error != nil {
|
||||
slog.Error("addWatchedToTag: Failed to tag watched item", "error", resp.Error)
|
||||
return errors.New("failed to tag watched item")
|
||||
}
|
||||
slog.Debug("addWatchedToTag: watched content successfully linked to tag", "watchedID", watchedId, "tagId", tagId)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove watched content from a tag (user must own the tag and watched entry).
|
||||
func rmWatchedFromTag(db *gorm.DB, userId uint, tagId uint, watchedId uint) error {
|
||||
slog.Debug("rmWatchedFromTag: Removing", "userId", userId, "watchedID", watchedId, "tagId", tagId)
|
||||
// 1. Make sure watched item exists and is owned by this user
|
||||
var w Watched
|
||||
if resp := db.Where("id = ? AND user_id = ?", watchedId, userId).Preload("Tags").Find(&w); resp.Error != nil {
|
||||
slog.Error("rmWatchedFromTag: failed to get watched item from db", "error", resp.Error)
|
||||
return errors.New("failed when retrieving watched item")
|
||||
}
|
||||
if w.ID == 0 {
|
||||
slog.Error("rmWatchedFromTag", "error", "watched item does not exist in db", "watchedID", watchedId)
|
||||
return errors.New("watched entry does not exist")
|
||||
}
|
||||
// 2. Make sure tag exists
|
||||
var t Tag
|
||||
if resp := db.Where("id = ? AND user_id = ?", tagId, userId).Find(&t); resp.Error != nil {
|
||||
slog.Error("rmWatchedFromTag: Failed to get tag from db", "error", resp.Error)
|
||||
return errors.New("failed when retrieving tag")
|
||||
}
|
||||
if t.ID == 0 {
|
||||
slog.Error("rmWatchedFromTag", "error", "tag does not exist in db", "tagId", tagId)
|
||||
return errors.New("tag does not exist")
|
||||
}
|
||||
// 3. Remove relation
|
||||
err := db.Model(&w).Association("Tags").Delete(&t)
|
||||
if err != nil {
|
||||
slog.Error("rmWatchedFromTag: Failed to untag watched item", "error", err)
|
||||
return errors.New("failed to untag watched item")
|
||||
}
|
||||
slog.Debug("rmWatchedFromTag: watched content successfully removed from tag", "watchedID", watchedId, "tagId", tagId)
|
||||
return nil
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -64,6 +64,12 @@ func userUpdate(db *gorm.DB, userId uint, ur UserSettings) (UserSettings, error)
|
||||
if ur.Country != nil {
|
||||
user.Country = ur.Country
|
||||
}
|
||||
if ur.RatingSystem != nil {
|
||||
user.RatingSystem = ur.RatingSystem
|
||||
}
|
||||
if ur.RatingStep != nil {
|
||||
user.RatingStep = ur.RatingStep
|
||||
}
|
||||
db.Save(&user)
|
||||
return UserSettings{
|
||||
Private: user.Private,
|
||||
@@ -90,6 +96,8 @@ func userGetSettings(db *gorm.DB, userId uint) (UserSettings, error) {
|
||||
IncludePreviouslyWatched: user.IncludePreviouslyWatched,
|
||||
AutomateShowStatuses: user.AutomateShowStatuses,
|
||||
Country: user.Country,
|
||||
RatingSystem: user.RatingSystem,
|
||||
RatingStep: user.RatingStep,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -82,6 +82,7 @@ func main() {
|
||||
&Image{},
|
||||
&Game{},
|
||||
&ArrRequest{},
|
||||
&Tag{},
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to auto migrate database:", err)
|
||||
@@ -94,10 +95,23 @@ func main() {
|
||||
gin.DefaultWriter = multiw
|
||||
gine := gin.Default()
|
||||
gine.Use(cors.New(cors.Config{
|
||||
AllowOrigins: []string{"*"},
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowHeaders: []string{"Content-Type", "Content-Length", "Accept-Encoding", "X-CSRF-Token", "Authorization", "accept", "origin", "Cache-Control", "X-Requested-With"},
|
||||
ExposeHeaders: []string{"Content-Length"},
|
||||
AllowOrigins: []string{"*"},
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowHeaders: []string{
|
||||
"Content-Type",
|
||||
"Content-Length",
|
||||
"Accept-Encoding",
|
||||
"X-CSRF-Token",
|
||||
"Authorization",
|
||||
"accept",
|
||||
"origin",
|
||||
"Cache-Control",
|
||||
"X-Requested-With",
|
||||
},
|
||||
ExposeHeaders: []string{
|
||||
"Content-Length",
|
||||
"watcharr-lastviewedseason-saved",
|
||||
},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 12 * time.Hour,
|
||||
}))
|
||||
@@ -144,6 +158,7 @@ func main() {
|
||||
br.addArrRequestRoutes()
|
||||
br.addJobRoutes()
|
||||
br.addTaskRoutes()
|
||||
br.addTagRoutes()
|
||||
br.rg.Static("/img", path.Join(DataPath, "img"))
|
||||
|
||||
go setupTasks(db)
|
||||
|
||||
@@ -27,8 +27,11 @@ const (
|
||||
|
||||
type Watched struct {
|
||||
GormModel
|
||||
Status WatchedStatus `json:"status"`
|
||||
Rating int8 `json:"rating"`
|
||||
Status WatchedStatus `json:"status"`
|
||||
// float so we can support decimal ratings.
|
||||
// Ratings should still always be saved as out of 10.0,
|
||||
// so they can be viewed with any ratings setting in the client.
|
||||
Rating float64 `json:"rating" gorm:"type:numeric(2,1)"`
|
||||
Thoughts string `json:"thoughts"`
|
||||
Pinned bool `json:"pinned" gorm:"default:false;not null"`
|
||||
UserID uint `json:"-" gorm:"uniqueIndex:usernctnidx;uniqueIndex:userngamidx"`
|
||||
@@ -39,11 +42,15 @@ type Watched struct {
|
||||
Activity []Activity `json:"activity"`
|
||||
WatchedSeasons []WatchedSeason `json:"watchedSeasons,omitempty"` // For shows
|
||||
WatchedEpisodes []WatchedEpisode `json:"watchedEpisodes,omitempty"` // For shows
|
||||
Tags []Tag `json:"tags,omitempty" gorm:"many2many:watched_tags;"`
|
||||
// The last season that was viewed by the user for this watched entry.
|
||||
// Only applies to tv shows of course.
|
||||
LastViewedSeason *int `json:"lastViewedSeason,omitempty"`
|
||||
}
|
||||
|
||||
type WatchedAddRequest struct {
|
||||
Status WatchedStatus `json:"status"`
|
||||
Rating int8 `json:"rating" binding:"max=10"`
|
||||
Rating float64 `json:"rating" binding:"max=10"`
|
||||
Thoughts string `json:"thoughts"`
|
||||
ContentID int `json:"contentId" binding:"required"`
|
||||
ContentType ContentType `json:"contentType" binding:"required,oneof=movie tv"`
|
||||
@@ -54,7 +61,7 @@ type WatchedAddRequest struct {
|
||||
|
||||
type WatchedUpdateRequest struct {
|
||||
Status WatchedStatus `json:"status" binding:"required_without_all=Rating Thoughts RemoveThoughts Pinned"`
|
||||
Rating int8 `json:"rating" binding:"max=10,required_without_all=Status Thoughts RemoveThoughts Pinned"`
|
||||
Rating float64 `json:"rating" binding:"max=10,required_without_all=Status Thoughts RemoveThoughts Pinned"`
|
||||
Thoughts string `json:"thoughts" binding:"required_without_all=Status Rating RemoveThoughts Pinned"`
|
||||
RemoveThoughts bool `json:"removeThoughts"`
|
||||
Pinned *bool `json:"pinned" binding:"required_without_all=Status Rating Thoughts RemoveThoughts"`
|
||||
@@ -70,7 +77,16 @@ type WatchedRemoveResponse struct {
|
||||
|
||||
func getWatched(db *gorm.DB, userId uint) []Watched {
|
||||
watched := new([]Watched)
|
||||
res := db.Model(&Watched{}).Preload("Content").Preload("Game").Preload("Game.Poster").Preload("Activity").Preload("WatchedSeasons").Preload("WatchedEpisodes").Where("user_id = ?", userId).Find(&watched)
|
||||
res := db.Model(&Watched{}).
|
||||
Preload("Content").
|
||||
Preload("Game").
|
||||
Preload("Game.Poster").
|
||||
Preload("Activity").
|
||||
Preload("WatchedSeasons").
|
||||
Preload("WatchedEpisodes").
|
||||
Preload("Tags").
|
||||
Where("user_id = ?", userId).
|
||||
Find(&watched)
|
||||
if res.Error != nil {
|
||||
panic(res.Error)
|
||||
}
|
||||
@@ -88,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)
|
||||
@@ -230,6 +262,24 @@ func updateWatched(db *gorm.DB, userId uint, id uint, ar WatchedUpdateRequest) (
|
||||
return WatchedUpdateResponse{NewActivity: addedActivity}, nil
|
||||
}
|
||||
|
||||
func updateWatchedLastViewedSeason(db *gorm.DB, userId uint, id uint, seasonNum int) error {
|
||||
slog.Debug("UpdateWatchedLastViewedSeason", "user_id", userId, "id", id, "season_num", seasonNum)
|
||||
res := db.
|
||||
Model(&Watched{}).
|
||||
Where("id = ? AND user_id = ?", id, userId).
|
||||
Update("last_viewed_season", seasonNum)
|
||||
if res.Error != nil {
|
||||
slog.Error("updateWatchedLastViewedSeason: Failed when updating.", "error", res.Error)
|
||||
return errors.New("failed to update db")
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
// likely the watched entry does not exist or is not owned by this `userId`.
|
||||
slog.Error("updateWatchedLastViewedSeason: Watched entry does not exist.")
|
||||
return errors.New("watched entry does not exist")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeWatched(db *gorm.DB, userId uint, id uint) (WatchedRemoveResponse, error) {
|
||||
slog.Debug("Removing watched item:", "id", id, "user_id", userId)
|
||||
// Our model has a deleted_at field, which will make gorm do a soft delete.
|
||||
@@ -248,45 +298,67 @@ func removeWatched(db *gorm.DB, userId uint, id uint) (WatchedRemoveResponse, er
|
||||
return WatchedRemoveResponse{NewActivity: addedActivity}, nil
|
||||
}
|
||||
|
||||
func download(url string, outf string) (err error) {
|
||||
slog.Debug("Attempting to download file", "url", url, "outf", outf)
|
||||
|
||||
// Create the file
|
||||
out, err := os.Create(outf)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
err = os.MkdirAll(path.Dir(outf), 0764)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// If dirs made, try making file again
|
||||
out, err = os.Create(outf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Download file over http (used for downloading poster images)
|
||||
// url - The remote file url.
|
||||
// outf - Where should we store the downloaded file.
|
||||
// force - Should we overwrite an existing file? If false, existing files will be skipped.
|
||||
func download(url string, outf string, force bool) (err error) {
|
||||
slog.Debug("download: Attempting to download file", "url", url, "outf", outf, "force", force)
|
||||
// If not forced, skip call if file already exists to save unnecessary requests.
|
||||
if !force {
|
||||
if _, err := os.Stat(outf); !errors.Is(err, os.ErrNotExist) {
|
||||
slog.Debug("download: Skipping file, it already exists locally.", "outf", outf, "error", err)
|
||||
return nil
|
||||
} else {
|
||||
return err
|
||||
slog.Debug("download: Continuing to download file, it does not already exist.", "outf", outf, "error", err)
|
||||
}
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
// Get the data
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
slog.Error("download: Failed to make request.", "outf", outf, "error", err)
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check server response
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
slog.Error("download: Request failed. Non OK response.", "outf", outf, "status", resp.Status, "error", err)
|
||||
return fmt.Errorf("bad status: %s", resp.Status)
|
||||
}
|
||||
|
||||
// Create the file
|
||||
out, err := os.Create(outf)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
slog.Warn("download: Failed to create out file, trying to recover by ensuring directories exist.", "outf", outf)
|
||||
err = os.MkdirAll(path.Dir(outf), 0764)
|
||||
if err != nil {
|
||||
slog.Error("download: Failed to create dir(s) in recovery attempt.", "outf", outf, "error", err)
|
||||
return err
|
||||
}
|
||||
// If dirs made, try making file again
|
||||
out, err = os.Create(outf)
|
||||
if err != nil {
|
||||
slog.Error("download: Failed to create out file again in recovery attempt.", "outf", outf, "error", err)
|
||||
return err
|
||||
}
|
||||
slog.Info("download: recovered by creating dir(s).", "outf", outf)
|
||||
} else {
|
||||
slog.Error("download: Failed to create out file. No known recovery path possible.", "outf", outf, "error", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
// Write the body to file
|
||||
_, err = io.Copy(out, resp.Body)
|
||||
if err != nil {
|
||||
slog.Error("download: Failed to write file to our file.", "outf", outf, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
slog.Debug("download: Successfully downloaded file", "outf", outf)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<!-- Uses Native Color Input -->
|
||||
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
value?: string;
|
||||
style?: string;
|
||||
}
|
||||
|
||||
let { value = $bindable("#000000"), style = "" }: Props = $props();
|
||||
</script>
|
||||
|
||||
<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;
|
||||
}
|
||||
|
||||
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}
|
||||
Browse For {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,289 +1,435 @@
|
||||
<script lang="ts">
|
||||
import type { Icon } from "@/types";
|
||||
import type { Icon } from "@/types";
|
||||
|
||||
export let i: Icon;
|
||||
export let wh: number | string = 24;
|
||||
export let facing: "up" | "down" | "left" | "right" | undefined = undefined;
|
||||
interface Props {
|
||||
i: Icon;
|
||||
wh?: number | string;
|
||||
facing?: "up" | "down" | "left" | "right" | undefined;
|
||||
}
|
||||
|
||||
let { i, wh = 24, facing = undefined }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if i === "check"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width={wh} height={wh}>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="square"
|
||||
stroke-miterlimit="10"
|
||||
stroke-width="44"
|
||||
d="M416 128L192 384l-96-96"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 512 512"
|
||||
width={wh}
|
||||
height={wh}
|
||||
>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="square"
|
||||
stroke-miterlimit="10"
|
||||
stroke-width="44"
|
||||
d="M416 128L192 384l-96-96"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "clock"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M256 48C141.13 48 48 141.13 48 256c0 114.69 93.32 208 208 208 114.86 0 208-93.14 208-208 0-114.69-93.31-208-208-208zm108 240H244a4 4 0 01-4-4V116a4 4 0 014-4h24a4 4 0 014 4v140h92a4 4 0 014 4v24a4 4 0 01-4 4z"
|
||||
/>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M256 48C141.13 48 48 141.13 48 256c0 114.69 93.32 208 208 208 114.86 0 208-93.14 208-208 0-114.69-93.31-208-208-208zm108 240H244a4 4 0 01-4-4V116a4 4 0 014-4h24a4 4 0 014 4v140h92a4 4 0 014 4v24a4 4 0 01-4 4z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "play"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"
|
||||
><path
|
||||
d="M133 440a35.37 35.37 0 01-17.5-4.67c-12-6.8-19.46-20-19.46-34.33V111c0-14.37 7.46-27.53 19.46-34.33a35.13 35.13 0 0135.77.45l247.85 148.36a36 36 0 010 61l-247.89 148.4A35.5 35.5 0 01133 440z"
|
||||
/></svg
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"
|
||||
><path
|
||||
d="M133 440a35.37 35.37 0 01-17.5-4.67c-12-6.8-19.46-20-19.46-34.33V111c0-14.37 7.46-27.53 19.46-34.33a35.13 35.13 0 0135.77.45l247.85 148.36a36 36 0 010 61l-247.89 148.4A35.5 35.5 0 01133 440z"
|
||||
/></svg
|
||||
>
|
||||
{:else if i === "calendar"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M32 456a24 24 0 0024 24h400a24 24 0 0024-24V176H32zm80-238.86a9.14 9.14 0 019.14-9.14h109.72a9.14 9.14 0 019.14 9.14v109.72a9.14 9.14 0 01-9.14 9.14H121.14a9.14 9.14 0 01-9.14-9.14zM456 64h-55.92V32h-48v32H159.92V32h-48v32H56a23.8 23.8 0 00-24 23.77V144h448V87.77A23.8 23.8 0 00456 64z"
|
||||
/>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M32 456a24 24 0 0024 24h400a24 24 0 0024-24V176H32zm80-238.86a9.14 9.14 0 019.14-9.14h109.72a9.14 9.14 0 019.14 9.14v109.72a9.14 9.14 0 01-9.14 9.14H121.14a9.14 9.14 0 01-9.14-9.14zM456 64h-55.92V32h-48v32H159.92V32h-48v32H56a23.8 23.8 0 00-24 23.77V144h448V87.77A23.8 23.8 0 00456 64z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "thumb-down"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"
|
||||
><path
|
||||
d="M39.94 178l144.16 6.12c4.61.36 23.9 1.22 23.9 25.88 0 23.8-19.16 25.33-24.14 25.88L39.94 242C27.27 241.87 16 227.56 16 210s11.27-31.87 23.94-32zm141.45 131.66L74.65 318C62 318 48 301.31 48 284.12v-.33c0-16.33 11.14-29.63 24.88-29.79l108.45 1.72C208 259 208 275.16 208 282.12c0 22.88-21.8 27.14-26.61 27.54zM90.15 32l89.37 8.93C204 41.86 208 58.18 208 68.4c0 18.39-13.41 24.6-26.67 24.6l-91 3C75.78 95.78 64 81.51 64 64s11.68-31.66 26.15-32zm-34.36 71.5l126.4 6.22c9.39.63 25.81 3 25.81 26.36 0 12-4.35 25.62-25 27.53L55.79 167.5C42.65 167.35 32 154 32 136.08s10.65-32.43 23.79-32.58z"
|
||||
/><path
|
||||
d="M378.45 273.93A15.84 15.84 0 01386 272a15.93 15.93 0 00-7.51 1.91zM337.86 343.22l-.13.22a2.53 2.53 0 01.13-.22c20.5-35.51 30.36-55 33.82-62-3.47 7.06-13.34 26.51-33.82 62z"
|
||||
fill="none"
|
||||
/><path d="M372.66 279.16l-1 2a16.29 16.29 0 016.77-7.26 16.48 16.48 0 00-5.77 5.26z" /><path
|
||||
d="M195.94 459.38C205.37 472.67 221 480 240 480a16 16 0 0014.31-8.85c3-6.06 15.25-24 28.19-42.9 18-26.33 40.35-59.08 55.23-84.81l.13-.22c20.48-35.49 30.35-54.94 33.82-62l1-2a16.48 16.48 0 015.79-5.23A15.93 15.93 0 01386 272h25.32A84.7 84.7 0 00496 187.3v-38.6A84.7 84.7 0 00411.31 64h-48.79a17.46 17.46 0 01-9.58-2.89C330 46.13 286.66 32 240 32c-7.45 0-14.19.14-20.27.38a8 8 0 00-6.2 12.68l.1.14C222.2 57.59 224 71 224 80a61.16 61.16 0 01-5.19 24.77 17.38 17.38 0 000 14.06 63.81 63.81 0 010 50.39 17.32 17.32 0 000 14 62.13 62.13 0 010 49.58 18.13 18.13 0 000 14.68A60.41 60.41 0 01224 273c0 8.2-2 21.3-8 31.18a15.66 15.66 0 00-1.14 13.65c.38 1 .76 2.06 1.13 3.17a24.8 24.8 0 01.86 11.57c-3 19.35-9.67 36.3-16.74 54.16-3.08 7.78-6.27 15.82-9.22 24.27-6.14 17.56-4.3 35.2 5.05 48.38z"
|
||||
/></svg
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"
|
||||
><path
|
||||
d="M39.94 178l144.16 6.12c4.61.36 23.9 1.22 23.9 25.88 0 23.8-19.16 25.33-24.14 25.88L39.94 242C27.27 241.87 16 227.56 16 210s11.27-31.87 23.94-32zm141.45 131.66L74.65 318C62 318 48 301.31 48 284.12v-.33c0-16.33 11.14-29.63 24.88-29.79l108.45 1.72C208 259 208 275.16 208 282.12c0 22.88-21.8 27.14-26.61 27.54zM90.15 32l89.37 8.93C204 41.86 208 58.18 208 68.4c0 18.39-13.41 24.6-26.67 24.6l-91 3C75.78 95.78 64 81.51 64 64s11.68-31.66 26.15-32zm-34.36 71.5l126.4 6.22c9.39.63 25.81 3 25.81 26.36 0 12-4.35 25.62-25 27.53L55.79 167.5C42.65 167.35 32 154 32 136.08s10.65-32.43 23.79-32.58z"
|
||||
/><path
|
||||
d="M378.45 273.93A15.84 15.84 0 01386 272a15.93 15.93 0 00-7.51 1.91zM337.86 343.22l-.13.22a2.53 2.53 0 01.13-.22c20.5-35.51 30.36-55 33.82-62-3.47 7.06-13.34 26.51-33.82 62z"
|
||||
fill="none"
|
||||
/><path
|
||||
d="M372.66 279.16l-1 2a16.29 16.29 0 016.77-7.26 16.48 16.48 0 00-5.77 5.26z"
|
||||
/><path
|
||||
d="M195.94 459.38C205.37 472.67 221 480 240 480a16 16 0 0014.31-8.85c3-6.06 15.25-24 28.19-42.9 18-26.33 40.35-59.08 55.23-84.81l.13-.22c20.48-35.49 30.35-54.94 33.82-62l1-2a16.48 16.48 0 015.79-5.23A15.93 15.93 0 01386 272h25.32A84.7 84.7 0 00496 187.3v-38.6A84.7 84.7 0 00411.31 64h-48.79a17.46 17.46 0 01-9.58-2.89C330 46.13 286.66 32 240 32c-7.45 0-14.19.14-20.27.38a8 8 0 00-6.2 12.68l.1.14C222.2 57.59 224 71 224 80a61.16 61.16 0 01-5.19 24.77 17.38 17.38 0 000 14.06 63.81 63.81 0 010 50.39 17.32 17.32 0 000 14 62.13 62.13 0 010 49.58 18.13 18.13 0 000 14.68A60.41 60.41 0 01224 273c0 8.2-2 21.3-8 31.18a15.66 15.66 0 00-1.14 13.65c.38 1 .76 2.06 1.13 3.17a24.8 24.8 0 01.86 11.57c-3 19.35-9.67 36.3-16.74 54.16-3.08 7.78-6.27 15.82-9.22 24.27-6.14 17.56-4.3 35.2 5.05 48.38z"
|
||||
/></svg
|
||||
>
|
||||
{:else if i === "thumb-up"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M472.06 334l-144.16-6.13c-4.61-.36-23.9-1.21-23.9-25.87 0-23.81 19.16-25.33 24.14-25.88L472.06 270c12.67.13 23.94 14.43 23.94 32s-11.27 31.87-23.94 32zM330.61 202.33L437.35 194C450 194 464 210.68 464 227.88v.33c0 16.32-11.14 29.62-24.88 29.79l-108.45-1.73C304 253 304 236.83 304 229.88c0-22.88 21.8-27.15 26.61-27.55zM421.85 480l-89.37-8.93C308 470.14 304 453.82 304 443.59c0-18.38 13.41-24.6 26.67-24.6l91-3c14.54.23 26.32 14.5 26.32 32s-11.67 31.67-26.14 32.01zm34.36-71.5l-126.4-6.21c-9.39-.63-25.81-3-25.81-26.37 0-12 4.35-25.61 25-27.53l127.19-3.88c13.16.14 23.81 13.49 23.81 31.4s-10.65 32.43-23.79 32.58z"
|
||||
/>
|
||||
<path
|
||||
d="M133.55 238.06A15.85 15.85 0 01126 240a15.82 15.82 0 007.51-1.92zM174.14 168.78l.13-.23-.13.23c-20.5 35.51-30.36 54.95-33.82 62 3.47-7.07 13.34-26.51 33.82-62z"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M139.34 232.84l1-2a16.27 16.27 0 01-6.77 7.25 16.35 16.35 0 005.77-5.25z"
|
||||
/><path
|
||||
d="M316.06 52.62C306.63 39.32 291 32 272 32a16 16 0 00-14.31 8.84c-3 6.07-15.25 24-28.19 42.91-18 26.33-40.35 59.07-55.23 84.8l-.13.23c-20.48 35.49-30.35 54.93-33.82 62l-1 2a16.35 16.35 0 01-5.79 5.22 15.82 15.82 0 01-7.53 2h-25.31A84.69 84.69 0 0016 324.69v38.61a84.69 84.69 0 0084.69 84.7h48.79a17.55 17.55 0 019.58 2.89C182 465.87 225.34 480 272 480c7.45 0 14.19-.14 20.27-.38a8 8 0 006.2-12.68l-.1-.14C289.8 454.41 288 441 288 432a61.2 61.2 0 015.19-24.77 17.36 17.36 0 000-14.05 63.81 63.81 0 010-50.39 17.32 17.32 0 000-14 62.15 62.15 0 010-49.59 18.13 18.13 0 000-14.68A60.33 60.33 0 01288 239c0-8.2 2-21.3 8-31.19a15.63 15.63 0 001.14-13.64c-.38-1-.76-2.07-1.13-3.17a24.84 24.84 0 01-.86-11.58c3-19.34 9.67-36.29 16.74-54.16 3.08-7.78 6.27-15.82 9.22-24.26 6.14-17.57 4.3-35.2-5.05-48.38z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "pause"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M256 48C141.31 48 48 141.31 48 256s93.31 208 208 208 208-93.31 208-208S370.69 48 256 48zm-32 272a16 16 0 01-32 0V192a16 16 0 0132 0zm96 0a16 16 0 01-32 0V192a16 16 0 0132 0z"
|
||||
/>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M256 48C141.31 48 48 141.31 48 256s93.31 208 208 208 208-93.31 208-208S370.69 48 256 48zm-32 272a16 16 0 01-32 0V192a16 16 0 0132 0zm96 0a16 16 0 01-32 0V192a16 16 0 0132 0z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "jellyfin"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={wh} height={wh}>
|
||||
<path
|
||||
d="M12 .002C8.826.002-1.398 18.537.16 21.666c1.56 3.129 22.14 3.094 23.682 0C25.384 18.573 15.177 0 12 0zm7.76 18.949c-1.008 2.028-14.493 2.05-15.514 0C3.224 16.9 9.92 4.755 12.003 4.755c2.081 0 8.77 12.166 7.759 14.196zM12 9.198c-1.054 0-4.446 6.15-3.93 7.189.518 1.04 7.348 1.027 7.86 0 .511-1.027-2.874-7.19-3.93-7.19z"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
width={wh}
|
||||
height={wh}
|
||||
>
|
||||
<path
|
||||
d="M12 .002C8.826.002-1.398 18.537.16 21.666c1.56 3.129 22.14 3.094 23.682 0C25.384 18.573 15.177 0 12 0zm7.76 18.949c-1.008 2.028-14.493 2.05-15.514 0C3.224 16.9 9.92 4.755 12.003 4.755c2.081 0 8.77 12.166 7.759 14.196zM12 9.198c-1.054 0-4.446 6.15-3.93 7.189.518 1.04 7.348 1.027 7.86 0 .511-1.027-2.874-7.19-3.93-7.19z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "emby"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={wh} height={wh}>
|
||||
<path
|
||||
d="M11.041 0c-.007 0-1.456 1.43-3.219 3.176L4.615 6.352l.512.513.512.512-2.819 2.791L0 12.961l1.83 1.848c1.006 1.016 2.438 2.46 3.182 3.209l1.351 1.359.508-.496c.28-.273.515-.498.524-.498.008 0 1.266 1.264 2.794 2.808L12.97 24l.187-.182c.23-.225 5.007-4.95 5.717-5.656l.52-.516-.502-.513c-.276-.282-.5-.52-.496-.53.003-.009 1.264-1.26 2.802-2.783 1.538-1.522 2.8-2.776 2.803-2.785.005-.012-3.617-3.684-6.107-6.193L17.65 4.6l-.505.505c-.279.278-.517.501-.53.497-.013-.005-1.27-1.267-2.793-2.805A449.655 449.655 0 0011.041 0zM9.223 7.367c.091.038 7.951 4.608 7.957 4.627.003.013-1.781 1.056-3.965 2.32a999.898 999.898 0 01-3.996 2.307c-.019.006-.026-1.266-.026-4.629 0-3.7.007-4.634.03-4.625Z"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
width={wh}
|
||||
height={wh}
|
||||
>
|
||||
<path
|
||||
d="M11.041 0c-.007 0-1.456 1.43-3.219 3.176L4.615 6.352l.512.513.512.512-2.819 2.791L0 12.961l1.83 1.848c1.006 1.016 2.438 2.46 3.182 3.209l1.351 1.359.508-.496c.28-.273.515-.498.524-.498.008 0 1.266 1.264 2.794 2.808L12.97 24l.187-.182c.23-.225 5.007-4.95 5.717-5.656l.52-.516-.502-.513c-.276-.282-.5-.52-.496-.53.003-.009 1.264-1.26 2.802-2.783 1.538-1.522 2.8-2.776 2.803-2.785.005-.012-3.617-3.684-6.107-6.193L17.65 4.6l-.505.505c-.279.278-.517.501-.53.497-.013-.005-1.27-1.267-2.793-2.805A449.655 449.655 0 0011.041 0zM9.223 7.367c.091.038 7.951 4.608 7.957 4.627.003.013-1.781 1.056-3.965 2.32a999.898 999.898 0 01-3.996 2.307c-.019.006-.026-1.266-.026-4.629 0-3.7.007-4.634.03-4.625Z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "plex"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width={wh} height={wh}>
|
||||
<path d="M256 70H148l108 186-108 186h108l108-186z" fill="#e5a00d" />
|
||||
</svg>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 512 512"
|
||||
width={wh}
|
||||
height={wh}
|
||||
>
|
||||
<path d="M256 70H148l108 186-108 186h108l108-186z" fill="#e5a00d" />
|
||||
</svg>
|
||||
{:else if i === "trash"}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={wh}
|
||||
height={wh}
|
||||
viewBox="0 0 512 512"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M296 64h-80a7.91 7.91 0 00-8 8v24h96V72a7.91 7.91 0 00-8-8z" fill="none" />
|
||||
<path
|
||||
d="M432 96h-96V72a40 40 0 00-40-40h-80a40 40 0 00-40 40v24H80a16 16 0 000 32h17l19 304.92c1.42 26.85 22 47.08 48 47.08h184c26.13 0 46.3-19.78 48-47l19-305h17a16 16 0 000-32zM192.57 416H192a16 16 0 01-16-15.43l-8-224a16 16 0 1132-1.14l8 224A16 16 0 01192.57 416zM272 400a16 16 0 01-32 0V176a16 16 0 0132 0zm32-304h-96V72a7.91 7.91 0 018-8h80a7.91 7.91 0 018 8zm32 304.57A16 16 0 01320 416h-.58A16 16 0 01304 399.43l8-224a16 16 0 1132 1.14z"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={wh}
|
||||
height={wh}
|
||||
viewBox="0 0 512 512"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M296 64h-80a7.91 7.91 0 00-8 8v24h96V72a7.91 7.91 0 00-8-8z"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M432 96h-96V72a40 40 0 00-40-40h-80a40 40 0 00-40 40v24H80a16 16 0 000 32h17l19 304.92c1.42 26.85 22 47.08 48 47.08h184c26.13 0 46.3-19.78 48-47l19-305h17a16 16 0 000-32zM192.57 416H192a16 16 0 01-16-15.43l-8-224a16 16 0 1132-1.14l8 224A16 16 0 01192.57 416zM272 400a16 16 0 01-32 0V176a16 16 0 0132 0zm32-304h-96V72a7.91 7.91 0 018-8h80a7.91 7.91 0 018 8zm32 304.57A16 16 0 01320 416h-.58A16 16 0 01304 399.43l8-224a16 16 0 1132 1.14z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "close"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width={wh} height={wh}>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="32"
|
||||
d="M368 368L144 144M368 144L144 368"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 512 512"
|
||||
width={wh}
|
||||
height={wh}
|
||||
>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="32"
|
||||
d="M368 368L144 144M368 144L144 368"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "close-circle"}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={wh}
|
||||
height={wh}
|
||||
viewBox="0 0 512 512"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M256 48C141.31 48 48 141.31 48 256s93.31 208 208 208 208-93.31 208-208S370.69 48 256 48zm75.31 260.69a16 16 0 11-22.62 22.62L256 278.63l-52.69 52.68a16 16 0 01-22.62-22.62L233.37 256l-52.68-52.69a16 16 0 0122.62-22.62L256 233.37l52.69-52.68a16 16 0 0122.62 22.62L278.63 256z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "filter"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M296 464a23.88 23.88 0 01-7.55-1.23L208.3 436.1a23.92 23.92 0 01-16.3-22.78V294.11a.44.44 0 00-.09-.13L23.26 97.54A30 30 0 0146.05 48H466a30 30 0 0122.79 49.54L320.09 294a.77.77 0 00-.09.13V440a23.93 23.93 0 01-24 24z"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={wh}
|
||||
height={wh}
|
||||
viewBox="0 0 512 512"
|
||||
>
|
||||
<path
|
||||
d="M296 464a23.88 23.88 0 01-7.55-1.23L208.3 436.1a23.92 23.92 0 01-16.3-22.78V294.11a.44.44 0 00-.09-.13L23.26 97.54A30 30 0 0146.05 48H466a30 30 0 0122.79 49.54L320.09 294a.77.77 0 00-.09.13V440a23.93 23.93 0 01-24 24z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "filter-circle"}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={wh}
|
||||
height={wh}
|
||||
viewBox="0 0 512 512"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M256 48C141.31 48 48 141.31 48 256s93.31 208 208 208 208-93.31 208-208S370.69 48 256 48zm32 304h-64a16 16 0 010-32h64a16 16 0 010 32zm48-64H176a16 16 0 010-32h160a16 16 0 010 32zm32-64H144a16 16 0 010-32h224a16 16 0 010 32z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "reel"}
|
||||
<svg
|
||||
id="reel"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 512 512"
|
||||
width={wh}
|
||||
height={wh}
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
fill="#fff"
|
||||
d="M426 138c-1-37-2-74-6-109-14-7-33-12-54-9-29 9-49 31-80 36-50 9-80-25-116-37-7-2-20 1-25-2l-3 2c-21 6-42 11-51 29-4 74-3 149-3 224 0 74-4 150 6 220 23 9 54 0 79-2 25-7 54-10 82-16 25-9 45-24 75-28 11 0 22 0 31 2 21 8 31 35 60 29l-1-111c-3-78 7-153 6-228Z"
|
||||
/>
|
||||
<path
|
||||
fill="#000"
|
||||
d="M158 133c19-2 55 3 75-2-19-8-49-1-72-4 0-16 5-39-3-51-5 13-3 42 0 57Zm-1-72c3-1 4-2 4-7-6-6-12 7-4 7Zm-8-34 19 1c36 15 61 41 114 38 33-5 54-22 80-34l-1 104c-70 4-143 1-213 4l1-111v-2Zm-15 21c2 4 1 11 0 15-8 1-20 3-28 2-2-4-1-11-1-15 8-2 21-4 29-2Zm268 6c3 3 1 10 2 15-7 3-21 2-29 1-2-4 0-11-1-16 7-4 21-2 28 0ZM133 80v20c-10 0-20-1-29-3l1-17c9-2 18 2 28 0Zm271 8c3 4 2 14 1 18-8-1-22 2-31-1l1-16c9-3 20 2 29-1Zm-300 28c9 2 20 1 31 1v20c-9 2-20-1-30-1 0-7-2-12-1-20m271 5 32 1c-1 6 1 14-1 19-11-1-22 2-32 0 1-7-1-16 1-20Zm-270 34c11-1 19 1 30 1v19c-9 3-21 0-30-1v-19Zm206 139c4 0 12 3 13-1-1-3-14-5-13 1Zm-144-1c5 7 23 1 27-5-9-2-20 2-27 5Zm-12-10c6-12 3-30 4-46 1-22 2-45-1-65l-3-1c-2 35-5 78 0 112Zm66-119 1 1c37 2 78 4 116 4 5 0 13 3 15-3-42-4-90-2-134-4l-1 1c1 0 1 2 3 1Zm-73-8c68 3 145 4 213 0-1 48-5 94-5 142-65 6-141 1-207 5l-2-145 1-2Zm259 6c1 5 0 12-1 17-12-1-24 4-33 0v-18c9-3 25-1 34 1Zm-2 35c0 6 1 14-1 19-11-1-24 0-31 1-3-4-3-16-1-20 7-3 23-1 33 0Zm-303 2c10-2 23 0 34 0l-2 20c-11 0-21-2-31-1 0-6 1-14-1-19Zm303 35-1 20c-9 2-22 1-32 1v-22c10-1 23 1 33 1Zm-302 0c11 2 26-2 33 3 0 7-2 12-1 20-11 0-22 0-32-2v-21Zm302 41c1 5 2 12 0 17-10 3-24 1-36 2v-22c12 1 28-2 36 3Zm-268 2-1 21c-10 2-24-2-34 0 1-7-1-16 1-21h34Zm268 37c2 5-1 11 0 18-10 2-25 0-35 2-2-5 0-14-2-19 9-3 27-2 37-1Zm-268 1v17c-9 3-25 2-35 1v-17c9-2 22-1 35-1Zm20 15c9 2 23 2 30-1-7-3-25-4-30 1Zm59-4c31 9 84 3 123 7 5 17 2 41 3 63-1 4-5 16 1 19 4-23 6-62 3-87-39-3-87-3-130-2Zm139-11c-1 39-4 83 0 121-23-6-40 5-61 7-45 4-97 2-143 6-2-43-1-87-2-130 68 2 137-4 206-4Zm49 34c0 6 2 11 1 17-7 4-21 1-32 2v-18c9-1 21-3 31-1m-270 0c1 8-1 13-1 21-10 0-23 1-30-3v-17c10-3 23 1 31-1m272 35v16c-9 2-21 0-32 0 0-5 1-11-1-16 9-1 24-2 33 0Zm-269 3v21c-10-3-28 1-35-4v-15c9-4 24-3 35-2Zm270 31v16c-8 3-24 2-32 0 0-6-1-11 1-16 9-2 23-2 31 0Zm-270 7 2 19c-10 3-25 0-35 2 0-6-2-15-1-21 11 0 24-2 34 0Zm119 36c-33 7-67 13-103 18 1-7-1-11-1-17 36 1 70 0 104-1Zm-119 1v20c-11 0-23 1-32-2v-17l32-1Z"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
id="reel"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 512 512"
|
||||
width={wh}
|
||||
height={wh}
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
fill="#fff"
|
||||
d="M426 138c-1-37-2-74-6-109-14-7-33-12-54-9-29 9-49 31-80 36-50 9-80-25-116-37-7-2-20 1-25-2l-3 2c-21 6-42 11-51 29-4 74-3 149-3 224 0 74-4 150 6 220 23 9 54 0 79-2 25-7 54-10 82-16 25-9 45-24 75-28 11 0 22 0 31 2 21 8 31 35 60 29l-1-111c-3-78 7-153 6-228Z"
|
||||
/>
|
||||
<path
|
||||
fill="#000"
|
||||
d="M158 133c19-2 55 3 75-2-19-8-49-1-72-4 0-16 5-39-3-51-5 13-3 42 0 57Zm-1-72c3-1 4-2 4-7-6-6-12 7-4 7Zm-8-34 19 1c36 15 61 41 114 38 33-5 54-22 80-34l-1 104c-70 4-143 1-213 4l1-111v-2Zm-15 21c2 4 1 11 0 15-8 1-20 3-28 2-2-4-1-11-1-15 8-2 21-4 29-2Zm268 6c3 3 1 10 2 15-7 3-21 2-29 1-2-4 0-11-1-16 7-4 21-2 28 0ZM133 80v20c-10 0-20-1-29-3l1-17c9-2 18 2 28 0Zm271 8c3 4 2 14 1 18-8-1-22 2-31-1l1-16c9-3 20 2 29-1Zm-300 28c9 2 20 1 31 1v20c-9 2-20-1-30-1 0-7-2-12-1-20m271 5 32 1c-1 6 1 14-1 19-11-1-22 2-32 0 1-7-1-16 1-20Zm-270 34c11-1 19 1 30 1v19c-9 3-21 0-30-1v-19Zm206 139c4 0 12 3 13-1-1-3-14-5-13 1Zm-144-1c5 7 23 1 27-5-9-2-20 2-27 5Zm-12-10c6-12 3-30 4-46 1-22 2-45-1-65l-3-1c-2 35-5 78 0 112Zm66-119 1 1c37 2 78 4 116 4 5 0 13 3 15-3-42-4-90-2-134-4l-1 1c1 0 1 2 3 1Zm-73-8c68 3 145 4 213 0-1 48-5 94-5 142-65 6-141 1-207 5l-2-145 1-2Zm259 6c1 5 0 12-1 17-12-1-24 4-33 0v-18c9-3 25-1 34 1Zm-2 35c0 6 1 14-1 19-11-1-24 0-31 1-3-4-3-16-1-20 7-3 23-1 33 0Zm-303 2c10-2 23 0 34 0l-2 20c-11 0-21-2-31-1 0-6 1-14-1-19Zm303 35-1 20c-9 2-22 1-32 1v-22c10-1 23 1 33 1Zm-302 0c11 2 26-2 33 3 0 7-2 12-1 20-11 0-22 0-32-2v-21Zm302 41c1 5 2 12 0 17-10 3-24 1-36 2v-22c12 1 28-2 36 3Zm-268 2-1 21c-10 2-24-2-34 0 1-7-1-16 1-21h34Zm268 37c2 5-1 11 0 18-10 2-25 0-35 2-2-5 0-14-2-19 9-3 27-2 37-1Zm-268 1v17c-9 3-25 2-35 1v-17c9-2 22-1 35-1Zm20 15c9 2 23 2 30-1-7-3-25-4-30 1Zm59-4c31 9 84 3 123 7 5 17 2 41 3 63-1 4-5 16 1 19 4-23 6-62 3-87-39-3-87-3-130-2Zm139-11c-1 39-4 83 0 121-23-6-40 5-61 7-45 4-97 2-143 6-2-43-1-87-2-130 68 2 137-4 206-4Zm49 34c0 6 2 11 1 17-7 4-21 1-32 2v-18c9-1 21-3 31-1m-270 0c1 8-1 13-1 21-10 0-23 1-30-3v-17c10-3 23 1 31-1m272 35v16c-9 2-21 0-32 0 0-5 1-11-1-16 9-1 24-2 33 0Zm-269 3v21c-10-3-28 1-35-4v-15c9-4 24-3 35-2Zm270 31v16c-8 3-24 2-32 0 0-6-1-11 1-16 9-2 23-2 31 0Zm-270 7 2 19c-10 3-25 0-35 2 0-6-2-15-1-21 11 0 24-2 34 0Zm119 36c-33 7-67 13-103 18 1-7-1-11-1-17 36 1 70 0 104-1Zm-119 1v20c-11 0-23 1-32-2v-17l32-1Z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "compass"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<circle cx="256" cy="256" r="24" />
|
||||
<path
|
||||
d="M256 48C141.31 48 48 141.31 48 256s93.31 208 208 208 208-93.31 208-208S370.69 48 256 48zm105.07 113.33l-46.88 117.2a64 64 0 01-35.66 35.66l-117.2 46.88a8 8 0 01-10.4-10.4l46.88-117.2a64 64 0 0135.66-35.66l117.2-46.88a8 8 0 0110.4 10.4z"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={wh}
|
||||
height={wh}
|
||||
viewBox="0 0 512 512"
|
||||
>
|
||||
<circle cx="256" cy="256" r="24" />
|
||||
<path
|
||||
d="M256 48C141.31 48 48 141.31 48 256s93.31 208 208 208 208-93.31 208-208S370.69 48 256 48zm105.07 113.33l-46.88 117.2a64 64 0 01-35.66 35.66l-117.2 46.88a8 8 0 01-10.4-10.4l46.88-117.2a64 64 0 0135.66-35.66l117.2-46.88a8 8 0 0110.4 10.4z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "document"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M416 221.25V416a48 48 0 0 1-48 48H144a48 48 0 0 1-48-48V96a48 48 0 0 1 48-48h98.75a32 32 0 0 1 22.62 9.37l141.26 141.26a32 32 0 0 1 9.37 22.62z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="32"
|
||||
/>
|
||||
<path
|
||||
d="M256 56v120a32 32 0 0 0 32 32h120m-232 80h160m-160 80h160"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="32"
|
||||
/>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M416 221.25V416a48 48 0 0 1-48 48H144a48 48 0 0 1-48-48V96a48 48 0 0 1 48-48h98.75a32 32 0 0 1 22.62 9.37l141.26 141.26a32 32 0 0 1 9.37 22.62z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="32"
|
||||
/>
|
||||
<path
|
||||
d="M256 56v120a32 32 0 0 0 32 32h120m-232 80h160m-160 80h160"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="32"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "add"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="32"
|
||||
d="M256 112v288M400 256H112"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={wh}
|
||||
height={wh}
|
||||
viewBox="0 0 512 512"
|
||||
>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="32"
|
||||
d="M256 112v288M400 256H112"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "arrow"}
|
||||
<svg
|
||||
class={facing}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={wh}
|
||||
height={wh}
|
||||
viewBox="0 0 512 512"
|
||||
>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="48"
|
||||
d="M244 400L100 256l144-144M120 256h292"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
class={facing}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={wh}
|
||||
height={wh}
|
||||
viewBox="0 0 512 512"
|
||||
>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="48"
|
||||
d="M244 400L100 256l144-144M120 256h292"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "chevron"}
|
||||
<svg
|
||||
class={facing}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={wh}
|
||||
height={wh}
|
||||
viewBox="0 0 512 512"
|
||||
>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="48"
|
||||
d="M328 112L184 256l144 144"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
class={facing}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={wh}
|
||||
height={wh}
|
||||
viewBox="0 0 512 512"
|
||||
>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="48"
|
||||
d="M328 112L184 256l144 144"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "search"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M456.69 421.39L362.6 327.3a173.81 173.81 0 0034.84-104.58C397.44 126.38 319.06 48 222.72 48S48 126.38 48 222.72s78.38 174.72 174.72 174.72A173.81 173.81 0 00327.3 362.6l94.09 94.09a25 25 0 0035.3-35.3zM97.92 222.72a124.8 124.8 0 11124.8 124.8 124.95 124.95 0 01-124.8-124.8z"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={wh}
|
||||
height={wh}
|
||||
viewBox="0 0 512 512"
|
||||
>
|
||||
<path
|
||||
d="M456.69 421.39L362.6 327.3a173.81 173.81 0 0034.84-104.58C397.44 126.38 319.06 48 222.72 48S48 126.38 48 222.72s78.38 174.72 174.72 174.72A173.81 173.81 0 00327.3 362.6l94.09 94.09a25 25 0 0035.3-35.3zM97.92 222.72a124.8 124.8 0 11124.8 124.8 124.95 124.95 0 01-124.8-124.8z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "sort"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M472 168H40a24 24 0 010-48h432a24 24 0 010 48zM392 280H120a24 24 0 010-48h272a24 24 0 010 48zM296 392h-80a24 24 0 010-48h80a24 24 0 010 48z"
|
||||
/>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M472 168H40a24 24 0 010-48h432a24 24 0 010 48zM392 280H120a24 24 0 010-48h272a24 24 0 010 48zM296 392h-80a24 24 0 010-48h80a24 24 0 010 48z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "eye-closed"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M432 448a15.92 15.92 0 01-11.31-4.69l-352-352a16 16 0 0122.62-22.62l352 352A16 16 0 01432 448zM248 315.85l-51.79-51.79a2 2 0 00-3.39 1.69 64.11 64.11 0 0053.49 53.49 2 2 0 001.69-3.39zM264 196.15L315.87 248a2 2 0 003.4-1.69 64.13 64.13 0 00-53.55-53.55 2 2 0 00-1.72 3.39z"
|
||||
/>
|
||||
<path
|
||||
d="M491 273.36a32.2 32.2 0 00-.1-34.76c-26.46-40.92-60.79-75.68-99.27-100.53C349 110.55 302 96 255.68 96a226.54 226.54 0 00-71.82 11.79 4 4 0 00-1.56 6.63l47.24 47.24a4 4 0 003.82 1.05 96 96 0 01116 116 4 4 0 001.05 3.81l67.95 68a4 4 0 005.4.24 343.81 343.81 0 0067.24-77.4zM256 352a96 96 0 01-93.3-118.63 4 4 0 00-1.05-3.81l-66.84-66.87a4 4 0 00-5.41-.23c-24.39 20.81-47 46.13-67.67 75.72a31.92 31.92 0 00-.64 35.54c26.41 41.33 60.39 76.14 98.28 100.65C162.06 402 207.92 416 255.68 416a238.22 238.22 0 0072.64-11.55 4 4 0 001.61-6.64l-47.47-47.46a4 4 0 00-3.81-1.05A96 96 0 01256 352z"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={wh}
|
||||
height={wh}
|
||||
viewBox="0 0 512 512"
|
||||
>
|
||||
<path
|
||||
d="M432 448a15.92 15.92 0 01-11.31-4.69l-352-352a16 16 0 0122.62-22.62l352 352A16 16 0 01432 448zM248 315.85l-51.79-51.79a2 2 0 00-3.39 1.69 64.11 64.11 0 0053.49 53.49 2 2 0 001.69-3.39zM264 196.15L315.87 248a2 2 0 003.4-1.69 64.13 64.13 0 00-53.55-53.55 2 2 0 00-1.72 3.39z"
|
||||
/>
|
||||
<path
|
||||
d="M491 273.36a32.2 32.2 0 00-.1-34.76c-26.46-40.92-60.79-75.68-99.27-100.53C349 110.55 302 96 255.68 96a226.54 226.54 0 00-71.82 11.79 4 4 0 00-1.56 6.63l47.24 47.24a4 4 0 003.82 1.05 96 96 0 01116 116 4 4 0 001.05 3.81l67.95 68a4 4 0 005.4.24 343.81 343.81 0 0067.24-77.4zM256 352a96 96 0 01-93.3-118.63 4 4 0 00-1.05-3.81l-66.84-66.87a4 4 0 00-5.41-.23c-24.39 20.81-47 46.13-67.67 75.72a31.92 31.92 0 00-.64 35.54c26.41 41.33 60.39 76.14 98.28 100.65C162.06 402 207.92 416 255.68 416a238.22 238.22 0 0072.64-11.55 4 4 0 001.61-6.64l-47.47-47.46a4 4 0 00-3.81-1.05A96 96 0 01256 352z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "people-nocircle"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M336 256c-20.56 0-40.44-9.18-56-25.84-15.13-16.25-24.37-37.92-26-61-1.74-24.62 5.77-47.26 21.14-63.76S312 80 336 80c23.83 0 45.38 9.06 60.7 25.52 15.47 16.62 23 39.22 21.26 63.63-1.67 23.11-10.9 44.77-26 61C376.44 246.82 356.57 256 336 256zm66-88zM467.83 432H204.18a27.71 27.71 0 01-22-10.67 30.22 30.22 0 01-5.26-25.79c8.42-33.81 29.28-61.85 60.32-81.08C264.79 297.4 299.86 288 336 288c36.85 0 71 9 98.71 26.05 31.11 19.13 52 47.33 60.38 81.55a30.27 30.27 0 01-5.32 25.78A27.68 27.68 0 01467.83 432zM147 260c-35.19 0-66.13-32.72-69-72.93-1.42-20.6 5-39.65 18-53.62 12.86-13.83 31-21.45 51-21.45s38 7.66 50.93 21.57c13.1 14.08 19.5 33.09 18 53.52-2.87 40.2-33.8 72.91-68.93 72.91zM212.66 291.45c-17.59-8.6-40.42-12.9-65.65-12.9-29.46 0-58.07 7.68-80.57 21.62-25.51 15.83-42.67 38.88-49.6 66.71a27.39 27.39 0 004.79 23.36A25.32 25.32 0 0041.72 400h111a8 8 0 007.87-6.57c.11-.63.25-1.26.41-1.88 8.48-34.06 28.35-62.84 57.71-83.82a8 8 0 00-.63-13.39c-1.57-.92-3.37-1.89-5.42-2.89z"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={wh}
|
||||
height={wh}
|
||||
viewBox="0 0 512 512"
|
||||
>
|
||||
<path
|
||||
d="M336 256c-20.56 0-40.44-9.18-56-25.84-15.13-16.25-24.37-37.92-26-61-1.74-24.62 5.77-47.26 21.14-63.76S312 80 336 80c23.83 0 45.38 9.06 60.7 25.52 15.47 16.62 23 39.22 21.26 63.63-1.67 23.11-10.9 44.77-26 61C376.44 246.82 356.57 256 336 256zm66-88zM467.83 432H204.18a27.71 27.71 0 01-22-10.67 30.22 30.22 0 01-5.26-25.79c8.42-33.81 29.28-61.85 60.32-81.08C264.79 297.4 299.86 288 336 288c36.85 0 71 9 98.71 26.05 31.11 19.13 52 47.33 60.38 81.55a30.27 30.27 0 01-5.32 25.78A27.68 27.68 0 01467.83 432zM147 260c-35.19 0-66.13-32.72-69-72.93-1.42-20.6 5-39.65 18-53.62 12.86-13.83 31-21.45 51-21.45s38 7.66 50.93 21.57c13.1 14.08 19.5 33.09 18 53.52-2.87 40.2-33.8 72.91-68.93 72.91zM212.66 291.45c-17.59-8.6-40.42-12.9-65.65-12.9-29.46 0-58.07 7.68-80.57 21.62-25.51 15.83-42.67 38.88-49.6 66.71a27.39 27.39 0 004.79 23.36A25.32 25.32 0 0041.72 400h111a8 8 0 007.87-6.57c.11-.63.25-1.26.41-1.88 8.48-34.06 28.35-62.84 57.71-83.82a8 8 0 00-.63-13.39c-1.57-.92-3.37-1.89-5.42-2.89z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "people"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M258.9 48C141.92 46.42 46.42 141.92 48 258.9c1.56 112.19 92.91 203.54 205.1 205.1 117 1.6 212.48-93.9 210.88-210.88C462.44 140.91 371.09 49.56 258.9 48zm-3.68 152.11c.21-1.2.44-2.4.71-3.59a66.46 66.46 0 0116.29-31.21c12.89-13.73 31.16-21.31 51.45-21.31a74.05 74.05 0 0125.06 4.26 66.69 66.69 0 0126.27 17.2 68.15 68.15 0 0118 42.14 78.46 78.46 0 010 11.4 86.19 86.19 0 01-8.2 31q-.76 1.59-1.59 3.15c-1.11 2.07-2.3 4.1-3.58 6.06a79.47 79.47 0 01-8.63 11c-13.12 14-29.92 21.73-47.31 21.73a59.61 59.61 0 01-19.17-3.18 63.47 63.47 0 01-6.1-2.43 70.76 70.76 0 01-22.07-16.12 83.76 83.76 0 01-22-51.32q-.27-3.88-.18-7.68a75.62 75.62 0 011.05-11.08zm-149.73 24.34a59.87 59.87 0 015.2-20.64 56.76 56.76 0 012.78-5.3 54.49 54.49 0 017.19-9.56 55.62 55.62 0 0114-10.82 56.84 56.84 0 018.11-3.64 63.85 63.85 0 0133.35-2.39 57 57 0 0130.78 17 57.86 57.86 0 0115.41 38.62c.05 2.11 0 4.23-.15 6.38a71.58 71.58 0 01-6 23.84 69.49 69.49 0 01-5.73 10.42 65.39 65.39 0 01-15.76 16.57c-1.5 1.07-3.06 2.07-4.67 3.07a54.21 54.21 0 01-10 4.65 49.31 49.31 0 01-16.2 2.76c-.93 0-1.86 0-2.78-.08a47.6 47.6 0 01-5.48-.62 51.19 51.19 0 01-5.35-1.23 53.54 53.54 0 01-7.72-2.89c-.84-.39-1.66-.8-2.48-1.23-18-9.49-31.57-29.16-34.23-52.12-.12-1.05-.22-2.1-.29-3.16a66.59 66.59 0 01.02-9.63zm53.92 178.6a177.27 177.27 0 01-61.94-70.65 4 4 0 011.62-5.26C117.67 316.69 141.4 311 163.82 311c17 0 30.7 2 42.69 5.88a8 8 0 012.59 13.77c-23.35 19-38.4 42.54-45.47 70.75a2.77 2.77 0 01-4.22 1.65zM256 432a175.12 175.12 0 01-65.7-12.72 4 4 0 01-2.4-4.46c.4-2.05.84-3.92 1.23-5.48 7.12-28.43 24.76-52 51-68.18 23.29-14.35 53-22.25 83.52-22.25 31.16 0 60 7.58 83.48 21.91a2.72 2.72 0 01.91 3.67A176.1 176.1 0 01256 432z"
|
||||
/>
|
||||
<path
|
||||
d="M161 295.28a47.6 47.6 0 01-5.48-.62 47.6 47.6 0 005.48.62zM134.64 178.13a55.62 55.62 0 00-14 10.82 54.49 54.49 0 00-7.19 9.56 54.49 54.49 0 017.19-9.56 55.62 55.62 0 0114-10.82zM216.17 257.89a71.58 71.58 0 006-23.84c.15-2.15.2-4.27.15-6.38q.08 3.15-.15 6.38a71.58 71.58 0 01-6 23.84zM134.64 178.13a56.84 56.84 0 018.11-3.64 56.84 56.84 0 00-8.11 3.64zM150.21 293.43a53.54 53.54 0 01-7.72-2.89 53.54 53.54 0 007.72 2.89zM105.78 237.19c2.66 23 16.26 42.63 34.23 52.12-18.01-9.49-31.57-29.16-34.23-52.12zM254.34 219a83.76 83.76 0 0022 51.32 70.76 70.76 0 0022.07 16.12 70.76 70.76 0 01-22.07-16.12 83.76 83.76 0 01-22-51.32q-.27-3.88-.18-7.68-.09 3.75.18 7.68zM304.5 288.82a63.47 63.47 0 01-6.1-2.43 63.47 63.47 0 006.1 2.43zM255.93 196.54a66.46 66.46 0 0116.29-31.21 66.46 66.46 0 00-16.29 31.21zM375 165.46a68.15 68.15 0 0118 42.14 68.15 68.15 0 00-18-42.14 66.69 66.69 0 00-26.27-17.2 66.69 66.69 0 0126.27 17.2zM393 219a86.19 86.19 0 01-8.2 31 86.19 86.19 0 008.2-31zM254.16 211.27a75.62 75.62 0 011.06-11.14 75.62 75.62 0 00-1.06 11.14zM383.19 253.16zM206.88 189.05a57.86 57.86 0 0115.41 38.62 57.86 57.86 0 00-15.41-38.62 57 57 0 00-30.78-17 57 57 0 0130.78 17zM190 288a54.21 54.21 0 01-10 4.65 54.21 54.21 0 0010-4.65zM105.49 224.45a59.87 59.87 0 015.2-20.64 59.87 59.87 0 00-5.2 20.64zM194.68 284.88C193.17 286 191.61 287 190 288c1.61-1 3.17-2 4.68-3.12zM216.17 257.89a69.49 69.49 0 01-5.73 10.42 69.49 69.49 0 005.73-10.42zM110.69 203.81a56.76 56.76 0 012.78-5.3 56.76 56.76 0 00-2.78 5.3zM194.68 284.88a65.39 65.39 0 0015.76-16.57 65.39 65.39 0 01-15.76 16.57z"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={wh}
|
||||
height={wh}
|
||||
viewBox="0 0 512 512"
|
||||
>
|
||||
<path
|
||||
d="M258.9 48C141.92 46.42 46.42 141.92 48 258.9c1.56 112.19 92.91 203.54 205.1 205.1 117 1.6 212.48-93.9 210.88-210.88C462.44 140.91 371.09 49.56 258.9 48zm-3.68 152.11c.21-1.2.44-2.4.71-3.59a66.46 66.46 0 0116.29-31.21c12.89-13.73 31.16-21.31 51.45-21.31a74.05 74.05 0 0125.06 4.26 66.69 66.69 0 0126.27 17.2 68.15 68.15 0 0118 42.14 78.46 78.46 0 010 11.4 86.19 86.19 0 01-8.2 31q-.76 1.59-1.59 3.15c-1.11 2.07-2.3 4.1-3.58 6.06a79.47 79.47 0 01-8.63 11c-13.12 14-29.92 21.73-47.31 21.73a59.61 59.61 0 01-19.17-3.18 63.47 63.47 0 01-6.1-2.43 70.76 70.76 0 01-22.07-16.12 83.76 83.76 0 01-22-51.32q-.27-3.88-.18-7.68a75.62 75.62 0 011.05-11.08zm-149.73 24.34a59.87 59.87 0 015.2-20.64 56.76 56.76 0 012.78-5.3 54.49 54.49 0 017.19-9.56 55.62 55.62 0 0114-10.82 56.84 56.84 0 018.11-3.64 63.85 63.85 0 0133.35-2.39 57 57 0 0130.78 17 57.86 57.86 0 0115.41 38.62c.05 2.11 0 4.23-.15 6.38a71.58 71.58 0 01-6 23.84 69.49 69.49 0 01-5.73 10.42 65.39 65.39 0 01-15.76 16.57c-1.5 1.07-3.06 2.07-4.67 3.07a54.21 54.21 0 01-10 4.65 49.31 49.31 0 01-16.2 2.76c-.93 0-1.86 0-2.78-.08a47.6 47.6 0 01-5.48-.62 51.19 51.19 0 01-5.35-1.23 53.54 53.54 0 01-7.72-2.89c-.84-.39-1.66-.8-2.48-1.23-18-9.49-31.57-29.16-34.23-52.12-.12-1.05-.22-2.1-.29-3.16a66.59 66.59 0 01.02-9.63zm53.92 178.6a177.27 177.27 0 01-61.94-70.65 4 4 0 011.62-5.26C117.67 316.69 141.4 311 163.82 311c17 0 30.7 2 42.69 5.88a8 8 0 012.59 13.77c-23.35 19-38.4 42.54-45.47 70.75a2.77 2.77 0 01-4.22 1.65zM256 432a175.12 175.12 0 01-65.7-12.72 4 4 0 01-2.4-4.46c.4-2.05.84-3.92 1.23-5.48 7.12-28.43 24.76-52 51-68.18 23.29-14.35 53-22.25 83.52-22.25 31.16 0 60 7.58 83.48 21.91a2.72 2.72 0 01.91 3.67A176.1 176.1 0 01256 432z"
|
||||
/>
|
||||
<path
|
||||
d="M161 295.28a47.6 47.6 0 01-5.48-.62 47.6 47.6 0 005.48.62zM134.64 178.13a55.62 55.62 0 00-14 10.82 54.49 54.49 0 00-7.19 9.56 54.49 54.49 0 017.19-9.56 55.62 55.62 0 0114-10.82zM216.17 257.89a71.58 71.58 0 006-23.84c.15-2.15.2-4.27.15-6.38q.08 3.15-.15 6.38a71.58 71.58 0 01-6 23.84zM134.64 178.13a56.84 56.84 0 018.11-3.64 56.84 56.84 0 00-8.11 3.64zM150.21 293.43a53.54 53.54 0 01-7.72-2.89 53.54 53.54 0 007.72 2.89zM105.78 237.19c2.66 23 16.26 42.63 34.23 52.12-18.01-9.49-31.57-29.16-34.23-52.12zM254.34 219a83.76 83.76 0 0022 51.32 70.76 70.76 0 0022.07 16.12 70.76 70.76 0 01-22.07-16.12 83.76 83.76 0 01-22-51.32q-.27-3.88-.18-7.68-.09 3.75.18 7.68zM304.5 288.82a63.47 63.47 0 01-6.1-2.43 63.47 63.47 0 006.1 2.43zM255.93 196.54a66.46 66.46 0 0116.29-31.21 66.46 66.46 0 00-16.29 31.21zM375 165.46a68.15 68.15 0 0118 42.14 68.15 68.15 0 00-18-42.14 66.69 66.69 0 00-26.27-17.2 66.69 66.69 0 0126.27 17.2zM393 219a86.19 86.19 0 01-8.2 31 86.19 86.19 0 008.2-31zM254.16 211.27a75.62 75.62 0 011.06-11.14 75.62 75.62 0 00-1.06 11.14zM383.19 253.16zM206.88 189.05a57.86 57.86 0 0115.41 38.62 57.86 57.86 0 00-15.41-38.62 57 57 0 00-30.78-17 57 57 0 0130.78 17zM190 288a54.21 54.21 0 01-10 4.65 54.21 54.21 0 0010-4.65zM105.49 224.45a59.87 59.87 0 015.2-20.64 59.87 59.87 0 00-5.2 20.64zM194.68 284.88C193.17 286 191.61 287 190 288c1.61-1 3.17-2 4.68-3.12zM216.17 257.89a69.49 69.49 0 01-5.73 10.42 69.49 69.49 0 005.73-10.42zM110.69 203.81a56.76 56.76 0 012.78-5.3 56.76 56.76 0 00-2.78 5.3zM194.68 284.88a65.39 65.39 0 0015.76-16.57 65.39 65.39 0 01-15.76 16.57z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "person"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M258.9 48C141.92 46.42 46.42 141.92 48 258.9c1.56 112.19 92.91 203.54 205.1 205.1 117 1.6 212.48-93.9 210.88-210.88C462.44 140.91 371.09 49.56 258.9 48zm126.42 327.25a4 4 0 01-6.14-.32 124.27 124.27 0 00-32.35-29.59C321.37 329 289.11 320 256 320s-65.37 9-90.83 25.34a124.24 124.24 0 00-32.35 29.58 4 4 0 01-6.14.32A175.32 175.32 0 0180 259c-1.63-97.31 78.22-178.76 175.57-179S432 158.81 432 256a175.32 175.32 0 01-46.68 119.25z"
|
||||
/>
|
||||
<path
|
||||
d="M256 144c-19.72 0-37.55 7.39-50.22 20.82s-19 32-17.57 51.93C191.11 256 221.52 288 256 288s64.83-32 67.79-71.24c1.48-19.74-4.8-38.14-17.68-51.82C293.39 151.44 275.59 144 256 144z"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={wh}
|
||||
height={wh}
|
||||
viewBox="0 0 512 512"
|
||||
>
|
||||
<path
|
||||
d="M258.9 48C141.92 46.42 46.42 141.92 48 258.9c1.56 112.19 92.91 203.54 205.1 205.1 117 1.6 212.48-93.9 210.88-210.88C462.44 140.91 371.09 49.56 258.9 48zm126.42 327.25a4 4 0 01-6.14-.32 124.27 124.27 0 00-32.35-29.59C321.37 329 289.11 320 256 320s-65.37 9-90.83 25.34a124.24 124.24 0 00-32.35 29.58 4 4 0 01-6.14.32A175.32 175.32 0 0180 259c-1.63-97.31 78.22-178.76 175.57-179S432 158.81 432 256a175.32 175.32 0 01-46.68 119.25z"
|
||||
/>
|
||||
<path
|
||||
d="M256 144c-19.72 0-37.55 7.39-50.22 20.82s-19 32-17.57 51.93C191.11 256 221.52 288 256 288s64.83-32 67.79-71.24c1.48-19.74-4.8-38.14-17.68-51.82C293.39 151.44 275.59 144 256 144z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "person-add"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M288 256c52.79 0 99.43-49.71 104-110.82 2.27-30.7-7.36-59.33-27.12-80.6C345.33 43.57 318 32 288 32c-30.24 0-57.59 11.5-77 32.38-19.63 21.11-29.2 49.8-27 80.78C188.49 206.28 235.12 256 288 256zM495.38 439.76c-8.44-46.82-34.79-86.15-76.19-113.75C382.42 301.5 335.83 288 288 288s-94.42 13.5-131.19 38c-41.4 27.6-67.75 66.93-76.19 113.75-1.93 10.73.69 21.34 7.19 29.11A30.94 30.94 0 00112 480h352a30.94 30.94 0 0024.21-11.13c6.48-7.77 9.1-18.38 7.17-29.11zM104 288v-40h40a16 16 0 000-32h-40v-40a16 16 0 00-32 0v40H32a16 16 0 000 32h40v40a16 16 0 0032 0z"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={wh}
|
||||
height={wh}
|
||||
viewBox="0 0 512 512"
|
||||
>
|
||||
<path
|
||||
d="M288 256c52.79 0 99.43-49.71 104-110.82 2.27-30.7-7.36-59.33-27.12-80.6C345.33 43.57 318 32 288 32c-30.24 0-57.59 11.5-77 32.38-19.63 21.11-29.2 49.8-27 80.78C188.49 206.28 235.12 256 288 256zM495.38 439.76c-8.44-46.82-34.79-86.15-76.19-113.75C382.42 301.5 335.83 288 288 288s-94.42 13.5-131.19 38c-41.4 27.6-67.75 66.93-76.19 113.75-1.93 10.73.69 21.34 7.19 29.11A30.94 30.94 0 00112 480h352a30.94 30.94 0 0024.21-11.13c6.48-7.77 9.1-18.38 7.17-29.11zM104 288v-40h40a16 16 0 000-32h-40v-40a16 16 0 00-32 0v40H32a16 16 0 000 32h40v40a16 16 0 0032 0z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "person-minus"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M288 256c52.79 0 99.43-49.71 104-110.82 2.27-30.7-7.36-59.33-27.12-80.6C345.33 43.57 318 32 288 32c-30.24 0-57.59 11.5-77 32.38-19.63 21.11-29.2 49.8-27 80.78C188.49 206.28 235.12 256 288 256zM495.38 439.76c-8.44-46.82-34.79-86.15-76.19-113.75C382.42 301.5 335.83 288 288 288s-94.42 13.5-131.19 38c-41.4 27.6-67.75 66.93-76.19 113.75-1.93 10.73.69 21.34 7.19 29.11A30.94 30.94 0 00112 480h352a30.94 30.94 0 0024.21-11.13c6.48-7.77 9.1-18.38 7.17-29.11zM144 216H32a16 16 0 000 32h112a16 16 0 000-32z"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={wh}
|
||||
height={wh}
|
||||
viewBox="0 0 512 512"
|
||||
>
|
||||
<path
|
||||
d="M288 256c52.79 0 99.43-49.71 104-110.82 2.27-30.7-7.36-59.33-27.12-80.6C345.33 43.57 318 32 288 32c-30.24 0-57.59 11.5-77 32.38-19.63 21.11-29.2 49.8-27 80.78C188.49 206.28 235.12 256 288 256zM495.38 439.76c-8.44-46.82-34.79-86.15-76.19-113.75C382.42 301.5 335.83 288 288 288s-94.42 13.5-131.19 38c-41.4 27.6-67.75 66.93-76.19 113.75-1.93 10.73.69 21.34 7.19 29.11A30.94 30.94 0 00112 480h352a30.94 30.94 0 0024.21-11.13c6.48-7.77 9.1-18.38 7.17-29.11zM144 216H32a16 16 0 000 32h112a16 16 0 000-32z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "pencil"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="44"
|
||||
d="M358.62 129.28L86.49 402.08 70 442l39.92-16.49 272.8-272.13-24.1-24.1zM413.07 74.84l-11.79 11.78 24.1 24.1 11.79-11.79a16.51 16.51 0 000-23.34l-.75-.75a16.51 16.51 0 00-23.35 0z"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={wh}
|
||||
height={wh}
|
||||
viewBox="0 0 512 512"
|
||||
>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="44"
|
||||
d="M358.62 129.28L86.49 402.08 70 442l39.92-16.49 272.8-272.13-24.1-24.1zM413.07 74.84l-11.79 11.78 24.1 24.1 11.79-11.79a16.51 16.51 0 000-23.34l-.75-.75a16.51 16.51 0 00-23.35 0z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "eye"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<circle cx="256" cy="256" r="64" />
|
||||
<path
|
||||
d="M490.84 238.6c-26.46-40.92-60.79-75.68-99.27-100.53C349 110.55 302 96 255.66 96c-42.52 0-84.33 12.15-124.27 36.11-40.73 24.43-77.63 60.12-109.68 106.07a31.92 31.92 0 00-.64 35.54c26.41 41.33 60.4 76.14 98.28 100.65C162 402 207.9 416 255.66 416c46.71 0 93.81-14.43 136.2-41.72 38.46-24.77 72.72-59.66 99.08-100.92a32.2 32.2 0 00-.1-34.76zM256 352a96 96 0 1196-96 96.11 96.11 0 01-96 96z"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={wh}
|
||||
height={wh}
|
||||
viewBox="0 0 512 512"
|
||||
>
|
||||
<circle cx="256" cy="256" r="64" />
|
||||
<path
|
||||
d="M490.84 238.6c-26.46-40.92-60.79-75.68-99.27-100.53C349 110.55 302 96 255.66 96c-42.52 0-84.33 12.15-124.27 36.11-40.73 24.43-77.63 60.12-109.68 106.07a31.92 31.92 0 00-.64 35.54c26.41 41.33 60.4 76.14 98.28 100.65C162 402 207.9 416 255.66 416c46.71 0 93.81-14.43 136.2-41.72 38.46-24.77 72.72-59.66 99.08-100.92a32.2 32.2 0 00-.1-34.76zM256 352a96 96 0 1196-96 96.11 96.11 0 01-96 96z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "star"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M394 480a16 16 0 01-9.39-3L256 383.76 127.39 477a16 16 0 01-24.55-18.08L153 310.35 23 221.2a16 16 0 019-29.2h160.38l48.4-148.95a16 16 0 0130.44 0l48.4 149H480a16 16 0 019.05 29.2L359 310.35l50.13 148.53A16 16 0 01394 480z"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={wh}
|
||||
height={wh}
|
||||
viewBox="0 0 512 512"
|
||||
>
|
||||
<path
|
||||
d="M394 480a16 16 0 01-9.39-3L256 383.76 127.39 477a16 16 0 01-24.55-18.08L153 310.35 23 221.2a16 16 0 019-29.2h160.38l48.4-148.95a16 16 0 0130.44 0l48.4 149H480a16 16 0 019.05 29.2L359 310.35l50.13 148.53A16 16 0 01394 480z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "refresh"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M256 48C141.31 48 48 141.32 48 256c0 114.86 93.14 208 208 208 114.69 0 208-93.31 208-208 0-114.87-93.13-208-208-208zm0 313a94 94 0 010-188h4.21l-14.11-14.1a14 14 0 0119.8-19.8l40 40a14 14 0 010 19.8l-40 40a14 14 0 01-19.8-19.8l18-18c-2.38-.1-5.1-.1-8.1-.1a66 66 0 1066 66 14 14 0 0128 0 94.11 94.11 0 01-94 94z"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={wh}
|
||||
height={wh}
|
||||
viewBox="0 0 512 512"
|
||||
>
|
||||
<path
|
||||
d="M256 48C141.31 48 48 141.32 48 256c0 114.86 93.14 208 208 208 114.69 0 208-93.31 208-208 0-114.87-93.13-208-208-208zm0 313a94 94 0 010-188h4.21l-14.11-14.1a14 14 0 0119.8-19.8l40 40a14 14 0 010 19.8l-40 40a14 14 0 01-19.8-19.8l18-18c-2.38-.1-5.1-.1-8.1-.1a66 66 0 1066 66 14 14 0 0128 0 94.11 94.11 0 01-94 94z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "movary"}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={wh}
|
||||
height={wh}
|
||||
viewBox="0 0 120 120"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
<g transform="translate(0.000000,120.000000) scale(0.100000,-0.100000)" fill="currentColor">
|
||||
<path
|
||||
d="M470 1184 c-221 -47 -408 -236 -455 -459 -43 -204 16 -398 164 -545
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={wh}
|
||||
height={wh}
|
||||
viewBox="0 0 120 120"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
<g
|
||||
transform="translate(0.000000,120.000000) scale(0.100000,-0.100000)"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M470 1184 c-221 -47 -408 -236 -455 -459 -43 -204 16 -398 164 -545
|
||||
87 -87 164 -131 277 -160 375 -96 742 191 744 581 0 83 -30 202 -72 282 -48
|
||||
92 -165 205 -258 251 -129 62 -264 80 -400 50z m-147 -216 c12 -6 75 -51 140
|
||||
-100 64 -49 123 -91 131 -94 7 -3 67 35 132 84 168 125 169 125 193 112 11 -6
|
||||
@@ -292,20 +438,23 @@
|
||||
-33 0 -34 0 2 201 c0 162 -2 205 -14 216 -22 23 -164 81 -181 75 -13 -6 -15
|
||||
-41 -15 -253 0 -216 -2 -250 -17 -266 -19 -21 -66 -24 -82 -4 -8 10 -12 113
|
||||
-12 357 0 340 1 344 22 362 25 20 24 20 52 5z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
{:else if i === "ryot"}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={wh}
|
||||
height={wh}
|
||||
viewBox="0 0 120 120"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
<g transform="translate(0.000000,120.000000) scale(0.0500000,-0.0500000)" fill="currentColor">
|
||||
<path
|
||||
d="M1126 2369 c-402 -59 -745 -340 -881 -722 -167 -469 1 -995 407
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={wh}
|
||||
height={wh}
|
||||
viewBox="0 0 120 120"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
<g
|
||||
transform="translate(0.000000,120.000000) scale(0.0500000,-0.0500000)"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M1126 2369 c-402 -59 -745 -340 -881 -722 -167 -469 1 -995 407
|
||||
-1275 518 -356 1213 -220 1555 305 199 304 233 689 93 1026 -96 232 -297 445
|
||||
-527 561 -192 96 -436 136 -647 105z m221 -131 c-3 -13 -40 -241 -83 -508 -42
|
||||
-267 -80 -489 -84 -493 -7 -8 -530 775 -530 793 0 12 95 78 175 121 134 72
|
||||
@@ -329,78 +478,199 @@
|
||||
m519 -7 c9 -10 -49 -162 -90 -235 -52 -93 -145 -205 -222 -269 -37 -30 -71
|
||||
-55 -75 -55 -4 0 -8 64 -8 143 0 78 -3 207 -7 285 l-6 144 201 -4 c110 -1 203
|
||||
-6 207 -9z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
{:else if i === "trakt"}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={wh}
|
||||
height={wh}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M12 24C5.385 24 0 18.615 0 12S5.385 0 12 0s12 5.385 12 12-5.385 12-12 12zm0-22.789C6.05 1.211 1.211 6.05 1.211 12S6.05 22.79 12 22.79 22.79 17.95 22.79 12 17.95 1.211 12 1.211zm-7.11 17.32c1.756 1.92 4.294 3.113 7.11 3.113 1.439 0 2.801-.313 4.027-.876l-6.697-6.68-4.44 4.443zm14.288-.067c1.541-1.71 2.484-3.99 2.484-6.466 0-3.885-2.287-7.215-5.568-8.76l-6.089 6.076 9.164 9.15h.009zm-9.877-8.429L4.227 15.09l-.679-.68 5.337-5.336 6.23-6.225c-.978-.328-2.02-.509-3.115-.509C6.663 2.337 2.337 6.663 2.337 12c0 2.172.713 4.178 1.939 5.801l5.056-5.055.359.329 7.245 7.245c.15-.082.285-.164.42-.266L9.33 12.05l-4.854 4.855-.679-.679 5.535-5.535.359.331 8.46 8.437c.135-.1.255-.215.375-.316L9.39 10.027l-.083.015-.006-.007zm3.047 1.028l-.678-.676 4.788-4.79.679.689-4.789 4.785v-.008zm4.542-6.578l-5.52 5.52-.68-.679 5.521-5.52.679.684v-.005z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "myanimelist"}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={wh}
|
||||
height={wh}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M8.273 7.247v8.423l-2.103-.003v-5.216l-2.03 2.404-1.989-2.458-.02 5.285H.001L0 7.247h2.203l1.865 2.545 2.015-2.546 2.19.001zm8.628 2.069l.025 6.335h-2.365l-.008-2.871h-2.8c.07.499.21 1.266.417 1.779.155.381.298.751.583 1.128l-1.705 1.125c-.349-.636-.622-1.337-.878-2.082a9.296 9.296 0 0 1-.507-2.179c-.085-.75-.097-1.471.107-2.212a3.908 3.908 0 0 1 1.161-1.866c.313-.293.749-.5 1.1-.687.351-.187.743-.264 1.107-.359a7.405 7.405 0 0 1 1.191-.183c.398-.034 1.107-.066 2.39-.028l.545 1.749H14.51c-.593.008-.878.001-1.341.209a2.236 2.236 0 0 0-1.278 1.92l2.663.033.038-1.81h2.309zm3.992-2.099v6.627l3.107.032-.43 1.775h-4.807V7.187l2.13.03z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "todomovies"}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={wh}
|
||||
height={wh}
|
||||
viewBox="100 20 600 570"
|
||||
>
|
||||
<path d="m494.113 203.464 50.423 50.423-178.649 178.65-50.423-50.424z" />
|
||||
<path d="m234.964 300.388 50.424-50.424 130.149 130.15-50.424 50.423z" />
|
||||
<path
|
||||
d="m95.774 15.493 187.325.704c.704 0-5.634 276.761-188.733 488.029M700.705 14.789l-187.243.704c-.703 0 5.632 276.76 188.651 488.029"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "themoviedb"}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={wh}
|
||||
height={wh}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M6.62 12a2.291 2.291 0 0 1 2.292-2.295h-.013A2.291 2.291 0 0 1 11.189 12a2.291 2.291 0 0 1-2.29 2.291h.013A2.291 2.291 0 0 1 6.62 12zm10.72-4.062h4.266a2.291 2.291 0 0 0 2.29-2.291 2.291 2.291 0 0 0-2.29-2.296H17.34a2.291 2.291 0 0 0-2.291 2.296 2.291 2.291 0 0 0 2.29 2.29zM2.688 20.645h8.285a2.291 2.291 0 0 0 2.291-2.292 2.291 2.291 0 0 0-2.29-2.295H2.687a2.291 2.291 0 0 0-2.291 2.295 2.291 2.291 0 0 0 2.29 2.292zm10.881-6.354h.81l1.894-4.586H15.19l-1.154 3.008h-.013l-1.135-3.008h-1.154zm4.208 0h1.011V9.705h-1.011zm2.878 0h3.235v-.93h-2.223v-.933h1.99v-.934h-1.99v-.855h2.107v-.934h-3.112zM1.31 7.941h1.01V4.247h1.31v-.895H0v.895h1.31zm3.747 0h1.011V5.959h1.958v1.984h1.011v-4.59h-1.01v1.711H6.061V3.351H5.057zm5.348 0h3.242v-.933H11.41v-.934h1.99v-.933h-1.99v-.856h2.107v-.934h-3.112zM.162 14.296h1.005v-3.52h.013l1.167 3.52h.765l1.206-3.52h.013v3.52h1.011v-4.59H3.82L2.755 12.7h-.013L1.686 9.705H.156zm14.534 6.353h1.641a3.188 3.188 0 0 0 .98-.149 2.531 2.531 0 0 0 .824-.437 2.123 2.123 0 0 0 .567-.713 2.193 2.193 0 0 0 .223-.983 2.399 2.399 0 0 0-.218-1.07 1.958 1.958 0 0 0-.586-.716 2.405 2.405 0 0 0-.873-.392 4.349 4.349 0 0 0-1.046-.13h-1.519zm1.013-3.656h.596a2.26 2.26 0 0 1 .606.08 1.514 1.514 0 0 1 .503.244 1.167 1.167 0 0 1 .34.412 1.28 1.28 0 0 1 .13.587 1.546 1.546 0 0 1-.13.658 1.127 1.127 0 0 1-.347.433 1.41 1.41 0 0 1-.518.238 2.797 2.797 0 0 1-.649.07h-.538zm4.686 3.656h1.88a2.997 2.997 0 0 0 .613-.064 1.735 1.735 0 0 0 .554-.214 1.221 1.221 0 0 0 .402-.39 1.105 1.105 0 0 0 .155-.606 1.188 1.188 0 0 0-.071-.415 1.01 1.01 0 0 0-.204-.34 1.087 1.087 0 0 0-.317-.24 1.297 1.297 0 0 0-.413-.13v-.012a1.203 1.203 0 0 0 .575-.366.962.962 0 0 0 .216-.648 1.081 1.081 0 0 0-.149-.603 1.022 1.022 0 0 0-.389-.354 1.673 1.673 0 0 0-.54-.169 4.463 4.463 0 0 0-.6-.041h-1.712zm1.011-3.734h.687a1.4 1.4 0 0 1 .24.022.748.748 0 0 1 .22.075.432.432 0 0 1 .16.147.418.418 0 0 1 .061.236.47.47 0 0 1-.055.233.433.433 0 0 1-.146.156.62.62 0 0 1-.204.084 1.058 1.058 0 0 1-.23.026h-.745zm0 1.835h.765a1.96 1.96 0 0 1 .266.02 1.015 1.015 0 0 1 .26.07.519.519 0 0 1 .204.152.406.406 0 0 1 .08.26.481.481 0 0 1-.06.253.519.519 0 0 1-.16.168.62.62 0 0 1-.217.09 1.155 1.155 0 0 1-.237.027H21.4z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "imdb"}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={wh}
|
||||
height={wh}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M22.3781 0H1.6218C.7411.0583.0587.7437.0018 1.5953l-.001 20.783c.0585.8761.7125 1.543 1.5559 1.6191A.337.337 0 0 0 1.6016 24h20.7971a.4579.4579 0 0 0 .0437-.002c.8727-.0768 1.5568-.8271 1.5568-1.7085V1.7098c0-.8914-.696-1.6416-1.584-1.7078A.3294.3294 0 0 0 22.3781 0zm0 .496a1.2144 1.2144 0 0 1 1.1252 1.2139v20.5797c0 .6377-.4875 1.1602-1.1045 1.2145H1.6016c-.5967-.0543-1.0645-.5297-1.1053-1.1258V1.6284C.5371 1.0185 1.0184.5364 1.6217.496h20.7564zM4.7954 8.2603v7.3636H2.8899V8.2603h1.9055zm6.5367 0v7.3636H9.6707v-4.9704l-.6711 4.9704H7.813l-.6986-4.8618-.0066 4.8618h-1.668V8.2603h2.468c.0748.4476.1492.9694.2307 1.5734l.2712 1.8713.4407-3.4447h2.4817zm2.9772 1.3289c.0742.0404.122.108.1417.2034.0279.0953.0345.3118.0345.6442v2.8548c0 .4881-.0345.7867-.0955.8954-.0609.1152-.2304.1695-.5018.1695V9.5211c.204 0 .3457.0205.4211.0681zm-.0211 6.0347c.4543 0 .8006-.0265 1.0245-.0742.2304-.0477.4204-.1357.5694-.2648.1556-.1218.2642-.298.3251-.5219.0611-.2238.1021-.6648.1021-1.3224v-2.5832c0-.6986-.0271-1.1668-.0742-1.4039-.041-.237-.1431-.4543-.3126-.6437-.1695-.1973-.4198-.3324-.7456-.421-.3191-.0808-.8542-.1285-1.7694-.1285h-1.4244v7.3636h2.3051zm5.14-1.7827c0 .3523-.0199.5762-.0544.6708-.033.0947-.1894.1424-.3046.1424-.1086 0-.19-.0477-.2238-.1351-.041-.0887-.0609-.2986-.0609-.6238v-1.9469c0-.3324.0199-.5423.0543-.6237.0338-.0808.1086-.122.2171-.122.1153 0 .2709.0412.3114.1425.041.0947.0609.2986.0609.6032v1.8926zm-2.4747-5.5809v7.3636h1.7157l.1152-.4675c.1556.1894.3251.3324.5152.4271.1828.0881.4608.1357.678.1357.3047 0 .5629-.0748.7802-.237.2165-.1562.3589-.3462.4198-.5628.0543-.2173.0887-.543.0887-.9841v-2.0675c0-.4409-.0139-.7324-.0344-.8681-.0199-.1357-.0742-.2781-.1695-.4204-.1021-.1425-.2437-.251-.4272-.3325-.1834-.0742-.3999-.1152-.6576-.1152-.2172 0-.4952.0477-.6846.1285-.1835.0887-.353.2238-.5086.4007V8.2603h-1.8309z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "gamepad"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M483.13 245.38C461.92 149.49 430 98.31 382.65 84.33A107.13 107.13 0 00352 80c-13.71 0-25.65 3.34-38.28 6.88C298.5 91.15 281.21 96 256 96s-42.51-4.84-57.76-9.11C185.6 83.34 173.67 80 160 80a115.74 115.74 0 00-31.73 4.32c-47.1 13.92-79 65.08-100.52 161C4.61 348.54 16 413.71 59.69 428.83a56.62 56.62 0 0018.64 3.22c29.93 0 53.93-24.93 70.33-45.34 18.53-23.1 40.22-34.82 107.34-34.82 59.95 0 84.76 8.13 106.19 34.82 13.47 16.78 26.2 28.52 38.9 35.91 16.89 9.82 33.77 12 50.16 6.37 25.82-8.81 40.62-32.1 44-69.24 2.57-28.48-1.39-65.89-12.12-114.37zM208 240h-32v32a16 16 0 01-32 0v-32h-32a16 16 0 010-32h32v-32a16 16 0 0132 0v32h32a16 16 0 010 32zm84 4a20 20 0 1120-20 20 20 0 01-20 20zm44 44a20 20 0 1120-19.95A20 20 0 01336 288zm0-88a20 20 0 1120-20 20 20 0 01-20 20zm44 44a20 20 0 1120-20 20 20 0 01-20 20z"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={wh}
|
||||
height={wh}
|
||||
viewBox="0 0 512 512"
|
||||
>
|
||||
<path
|
||||
d="M483.13 245.38C461.92 149.49 430 98.31 382.65 84.33A107.13 107.13 0 00352 80c-13.71 0-25.65 3.34-38.28 6.88C298.5 91.15 281.21 96 256 96s-42.51-4.84-57.76-9.11C185.6 83.34 173.67 80 160 80a115.74 115.74 0 00-31.73 4.32c-47.1 13.92-79 65.08-100.52 161C4.61 348.54 16 413.71 59.69 428.83a56.62 56.62 0 0018.64 3.22c29.93 0 53.93-24.93 70.33-45.34 18.53-23.1 40.22-34.82 107.34-34.82 59.95 0 84.76 8.13 106.19 34.82 13.47 16.78 26.2 28.52 38.9 35.91 16.89 9.82 33.77 12 50.16 6.37 25.82-8.81 40.62-32.1 44-69.24 2.57-28.48-1.39-65.89-12.12-114.37zM208 240h-32v32a16 16 0 01-32 0v-32h-32a16 16 0 010-32h32v-32a16 16 0 0132 0v32h32a16 16 0 010 32zm84 4a20 20 0 1120-20 20 20 0 01-20 20zm44 44a20 20 0 1120-19.95A20 20 0 01336 288zm0-88a20 20 0 1120-20 20 20 0 01-20 20zm44 44a20 20 0 1120-20 20 20 0 01-20 20z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "film"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M436 80H76a44.05 44.05 0 00-44 44v264a44.05 44.05 0 0044 44h360a44.05 44.05 0 0044-44V124a44.05 44.05 0 00-44-44zM112 388a12 12 0 01-12 12H76a12 12 0 01-12-12v-24a12 12 0 0112-12h24a12 12 0 0112 12zm0-80a12 12 0 01-12 12H76a12 12 0 01-12-12v-24a12 12 0 0112-12h24a12 12 0 0112 12zm0-80a12 12 0 01-12 12H76a12 12 0 01-12-12v-24a12 12 0 0112-12h24a12 12 0 0112 12zm0-80a12 12 0 01-12 12H76a12 12 0 01-12-12v-24a12 12 0 0112-12h24a12 12 0 0112 12zm241.68 124H158.32a16 16 0 010-32h195.36a16 16 0 110 32zM448 388a12 12 0 01-12 12h-24a12 12 0 01-12-12v-24a12 12 0 0112-12h24a12 12 0 0112 12zm0-80a12 12 0 01-12 12h-24a12 12 0 01-12-12v-24a12 12 0 0112-12h24a12 12 0 0112 12zm0-80a12 12 0 01-12 12h-24a12 12 0 01-12-12v-24a12 12 0 0112-12h24a12 12 0 0112 12zm0-80a12 12 0 01-12 12h-24a12 12 0 01-12-12v-24a12 12 0 0112-12h24a12 12 0 0112 12z"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={wh}
|
||||
height={wh}
|
||||
viewBox="0 0 512 512"
|
||||
>
|
||||
<path
|
||||
d="M436 80H76a44.05 44.05 0 00-44 44v264a44.05 44.05 0 0044 44h360a44.05 44.05 0 0044-44V124a44.05 44.05 0 00-44-44zM112 388a12 12 0 01-12 12H76a12 12 0 01-12-12v-24a12 12 0 0112-12h24a12 12 0 0112 12zm0-80a12 12 0 01-12 12H76a12 12 0 01-12-12v-24a12 12 0 0112-12h24a12 12 0 0112 12zm0-80a12 12 0 01-12 12H76a12 12 0 01-12-12v-24a12 12 0 0112-12h24a12 12 0 0112 12zm0-80a12 12 0 01-12 12H76a12 12 0 01-12-12v-24a12 12 0 0112-12h24a12 12 0 0112 12zm241.68 124H158.32a16 16 0 010-32h195.36a16 16 0 110 32zM448 388a12 12 0 01-12 12h-24a12 12 0 01-12-12v-24a12 12 0 0112-12h24a12 12 0 0112 12zm0-80a12 12 0 01-12 12h-24a12 12 0 01-12-12v-24a12 12 0 0112-12h24a12 12 0 0112 12zm0-80a12 12 0 01-12 12h-24a12 12 0 01-12-12v-24a12 12 0 0112-12h24a12 12 0 0112 12zm0-80a12 12 0 01-12 12h-24a12 12 0 01-12-12v-24a12 12 0 0112-12h24a12 12 0 0112 12z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "tv"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M447.86 384H64.14A48.2 48.2 0 0116 335.86V128.14A48.2 48.2 0 0164.14 80h383.72A48.2 48.2 0 01496 128.14v207.72A48.2 48.2 0 01447.86 384z"
|
||||
/>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-miterlimit="10"
|
||||
stroke-width="32"
|
||||
d="M128 416h256"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={wh}
|
||||
height={wh}
|
||||
viewBox="0 0 512 512"
|
||||
>
|
||||
<path
|
||||
d="M447.86 384H64.14A48.2 48.2 0 0116 335.86V128.14A48.2 48.2 0 0164.14 80h383.72A48.2 48.2 0 01496 128.14v207.72A48.2 48.2 0 01447.86 384z"
|
||||
/>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-miterlimit="10"
|
||||
stroke-width="32"
|
||||
d="M128 416h256"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "pin"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 -960 960 960">
|
||||
<path
|
||||
d="m640-480 80 80v80H520v240l-40 40-40-40v-240H240v-80l80-80v-280h-40v-80h400v80h-40v280Z"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={wh}
|
||||
height={wh}
|
||||
viewBox="0 -960 960 960"
|
||||
>
|
||||
<path
|
||||
d="m640-480 80 80v80H520v240l-40 40-40-40v-240H240v-80l80-80v-280h-40v-80h400v80h-40v280Z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "unpin"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 -960 960 960">
|
||||
<path
|
||||
d="M680-840v80h-40v327L313-760l-33-33v-47h400ZM480-40l-40-40v-240H240v-80l80-80v-46L56-792l56-56 736 736-58 56-264-264h-6v240l-40 40Z"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={wh}
|
||||
height={wh}
|
||||
viewBox="0 -960 960 960"
|
||||
>
|
||||
<path
|
||||
d="M680-840v80h-40v327L313-760l-33-33v-47h400ZM480-40l-40-40v-240H240v-80l80-80v-46L56-792l56-56 736 736-58 56-264-264h-6v240l-40 40Z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "sparkles"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M208 512a24.84 24.84 0 01-23.34-16l-39.84-103.6a16.06 16.06 0 00-9.19-9.19L32 343.34a25 25 0 010-46.68l103.6-39.84a16.06 16.06 0 009.19-9.19L184.66 144a25 25 0 0146.68 0l39.84 103.6a16.06 16.06 0 009.19 9.19l103 39.63a25.49 25.49 0 0116.63 24.1 24.82 24.82 0 01-16 22.82l-103.6 39.84a16.06 16.06 0 00-9.19 9.19L231.34 496A24.84 24.84 0 01208 512zm66.85-254.84zM88 176a14.67 14.67 0 01-13.69-9.4l-16.86-43.84a7.28 7.28 0 00-4.21-4.21L9.4 101.69a14.67 14.67 0 010-27.38l43.84-16.86a7.31 7.31 0 004.21-4.21L74.16 9.79A15 15 0 0186.23.11a14.67 14.67 0 0115.46 9.29l16.86 43.84a7.31 7.31 0 004.21 4.21l43.84 16.86a14.67 14.67 0 010 27.38l-43.84 16.86a7.28 7.28 0 00-4.21 4.21l-16.86 43.84A14.67 14.67 0 0188 176zM400 256a16 16 0 01-14.93-10.26l-22.84-59.37a8 8 0 00-4.6-4.6l-59.37-22.84a16 16 0 010-29.86l59.37-22.84a8 8 0 004.6-4.6l22.67-58.95a16.45 16.45 0 0113.17-10.57 16 16 0 0116.86 10.15l22.84 59.37a8 8 0 004.6 4.6l59.37 22.84a16 16 0 010 29.86l-59.37 22.84a8 8 0 00-4.6 4.6l-22.84 59.37A16 16 0 01400 256z"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={wh}
|
||||
height={wh}
|
||||
viewBox="0 0 512 512"
|
||||
>
|
||||
<path
|
||||
d="M208 512a24.84 24.84 0 01-23.34-16l-39.84-103.6a16.06 16.06 0 00-9.19-9.19L32 343.34a25 25 0 010-46.68l103.6-39.84a16.06 16.06 0 009.19-9.19L184.66 144a25 25 0 0146.68 0l39.84 103.6a16.06 16.06 0 009.19 9.19l103 39.63a25.49 25.49 0 0116.63 24.1 24.82 24.82 0 01-16 22.82l-103.6 39.84a16.06 16.06 0 00-9.19 9.19L231.34 496A24.84 24.84 0 01208 512zm66.85-254.84zM88 176a14.67 14.67 0 01-13.69-9.4l-16.86-43.84a7.28 7.28 0 00-4.21-4.21L9.4 101.69a14.67 14.67 0 010-27.38l43.84-16.86a7.31 7.31 0 004.21-4.21L74.16 9.79A15 15 0 0186.23.11a14.67 14.67 0 0115.46 9.29l16.86 43.84a7.31 7.31 0 004.21 4.21l43.84 16.86a14.67 14.67 0 010 27.38l-43.84 16.86a7.28 7.28 0 00-4.21 4.21l-16.86 43.84A14.67 14.67 0 0188 176zM400 256a16 16 0 01-14.93-10.26l-22.84-59.37a8 8 0 00-4.6-4.6l-59.37-22.84a16 16 0 010-29.86l59.37-22.84a8 8 0 004.6-4.6l22.67-58.95a16.45 16.45 0 0113.17-10.57 16 16 0 0116.86 10.15l22.84 59.37a8 8 0 004.6 4.6l59.37 22.84a16 16 0 010 29.86l-59.37 22.84a8 8 0 00-4.6 4.6l-22.84 59.37A16 16 0 01400 256z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "tag"}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={wh}
|
||||
height={wh}
|
||||
viewBox="0 0 512 512"
|
||||
>
|
||||
<path
|
||||
d="M467 45.2A44.45 44.45 0 00435.29 32H312.36a30.63 30.63 0 00-21.52 8.89L45.09 286.59a44.82 44.82 0 000 63.32l117 117a44.83 44.83 0 0063.34 0l245.65-245.6A30.6 30.6 0 00480 199.8v-123a44.24 44.24 0 00-13-31.6zM384 160a32 32 0 1132-32 32 32 0 01-32 32z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "ticket"}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={wh}
|
||||
height={wh}
|
||||
viewBox="0 0 512 512"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M490.18 181.4l-44.13-44.13a20 20 0 00-27-1 30.81 30.81 0 01-41.68-1.6 30.81 30.81 0 01-1.6-41.67 20 20 0 00-1-27L330.6 21.82a19.91 19.91 0 00-28.13 0l-70.35 70.34a39.87 39.87 0 00-9.57 15.5 7.71 7.71 0 01-4.83 4.83 39.78 39.78 0 00-15.5 9.58l-180.4 180.4a19.91 19.91 0 000 28.13L66 374.73a20 20 0 0027 1 30.69 30.69 0 0143.28 43.28 20 20 0 001 27l44.13 44.13a19.91 19.91 0 0028.13 0l180.4-180.4a39.82 39.82 0 009.58-15.49 7.69 7.69 0 014.84-4.84 39.84 39.84 0 0015.49-9.57l70.34-70.35a19.91 19.91 0 00-.01-28.09zm-228.37-29.65a16 16 0 01-22.63 0l-11.51-11.51a16 16 0 0122.63-22.62l11.51 11.5a16 16 0 010 22.63zm44 44a16 16 0 01-22.62 0l-11-11a16 16 0 1122.63-22.63l11 11a16 16 0 01.01 22.66zm44 44a16 16 0 01-22.63 0l-11-11a16 16 0 0122.63-22.62l11 11a16 16 0 01.05 22.67zm44.43 44.54a16 16 0 01-22.63 0l-11.44-11.5a16 16 0 1122.68-22.57l11.45 11.49a16 16 0 01-.01 22.63z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "lock-closed"}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={wh}
|
||||
height={wh}
|
||||
viewBox="0 0 512 512"
|
||||
>
|
||||
<path
|
||||
d="M368 192h-16v-80a96 96 0 10-192 0v80h-16a64.07 64.07 0 00-64 64v176a64.07 64.07 0 0064 64h224a64.07 64.07 0 0064-64V256a64.07 64.07 0 00-64-64zm-48 0H192v-80a64 64 0 11128 0z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
svg {
|
||||
transition: transform 100ms ease-in-out;
|
||||
svg {
|
||||
transition: transform 100ms ease-in-out;
|
||||
|
||||
&.up {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
&.up {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
&.down {
|
||||
transform: rotate(270deg);
|
||||
}
|
||||
&.down {
|
||||
transform: rotate(270deg);
|
||||
}
|
||||
|
||||
&.right {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
&.right {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
#reel {
|
||||
path:first-of-type {
|
||||
fill: $text-color;
|
||||
}
|
||||
#reel {
|
||||
path:first-of-type {
|
||||
fill: $text-color;
|
||||
}
|
||||
|
||||
path:last-of-type {
|
||||
fill: $bg-color;
|
||||
}
|
||||
}
|
||||
path:last-of-type {
|
||||
fill: $bg-color;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +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 { store } from "@/store.svelte";
|
||||
|
||||
interface Props {
|
||||
modalTitle: string;
|
||||
// Promise that returns the job id to watch.
|
||||
getJobId: () => Promise<{ jobId: string } | undefined>;
|
||||
onClose: () => void;
|
||||
messages: {
|
||||
starting: string;
|
||||
};
|
||||
}
|
||||
|
||||
let { modalTitle, getJobId, onClose, messages }: Props = $props();
|
||||
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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>
|
||||
</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;
|
||||
|
||||
& > div:last-of-type {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
|
||||
& > span {
|
||||
font-style: italic;
|
||||
|
||||
&::first-letter {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
& > ul {
|
||||
padding-left: 25px;
|
||||
|
||||
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,112 @@
|
||||
<!-- 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;
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
color: gray;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,80 +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";
|
||||
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}
|
||||
<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
</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,146 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
|
||||
export let rating: number | undefined;
|
||||
export let onChange: (newRating: number) => void;
|
||||
|
||||
let hoveredRating: number | undefined;
|
||||
let shownRating: number | undefined;
|
||||
let ratingContainer: HTMLDivElement;
|
||||
let ratingText: HTMLSpanElement;
|
||||
|
||||
const ratingDesc = [
|
||||
"Apalling",
|
||||
"Horrible",
|
||||
"Very Bad",
|
||||
"Bad",
|
||||
"Average",
|
||||
"Fine",
|
||||
"Good",
|
||||
"Very Good",
|
||||
"Great",
|
||||
"Masterpiece"
|
||||
];
|
||||
|
||||
function handleStarClick(r: number) {
|
||||
if (r === rating) return;
|
||||
onChange(r);
|
||||
}
|
||||
|
||||
$: {
|
||||
if (hoveredRating !== undefined) shownRating = hoveredRating;
|
||||
else if (rating !== undefined) {
|
||||
shownRating = rating;
|
||||
resetRatingText();
|
||||
} else shownRating = undefined;
|
||||
}
|
||||
|
||||
function resetRatingText() {
|
||||
if (!ratingText) {
|
||||
console.warn("resetRatingText: ratingText is not defined yet.");
|
||||
return;
|
||||
}
|
||||
if (typeof rating === "number" && rating > 0) {
|
||||
ratingText.innerText = ratingDesc[rating - 1];
|
||||
} else {
|
||||
ratingText.innerText = "Select Your Rating";
|
||||
}
|
||||
}
|
||||
|
||||
function handleStarHover(
|
||||
ev: MouseEvent & {
|
||||
currentTarget: EventTarget & HTMLButtonElement;
|
||||
},
|
||||
r: number
|
||||
) {
|
||||
hoveredRating = r;
|
||||
// We set innerText instead of letting svelte update dom for us
|
||||
// since we need the new width of span right now.
|
||||
ratingText.innerText = ratingDesc[r - 1];
|
||||
const start = ratingContainer?.getBoundingClientRect()?.x;
|
||||
const starl = ev?.currentTarget?.getBoundingClientRect()?.left;
|
||||
const rb = ratingText?.getBoundingClientRect();
|
||||
ratingText.style.left = `${starl - start - rb.width / 2 + 11.5}px`;
|
||||
ratingText.style.transform = "unset";
|
||||
}
|
||||
|
||||
function handleStarHoverEnd() {
|
||||
hoveredRating = undefined;
|
||||
ratingText.style.left = "50%";
|
||||
ratingText.style.transform = "translateX(-50%)";
|
||||
resetRatingText();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
resetRatingText();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="rating-container" bind:this={ratingContainer}>
|
||||
<span bind:this={ratingText}></span>
|
||||
<div class="rating">
|
||||
{#each [10, 9, 8, 7, 6, 5, 4, 3, 2, 1] as v}
|
||||
<button
|
||||
class="plain{shownRating === v ? ' lit' : ''}"
|
||||
on:click={() => handleStarClick(v)}
|
||||
on:mouseenter={(ev) => handleStarHover(ev, v)}
|
||||
on:mouseleave={() => handleStarHoverEnd()}
|
||||
>
|
||||
*
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.rating-container {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
overflow: visible;
|
||||
|
||||
& > span {
|
||||
position: relative;
|
||||
transition:
|
||||
left 100ms ease-in,
|
||||
transform 100ms ease-in;
|
||||
max-width: max-content;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
.rating {
|
||||
display: flex;
|
||||
flex-flow: row-reverse;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: $text-color;
|
||||
-webkit-text-stroke: 1.5px $text-color;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
margin: 10px 0 10px 0;
|
||||
padding: 1px;
|
||||
|
||||
button {
|
||||
font-size: 55px;
|
||||
font-family: "Rampart One";
|
||||
letter-spacing: 10px;
|
||||
line-height: 52px;
|
||||
height: 38px;
|
||||
|
||||
&:global(.lit),
|
||||
&:global(.lit ~ button) {
|
||||
color: gold;
|
||||
-webkit-text-stroke: 1.5px gold;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 450px) {
|
||||
font-size: 50px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 420px) {
|
||||
font-size: 45px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</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,323 +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; // Watched list item id
|
||||
interface Props {
|
||||
tvId: number;
|
||||
seasons: TMDBShowSeason[];
|
||||
watchedItem: Watched | undefined;
|
||||
}
|
||||
|
||||
let activeSeason = 1;
|
||||
let seasonDetailsReq: Promise<TMDBSeasonDetails>;
|
||||
let { tvId, seasons, watchedItem = $bindable() }: Props = $props();
|
||||
|
||||
async function sdr(seasonNum: number) {
|
||||
return (await axios.get(`/content/tv/${tvId}/season/${seasonNum}`)).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) {
|
||||
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 (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" style={ws?.rating ? "width: 65px" : "width: 45px"}>
|
||||
<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,400 +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;
|
||||
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) {
|
||||
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 (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}m</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,249 +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, 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">
|
||||
<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}
|
||||
</div>
|
||||
{/if}
|
||||
{#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 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>
|
||||
|
||||
@@ -0,0 +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;
|
||||
}
|
||||
|
||||
export default function stayInView(node: HTMLElement, opts: StayInViewOptions) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getInViewDeb = () => {
|
||||
clearTimeout(viewDeb);
|
||||
viewDeb = setTimeout(getInView, 200);
|
||||
};
|
||||
|
||||
window.addEventListener("resize", getInViewDeb);
|
||||
getInView();
|
||||
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||