Compare commits

...

317 Commits

Author SHA1 Message Date
IRHM b6d6e66f04 v2.0.2 2025-02-16 21:18:27 +00:00
IRHM 0b4f3c8604 2.0.2-dev 2025-02-16 21:00:09 +00:00
Mr 0a5a4e39f5 DetailedMenu: Fix wlDetailedView not setting correctly (#814)
wasn't causing setter to trigger and save to localStorage
2025-02-16 19:57:55 +00:00
IRHM 2d8def18ed v2.0.1 2025-02-14 04:14:25 +00:00
IRHM d53f62e96c 2.0.1-dev 2025-02-14 03:50:53 +00:00
Mr 4dd0b419e0 SeasonsList: Horizontal scroller for mobile view (#808)
* SeasonsList: Turn into horizontal list for mobile

* SeasonsList: Make seasons sticky for mobile view
2025-02-14 03:48:50 +00:00
Antonio Sarro 41948f15ba SeasonsList: Show season status and episode count (#784)
* feat: improved overall look of the series season sidebar

* SeasonsList: Responsivity fixes

- Fix responsivity for seasons when names are very long (eg on shows that have custom season names)
- Increase status icon size
- Create color variables for season episodes text for resuability and to fix bug causing z index rendering issues with opacity usage.

* SeasonsList: checkSeasonStatus: Improve log

* SeasonsList: Remove $inspect: Causing errors in console for some reason

* vars: Fix --text-color-accent for dark theme

---------

Co-authored-by: IRHM <37304121+IRHM@users.noreply.github.com>
2025-02-14 03:27:50 +00:00
Mr 49f0739503 Merge pull request #807 from sbondCo/search-by-id
Search by IGDB ID & TMDB ID support
2025-02-14 01:21:03 +00:00
IRHM 1c763e9761 search: by tmdb id, return after goto call 2025-02-14 01:17:32 +00:00
IRHM 18a30639b9 Search by tmdbId with movie or tv or series provider aliases
barbarically
2025-02-14 01:10:08 +00:00
IRHM a3847ccb3c Search by igdb id (with igdb or game provider aliases) 2025-02-14 00:58:48 +00:00
Mr 5f5eb3e9f2 game: Fix search query parameter (#806)
Was passing encoded query parameter, now unescaped first, fixing request to igdb.
2025-02-14 00:38:20 +00:00
IRHM 3bf1f64f25 v2.0.0 2025-01-23 22:23:19 +00:00
IRHM 82b5b30e1c 2.0.0-dev3 2025-01-23 21:46:25 +00:00
Mr 303761dc4c Fix importing state error (#781) 2025-01-21 23:50:51 +00:00
Mr 16012da267 Merge pull request #771 from sbondCo/new-screens
New screenshots for readme/doc site
2025-01-20 00:08:19 +00:00
IRHM 74bceee57b Update ATTRIBUTION.md
Added Ionicons, commons, google fonts, tmdb, igdb.
2025-01-20 00:06:26 +00:00
IRHM bc0aa56ced doc: HomepageTwoColFeats: Add v=2 to imgSrcs 2025-01-19 23:47:25 +00:00
IRHM 84753ce829 README: Add v=2 to screens 2025-01-19 23:44:26 +00:00
IRHM 780d7c55b1 screenshot: Fix homepage-poster-hover.png
i wasn't hovering over the damn poster
2025-01-19 23:42:07 +00:00
IRHM 9318df3e14 screenshot: Fix homepage-poster-hover
scrollbar is showing
2025-01-19 23:40:19 +00:00
IRHM d0c0ec1f67 Upload new screenshots 2025-01-19 23:37:42 +00:00
IRHM 32b3b792e5 ATTRIBUTION: Add penpot 2025-01-19 22:38:50 +00:00
IRHM 5018a229c0 Create ATTRIBUTION.md 2025-01-19 22:36:10 +00:00
IRHM 6e20ab85c3 devices-mock.png: higher resolution 2025-01-19 22:21:39 +00:00
IRHM 9c8a093b2e readme: Add devices-mock image 2025-01-19 22:02:35 +00:00
IRHM 1870d15c41 2.0.0-dev2 2025-01-19 17:13:46 +00:00
Mr 06cf9c012f Fix search debounce and styling (#770)
* app layout: Fix search styling and other small issues

- return destroy func in onMount to remove event listeners
- Fix timeout var type

* search: Fix searchUsers call not waiting for debounce
2025-01-19 17:11:05 +00:00
IRHM fdc415f545 2.0.0-dev1 2025-01-17 19:50:05 +00:00
IRHM e7ff4b7a4e Dockerfile: Ignore scripts when installing dependencies for runner layer
Fixes issue with new `prepare` script that we added for sveltekit, would always error here since we don't have any dev dependencies in this step for the command to work.

Also made the comments for each section bigger for readability (makes it easier to scan for where sections are).
2025-01-17 19:47:37 +00:00
IRHM 051acf5118 TagMenu: Fix title btns styling 2025-01-17 19:13:55 +00:00
IRHM a58a3c1bd7 2.0.0-dev 2025-01-17 18:56:09 +00:00
dependabot[bot] 0b04c9b827 ui: bump @sveltejs/kit from 2.15.1 to 2.16.0 (#764)
* ui: bump @sveltejs/kit from 2.15.1 to 2.16.0

Bumps [@sveltejs/kit](https://github.com/sveltejs/kit/tree/HEAD/packages/kit) from 2.15.1 to 2.16.0.
- [Release notes](https://github.com/sveltejs/kit/releases)
- [Changelog](https://github.com/sveltejs/kit/blob/main/packages/kit/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/kit/commits/@sveltejs/kit@2.16.0/packages/kit)

---
updated-dependencies:
- dependency-name: "@sveltejs/kit"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* package: Add "prepare" script for sveltekit v2.16 update

So install script does its thing and generates types.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: IRHM <37304121+IRHM@users.noreply.github.com>
2025-01-17 18:34:46 +00:00
dependabot[bot] d05ec6c8fa ui: bump @sveltejs/adapter-node from 5.2.11 to 5.2.12 (#763)
Bumps [@sveltejs/adapter-node](https://github.com/sveltejs/kit/tree/HEAD/packages/adapter-node) from 5.2.11 to 5.2.12.
- [Release notes](https://github.com/sveltejs/kit/releases)
- [Changelog](https://github.com/sveltejs/kit/blob/main/packages/adapter-node/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/kit/commits/@sveltejs/adapter-node@5.2.12/packages/adapter-node)

---
updated-dependencies:
- dependency-name: "@sveltejs/adapter-node"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-17 18:19:14 +00:00
dependabot[bot] 04594df963 server: bump github.com/go-co-op/gocron/v2 in /server (#761)
Bumps [github.com/go-co-op/gocron/v2](https://github.com/go-co-op/gocron) from 2.14.0 to 2.14.2.
- [Release notes](https://github.com/go-co-op/gocron/releases)
- [Commits](https://github.com/go-co-op/gocron/compare/v2.14.0...v2.14.2)

---
updated-dependencies:
- dependency-name: github.com/go-co-op/gocron/v2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-17 18:17:09 +00:00
dependabot[bot] dacfd65603 server: bump github.com/gin-contrib/cache from 1.3.0 to 1.3.1 in /server (#746)
Bumps [github.com/gin-contrib/cache](https://github.com/gin-contrib/cache) from 1.3.0 to 1.3.1.
- [Release notes](https://github.com/gin-contrib/cache/releases)
- [Changelog](https://github.com/gin-contrib/cache/blob/master/.goreleaser.yaml)
- [Commits](https://github.com/gin-contrib/cache/compare/v1.3.0...v1.3.1)

---
updated-dependencies:
- dependency-name: github.com/gin-contrib/cache
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-17 18:16:01 +00:00
dependabot[bot] 70d37d3555 server: bump golang.org/x/crypto from 0.31.0 to 0.32.0 in /server (#748)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.31.0 to 0.32.0.
- [Commits](https://github.com/golang/crypto/compare/v0.31.0...v0.32.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-17 18:14:41 +00:00
Mr f01f26fd39 Merge pull request #762 from sbondCo/svelte-5
Svelte 5, new code format rules for frontend, some general overdue refactoring
2025-01-17 18:10:59 +00:00
IRHM ea86451686 fix linting issues 2025-01-17 18:05:44 +00:00
IRHM 27699192b4 Refactor menus to use new component
Created a new <Menu> component which is now used for all of our menus (the ones for the nav + the menu in AddToTagButton).

Looks better now, easier to use, not sure why I ever did the global style for it, that was dumb. oh wait no im smart, it was probably because it wasn't used for the complex stuff it is now anyways shulda refactored ages agU
2025-01-17 01:40:31 +00:00
IRHM aff86db95e PosterRating: Fix styling for thumb rating system 2025-01-13 05:16:16 +00:00
IRHM bd2e645608 EditUserModal: Fix usage and reactivity 2025-01-13 05:16:01 +00:00
IRHM 0a22484037 Remove now-unknown svelte-ignore comment 2025-01-13 04:09:47 +00:00
IRHM aad65a2b4f login: finish migration to svelte 5 2025-01-13 04:09:15 +00:00
IRHM d93fbb0710 prettier: ignore .vscode folder 2025-01-13 04:00:07 +00:00
IRHM ea8e435e64 FilterMenu: Re-set activeFilters after modifying so setter saves new value 2025-01-13 03:57:11 +00:00
IRHM 10dcd6d4cc plain layout: Remove '/' link from watcharr text in nav
Clicking it would take the user to home page without login, but they are not logged in so no data.
2025-01-13 01:09:36 +00:00
IRHM b59f8a2850 profile: toFormattedTimeLong: Return "0 hours" as default 2025-01-13 01:03:43 +00:00
IRHM 00f645e001 Store: Use normal store for logging activeSort restore 2025-01-13 00:59:12 +00:00
IRHM 897bbf54e2 Store: Refactor to use get/set for all properties
So that we can use the setters for saving properties to localStorage after an update. $effect is not meant to be used for this, so using setters seemed like the best option, gives us a lot more control over behaviour.
2025-01-13 00:57:18 +00:00
IRHM e97662314b Create hooks.client.ts: Use init hook for logging app version 2025-01-13 00:54:45 +00:00
IRHM 910695474c login: Fix state by actually using state rune 2025-01-12 23:53:03 +00:00
IRHM b91e4f0e28 WatchedList: Fix list not re-sorting when item updated via poster
This change caused an issue where the poster mouseLeave event would not fire if the poster element was moved as a result of the re-sort, so some stuff has been restructured and a new call added to check if mouse is no longer over the poster to manually unactivate it.
2025-01-12 19:45:39 +00:00
IRHM 5ea8f59e6f Activity: Fix styling, remove isActivityEditorVisible and use clickedActivity state for all uses 2025-01-12 16:48:59 +00:00
IRHM aa71cdd20a SortMenu: Fix style & not updating store when sort changed 2025-01-12 01:04:12 +00:00
IRHM 64bd3296ab WatchedList: Fix filtering
this was wow many time wasted trying to figure this out but at least its a one time waste i hoppe.

Removed the games filter auto removal thing in filt() because it's not _really_ necessary. We have added additional UX stuff around this since, so if this rare case ever does occur, the user has easy ways around it.
2025-01-12 00:58:27 +00:00
IRHM c6a66e9a67 Run startStoreSaver outside onMount in main layout 2025-01-12 00:55:38 +00:00
IRHM c862bd6c09 store: Add debug logging to saver/rehydrator 2025-01-12 00:55:15 +00:00
IRHM 2b5ce07554 person: refactor and fix infinite loop in new/old sorts
- Refactored the newest/oldest sorts into one function used for both: newestOldestSort()
- Fixed an issue in the sort where an infinite loop could be caused when A & B both have no release date (added new case to resolve this).
2025-01-11 00:31:22 +00:00
IRHM c0b9074b7e PosterRating: Fix button fonts 2025-01-11 00:29:15 +00:00
IRHM 4f0ef3d12b fix some stuff 2025-01-09 22:51:50 +00:00
IRHM 21da15e284 deps: Remove tslib
maybe i set it up wrong but it didnt seem to noticeable change file size so i dont think its worth using? idk
2025-01-06 23:27:15 +00:00
IRHM ead70c9315 Fix poster Rating/Status styling 2025-01-06 22:59:05 +00:00
IRHM 2cfe1d13e7 format all code with new style (printWidth=80, useTabs=true, trailingComma=all) 2025-01-05 20:48:53 +00:00
IRHM a40f90a6b7 migrate more stuff to svelte 5 in a minimal fashion
getting stuff working first, then will cleanup where necessary
2025-01-05 20:07:21 +00:00
IRHM 738d48138a wip svelte 5 migration
some code has still wrongly been deleted by the migration tool, have to restore that............ aaaaaaaaaaaaaaaa
2025-01-03 21:21:24 +00:00
dependabot[bot] f858c21c51 server: bump github.com/gin-contrib/cors from 1.7.2 to 1.7.3 in /server (#745)
Bumps [github.com/gin-contrib/cors](https://github.com/gin-contrib/cors) from 1.7.2 to 1.7.3.
- [Release notes](https://github.com/gin-contrib/cors/releases)
- [Changelog](https://github.com/gin-contrib/cors/blob/master/.goreleaser.yaml)
- [Commits](https://github.com/gin-contrib/cors/compare/v1.7.2...v1.7.3)

---
updated-dependencies:
- dependency-name: github.com/gin-contrib/cors
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-01 23:40:41 +00:00
dependabot[bot] df8ca2a6e7 ui: bump @types/papaparse from 5.3.14 to 5.3.15 (#742)
Bumps [@types/papaparse](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/papaparse) from 5.3.14 to 5.3.15.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/papaparse)

---
updated-dependencies:
- dependency-name: "@types/papaparse"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-01 20:41:02 +00:00
Mr a029d38b7f Merge pull request #747 from sbondCo/user-type-change
manage_users: Support swapping users between Watcharr/Proxy users
2025-01-01 20:08:43 +00:00
IRHM 9ba1049d7f types: DropDownItem: remove disabled prop 2025-01-01 20:07:20 +00:00
IRHM a2ee161cc6 manage_users: Support changing user types (only watcharr/proxy currently) 2025-01-01 19:49:14 +00:00
IRHM 20b170393e Create UserTypeIcon component, use in /users/ table 2025-01-01 18:53:31 +00:00
Mr aa67f7eadb Create an error page with home and refresh btns (#738) 2024-12-30 13:42:31 +00:00
dependabot[bot] 7af39699dd server: bump golang.org/x/crypto from 0.27.0 to 0.31.0 in /server (#717)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.27.0 to 0.31.0.
- [Commits](https://github.com/golang/crypto/compare/v0.27.0...v0.31.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-29 23:39:55 +00:00
dependabot[bot] 694aefad30 server: bump gorm.io/driver/sqlite from 1.5.6 to 1.5.7 in /server (#723)
Bumps [gorm.io/driver/sqlite](https://github.com/go-gorm/sqlite) from 1.5.6 to 1.5.7.
- [Commits](https://github.com/go-gorm/sqlite/compare/v1.5.6...v1.5.7)

---
updated-dependencies:
- dependency-name: gorm.io/driver/sqlite
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-29 23:37:39 +00:00
dependabot[bot] 1e908fba38 server: bump github.com/go-co-op/gocron/v2 in /server (#731)
Bumps [github.com/go-co-op/gocron/v2](https://github.com/go-co-op/gocron) from 2.11.0 to 2.14.0.
- [Release notes](https://github.com/go-co-op/gocron/releases)
- [Commits](https://github.com/go-co-op/gocron/compare/v2.11.0...v2.14.0)

---
updated-dependencies:
- dependency-name: github.com/go-co-op/gocron/v2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-29 23:35:50 +00:00
dependabot[bot] 7acd8681b9 ui: bump prettier from 3.3.2 to 3.4.2 (#714)
* ui: bump prettier from 3.3.2 to 3.4.2

Bumps [prettier](https://github.com/prettier/prettier) from 3.3.2 to 3.4.2.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.3.2...3.4.2)

---
updated-dependencies:
- dependency-name: prettier
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* search: fix linting issue

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: IRHM <37304121+IRHM@users.noreply.github.com>
2024-12-29 23:31:23 +00:00
dependabot[bot] 940f719345 ui: bump @vite-pwa/sveltekit from 0.5.0 to 0.6.6 (#669)
Bumps [@vite-pwa/sveltekit](https://github.com/vite-pwa/sveltekit) from 0.5.0 to 0.6.6.
- [Release notes](https://github.com/vite-pwa/sveltekit/releases)
- [Commits](https://github.com/vite-pwa/sveltekit/compare/v0.5.0...v0.6.6)

---
updated-dependencies:
- dependency-name: "@vite-pwa/sveltekit"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-29 23:22:57 +00:00
dependabot[bot] 76379938eb ui: bump typescript from 5.5.4 to 5.7.2 (#703)
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.5.4 to 5.7.2.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release.yml)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.5.4...v5.7.2)

---
updated-dependencies:
- dependency-name: typescript
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-29 23:19:13 +00:00
dependabot[bot] 45ab9b6b1c ui: bump @typescript-eslint/eslint-plugin from 7.14.1 to 8.18.2 (#733)
* ui: bump @typescript-eslint/eslint-plugin from 7.14.1 to 8.18.2

Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 7.14.1 to 8.18.2.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.18.2/packages/eslint-plugin)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

* Update @typescript-eslint/parser to ^8.18.2

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: IRHM <37304121+IRHM@users.noreply.github.com>
2024-12-29 23:18:15 +00:00
IRHM f77893bcc1 test-pr-web.yml: Trigger on related files outside src folder changed 2024-12-29 23:09:22 +00:00
dependabot[bot] 646c0586ab ui: bump @sveltejs/kit from 2.5.27 to 2.15.1 (#737)
Bumps [@sveltejs/kit](https://github.com/sveltejs/kit/tree/HEAD/packages/kit) from 2.5.27 to 2.15.1.
- [Release notes](https://github.com/sveltejs/kit/releases)
- [Changelog](https://github.com/sveltejs/kit/blob/main/packages/kit/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/kit/commits/@sveltejs/kit@2.15.1/packages/kit)

---
updated-dependencies:
- dependency-name: "@sveltejs/kit"
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-29 23:03:20 +00:00
Mr 64bfbd51a0 Merge pull request #736 from sbondCo/proxy-auth-flow
Trusted header authentication
2024-12-29 22:51:33 +00:00
IRHM 31fc6633a2 login: Expand 'SSO' to 'Single Sign-On' in related btn text 2024-12-29 22:50:28 +00:00
IRHM 85c5131dad ProxyUserLogoutModal: Make watcharr logout warning more clear 2024-12-29 22:40:56 +00:00
IRHM b13da26b8b loginTrustedHeaderAuth: Only allow login to a PROXY_USER account 2024-12-29 22:33:38 +00:00
IRHM ad3271df7f Use clearWatcharrData in 401 axios interceptor 2024-12-28 19:11:23 +00:00
IRHM dfb49ee2a7 Config: Correct HEADER_AUTH comment 2024-12-28 16:33:16 +00:00
IRHM a456701bde loginTrustedHeaderAuth: Move creating user log to inside correct if block 2024-12-28 16:26:44 +00:00
IRHM 2b000c8d2f ProxyUserLogoutModal: Update desc to make more sense on modal 2024-12-28 16:07:08 +00:00
IRHM cbd2cfcfbe sso logout flow 2024-12-28 16:01:44 +00:00
IRHM eb2e2e9f93 handle sso auto login on client 2024-12-28 14:19:03 +00:00
IRHM 5dd26a09c0 login: let main btns wrap 2024-12-28 14:08:56 +00:00
IRHM 040297fb2a Update login sso btn style, update trusted header modal save action 2024-12-24 22:54:16 +00:00
IRHM c89e5e5027 TrustedHeaderAuthModal: More info in warning 2024-12-22 20:00:57 +00:00
IRHM 4e875b2f8a Create header auth form and save logic 2024-12-22 19:46:44 +00:00
IRHM a42b7e8ed3 wip new HEADER_AUTH setting to hold all related settings
moved around some code too, not tested, just saving my work
2024-12-21 00:08:12 +00:00
IRHM 69bbba9ff4 Add proxyLogin logic back
Co-Authored-By: Lukas S <62517060+lufixsch@users.noreply.github.com>
2024-12-20 19:36:21 +00:00
IRHM 13f2912009 Organise config and add warning to proxy auth header setting 2024-12-20 19:34:35 +00:00
IRHM 5d14691b7e Add missing loginProxy func
Co-Authored-By: Lukas S <62517060+lufixsch@users.noreply.github.com>
2024-12-20 19:27:05 +00:00
LufixSch 7334f9c29e add settings to specify authentication header 2024-12-20 19:18:08 +00:00
Mr 216ebc183c Merge pull request #728 from sbondCo/imdb-episodes
Imdb episodes import support (only if show is imported too from list)
2024-12-18 19:50:39 +00:00
IRHM 2305a0b7ac Support IMDb episode importing 2024-12-18 19:46:57 +00:00
IRHM b18c889777 searchByExternalId: Combine season/episode results too and fix search result processing in client 2024-12-18 17:44:00 +00:00
Mr e952011de8 import: Disable 'change statuses' button while importing (#727) 2024-12-18 02:23:51 +00:00
Mr cfb6196c2b Merge pull request #726 from sbondCo/imdb-import
Imdb import
2024-12-18 02:12:47 +00:00
IRHM 8eb4f1b297 import/process: Fix changeAllStatusesModalCb call when closing modal 2024-12-18 02:01:54 +00:00
IRHM c031b2aaf0 tooltip: Run hide() in destroy() to fix scenarios where it stay revealed forever when node is removed 2024-12-18 01:53:44 +00:00
IRHM 447523edfc import: Change all statuses button
For allow user to quickly override all statuses in the table for all content to a new one
2024-12-18 01:53:12 +00:00
IRHM 1e4d3b4076 imdb import: log type when skipping 2024-12-18 01:21:49 +00:00
IRHM a8ae54359a IMDb import support 2024-12-18 01:19:38 +00:00
Mr 52de4d4fe8 Search: Support special characters (technically breaking) (#713)
Now using query parameters for the search queries (q).

Don't think theres a way to get slashes (/) working in the url without putting the whole query in a query param rather than the path param.

Think this is proper REST too prob
2024-12-08 17:53:40 +00:00
Mr 6c404877f5 Merge pull request #709 from sbondCo/search-by-id
Search by external id
2024-12-05 22:01:57 +00:00
IRHM bb939ccb36 search: support all external ids with aliases 2024-12-05 21:53:19 +00:00
IRHM e43e249890 search: external id search 2024-12-03 23:40:59 +00:00
IRHM 63fee3111a search: fix max page check
encountered a bug where doing a search while last was loading could infinitely keep loading next pages because curPage got higher than maxContentPage.

Corrected the logic to check for curPage bigger OR equal to max page, rather than only checking equal.
2024-12-03 23:08:56 +00:00
IRHM af6bead88e content: create searchByExternalId endpoint 2024-12-03 23:04:37 +00:00
Mr bc5651905e person: show message when no credits to show (#705)
* person: show message when no credits to show

also hides filters when no credits, since they don't do anything in that case.

* person: remove unused css selector
2024-12-01 23:05:13 +00:00
Mr 5b5743210b Person: 'On my list' filter support (#704)
Port based on #671 with changes.

Co-authored-by: Alex Perathoner <31958579+AlexPerathoner@users.noreply.github.com>
2024-12-01 22:49:48 +00:00
Mr 27218e871b Merge pull request #670 from AlexPerathoner/dev
Sort items in people view
2024-11-05 01:20:25 +00:00
IRHM 0106f9c763 person: sortCredits: Remove use of sorted intermediary array, sort credits array directly
I believe this should be more performant (removing the use of spread operator) and is just a little cleaner.
2024-11-05 01:04:55 +00:00
IRHM 5dd4c4c6c6 DropDown: showActiveElementsInOptions: Just use options when true
No need for the filter when this is true, we can just pass the options var.
2024-11-05 00:51:47 +00:00
IRHM 525816a8fc person: Add max-width to .filters ctr, remove min-width, combine with .container
- Removed min-width so improve responsivity for mobile (overflows).
- Removed unused styles (margin-top/bottom that were overridden in .container).
- Add max-width to match PosterList.
- Remove .container and add styles to .filters (combined them).
2024-11-05 00:27:51 +00:00
Alex Perathoner acba239b8a Sort items in people view 2024-10-29 13:48:42 +01:00
IRHM d21eb06600 v1.44.2 2024-10-15 23:41:45 +01:00
IRHM 1cc8acd839 1.44.2-dev 2024-10-15 23:28:50 +01:00
Mr 99ea97a160 Merge pull request #664 from sbondCo/ryot-fix-dates-import
Ryot fix dates import
2024-10-15 22:23:02 +00:00
IRHM 77a0ba39ba issue templates: format all 2024-10-15 23:21:27 +01:00
IRHM e584cf22f7 import: ryot: add ended_on check before parsing date
idk
2024-10-15 23:20:37 +01:00
IRHM c7bb7a264c import: Add note to try refreshing page if missing data on successful import 2024-10-15 23:07:10 +01:00
IRHM f84a04d29b Add createdAt field to watchedEpisodes for ryot import
and don't set it if item is a movie
2024-10-15 23:06:55 +01:00
Mr 31737baf75 Update and rename feature_request.md to 02-feature_request.md 2024-10-14 13:17:01 +00:00
Mr 9f18893149 Rename bug_report.md to 01-bug_report.md 2024-10-14 13:14:31 +00:00
Mr 7c20414229 bug_report.md: Add comment and update eg: values
Added comment to ask for one issue per bug if they are unrelated.
2024-10-14 13:13:26 +00:00
Mr a33f7cc999 Update issue templates 2024-10-14 13:01:23 +00:00
Mr b38991a7d5 GamePoster: Fix focus lost issue on rating/status via quick btns (#659) 2024-10-13 12:52:39 +00:00
IRHM 94ef4cd4da v1.44.1 2024-10-13 13:13:02 +01:00
IRHM 09f6739686 1.44.1-dev 2024-10-13 12:51:03 +01:00
Mr c3b5f44045 Improve download file func - don't make empty files, logs (#658)
- Now makes the out file after the request, that way we ensure the request succeeds before creating the file.
- Added logging to every case, should make debugging easier in the future.
2024-10-13 11:43:16 +00:00
Mr b023763f63 Poster: Fix focus lost after giving status/rating via quick btns (#657)
* Poster: Fix focus loss shrink issue

* PosterRating: Only add extra padding when is-minimal

So we don't alter the spacing when this  component is used on an actual posters
2024-10-13 01:42:48 +00:00
Mr c24fb54c44 Merge pull request #655 from sbondCo/fix-rat
Fix PosterRating minimal option
2024-10-13 01:02:53 +00:00
IRHM 3f40bca286 SeasonsList: Remove explicit width on ratings container, now resized to fit max-content 2024-10-13 01:56:22 +01:00
IRHM e9cc54bd73 PosterRating: Add extra x axis padding 2024-10-13 01:55:56 +01:00
IRHM fd3e57e7bf PosterRating: Fix minimal style
Was used for season/episode ratings, which wouldn't open. Now fixed and works for all rating systems.
2024-10-13 01:46:21 +01:00
Mr 66faf7a741 Create SECURITY.md 2024-10-07 18:49:33 +00:00
IRHM ba28ae14f1 v1.44.0 2024-10-06 22:10:51 +01:00
IRHM 674c1a0e86 1.44.0-dev 2024-10-06 20:18:39 +01:00
IRHM aa754210a4 auth: use secret from config 2024-10-06 19:40:11 +01:00
IRHM 2fbdd5a16a Fix .small-scrollbar style for chromium
.small-scrollbar is used on poster rating/status lists, but wasn't working because we also have the `scrollbar-width` property set, so now if the browser supports the webkit scrollbar styling, we will prefer that over scrollbar-width.

Don't know if this will break safari or whatever but hell if ima download that.

firefox scrollbar looks nice and this will let us keep using scrollbar-width: small wherever we want for that sweet smol bar action in ff
2024-10-04 02:15:07 +00:00
IRHM 09954d2370 Poster: Let title/overview overflow with scroll 2024-10-04 02:15:07 +00:00
Mr 8d34513ab7 Merge pull request #647 from sbondCo/fix-poster-focus
Fix poster focus/accessibility issues and middle clicking
2024-10-03 23:21:04 +00:00
IRHM ed9a9aae7e Poster: fix being re-focused after window regain focus 2024-10-04 00:11:09 +01:00
IRHM 48814c213a Poster: Fix issues i made 11 days ago 2024-10-03 23:27:04 +01:00
IRHM 5a70b8a240 Posters: Fix mouseleave from rating/status menus causing extraDetails to show
hopefully they were left here by accident
2024-09-22 22:08:29 +01:00
IRHM 31ce74545a Posters: Fix mobile tab and hold link 2024-09-22 21:08:53 +01:00
IRHM 2b78581062 Posters: Fix keyboard focus not hiding ExtraDetails
Focus events now do same thing and mouse enter/leave (focusin/out), so we have the same behaviour.
2024-09-22 20:49:04 +01:00
IRHM 6d5aa0feff Poster: Set tabindex to -1 for inner container to avoid extra element to highlight when tabbing through
The inner container is not helpful for highlighting when tabbing through posters. The title of poster can still be used to enter the content via keyboard, the inner container did the same thing so was redundant and only added an extra click for any navigating via keyboard.
2024-09-22 20:39:28 +01:00
IRHM 7cfbc6c344 Posters: Fix focus style (only apply if focused via keyboard) 2024-09-22 20:37:49 +01:00
Mr 64e7929714 Merge pull request #636 from sbondCo/fix-tagmenu-oob
Fix tagmenu oob
2024-09-22 12:55:27 +00:00
IRHM b2f817b639 TagMenu: Use stayInView action, use an element for arrow
TagMenu uses an element for the arrow now so the stayInView action can control its position.
2024-09-22 13:48:52 +01:00
IRHM 7a3e5f4354 norm.scss: .menu support using an element with .arrow class instead of ::before pseudoelement when more control is needed
more control = need to be able to move it in js.

Still supports using ::before, so not all menus need updating and can stay simple, just the menus needing more control can start using an actual element for the arrow.
2024-09-22 13:46:50 +01:00
IRHM f033a50632 stayInView: Make logs debug scope, fix log in update func 2024-09-22 13:44:30 +01:00
IRHM e3ace9f2fe Create stayInView action
Currently will only shift elements back into view if out of bounds on the left.

Not necessary at the moment to support oob on other sides, or support resizing to fit. Maybe in the future when it's used more.
2024-09-22 13:43:09 +01:00
dependabot[bot] f07c2c797b ui: bump prettier-plugin-svelte from 3.2.3 to 3.2.6 (#616)
Bumps [prettier-plugin-svelte](https://github.com/sveltejs/prettier-plugin-svelte) from 3.2.3 to 3.2.6.
- [Changelog](https://github.com/sveltejs/prettier-plugin-svelte/blob/master/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/prettier-plugin-svelte/compare/v3.2.3...v3.2.6)

---
updated-dependencies:
- dependency-name: prettier-plugin-svelte
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-19 22:16:09 +00:00
dependabot[bot] 1a8da9137a ui: bump svelte-check from 3.8.6 to 4.0.2 (#628)
Bumps [svelte-check](https://github.com/sveltejs/language-tools) from 3.8.6 to 4.0.2.
- [Release notes](https://github.com/sveltejs/language-tools/releases)
- [Commits](https://github.com/sveltejs/language-tools/compare/svelte-check-3.8.6...svelte-check-4.0.2)

---
updated-dependencies:
- dependency-name: svelte-check
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-19 22:14:02 +00:00
dependabot[bot] 2476833a19 server: bump golang.org/x/crypto from 0.26.0 to 0.27.0 in /server (#624)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.26.0 to 0.27.0.
- [Commits](https://github.com/golang/crypto/compare/v0.26.0...v0.27.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-19 22:11:58 +00:00
dependabot[bot] 9f89d2101c ui: bump @sveltejs/adapter-node from 5.0.1 to 5.2.3 (#635)
Bumps [@sveltejs/adapter-node](https://github.com/sveltejs/kit/tree/HEAD/packages/adapter-node) from 5.0.1 to 5.2.3.
- [Release notes](https://github.com/sveltejs/kit/releases)
- [Changelog](https://github.com/sveltejs/kit/blob/main/packages/adapter-node/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/kit/commits/@sveltejs/adapter-node@5.2.3/packages/adapter-node)

---
updated-dependencies:
- dependency-name: "@sveltejs/adapter-node"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-19 22:09:27 +00:00
dependabot[bot] c3db5054b4 server: bump gorm.io/gorm from 1.25.11 to 1.25.12 in /server (#623)
Bumps [gorm.io/gorm](https://github.com/go-gorm/gorm) from 1.25.11 to 1.25.12.
- [Release notes](https://github.com/go-gorm/gorm/releases)
- [Commits](https://github.com/go-gorm/gorm/compare/v1.25.11...v1.25.12)

---
updated-dependencies:
- dependency-name: gorm.io/gorm
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-19 22:07:36 +00:00
dependabot[bot] f96562e2df ui: bump @sveltejs/kit from 2.5.24 to 2.5.27 (#629)
Bumps [@sveltejs/kit](https://github.com/sveltejs/kit/tree/HEAD/packages/kit) from 2.5.24 to 2.5.27.
- [Release notes](https://github.com/sveltejs/kit/releases)
- [Changelog](https://github.com/sveltejs/kit/blob/main/packages/kit/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/kit/commits/@sveltejs/kit@2.5.27/packages/kit)

---
updated-dependencies:
- dependency-name: "@sveltejs/kit"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-19 22:05:37 +00:00
dependabot[bot] 22163950ef ui: bump vite from 5.2.11 to 5.4.6 (#631)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.2.11 to 5.4.6.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.6/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.6/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-19 22:04:18 +00:00
Mr c80d5cabcb Add format_check_go job (#634)
* test-pr.yml: Add format_check_go job

* separate test-pr into two files for client and server

so we can set the `paths` property and make the jobs only run when changes are detected for the client or server

* test breaking formatting

* correct formatting

* test-pr-server.yml: uncomment yml path

test has succeeded
2024-09-19 21:59:04 +00:00
Mr d3cfc9258f Merge pull request #633 from sbondCo/fix-no-filtering-tag-page
Fix no filtering tag page and other stuffs i found
2024-09-19 01:29:02 +00:00
IRHM 74ff9f04dd FilterMenu: Remove array join from class name on clear button 2024-09-19 02:25:32 +01:00
IRHM 28f13836a1 WatchedList: Clear Filters btn when filters hiding all results 2024-09-19 01:53:21 +01:00
IRHM 242e70c9ec store: fix clear filters func name
y did me name features ;-()
2024-09-19 01:52:57 +01:00
IRHM 4bf2e0b376 FilterMenu: Keep height same with and without clear btn 2024-09-19 01:47:50 +01:00
IRHM 144244bd5e TagMenu: Fix title text and a comment in deleteTag() 2024-09-19 01:44:15 +01:00
IRHM 473f8f6319 FilterMenu: Clear filters button 2024-09-19 01:43:52 +01:00
IRHM b86ab4229f Icon: Fix ticket and filter-circle colors 2024-09-19 01:39:24 +01:00
IRHM 8f20589faa Show filter/sort btns on tag page 2024-09-19 01:03:16 +01:00
IRHM 33c2000182 Tag: Make empty tag page error nicer
Added icon, consistent font sizes to watched list empty error
2024-09-19 01:02:33 +01:00
IRHM 0505d265fe WatchedList: Improve empty list error to take active filters into account 2024-09-19 01:00:57 +01:00
Mr f8e5a668f8 Merge pull request #618 from AlexPerathoner/dev
Import: add todomovies
2024-09-16 23:52:38 +00:00
IRHM 447ee2e82f import/process: Add status table header and fix status dropdown PLANNED option
Status table header was missing, corrected dropdown value from PLANNING to PLANNED
2024-09-17 00:21:57 +01:00
IRHM fe7730dbcc tags: Rename getTag to getTagByNameAndColor
and formatted code, updated some logs
2024-09-16 23:58:13 +01:00
IRHM 09ac804730 import: Move trakt import button higher
Probably a more famous import option, so moving it a bit higher in the list
2024-09-16 23:57:19 +01:00
IRHM 0f24623384 Icon: Fix todomovies icon
simplified and removed fill so styling can color it
2024-09-16 23:56:37 +01:00
Mr 964c71382a Merge pull request #627 from sbondCo/new-rating-systems
New rating systems
2024-09-15 21:14:29 +00:00
IRHM 65f8f67e4d Rating: Remove debug text 2024-09-15 21:36:20 +01:00
IRHM 8b92d42634 RatingSetting: Allow overflow
Allow scrolling if too smol
2024-09-15 21:30:15 +01:00
IRHM 1cab95370b StarRating: Fix firefox mobile clicks not saving rating and the jitter after selecting a rating
We now wait for the save request to complete before unfocusing the stars, which avoids the jitter where it snaps back to actual rating before snapping to new rating when it finally saves
2024-09-15 21:16:28 +01:00
IRHM b5fac676e9 StarRating: Remove tap highlight 2024-09-15 18:36:53 +01:00
IRHM b67b0acc9a toShowableRating: Round OutOf5 Point1 result 2024-09-15 18:27:15 +01:00
IRHM c2fe4486d9 StarRating: Save selected rating on touchEnd 2024-09-15 18:21:46 +01:00
IRHM 88fea6a8bd StarRating: Fix oneStarWidth
Is smaller when the media query makes the letter-spacing smaller. If i need to change this again, might be better to get it dynamically, but on component load and window resize instead of in each func call?
2024-09-15 16:13:39 +01:00
IRHM 6b64aa0d89 StarRating: Off right check better 2024-09-15 16:02:11 +01:00
IRHM df3955cfe1 Rating: Make more responsive 2024-09-15 15:38:31 +01:00
IRHM 8359be7bee StarRating: Extra checks to keep text on screen 2024-09-15 15:23:21 +01:00
IRHM 15f4a98ccb StarRating: Fix rating texts being one off or completely wrong for OutOf5 2024-09-15 02:58:13 +01:00
IRHM 02e9654791 ExtraDetails now a component and supports rating scaling
- Moved ExtraDetails (from Poster and GamePoster) to its own component for reuse, also no longer one line.
- Consolodated ExtraDetails and ExtraDetailsGame types into one type: PosterExtraDetails.
- Created Rating helpers lib. Contains: toShowableRating() and toWhichThumb()
- Fix extra detail dates not listing for games on search page
2024-09-14 23:31:44 +01:00
IRHM 8fd570ad5c StarRating: Restore the longer 100ms transition for rating text 2024-09-14 19:52:50 +01:00
IRHM bd10d6854a StarRating: Fix centering issue in moveRatingText func
Instead of trying to center the rating text element ourselves by using its width/2, we can just simply rely on the tranlateX -50% style instead of removing it.
2024-09-14 19:50:23 +01:00
IRHM 6249134dd6 StarRating: Fix showing hoveredRating when 0 2024-09-10 01:44:09 +01:00
IRHM 5d425cadd6 StarRating: Comment log 2024-09-09 01:24:04 +01:00
IRHM 695015b481 PosterRating: Separate rating popup for minimal usage
Used for Season/Episode ratings.... probably should separate that out or support the rating systems there in the future
2024-09-09 01:21:09 +01:00
IRHM 8d81b96692 poster ratings 2024-09-09 01:10:04 +01:00
IRHM 0bc9aa612c StarRating: Fix shown ratings
Should work in all cases when swapping between star systems/steps now
2024-09-06 01:26:48 +01:00
IRHM 968212e189 StarRating: Show ratingText with svelte
Fixes responsive var issues where the value of hoveredRating was for the old one
2024-09-06 01:05:56 +01:00
IRHM 4ab2267867 StarRating: Show rating in ratingText for .1 increments 2024-09-06 00:40:30 +01:00
IRHM 2cfe39f276 StarRating: Wrap try/catch around moveRatingText 2024-09-06 00:34:24 +01:00
IRHM a155e1eeea StarRating: Get ratingText working again 2024-09-06 00:31:24 +01:00
IRHM 81299f30de StarRating: Scale rating before saving 2024-09-06 00:03:23 +01:00
IRHM 17349e8367 ThumbRating: Show rating and save 2024-09-05 23:49:55 +01:00
IRHM f2faad3886 Save rating settings, separate StarRating to its own component, create ThumRating component, RatingStep is now an enum to avoid using decimal value on server 2024-09-05 23:41:39 +01:00
IRHM 451a5923d4 rating now float64
Seems to actually be accurate for what we need, I think we should just use float64 instead of importing a library for decimal values (if it continues working and i find no more issues)
2024-09-05 16:18:36 +01:00
Alex Perathoner 5e3ed9ceed import: add todomovies
Added:
- button in /import
- process of .todomovieslist files
- tags from import
2024-09-03 16:39:56 +02:00
IRHM 57f29c054a Rating: Fix rating being reset before being saved
Because of a bug with mouseleave tirggering when a star is clicked directly and browser focused at same time.

Also renamed handleStarHoverEnd->handleRatingHoverEnd since its now on the rating wrap element instead of a star element
2024-09-03 00:45:32 +01:00
IRHM be10df1c57 Rating: Uncomment save rating code 2024-09-03 00:44:25 +01:00
IRHM baba2fad4d Rating: Keyboard support, fix type errors, moved things around, some cleanup 2024-09-03 00:18:01 +01:00
IRHM bad4f505ef Watched: Rating now a float32 instead of int8
The only backwards compatible way to support the new rating systems is to turn ratings into a float, still max 10.
2024-09-03 00:16:47 +01:00
IRHM 4390f02f4d WIP: New Rating System
Most of the client work is done:
- Added new RatingSystem and RatingStep settings
- Rating component now supports all systems and steps (minus thumbs)

Thumbs and server work for saving user setting and then testing! yupee
2024-09-01 20:57:30 +01:00
dependabot[bot] a790e0d4e0 ui: bump svelte from 4.2.17 to 4.2.19 (#611)
Bumps [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) from 4.2.17 to 4.2.19.
- [Release notes](https://github.com/sveltejs/svelte/releases)
- [Changelog](https://github.com/sveltejs/svelte/blob/svelte@4.2.19/packages/svelte/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte/commits/svelte@4.2.19/packages/svelte)

---
updated-dependencies:
- dependency-name: svelte
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-31 16:26:49 +00:00
IRHM 3ccd983a5e v1.43.0 2024-08-30 01:00:21 +01:00
IRHM 760de9dead 1.43.0-dev 2024-08-30 00:42:10 +01:00
Mr b997b421fa Preload last season viewed (#609)
* Save requested season number in `LastViewedSeason` property

When season details are requested and a watchedId is passed as a query parameter, the requested season number is now saved in the watched entries `LastViewedSeason` property.

* SeasonsList: Restore activeSeason and update state after request with new lastViewedSeason value

* get season details route: add extra log for else case where no watchedId is found
2024-08-29 01:53:05 +00:00
Mr 208a66f303 person: Fix credits not changing if nav between two people (#608) 2024-08-26 21:04:40 +00:00
Mr d431f4a434 page titles (profile, content, person) (#607)
* page titles (profile, content, person)

* person: remove log
2024-08-26 20:47:23 +00:00
dependabot[bot] b571239312 ui: bump svelte-check from 3.8.5 to 3.8.6 (#604)
Bumps [svelte-check](https://github.com/sveltejs/language-tools) from 3.8.5 to 3.8.6.
- [Release notes](https://github.com/sveltejs/language-tools/releases)
- [Commits](https://github.com/sveltejs/language-tools/compare/svelte-check-3.8.5...svelte-check-3.8.6)

---
updated-dependencies:
- dependency-name: svelte-check
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-26 20:17:06 +00:00
dependabot[bot] 126717ecdc ui: bump tslib from 2.6.2 to 2.7.0 (#605)
Bumps [tslib](https://github.com/Microsoft/tslib) from 2.6.2 to 2.7.0.
- [Release notes](https://github.com/Microsoft/tslib/releases)
- [Commits](https://github.com/Microsoft/tslib/compare/v2.6.2...v2.7.0)

---
updated-dependencies:
- dependency-name: tslib
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-26 20:16:42 +00:00
dependabot[bot] 374fc59499 ui: bump eslint-plugin-svelte from 2.41.0 to 2.43.0 (#603)
Bumps [eslint-plugin-svelte](https://github.com/sveltejs/eslint-plugin-svelte) from 2.41.0 to 2.43.0.
- [Release notes](https://github.com/sveltejs/eslint-plugin-svelte/releases)
- [Commits](https://github.com/sveltejs/eslint-plugin-svelte/compare/eslint-plugin-svelte@2.41.0...eslint-plugin-svelte@2.43.0)

---
updated-dependencies:
- dependency-name: eslint-plugin-svelte
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-26 20:16:28 +00:00
dependabot[bot] c8db3093be ui: bump svelte-eslint-parser from 0.39.2 to 0.41.0 (#606)
Bumps [svelte-eslint-parser](https://github.com/sveltejs/svelte-eslint-parser) from 0.39.2 to 0.41.0.
- [Release notes](https://github.com/sveltejs/svelte-eslint-parser/releases)
- [Changelog](https://github.com/sveltejs/svelte-eslint-parser/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte-eslint-parser/compare/v0.39.2...v0.41.0)

---
updated-dependencies:
- dependency-name: svelte-eslint-parser
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-26 20:14:13 +00:00
IRHM e182799ca9 v1.42.0 2024-08-25 23:33:16 +01:00
IRHM 870f2614b5 Import: fix trakt button colors for dark theme 2024-08-25 23:25:10 +01:00
IRHM 0e88f40a99 Tag: Fix icon color for dark theme 2024-08-25 23:22:21 +01:00
IRHM faead2ebd3 TagMenu: Move menu more to left 2024-08-25 23:17:31 +01:00
IRHM 416098b53d 1.42.0-dev1 2024-08-25 21:54:22 +01:00
IRHM 73c756a780 doc/README: Include link to live site 2024-08-25 21:41:33 +01:00
dependabot[bot] b7d261bad4 server: bump github.com/go-co-op/gocron/v2 in /server (#587)
Bumps [github.com/go-co-op/gocron/v2](https://github.com/go-co-op/gocron) from 2.7.0 to 2.11.0.
- [Release notes](https://github.com/go-co-op/gocron/releases)
- [Commits](https://github.com/go-co-op/gocron/compare/v2.7.0...v2.11.0)

---
updated-dependencies:
- dependency-name: github.com/go-co-op/gocron/v2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-25 19:50:02 +00:00
dependabot[bot] c8203187a7 server: bump gorm.io/gorm from 1.25.10 to 1.25.11 in /server (#584)
Bumps [gorm.io/gorm](https://github.com/go-gorm/gorm) from 1.25.10 to 1.25.11.
- [Release notes](https://github.com/go-gorm/gorm/releases)
- [Commits](https://github.com/go-gorm/gorm/compare/v1.25.10...v1.25.11)

---
updated-dependencies:
- dependency-name: gorm.io/gorm
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-25 15:03:07 +00:00
dependabot[bot] 3014019692 ui: bump svelte-preprocess from 5.1.4 to 6.0.2 (#586)
Bumps [svelte-preprocess](https://github.com/sveltejs/svelte-preprocess) from 5.1.4 to 6.0.2.
- [Changelog](https://github.com/sveltejs/svelte-preprocess/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte-preprocess/compare/v5.1.4...v6.0.2)

---
updated-dependencies:
- dependency-name: svelte-preprocess
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-25 01:02:01 +00:00
dependabot[bot] 9e2f60fa56 ui: bump typescript from 5.4.5 to 5.5.4 (#588)
Bumps [typescript](https://github.com/Microsoft/TypeScript) from 5.4.5 to 5.5.4.
- [Release notes](https://github.com/Microsoft/TypeScript/releases)
- [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release.yml)
- [Commits](https://github.com/Microsoft/TypeScript/compare/v5.4.5...v5.5.4)

---
updated-dependencies:
- dependency-name: typescript
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-25 00:56:54 +00:00
dependabot[bot] 7642655ef0 ui: bump svelte-check from 3.7.1 to 3.8.5 (#590)
Bumps [svelte-check](https://github.com/sveltejs/language-tools) from 3.7.1 to 3.8.5.
- [Release notes](https://github.com/sveltejs/language-tools/releases)
- [Commits](https://github.com/sveltejs/language-tools/compare/svelte-check-3.7.1...svelte-check-3.8.5)

---
updated-dependencies:
- dependency-name: svelte-check
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-25 00:55:45 +00:00
dependabot[bot] a28d199431 ui: bump axios from 1.7.2 to 1.7.4 (#600)
Bumps [axios](https://github.com/axios/axios) from 1.7.2 to 1.7.4.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.7.2...v1.7.4)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-25 00:53:07 +00:00
dependabot[bot] cf387d1c0a server: bump golang.org/x/crypto from 0.24.0 to 0.26.0 in /server (#593)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.24.0 to 0.26.0.
- [Commits](https://github.com/golang/crypto/compare/v0.24.0...v0.26.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-25 00:52:20 +00:00
dependabot[bot] 64f31a234e ui: bump @sveltejs/kit from 2.5.10 to 2.5.24 (#596)
Bumps [@sveltejs/kit](https://github.com/sveltejs/kit/tree/HEAD/packages/kit) from 2.5.10 to 2.5.24.
- [Release notes](https://github.com/sveltejs/kit/releases)
- [Changelog](https://github.com/sveltejs/kit/blob/main/packages/kit/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/kit/commits/@sveltejs/kit@2.5.24/packages/kit)

---
updated-dependencies:
- dependency-name: "@sveltejs/kit"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-25 00:49:19 +00:00
Mr 09d1c513d6 api: Use location to create baseURL var in dev mode (#599)
Makes it easier when accessing the vite dev server from outside the local machine, instead of making me change the variable to my machines local ip all the time
2024-08-25 00:46:01 +00:00
Mr 89f703ae9b Close all sub menus on nav bar after a navigation (#598) 2024-08-25 00:34:19 +00:00
Mr ae09de6f71 Merge pull request #597 from sbondCo/tags
Tags
2024-08-25 00:28:37 +00:00
IRHM 7c39a0649a Tag: remove unused css 2024-08-25 01:21:48 +01:00
IRHM 035d68a103 Add AddToTagButton to Movies and Games pages 2024-08-25 01:02:29 +01:00
IRHM 3b501e73c8 Tag: Add subtitle with some more info on empty tag text 2024-08-25 00:55:55 +01:00
IRHM d7353fc2a7 Support editing tags
by clicking on the tag in the tag page.. not very obvious, may need some way to make it so
2024-08-25 00:52:15 +01:00
IRHM c65579c0f6 Nav: Close tagMenu when tag clicked 2024-08-25 00:19:00 +01:00
IRHM 0a886f0062 DeleteTagModal: Also remove tag from all watched entries 2024-08-25 00:14:50 +01:00
IRHM a347e6dc97 Finish DeleteTagModal 2024-08-25 00:09:04 +01:00
IRHM a9088c9626 wip: Tag deletion (route working, modal nearly done) 2024-08-20 17:27:07 +01:00
IRHM a19d61a16d Tag: Show tag icon instead of 'viewing tag' text 2024-08-20 15:51:00 +01:00
IRHM 003c8e724c Tag: Add 'viewing tag:' text 2024-08-20 15:47:37 +01:00
IRHM 053fc28371 Fix nav input responsiveness 2024-08-20 15:47:23 +01:00
IRHM 9d8ae819a7 Fix nav menu positions 2024-08-20 15:36:54 +01:00
IRHM 658b9f356c Tag: not exist errors 2024-08-19 16:18:25 +01:00
IRHM becf9dd601 basic tag view page 2024-08-19 16:07:19 +01:00
IRHM b74ecbddf7 AddToTagButton: Close menu on scroll 2024-08-19 14:36:47 +01:00
IRHM 38e52bd821 Tagging and untagging watched content working 2024-08-18 20:25:36 +01:00
IRHM c3e08b2dc0 watched: getWatched: Preload Tags and format each call in chain to newline 2024-08-17 18:15:51 +01:00
IRHM f0019b540a OnTagClick 2024-08-17 18:15:20 +01:00
IRHM b0a72bf636 tag watched route 2024-08-17 00:43:17 +01:00
IRHM 1701dd560e Make TagMenu more reusable, create AddToTagButton
Started working on adding content to tag button (from the content page), will likely have to have other ways of adding content to a tag
2024-08-16 23:20:43 +01:00
IRHM 7ddb880843 Tag creation working
- New Tag component (to display a tag button)
- Modified tag color presets to be more readable
2024-08-14 00:13:42 +01:00
IRHM f0c929d743 Modal: New error property
Just simplifies what we have been doing in other places where we use the  Modal component; making an error span at the top.
2024-08-14 00:12:16 +01:00
IRHM a7fc789056 Basic tag creation modal, color selector 2024-08-11 23:31:43 +01:00
IRHM 11cf23acb8 Start work on tags 2024-08-11 00:07:23 +01:00
IRHM c05c271e1b Dockerfile: update backend image to golang:1.22-alpine
becuase we use go 1.22 now
2024-07-13 23:10:11 +01:00
IRHM 9dd7ecbff1 1.42.0-dev 2024-07-13 23:05:23 +01:00
oPisiti 96ec6f2c8e Fixed unit oversight (#578) 2024-07-13 21:58:45 +00:00
Mr ae707df709 job: support adding unique jobs (by name) (#583) 2024-07-13 21:57:16 +00:00
Mr 4bbc7ad869 Trakt import (#582)
* wip

* wip

* wip

* wip (also update go version)

* wip

* wip

* wip looks like its working

* increase job done time before deletion

30m seems more reasonable?
2024-07-13 21:27:59 +00:00
Mr 953dcf0034 doc: Create backup guide (#573)
* doc: Create backup guide

* doc: backup: correct grammar i think
2024-07-02 21:08:15 +00:00
IRHM fb4bf1e372 v1.41.0 2024-06-28 04:31:25 +01:00
IRHM ff067d28e6 1.41.0-dev5 2024-06-28 04:10:10 +01:00
IRHM 950e9afffb search: fix loader being hidden after a running request is cancelled
When a search request is running, and the query is changed, the previous request cancellation would hide the loader just after the new request started loading, resulting in the loader being hidden for the duration of the request being completed. Now the loader will only hide if the request errors, not for cancellations too which was causing this issue
2024-06-28 04:09:38 +01:00
IRHM ee15f64e55 1.41.0-dev4 2024-06-28 03:36:42 +01:00
IRHM 7888e0490f #566 Add max=10 validation to Rating in import/watched eps and seasons 2024-06-28 03:35:18 +01:00
IRHM bf45f1a279 1.41.0-dev3 2024-06-28 02:50:20 +01:00
IRHM 0299f03aa1 Ryot import: fix ratings
Ratings are out of 100 on ryot, scaled them down to fit with our out of 10 rating system
2024-06-28 02:48:23 +01:00
IRHM 56d1ed9f10 1.41.0-dev2 2024-06-28 02:29:52 +01:00
oPisiti 855b8722de Ryot import implementation (#563)
* Initial implementation of ryot import process

* Bug fixes for movie imports and show ratings

* Cleanup

* Added Ryot's logo to import button

* Ryot import now supports the DROPPED flag for shows

* Formatted the code and added restrictions for tv shows and movies only

* Better units

* Prettier format

* Fix type errors, change stat styling to push value down

---------

Co-authored-by: IRHM <37304121+IRHM@users.noreply.github.com>
2024-06-28 01:23:04 +00:00
IRHM aff99b5fa5 README: Add third party tools section with kodi plugin 2024-06-27 20:53:03 +01:00
IRHM d4683fb89d search: update page destroyed log 2024-06-26 03:30:43 +01:00
IRHM b61855d00f search: stop afterNavigate running when we only want onMount to 2024-06-26 03:29:46 +01:00
IRHM 3ee763fe29 Search: Support cancelling running searches
with only a few bugs introduced!
2024-06-26 03:10:35 +01:00
IRHM 03f7c51655 Search: Fix big jump when clicking try again and we fail again
Added same margin-bottom to the spinner.
2024-06-26 02:00:46 +01:00
IRHM 0c95468f93 Search: Comment out curPage 2024-06-25 02:18:20 +01:00
IRHM f7b96fb4b9 1.41.0-dev1 2024-06-25 02:16:13 +01:00
IRHM 471f3e3f6f Search: Fix race cond potentially causing all pages to load in background 2024-06-25 02:15:26 +01:00
IRHM 091bce7383 Search: Add comment to curPage increment in multi results 2024-06-25 01:58:33 +01:00
IRHM e3cec34c8b Search: Fix 'try again' not working if initial multi results failed to load 2024-06-25 01:57:18 +01:00
IRHM 4b82913308 1.41.0-dev 2024-06-25 00:49:31 +01:00
IRHM 0a50ead42a Search: Don't show games filter if games are not enabled on server 2024-06-25 00:43:15 +01:00
dependabot[bot] 1428b33279 server: bump github.com/go-co-op/gocron/v2 in /server (#557)
Bumps [github.com/go-co-op/gocron/v2](https://github.com/go-co-op/gocron) from 2.5.0 to 2.7.0.
- [Release notes](https://github.com/go-co-op/gocron/releases)
- [Commits](https://github.com/go-co-op/gocron/compare/v2.5.0...v2.7.0)

---
updated-dependencies:
- dependency-name: github.com/go-co-op/gocron/v2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-24 23:27:40 +00:00
dependabot[bot] 91bf6a6adf ui: bump eslint-plugin-svelte from 2.39.0 to 2.41.0 (#558)
Bumps [eslint-plugin-svelte](https://github.com/sveltejs/eslint-plugin-svelte) from 2.39.0 to 2.41.0.
- [Release notes](https://github.com/sveltejs/eslint-plugin-svelte/releases)
- [Commits](https://github.com/sveltejs/eslint-plugin-svelte/compare/v2.39.0...eslint-plugin-svelte@2.41.0)

---
updated-dependencies:
- dependency-name: eslint-plugin-svelte
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-24 23:26:42 +00:00
dependabot[bot] cbafb8c77b ui: bump svelte-eslint-parser from 0.36.0 to 0.39.2 (#559)
Bumps [svelte-eslint-parser](https://github.com/sveltejs/svelte-eslint-parser) from 0.36.0 to 0.39.2.
- [Release notes](https://github.com/sveltejs/svelte-eslint-parser/releases)
- [Changelog](https://github.com/sveltejs/svelte-eslint-parser/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte-eslint-parser/compare/v0.36.0...v0.39.2)

---
updated-dependencies:
- dependency-name: svelte-eslint-parser
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-24 23:26:26 +00:00
dependabot[bot] ec12281e9b ui: bump @typescript-eslint/eslint-plugin from 7.12.0 to 7.14.1 (#560)
Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 7.12.0 to 7.14.1.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v7.14.1/packages/eslint-plugin)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-24 23:25:37 +00:00
dependabot[bot] e65afd8b9d workflow: bump docker/build-push-action from 5 to 6 (#545)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 5 to 6.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-24 23:20:12 +00:00
dependabot[bot] 49d1411e47 server: bump gorm.io/driver/sqlite from 1.5.5 to 1.5.6 in /server (#546)
Bumps [gorm.io/driver/sqlite](https://github.com/go-gorm/sqlite) from 1.5.5 to 1.5.6.
- [Commits](https://github.com/go-gorm/sqlite/compare/v1.5.5...v1.5.6)

---
updated-dependencies:
- dependency-name: gorm.io/driver/sqlite
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-24 23:18:15 +00:00
dependabot[bot] 7ba0b8106d ui: bump prettier from 3.2.5 to 3.3.2 (#549)
Bumps [prettier](https://github.com/prettier/prettier) from 3.2.5 to 3.3.2.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.2.5...3.3.2)

---
updated-dependencies:
- dependency-name: prettier
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-24 23:16:03 +00:00
dependabot[bot] b7cfb904a2 server: bump golang.org/x/crypto from 0.23.0 to 0.24.0 in /server (#539)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.23.0 to 0.24.0.
- [Commits](https://github.com/golang/crypto/compare/v0.23.0...v0.24.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-24 23:15:17 +00:00
Mr eed3a7cee7 Merge pull request #562 from sbondCo/search-pages
Search: Infinite scrolling and serverside content type filters
2024-06-24 23:11:25 +00:00
IRHM 014984cdd1 Search: Comment out page num and log instead
Kind of wanted to display the page number somewhere but I can't think of where it could go
2024-06-24 23:53:04 +01:00
IRHM c1f0a954bf Search: Return nothing if higher page than 1 is requested for games
Doesn't support pagination, fixes showing duplicate games on multi search when scrolling down.
2024-06-24 02:25:54 +01:00
IRHM 5ca7841e61 Search: Auto load next page if first doesn't fill page 2024-06-24 02:24:50 +01:00
IRHM 54f8dd8912 Search: Better error handling, add retry button 2024-06-24 02:00:04 +01:00
IRHM fc4cbf3c66 Search: Basic infinite scrolling 2024-06-23 18:24:15 +01:00
IRHM b4cc8f85b7 search: Use new search routes for filters
Instead of using a local filter of `multi`+`game` results.
2024-06-23 18:09:42 +01:00
IRHM 4056178be4 content: Create movie, tv and person search services/routes 2024-06-22 01:03:03 +01:00
IRHM 2aab7b3fce routes: content: Move /:query route to /search/multi/:query 2024-06-22 01:02:12 +01:00
IRHM 9df978a4fd searchContent: Support pagination 2024-06-11 23:50:20 +01:00
201 changed files with 30503 additions and 22869 deletions
+25 -25
View File
@@ -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,
},
};
+35
View File
@@ -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.
+15
View File
@@ -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?
+1 -1
View File
@@ -41,7 +41,7 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
ghcr.io/${{ env.image }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
+31
View File
@@ -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:
+4
View File
@@ -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
+16 -16
View File
@@ -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"
}
}
]
}
+13 -13
View File
@@ -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}"
}
]
}
+3
View File
@@ -0,0 +1,3 @@
{
"editor.insertSpaces": false
}
+28
View File
@@ -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).
+12 -2
View File
@@ -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
+32 -15
View File
@@ -18,34 +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>
@@ -53,8 +56,22 @@ Feel free to abuse this demo instance (nicely), which runs on the latest `dev` b
[Checkout our documentation](https://watcharr.app/docs/category/installation) for an up to date guide on setup! If you hate manuals, but love docker, this [docker-compose.yml](./docker-compose.yml) file is your friend.
# Community Made Tools
Third-party tools made by the community for enhancing your Watcharr experience!
- [Kodi Plugin](https://github.com/airdogvan/watcharr_kodi) by [airdogvan](https://github.com/airdogvan) for automatically tracking your watched shows/movies.
Thanks to anyone that has made a script or tool for Watcharr. Feel free to add your own to the list if you have one!
**Note:** I cannot provide any assurances for these tools or stay on top of them (code review, etc), if you have any problems please open an issue in the project for the tool so that they can stay organized.
# Getting Help
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).
+7
View File
@@ -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).
+3 -1
View File
@@ -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 -1
View File
@@ -1,3 +1,3 @@
module.exports = {
presets: [require.resolve("@docusaurus/core/lib/babel/preset")]
presets: [require.resolve("@docusaurus/core/lib/babel/preset")],
};
+6 -6
View File
@@ -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."
}
}
+6 -6
View File
@@ -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."
}
}
+6 -6
View File
@@ -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."
}
}
+41
View File
@@ -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).
+6 -6
View File
@@ -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."
}
}
+91 -91
View File
@@ -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;
+42 -42
View File
@@ -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"
}
}
+4 -4
View File
@@ -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',
+52 -49
View File
@@ -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;
}
+60 -60
View File
@@ -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>
);
}
+34 -34
View File
@@ -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;
}
}
+22 -22
View File
@@ -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;
}
+40 -37
View File
@@ -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>
);
}
+18 -18
View File
@@ -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;
}
+8409 -8374
View File
File diff suppressed because it is too large Load Diff
+40 -39
View File
@@ -1,41 +1,42 @@
{
"name": "watcharr",
"version": "1.40.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.12.0",
"@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.39.0",
"prettier": "^3.2.5",
"prettier-plugin-svelte": "^3.2.3",
"svelte": "^4.2.17",
"svelte-check": "^3.7.1",
"svelte-eslint-parser": "^0.36.0",
"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"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 430 KiB

After

Width:  |  Height:  |  Size: 488 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 305 KiB

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 661 KiB

After

Width:  |  Height:  |  Size: 900 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 820 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 743 KiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 752 KiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 816 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

+1
View File
@@ -0,0 +1 @@
These screenshots were first uploaded Dec 10, 2023. They were retired on Jan 19, 2025.
Binary file not shown.

After

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 661 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 820 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 743 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 752 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 816 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 75 KiB

+15 -5
View File
@@ -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) {
+93
View File
@@ -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
}
+39 -1
View File
@@ -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
+83 -3
View 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())
}
@@ -243,9 +245,12 @@ func transformProviders(c *interface{}, country string) {
}
}
func searchContent(query string) (TMDBSearchMultiResponse, error) {
func searchContent(query string, pageNum int) (TMDBSearchMultiResponse, error) {
resp := new(TMDBSearchMultiResponse)
err := tmdbRequest("/search/multi", map[string]string{"query": query, "page": "1"}, &resp)
if pageNum == 0 {
pageNum = 1
}
err := tmdbRequest("/search/multi", map[string]string{"query": query, "page": strconv.Itoa(pageNum)}, &resp)
if err != nil {
slog.Error("Failed to complete multi search request!", "error", err.Error())
return TMDBSearchMultiResponse{}, errors.New("failed to complete multi search request")
@@ -253,6 +258,81 @@ func searchContent(query string) (TMDBSearchMultiResponse, error) {
return *resp, nil
}
func searchMovies(query string, pageNum int) (TMDBSearchMoviesResponse, error) {
resp := new(TMDBSearchMoviesResponse)
if pageNum == 0 {
pageNum = 1
}
err := tmdbRequest("/search/movie", map[string]string{"query": query, "page": strconv.Itoa(pageNum)}, &resp)
if err != nil {
slog.Error("Failed to complete movie search request!", "error", err.Error())
return TMDBSearchMoviesResponse{}, errors.New("failed to complete movie search request")
}
for i := range resp.Results {
resp.Results[i].MediaType = "movie"
}
return *resp, nil
}
func searchTv(query string, pageNum int) (TMDBSearchShowsResponse, error) {
resp := new(TMDBSearchShowsResponse)
if pageNum == 0 {
pageNum = 1
}
err := tmdbRequest("/search/tv", map[string]string{"query": query, "page": strconv.Itoa(pageNum)}, &resp)
if err != nil {
slog.Error("Failed to complete tv search request!", "error", err.Error())
return TMDBSearchShowsResponse{}, errors.New("failed to complete tv search request")
}
for i := range resp.Results {
resp.Results[i].MediaType = "tv"
}
return *resp, nil
}
func searchPeople(query string, pageNum int) (TMDBSearchPeopleResponse, error) {
resp := new(TMDBSearchPeopleResponse)
if pageNum == 0 {
pageNum = 1
}
err := tmdbRequest("/search/person", map[string]string{"query": query, "page": strconv.Itoa(pageNum)}, &resp)
if err != nil {
slog.Error("Failed to complete people search request!", "error", err.Error())
return TMDBSearchPeopleResponse{}, errors.New("failed to complete people search request")
}
for i := range resp.Results {
resp.Results[i].MediaType = "person"
}
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)
+1 -1
View File
@@ -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) {
+20
View File
@@ -33,6 +33,8 @@ func (i *IGDB) req(host string, ep string, p map[string]string, b string, resp i
return errors.New("using igdbHost without a clientID or accessToken")
}
slog.Debug("IGDB->req: Creating a request.", "ep", ep, "body", b)
base, err := url.Parse(host)
if err != nil {
return errors.New("failed to parse api uri")
@@ -181,6 +183,24 @@ func (i *IGDB) Search(q string) (GameSearchResponse, error) {
return resp, nil
}
// Should return same details as `Search`, we both are for search page only minimal details required.
func (i *IGDB) SearchById(id string) (GameSearchResponse, error) {
slog.Debug("IGDB Search called", "id", id)
var resp GameSearchResponse
err := i.req(
igdbHost,
"/games",
map[string]string{},
"fields name, cover.image_id, version_title, summary, first_release_date; where id = "+id+";",
&resp,
)
if err != nil {
slog.Error("IGDB Search request failed!", "error", err)
return GameSearchResponse{}, errors.New("request failed")
}
return resp, nil
}
func (i *IGDB) GameDetails(id string) (GameDetailsResponse, error) {
slog.Debug("IGDB GameDetails called", "id", id)
var resp []GameDetailsResponse
+1 -1
View File
@@ -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"`
}
+22 -23
View File
@@ -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.5.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.23.0
golang.org/x/crypto v0.32.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gorm.io/driver/sqlite v1.5.5
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.17 // 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.20.0 // indirect
golang.org/x/text v0.15.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
)
+46 -50
View File
@@ -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.5.0 h1:ff/TJX9GdTJBDL1il9cyd/Sj3WnS+BB7ZzwHKSNL5p8=
github.com/go-co-op/gocron/v2 v2.5.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=
@@ -66,8 +67,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/memcachier/mc/v3 v3.0.3 h1:qii+lDiPKi36O4Xg+HVKwHu6Oq+Gt17b+uEiA0Drwv4=
github.com/memcachier/mc/v3 v3.0.3/go.mod h1:GzjocBahcXPxt2cmqzknrgqCOmMxiSzhVKPOe90Tpug=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -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.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
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.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
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.5 h1:7MDMtUZhV065SilG62E0MquljeArQZNfJnjd9i9gx3E=
gorm.io/driver/sqlite v1.5.5/go.mod h1:6NgQ7sQWAIFsPrJJl1lSNSu2TABh0ZZ/zm5fosATavE=
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=
+84 -2
View File
@@ -30,7 +30,7 @@ type ImportRequest struct {
Name string `json:"name"`
TmdbID int `json:"tmdbId"`
Type ContentType `json:"type"`
Rating int8 `json:"rating"`
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,7 +72,53 @@ func importContent(db *gorm.DB, userId uint, ar ImportRequest) (ImportResponse,
return successfulImport(db, userId, cr.ID, SHOW, ar)
}
}
sr, err := searchContent(ar.Name)
// 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)
return ImportResponse{}, errors.New("Content search failed")
@@ -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
}
+456
View File
@@ -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
}
+23 -5
View File
@@ -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)
}()
}
+2 -2
View File
@@ -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 {
+395 -21
View File
@@ -3,6 +3,7 @@ package main
import (
"log/slog"
"net/http"
"net/url"
"strconv"
"time"
@@ -79,13 +80,112 @@ func (b *BaseRouter) addContentRoutes() {
exp := time.Hour * 24
// Search for content
content.GET("/:query", cache.CachePage(b.ms, exp, func(c *gin.Context) {
// println(c.Param("query"))
if c.Param("query") == "" {
c.Status(400)
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
}
content, err := searchContent(c.Param("query"))
pageQ := c.Query("page")
pageNum := 1
if pageQ != "" {
num, err := strconv.Atoi(pageQ)
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "query parameter 'page' is not a number"})
return
}
pageNum = num
}
content, err := searchContent(query, pageNum)
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, content)
}))
// Search for movies
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
}
pageQ := c.Query("page")
pageNum := 1
if pageQ != "" {
num, err := strconv.Atoi(pageQ)
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "query parameter 'page' is not a number"})
return
}
pageNum = num
}
content, err := searchMovies(query, pageNum)
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, content)
}))
// Search for shows
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
}
pageQ := c.Query("page")
pageNum := 1
if pageQ != "" {
num, err := strconv.Atoi(pageQ)
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "query parameter 'page' is not a number"})
return
}
pageNum = num
}
content, err := searchTv(query, pageNum)
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, content)
}))
// Search for people
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
}
pageQ := c.Query("page")
pageNum := 1
if pageQ != "" {
num, err := strconv.Atoi(pageQ)
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "query parameter 'page' is not a number"})
return
}
pageNum = num
}
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
@@ -150,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)
@@ -160,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)
})
@@ -271,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
@@ -472,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() {
@@ -594,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
@@ -611,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)
@@ -784,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
@@ -921,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() {
@@ -928,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)
@@ -1422,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)
})
}
+194
View File
@@ -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
}
+107 -5
View File
@@ -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,6 +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 {
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 {
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 {
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 {
+8
View File
@@ -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
}
+13 -2
View File
@@ -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")
+19 -4
View File
@@ -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)
+95 -23
View File
@@ -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 -1
View File
@@ -31,7 +31,7 @@ type WatchedEpisodeAddRequest struct {
SeasonNumber int `json:"seasonNumber"`
EpisodeNumber int `json:"episodeNumber"`
Status WatchedStatus `json:"status"`
Rating int8 `json:"rating"`
Rating int8 `json:"rating" binding:"max=10"`
addActivity ActivityType `json:"-"`
addActivityDate time.Time `json:"-"`
}
+1 -1
View File
@@ -25,7 +25,7 @@ type WatchedSeasonAddRequest struct {
WatchedID uint `json:"watchedId"`
SeasonNumber int `json:"seasonNumber"`
Status WatchedStatus `json:"status"`
Rating int8 `json:"rating"`
Rating int8 `json:"rating" binding:"max=10"`
addActivity ActivityType `json:"-"`
addActivityDate time.Time `json:"-"`
// Data to add to activity if the season is created.
+6 -6
View File
@@ -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 {};
+11 -11
View File
@@ -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>
+8
View File
@@ -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;",
);
};
+296 -290
View File
@@ -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>
+120 -111
View File
@@ -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>
+79 -70
View File
@@ -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>
+33
View File
@@ -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>
+171 -150
View File
@@ -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>
+116 -103
View File
@@ -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>
+38 -30
View File
@@ -1,40 +1,48 @@
<script lang="ts">
export let pretty: string;
export let error: any;
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}
</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>
+36 -31
View File
@@ -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>
+618 -311
View File
@@ -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,78 +438,239 @@
-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
-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
272 107 429 108 97 1 98 1 93 -21z m302 -44 c20 -9 32 -18 26 -19 -5 -2 -35
-10 -65 -19 -30 -8 -81 -31 -112 -50 -66 -39 -65 -40 -48 60 16 90 10 85 91
63 39 -11 88 -27 108 -35z m255 -173 c128 -63 199 -178 200 -326 1 -70 -3 -89
-31 -147 -134 -284 -529 -284 -661 0 -22 48 -27 71 -27 147 0 77 4 98 26 146
68 144 195 222 351 216 62 -3 89 -10 142 -36z m-1278 -173 c33 -50 62 -97 66
-104 6 -9 -8 -10 -55 -7 -72 6 -149 -8 -209 -37 -23 -11 -43 -18 -45 -17 -1 2
19 40 45 85 47 82 116 172 131 172 5 0 35 -41 67 -92z m109 -252 c110 -52 178
-160 179 -282 1 -62 -3 -80 -34 -140 -56 -110 -137 -160 -281 -173 l-66 -6 67
-240 c36 -132 65 -242 63 -244 -9 -11 -150 142 -200 217 -95 142 -148 295
-163 467 -14 174 13 270 101 349 62 56 114 74 209 75 59 1 86 -4 125 -23z
m1516 -129 c19 -96 26 -281 10 -306 -7 -11 -89 -12 -491 -6 l-482 8 5 26 c3
14 13 76 23 138 l18 112 57 -63 c150 -164 379 -202 583 -99 97 49 205 185 230
288 7 26 9 28 18 12 5 -10 18 -60 29 -110z m-1154 -332 l63 -95 95 -362 c53
-200 98 -369 101 -377 4 -12 -16 -13 -118 -8 -141 6 -249 31 -353 81 l-70 33
-64 234 c-35 129 -65 238 -65 242 -1 4 22 16 49 25 74 26 136 65 184 117 42
46 101 155 101 188 0 32 15 17 77 -78z m619 -99 c1 0 4 -146 7 -323 l6 -321
-47 -20 c-62 -27 -200 -67 -207 -61 -4 4 -195 719 -195 731 0 3 434 -2 436 -6z
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>
{: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>
+241
View File
@@ -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>
+56 -53
View File
@@ -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>
+112
View File
@@ -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>
+95 -65
View File
@@ -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>
+25
View File
@@ -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>
+36 -32
View File
@@ -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>
-146
View File
@@ -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>
+36 -28
View File
@@ -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}
+451 -288
View File
@@ -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>
+382 -359
View File
@@ -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>
+34 -31
View File
@@ -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>
+23 -19
View File
@@ -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>
+76 -57
View File
@@ -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>

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