Compare commits

...

145 Commits

Author SHA1 Message Date
IRHM 7e6ca542ec FilterMenu: Fix type btn overflow and style to be pills 2025-05-17 17:33:57 +01:00
IRHM a60710e384 wip: Watched sorting, fixed watcheds loader 2025-05-17 17:32:56 +01:00
IRHM fbf3002fa1 fix search person (use PaginatedRequest) 2025-04-23 22:28:12 +01:00
IRHM fce668ef97 add watched to discover data and fix search pagination 2025-04-23 22:22:46 +01:00
IRHM 939ff6cdaf getWatchedItemsByTmdbIds: Update log to comment out all watcheds 2025-04-20 22:36:15 +01:00
IRHM 6a716f7507 getWatched: return err instead of panicking 2025-04-20 22:35:32 +01:00
IRHM 8f80153d22 Support poster adding/removing watched, and make watched prop bindable to update parent data too 2025-04-20 13:24:37 +01:00
IRHM d3d476cc3f fix get watched caching and move addWatched to content stuff to functions 2025-04-20 13:23:40 +01:00
IRHM 2f78fdd0a5 removeWatched: Make notify call success when successfully removed 2025-04-18 21:17:09 +01:00
IRHM 79f1d1b242 Update WatchedList.svelte 2025-04-11 21:05:45 +01:00
IRHM b946dfd3c9 forgot what i did but progressed 2025-04-11 21:05:09 +01:00
IRHM 69c656bc97 infScroll: Add callback isloading requirement for implementing in comment 2025-04-03 22:35:33 +01:00
IRHM 10bf5324b2 infScroll: Create dataLoaded func for ensuring full page of data is present after page loads 2025-04-03 22:29:54 +01:00
IRHM fcdf752c6c Create go-cache GetCache helper func
Easier to use this method so we can simply pass a pointer of our var in and let it do everything for us, relieves us of our manual type assertion duty everywhere we get from cache.
2025-04-03 22:28:42 +01:00
IRHM df425b9587 tv: remove old comment 2025-03-26 23:38:29 +00:00
IRHM 78eee0e27e wip: getting show watched data in request 2025-03-26 23:36:47 +00:00
IRHM 0ea1fea88a wip: content: move watched entry to inside content object
instead of separating both, should be easier to work with this way and enable it to work for search results without complex mapping etc to find related watched entries
2025-02-18 22:08:59 +00:00
IRHM 18c0882650 wip: content: returning watched along with tv response 2025-02-18 21:40:24 +00:00
IRHM b70ae5d3c7 main page: show spinner while loading next list page 2025-02-16 14:23:30 +00:00
IRHM 01e175ee9f watched list pagination in ui 2025-02-16 14:23:30 +00:00
IRHM 409b2c9d41 search: Use infScroll 2025-02-16 14:23:30 +00:00
IRHM 902a444704 Create infScroll helper with reusable logic
based on the infinite scroll code from search page
2025-02-16 14:23:30 +00:00
IRHM 2c0983724a finalize PaginationResponse, get count in watchedpage 2025-02-16 14:23:30 +00:00
IRHM bf0a1eaa14 watched: Create getWatchedPage for paginated response 2025-02-16 14:23:30 +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
189 changed files with 30018 additions and 25935 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,
},
};
+5
View File
@@ -8,6 +8,11 @@ on:
- v*
paths:
- ".github/workflows/test-pr-web.yml"
- "package*.json"
- "svelte.config.json"
- "vite.config.json"
- "tsconfig.json"
- ".prettier*"
- "src/**"
jobs:
+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).
+11 -1
View File
@@ -1,4 +1,6 @@
#
# Backend
#
FROM golang:1.22-alpine AS server
WORKDIR /server
@@ -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
+21 -15
View File
@@ -18,35 +18,37 @@ I am built with Go and Svelte(Kit).
Feel free to abuse this demo instance (nicely), which runs on the latest `dev` build (there may be bugs, as new features are tested on here too): [https://beta.watcharr.app/](https://beta.watcharr.app/)
[Track Progress Until Next Version](https://github.com/orgs/sbondCo/projects/9/views/3)
[Track progress for the next version](https://github.com/orgs/sbondCo/projects/9/views/3).
### Contents
- [Screenshots](#screenshots)
- [Setup](#set-up)
- [Contributing](CONTRIBUTING.md)
- [Set Up](#set-up)
- [Community Made Tools](#community-made-tools)
- [Getting Help](#getting-help)
- [Contributing](#contributing)
# Screenshots
<p align="center">
| Homepage | Watched Show Hover |
| ---------------------------------------------------------- | ----------------------------------------------------------------------- |
| <img src="./screenshot/homepage.png" alt="Watched List" /> | <img src="./screenshot/homepage-poster-hover.png" alt="Watched List" /> |
<img src="./screenshot/devices-mock.png" alt="Overview" />
| Watched Show Status Change | Movie Details |
| --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| <img src="./screenshot/homepage-poster-change-status.png" alt="Changing Show Status" /> | <img src="./screenshot/content-details-page.png" alt="Content Details Page" /> |
| Homepage | Watched Show Hover |
| -------------------------------------------------------------- | --------------------------------------------------------------------------- |
| <img src="./screenshot/homepage.png?v=2" alt="Watched List" /> | <img src="./screenshot/homepage-poster-hover.png?v=2" alt="Watched List" /> |
| User Profile | Discover |
| ------------------------------------------------------------------- | ---------------------------------------------------------------- |
| <img src="./screenshot/user-profile.png" alt="User Profile Page" /> | <img src="./screenshot/discover-page.png" alt="Discover Page" /> |
| Watched Show Status Change | Movie Details |
| ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| <img src="./screenshot/homepage-poster-change-status.png?v=2" alt="Changing Show Status" /> | <img src="./screenshot/content-details-page.png?v=2" alt="Content Details Page" /> |
| Dark Homepage | Dark Content Details |
| ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| <img src="./screenshot/homepage-dark.png" alt="Dark Theme Homepage" /> | <img src="./screenshot/content-details-page-dark.png" alt="Dark Theme Content Page" /> |
| User Profile | Discover |
| ----------------------------------------------------------------------- | -------------------------------------------------------------------- |
| <img src="./screenshot/user-profile.png?v=2" alt="User Profile Page" /> | <img src="./screenshot/discover-page.png?v=2" alt="Discover Page" /> |
| Dark Homepage | Dark Content Details |
| -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| <img src="./screenshot/homepage-dark.png?v=2" alt="Dark Theme Homepage" /> | <img src="./screenshot/content-details-page-dark.png?v=2" alt="Dark Theme Content Page" /> |
</p>
@@ -69,3 +71,7 @@ Thanks to anyone that has made a script or tool for Watcharr. Feel free to add y
If something isn't working for you or you are stuck, [creating an issue](https://github.com/sbondCo/Watcharr/issues/new) is the best way to get help! Every type of issue is accepted, so don't be afraid to ask anything!
You can also [join our space on Matrix](https://matrix.to/#/#watcharr:matrix.org) for support.
# Contributing
Please continue to our [contributing guide](CONTRIBUTING.md).
+1 -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."
}
}
+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 -8640
View File
File diff suppressed because it is too large Load Diff
+40 -39
View File
@@ -1,41 +1,42 @@
{
"name": "watcharr",
"version": "1.44.2",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write .",
"server": "cd ./server && MODE=DEV go run ."
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.2.3",
"@sveltejs/kit": "^2.5.27",
"@types/papaparse": "^5.3.14",
"@typescript-eslint/eslint-plugin": "^7.14.1",
"@typescript-eslint/parser": "^7.8.0",
"@vite-pwa/sveltekit": "^0.5.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.43.0",
"prettier": "^3.3.2",
"prettier-plugin-svelte": "^3.2.6",
"svelte": "^4.2.19",
"svelte-check": "^4.0.2",
"svelte-eslint-parser": "^0.41.0",
"svelte-preprocess": "^6.0.2",
"tslib": "^2.7.0",
"typescript": "^5.5.4",
"vite": "^5.4.6"
},
"type": "module",
"dependencies": {
"axios": "^1.7.4",
"blurhash": "^2.0.5",
"papaparse": "^5.4.1"
}
"name": "watcharr",
"version": "2.0.1",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"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

+4 -1
View File
@@ -26,9 +26,11 @@ import (
type UserType uint8
var (
// Assume watcharr user if none of these...
WATCHARR_USER UserType = 0
JELLYFIN_USER UserType = 1
PLEX_USER UserType = 2
// Registered via trusted header auth
PROXY_USER UserType = 3
)
// User Perms
@@ -160,6 +162,7 @@ type AvailableAuthProvidersResponse struct {
SignupEnabled bool `json:"signupEnabled"`
IsInSetup bool `json:"isInSetup"`
UseEmby bool `json:"useEmby"`
HeaderAuthAutoLogin bool `json:"headerAuthAutoLogin"`
}
type ArgonParams struct {
+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
}
+64
View File
@@ -0,0 +1,64 @@
package main
import (
"log/slog"
"reflect"
"strconv"
"github.com/robfig/go-cache"
)
// Extension of the `.Get` method for `go-cache`.
// This method will simplify our usage so we don't need
// to type assert everywhere, this method will handle
// everything related to getting the value from cache.
// Returns `true` if `rv` was set to the cached value.
// Returns `false` if we couldn't get anything from cache.
func GetCache(c *cache.Cache, k string, rv any) bool {
if val, found := c.Get(k); found {
v := reflect.ValueOf(rv)
if v.Type().Kind() == reflect.Ptr && v.Elem().CanSet() {
v.Elem().Set(reflect.ValueOf(val))
slog.Debug("cachefunc: Cache found.", "key", k)
return true
}
slog.Error("cachefunc: Cache not set", "key", k)
return false
}
slog.Debug("cachefunc: Cache not found", "key", k)
return false
}
// Create a cache key for our in-mem cache.
//
// `name` should be the name of the function response we are caching.
//
// `...u` can be any amount of values that will make this key unique.
// Currently supports types:
// - `string`
// - `map[string]string`
// - `int`
func CreateCacheKey(name string, u ...any) string {
str := name
appnd := func(s string) {
str += "-" + s
}
for _, v := range u {
switch vv := v.(type) {
case string:
appnd(vv)
case map[string]string:
for k, e := range vv {
appnd(k + "_" + e)
}
case int:
appnd(strconv.Itoa(vv))
default:
// This should never happen, but incase of unknown
// value passed, hopefully this should make it easier
// to catch in logs.
str = str + "KEYTYPEERR"
}
}
return str
}
+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
+114 -14
View File
@@ -8,7 +8,7 @@ import (
"strconv"
"time"
"github.com/gin-contrib/cache/persistence"
"github.com/robfig/go-cache"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
@@ -18,9 +18,12 @@ type ContentType string
const (
MOVIE ContentType = "movie"
SHOW ContentType = "tv"
// Show episode
SHOW_EPISODE ContentType = "tv_episode"
)
var ContentStore = persistence.NewInMemoryStore(time.Hour * 24)
// inmemory content cache
var ContentStore = cache.New(time.Hour*24, time.Minute)
// For storing cached content, so we can serve the basic local data for watched list to work
type Content struct {
@@ -248,11 +251,17 @@ func searchContent(query string, pageNum int) (TMDBSearchMultiResponse, error) {
if pageNum == 0 {
pageNum = 1
}
cacheKey := CreateCacheKey("searchContent", query, pageNum)
if GetCache(ContentStore, cacheKey, &resp) {
slog.Debug("searchContent: Returning cache.")
return *resp, nil
}
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")
}
ContentStore.Set(cacheKey, resp, time.Hour*24)
return *resp, nil
}
@@ -261,6 +270,11 @@ func searchMovies(query string, pageNum int) (TMDBSearchMoviesResponse, error) {
if pageNum == 0 {
pageNum = 1
}
cacheKey := CreateCacheKey("searchMovies", query, pageNum)
if GetCache(ContentStore, cacheKey, &resp) {
slog.Debug("searchMovies: Returning cache.")
return *resp, nil
}
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())
@@ -269,6 +283,7 @@ func searchMovies(query string, pageNum int) (TMDBSearchMoviesResponse, error) {
for i := range resp.Results {
resp.Results[i].MediaType = "movie"
}
ContentStore.Set(cacheKey, resp, time.Hour*24)
return *resp, nil
}
@@ -277,6 +292,11 @@ func searchTv(query string, pageNum int) (TMDBSearchShowsResponse, error) {
if pageNum == 0 {
pageNum = 1
}
cacheKey := CreateCacheKey("searchTv", query, pageNum)
if GetCache(ContentStore, cacheKey, &resp) {
slog.Debug("searchTv: Returning cache.")
return *resp, nil
}
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())
@@ -285,6 +305,7 @@ func searchTv(query string, pageNum int) (TMDBSearchShowsResponse, error) {
for i := range resp.Results {
resp.Results[i].MediaType = "tv"
}
ContentStore.Set(cacheKey, resp, time.Hour*24)
return *resp, nil
}
@@ -293,7 +314,10 @@ func searchPeople(query string, pageNum int) (TMDBSearchPeopleResponse, error) {
if pageNum == 0 {
pageNum = 1
}
err := tmdbRequest("/search/person", map[string]string{"query": query, "page": strconv.Itoa(pageNum)}, &resp)
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")
@@ -304,8 +328,42 @@ func searchPeople(query string, pageNum int) (TMDBSearchPeopleResponse, error) {
return *resp, nil
}
// Search for content by an external id (imdb, etc).
// Defaults to imdb if no source if provided (probably most common).
func searchByExternalId(id string, source string) (TMDBSearchMultiResponse, error) {
resp := new(TMDBFindByExternalIdResponse)
if source == "" {
source = "imdb"
}
err := tmdbRequest("/find/"+id, map[string]string{"external_source": source + "_id"}, &resp)
if err != nil {
slog.Error("Failed to complete find/external_id request!", "error", err.Error())
return TMDBSearchMultiResponse{}, errors.New("failed to complete find/external_id request")
}
comb := []TMDBSearchMultiResults{}
comb = append(comb, resp.MovieResults...)
comb = append(comb, resp.TvResults...)
comb = append(comb, resp.PersonResults...)
comb = append(comb, resp.TvSeasonResults...)
comb = append(comb, resp.TvEpisodeResults...)
return TMDBSearchMultiResponse{TMDBSearchResponse: TMDBSearchResponse[TMDBSearchMultiResults]{
Results: comb,
TMDBPageFields: TMDBPageFields{
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)
cacheKey := CreateCacheKey("movieDetails", id, country, rParams)
if GetCache(ContentStore, cacheKey, &resp) {
slog.Debug("movieDetails: Returning cache.")
return *resp, nil
}
err := tmdbRequest("/movie/"+id, rParams, &resp)
if err != nil {
slog.Error("Failed to complete movie details request!", "error", err.Error())
@@ -313,6 +371,7 @@ func movieDetails(db *gorm.DB, id string, country string, rParams map[string]str
}
transformProviders(&resp.WatchProviders, country)
go cacheContentMovie(db, *resp, true)
ContentStore.Set(cacheKey, resp, time.Hour*24)
return *resp, nil
}
@@ -326,8 +385,18 @@ func movieCredits(id string) (TMDBContentCredits, error) {
return *resp, nil
}
func tvDetails(db *gorm.DB, id string, country string, rParams map[string]string) (TMDBShowDetails, error) {
func tvDetails(
db *gorm.DB,
id string,
country string,
rParams map[string]string,
) (TMDBShowDetails, error) {
cacheKey := CreateCacheKey("tvDetails", id, country, rParams)
resp := new(TMDBShowDetails)
if GetCache(ContentStore, cacheKey, &resp) {
slog.Debug("tvDetails: Returning cache.")
return *resp, nil
}
err := tmdbRequest("/tv/"+id, rParams, &resp)
if err != nil {
slog.Error("Failed to complete tv details request!", "error", err.Error())
@@ -335,6 +404,7 @@ func tvDetails(db *gorm.DB, id string, country string, rParams map[string]string
}
transformProviders(&resp.WatchProviders, country)
go cacheContentTv(db, *resp, true)
ContentStore.Set(cacheKey, resp, time.Hour*24)
return *resp, nil
}
@@ -350,13 +420,9 @@ func tvCredits(id string) (TMDBContentCredits, error) {
// This method is manually cached, so it can be easily used in other places (on the server) with cache benefits
func seasonDetails(tvId string, seasonNumber string) (TMDBSeasonDetails, error) {
var cacheKey = "contentstore-seasondetails-" + tvId + "-" + seasonNumber
cacheKey := CreateCacheKey("seasonDetails", tvId, seasonNumber)
resp := new(TMDBSeasonDetails)
if err := ContentStore.Get(cacheKey, &resp); err != nil {
if err != persistence.ErrCacheMiss {
slog.Error("seasonDetails: Cache failed for some reason", "error", err)
}
} else {
if GetCache(ContentStore, cacheKey, &resp) {
slog.Debug("seasonDetails: Returning cache.")
return *resp, nil
}
@@ -365,9 +431,7 @@ func seasonDetails(tvId string, seasonNumber string) (TMDBSeasonDetails, error)
slog.Error("seasonDetails: Failed to complete season details request!", "error", err.Error())
return TMDBSeasonDetails{}, errors.New("failed to complete season details request")
}
if err := ContentStore.Set(cacheKey, resp, time.Hour*24); err != nil {
slog.Error("seasonDetails: Failed to set cache!", "error", err)
}
ContentStore.Set(cacheKey, resp, time.Hour*24)
return *resp, nil
}
@@ -392,56 +456,92 @@ func personCredits(id string) (TMDBPersonCombinedCredits, error) {
}
func discoverMovies() (TMDBDiscoverMovies, error) {
cacheKey := CreateCacheKey("discoverMovies")
resp := new(TMDBDiscoverMovies)
if GetCache(ContentStore, cacheKey, &resp) {
slog.Debug("discoverMovies: Returning cache.")
return *resp, nil
}
err := tmdbRequest("/discover/movie", map[string]string{"page": "1"}, &resp)
if err != nil {
slog.Error("Failed to complete discover movies request!", "error", err.Error())
return TMDBDiscoverMovies{}, errors.New("failed to complete discover movies request")
}
ContentStore.Set(cacheKey, resp, time.Hour*24)
return *resp, nil
}
func discoverTv() (TMDBDiscoverShows, error) {
cacheKey := CreateCacheKey("discoverTv")
resp := new(TMDBDiscoverShows)
if GetCache(ContentStore, cacheKey, &resp) {
slog.Debug("discoverTv: Returning cache.")
return *resp, nil
}
err := tmdbRequest("/discover/tv", map[string]string{"page": "1"}, &resp)
if err != nil {
slog.Error("Failed to complete discover tv request!", "error", err.Error())
return TMDBDiscoverShows{}, errors.New("failed to complete discover tv request")
}
ContentStore.Set(cacheKey, resp, time.Hour*24)
return *resp, nil
}
func allTrending() (TMDBTrendingAll, error) {
cacheKey := CreateCacheKey("allTrending")
resp := new(TMDBTrendingAll)
if GetCache(ContentStore, cacheKey, &resp) {
slog.Debug("allTrending: Returning cache.")
return *resp, nil
}
err := tmdbRequest("/trending/all/day", map[string]string{}, &resp)
if err != nil {
slog.Error("Failed to complete all trending request!", "error", err.Error())
return TMDBTrendingAll{}, errors.New("failed to complete all trending request")
}
ContentStore.Set(cacheKey, resp, time.Hour*24)
return *resp, nil
}
func upcomingMovies() (TMDBUpcomingMovies, error) {
cacheKey := CreateCacheKey("upcomingMovies")
resp := new(TMDBUpcomingMovies)
if GetCache(ContentStore, cacheKey, &resp) {
slog.Debug("upcomingMovies: Returning cache.")
return *resp, nil
}
err := tmdbRequest("/movie/upcoming", map[string]string{"page": "1"}, &resp)
if err != nil {
slog.Error("Failed to complete upcoming movies request!", "error", err.Error())
return TMDBUpcomingMovies{}, errors.New("failed to complete upcoming movies request")
}
ContentStore.Set(cacheKey, resp, time.Hour*24)
return *resp, nil
}
// Theres no upcoming endpoint for tv ;( - using discover with future dates
func upcomingTv() (TMDBUpcomingShows, error) {
cacheKey := CreateCacheKey("upcomingTv")
resp := new(TMDBUpcomingShows)
if GetCache(ContentStore, cacheKey, &resp) {
slog.Debug("upcomingTv: Returning cache.")
return *resp, nil
}
dFmt := "2006-01-02"
mind := time.Now().Format(dFmt)
maxd := time.Now().AddDate(0, 0, 15).Format(dFmt)
err := tmdbRequest("/discover/tv", map[string]string{"page": "1", "first_air_date.gte": mind, "first_air_date.lte": maxd, "sort_by": "popularity.desc", "with_type": "2|3"}, &resp)
err := tmdbRequest("/discover/tv", map[string]string{
"page": "1",
"first_air_date.gte": mind,
"first_air_date.lte": maxd,
"sort_by": "popularity.desc",
"with_type": "2|3",
}, &resp)
if err != nil {
slog.Error("Failed to complete upcoming tv request!", "error", err.Error())
return TMDBUpcomingShows{}, errors.New("failed to complete upcoming tv request")
}
ContentStore.Set(cacheKey, resp, time.Hour*24)
return *resp, nil
}
+313
View File
@@ -0,0 +1,313 @@
// All the functions that help us turn a TMDB response struct
// into one that will also include Watched data.
// This process is very verbose. As far as I am aware, golangs
// generics are not mature (powerful) enough to support us doing
// this all with one function.
//
// TODO When possible look at turning all these funcs into one that
// is reuable for any tmdb search response type.
//
// Each function will basically perform these simple steps:
// 1. Repackage tmdb response so we can add Watched data to
// 2. Get all watched data for the tmdb results
// 4. Add any watched data to our new *WithWatched struct
package main
import (
"log/slog"
"gorm.io/gorm"
)
func searchContentAddWatched(
db *gorm.DB,
userId uint,
content TMDBSearchMultiResponse,
) TMDBSearchMultiResponseWithWatched {
withWatchedResp := TMDBSearchMultiResponseWithWatched{}
withWatchedResp.TMDBSearchResponse.TMDBPageFields = content.TMDBPageFields
contentIdAndTypePairs := [][]any{}
for _, v := range content.Results {
withWatchedResp.Results = append(withWatchedResp.Results, TMDBSearchMultiResultsWithWatched{
TMDBSearchMultiResults: v,
})
contentIdAndTypePairs = append(contentIdAndTypePairs, []any{
v.ID,
ContentType(v.MediaType),
})
}
if ws, err := getWatchedItemsByTmdbIds(db, userId, contentIdAndTypePairs); err == nil {
for _, v := range ws {
for i, vv := range withWatchedResp.Results {
if vv.ID == v.Content.TmdbID && vv.MediaType == string(v.Content.Type) {
withWatchedResp.Results[i].WatchedAddedToContent.Watched = &v
}
}
}
} else {
// TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
slog.Error("Getting watched items by tmdbIds failed!")
}
return withWatchedResp
}
func searchMoviesAddWatched(
db *gorm.DB,
userId uint,
content TMDBSearchMoviesResponse,
) TMDBSearchMoviesResponseWithWatched {
withWatchedResp := TMDBSearchMoviesResponseWithWatched{}
withWatchedResp.TMDBSearchResponse.TMDBPageFields = content.TMDBPageFields
contentIdAndTypePairs := [][]any{}
for _, v := range content.Results {
withWatchedResp.Results = append(withWatchedResp.Results, TMDBSearchMovieResultWithWatched{
TMDBSearchMovieResult: v,
})
contentIdAndTypePairs = append(contentIdAndTypePairs, []any{
v.ID,
ContentType(v.MediaType),
})
}
if ws, err := getWatchedItemsByTmdbIds(db, userId, contentIdAndTypePairs); err == nil {
for _, v := range ws {
for i, vv := range withWatchedResp.Results {
if vv.ID == v.Content.TmdbID && vv.MediaType == string(v.Content.Type) {
withWatchedResp.Results[i].WatchedAddedToContent.Watched = &v
}
}
}
} else {
// TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
slog.Error("Getting watched items by tmdbIds failed!")
}
return withWatchedResp
}
func searchTvAddWatched(
db *gorm.DB,
userId uint,
content TMDBSearchShowsResponse,
) TMDBSearchShowsResponseWithWatched {
withWatchedResp := TMDBSearchShowsResponseWithWatched{}
withWatchedResp.TMDBSearchResponse.TMDBPageFields = content.TMDBPageFields
contentIdAndTypePairs := [][]any{}
for _, v := range content.Results {
withWatchedResp.Results = append(withWatchedResp.Results, TMDBSearchShowsResultWithWatched{
TMDBSearchShowsResult: v,
})
contentIdAndTypePairs = append(contentIdAndTypePairs, []any{
v.ID,
ContentType(v.MediaType),
})
}
if ws, err := getWatchedItemsByTmdbIds(db, userId, contentIdAndTypePairs); err == nil {
for _, v := range ws {
for i, vv := range withWatchedResp.Results {
if vv.ID == v.Content.TmdbID && vv.MediaType == string(v.Content.Type) {
withWatchedResp.Results[i].WatchedAddedToContent.Watched = &v
}
}
}
} else {
// TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
slog.Error("Getting watched items by tmdbIds failed!")
}
return withWatchedResp
}
func tvDetailsAddWatched(
db *gorm.DB,
userId uint,
content TMDBShowDetails,
) TMDBShowDetailsWithWatched {
withWatchedResp := TMDBShowDetailsWithWatched{}
withWatchedResp.TMDBShowDetailsBase = content.TMDBShowDetailsBase
// Append watched list entry if exists
if watchedEntry, err := getWatchedItemByTmdbId(db, userId, uint(content.ID), SHOW); err != nil {
if err != gorm.ErrRecordNotFound {
withWatchedResp.FailedToGetWatched = true
}
} else {
withWatchedResp.Watched = &watchedEntry
}
// Add similar content with any watched entries
similarContentIdAndTypePairs := [][]any{}
for _, v := range content.Similar.Results {
withWatchedResp.Similar.Results = append(withWatchedResp.Similar.Results, TMDBShowSimilarResultWithWatched{
TMDBShowSimilarResult: v,
})
similarContentIdAndTypePairs = append(similarContentIdAndTypePairs, []any{
v.ID,
SHOW,
})
}
if ws, err := getWatchedItemsByTmdbIds(db, userId, similarContentIdAndTypePairs); err == nil {
for _, v := range ws {
for i, vv := range withWatchedResp.Similar.Results {
if vv.ID == v.Content.TmdbID && string(SHOW) == string(v.Content.Type) {
withWatchedResp.Similar.Results[i].WatchedAddedToContent.Watched = &v
}
}
}
} else {
// TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
slog.Error("Getting watched items by tmdbIds failed!")
}
return withWatchedResp
}
func allTrendingAddWatched(
db *gorm.DB,
userId uint,
content TMDBTrendingAll,
) TMDBTrendingAllWithWatched {
withWatchedResp := TMDBTrendingAllWithWatched{}
contentIdAndTypePairs := [][]any{}
for _, v := range content.Results {
withWatchedResp.Results = append(withWatchedResp.Results, TMDBTrendingAllResultWithWatched{
TMDBTrendingAllResult: v,
})
contentIdAndTypePairs = append(contentIdAndTypePairs, []any{
v.ID,
ContentType(v.MediaType),
})
}
if ws, err := getWatchedItemsByTmdbIds(db, userId, contentIdAndTypePairs); err == nil {
for _, v := range ws {
for i, vv := range withWatchedResp.Results {
if vv.ID == v.Content.TmdbID && vv.MediaType == string(v.Content.Type) {
withWatchedResp.Results[i].WatchedAddedToContent.Watched = &v
}
}
}
} else {
// TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
slog.Error("Getting watched items by tmdbIds failed!")
}
return withWatchedResp
}
func discoverTvAddWatched(
db *gorm.DB,
userId uint,
content TMDBDiscoverShows,
) TMDBDiscoverShowsWithWatched {
withWatchedResp := TMDBDiscoverShowsWithWatched{}
contentIdAndTypePairs := [][]any{}
for _, v := range content.Results {
withWatchedResp.Results = append(withWatchedResp.Results, TMDBDiscoverShowsResultWithWatched{
TMDBDiscoverShowsResult: v,
})
contentIdAndTypePairs = append(contentIdAndTypePairs, []any{
v.ID,
SHOW,
})
}
if ws, err := getWatchedItemsByTmdbIds(db, userId, contentIdAndTypePairs); err == nil {
for _, v := range ws {
for i, vv := range withWatchedResp.Results {
if vv.ID == v.Content.TmdbID && SHOW == v.Content.Type {
withWatchedResp.Results[i].WatchedAddedToContent.Watched = &v
}
}
}
} else {
// TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
slog.Error("Getting watched items by tmdbIds failed!")
}
return withWatchedResp
}
func upcomingTvAddWatched(
db *gorm.DB,
userId uint,
content TMDBUpcomingShows,
) TMDBUpcomingShowsWithWatched {
withWatchedResp := TMDBUpcomingShowsWithWatched{}
contentIdAndTypePairs := [][]any{}
for _, v := range content.Results {
withWatchedResp.Results = append(withWatchedResp.Results, TMDBUpcomingShowsResultWithWatched{
TMDBUpcomingShowsResult: v,
})
contentIdAndTypePairs = append(contentIdAndTypePairs, []any{
v.ID,
SHOW,
})
}
if ws, err := getWatchedItemsByTmdbIds(db, userId, contentIdAndTypePairs); err == nil {
for _, v := range ws {
for i, vv := range withWatchedResp.Results {
if vv.ID == v.Content.TmdbID && SHOW == v.Content.Type {
withWatchedResp.Results[i].WatchedAddedToContent.Watched = &v
}
}
}
} else {
// TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
slog.Error("Getting watched items by tmdbIds failed!")
}
return withWatchedResp
}
func discoverMoviesAddWatched(
db *gorm.DB,
userId uint,
content TMDBDiscoverMovies,
) TMDBDiscoverMoviesWithWatched {
withWatchedResp := TMDBDiscoverMoviesWithWatched{}
contentIdAndTypePairs := [][]any{}
for _, v := range content.Results {
withWatchedResp.Results = append(withWatchedResp.Results, TMDBDiscoverMoviesResultWithWatched{
TMDBDiscoverMoviesResult: v,
})
contentIdAndTypePairs = append(contentIdAndTypePairs, []any{
v.ID,
MOVIE,
})
}
if ws, err := getWatchedItemsByTmdbIds(db, userId, contentIdAndTypePairs); err == nil {
for _, v := range ws {
for i, vv := range withWatchedResp.Results {
if vv.ID == v.Content.TmdbID && MOVIE == v.Content.Type {
withWatchedResp.Results[i].WatchedAddedToContent.Watched = &v
}
}
}
} else {
// TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
slog.Error("Getting watched items by tmdbIds failed!")
}
return withWatchedResp
}
func upcomingMoviesAddWatched(
db *gorm.DB,
userId uint,
content TMDBUpcomingMovies,
) TMDBUpcomingMoviesWithWatched {
withWatchedResp := TMDBUpcomingMoviesWithWatched{}
contentIdAndTypePairs := [][]any{}
for _, v := range content.Results {
withWatchedResp.Results = append(withWatchedResp.Results, TMDBUpcomingMoviesResultWithWatched{
TMDBUpcomingMoviesResult: v,
})
contentIdAndTypePairs = append(contentIdAndTypePairs, []any{
v.ID,
MOVIE,
})
}
if ws, err := getWatchedItemsByTmdbIds(db, userId, contentIdAndTypePairs); err == nil {
for _, v := range ws {
for i, vv := range withWatchedResp.Results {
if vv.ID == v.Content.TmdbID && MOVIE == v.Content.Type {
withWatchedResp.Results[i].WatchedAddedToContent.Watched = &v
}
}
}
} else {
// TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
slog.Error("Getting watched items by tmdbIds failed!")
}
return withWatchedResp
}
+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
+18 -18
View File
@@ -4,53 +4,53 @@ 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.11.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.27.0
golang.org/x/crypto v0.32.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gorm.io/driver/sqlite v1.5.6
gorm.io/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/go-playground/validator/v10 v10.23.0 // indirect
github.com/goccy/go-json v0.10.4 // indirect
github.com/gomodule/redigo v1.9.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/jonboulle/clockwork v0.4.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/memcachier/mc/v3 v3.0.3 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/arch v0.12.0 // indirect
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sys v0.25.0 // indirect
golang.org/x/text v0.18.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
golang.org/x/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
)
+38 -42
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,32 +14,32 @@ 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.11.0 h1:IOowNA6SzwdRFnD4/Ol3Kj6G2xKfsoiiGq2Jhhm9bvE=
github.com/go-co-op/gocron/v2 v2.11.0/go.mod h1:xY7bJxGazKam1cz04EebrlP4S9q4iWdiAylMGP3jY9w=
github.com/go-co-op/gocron/v2 v2.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/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=
@@ -55,8 +56,8 @@ github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
@@ -75,8 +76,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
@@ -88,38 +89,34 @@ github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6po
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
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.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/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.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@@ -128,9 +125,8 @@ gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYs
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/sqlite v1.5.6 h1:fO/X46qn5NUEEOZtnjJRWRzZMe8nqJiQ9E+0hi+hKQE=
gorm.io/driver/sqlite v1.5.6/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
gorm.io/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=
+46
View File
@@ -39,6 +39,7 @@ type ImportRequest struct {
WatchedEpisodes []WatchedEpisode `json:"watchedEpisodes"`
WatchedSeason []WatchedSeason `json:"watchedSeasons"`
Tags []TagAddRequest `json:"tags"`
ImdbID string `json:"imdbId"`
}
type ImportResponse struct {
@@ -71,6 +72,51 @@ func importContent(db *gorm.DB, userId uint, ar ImportRequest) (ImportResponse,
return successfulImport(db, userId, cr.ID, SHOW, ar)
}
}
// If imdb id passed, attempt to get content with it
if ar.ImdbID != "" && (ar.Type == MOVIE || ar.Type == SHOW || ar.Type == SHOW_EPISODE) {
if imdbResp, err := searchByExternalId(ar.ImdbID, "imdb"); err == nil {
if len(imdbResp.Results) == 1 {
onlyResult := imdbResp.Results[0]
if onlyResult.MediaType == string(MOVIE) || onlyResult.MediaType == string(SHOW) {
// Will only be one result
slog.Debug("import: importing imdb match", "imdb_id", ar.ImdbID, "tmdb_id_thatwasfound", onlyResult.ID)
return successfulImport(db, userId, onlyResult.ID, ContentType(onlyResult.MediaType), ar)
} else if onlyResult.MediaType == string(SHOW_EPISODE) {
// Handle episodes differently.
// Clients must import tv episodes last so that the actual show can be imported first
// will fail if watched entry isn't imported first or already exists (we won't make it here).
w, e := getWatchedItemByTmdbId(db, userId, uint(onlyResult.ShowId), "tv")
if e != nil {
slog.Error("import: imdb match: Failed to add watched episode (failed to find watched item, it must exist!).", "rq", ar, "error", err)
return ImportResponse{Type: IMPORT_FAILED}, nil
}
ws, err := addWatchedEpisodes(db, userId, WatchedEpisodeAddRequest{
WatchedID: w.ID,
SeasonNumber: onlyResult.SeasonNumber,
EpisodeNumber: onlyResult.EpisodeNumber,
Status: ar.Status,
Rating: int8(ar.Rating),
addActivityDate: *ar.RatingCustomDate,
})
if err != nil {
slog.Error("import: imdb match: Failed to add watched episode.", "rq", ar, "error", err)
return ImportResponse{Type: IMPORT_FAILED}, nil
} else {
w.WatchedEpisodes = ws.WatchedEpisodes
return ImportResponse{Type: IMPORT_SUCCESS, WatchedEntry: w}, nil
}
} else {
slog.Error("import: imdb match has unsupported media type.", "media_type", imdbResp.Results[0].MediaType, "rq", ar)
return ImportResponse{Type: IMPORT_FAILED}, nil
}
} else {
// Content in tmdb may just be missing a related imdb id, so allow search to continue by name below.
slog.Warn("import: No results for search by imdb id.. search will contiue by content name.", "rq", ar)
}
} else {
slog.Warn("import: Failed to get content by imdb id.. search will contiue by content name.", "rq", ar)
}
}
// tmdbId not passed.. search for the content by name.
sr, err := searchContent(ar.Name, 1)
if err != nil {
+50
View File
@@ -2,6 +2,8 @@ package main
import (
"log/slog"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
@@ -29,3 +31,51 @@ func WhereaboutsRequired() gin.HandlerFunc {
c.Next()
}
}
// Pagination middleware
// Reusable way to get pagination values.
// If force=true then will default to page=1, otherwise
// assume pagination is disabled when query params not present.
func PaginatedRequest(force bool) gin.HandlerFunc {
return func(c *gin.Context) {
pageStr := c.Query("p")
page := 0
if pageStr == "" && force {
page = 1
slog.Debug("PossiblyPaginated: Pagination is forced, but no page was provided. Using default.")
} else if pageStr == "" {
slog.Debug("PossiblyPaginated: Pagination is disabled. No parameter provided.")
c.Set("paginationEnabled", false)
c.Next()
return
} else {
num, err := strconv.Atoi(pageStr)
if err != nil {
slog.Error("PossiblyPaginated: Query paramater 'p' was not parseable as an int", "err", err)
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "query param 'p' must be a number"})
return
}
page = num
}
limitStr := c.Query("l")
limit := 40
if limitStr != "" {
num, err := strconv.Atoi(limitStr)
if err != nil {
slog.Error("PossiblyPaginated: Query paramater 'l' was not parseable as an int", "err", err)
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "query param 'l' must be a number"})
return
}
limit = num
} else {
slog.Debug("PossiblyPaginated: Using default limit.")
}
slog.Debug("PossiblyPaginated: middleware hit", "page", page, "page_limit", limit)
c.Set("paginationEnabled", true)
c.Set("paginationParams", PaginationParams{
Page: page,
Limit: limit,
})
c.Next()
}
}
+54
View File
@@ -0,0 +1,54 @@
package main
import (
"log/slog"
"math"
"gorm.io/gorm"
)
// Parameters that the paginator uses to
// know what to return.
type PaginationParams struct {
// Page limit (max amount of items to get for each page).
Limit int `json:"limit"`
// Page number.
Page int `json:"page"`
}
// Pagination response struct.
type PaginationResponse[T interface{}] struct {
PaginationParams
// Max amount of pages we can produce from total_results
TotalPages int `json:"totalPages"`
TotalResults int64 `json:"totalResults"`
Results []T `json:"results"`
}
// Call when finished with PaginationResponse, before returning to user.
// Performs final calculations.
func (r *PaginationResponse[T]) Finished(p PaginationParams) {
r.PaginationParams = p
if r.TotalResults != 0 && r.Limit != 0 {
r.TotalPages = int(math.Ceil(float64(r.TotalResults) / float64(r.Limit)))
} else {
slog.Warn(
"PaginationResponse->Finished: TotalPages not calculated.",
"total_results", r.TotalResults,
"limit", r.Limit,
)
}
}
// Pagination gorm scope.
// Pass in `PaginationParams` and the `PaginationResponse` will be filled out,
// just fill out the `Results` manually.
func Paginate[T interface{}](
p PaginationParams,
r *PaginationResponse[T],
) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
offset := (p.Page - 1) * p.Limit
return db.Offset(offset).Limit(p.Limit)
}
}
+230 -98
View File
@@ -3,6 +3,7 @@ package main
import (
"log/slog"
"net/http"
"net/url"
"strconv"
"time"
@@ -79,70 +80,68 @@ func (b *BaseRouter) addContentRoutes() {
exp := time.Hour * 24
// Search for content
content.GET("/search/multi/:query", cache.CachePage(b.ms, exp, func(c *gin.Context) {
if c.Param("query") == "" {
content.GET("/search/multi", PaginatedRequest(true), func(c *gin.Context) {
userId := c.MustGet("userId").(uint)
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 := searchContent(c.Param("query"), pageNum)
pp := c.MustGet("paginationParams").(PaginationParams)
content, err := searchContent(query, pp.Page)
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, content)
}))
withWatchedResp := searchContentAddWatched(b.db, userId, content)
c.JSON(http.StatusOK, withWatchedResp)
})
// Search for movies
content.GET("/search/movie/:query", cache.CachePage(b.ms, exp, func(c *gin.Context) {
if c.Param("query") == "" {
content.GET("/search/movie", PaginatedRequest(true), func(c *gin.Context) {
userId := c.MustGet("userId").(uint)
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(c.Param("query"), pageNum)
pp := c.MustGet("paginationParams").(PaginationParams)
content, err := searchMovies(query, pp.Page)
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, content)
}))
withWatchedResp := searchMoviesAddWatched(b.db, userId, content)
c.JSON(http.StatusOK, withWatchedResp)
})
// Search for shows
content.GET("/search/tv/:query", cache.CachePage(b.ms, exp, func(c *gin.Context) {
if c.Param("query") == "" {
content.GET("/search/tv", PaginatedRequest(true), func(c *gin.Context) {
userId := c.MustGet("userId").(uint)
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
pp := c.MustGet("paginationParams").(PaginationParams)
content, err := searchTv(query, pp.Page)
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
content, err := searchTv(c.Param("query"), pageNum)
withWatchedResp := searchTvAddWatched(b.db, userId, content)
c.JSON(http.StatusOK, withWatchedResp)
})
// Search for people
content.GET("/search/person", PaginatedRequest(true), 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
}
pp := c.MustGet("paginationParams").(PaginationParams)
content, err := searchPeople(query, pp.Page)
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
@@ -150,23 +149,13 @@ func (b *BaseRouter) addContentRoutes() {
c.JSON(http.StatusOK, content)
}))
// Search for people
content.GET("/search/person/:query", cache.CachePage(b.ms, exp, func(c *gin.Context) {
if c.Param("query") == "" {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "a query was not provided"})
// 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
}
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(c.Param("query"), pageNum)
content, err := searchByExternalId(c.Param("id"), c.Param("source"))
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
@@ -203,18 +192,24 @@ func (b *BaseRouter) addContentRoutes() {
}))
// Get tv details (for tv page)
content.GET("/tv/:id", WhereaboutsRequired(), cache.CachePage(b.ms, exp, func(c *gin.Context) {
if c.Param("id") == "" {
c.Status(400)
return
}
content, err := tvDetails(b.db, c.Param("id"), c.MustGet("userCountry").(string), map[string]string{"append_to_response": "videos,watch/providers,similar,external_ids,keywords"})
content.GET("/tv/:id", WhereaboutsRequired(), func(c *gin.Context) {
userId := c.MustGet("userId").(uint)
// 1. Get details
content, err := tvDetails(
b.db,
c.Param("id"),
c.MustGet("userCountry").(string),
map[string]string{
"append_to_response": "videos,watch/providers,similar,external_ids,keywords",
},
)
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, content)
}))
withWatchedResp := tvDetailsAddWatched(b.db, userId, content)
c.JSON(http.StatusOK, withWatchedResp)
})
// Get tv cast
content.GET("/tv/:id/credits", cache.CachePage(b.ms, exp, func(c *gin.Context) {
@@ -296,54 +291,64 @@ func (b *BaseRouter) addContentRoutes() {
}))
// Discover movies
content.GET("/discover/movies", cache.CachePage(b.ms, exp, func(c *gin.Context) {
content.GET("/discover/movies", func(c *gin.Context) {
userId := c.MustGet("userId").(uint)
content, err := discoverMovies()
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, content)
}))
withWatchedResp := discoverMoviesAddWatched(b.db, userId, content)
c.JSON(http.StatusOK, withWatchedResp)
})
// Discover shows
content.GET("/discover/tv", cache.CachePage(b.ms, exp, func(c *gin.Context) {
content.GET("/discover/tv", func(c *gin.Context) {
userId := c.MustGet("userId").(uint)
content, err := discoverTv()
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, content)
}))
withWatchedResp := discoverTvAddWatched(b.db, userId, content)
c.JSON(http.StatusOK, withWatchedResp)
})
// Get all trending (movies, tv, people)
content.GET("/trending", cache.CachePage(b.ms, exp, func(c *gin.Context) {
content.GET("/trending", func(c *gin.Context) {
userId := c.MustGet("userId").(uint)
content, err := allTrending()
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, content)
}))
withWatchedResp := allTrendingAddWatched(b.db, userId, content)
c.JSON(http.StatusOK, withWatchedResp)
})
// Upcoming Movies
content.GET("/upcoming/movies", cache.CachePage(b.ms, exp, func(c *gin.Context) {
content.GET("/upcoming/movies", func(c *gin.Context) {
userId := c.MustGet("userId").(uint)
content, err := upcomingMovies()
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, content)
}))
withWatchedResp := upcomingMoviesAddWatched(b.db, userId, content)
c.JSON(http.StatusOK, withWatchedResp)
})
// Upcoming Tv
content.GET("/upcoming/tv", cache.CachePage(b.ms, exp, func(c *gin.Context) {
content.GET("/upcoming/tv", func(c *gin.Context) {
userId := c.MustGet("userId").(uint)
content, err := upcomingTv()
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, content)
}))
withWatchedResp := upcomingTvAddWatched(b.db, userId, content)
c.JSON(http.StatusOK, withWatchedResp)
})
// Available regions for watch providers
content.GET("/regions", func(c *gin.Context) {
@@ -375,12 +380,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
@@ -443,9 +468,33 @@ func (b *BaseRouter) addGameRoutes() {
func (b *BaseRouter) addWatchedRoutes() {
watched := b.rg.Group("/watched").Use(AuthRequired(nil))
watched.GET("", func(c *gin.Context) {
watched.GET("", PaginatedRequest(false), func(c *gin.Context) {
isPaginated := c.MustGet("paginationEnabled").(bool)
userId := c.MustGet("userId").(uint)
c.JSON(http.StatusOK, getWatched(b.db, userId))
if isPaginated {
pp := c.MustGet("paginationParams").(PaginationParams)
wp := WatchedGetPageRequest{
// Defaults..
Sort: watchedSortDateAdded,
SortDir: sortAscending,
}
if err := c.ShouldBind(&wp); err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "failed to get request parameters"})
return
}
if wp, err := getWatchedPage(b.db, userId, pp, wp); err == nil {
c.JSON(http.StatusOK, wp)
} else {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "failed to get page"})
}
return
}
// Non paginated response (doesn't support sorting/filtering atm)
if w, err := getWatched(b.db, userId); err == nil {
c.JSON(http.StatusOK, w)
} else {
c.JSON(http.StatusForbidden, ErrorResponse{Error: "failed"})
}
})
watched.GET(":id/:username", func(c *gin.Context) {
@@ -742,6 +791,28 @@ func (b *BaseRouter) addAuthRoutes() {
c.Status(400)
})
// Proxy Login
auth.POST("/proxy", func(c *gin.Context) {
var user User
if !trustedHeaderAuthIsEnabled() {
slog.Error("ProxyLogin: SSO has not been configured.")
c.JSON(http.StatusForbidden, ErrorResponse{Error: "proxy authentication is disabled"})
return
}
user.Username = c.GetHeader(Config.HEADER_AUTH.HeaderName)
if user.Username == "" {
slog.Error("ProxyLogin: Authentication header is missing.")
c.JSON(http.StatusForbidden, ErrorResponse{Error: "authentication header missing"})
return
}
response, err := loginTrustedHeaderAuth(&user, b.db)
if err != nil {
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
// Register
auth.POST("/register", func(c *gin.Context) {
var user UserRegisterRequest
@@ -759,24 +830,45 @@ func (b *BaseRouter) addAuthRoutes() {
// Get available auth providers
auth.GET("/available", func(c *gin.Context) {
availableAuthProviders := []string{}
if Config.JELLYFIN_HOST != "" {
availableAuthProviders = append(availableAuthProviders, "jellyfin")
}
if Config.PLEX_HOST != "" && Config.PLEX_MACHINE_ID != "" {
availableAuthProviders = append(availableAuthProviders, "plex")
}
c.JSON(http.StatusOK, &AvailableAuthProvidersResponse{
AvailableAuthProviders: availableAuthProviders,
resp := &AvailableAuthProvidersResponse{
AvailableAuthProviders: []string{},
SignupEnabled: Config.SIGNUP_ENABLED,
IsInSetup: ServerInSetup,
UseEmby: Config.USE_EMBY,
})
}
if Config.JELLYFIN_HOST != "" {
resp.AvailableAuthProviders = append(resp.AvailableAuthProviders, "jellyfin")
}
if Config.PLEX_HOST != "" && Config.PLEX_MACHINE_ID != "" {
resp.AvailableAuthProviders = append(resp.AvailableAuthProviders, "plex")
}
if trustedHeaderAuthIsEnabled() {
resp.AvailableAuthProviders = append(resp.AvailableAuthProviders, "header")
resp.HeaderAuthAutoLogin = Config.HEADER_AUTH.AutoLogin
}
c.JSON(http.StatusOK, resp)
})
// IMPORTANT: Routes below here must be authenticated.
auth.Use(AuthRequired(nil))
{
// Request details for logout process for proxy users.
// Any proxy user can request this for logout.
auth.GET("/proxy_logout_details", func(c *gin.Context) {
if !trustedHeaderAuthIsEnabled() {
slog.Error("GetProxy: SSO has not been configured.")
c.JSON(http.StatusForbidden, ErrorResponse{Error: "proxy authentication is disabled"})
return
}
userType := c.MustGet("userType").(UserType)
if userType != PROXY_USER {
slog.Error("GetProxy: Non proxy user attempted to fetch proxy logout details.")
c.JSON(http.StatusForbidden, ErrorResponse{Error: "you are not a proxy user"})
return
}
c.JSON(http.StatusOK, getTrustedHeaderAuthLogoutDetails())
})
// Request admin token
auth.GET("/admin_token", func(c *gin.Context) {
userId := c.MustGet("userId").(uint)
@@ -932,9 +1024,14 @@ func (b *BaseRouter) addUserRoutes() {
})
// Search users
u.GET("/search/:query", func(c *gin.Context) {
u.GET("/search", func(c *gin.Context) {
userId := c.MustGet("userId").(uint)
response, err := userSearch(b.db, userId, c.Param("query"))
query := c.Query("q")
if query == "" {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "a query was not provided"})
return
}
response, err := userSearch(b.db, userId, query)
if err != nil {
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
return
@@ -1092,18 +1189,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)
+327 -197
View File
@@ -10,24 +10,40 @@ import (
"time"
)
type TMDBSearchMultiResponse struct {
Page int `json:"page"`
Results []TMDBSearchMultiResults `json:"results"`
TotalPages int `json:"total_pages"`
TotalResults int `json:"total_results"`
// Separated from `TMDBSearchResponse` so we can embed it for
// easily assigning all page fields in one.
type TMDBPageFields struct {
Page int `json:"page"`
TotalPages int `json:"total_pages"`
TotalResults int `json:"total_results"`
}
type TMDBSearchResponse[R any] struct {
TMDBPageFields
Results []R `json:"results"`
}
// A common "base" type for search results.
type TMDBSearchResult struct {
// TMDB ID
ID int `json:"id"`
// Media Type (movie, show, person)
// Some requests won't return this value
// (namely any request other than a multi
// type search), but we add it in manually.
MediaType string `json:"media_type"`
}
type TMDBSearchMultiResults struct {
TMDBSearchResult
Adult bool `json:"adult"`
BackdropPath string `json:"backdrop_path"`
ID int `json:"id"`
Title string `json:"title,omitempty"`
OriginalLanguage string `json:"original_language"`
OriginalTitle string `json:"original_title,omitempty"`
Overview string `json:"overview"`
PosterPath string `json:"poster_path"`
ProfilePath string `json:"profile_path"`
MediaType string `json:"media_type"`
GenreIds []int64 `json:"genre_ids"`
Popularity float32 `json:"popularity"`
ReleaseDate string `json:"release_date,omitempty"`
@@ -38,86 +54,132 @@ 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 TMDBSearchMultiResultsWithWatched struct {
TMDBSearchMultiResults
WatchedAddedToContent
}
type TMDBSearchMultiResponse struct {
TMDBSearchResponse[TMDBSearchMultiResults]
}
type TMDBSearchMultiResponseWithWatched struct {
TMDBSearchResponse[TMDBSearchMultiResultsWithWatched]
}
type TMDBSearchMovieResult struct {
TMDBSearchResult
Adult bool `json:"adult"`
BackdropPath string `json:"backdrop_path"`
GenreIds []int `json:"genre_ids"`
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"`
}
type TMDBSearchMovieResultWithWatched struct {
TMDBSearchMovieResult
WatchedAddedToContent
}
type TMDBSearchMoviesResponse struct {
Page int `json:"page"`
Results []struct {
Adult bool `json:"adult"`
BackdropPath string `json:"backdrop_path"`
GenreIds []int `json:"genre_ids"`
ID int `json:"id"`
OriginalLanguage string `json:"original_language"`
OriginalTitle string `json:"original_title"`
Overview string `json:"overview"`
Popularity float64 `json:"popularity"`
PosterPath string `json:"poster_path"`
ReleaseDate string `json:"release_date"`
Title string `json:"title"`
Video bool `json:"video"`
VoteAverage float64 `json:"vote_average"`
VoteCount int `json:"vote_count"`
MediaType string `json:"media_type"` // API req doesn't include this, we will add it in our service.
} `json:"results"`
TotalPages int `json:"total_pages"`
TotalResults int `json:"total_results"`
TMDBSearchResponse[TMDBSearchMovieResult]
}
type TMDBSearchMoviesResponseWithWatched struct {
TMDBSearchResponse[TMDBSearchMovieResultWithWatched]
}
type TMDBSearchShowsResult struct {
TMDBSearchResult
Adult bool `json:"adult"`
BackdropPath string `json:"backdrop_path"`
GenreIds []int `json:"genre_ids"`
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"`
}
type TMDBSearchShowsResultWithWatched struct {
TMDBSearchShowsResult
WatchedAddedToContent
}
type TMDBSearchShowsResponse struct {
Page int `json:"page"`
Results []struct {
Adult bool `json:"adult"`
BackdropPath string `json:"backdrop_path"`
GenreIds []int `json:"genre_ids"`
ID int `json:"id"`
OriginCountry []string `json:"origin_country"`
OriginalLanguage string `json:"original_language"`
OriginalName string `json:"original_name"`
Overview string `json:"overview"`
Popularity float64 `json:"popularity"`
PosterPath string `json:"poster_path"`
FirstAirDate string `json:"first_air_date"`
Name string `json:"name"`
VoteAverage float64 `json:"vote_average"`
VoteCount int `json:"vote_count"`
MediaType string `json:"media_type"` // API req doesn't include this, we will add it in our service.
} `json:"results"`
TotalPages int `json:"total_pages"`
TotalResults int `json:"total_results"`
TMDBSearchResponse[TMDBSearchShowsResult]
}
type TMDBSearchShowsResponseWithWatched struct {
TMDBSearchResponse[TMDBSearchShowsResultWithWatched]
}
type TMDBSearchPeopleResult struct {
TMDBSearchResult
Adult bool `json:"adult"`
Gender int `json:"gender"`
KnownForDepartment string `json:"known_for_department"`
Name string `json:"name"`
OriginalName string `json:"original_name"`
Popularity float64 `json:"popularity"`
ProfilePath string `json:"profile_path"`
KnownFor []struct {
Adult bool `json:"adult"`
BackdropPath string `json:"backdrop_path"`
ID int `json:"id"`
Title string `json:"title"`
OriginalLanguage string `json:"original_language"`
OriginalTitle string `json:"original_title"`
Overview string `json:"overview"`
PosterPath string `json:"poster_path"`
MediaType string `json:"media_type"`
GenreIds []int `json:"genre_ids"`
Popularity float64 `json:"popularity"`
ReleaseDate string `json:"release_date"`
Video bool `json:"video"`
VoteAverage float64 `json:"vote_average"`
VoteCount int `json:"vote_count"`
} `json:"known_for"`
}
type TMDBSearchPeopleResponse struct {
Page int `json:"page"`
Results []struct {
Adult bool `json:"adult"`
Gender int `json:"gender"`
ID int `json:"id"`
KnownForDepartment string `json:"known_for_department"`
Name string `json:"name"`
OriginalName string `json:"original_name"`
Popularity float64 `json:"popularity"`
ProfilePath string `json:"profile_path"`
MediaType string `json:"media_type"` // API req doesn't include this, we will add it in our service.
KnownFor []struct {
Adult bool `json:"adult"`
BackdropPath string `json:"backdrop_path"`
ID int `json:"id"`
Title string `json:"title"`
OriginalLanguage string `json:"original_language"`
OriginalTitle string `json:"original_title"`
Overview string `json:"overview"`
PosterPath string `json:"poster_path"`
MediaType string `json:"media_type"`
GenreIds []int `json:"genre_ids"`
Popularity float64 `json:"popularity"`
ReleaseDate string `json:"release_date"`
Video bool `json:"video"`
VoteAverage float64 `json:"vote_average"`
VoteCount int `json:"vote_count"`
} `json:"known_for"`
} `json:"results"`
TotalPages int `json:"total_pages"`
TotalResults int `json:"total_results"`
TMDBSearchResponse[TMDBSearchPeopleResult]
}
type TMDBFindByExternalIdResponse struct {
// These are all a TMDBSearchMultiResults so our search func can easily
// combine all of them into one []TMDBSearchMultiResults for response
// to client (seems not easy to convert to TMDBSearchMultiResults for
// concatenation after unmarshalling to correct type).
MovieResults []TMDBSearchMultiResults `json:"movie_results"`
PersonResults []TMDBSearchMultiResults `json:"person_results"`
TvResults []TMDBSearchMultiResults `json:"tv_results"`
TvSeasonResults []TMDBSearchMultiResults `json:"tv_season_results"`
TvEpisodeResults []TMDBSearchMultiResults `json:"tv_episode_results"`
}
type TMDBContentDetails struct {
@@ -173,7 +235,12 @@ type TMDBMovieDetails struct {
ExternalIds TMDBExternalIdsMovie `json:"external_ids"`
}
type TMDBShowDetails struct {
type TMDBMovieDetailsWithWatched struct {
WatchedAddedToContent
*TMDBShowDetails
}
type TMDBShowDetailsBase struct {
TMDBContentDetails
CreatedBy []struct {
ID int `json:"id"`
@@ -223,13 +290,24 @@ type TMDBShowDetails struct {
Type string `json:"type"`
// Extra items because we use `append_to_response` on the request
// Similar SS `json:"similar"`
Videos TMDBContentVideos `json:"videos"`
WatchProviders interface{} `json:"watch/providers"`
Similar TMDBShowSimilar `json:"similar"`
ExternalIds TMDBExternalIdsShow `json:"external_ids"`
Keywords TMDBKeywords `json:"keywords"`
}
type TMDBShowDetails struct {
TMDBShowDetailsBase
Similar TMDBShowSimilar `json:"similar"`
}
type TMDBShowDetailsWithWatched struct {
WatchedAddedToContent
TMDBShowDetailsBase
Similar TMDBShowSimilarWithWatched `json:"similar"`
}
type WatchProvider struct {
ProviderID int `json:"provider_id"`
ProviderName string `json:"provider_name"`
@@ -303,25 +381,33 @@ type TMDBSeasonDetails struct {
}
type TMDBShowSimilar struct {
Page int `json:"page"`
Results []struct {
Adult bool `json:"adult"`
BackdropPath string `json:"backdrop_path"`
GenreIds []int `json:"genre_ids"`
ID int `json:"id"`
OriginCountry []string `json:"origin_country"`
OriginalLanguage string `json:"original_language"`
OriginalName string `json:"original_name"`
Overview string `json:"overview"`
Popularity float64 `json:"popularity"`
PosterPath string `json:"poster_path"`
FirstAirDate string `json:"first_air_date"`
Name string `json:"name"`
VoteAverage float64 `json:"vote_average"`
VoteCount uint32 `json:"vote_count"`
} `json:"results"`
TotalPages int `json:"total_pages"`
TotalResults int `json:"total_results"`
TMDBSearchResponse[TMDBShowSimilarResult]
}
type TMDBShowSimilarResult 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 uint32 `json:"vote_count"`
}
type TMDBShowSimilarWithWatched struct {
TMDBSearchResponse[TMDBShowSimilarResultWithWatched]
}
type TMDBShowSimilarResultWithWatched struct {
TMDBShowSimilarResult
WatchedAddedToContent
}
type TMDBMovieSimilar struct {
@@ -426,73 +512,97 @@ type TMDBContentCredits struct {
}
type TMDBDiscoverMovies struct {
Page int `json:"page"`
Results []struct {
Adult bool `json:"adult"`
BackdropPath string `json:"backdrop_path"`
GenreIds []int `json:"genre_ids"`
ID int `json:"id"`
OriginalLanguage string `json:"original_language"`
OriginalTitle string `json:"original_title"`
Overview string `json:"overview"`
Popularity float64 `json:"popularity"`
PosterPath string `json:"poster_path"`
ReleaseDate string `json:"release_date"`
Title string `json:"title"`
Video bool `json:"video"`
VoteAverage float64 `json:"vote_average"`
VoteCount int `json:"vote_count"`
} `json:"results"`
TotalPages int `json:"total_pages"`
TotalResults int `json:"total_results"`
TMDBSearchResponse[TMDBDiscoverMoviesResult]
}
type TMDBDiscoverMoviesWithWatched struct {
TMDBSearchResponse[TMDBDiscoverMoviesResultWithWatched]
}
type TMDBDiscoverMoviesResult 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"`
}
type TMDBDiscoverMoviesResultWithWatched struct {
TMDBDiscoverMoviesResult
WatchedAddedToContent
}
type TMDBDiscoverShows struct {
Page int `json:"page"`
Results []struct {
BackdropPath string `json:"backdrop_path"`
FirstAirDate string `json:"first_air_date"`
GenreIds []int `json:"genre_ids"`
ID int `json:"id"`
Name string `json:"name"`
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"`
VoteAverage float32 `json:"vote_average"`
VoteCount int `json:"vote_count"`
} `json:"results"`
TotalPages int `json:"total_pages"`
TotalResults int `json:"total_results"`
TMDBSearchResponse[TMDBDiscoverShowsResult]
}
type TMDBDiscoverShowsWithWatched struct {
TMDBSearchResponse[TMDBDiscoverShowsResultWithWatched]
}
type TMDBDiscoverShowsResult struct {
BackdropPath string `json:"backdrop_path"`
FirstAirDate string `json:"first_air_date"`
GenreIds []int `json:"genre_ids"`
ID int `json:"id"`
Name string `json:"name"`
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"`
VoteAverage float32 `json:"vote_average"`
VoteCount int `json:"vote_count"`
}
type TMDBDiscoverShowsResultWithWatched struct {
TMDBDiscoverShowsResult
WatchedAddedToContent
}
type TMDBTrendingAll struct {
Page int `json:"page"`
Results []struct {
Adult bool `json:"adult"`
BackdropPath string `json:"backdrop_path"`
ID int `json:"id"`
Title string `json:"title,omitempty"`
OriginalLanguage string `json:"original_language"`
OriginalTitle string `json:"original_title,omitempty"`
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,omitempty"`
Video bool `json:"video,omitempty"`
VoteAverage float64 `json:"vote_average"`
VoteCount int `json:"vote_count"`
Name string `json:"name,omitempty"`
OriginalName string `json:"original_name,omitempty"`
FirstAirDate string `json:"first_air_date,omitempty"`
OriginCountry []string `json:"origin_country,omitempty"`
} `json:"results"`
TotalPages int `json:"total_pages"`
TotalResults int `json:"total_results"`
TMDBSearchResponse[TMDBTrendingAllResult]
}
type TMDBTrendingAllWithWatched struct {
TMDBSearchResponse[TMDBTrendingAllResultWithWatched]
}
type TMDBTrendingAllResult struct {
Adult bool `json:"adult"`
BackdropPath string `json:"backdrop_path"`
ID int `json:"id"`
Title string `json:"title,omitempty"`
OriginalLanguage string `json:"original_language"`
OriginalTitle string `json:"original_title,omitempty"`
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,omitempty"`
Video bool `json:"video,omitempty"`
VoteAverage float64 `json:"vote_average"`
VoteCount int `json:"vote_count"`
Name string `json:"name,omitempty"`
OriginalName string `json:"original_name,omitempty"`
FirstAirDate string `json:"first_air_date,omitempty"`
OriginCountry []string `json:"origin_country,omitempty"`
}
type TMDBTrendingAllResultWithWatched struct {
TMDBTrendingAllResult
WatchedAddedToContent
}
type TMDBUpcomingMovies struct {
@@ -500,46 +610,66 @@ type TMDBUpcomingMovies struct {
Maximum string `json:"maximum"`
Minimum string `json:"minimum"`
} `json:"dates"`
Page int `json:"page"`
Results []struct {
Adult bool `json:"adult"`
BackdropPath string `json:"backdrop_path"`
GenreIds []int `json:"genre_ids"`
ID int `json:"id"`
OriginalLanguage string `json:"original_language"`
OriginalTitle string `json:"original_title"`
Overview string `json:"overview"`
Popularity float64 `json:"popularity"`
PosterPath string `json:"poster_path"`
ReleaseDate string `json:"release_date"`
Title string `json:"title"`
Video bool `json:"video"`
VoteAverage float32 `json:"vote_average"`
VoteCount int `json:"vote_count"`
} `json:"results"`
TotalPages int `json:"total_pages"`
TotalResults int `json:"total_results"`
TMDBSearchResponse[TMDBUpcomingMoviesResult]
}
type TMDBUpcomingMoviesWithWatched struct {
Dates struct {
Maximum string `json:"maximum"`
Minimum string `json:"minimum"`
} `json:"dates"`
TMDBSearchResponse[TMDBUpcomingMoviesResultWithWatched]
}
type TMDBUpcomingMoviesResult 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 float32 `json:"vote_average"`
VoteCount int `json:"vote_count"`
}
type TMDBUpcomingMoviesResultWithWatched struct {
TMDBUpcomingMoviesResult
WatchedAddedToContent
}
type TMDBUpcomingShows struct {
Page int `json:"page"`
Results []struct {
BackdropPath string `json:"backdrop_path"`
FirstAirDate string `json:"first_air_date"`
GenreIds []int `json:"genre_ids"`
ID int `json:"id"`
Name string `json:"name"`
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"`
VoteAverage float32 `json:"vote_average"`
VoteCount int `json:"vote_count"`
} `json:"results"`
TotalPages int `json:"total_pages"`
TotalResults int `json:"total_results"`
TMDBSearchResponse[TMDBUpcomingShowsResult]
}
type TMDBUpcomingShowsWithWatched struct {
TMDBSearchResponse[TMDBUpcomingShowsResultWithWatched]
}
type TMDBUpcomingShowsResult struct {
BackdropPath string `json:"backdrop_path"`
FirstAirDate string `json:"first_air_date"`
GenreIds []int `json:"genre_ids"`
ID int `json:"id"`
Name string `json:"name"`
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"`
VoteAverage float32 `json:"vote_average"`
VoteCount int `json:"vote_count"`
}
type TMDBUpcomingShowsResultWithWatched struct {
TMDBUpcomingShowsResult
WatchedAddedToContent
}
type TMDBExternalIds struct {
+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")
+10
View File
@@ -28,6 +28,16 @@ type GormModel struct {
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
}
// Types of media supported by Watcharr
// in an overarching way.
type SupportedMedia string
const (
SupportedMediaMovie SupportedMedia = "movie"
SupportedMediaShow SupportedMedia = "tv"
SupportedMediaGame SupportedMedia = "game"
)
var (
ServerInSetup = false
logLevel = new(slog.LevelVar)
+118 -3
View File
@@ -75,7 +75,51 @@ type WatchedRemoveResponse struct {
NewActivity Activity `json:"newActivity"`
}
func getWatched(db *gorm.DB, userId uint) []Watched {
// Get watched page request extra (GET) options.
type WatchedGetPageRequest struct {
// Sorting type.
Sort WatchedSort `form:"sort"`
// Sorting direction (asc or desc).
SortDir SortDirection `form:"sortDir,default=desc"`
// Filtering options.
Filter struct {
Type SupportedMedia `form:"filter.type"`
Status WatchedStatus `form:"filter.status"`
}
}
type WatchedSort string
const (
watchedSortDateAdded WatchedSort = "DATEADDED"
watchedSortLastChanged WatchedSort = "LASTCHANGED"
watchedSortLastFinished WatchedSort = "LASTFIN"
watchedSortRating WatchedSort = "RATING"
watchedSortAlphabetical WatchedSort = "ALPHA"
)
type SortDirection string
const (
sortAscending SortDirection = "asc"
sortDescending SortDirection = "desc"
)
// This struct is for embedding inside content response structs.
// This holds the watched entry response data that will go along
// with the content responses.
type WatchedAddedToContent struct {
// The related watched entry.
Watched *Watched `json:"watched,omitempty"`
// If we failed to get the watched entry,
// set this to true, so the frontend can
// notify the user of why there is possibly
// missing watched list data.
FailedToGetWatched bool `json:"failedToGetWatched,omitempty"`
}
// Get entire watched list
func getWatched(db *gorm.DB, userId uint) ([]Watched, error) {
watched := new([]Watched)
res := db.Model(&Watched{}).
Preload("Content").
@@ -88,9 +132,39 @@ func getWatched(db *gorm.DB, userId uint) []Watched {
Where("user_id = ?", userId).
Find(&watched)
if res.Error != nil {
panic(res.Error)
slog.Error("getWatched: Failed!", "error", res.Error)
return []Watched{}, res.Error
}
return *watched
return *watched, nil
}
// Returns a page of users watched list.
func getWatchedPage(db *gorm.DB, userId uint, pp PaginationParams, wr WatchedGetPageRequest) (PaginationResponse[Watched], error) {
slog.Debug("getWatchedPage: A page was requested.", "user_id", userId, "pagination_params", pp, "wr", wr)
watched := new([]Watched)
pRes := &PaginationResponse[Watched]{}
res := db.
Model(&Watched{}).
Where(&Watched{UserID: userId}).
Count(&pRes.TotalResults).
Joins("Content").
Joins("Game").
Preload("Game.Poster").
Preload("Tags").
Scopes(
Paginate(pp, pRes),
// NOTE: watchedRefine->watchedSortLastFinished sort changes the SELECT
// statement for this query, so keep that in mind if it ever changes.
watchedRefine(wr),
).
Find(&watched)
if res.Error != nil {
slog.Error("getWatchedPage: Failed!", "error", res.Error)
return PaginationResponse[Watched]{}, res.Error
}
pRes.Results = *watched
pRes.Finished(pp)
return *pRes, nil
}
// Get a watched list item by id (must be for `userId`).
@@ -104,6 +178,47 @@ 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
}
// Same as `getWatchedItemByTmdbId` except for getting in bulk (multiple content ids).
// `c` entries should be in format: [tmdb_id, ContentType] (Note: Couldn't figure out
// if it's possible to type this to enforce [int, ContentType] type for entries)
func getWatchedItemsByTmdbIds(db *gorm.DB, userId uint, c [][]any) ([]Watched, error) {
slog.Debug("getWatchedItemsByTmdbIds: Running.", "userId", userId, "c", c)
watched := new([]Watched)
res := db.Model(&Watched{}).
Joins("Content").
Where("user_id = ?", userId).
Where(
"(Content.tmdb_id, Content.type) IN ?", c).
Find(&watched)
if res.Error != nil {
slog.Error("getWatchedItemsByTmdbIds: Failed!", "error", res.Error)
return []Watched{}, res.Error
}
slog.Debug(
"getWatchedItemsByTmdbIds: Done.",
"userId", userId,
"watcheds_found", len(*watched),
// "wdev", *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)
+53
View File
@@ -0,0 +1,53 @@
// Watched sorting & filtering.
package main
import (
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// gorm scope for applying sort and filters to watched
// list data.
func watchedRefine(wr WatchedGetPageRequest) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
// Apply filters
// Apply sort
if wr.Sort != "" {
obc := func(cn string) clause.OrderByColumn {
o := clause.OrderByColumn{}
if wr.SortDir == sortAscending {
o.Desc = false
} else {
o.Desc = true
}
o.Column = clause.Column{Name: cn}
return o
}
switch wr.Sort {
case watchedSortDateAdded:
db.Order(obc("watcheds.created_at"))
case watchedSortLastChanged:
db.Order(obc("watcheds.updated_at"))
case watchedSortLastFinished:
// TODO This can make the query quite slow, look at improving performance.
db.
Joins("LEFT JOIN activities ON activities.watched_id = watcheds.id").
// TODO this whole query looks to work, but we have to add `watcheds.*` to this SELECT,
// otherwise it doesn't select them (like it does when we use Model, in the original query build),
// is there a better way? Cuz if we modify the Model later in main query, this would break, which isn't ideal.
Select("watcheds.*, MAX(MAX(activities.created_at), MAX(activities.custom_date)) as latest_watched_activity").
Group("watcheds.id").
Order(obc("latest_watched_activity"))
case watchedSortRating:
db.Order(obc("watcheds.rating"))
case watchedSortAlphabetical:
db.
Order(obc("Content__title")).
Order(obc("Game__name"))
}
}
return db
}
}
+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: 18px;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>
+23 -15
View File
@@ -1,25 +1,33 @@
<!-- Uses Native Color Input -->
<script lang="ts">
export let value = "#000000";
export let style = "";
interface Props {
value?: string;
style?: string;
}
let { value = $bindable("#000000"), style = "" }: Props = $props();
</script>
<button class="plain" style={`background-color: ${value};${style}`}>
<input type="color" bind:value />
<button
aria-label="Open color picker"
class="plain"
style={`background-color: ${value};${style}`}
>
<input type="color" bind:value />
</button>
<style lang="scss">
button {
width: 100%;
height: 100%;
border-radius: 10px;
outline: 1px solid $accent-color;
cursor: pointer;
}
button {
width: 100%;
height: 100%;
border-radius: 10px;
outline: 1px solid $accent-color;
cursor: pointer;
}
input {
opacity: 0;
cursor: pointer;
}
input {
opacity: 0;
cursor: pointer;
}
</style>
+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}
{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 -34
View File
@@ -1,44 +1,48 @@
<script lang="ts">
export let pretty: string;
export let error: any;
export let onRetry: () => void | undefined = undefined!;
interface Props {
pretty: string;
error: any;
onRetry?: () => void | undefined;
}
let { pretty, error, onRetry = undefined! }: Props = $props();
</script>
<div>
<div>
<strong>{pretty}</strong>
{#if error?.message}
<p>{error.message}</p>
{#if error.response?.data?.error}
<p>{error.response.data.error}</p>
{/if}
{:else}
<p>{JSON.stringify(error)}</p>
{/if}
{#if onRetry}
<button on:click={onRetry}>Try Again</button>
{/if}
</div>
<div>
<strong>{pretty}</strong>
{#if error?.message}
<p>{error.message}</p>
{#if error.response?.data?.error}
<p>{error.response.data.error}</p>
{/if}
{:else}
<p>{JSON.stringify(error)}</p>
{/if}
{#if onRetry}
<button onclick={onRetry}>Try Again</button>
{/if}
</div>
</div>
<style lang="scss">
div {
display: flex;
justify-content: center;
width: 100%;
div {
display: flex;
justify-content: center;
width: 100%;
div {
display: flex;
justify-content: center;
flex-flow: column;
max-width: 500px;
gap: 5px;
text-transform: capitalize;
font-size: 16px;
div {
display: flex;
justify-content: center;
flex-flow: column;
max-width: 500px;
gap: 5px;
text-transform: capitalize;
font-size: 16px;
p {
font-size: 12px;
}
}
}
p {
font-size: 12px;
}
}
}
</style>
+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>
+585 -381
View File
File diff suppressed because it is too large Load Diff
+221 -192
View File
@@ -1,212 +1,241 @@
<script lang="ts">
import Icon from "@/lib/Icon.svelte";
import Modal from "@/lib/Modal.svelte";
import Spinner from "@/lib/Spinner.svelte";
import { notify } from "@/lib/util/notify";
import { JobStatus, type GetJobResponse, type JobCreatedResponse } from "@/types";
import axios from "axios";
import { onDestroy, onMount } from "svelte";
import { watchedList } from "@/store";
import Icon from "@/lib/Icon.svelte";
import Modal from "@/lib/Modal.svelte";
import Spinner from "@/lib/Spinner.svelte";
import { notify } from "@/lib/util/notify";
import {
JobStatus,
type GetJobResponse,
type JobCreatedResponse,
} from "@/types";
import axios from "axios";
import { onDestroy, onMount } from "svelte";
import { store } from "@/store.svelte";
export let modalTitle: string;
// Promise that returns the job id to watch.
export let getJobId: () => Promise<{ jobId: string } | undefined>;
export let onClose: () => void;
export let messages: {
starting: string;
};
interface Props {
modalTitle: string;
// Promise that returns the job id to watch.
getJobId: () => Promise<{ jobId: string } | undefined>;
onClose: () => void;
messages: {
starting: string;
};
}
let step: "starting" | "errored" | "job-running" | "done" | "modal-closing" = "starting";
let jobId: string | undefined;
let currentTask: string | undefined;
let latestJobStatus: GetJobResponse | undefined;
let jobFailError: string | undefined;
let { modalTitle, getJobId, onClose, messages }: Props = $props();
async function startSync() {
try {
const r = await getJobId();
console.log("startSync: Response:", r);
if (!r?.jobId) {
step = "errored";
console.error("startSync: No jobId returned!");
return;
}
jobId = r.jobId;
step = "job-running";
startJobWatcher();
} catch (err: any) {
console.error("startSync failed!", err);
step = "errored";
jobFailError = err?.response ? err?.response?.data?.error : String(err);
}
}
let step: "starting" | "errored" | "job-running" | "done" | "modal-closing" =
$state("starting");
let jobId: string | undefined;
let currentTask: string | undefined = $state();
let latestJobStatus: GetJobResponse | undefined = $state();
let jobFailError: string | undefined = $state();
async function startJobWatcher() {
if (!jobId) {
console.error("startJobWatcher: No Job Id");
notify({ text: "Unable to start job watcher, no job id.", type: "error" });
return;
}
console.log("startJobWatcher: Starting..");
let seqfailedJobReqs = 0;
while (step === "job-running") {
try {
const r = await axios.get<GetJobResponse>(`/job/${jobId}`);
console.log("jobWatcher: Got job data:", r.data);
latestJobStatus = r.data;
currentTask = r.data?.currentTask;
if (r.data?.status === JobStatus.DONE) {
step = "done";
} else if (r.data?.status === JobStatus.CANCELLED) {
step = "errored";
}
// If we get here without erroring, we can reset it to 0.
seqfailedJobReqs = 0;
} catch (err) {
console.error("jobWatcher: Get job request failed!", seqfailedJobReqs, err);
seqfailedJobReqs++;
}
if (seqfailedJobReqs >= 10) {
console.error("jobWatcher: Failed 10 times in a row!");
notify({
text: "Status checker has failed 10 times in a row!",
type: "error",
time: 30000
});
step = "errored";
break;
}
await new Promise((r) => setTimeout(r, 1000));
}
if (step !== "modal-closing") {
// Update our watched list
const nid = notify({ text: "Fetching updated watched list.", type: "loading" });
try {
const w = await axios.get("/watched");
if (w?.data?.length > 0) {
watchedList.update((wl) => (wl = w.data));
}
notify({ id: nid, text: "Fetched updated watched list.", type: "success" });
} catch (err) {
console.error("jobWatcher: Getting updated watched list failed!", err);
notify({ id: nid, text: "Getting updated watched list failed!", type: "error" });
}
}
}
async function startSync() {
try {
const r = await getJobId();
console.log("startSync: Response:", r);
if (!r?.jobId) {
step = "errored";
console.error("startSync: No jobId returned!");
return;
}
jobId = r.jobId;
step = "job-running";
startJobWatcher();
} catch (err: any) {
console.error("startSync failed!", err);
step = "errored";
jobFailError = err?.response ? err?.response?.data?.error : String(err);
}
}
function modalClose() {
if (step === "job-running") {
notify({
text: "Sync will continue in the background.. please refresh the page periodically to view your updated list or come back later.",
time: 10000
});
}
step = "modal-closing";
onClose();
}
async function startJobWatcher() {
if (!jobId) {
console.error("startJobWatcher: No Job Id");
notify({
text: "Unable to start job watcher, no job id.",
type: "error",
});
return;
}
console.log("startJobWatcher: Starting..");
let seqfailedJobReqs = 0;
while (step === "job-running") {
try {
const r = await axios.get<GetJobResponse>(`/job/${jobId}`);
console.log("jobWatcher: Got job data:", r.data);
latestJobStatus = r.data;
currentTask = r.data?.currentTask;
if (r.data?.status === JobStatus.DONE) {
step = "done";
} else if (r.data?.status === JobStatus.CANCELLED) {
step = "errored";
}
// If we get here without erroring, we can reset it to 0.
seqfailedJobReqs = 0;
} catch (err) {
console.error(
"jobWatcher: Get job request failed!",
seqfailedJobReqs,
err,
);
seqfailedJobReqs++;
}
if (seqfailedJobReqs >= 10) {
console.error("jobWatcher: Failed 10 times in a row!");
notify({
text: "Status checker has failed 10 times in a row!",
type: "error",
time: 30000,
});
step = "errored";
break;
}
await new Promise((r) => setTimeout(r, 1000));
}
if (step !== "modal-closing") {
// Update our watched list
const nid = notify({
text: "Fetching updated watched list.",
type: "loading",
});
try {
const w = await axios.get("/watched");
if (w?.data?.length > 0) {
store.watchedList = w.data;
}
notify({
id: nid,
text: "Fetched updated watched list.",
type: "success",
});
} catch (err) {
console.error("jobWatcher: Getting updated watched list failed!", err);
notify({
id: nid,
text: "Getting updated watched list failed!",
type: "error",
});
}
}
}
onMount(() => {
startSync();
});
function modalClose() {
if (step === "job-running") {
notify({
text: "Sync will continue in the background.. please refresh the page periodically to view your updated list or come back later.",
time: 10000,
});
}
step = "modal-closing";
onClose();
}
onDestroy(() => {
step = "starting";
jobId = undefined;
currentTask = undefined;
latestJobStatus = undefined;
});
onMount(() => {
startSync();
});
onDestroy(() => {
step = "starting";
jobId = undefined;
currentTask = undefined;
latestJobStatus = undefined;
});
</script>
<Modal title={modalTitle} maxWidth="700px" onClose={modalClose}>
<div class="ctr">
{#if step === "done"}
<Icon i="check" wh={60} />
{:else if step === "errored"}
<Icon i="close" wh={70} />
{:else}
<Spinner />
{/if}
<div>
{#if step === "starting"}
<h4 class="norm">Starting</h4>
<span>{messages.starting}</span>
{:else if step === "job-running"}
<h4 class="norm">Running</h4>
{#if currentTask}
<span>{currentTask}</span>
{/if}
{:else if step === "done"}
{#if !latestJobStatus?.errors || latestJobStatus?.errors?.length <= 0}
<h4 class="norm">Finished</h4>
<span>We have finished. Looks like there were no errors!</span>
{:else}
<h4 class="norm">
Finished With {latestJobStatus?.errors?.length} Error{latestJobStatus?.errors
?.length === 1
? ""
: "s"}
</h4>
<span>Job finished, but with errors:</span>
<ul>
{#each latestJobStatus?.errors as e}
<li>{e}</li>
{/each}
</ul>
{/if}
{:else if step === "errored"}
<h4 class="norm">We Errored!</h4>
{#if jobFailError}
<span>{jobFailError}</span>
{:else}
<span>We errored before starting the job or the job was cancelled.</span>
{/if}
{#if latestJobStatus?.errors && latestJobStatus?.errors?.length > 0}
<ul>
{#each latestJobStatus?.errors as e}
<li>{e}</li>
{/each}
</ul>
{/if}
{:else}
<h4 class="norm">Unknown State!</h4>
<span>We're not sure of the current job status.</span>
{/if}
</div>
</div>
<div class="ctr">
{#if step === "done"}
<Icon i="check" wh={60} />
{:else if step === "errored"}
<Icon i="close" wh={70} />
{:else}
<Spinner />
{/if}
<div>
{#if step === "starting"}
<h4 class="norm">Starting</h4>
<span>{messages.starting}</span>
{:else if step === "job-running"}
<h4 class="norm">Running</h4>
{#if currentTask}
<span>{currentTask}</span>
{/if}
{:else if step === "done"}
{#if !latestJobStatus?.errors || latestJobStatus?.errors?.length <= 0}
<h4 class="norm">Finished</h4>
<span>We have finished. Looks like there were no errors!</span>
{:else}
<h4 class="norm">
Finished With {latestJobStatus?.errors?.length} Error{latestJobStatus
?.errors?.length === 1
? ""
: "s"}
</h4>
<span>Job finished, but with errors:</span>
<ul>
{#each latestJobStatus?.errors as e}
<li>{e}</li>
{/each}
</ul>
{/if}
{:else if step === "errored"}
<h4 class="norm">We Errored!</h4>
{#if jobFailError}
<span>{jobFailError}</span>
{:else}
<span
>We errored before starting the job or the job was cancelled.</span
>
{/if}
{#if latestJobStatus?.errors && latestJobStatus?.errors?.length > 0}
<ul>
{#each latestJobStatus?.errors as e}
<li>{e}</li>
{/each}
</ul>
{/if}
{:else}
<h4 class="norm">Unknown State!</h4>
<span>We're not sure of the current job status.</span>
{/if}
</div>
</div>
</Modal>
<style lang="scss">
.ctr {
display: flex;
flex-flow: row;
gap: 20px;
justify-content: start;
align-items: start;
margin-top: 25px;
margin-bottom: 15px;
margin-left: 15px;
.ctr {
display: flex;
flex-flow: row;
gap: 20px;
justify-content: start;
align-items: start;
margin-top: 25px;
margin-bottom: 15px;
margin-left: 15px;
& > div:last-of-type {
display: flex;
flex-flow: column;
gap: 8px;
padding: 8px 0;
& > div:last-of-type {
display: flex;
flex-flow: column;
gap: 8px;
padding: 8px 0;
& > span {
font-style: italic;
& > span {
font-style: italic;
&::first-letter {
text-transform: uppercase;
}
}
&::first-letter {
text-transform: uppercase;
}
}
& > ul {
padding-left: 25px;
& > ul {
padding-left: 25px;
li::first-letter {
text-transform: uppercase;
}
}
}
}
li::first-letter {
text-transform: uppercase;
}
}
}
}
</style>
+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>
+94 -81
View File
@@ -1,97 +1,110 @@
<script lang="ts">
import Icon from "./Icon.svelte";
import Icon from "./Icon.svelte";
export let title: string;
export let desc: string | undefined = undefined;
export let onClose: (() => void) | undefined = undefined;
export let maxWidth = "1000px";
export let error: string | undefined = undefined; // TODO This property is new, mimics what we do with other modals by showing an error at top.. we could migrate to use this in other places.
interface Props {
title: string;
desc?: string | undefined;
onClose?: (() => void) | undefined;
maxWidth?: string;
error?: string | undefined; // TODO This property is new, mimics what we do with other modals by showing an error at top.. we could migrate to use this in other places.
children?: import("svelte").Snippet;
}
let {
title,
desc = undefined,
onClose = undefined,
maxWidth = "1000px",
error = undefined,
children,
}: Props = $props();
</script>
<div class="backdrop"></div>
<div class="modal">
<div style="max-width:{maxWidth};">
{#if typeof onClose !== "undefined"}
<button class="close" on:click={onClose}><Icon i="close" wh="20" /></button>
{/if}
<h3 class="norm">{title}</h3>
{#if desc}
<h5 class="norm">{desc}</h5>
{/if}
{#if error}
<span class="error">{error}</span>
{/if}
<slot />
</div>
<div style="max-width:{maxWidth};">
{#if typeof onClose !== "undefined"}
<button class="close" onclick={onClose}><Icon i="close" wh="20" /></button
>
{/if}
<h3 class="norm">{title}</h3>
{#if desc}
<h5 class="norm">{desc}</h5>
{/if}
{#if error}
<span class="error">{error}</span>
{/if}
{@render children?.()}
</div>
</div>
<style lang="scss">
.backdrop {
position: absolute;
top: 0;
left: 0;
width: 100dvw;
height: 100dvh;
backdrop-filter: blur(2px) saturate(180%);
background-color: color-mix(in srgb, black 85%, transparent);
position: fixed;
z-index: 99998;
}
.backdrop {
position: absolute;
top: 0;
left: 0;
width: 100dvw;
height: 100dvh;
backdrop-filter: blur(2px) saturate(180%);
background-color: color-mix(in srgb, black 85%, transparent);
position: fixed;
z-index: 99998;
}
.modal {
display: flex;
justify-content: center;
align-items: center;
width: 100dvw;
height: 100dvh;
top: 0;
left: 0;
position: fixed;
z-index: 99998;
.modal {
display: flex;
justify-content: center;
align-items: center;
width: 100dvw;
height: 100dvh;
top: 0;
left: 0;
position: fixed;
z-index: 99998;
& > div {
position: relative;
display: flex;
flex-flow: column;
min-width: 300px;
width: 100%;
max-height: calc(100% - 20px);
background-color: $bg-color;
border-radius: 10px;
padding: 15px 20px;
margin: 20px 100px;
transition: margin 100ms ease;
outline: 2px solid $text-color;
overflow: auto;
& > div {
position: relative;
display: flex;
flex-flow: column;
min-width: 300px;
width: 100%;
max-height: calc(100% - 20px);
background-color: $bg-color;
border-radius: 10px;
padding: 15px 20px;
margin: 20px 100px;
transition: margin 100ms ease;
outline: 2px solid $text-color;
overflow: auto;
h5 {
margin-bottom: 15px;
}
h5 {
margin-bottom: 15px;
}
button.close {
position: absolute;
top: 8px;
right: 8px;
width: max-content;
padding: 3px 5px;
}
button.close {
position: absolute;
top: 8px;
right: 8px;
width: max-content;
padding: 3px 5px;
}
@media screen and (max-width: 680px) {
margin: 20px;
}
@media screen and (max-width: 680px) {
margin: 20px;
}
.error {
position: sticky;
top: 0;
display: flex;
justify-content: center;
width: 100%;
padding: 10px;
background-color: rgb(221, 48, 48);
text-transform: capitalize;
color: white;
margin-bottom: 15px;
}
}
}
.error {
position: sticky;
top: 0;
display: flex;
justify-content: center;
width: 100%;
padding: 10px;
background-color: rgb(221, 48, 48);
text-transform: capitalize;
color: white;
margin-bottom: 15px;
}
}
}
</style>
+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>
+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 -319
View File
@@ -1,354 +1,486 @@
<script lang="ts">
import type {
TMDBSeasonDetails,
TMDBShowSeason,
Watched,
WatchedSeasonAddResponse,
WatchedStatus
} from "@/types";
import axios from "axios";
import Spinner from "./Spinner.svelte";
import Error from "./Error.svelte";
import SeasonsListEpisode from "./SeasonsListEpisode.svelte";
import PosterStatus from "./poster/PosterStatus.svelte";
import { notify } from "./util/notify";
import { get } from "svelte/store";
import { watchedList } from "@/store";
import PosterRating from "./poster/PosterRating.svelte";
import type {
TMDBSeasonDetails,
TMDBShowSeason,
Watched,
WatchedSeason,
WatchedSeasonAddResponse,
WatchedStatus,
} from "@/types";
import axios from "axios";
import Spinner from "./Spinner.svelte";
import Error from "./Error.svelte";
import SeasonsListEpisode from "./SeasonsListEpisode.svelte";
import PosterStatus from "./poster/PosterStatus.svelte";
import { notify } from "./util/notify";
import { store } from "@/store.svelte";
import PosterRating from "./poster/PosterRating.svelte";
import Icon from "./Icon.svelte";
import { watchedStatuses } from "./util/helpers";
export let tvId: number;
export let seasons: TMDBShowSeason[];
export let watchedItem: Watched | undefined;
interface Props {
tvId: number;
seasons: TMDBShowSeason[];
watchedItem: Watched | undefined;
}
let activeSeason =
typeof watchedItem?.lastViewedSeason === "number" ? watchedItem?.lastViewedSeason : 1;
let seasonDetailsReq: Promise<TMDBSeasonDetails>;
let { tvId, seasons, watchedItem = $bindable() }: Props = $props();
async function sdr(seasonNum: number) {
const resp = await axios.get(`/content/tv/${tvId}/season/${seasonNum}`, {
params: {
watchedId: watchedItem?.id
}
});
try {
if (watchedItem?.id) {
// If we sent a watched id, expect a 'watcharr-lastviewedseason-saved' header in the response.
const hVal = resp.headers["watcharr-lastviewedseason-saved"];
if (hVal) {
watchedItem.lastViewedSeason = seasonNum;
watchedList.update((w) => w);
} else {
console.error(
"SeasonList: sdr: No header in response indicating that the lastviewedseason was saved."
);
notify({ type: "error", text: "Failed when saving last viewed season" });
}
}
} catch (err) {
console.error("SeasonList: sdr: Failed to process lastviewedseason-saved header.", err);
}
return resp.data as TMDBSeasonDetails;
}
let activeSeason = $state(
typeof watchedItem?.lastViewedSeason === "number"
? watchedItem?.lastViewedSeason
: 1,
);
let seasonDetailsReq: Promise<TMDBSeasonDetails> = $derived(
sdr(activeSeason),
);
// Add/update watched season
function updateWatchedSeason(seasonNumber: number, status?: WatchedStatus, rating?: number) {
if (!watchedItem) {
console.error("updateWatchedSeason: No watched item.");
return;
}
const nid = notify({ text: `Saving`, type: "loading" });
axios
.post<WatchedSeasonAddResponse>(`/watched/season`, {
watchedId: watchedItem.id,
seasonNumber: seasonNumber,
status,
rating
})
.then((r) => {
const wList = get(watchedList);
const wEntry = wList.find((w) => w.id === watchedItem.id);
if (!wEntry) {
notify({
id: nid,
text: `Request succeeded, but failed to find local data. Please refresh.`,
type: "error"
});
return;
}
if (r.status === 200) {
wEntry.watchedSeasons = r.data.watchedSeasons;
if (wEntry.activity?.length > 0) {
wEntry.activity.push(r.data.addedActivity);
} else {
wEntry.activity = [r.data.addedActivity];
}
watchedList.update((w) => w);
notify({ id: nid, text: `Saved!`, type: "success" });
}
})
.catch((err) => {
console.error(err);
notify({ id: nid, text: "Failed To Update!", type: "error" });
});
}
async function sdr(seasonNum: number) {
const resp = await axios.get(`/content/tv/${tvId}/season/${seasonNum}`, {
params: {
watchedId: watchedItem?.id,
},
});
try {
if (watchedItem?.id) {
// If we sent a watched id, expect a 'watcharr-lastviewedseason-saved' header in the response.
const hVal = resp.headers["watcharr-lastviewedseason-saved"];
if (hVal) {
watchedItem.lastViewedSeason = seasonNum;
// watchedList.update((w) => w);
} else {
console.error(
"SeasonList: sdr: No header in response indicating that the lastviewedseason was saved.",
);
notify({
type: "error",
text: "Failed when saving last viewed season",
});
}
}
} catch (err) {
console.error(
"SeasonList: sdr: Failed to process lastviewedseason-saved header.",
err,
);
}
return resp.data as TMDBSeasonDetails;
}
function handleStatusClick(type: WatchedStatus | "DELETE", seasonNumber: number) {
if (!watchedItem) {
console.error("handleStatusClick: No watched item.");
return;
}
if (type === "DELETE") {
const ws = watchedItem.watchedSeasons?.find((s) => s.seasonNumber === seasonNumber);
if (!ws) {
notify({ text: "Failed to find watched season id. Please try refreshing.", type: "error" });
return;
}
const nid = notify({ text: `Saving`, type: "loading" });
axios
.delete(`/watched/season/${ws.id}`)
.then((r) => {
const wList = get(watchedList);
const wEntry = wList.find((w) => w.id === watchedItem.id);
if (!wEntry) {
notify({
id: nid,
text: `Request succeeded, but failed to find local data. Please refresh.`,
type: "error"
});
return;
}
if (r.status === 200) {
wEntry.watchedSeasons = wEntry.watchedSeasons?.filter((s) => s.id !== ws.id);
if (r.data) {
if (wEntry.activity?.length > 0) {
wEntry.activity.push(r.data);
} else {
wEntry.activity = [r.data];
}
}
watchedList.update((w) => w);
notify({ id: nid, text: `Removed!`, type: "success" });
}
})
.catch((err) => {
console.error(err);
notify({ id: nid, text: "Failed To Remove!", type: "error" });
});
return;
}
updateWatchedSeason(seasonNumber, type);
}
// Add/update watched season
function updateWatchedSeason(
seasonNumber: number,
status?: WatchedStatus,
rating?: number,
) {
if (!watchedItem) {
console.error("updateWatchedSeason: No watched item.");
return;
}
const nid = notify({ text: `Saving`, type: "loading" });
axios
.post<WatchedSeasonAddResponse>(`/watched/season`, {
watchedId: watchedItem.id,
seasonNumber: seasonNumber,
status,
rating,
})
.then((r) => {
// const wList = get(watchedList);
const wEntry = store.watchedList.find((w) => w.id === watchedItem.id);
if (!wEntry) {
notify({
id: nid,
text: `Request succeeded, but failed to find local data. Please refresh.`,
type: "error",
});
return;
}
if (r.status === 200) {
wEntry.watchedSeasons = r.data.watchedSeasons;
if (wEntry.activity?.length > 0) {
wEntry.activity.push(r.data.addedActivity);
} else {
wEntry.activity = [r.data.addedActivity];
}
notify({ id: nid, text: `Saved!`, type: "success" });
}
})
.catch((err) => {
console.error(err);
notify({ id: nid, text: "Failed To Update!", type: "error" });
});
}
function handleStarClick(rating: number, seasonNumber: number) {
updateWatchedSeason(seasonNumber, undefined, rating);
}
function handleStatusClick(
type: WatchedStatus | "DELETE",
seasonNumber: number,
) {
if (!watchedItem) {
console.error("handleStatusClick: No watched item.");
return;
}
if (type === "DELETE") {
const ws = watchedItem.watchedSeasons?.find(
(s) => s.seasonNumber === seasonNumber,
);
if (!ws) {
notify({
text: "Failed to find watched season id. Please try refreshing.",
type: "error",
});
return;
}
const nid = notify({ text: `Saving`, type: "loading" });
axios
.delete(`/watched/season/${ws.id}`)
.then((r) => {
const wEntry = store.watchedList.find((w) => w.id === watchedItem.id);
if (!wEntry) {
notify({
id: nid,
text: `Request succeeded, but failed to find local data. Please refresh.`,
type: "error",
});
return;
}
if (r.status === 200) {
wEntry.watchedSeasons = wEntry.watchedSeasons?.filter(
(s) => s.id !== ws.id,
);
if (r.data) {
if (wEntry.activity?.length > 0) {
wEntry.activity.push(r.data);
} else {
wEntry.activity = [r.data];
}
}
notify({ id: nid, text: `Removed!`, type: "success" });
}
})
.catch((err) => {
console.error(err);
notify({ id: nid, text: "Failed To Remove!", type: "error" });
});
return;
}
updateWatchedSeason(seasonNumber, type);
}
$: {
seasonDetailsReq = sdr(activeSeason);
}
function handleStarClick(rating: number, seasonNumber: number) {
updateWatchedSeason(seasonNumber, undefined, rating);
}
function checkSeasonStatus(
watchedSeasons: WatchedSeason[] | undefined,
currentSeason: TMDBShowSeason,
): WatchedStatus | undefined {
if (watchedSeasons) {
const watchedSeason = watchedSeasons.find(
(ws) => ws.seasonNumber === currentSeason.season_number,
);
console.debug(
"checkSeasonStatus:",
currentSeason.season_number,
watchedSeason?.status,
);
return watchedSeason?.status;
}
return undefined;
}
</script>
<div class="ctr">
<ul class="seasons">
{#each seasons as season}
<button
class={`plain${activeSeason === season.season_number ? " active" : ""}`}
on:click={() => {
activeSeason = season.season_number;
}}
>
<h1>{season.name}</h1>
{#if season.air_date}
<h2>{new Date(Date.parse(season.air_date)).getFullYear()}</h2>
{:else if season.season_number > 0}
<h2>TBD</h2>
{/if}
</button>
{/each}
<div class="last" />
</ul>
<ul class="seasons">
{#each seasons as season}
<button
class={`plain${activeSeason === season.season_number ? " active" : ""}`}
onclick={() => {
activeSeason = season.season_number;
}}
>
<div>
<h1 class="season-name">{season.name}</h1>
{#if season.episode_count > 0}
<h2 class="season-episodes">{season.episode_count} Episodes</h2>
{/if}
</div>
<div>
{#if season.air_date}
<h2 class="season-date">
{new Date(Date.parse(season.air_date)).getFullYear()}
</h2>
{:else if season.season_number > 0}
<h2>TBD</h2>
{/if}
<div class="episodes">
{#await seasonDetailsReq}
<Spinner />
{:then season}
<div class="episodes-topbar">
<h3>{season.name}</h3>
{#if watchedItem}
{@const ws = watchedItem?.watchedSeasons?.find(
(s) => s.seasonNumber === season.season_number
)}
{#if ws}
<div class="rating">
<PosterRating
rating={ws?.rating}
btnTooltip="Season Rating"
handleStarClick={(r) => handleStarClick(r, season.season_number)}
minimal={true}
direction="bot"
/>
</div>
{/if}
<div class="status">
<PosterStatus
status={ws?.status}
btnTooltip="Season Status"
handleStatusClick={(t) => handleStatusClick(t, season.season_number)}
direction="bot"
width="100%"
small
/>
</div>
{/if}
</div>
{#if season?.episodes?.length > 0}
<ul>
{#each season.episodes as ep}
<SeasonsListEpisode {ep} {watchedItem} />
{/each}
</ul>
{:else}
<h3 class="norm">No episodes in this season yet!</h3>
{/if}
{:catch err}
<Error pretty="Failed to load season details!" error={err} />
{/await}
</div>
{#if watchedItem}
{@const status = checkSeasonStatus(
watchedItem.watchedSeasons,
season,
)}
{#if status}
<div class="plain season-status">
<Icon i={watchedStatuses[status]} />
</div>
{/if}
{/if}
</div>
</button>
{/each}
<div class="last"></div>
</ul>
<div class="episodes">
{#await seasonDetailsReq}
<Spinner />
{:then season}
<div class="episodes-topbar">
<h3>{season.name}</h3>
{#if watchedItem}
{@const ws = watchedItem?.watchedSeasons?.find(
(s) => s.seasonNumber === season.season_number,
)}
{#if ws}
<div class="rating">
<PosterRating
rating={ws?.rating}
btnTooltip="Season Rating"
handleStarClick={(r) =>
handleStarClick(r, season.season_number)}
minimal={true}
direction="bot"
/>
</div>
{/if}
<div class="status">
<PosterStatus
status={ws?.status}
btnTooltip="Season Status"
handleStatusClick={(t) =>
handleStatusClick(t, season.season_number)}
direction="bot"
width="100%"
small
/>
</div>
{/if}
</div>
{#if season?.episodes?.length > 0}
<ul>
{#each season.episodes as ep}
<SeasonsListEpisode {ep} {watchedItem} />
{/each}
</ul>
{:else}
<h3 class="norm">No episodes in this season yet!</h3>
{/if}
{:catch err}
<Error pretty="Failed to load season details!" error={err} />
{/await}
</div>
</div>
<style lang="scss">
.ctr {
display: flex;
flex-flow: row;
gap: 20px;
width: 100%;
}
.ctr {
display: flex;
flex-flow: row;
gap: 20px;
width: 100%;
}
.episodes {
width: 100%;
.episodes {
width: 100%;
ul {
display: flex;
flex-flow: column;
list-style: none;
gap: 20px;
}
}
ul {
display: flex;
flex-flow: column;
list-style: none;
gap: 20px;
}
}
ul.seasons {
display: flex;
flex-flow: column;
list-style: none;
gap: 8px;
min-width: fit-content;
height: 100vh;
overflow: auto;
position: sticky;
top: 0px;
transition: top 200ms ease-in-out;
ul.seasons {
display: flex;
flex-flow: column;
list-style: none;
gap: 8px;
min-width: fit-content;
height: 100vh;
overflow: auto;
position: sticky;
top: 0px;
transition: top 200ms ease-in-out;
button {
display: flex;
flex-flow: row;
flex-wrap: wrap;
gap: 0 18px;
align-items: center;
padding: 10px;
border: 2px solid #302d2d;
border-radius: 8px;
cursor: pointer;
max-width: 220px;
transition: background-color 100ms ease;
button {
display: flex;
flex-flow: row;
gap: 0 18px;
align-items: center;
border: 2px solid #302d2d;
border-radius: 8px;
padding: 4px 8px;
cursor: pointer;
min-width: 160px;
max-width: 220px;
transition: background-color 100ms ease;
&:first-of-type {
margin-top: 10px;
}
& > div {
&:first-of-type {
display: flex;
flex-flow: column;
}
h1 {
font-size: 18px;
font-family: sans-serif;
}
&:last-of-type {
display: flex;
flex-flow: column;
margin-left: auto;
margin-bottom: auto;
padding-top: 5px;
}
}
h2 {
font-size: 12px;
font-family: sans-serif;
margin-left: auto;
}
&:first-of-type {
margin-top: 10px;
}
&:hover,
&.active {
color: white;
background-color: black;
}
.season-name {
text-align: left;
}
&.active {
position: sticky;
top: 10px;
bottom: 10px;
}
}
.season-name,
.season-episodes {
margin-right: auto;
}
/* hack to get extra scroll space under last el */
.last {
padding: 1px;
}
}
.season-date,
.season-status {
margin-left: auto;
}
.episodes-topbar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 20px;
min-height: 40px;
.season-episodes {
color: $text-color-accent;
}
div {
transition: width 100ms ease;
.season-status {
fill: $text-color;
&:first-of-type {
margin-left: auto;
}
:global(svg) {
width: 20px;
height: 20px;
}
}
&.rating {
height: 40px;
min-height: 40px;
}
h1 {
font-size: 18px;
}
&.status {
width: 45px;
min-height: 40px;
height: 40px;
overflow: visible;
}
}
}
h2 {
font-size: 12px;
}
@media screen and (min-width: 960px) {
:global(body.nav-shown) ul.seasons {
top: $nav-height;
height: calc(100vh - $nav-height);
}
}
h1,
h2 {
font-family: sans-serif;
}
@media screen and (max-width: 960px) {
.ctr {
flex-flow: column;
}
&:hover,
&.active {
color: $bg-color;
background-color: $text-color;
ul.seasons {
flex-flow: row;
flex-wrap: wrap;
position: unset;
height: unset;
justify-content: center;
.season-status {
fill: $bg-color;
}
button {
&:first-of-type {
margin-top: unset;
}
&.active {
position: unset;
}
}
}
}
.season-episodes {
color: $bg-color-accent;
}
}
&.active {
position: sticky;
top: 10px;
bottom: 10px;
.season-status {
fill: $bg-color;
}
}
}
/* hack to get extra scroll space under last el */
.last {
padding: 1px;
}
}
.episodes-topbar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 20px;
min-height: 40px;
div {
transition: width 100ms ease;
&:first-of-type {
margin-left: auto;
}
&.rating {
height: 40px;
min-height: 40px;
}
&.status {
width: 45px;
min-height: 40px;
height: 40px;
overflow: visible;
}
}
}
@media screen and (min-width: 960px) {
:global(body.nav-shown) ul.seasons {
top: $nav-height;
height: calc(100vh - $nav-height);
}
}
@media screen and (max-width: 960px) {
.ctr {
flex-flow: column;
}
:global(body.nav-shown) ul.seasons {
top: $nav-height;
}
ul.seasons {
flex-flow: row;
flex-wrap: nowrap;
position: unset;
height: unset;
justify-content: unset;
overflow: auto;
min-width: unset;
position: sticky;
top: 0px;
padding: 10px 0;
z-index: 5;
@include nav-blur;
button {
&:first-of-type {
margin-top: unset;
}
&.active {
position: unset;
}
}
}
}
</style>
+382 -367
View File
@@ -1,408 +1,423 @@
<script lang="ts">
import type {
TMDBSeasonDetailsEpisode,
WatchedStatus,
WatchedEpisodeAddResponse,
Watched
} from "@/types";
import Icon from "./Icon.svelte";
import { userSettings } from "@/store";
import PosterRating from "./poster/PosterRating.svelte";
import PosterStatus from "./poster/PosterStatus.svelte";
import axios from "axios";
import { notify } from "./util/notify";
import { get } from "svelte/store";
import { watchedList } from "@/store";
import type {
TMDBSeasonDetailsEpisode,
WatchedStatus,
WatchedEpisodeAddResponse,
Watched,
} from "@/types";
import Icon from "./Icon.svelte";
import PosterRating from "./poster/PosterRating.svelte";
import PosterStatus from "./poster/PosterStatus.svelte";
import axios from "axios";
import { notify } from "./util/notify";
import { get } from "svelte/store";
import { store } from "@/store.svelte";
$: settings = $userSettings;
// let settings = $derived($userSettings);
export let ep: TMDBSeasonDetailsEpisode;
export let watchedItem: Watched | undefined;
interface Props {
ep: TMDBSeasonDetailsEpisode;
watchedItem: Watched | undefined;
}
let isHidden = false;
let { ep, watchedItem }: Props = $props();
$: {
if (settings) isHidden = settings.hideSpoilers;
}
let isHidden: boolean = $state(!!store?.userSettings?.hideSpoilers);
function updateWatchedEpisode(status?: WatchedStatus, rating?: number) {
if (!watchedItem) {
console.error("SeasonListEpisode: updateWatchedEpisode: No watched item.");
return;
}
const nid = notify({ text: `Saving`, type: "loading" });
axios
.post<WatchedEpisodeAddResponse>(`/watched/episode`, {
watchedId: watchedItem.id,
seasonNumber: ep.season_number,
episodeNumber: ep.episode_number,
status,
rating
})
.then((r) => {
const wList = get(watchedList);
const wEntry = wList.find((w) => w.id === watchedItem.id);
if (!wEntry) {
notify({
id: nid,
text: `Request succeeded, but failed to find local data. Please refresh.`,
type: "error"
});
return;
}
if (r.status === 200) {
wEntry.watchedEpisodes = r.data.watchedEpisodes;
if (wEntry.activity?.length > 0) {
wEntry.activity.push(r.data.addedActivity);
} else {
wEntry.activity = [r.data.addedActivity];
}
try {
const epHookResp = r?.data?.episodeStatusChangedHookResponse;
if (epHookResp && Object.keys(epHookResp).length > 0) {
if (epHookResp.errors && epHookResp.errors.length > 0) {
console.error(
"episodeStatusChangedHookResponse contained errors! All possible automations may not have been completed.",
epHookResp.errors
);
notify({
type: "error",
text: "Some automations have failed, check console for more info."
});
}
if (epHookResp.addedActivities && epHookResp.addedActivities.length > 0) {
wEntry.activity.push(...epHookResp.addedActivities);
}
if (epHookResp.watchedSeason) {
if (!wEntry.watchedSeasons) {
wEntry.watchedSeasons = [epHookResp.watchedSeason];
} else {
const watchedSeasonIdx = wEntry.watchedSeasons.findIndex(
(s) => s.id === epHookResp.watchedSeason?.id
);
if (watchedSeasonIdx === -1) {
wEntry.watchedSeasons.push(epHookResp.watchedSeason);
} else {
wEntry.watchedSeasons[watchedSeasonIdx] = epHookResp.watchedSeason;
}
}
}
if (epHookResp.newShowStatus) {
wEntry.status = epHookResp.newShowStatus;
}
}
} catch (err) {
console.error("Failed to process episodeStatusChangedHookResponse", err);
notify({
type: "error",
text: "Failed to process automation response, check console for more info."
});
}
watchedList.update((w) => w);
notify({ id: nid, text: `Saved!`, type: "success" });
}
})
.catch((err) => {
console.error(err);
notify({ id: nid, text: "Failed To Update!", type: "error" });
});
}
function updateWatchedEpisode(status?: WatchedStatus, rating?: number) {
if (!watchedItem) {
console.error(
"SeasonListEpisode: updateWatchedEpisode: No watched item.",
);
return;
}
const nid = notify({ text: `Saving`, type: "loading" });
axios
.post<WatchedEpisodeAddResponse>(`/watched/episode`, {
watchedId: watchedItem.id,
seasonNumber: ep.season_number,
episodeNumber: ep.episode_number,
status,
rating,
})
.then((r) => {
const wEntry = store.watchedList.find((w) => w.id === watchedItem.id);
if (!wEntry) {
notify({
id: nid,
text: `Request succeeded, but failed to find local data. Please refresh.`,
type: "error",
});
return;
}
if (r.status === 200) {
wEntry.watchedEpisodes = r.data.watchedEpisodes;
if (wEntry.activity?.length > 0) {
wEntry.activity.push(r.data.addedActivity);
} else {
wEntry.activity = [r.data.addedActivity];
}
try {
const epHookResp = r?.data?.episodeStatusChangedHookResponse;
if (epHookResp && Object.keys(epHookResp).length > 0) {
if (epHookResp.errors && epHookResp.errors.length > 0) {
console.error(
"episodeStatusChangedHookResponse contained errors! All possible automations may not have been completed.",
epHookResp.errors,
);
notify({
type: "error",
text: "Some automations have failed, check console for more info.",
});
}
if (
epHookResp.addedActivities &&
epHookResp.addedActivities.length > 0
) {
wEntry.activity.push(...epHookResp.addedActivities);
}
if (epHookResp.watchedSeason) {
if (!wEntry.watchedSeasons) {
wEntry.watchedSeasons = [epHookResp.watchedSeason];
} else {
const watchedSeasonIdx = wEntry.watchedSeasons.findIndex(
(s) => s.id === epHookResp.watchedSeason?.id,
);
if (watchedSeasonIdx === -1) {
wEntry.watchedSeasons.push(epHookResp.watchedSeason);
} else {
wEntry.watchedSeasons[watchedSeasonIdx] =
epHookResp.watchedSeason;
}
}
}
if (epHookResp.newShowStatus) {
wEntry.status = epHookResp.newShowStatus;
}
}
} catch (err) {
console.error(
"Failed to process episodeStatusChangedHookResponse",
err,
);
notify({
type: "error",
text: "Failed to process automation response, check console for more info.",
});
}
notify({ id: nid, text: `Saved!`, type: "success" });
}
})
.catch((err) => {
console.error(err);
notify({ id: nid, text: "Failed To Update!", type: "error" });
});
}
function handleStatusClick(type: WatchedStatus | "DELETE") {
if (!watchedItem) {
console.error("SeasonListEpisode: handleStatusClick: No watched item.");
return;
}
if (type === "DELETE") {
const ws = watchedItem.watchedEpisodes?.find(
(s) => s.seasonNumber === ep.season_number && s.episodeNumber === ep.episode_number
);
if (!ws) {
notify({
text: "Failed to find watched episode id. Please try refreshing.",
type: "error"
});
return;
}
const nid = notify({ text: `Saving`, type: "loading" });
axios
.delete(`/watched/episode/${ws.id}`)
.then((r) => {
const wList = get(watchedList);
const wEntry = wList.find((w) => w.id === watchedItem.id);
if (!wEntry) {
notify({
id: nid,
text: `Request succeeded, but failed to find local data. Please refresh.`,
type: "error"
});
return;
}
if (r.status === 200) {
wEntry.watchedEpisodes = wEntry.watchedEpisodes?.filter((s) => s.id !== ws.id);
if (r.data) {
if (wEntry.activity?.length > 0) {
wEntry.activity.push(r.data);
} else {
wEntry.activity = [r.data];
}
}
watchedList.update((w) => w);
notify({ id: nid, text: `Removed!`, type: "success" });
}
})
.catch((err) => {
console.error(err);
notify({ id: nid, text: "Failed To Remove!", type: "error" });
});
return;
}
updateWatchedEpisode(type);
}
function handleStatusClick(type: WatchedStatus | "DELETE") {
if (!watchedItem) {
console.error("SeasonListEpisode: handleStatusClick: No watched item.");
return;
}
if (type === "DELETE") {
const ws = watchedItem.watchedEpisodes?.find(
(s) =>
s.seasonNumber === ep.season_number &&
s.episodeNumber === ep.episode_number,
);
if (!ws) {
notify({
text: "Failed to find watched episode id. Please try refreshing.",
type: "error",
});
return;
}
const nid = notify({ text: `Saving`, type: "loading" });
axios
.delete(`/watched/episode/${ws.id}`)
.then((r) => {
const wEntry = store.watchedList.find((w) => w.id === watchedItem.id);
if (!wEntry) {
notify({
id: nid,
text: `Request succeeded, but failed to find local data. Please refresh.`,
type: "error",
});
return;
}
if (r.status === 200) {
wEntry.watchedEpisodes = wEntry.watchedEpisodes?.filter(
(s) => s.id !== ws.id,
);
if (r.data) {
if (wEntry.activity?.length > 0) {
wEntry.activity.push(r.data);
} else {
wEntry.activity = [r.data];
}
}
notify({ id: nid, text: `Removed!`, type: "success" });
}
})
.catch((err) => {
console.error(err);
notify({ id: nid, text: "Failed To Remove!", type: "error" });
});
return;
}
updateWatchedEpisode(type);
}
function handleStarClick(rating: number) {
updateWatchedEpisode(undefined, rating);
}
function handleStarClick(rating: number) {
updateWatchedEpisode(undefined, rating);
}
</script>
<li class={isHidden ? "dont-spoil" : ""}>
{#if ep.still_path}
<img src={`https://www.themoviedb.org/t/p/w227_and_h127_bestv2/${ep.still_path}`} alt="" />
{:else}
<div class="no-still" />
{/if}
<div class="info">
<div>
<span>
<b>{ep.episode_number}</b>
<span class="episode-name">{ep.name}</span>
{#if ep.runtime}
<span class="episode-runtime" title="This episode has a runtime of {ep.runtime} minutes."
>{ep.runtime} min</span
>
{/if}
</span>
<span
class="rating"
title={`TMDB Rating: ${ep.vote_average} out of 10 (based on ${ep.vote_count} votes)`}
>
<span>*</span>
{Math.round(ep.vote_average * 10) / 10}
</span>
</div>
<span class="overview">{ep.overview}</span>
</div>
{#if watchedItem}
{@const we = watchedItem.watchedEpisodes?.find(
(s) => s.seasonNumber === ep.season_number && s.episodeNumber === ep.episode_number
)}
<div class="status-rating-ctr">
<div class="rating" style={"width: 45px"}>
<PosterRating
rating={we?.rating}
btnTooltip={`Episode ${ep.episode_number} Rating`}
handleStarClick={(r) => handleStarClick(r)}
minimal={true}
direction="bot"
hideStarWhenRated
/>
</div>
<div class="status">
<PosterStatus
status={we?.status}
btnTooltip={`Episode ${ep.episode_number} Status`}
handleStatusClick={(t) => handleStatusClick(t)}
direction="bot"
width="100%"
small
/>
</div>
</div>
{/if}
{#if isHidden}
<button class="plain spoiler-text" on:click={() => (isHidden = false)}>
<Icon i="eye-closed" wh={34} />
<span>Click To Reveal</span>
</button>
{/if}
{#if ep.still_path}
<img
src={`https://www.themoviedb.org/t/p/w227_and_h127_bestv2/${ep.still_path}`}
alt=""
/>
{:else}
<div class="no-still"></div>
{/if}
<div class="info">
<div>
<span>
<b>{ep.episode_number}</b>
<span class="episode-name">{ep.name}</span>
{#if ep.runtime}
<span
class="episode-runtime"
title="This episode has a runtime of {ep.runtime} minutes."
>{ep.runtime} min</span
>
{/if}
</span>
<span
class="rating"
title={`TMDB Rating: ${ep.vote_average} out of 10 (based on ${ep.vote_count} votes)`}
>
<span>*</span>
{Math.round(ep.vote_average * 10) / 10}
</span>
</div>
<span class="overview">{ep.overview}</span>
</div>
{#if watchedItem}
{@const we = watchedItem.watchedEpisodes?.find(
(s) =>
s.seasonNumber === ep.season_number &&
s.episodeNumber === ep.episode_number,
)}
<div class="status-rating-ctr">
<div class="rating" style={"width: 45px"}>
<PosterRating
rating={we?.rating}
btnTooltip={`Episode ${ep.episode_number} Rating`}
handleStarClick={(r) => handleStarClick(r)}
minimal={true}
direction="bot"
hideStarWhenRated
/>
</div>
<div class="status">
<PosterStatus
status={we?.status}
btnTooltip={`Episode ${ep.episode_number} Status`}
handleStatusClick={(t) => handleStatusClick(t)}
direction="bot"
width="100%"
small
/>
</div>
</div>
{/if}
{#if isHidden}
<button class="plain spoiler-text" onclick={() => (isHidden = false)}>
<Icon i="eye-closed" wh={34} />
<span>Click To Reveal</span>
</button>
{/if}
</li>
<style lang="scss">
li {
display: flex;
flex-flow: row;
gap: 8px;
position: relative;
li {
display: flex;
flex-flow: row;
gap: 8px;
position: relative;
img,
.no-still {
width: 227px;
min-width: 227px;
height: 127px;
min-height: 127px;
border-radius: 10px;
background-color: rgb(0, 0, 0);
object-fit: fill;
img,
.no-still {
width: 227px;
min-width: 227px;
height: 127px;
min-height: 127px;
border-radius: 10px;
background-color: rgb(0, 0, 0);
object-fit: fill;
@media screen and (max-width: 590px) {
width: 80%;
height: auto;
}
@media screen and (max-width: 590px) {
width: 80%;
height: auto;
}
@media screen and (max-width: 450px) {
width: 100%;
}
}
@media screen and (max-width: 450px) {
width: 100%;
}
}
.info {
display: flex;
flex-flow: column;
.info {
display: flex;
flex-flow: column;
& > div {
display: flex;
flex-flow: row;
align-items: center;
& > div {
display: flex;
flex-flow: row;
align-items: center;
.episode-name,
.episode-runtime {
text-transform: lowercase;
font-variant: small-caps;
font-weight: bold;
font-size: 16px;
}
.episode-name,
.episode-runtime {
text-transform: lowercase;
font-variant: small-caps;
font-weight: bold;
font-size: 16px;
}
.episode-runtime {
font-size: 14px;
padding: 0 2px;
}
.episode-runtime {
font-size: 14px;
padding: 0 2px;
}
.rating {
display: flex;
align-items: start;
justify-content: center;
font-size: 15px;
color: $rating-color;
font-weight: bolder;
overflow: hidden;
.rating {
display: flex;
align-items: start;
justify-content: center;
font-size: 15px;
color: $rating-color;
font-weight: bolder;
overflow: hidden;
span {
margin-top: 2px;
font-family: "Rampart One";
-webkit-text-stroke: 1px $rating-color;
font-size: 25px;
line-height: 0.7;
}
}
}
}
span {
margin-top: 2px;
font-family: "Rampart One";
-webkit-text-stroke: 1px $rating-color;
font-size: 25px;
line-height: 0.7;
}
}
}
}
.status-rating-ctr {
display: flex;
align-items: center;
flex-flow: column-reverse;
gap: 10px;
margin-bottom: auto;
min-height: 40px;
margin-left: auto;
.status-rating-ctr {
display: flex;
align-items: center;
flex-flow: column-reverse;
gap: 10px;
margin-bottom: auto;
min-height: 40px;
margin-left: auto;
div {
transition: width 100ms ease;
div {
transition: width 100ms ease;
&:first-of-type {
margin-left: auto;
}
&:first-of-type {
margin-left: auto;
}
&.rating {
height: 40px;
min-height: 40px;
}
&.rating {
height: 40px;
min-height: 40px;
}
&.status {
width: 45px;
min-height: 40px;
height: 40px;
overflow: visible;
}
}
}
&.status {
width: 45px;
min-height: 40px;
height: 40px;
overflow: visible;
}
}
}
span {
padding: 3px 5px;
span {
padding: 3px 5px;
@media screen and (max-width: 590px) {
text-align: center;
}
}
@media screen and (max-width: 590px) {
text-align: center;
}
}
.spoiler-text {
display: flex;
flex-flow: column;
align-items: center;
justify-content: center;
gap: 8px;
position: absolute;
width: 100%;
height: 100%;
font-weight: bolder;
font-size: 20px;
fill: $text-color;
opacity: 0;
transition:
visibility 150ms ease-in,
opacity 150ms ease-in;
cursor: pointer;
.spoiler-text {
display: flex;
flex-flow: column;
align-items: center;
justify-content: center;
gap: 8px;
position: absolute;
width: 100%;
height: 100%;
font-weight: bolder;
font-size: 20px;
fill: $text-color;
opacity: 0;
transition:
visibility 150ms ease-in,
opacity 150ms ease-in;
cursor: pointer;
span {
text-shadow: 0 0 6px $bg-color;
}
span {
text-shadow: 0 0 6px $bg-color;
}
:global(svg) {
filter: drop-shadow(0 0 8px $bg-color);
}
:global(svg) {
filter: drop-shadow(0 0 8px $bg-color);
}
&:hover,
&:active,
&:focus {
opacity: 1;
}
}
&:hover,
&:active,
&:focus {
opacity: 1;
}
}
img,
.episode-name,
.rating,
.overview {
transition: filter 150ms ease-out;
}
img,
.episode-name,
.rating,
.overview {
transition: filter 150ms ease-out;
}
&.dont-spoil {
.episode-name,
.rating,
.overview {
filter: blur(4px);
}
&.dont-spoil {
.episode-name,
.rating,
.overview {
filter: blur(4px);
}
img {
filter: blur(6px);
}
}
}
img {
filter: blur(6px);
}
}
}
@media screen and (max-width: 590px) {
li {
align-items: center;
flex-flow: column;
width: 100%;
height: 100%;
@media screen and (max-width: 590px) {
li {
align-items: center;
flex-flow: column;
width: 100%;
height: 100%;
.status-rating-ctr {
flex-flow: row;
justify-content: center;
margin-left: unset;
}
}
.status-rating-ctr {
flex-flow: row;
justify-content: center;
margin-left: unset;
}
}
.rating {
margin-left: auto;
}
}
.rating {
margin-left: auto;
}
}
</style>
+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>
+56 -52
View File
@@ -1,66 +1,70 @@
<script lang="ts">
import type { PublicUser } from "@/types";
import type { PublicUser } from "@/types";
export let users: PublicUser[];
interface Props {
users: PublicUser[];
}
let { users }: Props = $props();
</script>
<div>
<h2>Users</h2>
<ul>
{#each users as user}
<li title={user.username}>
<a href="/lists/{user.id}/{user.username}">
<span>{user.username}</span>
</a>
</li>
{/each}
</ul>
<h2>Users</h2>
<ul>
{#each users as user}
<li title={user.username}>
<a href="/lists/{user.id}/{user.username}">
<span>{user.username}</span>
</a>
</li>
{/each}
</ul>
</div>
<style lang="scss">
div {
display: flex;
flex-flow: column;
margin: 5px 15px;
}
div {
display: flex;
flex-flow: column;
margin: 5px 15px;
}
ul {
display: flex;
flex-flow: row;
gap: 10px;
list-style: none;
overflow: auto;
padding: 15px;
ul {
display: flex;
flex-flow: row;
gap: 10px;
list-style: none;
overflow: auto;
padding: 15px;
li {
display: flex;
max-width: 200px;
li {
display: flex;
max-width: 200px;
a {
display: flex;
flex-flow: column;
gap: 8px;
width: 100%;
height: 100%;
padding: 3px 8px;
padding: 10px 20px;
border-radius: 8px;
background-color: $accent-color;
transition:
background-color 150ms ease,
color 150ms ease;
a {
display: flex;
flex-flow: column;
gap: 8px;
width: 100%;
height: 100%;
padding: 3px 8px;
padding: 10px 20px;
border-radius: 8px;
background-color: $accent-color;
transition:
background-color 150ms ease,
color 150ms ease;
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&:hover {
color: $bg-color;
background-color: $accent-color-hover;
}
}
}
}
&:hover {
color: $bg-color;
background-color: $accent-color-hover;
}
}
}
}
</style>
+266 -247
View File
@@ -1,264 +1,283 @@
<script lang="ts">
import { goto } from "$app/navigation";
import Icon from "@/lib/Icon.svelte";
import Poster from "@/lib/poster/Poster.svelte";
import PosterList from "@/lib/poster/PosterList.svelte";
import {
activeFilters,
activeSort,
clearActiveFilters,
serverFeatures,
userSettings
} from "@/store";
import type { Watched } from "@/types";
import GamePoster from "./poster/GamePoster.svelte";
import { get } from "svelte/store";
import { getLatestWatchedInTv } from "./util/helpers";
import { notify } from "./util/notify";
import { goto } from "$app/navigation";
import Icon from "@/lib/Icon.svelte";
import Poster from "@/lib/poster/Poster.svelte";
import PosterList from "@/lib/poster/PosterList.svelte";
import { store, clearActiveFilters } from "@/store.svelte";
import type { Watched } from "@/types";
import GamePoster from "./poster/GamePoster.svelte";
import { notify } from "./util/notify";
import Spinner from "./Spinner.svelte";
export let list: Watched[];
export let isPublicList: boolean = false;
interface Props {
list: Watched[];
isLoading: boolean;
isPublicList?: boolean;
}
$: sort = $activeSort;
$: filters = $activeFilters;
$: watched = list;
$: settings = $userSettings;
$: features = $serverFeatures;
let { list, isPublicList = false, isLoading = false }: Props = $props();
/**
* Checks if content has been watched previously
* by analyzing the watched entrys activity (with
* the latest AI improvements added in of course.)
*/
function contentWatchedPreviously(w: Watched) {
let wp = false;
const relatedActivity = w.activity.filter(
(a) =>
a.type === "ADDED_WATCHED" ||
a.type === "IMPORTED_ADDED_WATCHED" ||
a.type === "IMPORTED_WATCHED" ||
a.type === "STATUS_CHANGED"
);
for (let i = 0; i < relatedActivity.length; i++) {
const ra = relatedActivity[i];
if (ra.type === "IMPORTED_ADDED_WATCHED") {
wp = true;
break;
} else if (ra.type === "ADDED_WATCHED" || ra.type === "IMPORTED_WATCHED") {
const data = JSON.parse(ra.data);
if (data?.status == "FINISHED") {
wp = true;
break;
}
} else if (ra.type === "STATUS_CHANGED") {
if (ra.data === "FINISHED") {
wp = true;
break;
}
}
}
return wp;
}
let sort = $derived(store.activeSort);
let filters = $derived(store.activeFilters);
let settings = $derived(store.userSettings);
let watched: Watched[] = $state([]);
// Monsterous code for filters. Soz.
$: (watched, filters, sort), filt();
/**
* Checks if content has been watched previously
* by analyzing the watched entrys activity.
*/
function contentWatchedPreviously(w: Watched) {
let wp = false;
const relatedActivity = w.activity.filter(
(a) =>
a.type === "ADDED_WATCHED" ||
a.type === "IMPORTED_ADDED_WATCHED" ||
a.type === "IMPORTED_WATCHED" ||
a.type === "STATUS_CHANGED",
);
for (let i = 0; i < relatedActivity.length; i++) {
const ra = relatedActivity[i];
if (ra.type === "IMPORTED_ADDED_WATCHED") {
wp = true;
break;
} else if (
ra.type === "ADDED_WATCHED" ||
ra.type === "IMPORTED_WATCHED"
) {
const data = JSON.parse(ra.data);
if (data?.status == "FINISHED") {
wp = true;
break;
}
} else if (ra.type === "STATUS_CHANGED") {
if (ra.data === "FINISHED") {
wp = true;
break;
}
}
}
return wp;
}
function filt() {
try {
// Set watched to list and sort it.
watched = list
.sort((a, b) => {
if (sort[0] === "DATEADDED" && sort[1] === "UP") {
return Date.parse(a.createdAt) - Date.parse(b.createdAt);
} else if (sort[0] === "ALPHA") {
const atitle = a.content ? a.content.title : a.game ? a.game.name : "";
const btitle = b.content ? b.content.title : b.game ? b.game.name : "";
if (sort[1] === "UP") {
return atitle.localeCompare(btitle);
} else if (sort[1] === "DOWN") {
return btitle.localeCompare(atitle);
}
} else if (sort[0] === "LASTCHANGED") {
if (sort[1] === "UP") return Date.parse(a.updatedAt) - Date.parse(b.updatedAt);
else if (sort[1] === "DOWN") return Date.parse(b.updatedAt) - Date.parse(a.updatedAt);
} else if (sort[0] === "LASTFIN") {
const aLastFinishActivity = a.activity
?.sort(
(aa, bb) =>
Date.parse(bb.customDate ?? bb.updatedAt) -
Date.parse(aa.customDate ?? aa.updatedAt)
)
?.find(
(aa) =>
(aa.type === "STATUS_CHANGED" && aa.data === "FINISHED") ||
(aa.type === "ADDED_WATCHED" && aa.data?.includes("FINISHED"))
);
const bLastFinishActivity = b.activity
?.sort(
(aa, bb) =>
Date.parse(bb.customDate ?? bb.updatedAt) -
Date.parse(aa.customDate ?? aa.updatedAt)
)
?.find(
(aa) =>
(aa.type === "STATUS_CHANGED" && aa.data === "FINISHED") ||
(aa.type === "ADDED_WATCHED" && aa.data?.includes("FINISHED"))
);
if (!aLastFinishActivity) return 1;
if (!bLastFinishActivity) return -1;
const alfaDate = aLastFinishActivity.customDate ?? aLastFinishActivity.updatedAt;
const blfaDate = bLastFinishActivity.customDate ?? bLastFinishActivity.updatedAt;
if (sort[1] === "UP") return Date.parse(alfaDate) - Date.parse(blfaDate);
else if (sort[1] === "DOWN") return Date.parse(blfaDate) - Date.parse(alfaDate);
} else if (sort[0] === "RATING") {
if (sort[1] === "UP") return (a.rating ?? 0) - (b.rating ?? 0);
else if (sort[1] === "DOWN") return (b.rating ?? 0) - (a.rating ?? 0);
}
// default DATEADDED DOWN
return Date.parse(b.createdAt) - Date.parse(a.createdAt);
})
.sort((a, b) => {
if (a.pinned && !b.pinned) return -1;
if (!a.pinned && b.pinned) return 1;
return 0;
});
// If games type filter enabled, but games disabled on server, make sure we remove it from active filters.
if (!features.games) {
const af = get(activeFilters);
af.type = af.type?.filter((a) => a !== "game");
filters.type = filters.type.filter((f) => f !== "game");
}
// Now apply filters to watch list.
if (filters.status.length > 0 && filters.type.length > 0) {
// If status and type filters applied, combine both.
if (settings?.includePreviouslyWatched && filters.status.includes("finished")) {
watched = watched.filter(
(w) =>
(filters.status.includes(w.status?.toLowerCase()) || contentWatchedPreviously(w)) &&
filters.type.includes(w.content ? w.content.type : w.game ? "game" : "")
);
} else {
watched = watched.filter(
(w) =>
filters.status.includes(w.status?.toLowerCase()) &&
filters.type.includes(w.content ? w.content.type : w.game ? "game" : "")
);
}
} else if (filters.type.length > 0) {
// Only filter type
watched = watched.filter((w) =>
filters.type.includes(w.content ? w.content.type : w.game ? "game" : "")
);
} else if (filters.status.length > 0) {
// Only filter status
if (settings?.includePreviouslyWatched && filters.status.includes("finished")) {
watched = watched.filter(
(w) => filters.status.includes(w.status?.toLowerCase()) || contentWatchedPreviously(w)
);
} else {
watched = watched.filter((w) => filters.status.includes(w.status?.toLowerCase()));
}
}
} catch (err) {
console.error("filt: Failed to filter/sort current list!", err);
notify({ text: "Failed to filter/sort list!", type: "error", time: 6000 });
}
}
// Monsterous code for filters. Soz.
function filt() {
console.debug("WatchedList: filt()");
try {
// Set watched to list and sort it.
watched = list
.sort((a, b) => {
if (sort[0] === "LASTFIN") {
const aLastFinishActivity = a.activity
?.sort(
(aa, bb) =>
Date.parse(bb.customDate ?? bb.updatedAt) -
Date.parse(aa.customDate ?? aa.updatedAt),
)
?.find(
(aa) =>
// PORT NOTE: ALSO `IMPORTED_WATCHED` & `IMPORTED_ADDED_WATCHED`
(aa.type === "STATUS_CHANGED" && aa.data === "FINISHED") ||
(aa.type === "ADDED_WATCHED" &&
aa.data?.includes("FINISHED")),
);
const bLastFinishActivity = b.activity
?.sort(
(aa, bb) =>
Date.parse(bb.customDate ?? bb.updatedAt) -
Date.parse(aa.customDate ?? aa.updatedAt),
)
?.find(
(aa) =>
(aa.type === "STATUS_CHANGED" && aa.data === "FINISHED") ||
(aa.type === "ADDED_WATCHED" &&
aa.data?.includes("FINISHED")),
);
if (!aLastFinishActivity) return 1;
if (!bLastFinishActivity) return -1;
const alfaDate =
aLastFinishActivity.customDate ?? aLastFinishActivity.updatedAt;
const blfaDate =
bLastFinishActivity.customDate ?? bLastFinishActivity.updatedAt;
if (sort[1] === "UP")
return Date.parse(alfaDate) - Date.parse(blfaDate);
else if (sort[1] === "DOWN")
return Date.parse(blfaDate) - Date.parse(alfaDate);
}
// default DATEADDED DOWN
return Date.parse(b.createdAt) - Date.parse(a.createdAt);
})
.sort((a, b) => {
if (a.pinned && !b.pinned) return -1;
if (!a.pinned && b.pinned) return 1;
return 0;
});
// Now apply filters to watch list.
if (filters.status.length > 0 && filters.type.length > 0) {
// If status and type filters applied, combine both.
if (
settings?.includePreviouslyWatched &&
filters.status.includes("finished")
) {
watched = watched.filter(
(w) =>
(filters.status.includes(w.status?.toLowerCase()) ||
contentWatchedPreviously(w)) &&
filters.type.includes(
w.content ? w.content.type : w.game ? "game" : "",
),
);
} else {
watched = watched.filter(
(w) =>
filters.status.includes(w.status?.toLowerCase()) &&
filters.type.includes(
w.content ? w.content.type : w.game ? "game" : "",
),
);
}
} else if (filters.type.length > 0) {
// Only filter type
watched = watched.filter((w) =>
filters.type.includes(
w.content ? w.content.type : w.game ? "game" : "",
),
);
} else if (filters.status.length > 0) {
// Only filter status
if (
settings?.includePreviouslyWatched &&
filters.status.includes("finished")
) {
watched = watched.filter(
(w) =>
filters.status.includes(w.status?.toLowerCase()) ||
contentWatchedPreviously(w),
);
} else {
watched = watched.filter((w) =>
filters.status.includes(w.status?.toLowerCase()),
);
}
}
} catch (err) {
console.error("filt: Failed to filter/sort current list!", err);
notify({
text: "Failed to filter/sort list!",
type: "error",
time: 6000,
});
}
}
/**
* Callback for when a watched list item is updated through poster,
* this allows us to run the filt() func again so the sorting is
* updated.
*/
function itemUpdated() {
console.debug("itemUpdated");
// filt();
}
</script>
<PosterList>
{#if watched?.length > 0}
{#each watched as w (w.id)}
{#if w.game}
<GamePoster
id={w.id}
rating={w.rating}
status={w.status}
media={{
id: w.game.igdbId,
coverId: w.game.coverId,
name: w.game.name,
summary: w.game.summary,
firstReleaseDate: w.game.releaseDate,
poster: w.game.poster
}}
disableInteraction={isPublicList}
extraDetails={{
dateAdded: w.createdAt,
dateModified: w.updatedAt
}}
fluidSize={true}
pinned={w.pinned}
/>
{:else if w.content}
<Poster
id={w.id}
media={{
id: w.content.tmdbId,
poster_path: w.content.poster_path,
title: w.content.title,
overview: w.content.overview,
media_type: w.content.type,
release_date: w.content.release_date,
first_air_date: w.content.first_air_date
}}
rating={w.rating}
status={w.status}
disableInteraction={isPublicList}
extraDetails={{
dateAdded: w.createdAt,
dateModified: w.updatedAt,
lastWatched: getLatestWatchedInTv(w.watchedSeasons, w.watchedEpisodes)
}}
fluidSize={true}
pinned={w.pinned}
/>
{/if}
{/each}
{:else}
<div class="empty-list">
{#if list?.length > 0}
<!-- `watched` (filtered list) is empty, but `list` (unfiltered) isn't,
{#if list?.length > 0}
{#each list as w, i (w.id)}
{#if w.game}
<GamePoster
id={w.id}
rating={w.rating}
status={w.status}
media={{
id: w.game.igdbId,
coverId: w.game.coverId,
name: w.game.name,
summary: w.game.summary,
firstReleaseDate: w.game.releaseDate,
poster: w.game.poster,
}}
disableInteraction={isPublicList}
extraDetails={{
dateAdded: w.createdAt,
dateModified: w.updatedAt,
}}
fluidSize={true}
pinned={w.pinned}
onUpdated={itemUpdated}
/>
{:else if w.content}
<Poster
bind:watched={list[i]}
media={{
id: w.content.tmdbId,
poster_path: w.content.poster_path,
title: w.content.title,
overview: w.content.overview,
media_type: w.content.type,
release_date: w.content.release_date,
first_air_date: w.content.first_air_date,
}}
disableInteraction={isPublicList}
fluidSize={true}
pinned={w.pinned}
onUpdated={itemUpdated}
/>
{/if}
{/each}
{:else if !isLoading}
<div class="empty-list">
{#if list?.length > 0}
<!-- `watched` (filtered list) is empty, but `list` (unfiltered) isn't,
so we should let the user know why there is nothing to show. -->
<Icon i="filter-circle" wh={80} />
<h2 class="norm">Filters are hiding all results!</h2>
<h4 class="norm">Try changing or removing your active filters.</h4>
<button on:click={() => clearActiveFilters()}>Clear Filters</button>
{:else}
<Icon i="reel" wh={80} />
{#if isPublicList}
<h2 class="norm">This watched list is empty!</h2>
<h4 class="norm">Come back later to see if they have added anything.</h4>
{:else}
<h2 class="norm">Your watched list is empty!</h2>
<h4 class="norm">Try searching for something you would like to add.</h4>
<button on:click={() => goto("/import")}>Import</button>
{/if}
{/if}
</div>
{/if}
<Icon i="filter-circle" wh={80} />
<h2 class="norm">Filters are hiding all results!</h2>
<h4 class="norm">Try changing or removing your active filters.</h4>
<button onclick={() => clearActiveFilters()}>Clear Filters</button>
{:else}
<Icon i="reel" wh={80} />
{#if isPublicList}
<h2 class="norm">This watched list is empty!</h2>
<h4 class="norm">
Come back later to see if they have added anything.
</h4>
{:else}
<h2 class="norm">Your watched list is empty!</h2>
<h4 class="norm">
Try searching for something you would like to add.
</h4>
<button onclick={() => goto("/import")}>Import</button>
{/if}
{/if}
</div>
{/if}
</PosterList>
{#if isLoading}
<div style="margin-bottom: 60px;">
<Spinner />
</div>
{/if}
<style lang="scss">
.empty-list {
display: flex;
flex-flow: column;
gap: 5px;
align-items: center;
.empty-list {
display: flex;
flex-flow: column;
gap: 5px;
align-items: center;
h2 {
margin-top: 10px;
}
h2 {
margin-top: 10px;
}
h4 {
font-weight: normal;
}
h4 {
font-weight: normal;
}
button {
width: max-content;
padding-left: 20px;
padding-right: 20px;
margin-top: 15px;
}
}
button {
width: max-content;
padding-left: 20px;
padding-right: 20px;
margin-top: 15px;
}
}
</style>
+56 -56
View File
@@ -1,65 +1,65 @@
export interface StayInViewOptions {
/**
* If the `node` contains an element (like an arrow for menus),
* that should be shifted over to account for any shifting
* of the `node` itself, then pass a selector for it here.
*/
elToShiftSelector?: string;
/**
* If the `node` contains an element (like an arrow for menus),
* that should be shifted over to account for any shifting
* of the `node` itself, then pass a selector for it here.
*/
elToShiftSelector?: string;
}
export default function stayInView(node: HTMLElement, opts: StayInViewOptions) {
console.debug("stayInView: Initial opts:", opts);
let { elToShiftSelector } = opts;
let viewDeb: ReturnType<typeof setTimeout>;
console.debug("stayInView: Initial opts:", opts);
let { elToShiftSelector } = opts;
let viewDeb: ReturnType<typeof setTimeout>;
/**
* Move element to in view, if it isn't.
*
* Currently only supports moving `node` back into view if oob
* on the left. May need to support other sides and/or resizing
* when `node` still wont fit after shifting it in the future.
*/
const getInView = () => {
const nrect = node.getBoundingClientRect();
const brect = document.body.getBoundingClientRect();
console.debug("stayInView->getInView: Called.", nrect, brect);
if (nrect.x <= brect.x) {
const diff = nrect.x - brect.x + 10;
console.debug(
"stayInView->getInView: Node is out of bounds on the left, shifting forwards to:",
diff
);
node.style.left = `${diff}px`;
if (elToShiftSelector) {
const elToShift = node.querySelector(elToShiftSelector) as HTMLElement;
if (elToShift) {
console.debug("stayInView->getInView: Shifting elToShift.");
const nrectNew = node.getBoundingClientRect();
const arrowDiff = nrectNew.left - nrect.left;
elToShift.style.left = `${elToShift.offsetLeft - arrowDiff}px`;
} else {
console.warn("elToShift not found.", elToShiftSelector);
}
}
}
};
/**
* Move element to in view, if it isn't.
*
* Currently only supports moving `node` back into view if oob
* on the left. May need to support other sides and/or resizing
* when `node` still wont fit after shifting it in the future.
*/
const getInView = () => {
const nrect = node.getBoundingClientRect();
const brect = document.body.getBoundingClientRect();
console.debug("stayInView->getInView: Called.", nrect, brect);
if (nrect.x <= brect.x) {
const diff = nrect.x - brect.x + 10;
console.debug(
"stayInView->getInView: Node is out of bounds on the left, shifting forwards to:",
diff,
);
node.style.left = `${diff}px`;
if (elToShiftSelector) {
const elToShift = node.querySelector(elToShiftSelector) as HTMLElement;
if (elToShift) {
console.debug("stayInView->getInView: Shifting elToShift.");
const nrectNew = node.getBoundingClientRect();
const arrowDiff = nrectNew.left - nrect.left;
elToShift.style.left = `${elToShift.offsetLeft - arrowDiff}px`;
} else {
console.warn("elToShift not found.", elToShiftSelector);
}
}
}
};
const getInViewDeb = () => {
clearTimeout(viewDeb);
viewDeb = setTimeout(getInView, 200);
};
const getInViewDeb = () => {
clearTimeout(viewDeb);
viewDeb = setTimeout(getInView, 200);
};
window.addEventListener("resize", getInViewDeb);
getInView();
window.addEventListener("resize", getInViewDeb);
getInView();
return {
update(opts: StayInViewOptions) {
console.debug("stayInView: Opts updated", opts);
elToShiftSelector = opts.elToShiftSelector;
getInView();
},
destroy() {
window.removeEventListener("resize", getInViewDeb);
}
};
return {
update(opts: StayInViewOptions) {
console.debug("stayInView: Opts updated", opts);
elToShiftSelector = opts.elToShiftSelector;
getInView();
},
destroy() {
window.removeEventListener("resize", getInViewDeb);
},
};
}
+56 -52
View File
@@ -1,62 +1,66 @@
export interface ToolTipOptions {
text: string;
pos?: "left" | "top" | "bot";
text: string;
pos?: "left" | "top" | "bot";
/**
* Only show tooltip if this condition is true.
*/
condition?: boolean;
/**
* Only show tooltip if this condition is true.
*/
condition?: boolean;
}
export default function tooltip(node: HTMLElement, opts: ToolTipOptions) {
let { text, pos = "left", condition = true } = opts;
const tooltip = document.getElementById("tooltip");
let { text, pos = "left", condition = true } = opts;
const tooltip = document.getElementById("tooltip");
const show = () => {
const trimmedText = text?.trim();
if (!condition || !trimmedText) return;
if (tooltip) {
tooltip.innerHTML = trimmedText;
const nrect = node.getBoundingClientRect();
const trect = tooltip.getBoundingClientRect();
nrect.y += window.scrollY; // Add scrollY to node dom rect so tooltip shows correcting when page is scrolled down
if (pos === "left") {
tooltip.style.left = `${nrect.x - trect.width - 10}px`;
tooltip.style.top = `${nrect.y + trect.height / 2 - 19.5}px`;
} else if (pos === "top") {
tooltip.style.left = `${nrect.x - trect.width / 2 + nrect.width / 2}px`;
tooltip.style.top = `${nrect.y - trect.height - 5}px`;
} else if (pos === "bot") {
tooltip.style.left = `${nrect.x - trect.width / 2 + nrect.width / 2}px`;
tooltip.style.top = `${nrect.y + trect.height + 5}px`;
}
tooltip.style.visibility = "visible";
}
};
const show = () => {
const trimmedText = text?.trim();
if (!condition || !trimmedText) return;
if (tooltip) {
tooltip.innerHTML = trimmedText;
const nrect = node.getBoundingClientRect();
const trect = tooltip.getBoundingClientRect();
nrect.y += window.scrollY; // Add scrollY to node dom rect so tooltip shows correcting when page is scrolled down
if (pos === "left") {
tooltip.style.left = `${nrect.x - trect.width - 10}px`;
tooltip.style.top = `${nrect.y + trect.height / 2 - 19.5}px`;
} else if (pos === "top") {
tooltip.style.left = `${nrect.x - trect.width / 2 + nrect.width / 2}px`;
tooltip.style.top = `${nrect.y - trect.height - 5}px`;
} else if (pos === "bot") {
tooltip.style.left = `${nrect.x - trect.width / 2 + nrect.width / 2}px`;
tooltip.style.top = `${nrect.y + trect.height + 5}px`;
}
tooltip.style.visibility = "visible";
}
};
const hide = () => {
if (tooltip) {
tooltip.style.visibility = "hidden";
}
};
const hide = () => {
if (tooltip) {
tooltip.style.visibility = "hidden";
}
};
node.addEventListener("mouseover", show);
node.addEventListener("touchstart", show);
node.addEventListener("mouseout", hide);
node.addEventListener("touchend", hide);
node.addEventListener("click", hide);
node.addEventListener("mouseover", show);
node.addEventListener("touchstart", show);
node.addEventListener("mouseout", hide);
node.addEventListener("touchend", hide);
node.addEventListener("click", hide);
return {
update(opts: ToolTipOptions) {
text = opts.text;
condition = opts.condition ?? true;
},
destroy() {
node.removeEventListener("mouseover", show);
node.removeEventListener("touchstart", show);
node.removeEventListener("mouseout", hide);
node.removeEventListener("touchend", hide);
node.removeEventListener("click", hide);
}
};
return {
update(opts: ToolTipOptions) {
text = opts.text;
condition = opts.condition ?? true;
},
destroy() {
node.removeEventListener("mouseover", show);
node.removeEventListener("touchstart", show);
node.removeEventListener("mouseout", hide);
node.removeEventListener("touchend", hide);
node.removeEventListener("click", hide);
// Running hide() here fixes scenario where we (eg) hide `node`
// after it is pressed, but tooltip persists and there is no way
// to remove it.
hide();
},
};
}
+137 -127
View File
@@ -1,154 +1,164 @@
<script lang="ts">
import type { ContentType, PublicUser, WatchedStatus } from "@/types";
import HorizontalList from "../HorizontalList.svelte";
import Modal from "../Modal.svelte";
import axios from "axios";
import Spinner from "../Spinner.svelte";
import Error from "../Error.svelte";
import Icon from "../Icon.svelte";
import { watchedStatuses } from "../util/helpers";
import type { ContentType, PublicUser, WatchedStatus } from "@/types";
import HorizontalList from "../HorizontalList.svelte";
import Modal from "../Modal.svelte";
import axios from "axios";
import Spinner from "../Spinner.svelte";
import Error from "../Error.svelte";
import Icon from "../Icon.svelte";
import { watchedStatuses } from "../util/helpers";
interface FollowThoughts {
followedUser: PublicUser;
thoughts: string;
status: WatchedStatus;
rating: number;
}
interface FollowThoughts {
followedUser: PublicUser;
thoughts: string;
status: WatchedStatus;
rating: number;
}
export let mediaType: ContentType | "game";
// The tmdbId for movie/tv, igdbId for games.
export let mediaId: number;
interface Props {
mediaType: ContentType | "game";
// The tmdbId for movie/tv, igdbId for games.
mediaId: number;
}
let modalShownFor: FollowThoughts | undefined = undefined;
let { mediaType, mediaId }: Props = $props();
async function getFollowsThoughts() {
return (await axios.get<FollowThoughts[]>(`/follow/thoughts/${mediaType}/${mediaId}`)).data;
}
let modalShownFor: FollowThoughts | undefined = $state(undefined);
async function getFollowsThoughts() {
return (
await axios.get<FollowThoughts[]>(
`/follow/thoughts/${mediaType}/${mediaId}`,
)
).data;
}
</script>
{#await getFollowsThoughts()}
<Spinner />
<Spinner />
{:then fts}
{#if fts?.length > 0}
<HorizontalList title="Followed Thoughts">
{#each fts as ft}
<button
class={["thoughts-card plain", ft.thoughts ? "" : "no-thoughts"].join(" ")}
on:click={() => (modalShownFor = ft)}
>
<div>
<h4 title={ft.followedUser.username}>{ft.followedUser.username}</h4>
{#if ft.status}
<div class="status-icon">
<Icon i={watchedStatuses[ft.status]} wh={22} />
</div>
{/if}
{#if ft.rating}
<span class="rating">
<span>*</span>
{ft.rating}
</span>
{/if}
</div>
<div class="thought">
{ft.thoughts || "No thoughts yet."}
</div>
</button>
{/each}
</HorizontalList>
{/if}
{#if fts?.length > 0}
<HorizontalList title="Followed Thoughts">
{#each fts as ft}
<button
class={["thoughts-card plain", ft.thoughts ? "" : "no-thoughts"].join(
" ",
)}
onclick={() => (modalShownFor = ft)}
>
<div>
<h4 title={ft.followedUser.username}>{ft.followedUser.username}</h4>
{#if ft.status}
<div class="status-icon">
<Icon i={watchedStatuses[ft.status]} wh={22} />
</div>
{/if}
{#if ft.rating}
<span class="rating">
<span>*</span>
{ft.rating}
</span>
{/if}
</div>
<div class="thought">
{ft.thoughts || "No thoughts yet."}
</div>
</button>
{/each}
</HorizontalList>
{/if}
{:catch err}
<Error error={err} pretty="Failed to load followed thoughts!" />
<Error error={err} pretty="Failed to load followed thoughts!" />
{/await}
{#if modalShownFor}
<Modal
title={`${modalShownFor.followedUser.username}'s Thoughts`}
onClose={() => (modalShownFor = undefined)}
>
<span>{modalShownFor.thoughts}</span>
</Modal>
<Modal
title={`${modalShownFor.followedUser.username}'s Thoughts`}
onClose={() => (modalShownFor = undefined)}
>
<span>{modalShownFor.thoughts}</span>
</Modal>
{/if}
<style lang="scss">
.thoughts-card {
display: flex;
flex-flow: column;
padding: 15px 20px;
background-color: $accent-color;
fill: $text-color;
border-radius: 10px;
min-width: 250px;
max-width: 250px;
font-size: 16px;
transition: background-color 200ms ease;
text-align: unset;
overflow: hidden;
cursor: pointer;
.thoughts-card {
display: flex;
flex-flow: column;
padding: 15px 20px;
background-color: $accent-color;
fill: $text-color;
border-radius: 10px;
min-width: 250px;
max-width: 250px;
font-size: 16px;
transition: background-color 200ms ease;
text-align: unset;
overflow: hidden;
cursor: pointer;
& > div {
display: flex;
flex-flow: row;
align-items: center;
gap: 8px;
margin-bottom: 3px;
& > div {
display: flex;
flex-flow: row;
align-items: center;
gap: 8px;
margin-bottom: 3px;
h4 {
overflow: hidden;
text-overflow: ellipsis;
}
h4 {
overflow: hidden;
text-overflow: ellipsis;
}
.rating {
display: flex;
align-items: start;
justify-content: center;
gap: 5px;
font-size: 18px;
font-weight: bolder;
.rating {
display: flex;
align-items: start;
justify-content: center;
gap: 5px;
font-size: 18px;
font-weight: bolder;
span {
font-family: "Rampart One";
-webkit-text-stroke: 1px $text-color;
font-size: 32px;
line-height: 0.55;
margin-top: 7px;
}
}
span {
font-family: "Rampart One";
-webkit-text-stroke: 1px $text-color;
font-size: 32px;
line-height: 0.55;
margin-top: 7px;
}
}
.status-icon {
margin-left: auto;
margin-right: 2px;
width: 20px;
height: 20px;
min-width: 20px;
min-height: 20px;
}
}
.status-icon {
margin-left: auto;
margin-right: 2px;
width: 20px;
height: 20px;
min-width: 20px;
min-height: 20px;
}
}
.thought {
display: -webkit-box;
-webkit-line-clamp: 9;
-webkit-box-orient: vertical;
hyphens: auto;
overflow: hidden;
}
.thought {
display: -webkit-box;
-webkit-line-clamp: 9;
-webkit-box-orient: vertical;
hyphens: auto;
overflow: hidden;
}
&.no-thoughts {
pointer-events: none;
&.no-thoughts {
pointer-events: none;
.thought {
font-weight: lighter;
}
}
.thought {
font-weight: lighter;
}
}
&:hover {
color: $bg-color;
fill: $bg-color;
background-color: $accent-color-hover;
&:hover {
color: $bg-color;
fill: $bg-color;
background-color: $accent-color-hover;
.rating span {
-webkit-text-stroke: 1px $bg-color;
}
}
}
.rating span {
-webkit-text-stroke: 1px $bg-color;
}
}
}
</style>
+107 -100
View File
@@ -1,123 +1,130 @@
<script lang="ts">
import { onMount } from "svelte";
import Modal from "../Modal.svelte";
import Icon from "../Icon.svelte";
import { notify } from "../util/notify";
import Modal from "../Modal.svelte";
import Icon from "../Icon.svelte";
import { notify } from "../util/notify";
export let contentTitle: string;
export let thoughts: string;
export let onChange: (newThoughts: string) => Promise<boolean>;
interface Props {
contentTitle: string;
thoughts: string;
onChange: (newThoughts: string) => Promise<void>;
}
let modalOpen = false;
let textarea: HTMLTextAreaElement | undefined;
$: thoughtsToDisplay = thoughts ? thoughts : `Set thoughts on ${contentTitle}`;
let { contentTitle, thoughts, onChange }: Props = $props();
function resizeTextarea() {
if (!textarea) {
return;
}
textarea.style.height = "";
textarea.style.height = textarea.scrollHeight + "px";
}
let modalOpen = $state(false);
let textarea: HTMLTextAreaElement | undefined = $state();
let thoughtsToDisplay = $derived(
thoughts ? thoughts : `Set thoughts on ${contentTitle}`,
);
$: textarea, resizeTextarea();
function resizeTextarea() {
if (!textarea) {
return;
}
textarea.style.height = "";
textarea.style.height = textarea.scrollHeight + "px";
}
$effect(() => {
if (textarea) resizeTextarea();
});
</script>
<button
class={`plain thoughts${thoughtsToDisplay?.length > 100 ? " long" : ""}${thoughts ? "" : " placeholdered"}`}
on:click={() => {
modalOpen = !modalOpen;
if (modalOpen) {
resizeTextarea();
}
}}
class={`plain thoughts${thoughtsToDisplay?.length > 100 ? " long" : ""}${thoughts ? "" : " placeholdered"}`}
onclick={() => {
modalOpen = !modalOpen;
if (modalOpen) {
resizeTextarea();
}
}}
>
<i><Icon i="pencil" wh={24} /></i>
<p>{thoughtsToDisplay}</p>
<i><Icon i="pencil" wh={24} /></i>
<p>{thoughtsToDisplay}</p>
</button>
{#if modalOpen}
<Modal
title="Your Thoughts"
desc="View or modify your thoughts on {contentTitle}"
onClose={async () => {
if (!textarea) {
notify({
text: "Failed to find the text box! Please copy your changes to avoid losing them and try again!"
});
return;
}
// If thoughts weren't changed or changes saved successfully.
if (thoughts === textarea.value || (await onChange(textarea.value))) {
modalOpen = false;
}
}}
>
<textarea
name="Thoughts"
rows="3"
placeholder={`My thoughts on ${contentTitle}`}
value={thoughts}
bind:this={textarea}
on:input={resizeTextarea}
/>
</Modal>
<Modal
title="Your Thoughts"
desc="View or modify your thoughts on {contentTitle}"
onClose={async () => {
if (!textarea) {
notify({
text: "Failed to find the text box! Please copy your changes to avoid losing them and try again!",
});
return;
}
// If thoughts weren't changed or changes saved successfully.
if (thoughts === textarea.value || (await onChange(textarea.value))) {
modalOpen = false;
}
}}
>
<textarea
name="Thoughts"
rows="3"
placeholder={`My thoughts on ${contentTitle}`}
value={thoughts}
bind:this={textarea}
oninput={resizeTextarea}
></textarea>
</Modal>
{/if}
<style lang="scss">
button.thoughts {
position: relative;
width: 100%;
text-align: start;
padding: 7px 10px;
border: 2px solid $text-color;
border-radius: 5px;
max-height: 100px;
opacity: 0.5;
transition: opacity 150ms ease-in-out;
button.thoughts {
position: relative;
width: 100%;
text-align: start;
padding: 7px 10px;
border: 2px solid $text-color;
border-radius: 5px;
max-height: 100px;
opacity: 0.5;
transition: opacity 150ms ease-in-out;
&:hover {
opacity: 1;
}
&:hover {
opacity: 1;
}
i {
display: flex;
position: absolute;
bottom: 4px;
right: 5px;
opacity: 0;
transform: scale(0.5);
transition:
opacity 150ms ease-in-out,
transform 150ms ease-in-out;
}
i {
display: flex;
position: absolute;
bottom: 4px;
right: 5px;
opacity: 0;
transform: scale(0.5);
transition:
opacity 150ms ease-in-out,
transform 150ms ease-in-out;
}
&:hover i {
transform: scale(1);
opacity: 1;
}
&:hover i {
transform: scale(1);
opacity: 1;
}
&.placeholdered {
padding: 12px 12px;
}
&.placeholdered {
padding: 12px 12px;
}
p {
max-height: 90px;
overflow: hidden;
}
p {
max-height: 90px;
overflow: hidden;
}
&.long p {
mask: linear-gradient(
to bottom,
rgba(0, 0, 0, 1) 0,
rgba(0, 0, 0, 1) 40%,
rgba(0, 0, 0, 0) 95%,
rgba(0, 0, 0, 0) 0
);
}
}
&.long p {
mask: linear-gradient(
to bottom,
rgba(0, 0, 0, 1) 0,
rgba(0, 0, 0, 1) 40%,
rgba(0, 0, 0, 0) 95%,
rgba(0, 0, 0, 0) 0
);
}
}
textarea {
border: 0;
}
textarea {
border: 0;
}
</style>
+158 -116
View File
@@ -4,138 +4,180 @@
-->
<script lang="ts">
export let i: string;
export let wh = 24;
interface Props {
i: string;
wh?: number;
}
let { i, wh = 24 }: Props = $props();
</script>
{#if i === "Apple TV Plus"}
<a href="https://tv.apple.com/" target="_blank">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 45.046 17.091" width={wh} height={wh}>
<path
d="M9.436 2.742A3.857 3.857 0 0 0 10.316 0a3.769 3.769 0 0 0-2.51 1.311 3.622 3.622 0 0 0-.9 2.631 3.138 3.138 0 0 0 2.53-1.2m.82 1.381c-1.4-.081-2.58.8-3.25.8s-1.69-.756-2.79-.736a4.117 4.117 0 0 0-3.5 2.147c-1.5 2.6-.4 6.473 1.06 8.59.71 1.006 1.56 2.205 2.69 2.166s1.48-.7 2.77-.7 1.67.7 2.79.675 1.9-1.008 2.6-2.1a9.317 9.317 0 0 0 1.17-2.42 3.814 3.814 0 0 1-2.27-3.468 3.9 3.9 0 0 1 1.83-3.256 3.991 3.991 0 0 0-3.1-1.7m8.93-2.016V4.96h2.28v1.885h-2.28V13.6c0 1.008.45 1.522 1.45 1.522a7.482 7.482 0 0 0 .82-.06v1.9a7.823 7.823 0 0 1-1.35.1c-2.36 0-3.27-.917-3.27-3.216V6.89h-1.79V5h1.74V2.107Zm10.25 14.853h-2.5L22.736 5h2.49l2.95 9.608h.06L31.186 5h2.44Zm10.98 0h-2.16v-4.9h-4.64V9.9h4.63V5h2.16v4.9h4.64v2.158h-4.63Z"
/>
</svg>
</a>
<a aria-label={i} href="https://tv.apple.com/" target="_blank">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 45.046 17.091"
width={wh}
height={wh}
>
<path
d="M9.436 2.742A3.857 3.857 0 0 0 10.316 0a3.769 3.769 0 0 0-2.51 1.311 3.622 3.622 0 0 0-.9 2.631 3.138 3.138 0 0 0 2.53-1.2m.82 1.381c-1.4-.081-2.58.8-3.25.8s-1.69-.756-2.79-.736a4.117 4.117 0 0 0-3.5 2.147c-1.5 2.6-.4 6.473 1.06 8.59.71 1.006 1.56 2.205 2.69 2.166s1.48-.7 2.77-.7 1.67.7 2.79.675 1.9-1.008 2.6-2.1a9.317 9.317 0 0 0 1.17-2.42 3.814 3.814 0 0 1-2.27-3.468 3.9 3.9 0 0 1 1.83-3.256 3.991 3.991 0 0 0-3.1-1.7m8.93-2.016V4.96h2.28v1.885h-2.28V13.6c0 1.008.45 1.522 1.45 1.522a7.482 7.482 0 0 0 .82-.06v1.9a7.823 7.823 0 0 1-1.35.1c-2.36 0-3.27-.917-3.27-3.216V6.89h-1.79V5h1.74V2.107Zm10.25 14.853h-2.5L22.736 5h2.49l2.95 9.608h.06L31.186 5h2.44Zm10.98 0h-2.16v-4.9h-4.64V9.9h4.63V5h2.16v4.9h4.64v2.158h-4.63Z"
/>
</svg>
</a>
{:else if i === "Apple TV"}
<a href="https://tv.apple.com/" target="_blank">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width={wh} height={wh}>
<path
d="M20.57 17.735h-1.815l-3.34-9.203h1.633l2.02 5.987c.075.231.273.9.586 2.012l.297-.997.33-1.006 2.094-6.004H24zm-5.344-.066a5.76 5.76 0 0 1-1.55.207c-1.23 0-1.84-.693-1.84-2.087V9.646h-1.063V8.532h1.121V7.081l1.476-.602v2.062h1.707v1.113H13.38v5.805c0 .446.074.75.214.932.14.182.396.264.75.264.207 0 .495-.041.883-.115zm-7.29-5.343c.017 1.764 1.55 2.358 1.567 2.366-.017.042-.248.842-.808 1.658-.487.71-.99 1.418-1.79 1.435-.783.016-1.03-.462-1.93-.462-.89 0-1.17.445-1.913.478-.758.025-1.344-.775-1.838-1.484-.998-1.451-1.765-4.098-.734-5.88.51-.89 1.426-1.451 2.416-1.46.75-.016 1.468.512 1.93.512.461 0 1.327-.627 2.234-.536.38.016 1.452.157 2.136 1.154-.058.033-1.278.743-1.27 2.219M6.468 7.988c.404-.495.685-1.18.61-1.864-.585.025-1.294.388-1.723.883-.38.437-.71 1.138-.619 1.806.652.05 1.328-.338 1.732-.825Z"
/>
</svg>
</a>
<a aria-label={i} href="https://tv.apple.com/" target="_blank">
<svg
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
width={wh}
height={wh}
>
<path
d="M20.57 17.735h-1.815l-3.34-9.203h1.633l2.02 5.987c.075.231.273.9.586 2.012l.297-.997.33-1.006 2.094-6.004H24zm-5.344-.066a5.76 5.76 0 0 1-1.55.207c-1.23 0-1.84-.693-1.84-2.087V9.646h-1.063V8.532h1.121V7.081l1.476-.602v2.062h1.707v1.113H13.38v5.805c0 .446.074.75.214.932.14.182.396.264.75.264.207 0 .495-.041.883-.115zm-7.29-5.343c.017 1.764 1.55 2.358 1.567 2.366-.017.042-.248.842-.808 1.658-.487.71-.99 1.418-1.79 1.435-.783.016-1.03-.462-1.93-.462-.89 0-1.17.445-1.913.478-.758.025-1.344-.775-1.838-1.484-.998-1.451-1.765-4.098-.734-5.88.51-.89 1.426-1.451 2.416-1.46.75-.016 1.468.512 1.93.512.461 0 1.327-.627 2.234-.536.38.016 1.452.157 2.136 1.154-.058.033-1.278.743-1.27 2.219M6.468 7.988c.404-.495.685-1.18.61-1.864-.585.025-1.294.388-1.723.883-.38.437-.71 1.138-.619 1.806.652.05 1.328-.338 1.732-.825Z"
/>
</svg>
</a>
{:else if i === "Amazon Prime Video"}
<a href="https://www.amazon.com/gp/video/storefront/" target="_blank">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width={wh} height={wh}>
<path
d="M22.787 15.292c-.336-.43-2.222-.204-3.069-.103-.257.031-.296-.193-.065-.356 1.504-1.056 3.968-.75 4.255-.397.288.357-.076 2.827-1.485 4.007-.217.18-.423.084-.327-.155.317-.792 1.027-2.566.69-2.996m-1.093 1.248c-2.627 1.94-6.437 2.97-9.717 2.97-4.597 0-8.737-1.7-11.87-4.528-.246-.222-.026-.525.27-.353 3.38 1.967 7.559 3.151 11.876 3.151a23.63 23.63 0 0 0 9.06-1.854c.444-.188.816.293.381.614m.482-5.038c-.761 0-1.346-.209-1.755-.626-.409-.418-.613-1.017-.613-1.797 0-.799.209-1.425.627-1.88.418-.454.998-.682 1.741-.682.572 0 1.019.138 1.341.415.323.276.484.645.484 1.105 0 .461-.174.81-.52 1.046-.348.237-.86.355-1.535.355-.35 0-.654-.034-.912-.101.037.411.161.706.373.884.212.178.533.268.963.268.172 0 .34-.011.502-.033a6.208 6.208 0 0 0 .733-.157.304.304 0 0 1 .046-.004c.104 0 .156.07.156.212v.424c0 .098-.013.167-.04.207a.341.341 0 0 1-.162.106 3.954 3.954 0 0 1-1.429.258m-.304-2.893c.314 0 .541-.048.682-.143.142-.095.212-.241.212-.438 0-.387-.23-.58-.69-.58-.59 0-.931.362-1.024 1.087.246.05.52.074.82.074m-9.84 2.755c-.08 0-.139-.018-.176-.055-.036-.037-.055-.096-.055-.175V6.886c0-.086.019-.146.055-.18.037-.034.096-.05.176-.05h.663c.141 0 .227.067.258.202l.074.249c.325-.215.619-.367.88-.456.26-.09.53-.134.806-.134.553 0 .943.197 1.17.59a3.77 3.77 0 0 1 .885-.452c.276-.092.562-.138.857-.138.43 0 .763.12 1 .36.236.239.354.574.354 1.004v3.253c0 .08-.017.138-.05.175-.034.037-.094.055-.18.055h-.885c-.08 0-.138-.018-.175-.055-.037-.037-.055-.096-.055-.175V8.176c0-.418-.188-.627-.562-.627-.332 0-.667.08-1.005.24v3.345c0 .08-.017.138-.05.175-.034.037-.094.055-.18.055h-.884c-.08 0-.139-.018-.176-.055-.036-.037-.055-.096-.055-.175V8.176c0-.418-.187-.627-.562-.627-.344 0-.682.083-1.013.249v3.336c0 .08-.017.138-.051.175-.034.037-.094.055-.18.055zM9.987 5.927c-.234 0-.42-.064-.562-.193-.142-.129-.212-.304-.212-.525 0-.221.07-.397.212-.526.141-.129.328-.193.562-.193.233 0 .42.064.562.193a.676.676 0 0 1 .212.526c0 .22-.07.396-.212.525-.141.129-.329.193-.562.193m-.443 5.437c-.08 0-.138-.019-.175-.055-.037-.037-.055-.096-.055-.176V6.886c0-.086.018-.146.055-.18.037-.034.096-.05.175-.05h.885c.086 0 .146.016.18.05s.05.094.05.18v4.247c0 .08-.017.139-.05.176-.034.036-.094.055-.18.055zm-3.681 0c-.08 0-.139-.018-.176-.055-.036-.037-.055-.096-.055-.175V6.886c0-.086.019-.146.055-.18.037-.034.096-.05.176-.05h.663c.141 0 .227.067.258.202l.12.497c.245-.27.477-.462.695-.575.219-.114.45-.17.696-.17h.13c.085 0 .147.016.183.05.037.034.056.094.056.18v.773c0 .08-.017.139-.051.176-.034.036-.094.055-.18.055a1.93 1.93 0 0 1-.166-.01 2.968 2.968 0 0 0-.258-.009c-.14 0-.313.02-.516.06-.202.04-.374.091-.515.152v3.097c0 .08-.018.138-.051.175-.034.037-.094.055-.18.055zM.344 13.262c-.08 0-.138-.017-.175-.05-.037-.034-.055-.095-.055-.18V6.886c0-.086.018-.146.055-.18.037-.034.095-.05.175-.05h.664c.14 0 .227.067.258.202l.064.24a2.03 2.03 0 0 1 .668-.424 2.13 2.13 0 0 1 .797-.157c.596 0 1.067.218 1.414.654.348.437.521 1.026.521 1.77 0 .51-.086.955-.258 1.336-.172.38-.405.674-.7.88a1.727 1.727 0 0 1-1.014.308c-.252 0-.491-.04-.719-.12a1.74 1.74 0 0 1-.58-.331v2.018c0 .085-.017.146-.05.18-.034.033-.095.05-.18.05zm2.018-2.81c.344 0 .597-.117.76-.35.163-.234.245-.603.245-1.106 0-.51-.08-.882-.24-1.115-.16-.234-.415-.35-.765-.35-.32 0-.62.083-.903.248v2.424c.27.166.571.249.903.249Z"
/>
</svg>
</a>
<a
aria-label={i}
href="https://www.amazon.com/gp/video/storefront/"
target="_blank"
>
<svg
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
width={wh}
height={wh}
>
<path
d="M22.787 15.292c-.336-.43-2.222-.204-3.069-.103-.257.031-.296-.193-.065-.356 1.504-1.056 3.968-.75 4.255-.397.288.357-.076 2.827-1.485 4.007-.217.18-.423.084-.327-.155.317-.792 1.027-2.566.69-2.996m-1.093 1.248c-2.627 1.94-6.437 2.97-9.717 2.97-4.597 0-8.737-1.7-11.87-4.528-.246-.222-.026-.525.27-.353 3.38 1.967 7.559 3.151 11.876 3.151a23.63 23.63 0 0 0 9.06-1.854c.444-.188.816.293.381.614m.482-5.038c-.761 0-1.346-.209-1.755-.626-.409-.418-.613-1.017-.613-1.797 0-.799.209-1.425.627-1.88.418-.454.998-.682 1.741-.682.572 0 1.019.138 1.341.415.323.276.484.645.484 1.105 0 .461-.174.81-.52 1.046-.348.237-.86.355-1.535.355-.35 0-.654-.034-.912-.101.037.411.161.706.373.884.212.178.533.268.963.268.172 0 .34-.011.502-.033a6.208 6.208 0 0 0 .733-.157.304.304 0 0 1 .046-.004c.104 0 .156.07.156.212v.424c0 .098-.013.167-.04.207a.341.341 0 0 1-.162.106 3.954 3.954 0 0 1-1.429.258m-.304-2.893c.314 0 .541-.048.682-.143.142-.095.212-.241.212-.438 0-.387-.23-.58-.69-.58-.59 0-.931.362-1.024 1.087.246.05.52.074.82.074m-9.84 2.755c-.08 0-.139-.018-.176-.055-.036-.037-.055-.096-.055-.175V6.886c0-.086.019-.146.055-.18.037-.034.096-.05.176-.05h.663c.141 0 .227.067.258.202l.074.249c.325-.215.619-.367.88-.456.26-.09.53-.134.806-.134.553 0 .943.197 1.17.59a3.77 3.77 0 0 1 .885-.452c.276-.092.562-.138.857-.138.43 0 .763.12 1 .36.236.239.354.574.354 1.004v3.253c0 .08-.017.138-.05.175-.034.037-.094.055-.18.055h-.885c-.08 0-.138-.018-.175-.055-.037-.037-.055-.096-.055-.175V8.176c0-.418-.188-.627-.562-.627-.332 0-.667.08-1.005.24v3.345c0 .08-.017.138-.05.175-.034.037-.094.055-.18.055h-.884c-.08 0-.139-.018-.176-.055-.036-.037-.055-.096-.055-.175V8.176c0-.418-.187-.627-.562-.627-.344 0-.682.083-1.013.249v3.336c0 .08-.017.138-.051.175-.034.037-.094.055-.18.055zM9.987 5.927c-.234 0-.42-.064-.562-.193-.142-.129-.212-.304-.212-.525 0-.221.07-.397.212-.526.141-.129.328-.193.562-.193.233 0 .42.064.562.193a.676.676 0 0 1 .212.526c0 .22-.07.396-.212.525-.141.129-.329.193-.562.193m-.443 5.437c-.08 0-.138-.019-.175-.055-.037-.037-.055-.096-.055-.176V6.886c0-.086.018-.146.055-.18.037-.034.096-.05.175-.05h.885c.086 0 .146.016.18.05s.05.094.05.18v4.247c0 .08-.017.139-.05.176-.034.036-.094.055-.18.055zm-3.681 0c-.08 0-.139-.018-.176-.055-.036-.037-.055-.096-.055-.175V6.886c0-.086.019-.146.055-.18.037-.034.096-.05.176-.05h.663c.141 0 .227.067.258.202l.12.497c.245-.27.477-.462.695-.575.219-.114.45-.17.696-.17h.13c.085 0 .147.016.183.05.037.034.056.094.056.18v.773c0 .08-.017.139-.051.176-.034.036-.094.055-.18.055a1.93 1.93 0 0 1-.166-.01 2.968 2.968 0 0 0-.258-.009c-.14 0-.313.02-.516.06-.202.04-.374.091-.515.152v3.097c0 .08-.018.138-.051.175-.034.037-.094.055-.18.055zM.344 13.262c-.08 0-.138-.017-.175-.05-.037-.034-.055-.095-.055-.18V6.886c0-.086.018-.146.055-.18.037-.034.095-.05.175-.05h.664c.14 0 .227.067.258.202l.064.24a2.03 2.03 0 0 1 .668-.424 2.13 2.13 0 0 1 .797-.157c.596 0 1.067.218 1.414.654.348.437.521 1.026.521 1.77 0 .51-.086.955-.258 1.336-.172.38-.405.674-.7.88a1.727 1.727 0 0 1-1.014.308c-.252 0-.491-.04-.719-.12a1.74 1.74 0 0 1-.58-.331v2.018c0 .085-.017.146-.05.18-.034.033-.095.05-.18.05zm2.018-2.81c.344 0 .597-.117.76-.35.163-.234.245-.603.245-1.106 0-.51-.08-.882-.24-1.115-.16-.234-.415-.35-.765-.35-.32 0-.62.083-.903.248v2.424c.27.166.571.249.903.249Z"
/>
</svg>
</a>
{:else if i === "Disney Plus"}
<a href="https://disneyplus.com/" target="_blank">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1041 565" width={wh} height={wh}
><path
d="M736 366c-15 3-52 5-52 5l-5 15s19-2 32 0c0 0 5-1 5 5v11s0 3-5 4l-41 2-6 20s-2 4 3 3 42-8 47-7 11 8 9 14c-2 8-39 32-62 30 0 0-12 1-22-15-9-16 4-45 4-45s-6-13-2-18c0 0 3-2 10-3l9-19s-10 1-16-6c-6-8-7-11-2-13s48-10 78-9c0 0 10-1 19 17 0 0 4 7-3 9Zm-112 72c-4 9-14 19-27 13-12-6-32-46-32-46s-7-15-9-15c0 0-1-3-2 13-1 17 0 49-7 54-6 5-13 3-17-3s-5-20-3-44 8-50 15-58 13-2 15 0c0 0 10 9 26 34l2 5 16 24h2c2 0 1-8 1-8s-3-26-16-71c0 0-2-5 0-11 1-5 6-2 6-2s21 10 30 43c10 33 4 63 0 72Zm-100-85c-2 3-3 8-12 10 0 0-82 5-86 11 0 0-3 3 2 4 4 1 23 4 32 4s42 1 53 15c0 0 7 7 7 23s-3 21-9 27c-7 6-63 31-99-8 0 0-16-18 6-32 0 0 16-10 57 1 0 0 12 5 12 9-1 5-10 10-24 10s-23-7-21-6c1 1-15-8-20-2-5 5-4 9 1 12 13 7 61 5 75-11 0 0 6-7-3-12-8-5-33-8-43-9-9 0-44 0-49-9 0 0-5-6 1-24s46-25 63-27c0 0 48-1 57 8v6ZM388 461c-6 4-19 2-22-2-4-5-5-22-4-49s1-60 7-66c6-5 10 0 12 3 3 4 6 8 7 16l2 54s3 40-2 44Zm12-144c-17 6-28 4-38 0-5 7-7 9-10 10-5 0-9-7-10-10-1-2-3-5-1-12-9-9-10-21-8-28 2-9 18-44 68-48 0 0 24-1 28 11h1s23 1 22 21c0 21-26 47-52 56Zm-46-46c-5 8-5 13-3 16 6-9 16-22 32-33-12 1-22 6-29 17Zm68-14c-15 3-39 24-51 41 18 3 49 2 62-26 0 0 7-18-11-15Zm421 162c-9 16-35 50-70 42-12 28-21 56-27 98 0 0-1 8-8 5-7-2-18-13-20-29-2-20 7-55 25-94-5-9-9-22-6-40 0 0 5-33 38-63 0 0 4-3 7-2 2 1 1 12-1 17l-17 31s-9 17-7 31c18-27 58-81 82-64 9 6 12 19 12 33 0 12-3 25-8 35Zm-7-43s-2-11-12 1c-9 10-25 29-38 54 13-2 27-9 31-13 6-6 21-21 19-42Zm-486 14c-2 24-11 64-77 85-43 13-84 6-107 1 0 9-1 12-3 14s-16 10-24-2c-3-5-5-15-6-24-50-23-74-57-74-58-2-1-13-13-2-28 11-13 47-27 78-32 1-27 5-48 8-57 5-11 11-1 16 6 4 6 7 29 7 48 21-1 33 1 56 5 30 6 50 21 49 38-2 18-17 25-23 25-7 1-17-4-17-4-6-3 0-6 8-9 9-5 7-9 7-9-3-10-43-16-82-16l2 78c27 5 47 4 47 4s100-3 103-66c3-64-99-125-175-145-76-19-118-6-122-4s0 3 0 3 4 0 11 3c7 2 2 6 2 6-13 4-28 2-31-4-2-6 2-11 8-19 5-8 11-8 11-8 93-32 207 26 207 26 107 55 125 118 123 143Zm-282-4c-11 5-3 13-3 13 20 21 44 35 67 43 3-37 3-50 3-68-36 2-57 8-67 12ZM1041 379v13c0 3-2 5-5 5h-63v9c0 19-1 35-3 53 0 3-2 5-5 5h-13a5 5 0 0 1-5-5c2-18 3-34 3-53l-1-9h-62c-3 0-5-2-5-5v-13c0-3 2-6 5-6h62a461 461 0 0 0-8-66l4-2h14c3 0 5 2 5 4 4 22 7 43 8 64h64c3 0 5 3 5 6Z"
/>
<path d="M199 204h1-1Zm0 0h1-1Z" fill="none" />
<defs>
<radialGradient
id="a"
gradientUnits="userSpaceOnUse"
cx="942.5"
cy="279.9"
r="760.1"
fx="942.5"
fy="279.9"
>
<stop offset="0" stop-color="#021192" /><stop offset="0" stop-color="#021096" /><stop
offset=".1"
stop-color="#010cb4"
/>
<stop offset=".1" stop-color="#0008ce" /><stop offset=".1" stop-color="#0006d7" /><stop
offset=".1"
stop-color="#0004e1"
/>
<stop offset=".2" stop-color="#0001fa" /><stop offset=".2" stop-color="#0000fe" /><stop
offset=".2"
stop-color="#0f1eff"
/>
<stop offset="1" stop-color="#0ff" stop-opacity="0" /></radialGradient
>
</defs>
<path
d="M955 274a439 439 0 0 0-772-74c0 2 2 3 3 4l11 4h2c3 0 5-1 6-3A419 419 0 0 1 549 23a414 414 0 0 1 383 254c1 2 3 3 5 3h14c1 0 3 0 4-2v-4Zm-756-70h1-1Zm735 75h1-1Z"
/>
</svg>
</a>
<a aria-label={i} href="https://disneyplus.com/" target="_blank">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1041 565"
width={wh}
height={wh}
><path
d="M736 366c-15 3-52 5-52 5l-5 15s19-2 32 0c0 0 5-1 5 5v11s0 3-5 4l-41 2-6 20s-2 4 3 3 42-8 47-7 11 8 9 14c-2 8-39 32-62 30 0 0-12 1-22-15-9-16 4-45 4-45s-6-13-2-18c0 0 3-2 10-3l9-19s-10 1-16-6c-6-8-7-11-2-13s48-10 78-9c0 0 10-1 19 17 0 0 4 7-3 9Zm-112 72c-4 9-14 19-27 13-12-6-32-46-32-46s-7-15-9-15c0 0-1-3-2 13-1 17 0 49-7 54-6 5-13 3-17-3s-5-20-3-44 8-50 15-58 13-2 15 0c0 0 10 9 26 34l2 5 16 24h2c2 0 1-8 1-8s-3-26-16-71c0 0-2-5 0-11 1-5 6-2 6-2s21 10 30 43c10 33 4 63 0 72Zm-100-85c-2 3-3 8-12 10 0 0-82 5-86 11 0 0-3 3 2 4 4 1 23 4 32 4s42 1 53 15c0 0 7 7 7 23s-3 21-9 27c-7 6-63 31-99-8 0 0-16-18 6-32 0 0 16-10 57 1 0 0 12 5 12 9-1 5-10 10-24 10s-23-7-21-6c1 1-15-8-20-2-5 5-4 9 1 12 13 7 61 5 75-11 0 0 6-7-3-12-8-5-33-8-43-9-9 0-44 0-49-9 0 0-5-6 1-24s46-25 63-27c0 0 48-1 57 8v6ZM388 461c-6 4-19 2-22-2-4-5-5-22-4-49s1-60 7-66c6-5 10 0 12 3 3 4 6 8 7 16l2 54s3 40-2 44Zm12-144c-17 6-28 4-38 0-5 7-7 9-10 10-5 0-9-7-10-10-1-2-3-5-1-12-9-9-10-21-8-28 2-9 18-44 68-48 0 0 24-1 28 11h1s23 1 22 21c0 21-26 47-52 56Zm-46-46c-5 8-5 13-3 16 6-9 16-22 32-33-12 1-22 6-29 17Zm68-14c-15 3-39 24-51 41 18 3 49 2 62-26 0 0 7-18-11-15Zm421 162c-9 16-35 50-70 42-12 28-21 56-27 98 0 0-1 8-8 5-7-2-18-13-20-29-2-20 7-55 25-94-5-9-9-22-6-40 0 0 5-33 38-63 0 0 4-3 7-2 2 1 1 12-1 17l-17 31s-9 17-7 31c18-27 58-81 82-64 9 6 12 19 12 33 0 12-3 25-8 35Zm-7-43s-2-11-12 1c-9 10-25 29-38 54 13-2 27-9 31-13 6-6 21-21 19-42Zm-486 14c-2 24-11 64-77 85-43 13-84 6-107 1 0 9-1 12-3 14s-16 10-24-2c-3-5-5-15-6-24-50-23-74-57-74-58-2-1-13-13-2-28 11-13 47-27 78-32 1-27 5-48 8-57 5-11 11-1 16 6 4 6 7 29 7 48 21-1 33 1 56 5 30 6 50 21 49 38-2 18-17 25-23 25-7 1-17-4-17-4-6-3 0-6 8-9 9-5 7-9 7-9-3-10-43-16-82-16l2 78c27 5 47 4 47 4s100-3 103-66c3-64-99-125-175-145-76-19-118-6-122-4s0 3 0 3 4 0 11 3c7 2 2 6 2 6-13 4-28 2-31-4-2-6 2-11 8-19 5-8 11-8 11-8 93-32 207 26 207 26 107 55 125 118 123 143Zm-282-4c-11 5-3 13-3 13 20 21 44 35 67 43 3-37 3-50 3-68-36 2-57 8-67 12ZM1041 379v13c0 3-2 5-5 5h-63v9c0 19-1 35-3 53 0 3-2 5-5 5h-13a5 5 0 0 1-5-5c2-18 3-34 3-53l-1-9h-62c-3 0-5-2-5-5v-13c0-3 2-6 5-6h62a461 461 0 0 0-8-66l4-2h14c3 0 5 2 5 4 4 22 7 43 8 64h64c3 0 5 3 5 6Z"
/>
<path d="M199 204h1-1Zm0 0h1-1Z" fill="none" />
<defs>
<radialGradient
id="a"
gradientUnits="userSpaceOnUse"
cx="942.5"
cy="279.9"
r="760.1"
fx="942.5"
fy="279.9"
>
<stop offset="0" stop-color="#021192" /><stop
offset="0"
stop-color="#021096"
/><stop offset=".1" stop-color="#010cb4" />
<stop offset=".1" stop-color="#0008ce" /><stop
offset=".1"
stop-color="#0006d7"
/><stop offset=".1" stop-color="#0004e1" />
<stop offset=".2" stop-color="#0001fa" /><stop
offset=".2"
stop-color="#0000fe"
/><stop offset=".2" stop-color="#0f1eff" />
<stop offset="1" stop-color="#0ff" stop-opacity="0" /></radialGradient
>
</defs>
<path
d="M955 274a439 439 0 0 0-772-74c0 2 2 3 3 4l11 4h2c3 0 5-1 6-3A419 419 0 0 1 549 23a414 414 0 0 1 383 254c1 2 3 3 5 3h14c1 0 3 0 4-2v-4Zm-756-70h1-1Zm735 75h1-1Z"
/>
</svg>
</a>
{:else if i === "Netflix"}
<a href="https://netflix.com/" target="_blank">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width="30">
<path
d="M5.398 0v.006c3.028 8.556 5.37 15.175 8.348 23.596 2.344.058 4.85.398 4.854.398-2.8-7.924-5.923-16.747-8.487-24zm8.489 0v9.63L18.6 22.951c-.043-7.86-.004-15.913.002-22.95zM5.398 1.05V24c1.873-.225 2.81-.312 4.715-.398v-9.22z"
/>
</svg>
</a>
<a aria-label={i} href="https://netflix.com/" target="_blank">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width="30">
<path
d="M5.398 0v.006c3.028 8.556 5.37 15.175 8.348 23.596 2.344.058 4.85.398 4.854.398-2.8-7.924-5.923-16.747-8.487-24zm8.489 0v9.63L18.6 22.951c-.043-7.86-.004-15.913.002-22.95zM5.398 1.05V24c1.873-.225 2.81-.312 4.715-.398v-9.22z"
/>
</svg>
</a>
{:else if i === "Sky Go"}
<a href="https://go.sky.com/" target="_blank">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width={wh} height={wh}>
<path
d="M7.387 13.656c0 1.423-.933 2.454-2.823 2.675-1.35.147-3.337-.025-4.294-.148-.025-.147-.074-.343-.074-.49 0-1.252.663-1.522 1.3-1.522.664 0 1.694.123 2.455.123.834 0 1.104-.295 1.104-.565 0-.368-.343-.515-1.006-.638l-1.767-.343C.785 12.453 0 11.423 0 10.343c0-1.325.933-2.454 2.798-2.65 1.398-.148 3.116.024 4.049.122.024.172.049.32.049.491 0 1.252-.663 1.522-1.276 1.522-.491 0-1.227-.099-2.086-.099-.884 0-1.227.246-1.227.54 0 .32.343.442.883.54l1.718.32c1.742.294 2.479 1.3 2.479 2.527m3.092 1.521c0 .761-.295 1.203-1.792 1.203-.196 0-.368-.025-.54-.05V6.22c0-.76.27-1.57 1.767-1.57.196 0 .393.024.565.049zm6.085 3.927c.197.098.59.22 1.105.245.859.025 1.325-.319 1.693-1.08L24 7.913a2.5 2.5 0 0 0-.957-.22c-.589 0-1.399.122-1.914 1.325l-1.497 3.534-2.945-4.81c-.196-.05-.662-.148-1.006-.148-1.03 0-1.62.393-2.233 1.031l-2.871 3.141 2.306 3.632c.418.663.982 1.006 1.89 1.006.589 0 1.104-.147 1.325-.245l-2.773-4.196 1.963-2.086 3.24 5.08Z"
/>
</svg>
</a>
<a aria-label={i} href="https://go.sky.com/" target="_blank">
<svg
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
width={wh}
height={wh}
>
<path
d="M7.387 13.656c0 1.423-.933 2.454-2.823 2.675-1.35.147-3.337-.025-4.294-.148-.025-.147-.074-.343-.074-.49 0-1.252.663-1.522 1.3-1.522.664 0 1.694.123 2.455.123.834 0 1.104-.295 1.104-.565 0-.368-.343-.515-1.006-.638l-1.767-.343C.785 12.453 0 11.423 0 10.343c0-1.325.933-2.454 2.798-2.65 1.398-.148 3.116.024 4.049.122.024.172.049.32.049.491 0 1.252-.663 1.522-1.276 1.522-.491 0-1.227-.099-2.086-.099-.884 0-1.227.246-1.227.54 0 .32.343.442.883.54l1.718.32c1.742.294 2.479 1.3 2.479 2.527m3.092 1.521c0 .761-.295 1.203-1.792 1.203-.196 0-.368-.025-.54-.05V6.22c0-.76.27-1.57 1.767-1.57.196 0 .393.024.565.049zm6.085 3.927c.197.098.59.22 1.105.245.859.025 1.325-.319 1.693-1.08L24 7.913a2.5 2.5 0 0 0-.957-.22c-.589 0-1.399.122-1.914 1.325l-1.497 3.534-2.945-4.81c-.196-.05-.662-.148-1.006-.148-1.03 0-1.62.393-2.233 1.031l-2.871 3.141 2.306 3.632c.418.663.982 1.006 1.89 1.006.589 0 1.104-.147 1.325-.245l-2.773-4.196 1.963-2.086 3.24 5.08Z"
/>
</svg>
</a>
{:else if i === "Paramount Plus"}
<a href="https://paramountplus.com" target="_blank">
<svg
xmlns="http://www.w3.org/2000/svg"
width="100"
viewBox="-161.6 95.4 1000 230.4"
xml:space="preserve"
>
<path
d="M94 154c0-40-42-59-92-59-53 0-102 24-120 61a69 69 0 0 0-8 31 47 47 0 0 0 8 27c7 11 21 19 41 19 25 0 45-14 45-38 0 0 0-6-8-6-6 0-7 4-7 6 1 19-10 34-30 34-22 0-31-19-31-39 0-41 31-68 63-80a128 128 0 0 1 47-8c36 0 66 14 66 51 0 32-26 58-58 60l1-4c7-23 15-48 28-66a71 71 0 0 1 3-5l-2-2a76 76 0 0 0-8 5c-60 47-62 179-142 179a62 62 0 0 1-8 0c-17-3-26-14-26-30l1-10a8 8 0 0 0-7-8h-1c-7 0-10 6-11 14 0 23 17 37 44 39l9 1c62 0 101-48 117-105a138 138 0 0 0 24-5c29-8 62-28 62-62zm596 14h-22l-5 11-9 20h-10l-3 6h11l-13 29c-9 20-24 39-32 39-2 0-3 0-3-2l3-9 10-21c4-9 12-24 12-31s-5-13-14-13c-10 0-19 6-28 15l6-13h-21l-17 38c-7 14-20 36-29 36-2 0-3-1-3-2a16 16 0 0 1 2-5l11-26 18-42h-22l-18 41c-6 13-20 34-29 34a3 3 0 0 1-3-2v-1l3-9 9-21 20-42h-22l-5 10c-3 6-9 14-18 14-4 0-6-2-7-3-1-14-10-22-23-22-19 0-32 11-40 24a123 123 0 0 0-13 29c-8 13-17 23-23 23-2 0-3-1-3-3l3-9 15-31c4-9 6-15 6-22 0-6-4-11-12-11-10 0-22 6-31 16l1-5c0-7-4-11-11-11-10 0-20 6-29 16l6-14h-20l-17 38c-8 18-21 36-29 36-1 0-2-1-2-3l5-15 22-52c2-3-9-6-22-6-15 0-29 7-40 16-7 6-13 9-16 9a2 2 0 0 1-2-1c0-3 8-12 8-19 0-3-2-5-6-5-8 0-18 8-25 16l6-14h-19l-18 38c-8 18-21 37-28 37-2 0-3-1-3-3 0-3 4-12 6-16l22-52c2-3-9-6-23-6-20 0-40 14-50 28a83 83 0 0 0-20 45c0 10 5 17 16 17 13 0 23-10 28-17a36 36 0 0 0-1 4c0 8 3 13 12 13 8 0 19-7 27-17l-7 15h22l21-47c7-16 15-24 17-24a1 1 0 0 1 1 1c0 1-3 6-3 10s2 7 8 7c4 0 10-2 14-5a79 79 0 0 0-18 43c0 10 5 17 16 17 12 0 23-11 29-18a45 45 0 0 0-1 5c0 7 4 13 13 13s16-6 25-17l-7 15h22l21-45c8-20 20-31 26-31a3 3 0 0 1 3 3 20 20 0 0 1-2 7l-29 67h23l21-47c8-20 18-30 25-30 3 0 4 1 4 4a20 20 0 0 1-3 7l-19 42a39 39 0 0 0-4 13c0 7 4 12 14 12 13 0 22-9 33-24v3c1 12 8 22 25 22 20 0 36-14 46-36a78 78 0 0 0 7-25 14 14 0 0 0 7 1 19 19 0 0 0 10-2l-3 7-12 26a41 41 0 0 0-4 15c0 8 4 14 13 14s21-9 30-21a34 34 0 0 0 0 8c0 6 2 13 11 13 10 0 19-8 27-18l-7 16h22l18-41c10-23 20-36 29-36a3 3 0 0 1 4 2v1c0 4-6 14-11 25l-10 22a43 43 0 0 0-4 16c0 7 4 12 12 12 13 0 26-13 33-22a46 46 0 0 0-2 14c0 13 7 19 18 19a34 34 0 0 0 20-6c9-6 15-15 21-23l-4-4c-5 8-11 15-17 19a19 19 0 0 1-11 5c-5 0-8-3-8-9s3-13 7-24l12-27 10-24h15l3-6h-15l14-31zM64 274c-3 0-5-1-5-5 0-11 9-31 20-46 8-11 18-18 29-18l-14 31c-10 23-21 38-30 38zm137 0c-3 0-5-1-5-5 0-11 9-31 20-46 8-11 18-18 29-18l-14 31c-10 24-21 38-30 38zm244-61c0 12-11 41-23 58-5 7-9 9-13 9-5 0-7-4-6-11 1-10 10-36 23-53 5-8 9-11 14-11 4 0 5 3 5 8zm343-14 20-47h-24l-20 47h-51l-9 19h51l-20 47h24l20-47h51l8-19h-50z"
/>
</svg>
</a>
<a aria-label={i} href="https://paramountplus.com" target="_blank">
<svg
xmlns="http://www.w3.org/2000/svg"
width="100"
viewBox="-161.6 95.4 1000 230.4"
xml:space="preserve"
>
<path
d="M94 154c0-40-42-59-92-59-53 0-102 24-120 61a69 69 0 0 0-8 31 47 47 0 0 0 8 27c7 11 21 19 41 19 25 0 45-14 45-38 0 0 0-6-8-6-6 0-7 4-7 6 1 19-10 34-30 34-22 0-31-19-31-39 0-41 31-68 63-80a128 128 0 0 1 47-8c36 0 66 14 66 51 0 32-26 58-58 60l1-4c7-23 15-48 28-66a71 71 0 0 1 3-5l-2-2a76 76 0 0 0-8 5c-60 47-62 179-142 179a62 62 0 0 1-8 0c-17-3-26-14-26-30l1-10a8 8 0 0 0-7-8h-1c-7 0-10 6-11 14 0 23 17 37 44 39l9 1c62 0 101-48 117-105a138 138 0 0 0 24-5c29-8 62-28 62-62zm596 14h-22l-5 11-9 20h-10l-3 6h11l-13 29c-9 20-24 39-32 39-2 0-3 0-3-2l3-9 10-21c4-9 12-24 12-31s-5-13-14-13c-10 0-19 6-28 15l6-13h-21l-17 38c-7 14-20 36-29 36-2 0-3-1-3-2a16 16 0 0 1 2-5l11-26 18-42h-22l-18 41c-6 13-20 34-29 34a3 3 0 0 1-3-2v-1l3-9 9-21 20-42h-22l-5 10c-3 6-9 14-18 14-4 0-6-2-7-3-1-14-10-22-23-22-19 0-32 11-40 24a123 123 0 0 0-13 29c-8 13-17 23-23 23-2 0-3-1-3-3l3-9 15-31c4-9 6-15 6-22 0-6-4-11-12-11-10 0-22 6-31 16l1-5c0-7-4-11-11-11-10 0-20 6-29 16l6-14h-20l-17 38c-8 18-21 36-29 36-1 0-2-1-2-3l5-15 22-52c2-3-9-6-22-6-15 0-29 7-40 16-7 6-13 9-16 9a2 2 0 0 1-2-1c0-3 8-12 8-19 0-3-2-5-6-5-8 0-18 8-25 16l6-14h-19l-18 38c-8 18-21 37-28 37-2 0-3-1-3-3 0-3 4-12 6-16l22-52c2-3-9-6-23-6-20 0-40 14-50 28a83 83 0 0 0-20 45c0 10 5 17 16 17 13 0 23-10 28-17a36 36 0 0 0-1 4c0 8 3 13 12 13 8 0 19-7 27-17l-7 15h22l21-47c7-16 15-24 17-24a1 1 0 0 1 1 1c0 1-3 6-3 10s2 7 8 7c4 0 10-2 14-5a79 79 0 0 0-18 43c0 10 5 17 16 17 12 0 23-11 29-18a45 45 0 0 0-1 5c0 7 4 13 13 13s16-6 25-17l-7 15h22l21-45c8-20 20-31 26-31a3 3 0 0 1 3 3 20 20 0 0 1-2 7l-29 67h23l21-47c8-20 18-30 25-30 3 0 4 1 4 4a20 20 0 0 1-3 7l-19 42a39 39 0 0 0-4 13c0 7 4 12 14 12 13 0 22-9 33-24v3c1 12 8 22 25 22 20 0 36-14 46-36a78 78 0 0 0 7-25 14 14 0 0 0 7 1 19 19 0 0 0 10-2l-3 7-12 26a41 41 0 0 0-4 15c0 8 4 14 13 14s21-9 30-21a34 34 0 0 0 0 8c0 6 2 13 11 13 10 0 19-8 27-18l-7 16h22l18-41c10-23 20-36 29-36a3 3 0 0 1 4 2v1c0 4-6 14-11 25l-10 22a43 43 0 0 0-4 16c0 7 4 12 12 12 13 0 26-13 33-22a46 46 0 0 0-2 14c0 13 7 19 18 19a34 34 0 0 0 20-6c9-6 15-15 21-23l-4-4c-5 8-11 15-17 19a19 19 0 0 1-11 5c-5 0-8-3-8-9s3-13 7-24l12-27 10-24h15l3-6h-15l14-31zM64 274c-3 0-5-1-5-5 0-11 9-31 20-46 8-11 18-18 29-18l-14 31c-10 23-21 38-30 38zm137 0c-3 0-5-1-5-5 0-11 9-31 20-46 8-11 18-18 29-18l-14 31c-10 24-21 38-30 38zm244-61c0 12-11 41-23 58-5 7-9 9-13 9-5 0-7-4-6-11 1-10 10-36 23-53 5-8 9-11 14-11 4 0 5 3 5 8zm343-14 20-47h-24l-20 47h-51l-9 19h51l-20 47h24l20-47h51l8-19h-50z"
/>
</svg>
</a>
{:else if i === "BBC iPlayer"}
<a href="https://www.bbc.co.uk/iplayer" target="_blank">
<svg width={80} height={wh} viewBox="0 0 605 107" xmlns="http://www.w3.org/2000/svg">
<path
d="M.022 34.586h22.003V106.3H.022zM108.188 9.069C100.852 3.025 90.348 0 76.67 0H38.461v106.299h22.598V70.024h15.462c9.118 0 16.848-1.362 23.192-4.088 6.342-2.725 11.174-6.691 14.496-11.894 3.319-5.204 4.98-11.523 4.98-18.956 0-11.299-3.668-19.97-11.001-26.017Zm-17.47 38.877c-3.519 2.728-8.648 4.088-15.387 4.088H61.059V17.989H75.48c6.639 0 11.719 1.39 15.238 4.163 3.518 2.776 5.279 7.088 5.279 12.935 0 5.848-1.761 10.135-5.279 12.859ZM156.058 0h-22.895v106.299h73.295V86.378h-50.4zm127.661 0H259.04c-7.136 16.751-14.198 33.997-21.186 51.737-6.987 17.743-13.852 35.93-20.59 54.562h24.53c2.576-7.731 5.152-15.41 7.731-23.044h43.114c2.576 7.533 5.152 15.216 7.731 23.044h25.126c-6.541-17.641-13.306-35.358-20.294-53.149-6.987-17.789-14.149-35.506-21.483-53.15Zm-27.652 64.225c4.855-13.775 9.862-27.452 15.016-41.033 2.476 6.642 4.98 13.406 7.507 20.294 2.528 6.89 4.981 13.803 7.36 20.739h-29.883Zm106.708-12.121c-4.362-7.631-21.508-44.273-25.571-52.103h-26.018l39.843 71.961V106.3h22.895V71.962L413.917.001h-25.423c-4.163 7.83-21.259 44.372-25.719 52.103Zm84.238 9h44.601v-19.03h-44.601V19.03h50.25V0h-73.145v106.299h75.97V87.27h-53.075zm144.253 23.415c-4.362-7.085-8.772-14.147-13.232-21.186 6.144-2.676 10.876-6.49 14.199-11.447 3.319-4.955 4.98-10.951 4.98-17.989 0-10.902-3.742-19.276-11.224-25.126C578.504 2.924 567.777 0 553.802 0h-38.804v106.299h22.598V67.645h18.287c7.631 12.688 15.015 25.571 22.151 38.654h26.315c-4.363-7.433-8.723-14.692-13.083-21.78Zm-22.747-38.654c-3.668 2.528-9.02 3.791-16.056 3.791h-14.867V17.989h15.016c7.036 0 12.362 1.29 15.982 3.865 3.616 2.579 5.427 6.593 5.427 12.043-.001 5.452-1.836 9.441-5.502 11.968ZM0 0h22.025v18.15H0z"
></path>
</svg>
</a>
<a aria-label={i} href="https://www.bbc.co.uk/iplayer" target="_blank">
<svg
width={80}
height={wh}
viewBox="0 0 605 107"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M.022 34.586h22.003V106.3H.022zM108.188 9.069C100.852 3.025 90.348 0 76.67 0H38.461v106.299h22.598V70.024h15.462c9.118 0 16.848-1.362 23.192-4.088 6.342-2.725 11.174-6.691 14.496-11.894 3.319-5.204 4.98-11.523 4.98-18.956 0-11.299-3.668-19.97-11.001-26.017Zm-17.47 38.877c-3.519 2.728-8.648 4.088-15.387 4.088H61.059V17.989H75.48c6.639 0 11.719 1.39 15.238 4.163 3.518 2.776 5.279 7.088 5.279 12.935 0 5.848-1.761 10.135-5.279 12.859ZM156.058 0h-22.895v106.299h73.295V86.378h-50.4zm127.661 0H259.04c-7.136 16.751-14.198 33.997-21.186 51.737-6.987 17.743-13.852 35.93-20.59 54.562h24.53c2.576-7.731 5.152-15.41 7.731-23.044h43.114c2.576 7.533 5.152 15.216 7.731 23.044h25.126c-6.541-17.641-13.306-35.358-20.294-53.149-6.987-17.789-14.149-35.506-21.483-53.15Zm-27.652 64.225c4.855-13.775 9.862-27.452 15.016-41.033 2.476 6.642 4.98 13.406 7.507 20.294 2.528 6.89 4.981 13.803 7.36 20.739h-29.883Zm106.708-12.121c-4.362-7.631-21.508-44.273-25.571-52.103h-26.018l39.843 71.961V106.3h22.895V71.962L413.917.001h-25.423c-4.163 7.83-21.259 44.372-25.719 52.103Zm84.238 9h44.601v-19.03h-44.601V19.03h50.25V0h-73.145v106.299h75.97V87.27h-53.075zm144.253 23.415c-4.362-7.085-8.772-14.147-13.232-21.186 6.144-2.676 10.876-6.49 14.199-11.447 3.319-4.955 4.98-10.951 4.98-17.989 0-10.902-3.742-19.276-11.224-25.126C578.504 2.924 567.777 0 553.802 0h-38.804v106.299h22.598V67.645h18.287c7.631 12.688 15.015 25.571 22.151 38.654h26.315c-4.363-7.433-8.723-14.692-13.083-21.78Zm-22.747-38.654c-3.668 2.528-9.02 3.791-16.056 3.791h-14.867V17.989h15.016c7.036 0 12.362 1.29 15.982 3.865 3.616 2.579 5.427 6.593 5.427 12.043-.001 5.452-1.836 9.441-5.502 11.968ZM0 0h22.025v18.15H0z"
></path>
</svg>
</a>
{:else if i === "Hulu"}
<a href="https://hulu.com" target="_blank">
<svg width={wh} height={wh} viewBox="0 0 64 23" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M10.0599 6.54678H6.7997C5.65659 6.54678 5.08178 6.85375 5.08178 6.85375V0H0V20.98H5.08178V12.5141C5.08178 11.7654 5.69215 11.1591 6.44385 11.1591H9.40148C10.1544 11.1591 10.763 11.7654 10.763 12.5141V20.98H15.8462V11.8589C15.8462 8.02298 13.2767 6.54678 10.0599 6.54678ZM58.9167 6.54678V15.0115C58.9167 15.7614 58.3078 16.368 57.5556 16.368H54.5982C53.8453 16.368 53.2367 15.7614 53.2367 15.0115V6.54678H48.1526V15.4338C48.1526 19.0662 50.4883 20.98 53.9404 20.98H58.9173V20.9541C62.0966 20.9541 64 18.7023 64 15.4338V6.54678H58.9167ZM30.2441 15.0115C30.2441 15.7614 29.6347 16.368 28.8824 16.368H25.9247C25.1719 16.368 24.563 15.7614 24.563 15.0115V6.54678H19.4797V15.4338C19.4797 19.0662 21.8154 20.98 25.2673 20.98H30.2441V20.9541C33.4237 20.9541 35.3274 18.7023 35.3274 15.4338V6.54678H30.2441V15.0115ZM39.2015 20.98H44.2853V0H39.2015V20.98Z"
></path>
</svg>
</a>
<a aria-label={i} href="https://hulu.com" target="_blank">
<svg
width={wh}
height={wh}
viewBox="0 0 64 23"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M10.0599 6.54678H6.7997C5.65659 6.54678 5.08178 6.85375 5.08178 6.85375V0H0V20.98H5.08178V12.5141C5.08178 11.7654 5.69215 11.1591 6.44385 11.1591H9.40148C10.1544 11.1591 10.763 11.7654 10.763 12.5141V20.98H15.8462V11.8589C15.8462 8.02298 13.2767 6.54678 10.0599 6.54678ZM58.9167 6.54678V15.0115C58.9167 15.7614 58.3078 16.368 57.5556 16.368H54.5982C53.8453 16.368 53.2367 15.7614 53.2367 15.0115V6.54678H48.1526V15.4338C48.1526 19.0662 50.4883 20.98 53.9404 20.98H58.9173V20.9541C62.0966 20.9541 64 18.7023 64 15.4338V6.54678H58.9167ZM30.2441 15.0115C30.2441 15.7614 29.6347 16.368 28.8824 16.368H25.9247C25.1719 16.368 24.563 15.7614 24.563 15.0115V6.54678H19.4797V15.4338C19.4797 19.0662 21.8154 20.98 25.2673 20.98H30.2441V20.9541C33.4237 20.9541 35.3274 18.7023 35.3274 15.4338V6.54678H30.2441V15.0115ZM39.2015 20.98H44.2853V0H39.2015V20.98Z"
></path>
</svg>
</a>
{:else}
<span>{i}</span>
<span>{i}</span>
{/if}
<style lang="scss">
a {
display: contents;
}
a {
display: contents;
}
svg {
fill: white;
opacity: 0.7;
svg {
fill: white;
opacity: 0.7;
&:hover {
opacity: 1;
}
}
&:hover {
opacity: 1;
}
}
span {
font-size: 14px;
font-weight: bold;
}
span {
font-size: 14px;
font-weight: bold;
}
</style>
+27 -23
View File
@@ -1,32 +1,36 @@
<script lang="ts">
import type { TMDBContentWatchProviders } from "@/types";
import ProviderIcon from "./ProviderIcon.svelte";
import type { TMDBContentWatchProviders } from "@/types";
import ProviderIcon from "./ProviderIcon.svelte";
export let providers: TMDBContentWatchProviders;
interface Props {
providers: TMDBContentWatchProviders;
}
let { providers }: Props = $props();
</script>
{#if providers?.flatrate?.length > 0 || providers?.free?.length > 0}
<div class="streaming-providers">
{#if providers?.flatrate?.length > 0}
{#each providers.flatrate as provider}
<ProviderIcon i={provider.provider_name} wh={40} />
{/each}
{/if}
{#if providers?.free?.length > 0}
{#each providers.free as provider}
<ProviderIcon i={provider.provider_name} wh={40} />
{/each}
{/if}
</div>
<div class="streaming-providers">
{#if providers?.flatrate?.length > 0}
{#each providers.flatrate as provider}
<ProviderIcon i={provider.provider_name} wh={40} />
{/each}
{/if}
{#if providers?.free?.length > 0}
{#each providers.free as provider}
<ProviderIcon i={provider.provider_name} wh={40} />
{/each}
{/if}
</div>
{/if}
<style lang="scss">
.streaming-providers {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 15px;
margin-top: auto;
padding-top: 10px;
}
.streaming-providers {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 15px;
margin-top: auto;
padding-top: 10px;
}
</style>
+17 -17
View File
@@ -1,24 +1,24 @@
<script lang="ts">
import type { ContentType, TMDBMovieSimilar, TMDBShowSimilar } from "@/types";
import HorizontalList from "../HorizontalList.svelte";
import { watchedList } from "@/store";
import { getWatchedDependedProps } from "@/lib/util/helpers";
import Poster from "../poster/Poster.svelte";
import type { ContentType, TMDBMovieSimilar, TMDBShowSimilar } from "@/types";
import HorizontalList from "../HorizontalList.svelte";
import Poster from "../poster/Poster.svelte";
$: wList = $watchedList;
interface Props {
type: ContentType;
similar: TMDBShowSimilar | TMDBMovieSimilar;
}
export let type: ContentType;
export let similar: TMDBShowSimilar | TMDBMovieSimilar;
let { type, similar }: Props = $props();
</script>
{#if similar?.results?.length > 0}
<HorizontalList title="Similar">
{#each similar.results as content}
<Poster
media={{ ...content, media_type: type }}
{...getWatchedDependedProps(content.id, type, wList)}
small={true}
/>
{/each}
</HorizontalList>
<HorizontalList title="Similar">
{#each similar.results as content, i}
<Poster
media={{ ...content, media_type: type }}
small={true}
bind:watched={similar.results[i].watched}
/>
{/each}
</HorizontalList>
{/if}
+64 -55
View File
@@ -1,68 +1,77 @@
<script lang="ts">
export let homepage: string | undefined;
export let title: string;
export let releaseYear: string | number;
export let voteAverage: number;
export let voteCount: number;
interface Props {
homepage: string | undefined;
title: string;
releaseYear: string | number;
voteAverage: number;
voteCount: number;
}
// if voteAvg bigger than 10, it is out of 100, so no need to * by 10
const vote = Math.round(voteAverage > 10 ? voteAverage : voteAverage * 10) / 10;
let { homepage, title, releaseYear, voteAverage, voteCount }: Props =
$props();
// if voteAvg bigger than 10, it is out of 100, so no need to * by 10
const vote =
Math.round(voteAverage > 10 ? voteAverage : voteAverage * 10) / 10;
</script>
<span class="title-container">
<span class="title">
{#if homepage}
<a href={homepage} target="_blank">{title}</a>
{:else}
<span class="t">{title}</span>
{/if}
<span class="year">{releaseYear}</span>
</span>
<span class="rating" title={`TMDB Rating: ${vote} out of 10 (based on ${voteCount} votes)`}>
<span>*</span>
{vote}
</span>
<span class="title">
{#if homepage}
<a href={homepage} target="_blank">{title}</a>
{:else}
<span class="t">{title}</span>
{/if}
<span class="year">{releaseYear}</span>
</span>
<span
class="rating"
title={`TMDB Rating: ${vote} out of 10 (based on ${voteCount} votes)`}
>
<span>*</span>
{vote}
</span>
</span>
<style lang="scss">
.title-container {
display: flex;
gap: 10px;
.title-container {
display: flex;
gap: 10px;
.title {
a,
span.t {
color: white;
text-decoration: none;
font-size: 30px;
font-weight: bold;
padding-right: 3px;
}
.title {
a,
span.t {
color: white;
text-decoration: none;
font-size: 30px;
font-weight: bold;
padding-right: 3px;
}
span.year {
font-size: 20px;
color: rgba($color: #fff, $alpha: 0.7);
}
}
span.year {
font-size: 20px;
color: rgba($color: #fff, $alpha: 0.7);
}
}
.rating {
display: flex;
align-items: start;
justify-content: center;
gap: 5px;
color: green;
margin-left: auto;
font-size: 22px;
color: gold;
font-weight: bolder;
.rating {
display: flex;
align-items: start;
justify-content: center;
gap: 5px;
color: green;
margin-left: auto;
font-size: 22px;
color: gold;
font-weight: bolder;
span {
font-family: "Rampart One";
-webkit-text-stroke: 1px gold;
font-size: 40px;
line-height: 0.7;
margin-top: 7px;
}
}
}
span {
font-family: "Rampart One";
-webkit-text-stroke: 1px gold;
font-size: 40px;
line-height: 0.7;
margin-top: 7px;
}
}
}
</style>
+59 -49
View File
@@ -1,63 +1,73 @@
<script lang="ts">
import { onMount } from "svelte";
import { onMount } from "svelte";
export let embed: string;
export let closed: () => void;
interface Props {
embed: string;
closed: () => void;
}
let modalDiv: HTMLDivElement;
let { embed, closed }: Props = $props();
onMount(() => {
// For better experience on keyboard.
// If modal shown by btn click, the btn will still be
// focused, so clicking `esc` wont trigger until tab
// is clicked first... which I count as not intuitive.
if (modalDiv) {
modalDiv.focus();
}
});
let modalDiv: HTMLDivElement = $state();
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
closed();
}
}
onMount(() => {
// For better experience on keyboard.
// If modal shown by btn click, the btn will still be
// focused, so clicking `esc` wont trigger until tab
// is clicked first... which I count as not intuitive.
if (modalDiv) {
modalDiv.focus();
}
});
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
closed();
}
}
</script>
<div
bind:this={modalDiv}
class="modal"
on:click={closed}
on:keydown={handleKeyDown}
role="button"
tabindex="0"
bind:this={modalDiv}
class="modal"
onclick={closed}
onkeydown={handleKeyDown}
role="button"
tabindex="0"
>
{#if embed}
<div class="wrapper">
<iframe title="Video Embed" src={embed} frameborder="0" width="100%" height="100%" />
</div>
{/if}
{#if embed}
<div class="wrapper">
<iframe
title="Video Embed"
src={embed}
frameborder="0"
width="100%"
height="100%"
></iframe>
</div>
{/if}
</div>
<style lang="scss">
.modal {
display: flex;
align-items: center;
justify-content: center;
position: fixed;
top: 0;
left: 0;
width: 100dvw;
height: 100dvh;
background-color: rgba($color: #000000, $alpha: 0.5);
z-index: 99998;
.modal {
display: flex;
align-items: center;
justify-content: center;
position: fixed;
top: 0;
left: 0;
width: 100dvw;
height: 100dvh;
background-color: rgba($color: #000000, $alpha: 0.5);
z-index: 99998;
.wrapper {
width: 100%;
max-width: 800px;
border-radius: 15px;
aspect-ratio: 16 / 9;
overflow: hidden;
margin: 20px;
}
}
.wrapper {
width: 100%;
max-width: 800px;
border-radius: 15px;
aspect-ratio: 16 / 9;
overflow: hidden;
margin: 20px;
}
}
</style>
+93 -77
View File
@@ -1,95 +1,111 @@
<script lang="ts">
import { decode } from "blurhash";
import Icon from "../Icon.svelte";
import { baseURL } from "../util/api";
import { onMount } from "svelte";
import type { Image } from "@/types";
import { run } from "svelte/legacy";
/**
* Users avatar.
*/
export let img: Image | undefined;
export let avatarDropped: ((ev: Event) => void) | undefined = undefined;
import { decode } from "blurhash";
import Icon from "../Icon.svelte";
import { baseURL } from "../util/api";
import { onMount } from "svelte";
import type { Image } from "@/types";
let bhCanvas: HTMLCanvasElement;
let avatarInput: HTMLInputElement;
interface Props {
/**
* Users avatar.
*/
img: Image | undefined;
avatarDropped?: ((ev: Event) => void) | undefined;
}
function avatarLoaded() {
console.log("avatar loaded.. removing canvas");
bhCanvas?.remove();
}
let { img, avatarDropped = undefined }: Props = $props();
$: {
if (img?.path && img?.blurHash && bhCanvas) {
const pixels = decode(img.blurHash, 80, 80);
const ctx = bhCanvas.getContext("2d");
if (ctx) {
const imageData = ctx.createImageData(80, 80);
imageData.data.set(pixels);
ctx.putImageData(imageData, 0, 0);
}
}
}
let bhCanvas: HTMLCanvasElement | undefined = $state();
let avatarInput: HTMLInputElement | undefined = $state();
onMount(() => {
// Ignore rest if avatarDropped not defined
if (typeof avatarDropped !== "function") return;
function avatarLoaded() {
console.log("avatar loaded.. removing canvas");
bhCanvas?.remove();
}
avatarInput?.addEventListener("input", avatarDropped);
run(() => {
if (img?.path && img?.blurHash && bhCanvas) {
const pixels = decode(img.blurHash, 80, 80);
const ctx = bhCanvas.getContext("2d");
if (ctx) {
const imageData = ctx.createImageData(80, 80);
imageData.data.set(pixels);
ctx.putImageData(imageData, 0, 0);
}
}
});
return () => {
avatarInput?.removeEventListener("input", avatarDropped!);
};
});
onMount(() => {
// Ignore rest if avatarDropped not defined
if (typeof avatarDropped !== "function") return;
avatarInput?.addEventListener("input", avatarDropped);
return () => {
avatarInput?.removeEventListener("input", avatarDropped!);
};
});
</script>
<div class={["img-ctr", typeof avatarDropped === "function" ? "" : "no-click"].join(" ")}>
{#if img?.path}
<canvas bind:this={bhCanvas} />
<img src={`${baseURL}/${img.path}`} alt="" on:load={avatarLoaded} />
{:else}
<Icon i="person" wh="100%" />
{/if}
{#if typeof avatarDropped === "function"}
<input bind:this={avatarInput} type="file" title="" accept=".jpg,.png,.gif,.webp" />
{/if}
<div
class={[
"img-ctr",
typeof avatarDropped === "function" ? "" : "no-click",
].join(" ")}
>
{#if img?.path}
<canvas bind:this={bhCanvas}></canvas>
<img src={`${baseURL}/${img.path}`} alt="" onload={avatarLoaded} />
{:else}
<Icon i="person" wh="100%" />
{/if}
{#if typeof avatarDropped === "function"}
<input
bind:this={avatarInput}
type="file"
title=""
accept=".jpg,.png,.gif,.webp"
/>
{/if}
</div>
<style lang="scss">
.img-ctr {
width: 80px;
min-width: 80px;
height: 80px;
min-height: 80px;
border-radius: 50%;
position: relative;
overflow: hidden;
fill: $text-color;
.img-ctr {
width: 80px;
min-width: 80px;
height: 80px;
min-height: 80px;
border-radius: 50%;
position: relative;
overflow: hidden;
fill: $text-color;
img {
width: 80px;
min-width: 80px;
height: 80px;
min-height: 80px;
object-fit: cover;
}
img {
width: 80px;
min-width: 80px;
height: 80px;
min-height: 80px;
object-fit: cover;
}
canvas {
position: absolute;
left: 0;
}
canvas {
position: absolute;
left: 0;
}
&:not(.no-click):hover {
opacity: 0.8;
}
&:not(.no-click):hover {
opacity: 0.8;
}
input[type="file"] {
opacity: 0;
width: 100%;
height: 100%;
left: 0;
position: absolute;
cursor: pointer;
}
}
input[type="file"] {
opacity: 0;
width: 100%;
height: 100%;
left: 0;
position: absolute;
cursor: pointer;
}
}
</style>
@@ -0,0 +1,98 @@
<script lang="ts">
import { onMount } from "svelte";
import Modal from "../Modal.svelte";
import axios from "axios";
import type { TrustedHeaderAuthLogoutDetailsResponse } from "@/types";
import Spinner from "../Spinner.svelte";
import { clearWatcharrData } from ".";
import { goto } from "$app/navigation";
interface Props {
onClose: () => void;
}
let { onClose }: Props = $props();
let loadingBtns = $state(true);
let logoutUrl: string | undefined = $state(undefined);
onMount(async () => {
try {
const r = await axios.get<TrustedHeaderAuthLogoutDetailsResponse>(
"/auth/proxy_logout_details",
);
logoutUrl = r?.data?.logoutUrl;
if (!logoutUrl?.toLowerCase()?.startsWith("http")) {
// If no protocol in logoutUrl, set https
logoutUrl = `https://${logoutUrl}`;
}
} catch (err) {
console.error("Failed to get proxy logout details!", err);
}
loadingBtns = false;
});
function logout() {
clearWatcharrData();
goto("/login?noAuto=1");
}
function proxyLogout() {
if (!logoutUrl) {
console.error(
"proxyLogout: Not supported without a configured logoutUrl.",
);
return;
}
clearWatcharrData();
window.location.replace(logoutUrl);
}
</script>
<Modal
title="Logout"
desc="You logged in via single sign-on. Did you click log out by mistake?"
{onClose}
>
{#if loadingBtns}
<Spinner />
{:else}
<div>
<p>
{#if logoutUrl}
This is likely what you want: <b
>Fully logout of your single sign-on service and Watcharr</b
>.
{:else}
Single sign-on logout has not been configured on this server. If you
are not the server operator, let them know!
{/if}
</p>
<button disabled={!logoutUrl} onclick={proxyLogout}
>Log out of Single Sign-On Service</button
>
<p>
Logging out of Watcharr will clear your local credentials and data, but <b
>you will still be logged in to your single sign-on service</b
>! Do <b>not</b> do this on a public machine and assume you are logged out,
this account could still be accessible.
</p>
<button onclick={logout}>Log out of Watcharr</button>
</div>
{/if}
</Modal>
<style lang="scss">
div {
display: flex;
flex-flow: column;
p {
margin-bottom: 5px;
}
button:first-of-type {
margin-bottom: 15px;
}
}
</style>
+14
View File
@@ -0,0 +1,14 @@
import { clearAllStores } from "@/store.svelte";
/**
* Helper to clear local data in client for logout
* process. Logic is here so it's not duplciated,
* this should help avoid forgetting to copy any
* new logic to other places, which could break stuff.
* Since this is reusable, technically unrelated logic
* should not be included here (eg: redirecting to /login).
*/
export function clearWatcharrData() {
localStorage.removeItem("token");
clearAllStores();
}

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