Compare commits

...

75 Commits

Author SHA1 Message Date
IRHM 0730a99df4 auth: use secret from config 2024-10-06 19:39:32 +01:00
IRHM 46a043e5b9 Fix .small-scrollbar style for chromium
.small-scrollbar is used on poster rating/status lists, but wasn't working because we also have the `scrollbar-width` property set, so now if the browser supports the webkit scrollbar styling, we will prefer that over scrollbar-width.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* test breaking formatting

* correct formatting

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-31 16:26:49 +00:00
45 changed files with 2377 additions and 852 deletions
+31
View File
@@ -0,0 +1,31 @@
name: Test PR (server)
on:
pull_request:
branches:
- "**"
tags-ignore:
- v*
paths:
- ".github/workflows/test-pr-server.yml"
- "server/**"
jobs:
format_check_go:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
- name: gofmt test
run: |
outp=$(gofmt -l .)
if [ -n "$outp" ]; then
echo "Need to fix formatting!"
echo "$outp"
exit 1
else
echo "Everything looks formatted"
exit 0
fi
@@ -1,4 +1,4 @@
name: Test PR Changes
name: Test PR (web)
on:
pull_request:
@@ -6,6 +6,9 @@ on:
- "**"
tags-ignore:
- v*
paths:
- ".github/workflows/test-pr-web.yml"
- "src/**"
jobs:
linter_check:
+593 -386
View File
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -13,8 +13,8 @@
"server": "cd ./server && MODE=DEV go run ."
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.0.1",
"@sveltejs/kit": "^2.5.24",
"@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",
@@ -23,14 +23,14 @@
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.43.0",
"prettier": "^3.3.2",
"prettier-plugin-svelte": "^3.2.3",
"svelte": "^4.2.17",
"svelte-check": "^3.8.6",
"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.2.11"
"vite": "^5.4.6"
},
"type": "module",
"dependencies": {
+7 -3
View File
@@ -13,7 +13,6 @@ import (
"log/slog"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
@@ -96,6 +95,11 @@ type UserSettings struct {
Country *string `gorm:"default:'US'" json:"country"`
// Does the user want show, season and episode automations enabled.
AutomateShowStatuses *bool `gorm:"default:true" json:"automateShowStatuses"`
// Rating system user wants to use (frontend only).
// RatingSystem enum in frontend maxes out at 3, so just max=3 on this and we should be gut.
RatingSystem *int `json:"ratingSystem" binding:"omitempty,max=3"`
// Rating step for supported rating systems (frontend only, enum goes up to 2).
RatingStep *int `json:"ratingStep" binding:"omitempty,max=2"`
}
// Holds third party service auth tokens for users.
@@ -202,7 +206,7 @@ func AuthRequired(db *gorm.DB) gin.HandlerFunc {
}
// Parse token
token, err := jwt.ParseWithClaims(atoken, &TokenClaims{}, func(token *jwt.Token) (interface{}, error) {
return []byte(os.Getenv("JWT_SECRET")), nil
return []byte(Config.JWT_SECRET), nil
})
if err != nil {
slog.Error("AuthRequired failed to parse token", "error", err)
@@ -582,7 +586,7 @@ func signJWT(user *User) (token string, err error) {
})
// Sign and get the complete encoded token as a string using the secret
return jwt.SignedString([]byte(os.Getenv("JWT_SECRET")))
return jwt.SignedString([]byte(Config.JWT_SECRET))
}
func hashPassword(password string, p *ArgonParams) (encodedHash string, err error) {
+1 -1
View File
@@ -28,7 +28,7 @@ type FollowThoughts struct {
FollowedUser PublicUser `json:"followedUser"`
Thoughts string `json:"thoughts"`
Status WatchedStatus `json:"status"`
Rating int8 `json:"rating"`
Rating float64 `json:"rating"`
}
func followUser(db *gorm.DB, currentUserId uint, toFollowUserId uint) (FollowPublic, error) {
+1 -1
View File
@@ -38,7 +38,7 @@ type Game struct {
type PlayedAddRequest struct {
Status WatchedStatus `json:"status"`
Rating int8 `json:"rating" binding:"max=10"`
Rating float64 `json:"rating" binding:"max=10"`
IgdbID int `json:"igdbId" binding:"required"`
}
+4 -4
View File
@@ -10,10 +10,10 @@ require (
github.com/go-co-op/gocron/v2 v2.11.0
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/joho/godotenv v1.5.1
golang.org/x/crypto v0.26.0
golang.org/x/crypto v0.27.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gorm.io/driver/sqlite v1.5.6
gorm.io/gorm v1.25.11
gorm.io/gorm v1.25.12
)
require (
@@ -49,8 +49,8 @@ require (
golang.org/x/arch v0.8.0 // indirect
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sys v0.23.0 // indirect
golang.org/x/text v0.17.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
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+8 -8
View File
@@ -106,18 +106,18 @@ 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.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY=
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM=
golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
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=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -130,7 +130,7 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/sqlite v1.5.6 h1:fO/X46qn5NUEEOZtnjJRWRzZMe8nqJiQ9E+0hi+hKQE=
gorm.io/driver/sqlite v1.5.6/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
gorm.io/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg=
gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
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=
+36 -1
View File
@@ -30,7 +30,7 @@ type ImportRequest struct {
Name string `json:"name"`
TmdbID int `json:"tmdbId"`
Type ContentType `json:"type"`
Rating int8 `json:"rating" binding:"max=10"`
Rating float64 `json:"rating" binding:"max=10"`
RatingCustomDate *time.Time `json:"ratingCustomDate"`
Status WatchedStatus `json:"status"`
Thoughts string `json:"thoughts"`
@@ -38,6 +38,7 @@ type ImportRequest struct {
Activity []Activity `json:"activity"`
WatchedEpisodes []WatchedEpisode `json:"watchedEpisodes"`
WatchedSeason []WatchedSeason `json:"watchedSeasons"`
Tags []TagAddRequest `json:"tags"`
}
type ImportResponse struct {
@@ -228,5 +229,39 @@ func successfulImport(db *gorm.DB, userId uint, contentId int, contentType Conte
w.WatchedEpisodes = ws.WatchedEpisodes
}
}
// Import tags, if any
if len(ar.Tags) > 0 {
// Create tags if they dont exist
slog.Debug("successfulImport: Importing tags")
for _, v := range ar.Tags {
// Check if tag exists
var t Tag
t, err := getTagByNameAndColor(db, userId, v.Name, v.Color, v.BgColor)
if err != nil && err.Error() != "tag does not exist" {
slog.Error("successfulImport: Failed to check for an existing tag", "name", v.Name, "error", err)
continue
}
if t.ID == 0 {
tag, err := addTag(db, userId, TagAddRequest{
Name: v.Name,
Color: v.Color,
BgColor: v.BgColor,
})
if err != nil {
slog.Error("successfulImport: Failed to add a tag.", "name", v.Name, "error", err)
continue
}
t = tag
}
// Associate the watched entry with the tag
err = addWatchedToTag(db, userId, t.ID, w.ID)
if err != nil {
slog.Error("successfulImport: Failed to associate watched entry with tag.", "error", err)
continue
}
w.Tags = append(w.Tags, t)
}
}
return ImportResponse{Type: IMPORT_SUCCESS, WatchedEntry: w}, nil
}
+1 -1
View File
@@ -310,7 +310,7 @@ func startTraktImport(db *gorm.DB, jobId string, userId uint, traktUsername stri
addJobError(jobId, userId, fmt.Sprintf("episode rating of %d for %s not imported. The episode does not exist in your history or watchlist.", v.Rating, title))
}
} else {
mv.Rating = int8(v.Rating)
mv.Rating = float64(v.Rating)
toImport[mapKey] = mv
}
} else {
+2 -2
View File
@@ -79,7 +79,7 @@ func startPlexSync(
Status: FINISHED,
ContentID: tmdbId,
ContentType: MOVIE,
Rating: int8(movie.UserRating),
Rating: float64(movie.UserRating),
WatchedDate: lastViewedAt,
}, IMPORTED_WATCHED_PLEX)
if err != nil {
@@ -146,7 +146,7 @@ func startPlexSync(
Status: FINISHED,
ContentID: tmdbId,
ContentType: SHOW,
Rating: int8(show.UserRating),
Rating: float64(show.UserRating),
WatchedDate: lastViewedAt,
}, IMPORTED_WATCHED_PLEX)
if err != nil {
+18
View File
@@ -54,6 +54,24 @@ func getTags(db *gorm.DB, userId uint) ([]Tag, error) {
// return *tag, nil
// }
// This method should only be used when we don't have the tagId
// (eg: when we are importing data) because this is not technically
// reliable, since users can have multiple tags with the same name/colors
// (realistically they probably won't, but...).
func getTagByNameAndColor(db *gorm.DB, userId uint, tagName string, tagColor string, tagBgColor string) (Tag, error) {
tag := new(Tag)
res := db.Model(&Tag{}).Where("name = ? AND user_id = ? AND color = ? AND bg_color = ?", tagName, userId, tagColor, tagBgColor).Preload("Watched").Find(&tag)
if res.Error != nil {
slog.Error("getTagByNameAndColor: Failed getting tag from database", "error", res.Error.Error())
return Tag{}, errors.New("failed getting tag")
}
if tag.ID == 0 {
slog.Error("getTagByNameAndColor: Tag does not exist for this user.", "user_id", userId)
return Tag{}, errors.New("tag does not exist")
}
return *tag, nil
}
// Let user create a tag.
func addTag(db *gorm.DB, userId uint, tr TagAddRequest) (Tag, error) {
if tr.Name == "" {
+8
View File
@@ -64,6 +64,12 @@ func userUpdate(db *gorm.DB, userId uint, ur UserSettings) (UserSettings, error)
if ur.Country != nil {
user.Country = ur.Country
}
if ur.RatingSystem != nil {
user.RatingSystem = ur.RatingSystem
}
if ur.RatingStep != nil {
user.RatingStep = ur.RatingStep
}
db.Save(&user)
return UserSettings{
Private: user.Private,
@@ -90,6 +96,8 @@ func userGetSettings(db *gorm.DB, userId uint) (UserSettings, error) {
IncludePreviouslyWatched: user.IncludePreviouslyWatched,
AutomateShowStatuses: user.AutomateShowStatuses,
Country: user.Country,
RatingSystem: user.RatingSystem,
RatingStep: user.RatingStep,
}, nil
}
+7 -4
View File
@@ -27,8 +27,11 @@ const (
type Watched struct {
GormModel
Status WatchedStatus `json:"status"`
Rating int8 `json:"rating"`
Status WatchedStatus `json:"status"`
// float so we can support decimal ratings.
// Ratings should still always be saved as out of 10.0,
// so they can be viewed with any ratings setting in the client.
Rating float64 `json:"rating" gorm:"type:numeric(2,1)"`
Thoughts string `json:"thoughts"`
Pinned bool `json:"pinned" gorm:"default:false;not null"`
UserID uint `json:"-" gorm:"uniqueIndex:usernctnidx;uniqueIndex:userngamidx"`
@@ -47,7 +50,7 @@ type Watched struct {
type WatchedAddRequest struct {
Status WatchedStatus `json:"status"`
Rating int8 `json:"rating" binding:"max=10"`
Rating float64 `json:"rating" binding:"max=10"`
Thoughts string `json:"thoughts"`
ContentID int `json:"contentId" binding:"required"`
ContentType ContentType `json:"contentType" binding:"required,oneof=movie tv"`
@@ -58,7 +61,7 @@ type WatchedAddRequest struct {
type WatchedUpdateRequest struct {
Status WatchedStatus `json:"status" binding:"required_without_all=Rating Thoughts RemoveThoughts Pinned"`
Rating int8 `json:"rating" binding:"max=10,required_without_all=Status Thoughts RemoveThoughts Pinned"`
Rating float64 `json:"rating" binding:"max=10,required_without_all=Status Thoughts RemoveThoughts Pinned"`
Thoughts string `json:"thoughts" binding:"required_without_all=Status Rating RemoveThoughts Pinned"`
RemoveThoughts bool `json:"removeThoughts"`
Pinned *bool `json:"pinned" binding:"required_without_all=Status Rating Thoughts RemoveThoughts"`
+42
View File
@@ -46,6 +46,19 @@
d="M195.94 459.38C205.37 472.67 221 480 240 480a16 16 0 0014.31-8.85c3-6.06 15.25-24 28.19-42.9 18-26.33 40.35-59.08 55.23-84.81l.13-.22c20.48-35.49 30.35-54.94 33.82-62l1-2a16.48 16.48 0 015.79-5.23A15.93 15.93 0 01386 272h25.32A84.7 84.7 0 00496 187.3v-38.6A84.7 84.7 0 00411.31 64h-48.79a17.46 17.46 0 01-9.58-2.89C330 46.13 286.66 32 240 32c-7.45 0-14.19.14-20.27.38a8 8 0 00-6.2 12.68l.1.14C222.2 57.59 224 71 224 80a61.16 61.16 0 01-5.19 24.77 17.38 17.38 0 000 14.06 63.81 63.81 0 010 50.39 17.32 17.32 0 000 14 62.13 62.13 0 010 49.58 18.13 18.13 0 000 14.68A60.41 60.41 0 01224 273c0 8.2-2 21.3-8 31.18a15.66 15.66 0 00-1.14 13.65c.38 1 .76 2.06 1.13 3.17a24.8 24.8 0 01.86 11.57c-3 19.35-9.67 36.3-16.74 54.16-3.08 7.78-6.27 15.82-9.22 24.27-6.14 17.56-4.3 35.2 5.05 48.38z"
/></svg
>
{:else if i === "thumb-up"}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<path
d="M472.06 334l-144.16-6.13c-4.61-.36-23.9-1.21-23.9-25.87 0-23.81 19.16-25.33 24.14-25.88L472.06 270c12.67.13 23.94 14.43 23.94 32s-11.27 31.87-23.94 32zM330.61 202.33L437.35 194C450 194 464 210.68 464 227.88v.33c0 16.32-11.14 29.62-24.88 29.79l-108.45-1.73C304 253 304 236.83 304 229.88c0-22.88 21.8-27.15 26.61-27.55zM421.85 480l-89.37-8.93C308 470.14 304 453.82 304 443.59c0-18.38 13.41-24.6 26.67-24.6l91-3c14.54.23 26.32 14.5 26.32 32s-11.67 31.67-26.14 32.01zm34.36-71.5l-126.4-6.21c-9.39-.63-25.81-3-25.81-26.37 0-12 4.35-25.61 25-27.53l127.19-3.88c13.16.14 23.81 13.49 23.81 31.4s-10.65 32.43-23.79 32.58z"
/>
<path
d="M133.55 238.06A15.85 15.85 0 01126 240a15.82 15.82 0 007.51-1.92zM174.14 168.78l.13-.23-.13.23c-20.5 35.51-30.36 54.95-33.82 62 3.47-7.07 13.34-26.51 33.82-62z"
fill="none"
/>
<path d="M139.34 232.84l1-2a16.27 16.27 0 01-6.77 7.25 16.35 16.35 0 005.77-5.25z" /><path
d="M316.06 52.62C306.63 39.32 291 32 272 32a16 16 0 00-14.31 8.84c-3 6.07-15.25 24-28.19 42.91-18 26.33-40.35 59.07-55.23 84.8l-.13.23c-20.48 35.49-30.35 54.93-33.82 62l-1 2a16.35 16.35 0 01-5.79 5.22 15.82 15.82 0 01-7.53 2h-25.31A84.69 84.69 0 0016 324.69v38.61a84.69 84.69 0 0084.69 84.7h48.79a17.55 17.55 0 019.58 2.89C182 465.87 225.34 480 272 480c7.45 0 14.19-.14 20.27-.38a8 8 0 006.2-12.68l-.1-.14C289.8 454.41 288 441 288 432a61.2 61.2 0 015.19-24.77 17.36 17.36 0 000-14.05 63.81 63.81 0 010-50.39 17.32 17.32 0 000-14 62.15 62.15 0 010-49.59 18.13 18.13 0 000-14.68A60.33 60.33 0 01288 239c0-8.2 2-21.3 8-31.19a15.63 15.63 0 001.14-13.64c-.38-1-.76-2.07-1.13-3.17a24.84 24.84 0 01-.86-11.58c3-19.34 9.67-36.29 16.74-54.16 3.08-7.78 6.27-15.82 9.22-24.26 6.14-17.57 4.3-35.2-5.05-48.38z"
/>
</svg>
{:else if i === "pause"}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<path
@@ -92,12 +105,26 @@
d="M368 368L144 144M368 144L144 368"
/>
</svg>
{:else if i === "close-circle"}
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
<path
fill="currentColor"
d="M256 48C141.31 48 48 141.31 48 256s93.31 208 208 208 208-93.31 208-208S370.69 48 256 48zm75.31 260.69a16 16 0 11-22.62 22.62L256 278.63l-52.69 52.68a16 16 0 01-22.62-22.62L233.37 256l-52.68-52.69a16 16 0 0122.62-22.62L256 233.37l52.69-52.68a16 16 0 0122.62 22.62L278.63 256z"
/>
</svg>
{:else if i === "filter"}
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
<path
d="M296 464a23.88 23.88 0 01-7.55-1.23L208.3 436.1a23.92 23.92 0 01-16.3-22.78V294.11a.44.44 0 00-.09-.13L23.26 97.54A30 30 0 0146.05 48H466a30 30 0 0122.79 49.54L320.09 294a.77.77 0 00-.09.13V440a23.93 23.93 0 01-24 24z"
/>
</svg>
{:else if i === "filter-circle"}
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
<path
fill="currentColor"
d="M256 48C141.31 48 48 141.31 48 256s93.31 208 208 208 208-93.31 208-208S370.69 48 256 48zm32 304h-64a16 16 0 010-32h64a16 16 0 010 32zm48-64H176a16 16 0 010-32h160a16 16 0 010 32zm32-64H144a16 16 0 010-32h224a16 16 0 010 32z"
/>
</svg>
{:else if i === "reel"}
<svg
id="reel"
@@ -344,6 +371,14 @@
d="M8.273 7.247v8.423l-2.103-.003v-5.216l-2.03 2.404-1.989-2.458-.02 5.285H.001L0 7.247h2.203l1.865 2.545 2.015-2.546 2.19.001zm8.628 2.069l.025 6.335h-2.365l-.008-2.871h-2.8c.07.499.21 1.266.417 1.779.155.381.298.751.583 1.128l-1.705 1.125c-.349-.636-.622-1.337-.878-2.082a9.296 9.296 0 0 1-.507-2.179c-.085-.75-.097-1.471.107-2.212a3.908 3.908 0 0 1 1.161-1.866c.313-.293.749-.5 1.1-.687.351-.187.743-.264 1.107-.359a7.405 7.405 0 0 1 1.191-.183c.398-.034 1.107-.066 2.39-.028l.545 1.749H14.51c-.593.008-.878.001-1.341.209a2.236 2.236 0 0 0-1.278 1.92l2.663.033.038-1.81h2.309zm3.992-2.099v6.627l3.107.032-.43 1.775h-4.807V7.187l2.13.03z"
/>
</svg>
{:else if i === "todomovies"}
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="100 20 600 570">
<path d="m494.113 203.464 50.423 50.423-178.649 178.65-50.423-50.424z" />
<path d="m234.964 300.388 50.424-50.424 130.149 130.15-50.424 50.423z" />
<path
d="m95.774 15.493 187.325.704c.704 0-5.634 276.761-188.733 488.029M700.705 14.789l-187.243.704c-.703 0 5.632 276.76 188.651 488.029"
/>
</svg>
{:else if i === "themoviedb"}
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 24 24">
<path
@@ -399,6 +434,13 @@
d="M467 45.2A44.45 44.45 0 00435.29 32H312.36a30.63 30.63 0 00-21.52 8.89L45.09 286.59a44.82 44.82 0 000 63.32l117 117a44.83 44.83 0 0063.34 0l245.65-245.6A30.6 30.6 0 00480 199.8v-123a44.24 44.24 0 00-13-31.6zM384 160a32 32 0 1132-32 32 32 0 01-32 32z"
/>
</svg>
{:else if i === "ticket"}
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
<path
fill="currentColor"
d="M490.18 181.4l-44.13-44.13a20 20 0 00-27-1 30.81 30.81 0 01-41.68-1.6 30.81 30.81 0 01-1.6-41.67 20 20 0 00-1-27L330.6 21.82a19.91 19.91 0 00-28.13 0l-70.35 70.34a39.87 39.87 0 00-9.57 15.5 7.71 7.71 0 01-4.83 4.83 39.78 39.78 0 00-15.5 9.58l-180.4 180.4a19.91 19.91 0 000 28.13L66 374.73a20 20 0 0027 1 30.69 30.69 0 0143.28 43.28 20 20 0 001 27l44.13 44.13a19.91 19.91 0 0028.13 0l180.4-180.4a39.82 39.82 0 009.58-15.49 7.69 7.69 0 014.84-4.84 39.84 39.84 0 0015.49-9.57l70.34-70.35a19.91 19.91 0 00-.01-28.09zm-228.37-29.65a16 16 0 01-22.63 0l-11.51-11.51a16 16 0 0122.63-22.62l11.51 11.5a16 16 0 010 22.63zm44 44a16 16 0 01-22.62 0l-11-11a16 16 0 1122.63-22.63l11 11a16 16 0 01.01 22.66zm44 44a16 16 0 01-22.63 0l-11-11a16 16 0 0122.63-22.62l11 11a16 16 0 01.05 22.67zm44.43 44.54a16 16 0 01-22.63 0l-11.44-11.5a16 16 0 1122.68-22.57l11.45 11.49a16 16 0 01-.01 22.63z"
/>
</svg>
{/if}
<style lang="scss">
-146
View File
@@ -1,146 +0,0 @@
<script lang="ts">
import { onMount } from "svelte";
export let rating: number | undefined;
export let onChange: (newRating: number) => void;
let hoveredRating: number | undefined;
let shownRating: number | undefined;
let ratingContainer: HTMLDivElement;
let ratingText: HTMLSpanElement;
const ratingDesc = [
"Apalling",
"Horrible",
"Very Bad",
"Bad",
"Average",
"Fine",
"Good",
"Very Good",
"Great",
"Masterpiece"
];
function handleStarClick(r: number) {
if (r === rating) return;
onChange(r);
}
$: {
if (hoveredRating !== undefined) shownRating = hoveredRating;
else if (rating !== undefined) {
shownRating = rating;
resetRatingText();
} else shownRating = undefined;
}
function resetRatingText() {
if (!ratingText) {
console.warn("resetRatingText: ratingText is not defined yet.");
return;
}
if (typeof rating === "number" && rating > 0) {
ratingText.innerText = ratingDesc[rating - 1];
} else {
ratingText.innerText = "Select Your Rating";
}
}
function handleStarHover(
ev: MouseEvent & {
currentTarget: EventTarget & HTMLButtonElement;
},
r: number
) {
hoveredRating = r;
// We set innerText instead of letting svelte update dom for us
// since we need the new width of span right now.
ratingText.innerText = ratingDesc[r - 1];
const start = ratingContainer?.getBoundingClientRect()?.x;
const starl = ev?.currentTarget?.getBoundingClientRect()?.left;
const rb = ratingText?.getBoundingClientRect();
ratingText.style.left = `${starl - start - rb.width / 2 + 11.5}px`;
ratingText.style.transform = "unset";
}
function handleStarHoverEnd() {
hoveredRating = undefined;
ratingText.style.left = "50%";
ratingText.style.transform = "translateX(-50%)";
resetRatingText();
}
onMount(() => {
resetRatingText();
});
</script>
<div class="rating-container" bind:this={ratingContainer}>
<span bind:this={ratingText}></span>
<div class="rating">
{#each [10, 9, 8, 7, 6, 5, 4, 3, 2, 1] as v}
<button
class="plain{shownRating === v ? ' lit' : ''}"
on:click={() => handleStarClick(v)}
on:mouseenter={(ev) => handleStarHover(ev, v)}
on:mouseleave={() => handleStarHoverEnd()}
>
*
</button>
{/each}
</div>
</div>
<style lang="scss">
.rating-container {
display: flex;
flex-flow: column;
overflow: visible;
& > span {
position: relative;
transition:
left 100ms ease-in,
transform 100ms ease-in;
max-width: max-content;
left: 50%;
transform: translateX(-50%);
}
}
.rating {
display: flex;
flex-flow: row-reverse;
align-items: center;
justify-content: center;
color: $text-color;
-webkit-text-stroke: 1.5px $text-color;
cursor: pointer;
overflow: hidden;
margin: 10px 0 10px 0;
padding: 1px;
button {
font-size: 55px;
font-family: "Rampart One";
letter-spacing: 10px;
line-height: 52px;
height: 38px;
&:global(.lit),
&:global(.lit ~ button) {
color: gold;
-webkit-text-stroke: 1.5px gold;
}
@media screen and (max-width: 450px) {
font-size: 50px;
}
@media screen and (max-width: 420px) {
font-size: 45px;
}
}
}
</style>
+23 -8
View File
@@ -3,7 +3,13 @@
import Icon from "@/lib/Icon.svelte";
import Poster from "@/lib/poster/Poster.svelte";
import PosterList from "@/lib/poster/PosterList.svelte";
import { activeFilters, activeSort, serverFeatures, userSettings } from "@/store";
import {
activeFilters,
activeSort,
clearActiveFilters,
serverFeatures,
userSettings
} from "@/store";
import type { Watched } from "@/types";
import GamePoster from "./poster/GamePoster.svelte";
import { get } from "svelte/store";
@@ -211,14 +217,23 @@
{/each}
{:else}
<div class="empty-list">
<Icon i="reel" wh={80} />
{#if isPublicList}
<h2 class="norm">This watched list is empty!</h2>
<h4 class="norm">Come back later to see if they have added anything.</h4>
{#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}
<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>
<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}
+65
View File
@@ -0,0 +1,65 @@
export interface StayInViewOptions {
/**
* If the `node` contains an element (like an arrow for menus),
* that should be shifted over to account for any shifting
* of the `node` itself, then pass a selector for it here.
*/
elToShiftSelector?: string;
}
export default function stayInView(node: HTMLElement, opts: StayInViewOptions) {
console.debug("stayInView: Initial opts:", opts);
let { elToShiftSelector } = opts;
let viewDeb: ReturnType<typeof setTimeout>;
/**
* Move element to in view, if it isn't.
*
* Currently only supports moving `node` back into view if oob
* on the left. May need to support other sides and/or resizing
* when `node` still wont fit after shifting it in the future.
*/
const getInView = () => {
const nrect = node.getBoundingClientRect();
const brect = document.body.getBoundingClientRect();
console.debug("stayInView->getInView: Called.", nrect, brect);
if (nrect.x <= brect.x) {
const diff = nrect.x - brect.x + 10;
console.debug(
"stayInView->getInView: Node is out of bounds on the left, shifting forwards to:",
diff
);
node.style.left = `${diff}px`;
if (elToShiftSelector) {
const elToShift = node.querySelector(elToShiftSelector) as HTMLElement;
if (elToShift) {
console.debug("stayInView->getInView: Shifting elToShift.");
const nrectNew = node.getBoundingClientRect();
const arrowDiff = nrectNew.left - nrect.left;
elToShift.style.left = `${elToShift.offsetLeft - arrowDiff}px`;
} else {
console.warn("elToShift not found.", elToShiftSelector);
}
}
}
};
const getInViewDeb = () => {
clearTimeout(viewDeb);
viewDeb = setTimeout(getInView, 200);
};
window.addEventListener("resize", getInViewDeb);
getInView();
return {
update(opts: StayInViewOptions) {
console.debug("stayInView: Opts updated", opts);
elToShiftSelector = opts.elToShiftSelector;
getInView();
},
destroy() {
window.removeEventListener("resize", getInViewDeb);
}
};
}
+44 -5
View File
@@ -1,7 +1,9 @@
<script lang="ts">
import { activeFilters, serverFeatures } from "@/store";
import { activeFilters, clearActiveFilters, serverFeatures } from "@/store";
import type { Filters } from "@/types";
import { get } from "svelte/store";
import Icon from "../Icon.svelte";
import tooltip from "../actions/tooltip";
function filterClicked(type: keyof Filters, f: string) {
const af = get(activeFilters);
@@ -19,7 +21,18 @@
<div class="menu">
<div class="inner">
<h4 class="norm sm-caps">type</h4>
<div class="title">
<h4 class="norm sm-caps">type</h4>
{#if filter?.type?.length > 0 || filter?.status?.length > 0}
<button
class="plain"
use:tooltip={{ text: "Clear", pos: "left" }}
on:click={() => clearActiveFilters()}
>
<Icon i="close-circle" wh={18} />
</button>
{/if}
</div>
<div class="type-filter">
<button
class={`${filter.type.includes("tv") ? "active" : ""}`}
@@ -93,11 +106,37 @@
}
div.inner {
h4 {
& > h4 {
margin-top: 8px;
margin-bottom: 8px;
}
&:not(:first-of-type) {
margin-top: 8px;
.title {
display: flex;
flex-flow: row;
align-items: center;
margin-bottom: 8px;
gap: 5px;
/* Always height of when clear filters btn is shown so there is no jump */
min-height: 26px;
button {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 26px;
padding: 2px 3px;
border-radius: 8px;
&.manage-on {
color: #f3555a;
background-color: $text-color;
}
&:first-of-type {
margin-left: auto;
}
}
}
+148
View File
@@ -0,0 +1,148 @@
<!-- Extra Details View For Posters -->
<script lang="ts">
import { userSettings, wlDetailedView } from "@/store";
import { RatingSystem, type PosterExtraDetails, type WatchedStatus } from "@/types";
import { getOrdinalSuffix, monthsShort, watchedStatuses } from "../util/helpers";
import Icon from "../Icon.svelte";
import { toShowableRating, toWhichThumb } from "../rating/helpers";
import { page } from "$app/stores";
export let rating: number | undefined;
export let status: WatchedStatus | undefined;
export let details: PosterExtraDetails | undefined;
$: dve = $wlDetailedView;
$: settings = $userSettings;
$: isUsingThumbs = settings && settings.ratingSystem === RatingSystem.Thumbs;
function formatDate(e: number) {
if (!e) {
return "Unknown";
}
const d = new Date(e);
return `${d.getDate()}${getOrdinalSuffix(d.getDate())} ${monthsShort[d.getMonth()]} '${String(
d.getFullYear()
).substring(2, 4)}`;
}
</script>
{#if ($page.url?.pathname === "/" || $page.url?.pathname.startsWith("/search/")) && details && dve && dve.length > 0}
<div class="extra-details">
<!--
The `if` statements can't be on their own line to look pretty
because that will cause whitespace in the generated markup,
which causes the :empty css tag to not work.
Can be reverted when its possible to trim whitespace in svelte
OR when :empty tag is updated in browsers to new spec and counts whitespace as empty.
-->
<div>
{#if details.dateAdded && dve.includes("dateAdded")}
<span title="Date added to watch list">
<i><Icon i="calendar" /></i>
<span>
{formatDate(Date.parse(details.dateAdded))}
</span>
</span>
{/if}{#if details.dateModified && dve.includes("dateModified")}
<span title="Date last modified">
<i><Icon i="pencil" wh={15} /></i>
<span>
{formatDate(Date.parse(details.dateModified))}
</span>
</span>
{/if}{#if details.lastWatched && dve.includes("lastWatched")}
<span title="Latest season watched">
<i><Icon i="play" wh={15} /></i>
<span>{details.lastWatched}</span>
</span>
{/if}{#if dve.includes("statusRating")}
<span class="status-rating" title="Status and Rating">
{#if !isUsingThumbs}
<i><Icon i="star" /></i>
{/if}
<span class="rating-span">
{#if isUsingThumbs}
{@const r = toWhichThumb(rating)}
{#if r === -1}
<i><Icon i="thumb-down" /></i>
{:else if r === 0}
<span
style="display: flex; transform: translate(2px, 3px); font-size: 40px; font-family: 'Shrikhand';"
>
-
</span>
{:else if r === 1}
<i><Icon i="thumb-up" /></i>
{/if}
{:else}
{toShowableRating(rating)}
{/if}
</span>
{#if status}
<i><Icon i={watchedStatuses[status]} wh={15} /></i>
{/if}
</span>
{/if}
</div>
</div>
{/if}
<style lang="scss">
.extra-details {
position: absolute;
bottom: 5px;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-flow: column;
justify-content: center;
align-items: center;
font-size: 14px;
width: 160px;
color: white;
background-color: $poster-extra-detail-bg-color;
border-radius: 10px;
transition: opacity 100ms ease-out;
pointer-events: none !important;
& > div {
padding: 8px 3px;
&:empty {
padding: 0px;
}
& > span {
display: flex;
flex-flow: row;
align-items: center;
gap: 8px;
height: 15px;
font-weight: bold;
&.status-rating {
gap: 10px;
}
&:not(:last-child) {
margin-bottom: 5px;
}
i {
display: flex;
width: 15px;
fill: white;
}
}
}
.status-rating i:last-of-type {
margin-left: auto;
}
.status-rating .rating-span:empty {
/* Can happen when content is not rated and using thumbs system */
display: none;
}
}
</style>
+67 -91
View File
@@ -1,13 +1,11 @@
<script lang="ts">
import type { ExtraDetails, ExtraDetailsGame, Image, WatchedStatus } from "@/types";
import Icon from "../Icon.svelte";
import type { PosterExtraDetails, Image, WatchedStatus } from "@/types";
import {
addClassToParent,
calculateTransformOrigin,
getOrdinalSuffix,
isTouch,
monthsShort,
watchedStatuses
monthsShort
} from "@/lib/util/helpers";
import { goto } from "$app/navigation";
import { baseURL, removeWatched, updatePlayed } from "../util/api";
@@ -16,8 +14,8 @@
import PosterStatus from "./PosterStatus.svelte";
import PosterRating from "./PosterRating.svelte";
import { wlDetailedView } from "@/store";
import { page } from "$app/stores";
import { decode } from "blurhash";
import ExtraDetails from "./ExtraDetails.svelte";
export let id: number | undefined = undefined; // Watched list id
export let media: {
@@ -33,7 +31,7 @@
export let small = false;
export let disableInteraction = false;
export let hideButtons = false;
export let extraDetails: ExtraDetailsGame | undefined = undefined;
export let extraDetails: PosterExtraDetails | undefined = undefined;
export let fluidSize = false;
export let pinned = false;
// When provided, default click handlers will instead run this callback.
@@ -131,9 +129,39 @@
}}
on:focusin={(e) => {
if (!posterActive) calculateTransformOrigin(e);
if (!isTouch()) {
posterActive = true;
}
}}
on:focusout={() => {
if (!isTouch()) {
// Only on !isTouch (to match focusin) to avoid breaking a tap and hold on link on mobile.
posterActive = false;
}
}}
on:mouseleave={() => {
posterActive = false;
const ae = document.activeElement;
if (
ae &&
ae instanceof HTMLElement &&
(ae.parentElement?.id === "ilikemoviessueme" ||
ae.parentElement?.parentElement?.id === "ilikemoviessueme")
) {
// Stops the poster being re-focused after the browser window
// loses focus, then regains it (ex: you middle click the poster,
// go to the opened tab (or lose browser window focus, then when
// you come back the poster is sent `focusin` and stuck activated
// until mouseleave again).
ae.blur();
}
}}
on:mouseleave={() => (posterActive = false)}
on:click={() => (posterActive = true)}
on:keyup={(e) => {
if (e.key === "Tab") {
e.currentTarget.scrollIntoView({ block: "center" });
}
}}
on:keypress={() => console.log("on kpress")}
class={`${posterActive ? "active " : ""}${pinned ? "pinned " : ""}`}
>
@@ -159,23 +187,16 @@
}}
/>
{/if}
{#if $page.url?.pathname === "/" && extraDetails && dve && dve.length > 0}
<div class="extra-details">
<!--
This is one line because svelte leaves whitespace,
which causes the :empty css tag to not work.
Can be reverted when its possible to trim whitespace in svelte
OR when :empty tag is updated in browsers to new spec and counts whitespace as empty.
So ye this is probably gonna be one line until the end of time.
-->
<!-- prettier-ignore -->
<div>{#if dve.includes("dateAdded")}<span title="Date added to watch list"><i><Icon i="calendar" /></i><span>{formatDate(Date.parse(extraDetails.dateAdded))}</span></span>{/if}{#if dve.includes("dateModified")}<span title="Date last modified"><i><Icon i="pencil" wh={15} /></i><span>{formatDate(Date.parse(extraDetails.dateModified))}</span></span>{/if}{#if dve.includes("statusRating")}<span class="status-rating" title="Status and Rating"><i><Icon i="star" /></i><span>{rating}</span>{#if status}<i><Icon i={watchedStatuses[status]} wh={15} /></i>{/if}</span>{/if}</div>
</div>
{#if id && !posterActive}
<!-- Must be on watched list, and poster not hovered -->
<ExtraDetails details={extraDetails} {status} {rating} />
{/if}
<div
on:click={() => {
on:click={(e) => {
if (typeof onClick !== "undefined") {
onClick();
// Prevent the link inside this div from being clicked in this case.
e.preventDefault();
return;
}
if (posterActive && link) goto(link);
@@ -184,21 +205,17 @@
id="ilikemoviessueme"
class="inner"
role="button"
tabindex="0"
tabindex="-1"
>
<h2>
{#if typeof onClick === "undefined" && link}
<a data-sveltekit-preload-data="tap" href={link}>
{title}
</a>
{:else}
<a data-sveltekit-preload-data="tap" href={link} class="small-scrollbar">
<h2>
{title}
{/if}
{#if year}
<time>{year}</time>
{/if}
</h2>
<span>{media.summary}</span>
{#if year}
<time>{year}</time>
{/if}
</h2>
<span>{media.summary}</span>
</a>
{#if !hideButtons}
<div class="buttons">
@@ -219,6 +236,15 @@
outline: 3px solid gold;
}
li {
&:not(.active) {
.container .inner,
.container .inner .buttons {
pointer-events: none !important;
}
}
}
.container {
display: flex;
flex-flow: column;
@@ -289,54 +315,6 @@
}
}
.extra-details {
position: absolute;
bottom: 5px;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-flow: column;
justify-content: center;
align-items: center;
font-size: 14px;
width: 160px;
color: white;
background-color: $poster-extra-detail-bg-color;
border-radius: 10px;
transition: opacity 100ms ease-out;
& > div {
padding: 8px 3px;
&:empty {
padding: 0px;
}
& > span {
display: flex;
flex-flow: row;
align-items: center;
gap: 8px;
height: 15px;
font-weight: bold;
&:not(:last-child) {
margin-bottom: 5px;
}
i {
display: flex;
width: 15px;
fill: white;
}
}
}
.status-rating i:last-of-type {
margin-left: auto;
}
}
.inner {
position: absolute;
opacity: 0;
@@ -349,6 +327,11 @@
background-color: transparent;
transition: opacity 150ms cubic-bezier(0.19, 1, 0.22, 1);
& > a {
height: 100%;
overflow: auto;
}
h2 {
font-family:
sans-serif,
@@ -394,23 +377,16 @@
font-size: 11px;
}
&:hover,
&:focus-within {
.active & {
transform: scale(1.3);
z-index: 99;
.extra-details {
opacity: 0;
}
}
&.small:hover,
&.small:focus-within {
.active &.small {
transform: scale(1.1);
}
&:hover,
&:focus-within,
.active &,
&:global(.details-shown) {
img {
filter: blur(4px) grayscale(80%);
+3 -3
View File
@@ -143,7 +143,7 @@
&:not(.no-zoom) {
&:hover,
&:focus-within {
&:has(:focus-visible) {
transform: scale(1.3);
z-index: 99;
}
@@ -151,13 +151,13 @@
&:not(:not(.no-zoom)) {
&:hover,
&:focus-within {
&:has(:focus-visible) {
outline: 3px solid $text-color;
}
}
&:hover,
&:focus-within,
&:has(:focus-visible),
&:global(.details-shown) {
.inner {
color: white;
+67 -111
View File
@@ -1,23 +1,13 @@
<script lang="ts">
import type { ExtraDetails, MediaType, WLDetailedViewOption, WatchedStatus } from "@/types";
import Icon from "../Icon.svelte";
import {
addClassToParent,
calculateTransformOrigin,
getOrdinalSuffix,
isTouch,
monthsShort,
watchedStatuses
} from "@/lib/util/helpers";
import type { PosterExtraDetails, MediaType, WatchedStatus } from "@/types";
import { addClassToParent, calculateTransformOrigin, isTouch } from "@/lib/util/helpers";
import { goto } from "$app/navigation";
import tooltip from "../actions/tooltip";
import { baseURL, removeWatched, updateWatched } from "../util/api";
import { notify } from "../util/notify";
import { onMount } from "svelte";
import PosterStatus from "./PosterStatus.svelte";
import PosterRating from "./PosterRating.svelte";
import { wlDetailedView } from "@/store";
import { page } from "$app/stores";
import ExtraDetails from "./ExtraDetails.svelte";
export let id: number | undefined = undefined; // Watched list id
export let media: {
@@ -35,14 +25,12 @@
export let small = false;
export let disableInteraction = false;
export let hideButtons = false;
export let extraDetails: ExtraDetails | undefined = undefined;
export let extraDetails: PosterExtraDetails | undefined = undefined;
export let fluidSize = false;
export let pinned = false;
// When provided, default click handlers will instead run this callback.
export let onClick: (() => void) | undefined = undefined;
$: dve = $wlDetailedView;
// If poster is active (scaled up)
let posterActive = false;
@@ -90,16 +78,6 @@
}
}
function formatDate(e: number) {
if (!e) {
return "Unknown";
}
const d = new Date(e);
return `${d.getDate()}${getOrdinalSuffix(d.getDate())} ${monthsShort[d.getMonth()]} '${String(
d.getFullYear()
).substring(2, 4)}`;
}
onMount(() => {
if (containerEl) {
if (small) {
@@ -123,9 +101,39 @@
}}
on:focusin={(e) => {
if (!posterActive) calculateTransformOrigin(e);
if (!isTouch()) {
posterActive = true;
}
}}
on:focusout={() => {
if (!isTouch()) {
// Only on !isTouch (to match focusin) to avoid breaking a tap and hold on link on mobile.
posterActive = false;
}
}}
on:mouseleave={() => {
posterActive = false;
const ae = document.activeElement;
if (
ae &&
ae instanceof HTMLElement &&
(ae.parentElement?.id === "ilikemoviessueme" ||
ae.parentElement?.parentElement?.id === "ilikemoviessueme")
) {
// Stops the poster being re-focused after the browser window
// loses focus, then regains it (ex: you middle click the poster,
// go to the opened tab (or lose browser window focus, then when
// you come back the poster is sent `focusin` and stuck activated
// until mouseleave again).
ae.blur();
}
}}
on:mouseleave={() => (posterActive = false)}
on:click={() => (posterActive = true)}
on:keyup={(e) => {
if (e.key === "Tab") {
e.currentTarget.scrollIntoView({ block: "center" });
}
}}
on:keypress={() => console.log("on kpress")}
class={`${posterActive ? "active " : ""}${pinned ? "pinned " : ""}`}
>
@@ -147,23 +155,16 @@
}}
/>
{/if}
{#if ($page.url?.pathname === "/" || $page.url?.pathname.startsWith("/search/")) && extraDetails && dve && dve.length > 0}
<div class="extra-details">
<!--
This is one line because svelte leaves whitespace,
which causes the :empty css tag to not work.
Can be reverted when its possible to trim whitespace in svelte
OR when :empty tag is updated in browsers to new spec and counts whitespace as empty.
So ye this is probably gonna be one line until the end of time.
-->
<!-- prettier-ignore -->
<div>{#if dve.includes("dateAdded")}<span title="Date added to watch list"><i><Icon i="calendar" /></i><span>{formatDate(Date.parse(extraDetails.dateAdded))}</span></span>{/if}{#if dve.includes("dateModified")}<span title="Date last modified"><i><Icon i="pencil" wh={15} /></i><span>{formatDate(Date.parse(extraDetails.dateModified))}</span></span>{/if}{#if extraDetails.lastWatched && dve.includes("lastWatched")}<span title="Latest season watched"><i><Icon i="play" wh={15} /></i><span>{extraDetails.lastWatched}</span></span>{/if}{#if dve.includes("statusRating")}<span class="status-rating" title="Status and Rating"><i><Icon i="star" /></i><span>{rating}</span>{#if status}<i><Icon i={watchedStatuses[status]} wh={15} /></i>{/if}</span>{/if}</div>
</div>
{#if id && !posterActive}
<!-- Must be on watched list, and poster not hovered -->
<ExtraDetails details={extraDetails} {status} {rating} />
{/if}
<div
on:click={() => {
on:click={(e) => {
if (typeof onClick !== "undefined") {
onClick();
// Prevent the link inside this div from being clicked in this case.
e.preventDefault();
return;
}
if (posterActive && link) goto(link);
@@ -172,21 +173,17 @@
id="ilikemoviessueme"
class="inner"
role="button"
tabindex="0"
tabindex="-1"
>
<h2>
{#if typeof onClick === "undefined" && link}
<a data-sveltekit-preload-data="tap" href={link}>
{title}
</a>
{:else}
<a data-sveltekit-preload-data="tap" href={link} class="small-scrollbar">
<h2>
{title}
{/if}
{#if year}
<time>{year}</time>
{/if}
</h2>
<span>{media.overview}</span>
{#if year}
<time>{year}</time>
{/if}
</h2>
<span>{media.overview}</span>
</a>
{#if !hideButtons}
<div class="buttons">
@@ -207,6 +204,15 @@
outline: 3px solid gold;
}
li {
&:not(.active) {
.container .inner,
.container .inner .buttons {
pointer-events: none !important;
}
}
}
.container {
display: flex;
flex-flow: column;
@@ -257,54 +263,6 @@
}
}
.extra-details {
position: absolute;
bottom: 5px;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-flow: column;
justify-content: center;
align-items: center;
font-size: 14px;
width: 160px;
color: white;
background-color: $poster-extra-detail-bg-color;
border-radius: 10px;
transition: opacity 100ms ease-out;
& > div {
padding: 8px 3px;
&:empty {
padding: 0px;
}
& > span {
display: flex;
flex-flow: row;
align-items: center;
gap: 8px;
height: 15px;
font-weight: bold;
&:not(:last-child) {
margin-bottom: 5px;
}
i {
display: flex;
width: 15px;
fill: white;
}
}
}
.status-rating i:last-of-type {
margin-left: auto;
}
}
.inner {
position: absolute;
opacity: 0;
@@ -317,6 +275,11 @@
background-color: transparent;
transition: opacity 150ms cubic-bezier(0.19, 1, 0.22, 1);
& > a {
height: 100%;
overflow: auto;
}
h2 {
font-family:
sans-serif,
@@ -362,23 +325,16 @@
font-size: 11px;
}
&:hover,
&:focus-within {
.active & {
transform: scale(1.3);
z-index: 99;
.extra-details {
opacity: 0;
}
}
&.small:hover,
&.small:focus-within {
.active &.small {
transform: scale(1.1);
}
&:hover,
&:focus-within,
.active &,
&:global(.details-shown) {
img {
filter: blur(4px) grayscale(80%);
+124 -9
View File
@@ -1,15 +1,26 @@
<script lang="ts">
import { userSettings } from "@/store";
import tooltip from "../actions/tooltip";
import { RatingStep, RatingSystem } from "@/types";
import Icon from "../Icon.svelte";
import { toShowableRating, toWhichThumb } from "../rating/helpers";
export let rating: number | undefined = undefined;
export let handleStarClick: (rating: number) => void;
export let disableInteraction: boolean = false;
/**
* When not minimal, we will use user settings to
* display ratings as they want.
*/
export let minimal = false;
export let direction: "top" | "bot" = "top";
export let btnTooltip: string = "";
export let hideStarWhenRated = false;
let ratingsShown = false;
$: settings = $userSettings;
$: isUsingThumbs = settings && settings.ratingSystem === RatingSystem.Thumbs;
</script>
<button
@@ -24,22 +35,106 @@
}}
on:mouseleave={(ev) => {
ratingsShown = false;
ev.currentTarget.blur();
}}
use:tooltip={{ text: btnTooltip, pos: "top", condition: !!btnTooltip && !ratingsShown }}
>
<span style={hideStarWhenRated && rating ? "display: none" : ""}>*</span>
{#if !isUsingThumbs}
<span class="star" style={hideStarWhenRated && rating ? "display: none" : ""}>*</span>
{/if}
{#if !minimal}
<span class={!rating && disableInteraction ? "unrated-text" : ""}>
{rating ? rating : disableInteraction ? "Unrated" : "Rate"}
<span class={[!rating && disableInteraction ? "unrated-text" : "", "rating-text"].join(" ")}>
{#if rating}
{#if isUsingThumbs}
{@const r = toWhichThumb(rating)}
{#if r === -1}
<Icon i="thumb-down" />
{:else if r === 0}
<span
style="display: flex; transform: translate(2px, -7px); font-size: 40px; font-family: 'Shrikhand';"
>
-
</span>
{:else if r === 1}
<Icon i="thumb-up" />
{/if}
{:else}
{toShowableRating(rating)}
{/if}
{:else if disableInteraction}
Unrated
{:else}
Rate
{/if}
</span>
<div
class={[
ratingsShown ? "shown" : "",
"small-scrollbar",
direction,
isUsingThumbs ? "is-using-thumbs" : ""
].join(" ")}
>
{#if isUsingThumbs}
<button
on:click={() => handleStarClick(1)}
class="plain{rating && rating > 0 && rating < 5 ? ' active' : ''}"
style="display: flex; justify-content: center;"
>
<i style="display: flex; width: 35px;"><Icon i="thumb-down" /></i>
</button>
<button
on:click={() => handleStarClick(5)}
class="plain{rating && rating > 4 && rating < 9 ? ' active' : ''}"
style="display: flex; justify-content: center;"
>
<span
style="display: flex; transform: translate(0px, -2px); font-size: 40px; height: 40px; font-family: 'Shrikhand';"
>
-
</span>
</button>
<button
on:click={() => handleStarClick(9)}
class="plain{rating && rating > 8 ? ' active' : ''}"
style="display: flex; justify-content: center;"
>
<i style="display: flex; width: 35px;"><Icon i="thumb-up" /></i>
</button>
{:else}
{@const stars =
settings?.ratingSystem == RatingSystem.OutOf5
? [5, 4, 3, 2, 1]
: [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]}
{#each stars as v}
<button
class="plain{rating === v ? ' active' : ''}"
on:click={(ev) => {
ev.stopPropagation();
handleStarClick(settings?.ratingSystem === RatingSystem.OutOf5 ? v * 2 : v);
ratingsShown = false;
}}
>
{#if settings?.ratingSystem === RatingSystem.OutOf100}
{v * 10}
{:else if settings?.ratingSystem === RatingSystem.OutOf5}
{v}
{:else}
{v}
{/if}
</button>
{/each}
{/if}
</div>
{:else if rating}
<span>
<span class="rating-text">
{rating}
</span>
{/if}
{#if ratingsShown}
<div class={["small-scrollbar", direction].join(" ")}>
<!-- Ratings popup for usage with `minimal` -->
{#if minimal && ratingsShown}
<div class={["small-scrollbar", direction, isUsingThumbs ? "is-using-thumbs" : ""].join(" ")}>
{#each [10, 9, 8, 7, 6, 5, 4, 3, 2, 1] as v}
<button
class="plain{rating === v ? ' active' : ''}"
@@ -92,7 +187,7 @@
}
span {
&:first-child {
&.star {
color: $text-color;
font-size: 39px;
letter-spacing: 10px;
@@ -100,16 +195,22 @@
height: 42px;
}
&:nth-child(2) {
&.rating-text {
color: $text-color;
font-size: 22px;
height: 35px; // quick fix to make the rating num look centered - text-stroke makes it look not centered
& :global(svg) {
height: 100%;
padding: 5px;
}
}
}
&:hover span,
&:focus-visible span {
color: $poster-rating-color;
fill: $poster-rating-color;
}
div {
@@ -127,6 +228,10 @@
z-index: 40;
box-shadow: 0px 0px 1px #000;
&:not(.shown) {
display: none;
}
&.bot {
top: calc(100% + 2px);
border-radius: 0 0 4px 4px;
@@ -149,6 +254,16 @@
background-color: rgb(100, 100, 100, 0.25);
}
}
&.is-using-thumbs {
height: 150px;
top: calc(-100% - 120px);
button span {
/* Overriding color so dash for thumbs ratings stays text-color */
color: $text-color;
}
}
}
}
</style>
+33 -25
View File
@@ -25,7 +25,6 @@
}}
on:mouseleave={(ev) => {
statusesShown = false;
ev.currentTarget.blur();
}}
use:tooltip={{ text: btnTooltip, pos: "top", condition: !!btnTooltip && !statusesShown }}
>
@@ -34,30 +33,35 @@
{:else}
<span class={["no-icon", small ? "small" : ""].join(" ")}>+</span>
{/if}
{#if statusesShown}
<div class={["small-scrollbar", status ? "has-status" : "", direction].join(" ")}>
{#each Object.entries(watchedStatuses) as [statusName, icon]}
<button
class="plain{status && status !== statusName ? ' not-active' : ''}"
on:click={() => handleStatusClick(statusName)}
use:tooltip={{
text: toUnderstandableStatus(statusName, isForGame)
}}
>
<Icon i={icon} />
</button>
{/each}
{#if status}
<button
class="plain not-active"
on:click={() => handleStatusClick("DELETE")}
use:tooltip={{ text: "Delete" }}
>
<Icon i="trash" />
</button>
{/if}
</div>
{/if}
<div
class={[
statusesShown ? "shown" : "",
"small-scrollbar",
status ? "has-status" : "",
direction
].join(" ")}
>
{#each Object.entries(watchedStatuses) as [statusName, icon]}
<button
class="plain{status && status !== statusName ? ' not-active' : ''}"
on:click={() => handleStatusClick(statusName)}
use:tooltip={{
text: toUnderstandableStatus(statusName, isForGame)
}}
>
<Icon i={icon} />
</button>
{/each}
{#if status}
<button
class="plain not-active"
on:click={() => handleStatusClick("DELETE")}
use:tooltip={{ text: "Delete" }}
>
<Icon i="trash" />
</button>
{/if}
</div>
</button>
<style lang="scss">
@@ -112,6 +116,10 @@
z-index: 40;
box-shadow: 0px 0px 1px #000;
&:not(.shown) {
display: none;
}
&.bot {
top: calc(100% + 2px);
border-radius: 0 0 4px 4px;
+30
View File
@@ -0,0 +1,30 @@
<script lang="ts">
import { userSettings } from "@/store";
import { RatingSystem } from "@/types";
import StarRating from "./StarRating.svelte";
import ThumbRating from "./ThumbRating.svelte";
$: settings = $userSettings;
export let rating: number | undefined;
export let onChange: (newRating: number) => Promise<boolean>;
</script>
<div class="wrap">
{#if settings?.ratingSystem === RatingSystem.Thumbs}
<ThumbRating {rating} {onChange} />
{:else}
<!-- All other systems work with the stars -->
<StarRating {rating} {onChange} />
{/if}
</div>
<style lang="scss">
.wrap {
width: 377px;
@media screen and (max-width: 420px) {
width: 100%;
}
}
</style>
+130
View File
@@ -0,0 +1,130 @@
<script lang="ts">
import { userSettings } from "@/store";
import Setting from "../settings/Setting.svelte";
import { RatingStep, RatingSystem } from "@/types";
import { updateUserSetting } from "../util/api";
$: settings = $userSettings;
function update(v: RatingSystem) {
if (!settings) {
console.error("No settings.");
return;
}
updateUserSetting("ratingSystem", v, () => {
settings.ratingSystem = v;
});
}
function updateStep(v: number) {
if (!settings) {
console.error("No settings.");
return;
}
updateUserSetting("ratingStep", v, () => {
settings.ratingStep = v;
});
}
</script>
<Setting title="Rating System" desc="How would you like to rate content?">
<div class="rat-wrap">
<button
class={["plain", settings?.ratingSystem === RatingSystem.OutOf5 ? "active" : ""].join(" ")}
on:click={() => update(RatingSystem.OutOf5)}
>
0-5
</button>
<button
class={[
"plain",
settings?.ratingSystem === RatingSystem.OutOf10 || !settings?.ratingSystem ? "active" : ""
].join(" ")}
on:click={() => update(RatingSystem.OutOf10)}
>
0-10
</button>
<button
class={["plain", settings?.ratingSystem === RatingSystem.OutOf100 ? "active" : ""].join(" ")}
on:click={() => update(RatingSystem.OutOf100)}
>
0-100
</button>
<button
class={["plain", settings?.ratingSystem === RatingSystem.Thumbs ? "active" : ""].join(" ")}
on:click={() => update(RatingSystem.Thumbs)}
>
Thumbs
</button>
</div>
</Setting>
{#if settings?.ratingSystem === RatingSystem.OutOf10 || settings?.ratingSystem === RatingSystem.OutOf5 || !settings?.ratingSystem}
<Setting title="Rating Step" desc="How would you like to increment through the stars?">
<div class="rat-wrap">
<button
class={["plain", settings?.ratingStep === RatingStep.Point1 ? "active" : ""].join(" ")}
on:click={() => updateStep(RatingStep.Point1)}
>
0.1
</button>
<button
class={["plain", settings?.ratingStep === RatingStep.Point5 ? "active" : ""].join(" ")}
on:click={() => updateStep(RatingStep.Point5)}
>
0.5
</button>
<button
class={[
"plain",
settings?.ratingStep === RatingStep.One || !settings?.ratingStep ? "active" : ""
].join(" ")}
on:click={() => updateStep(RatingStep.One)}
>
1
</button>
</div>
</Setting>
{/if}
<style lang="scss">
.rat-wrap {
display: flex;
flex-flow: row;
/* gap: 5px; */
border-radius: 10px;
overflow: auto;
button {
display: flex;
flex-flow: row;
align-items: center;
justify-content: center;
gap: 3px;
width: 100%;
padding: 15px 8px;
color: $text-color;
background-color: $accent-color;
/* font-family: "Shrikhand", sans-serif; */
font-size: 16px;
transition:
color 150ms ease-in-out,
background-color 150ms ease-in-out;
&:hover,
&.active {
color: $bg-color;
background-color: $accent-color-hover;
font-weight: bold;
}
&:not(:last-of-type) {
/* border-right: 1px solid $placeholder-color; */
}
:global(svg) {
width: 24px;
}
}
}
</style>
+451
View File
@@ -0,0 +1,451 @@
<!-- To be used from Rating component -->
<script lang="ts">
import { userSettings } from "@/store";
import { RatingStep, RatingSystem } from "@/types";
import { onMount } from "svelte";
$: settings = $userSettings;
export let rating: number | undefined;
export let onChange: (newRating: number) => Promise<boolean>;
let hoveredRating: number | undefined;
let shownRating: number | undefined;
let shownPerc: number | undefined;
let ratingContainer: HTMLDivElement;
let ratingWrapEl: HTMLDivElement;
let ratingText: HTMLSpanElement;
let highlightContainer: HTMLDivElement;
let normalContainer: HTMLDivElement;
/**
* Percentage star step.
*/
let starStep = 10;
$: stars =
settings?.ratingSystem == RatingSystem.OutOf5
? [5, 4, 3, 2, 1]
: [10, 9, 8, 7, 6, 5, 4, 3, 2, 1];
const ratingDesc = [
"Apalling",
"Horrible",
"Very Bad",
"Bad",
"Average",
"Fine",
"Good",
"Very Good",
"Great",
"Masterpiece"
];
async function saveSelectedRating() {
if (!shownPerc) {
console.warn("saveSelectedRating: Rating not set, ignoring call.");
return;
}
// Rating needs to always scale to out of 10 before saving,
// scaling down the shown percentage will work.
const r = Math.round(shownPerc) / 10;
console.log("saveSelectedRating:", shownPerc, "=", r);
if (r === rating) {
console.warn("saveSelectedRating: Rating not changed, ignoring call.");
return;
}
return await onChange(r);
}
$: {
if (hoveredRating !== undefined && hoveredRating > 0) {
console.debug("showRatingCaller: We have a hoveredRating.");
shownRating = hoveredRating;
showRating(
Math.round(
(hoveredRating * 100) / (settings?.ratingSystem === RatingSystem.OutOf5 ? 5 : 10)
)
);
} else if (rating !== undefined) {
console.debug("showRatingCaller: We have a rating.");
shownRating = rating;
showRating(Math.round((rating * 100) / 10));
} else {
console.debug("showRatingCaller: We have nothing.");
shownRating = undefined;
showRating(0);
}
}
function setHoveredRatingFromPerc(perc: number) {
let hovR = perc;
if (settings?.ratingSystem === RatingSystem.OutOf5) {
hovR = hovR / 20;
} else {
hovR = hovR / 10;
}
hoveredRating = Math.max(Math.min(hovR, stars[0]), 0);
}
/**
* Show RatingText over hovered star.
*/
function moveRatingText() {
try {
if (!hoveredRating) {
handleRatingHoverEnd();
return;
}
// Get star number we are putting text above
let r: number;
if (settings?.ratingSystem === RatingSystem.OutOf5) {
r = Math.ceil(hoveredRating);
} else {
r = Math.ceil(hoveredRating);
}
const start = ratingContainer?.getBoundingClientRect()?.x;
const starl = ratingWrapEl?.getBoundingClientRect()?.left;
const bodyRect = document.body.getBoundingClientRect();
const oneStarWidth = bodyRect.width <= 420 ? 33.5 : 37.5;
const offset = (r - 1) * oneStarWidth;
let prospectLeft = starl + offset - start + 11.5;
const bodyXX = bodyRect.width - 120;
if (bodyXX < prospectLeft) {
// Move text back if going off right
prospectLeft = prospectLeft - (prospectLeft - bodyXX);
} else if (bodyRect.x + 20 > prospectLeft && starl < 60) {
// This should stop the first star text from going off left
prospectLeft = prospectLeft + 30;
}
ratingText.style.left = `${prospectLeft}px`;
} catch (err) {
console.error("moveRatingText: Failed!", err);
}
}
function handleRatingHoverEnd() {
console.debug("handleRatingHoverEnd");
hoveredRating = undefined;
ratingText.style.left = "50%";
}
function handleMouseOver(
ev: (TouchEvent | MouseEvent) & {
currentTarget: EventTarget & HTMLDivElement;
}
) {
const rect = ev.currentTarget.getBoundingClientRect();
const x = (ev instanceof MouseEvent ? ev.clientX : ev.touches[0].clientX) - rect.left; // rel to start of container
const perc = Math.ceil(Math.round((x * 100) / rect.width) / starStep) * starStep;
setHoveredRatingFromPerc(perc);
moveRatingText();
}
function handleKeyDown(
ev: KeyboardEvent & {
currentTarget: EventTarget & HTMLDivElement;
}
) {
console.log("handleKeyDown:", ev);
if (ev.code === "ArrowRight") {
console.debug("handleKeyDown: Increasing selected rating.");
setHoveredRatingFromPerc((shownPerc ?? 0) + starStep);
} else if (ev.code === "ArrowLeft") {
console.debug("handleKeyDown: Decreasing selected rating.");
setHoveredRatingFromPerc(shownPerc ? shownPerc - starStep : 0);
} else if (ev.key === "Enter") {
console.debug("handleKeyDown: Enter pressed.. saving rating.");
saveSelectedRating();
}
}
function showRating(perc: number) {
try {
if (!highlightContainer || !normalContainer) {
console.warn("showRating: Containers not defined yet.");
return;
}
// console.debug("showRating: perc", perc, starStep);
perc = Math.max(Math.min(Math.round(perc / starStep) * starStep, 100), 0);
// console.debug("showRating: perc2", perc);
if (perc > 1) {
let percToHighlight = perc;
let percToHide = 100 - perc;
if (starStep == 5) {
// On step of 5, it looks nicer when we take highlight back one percent,
// otherwise more than half the star looks highlighted... ew. This is only visual.
percToHighlight--;
percToHide++;
}
highlightContainer.style.display = "flex";
highlightContainer.style.width = `${percToHighlight}%`;
// We shrink this container too because of what seems to be a bug
// where the text-stroke draws on the upper layer too, instead
// of being hidden by the highlighted stars overlay.
normalContainer.style.width = `${percToHide}%`;
} else {
highlightContainer.style.display = "none";
highlightContainer.style.width = "0";
normalContainer.style.width = "100%";
}
shownPerc = perc;
} catch (err) {
console.error("showRating: Failed!", err);
}
}
$: {
// console.log("block", starStep, settings?.ratingStep, settings.ratingSystem);
try {
if (settings) {
if (typeof settings.ratingSystem === "number") {
// Set default star step for system.
switch (settings.ratingSystem) {
case RatingSystem.OutOf100:
starStep = 1;
break;
case RatingSystem.OutOf5:
starStep = 20;
break;
case RatingSystem.OutOf10:
default:
starStep = 10;
break;
}
}
// Override default with user set step if supported by this system.
if (typeof settings.ratingStep === "number") {
if (
settings.ratingSystem === RatingSystem.OutOf5 ||
settings.ratingSystem === RatingSystem.OutOf10 ||
!settings.ratingSystem
) {
if (
settings.ratingStep === RatingStep.Point1 ||
settings.ratingStep === RatingStep.Point5 ||
settings.ratingStep === RatingStep.One
) {
// Turn enum value into an actual step value.
const actualRatingStep =
settings.ratingStep === RatingStep.Point1
? 0.1
: settings.ratingStep === RatingStep.Point5
? 0.5
: 1;
console.log("actualRatingStep", actualRatingStep);
starStep =
settings.ratingSystem === RatingSystem.OutOf5
? actualRatingStep * 20
: actualRatingStep * 10;
console.debug("Set starStep from setting:", starStep);
} else {
starStep = settings.ratingSystem === RatingSystem.OutOf5 ? 20 : 10;
console.debug("Set starStep using default:", starStep);
}
}
}
}
} catch (err) {
console.error("Failed to set startStep from settings:", err);
}
}
onMount(() => {
if (rating) showRating(Math.round((rating * 100) / 10));
});
</script>
<!-- step: {starStep}
stepSetting: {settings?.ratingStep}<br />
hoveredRating: {hoveredRating}<br />
shownPerc: {shownPerc}<br /> -->
<div class="rating-container" bind:this={ratingContainer}>
<span bind:this={ratingText}>
{#if hoveredRating}
{#if settings?.ratingSystem === RatingSystem.OutOf5 && shownPerc}
{ratingDesc[Math.ceil(shownPerc / 10) - 1]}
{:else}
{ratingDesc[Math.ceil(hoveredRating) - 1]}
{/if}
{#if settings?.ratingSystem === RatingSystem.OutOf100}
({shownPerc})
{:else}
({hoveredRating})
{/if}
{:else if typeof rating === "number" && rating > 0}
{ratingDesc[Math.ceil(rating) - 1]}
{#if shownPerc}
{#if settings?.ratingSystem === RatingSystem.OutOf100}
({shownPerc})
{:else if settings?.ratingSystem === RatingSystem.OutOf5}
({shownPerc / 20})
{:else}
({shownPerc / 10})
{/if}
{/if}
{:else}
Select Your Rating
{/if}
</span>
<div
class="rating-wrap"
bind:this={ratingWrapEl}
on:pointermove={(ev) => handleMouseOver(ev)}
on:touchmove={(ev) => handleMouseOver(ev)}
on:touchstart={(ev) => {
// Prevent `click` event from triggering.
// Mainly for firefox mobile, these events dont work the same
// there (as chromium mobile), so forcing touch devices to use
// this flow (touchstart -> touchend) for clicks too will
// workaround that difference.
ev.preventDefault();
handleMouseOver(ev);
}}
on:mouseleave={(ev) => {
if (!ev.relatedTarget) {
// When not focused on the browser, and then clicking a star directly
// without first focusing the browser, this event can be triggered,
// which causes hoveredRating to reset. This check seems to fix that.
console.debug("rating-wrap: mouseleave event triggered, but me think mistake.. ignoring.");
return;
}
console.debug("rating-wrap: mouseleave");
handleRatingHoverEnd();
}}
on:touchend={async () => {
console.debug("rating-wrap: touchend");
await saveSelectedRating();
handleRatingHoverEnd();
}}
on:blur={() => {
console.debug("rating-wrap: blur");
handleRatingHoverEnd();
}}
on:click={() => {
saveSelectedRating();
}}
on:keydown={(ev) => handleKeyDown(ev)}
role="button"
tabindex="0"
>
<!-- The unlit stars. -->
<div bind:this={normalContainer} class="rating the-normal-one" tabindex="-1">
{#each stars as _}
<button class="plain" tabindex="-1">*</button>
{/each}
</div>
<!-- Overlays on stars above to show them as highlighted. -->
<div bind:this={highlightContainer} class="rating the-highlight-one" tabindex="-1">
{#each stars as _}
<button class="plain lit" tabindex="-1">*</button>
{/each}
</div>
<!-- Hidden stars, just to keep correct layout since the two above are abolute. -->
<div class="rating the-hidden-one-for-layout-reasons" tabindex="-1">
{#each stars as _}
<button class="plain" style="opacity: 0; pointer-events: none;" tabindex="-1">*</button>
{/each}
</div>
</div>
<span class="keyboard-tip">Left/Right Arrows to change rating, Enter to save.</span>
</div>
<style lang="scss">
.rating-container {
display: flex;
flex-flow: column;
overflow: visible;
& > span {
position: relative;
transition:
left 100ms ease-in,
transform 100ms ease-in;
max-width: max-content;
left: 50%;
transform: translateX(-50%);
}
}
.rating-wrap {
position: relative;
user-select: none;
cursor: pointer;
width: max-content;
margin-left: auto;
margin-right: auto;
/* For chromium on mobile, looks a lil gross */
-webkit-tap-highlight-color: transparent;
&:focus-visible {
+ .keyboard-tip {
display: unset;
}
}
}
.keyboard-tip {
// For when the rating-wrap is accessed via keyboard.
font-size: 12px;
margin-top: 5px;
margin-bottom: 5px;
display: none;
}
.rating {
display: flex;
align-items: center;
color: $text-color;
overflow: hidden;
margin: 10px 0 10px 0;
padding: 1px;
&.the-highlight-one {
width: 0%;
display: none;
overflow: hidden;
-webkit-text-stroke: 1.5px gold;
pointer-events: none;
}
&:not(.the-highlight-one) {
flex-flow: row-reverse;
justify-content: center;
}
&:not(.the-hidden-one-for-layout-reasons) {
position: absolute;
left: 0;
top: 0;
}
&.the-normal-one {
justify-content: unset;
white-space: nowrap;
left: unset;
right: 0;
-webkit-text-stroke: 1.5px $text-color;
pointer-events: none;
}
button {
font-size: 55px;
font-family: "Rampart One";
letter-spacing: 10px;
line-height: 52px;
height: 38px;
&:global(.lit),
&:global(.lit ~ button) {
color: gold;
-webkit-text-stroke: 1.5px gold;
}
@media screen and (max-width: 420px) {
letter-spacing: 6px;
}
}
}
</style>
+63
View File
@@ -0,0 +1,63 @@
<!-- To be used from Rating component -->
<script lang="ts">
import Icon from "../Icon.svelte";
import tooltip from "../actions/tooltip";
export let rating: number | undefined;
export let onChange: (newRating: number) => Promise<boolean>;
$: r = rating ? Math.round(rating) : 0;
</script>
<div class="thumbs-ctr">
<button
use:tooltip={{ text: "Disliked", pos: "top" }}
on:click={() => onChange(1)}
class={r && r > 0 && r < 5 ? "active" : ""}
>
<Icon i="thumb-down" />
</button>
<button
use:tooltip={{ text: "Mediocre", pos: "top" }}
on:click={() => onChange(5)}
class={r && r > 4 && r < 8 ? "active" : ""}
>
<span>-</span>
</button>
<button
use:tooltip={{ text: "Liked", pos: "top" }}
on:click={() => onChange(9)}
class={r && r > 7 ? "active" : ""}
>
<Icon i="thumb-up" />
</button>
</div>
<style lang="scss">
.thumbs-ctr {
display: flex;
flex-flow: row;
gap: 10px;
button {
display: flex;
align-items: center;
justify-content: center;
height: 60px;
&:nth-child(2) span {
/* Center the dash (-) for the 'mediocre' rating button */
transform: translateY(5px);
font-size: 50px;
font-family: "Shrikhand";
}
&.active {
color: gold;
fill: gold;
background-color: $text-color;
}
}
}
</style>
+55
View File
@@ -0,0 +1,55 @@
import { userSettings } from "@/store";
import { RatingStep, RatingSystem } from "@/types";
import { get } from "svelte/store";
/**
* Used for scaling users 'actual' rating we store in db
* into one we can show that takes into account their
* settings for how they want stars displayed.
* Only used for star ratings, not thumbs.
*/
export function toShowableRating(r?: number) {
if (!r) {
return 0;
}
const settings = get(userSettings);
if (!settings || (!settings.ratingSystem && !settings.ratingStep)) {
return Math.round(r);
}
if (settings.ratingSystem === RatingSystem.OutOf100) {
return r * 10;
}
if (settings.ratingSystem === RatingSystem.OutOf5) {
if (settings.ratingStep === RatingStep.Point5) {
return Math.ceil((r / 2) * 2) / 2;
}
if (settings.ratingStep === RatingStep.Point1) {
return Math.round((r / 2) * 10) / 10;
}
return Math.round(r / 2);
}
if (settings.ratingSystem === RatingSystem.OutOf10) {
if (settings.ratingStep === RatingStep.Point5) {
return Math.ceil(r * 2) / 2;
}
if (settings.ratingStep === RatingStep.Point1) {
return r;
}
return Math.round(r);
}
return Math.round(r);
}
export function toWhichThumb(r?: number) {
if (!r) {
return;
}
const rr = Math.round(r);
if (rr > 0 && rr <= 4) {
return -1;
} else if (r >= 4 && r <= 7) {
return 0;
} else if (r >= 8) {
return 1;
}
}
+6 -2
View File
@@ -52,9 +52,7 @@
.setting-ctr {
margin: 0 15px;
}
div {
&.row {
display: flex;
flex-flow: row;
@@ -65,5 +63,11 @@
margin-right: auto;
}
}
&:not(.row) {
h5 {
margin-bottom: 5px;
}
}
}
</style>
+7 -5
View File
@@ -5,6 +5,7 @@
import type { Tag as TagT } from "@/types";
import Tag from "./Tag.svelte";
import DeleteTagModal from "./DeleteTagModal.svelte";
import stayInView from "../actions/stayInView";
export let titleText: string | undefined = undefined;
export let classes: string | undefined = undefined;
@@ -24,15 +25,16 @@
let tagToDelete: TagT | undefined = undefined;
function deleteTag(t: TagT) {
// TODO 1. confirmation 2. run delete req 3. remove from local state & from watcheds state
// This will show the DeleteTagModal (look below).
tagToDelete = t;
}
</script>
<div class={[`menu`, classes].join(" ")}>
<div class={[`menu`, classes].join(" ")} use:stayInView={{ elToShiftSelector: "& > .arrow" }}>
<i class="arrow"></i>
<div class="inner">
<div class="title">
<h4 class="norm sm-caps">{titleText ? titleText : "My Tags"}</h4>
<h4 class="norm sm-caps">{titleText ? titleText : "my tags"}</h4>
{#if showManageBtn}
<button
class={["plain", inManageMode ? "manage-on" : ""].join(" ")}
@@ -86,7 +88,7 @@
width: 200px;
right: 47px;
&:before {
.arrow {
left: 78px;
}
@@ -94,7 +96,7 @@
top: 50px;
right: -78px;
&:before {
.arrow {
left: 87px;
/* The place where this button will be is always dark, so white works for both themes */
border-bottom-color: white;
+5 -1
View File
@@ -80,7 +80,11 @@ export function getPlayedDependedProps(wid: number, list: Watched[]) {
return {
id: wel.id,
status: wel.status,
rating: wel.rating
rating: wel.rating,
extraDetails: {
dateAdded: wel.createdAt,
dateModified: wel.updatedAt
}
};
}
+24 -11
View File
@@ -253,18 +253,27 @@
}
.small-scrollbar {
&::-webkit-scrollbar {
width: 4.5px;
}
@supports selector(::-webkit-scrollbar) {
&::-webkit-scrollbar {
width: 4.5px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background-color: rgba(155, 155, 155, 0.5);
border-radius: 20px;
border: transparent;
&::-webkit-scrollbar-thumb {
background-color: rgba(155, 155, 155, 0.5);
border-radius: 20px;
border: transparent;
}
// The scrollbar-width property will always override
// -webkit-scrollbar, but since the scrollbars in
// chromium look ugly af, we will try to unset scrollbar-width
// in these cases and use our manual styling instead to try
// mimicking beatiful firefox.
scrollbar-width: auto !important;
}
}
}
@@ -283,7 +292,11 @@
z-index: 50;
// The lil arrow, adjust its pos under each specific menu.
&:before {
&: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%;
+1 -1
View File
@@ -271,7 +271,7 @@
{/if}
{/if}
<!-- Show on watched list and shared/followed watched lists -->
{#if $page.url?.pathname === "/" || $page.url?.pathname.includes("/lists/")}
{#if $page.url?.pathname === "/" || $page.url?.pathname.includes("/lists/") || $page.url?.pathname.includes("/tag/")}
<button
class="plain other sort"
on:click={() => {
+2 -1
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import PageError from "@/lib/PageError.svelte";
import PersonPoster from "@/lib/poster/PersonPoster.svelte";
import Rating from "@/lib/Rating.svelte";
import Rating from "@/lib/rating/Rating.svelte";
import Spinner from "@/lib/Spinner.svelte";
import Status from "@/lib/Status.svelte";
import HorizontalList from "@/lib/HorizontalList.svelte";
@@ -383,6 +383,7 @@
display: flex;
flex-flow: column;
gap: 10px;
width: 100%;
max-width: 380px;
@media screen and (max-width: 420px) {
+129 -5
View File
@@ -19,7 +19,11 @@
MovaryRatings,
MovaryWatchlist,
Watched,
WatchedStatus
WatchedStatus,
TodoMoviesExport,
TagAddRequest,
TodoMoviesCustomList,
TodoMoviesMovie
} from "@/types";
import Icon from "@/lib/Icon.svelte";
@@ -510,6 +514,120 @@
}
}
async function processTodoMoviesFile(files?: FileList | null) {
try {
console.log("processFilesTodoMovies", files);
if (!files || files?.length <= 0) {
console.error("processFilesTodoMovies", "No files to process!");
notify({
type: "error",
text: "File not found in dropped items. Please try again or refresh.",
time: 6000
});
isDragOver = false;
return;
}
isLoading = true;
if (files.length > 1) {
notify({
type: "error",
text: "Only one file at a time is supported. Continuing with the first.",
time: 6000
});
}
// Currently only support for importing one file at a time
const file = files[0];
if (file.type !== "" && !file.name.endsWith(".todomovieslist")) {
notify({
type: "error",
text: "Must be a TodoMovies backup file (.todomovieslist)"
});
isLoading = false;
isDragOver = false;
return;
}
// Read file data into strings
let exportTodoMoviesStr: string | undefined;
const r = new FileReader();
exportTodoMoviesStr = await readFile(r, file);
if (!exportTodoMoviesStr) {
notify({
type: "error",
text: "Failed to read export file. Ensure you have attached the correct file.",
time: 6000
});
isDragOver = false;
isLoading = false;
return;
}
console.log("Loaded file");
const exportTodoMovies: TodoMoviesExport = JSON.parse(exportTodoMoviesStr);
console.log("exportTodoMovies:", exportTodoMovies);
const movieList: TodoMoviesMovie[] = exportTodoMovies.Movie;
const customLists: TodoMoviesCustomList[] = exportTodoMovies.MovieList;
// Build toImport array
const toImport: ImportedList[] = [];
// Convert the timestamp to seconds (NSDate uses seconds since 2001-01-01 00:00:00 UTC)
const referenceDate = new Date(2001, 0, 1);
// Add all history movies. There is only one entry for every movie.
// Movies which have already been watched but which have been added back in planning are also included, as this could also have ratings and comments.
for (let i = 0; i < movieList.length; i++) {
const h = movieList[i];
// Skip if no tmdb id.
if (!h.Attrs.tmdbID) {
continue;
}
// Skip if already added. The first time it is added we get all info needed from other entries.
if (toImport.filter((ti) => ti.tmdbId == Number(h.Attrs.tmdbID)).length > 0) {
continue;
}
const nsInsertionDate = h.Attrs.insertionDate.Value;
const date = new Date(referenceDate.getTime() + nsInsertionDate * 1000);
const tagsIds = h.Rels.lists.Items;
const tags = tagsIds.map((tagId) => {
const tag = customLists.find((list) => list.ObjectID == tagId);
return {
name: "TodoMovies list: " + tag?.Attrs.name,
color: "#000000",
bgColor: tag?.Attrs.colorInHex
} as TagAddRequest;
});
const t: ImportedList = {
name: h.Attrs.title,
tmdbId: Number(h.Attrs.tmdbID),
status: h.Attrs.isWatched == 1 ? "FINISHED" : "PLANNED",
type: "movie", // TodoMovies only supports movies
datesWatched: [date], // use activities instead
thoughts: "", // no comments in TodoMovies
rating: h.Attrs.myScore,
tags: tags
};
toImport.push(t);
}
console.log("toImport:", toImport);
importedList.set({
data: JSON.stringify(toImport),
type: "todomovies"
});
goto("/import/process");
} catch (err) {
isLoading = false;
notify({ type: "error", text: "Failed to read files!" });
console.error("import: Failed to read files!", err);
}
}
onMount(() => {
if (!localStorage.getItem("token")) {
goto("/login");
@@ -540,6 +658,11 @@
filesSelected={(f) => processFiles(f)}
/>
<button class="plain" on:click={() => goto("/import/trakt")}>
<Icon i="trakt" wh="100%" />
<h4 class="norm">Trakt Import</h4>
</button>
<DropFileButton
icon="movary"
text="Movary Exports"
@@ -555,10 +678,11 @@
<DropFileButton icon="ryot" text="Ryot Exports" filesSelected={(f) => processRyotFile(f)} />
<button class="plain" on:click={() => goto("/import/trakt")}>
<Icon i="trakt" wh="100%" />
<h4 class="norm">Trakt Import</h4>
</button>
<DropFileButton
icon="todomovies"
text="TodoMovies"
filesSelected={(f) => processTodoMoviesFile(f)}
/>
{/if}
</div>
</div>
@@ -266,6 +266,19 @@
text: "Processing failed!. Please report this issue if it persists."
});
}
} else if (list?.type === "todomovies") {
importText = "TodoMovies";
try {
const s = JSON.parse(list.data);
// Builds imported list in previous step for ease.
rList = s;
} catch (err) {
console.error("TodoMovies import processing failed!", err);
notify({
type: "error",
text: "Processing failed!. Please report this issue if it persists."
});
}
}
// TODO: remove duplicate names in list
return list;
@@ -414,6 +427,7 @@
<th>Name</th>
<th>Year</th>
<th>Type</th>
<th>Status</th>
{#if !isImporting}
<th></th>
{/if}
@@ -456,6 +470,15 @@
disabled={isImporting}
/>
</td>
<td class="type">
<DropDown
options={["FINISHED", "PLANNED", "WATCHING", "HOLD", "DROPPED"]}
bind:active={l.status}
placeholder="Status"
blendIn={true}
disabled={isImporting}
/>
</td>
{#if !isImporting}
<td>
<button
@@ -477,6 +500,7 @@
<input class="plain" id="addYear" placeholder="YYYY" type="number" />
</td>
<td class="type"></td>
<td class="status"></td>
<td></td>
</tr>
{/if}
+2 -1
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import PageError from "@/lib/PageError.svelte";
import PersonPoster from "@/lib/poster/PersonPoster.svelte";
import Rating from "@/lib/Rating.svelte";
import Rating from "@/lib/rating/Rating.svelte";
import Spinner from "@/lib/Spinner.svelte";
import Status from "@/lib/Status.svelte";
import HorizontalList from "@/lib/HorizontalList.svelte";
@@ -434,6 +434,7 @@
display: flex;
flex-flow: column;
gap: 10px;
width: 100%;
max-width: 380px;
@media screen and (max-width: 420px) {
+3
View File
@@ -16,6 +16,7 @@
import PwChangeModal from "@/routes/(app)/profile/modals/PwChangeModal.svelte";
import SyncModal from "./modals/SyncModal.svelte";
import RegionDropDown from "@/lib/RegionDropDown.svelte";
import RatingSetting from "@/lib/rating/RatingSetting.svelte";
$: user = $userInfo;
$: settings = $userSettings;
@@ -320,6 +321,8 @@
/>
</Setting>
<RatingSetting />
<div class="row btns">
<button on:click={() => goto("/import")}>Import</button>
<button on:click={() => downloadWatchedList()} disabled={exportDisabled}>Export</button>
+14 -3
View File
@@ -39,10 +39,11 @@
{#if watcheds?.length > 0}
<WatchedList list={watcheds} />
{:else}
<div class="content">
<div class="content empty-tag">
<div class="inner">
<strong>This tag is empty!</strong>
<span>Add entries to this tag via it's page.</span>
<Icon i="ticket" wh={80} />
<h2 class="norm">This tag is empty!</h2>
<h4 class="norm">Add entries to this tag via it's page.</h4>
</div>
</div>
{/if}
@@ -77,6 +78,16 @@
}
}
.empty-tag {
h2 {
margin-top: 10px;
}
h4 {
font-weight: normal;
}
}
.basic-ctr {
display: flex;
align-items: center;
+2 -1
View File
@@ -6,7 +6,7 @@
import Icon from "@/lib/Icon.svelte";
import PageError from "@/lib/PageError.svelte";
import PersonPoster from "@/lib/poster/PersonPoster.svelte";
import Rating from "@/lib/Rating.svelte";
import Rating from "@/lib/rating/Rating.svelte";
import SeasonsList from "@/lib/SeasonsList.svelte";
import Spinner from "@/lib/Spinner.svelte";
import Status from "@/lib/Status.svelte";
@@ -444,6 +444,7 @@
display: flex;
flex-flow: column;
gap: 10px;
width: 100%;
max-width: 380px;
@media screen and (max-width: 420px) {
+9 -2
View File
@@ -25,7 +25,10 @@ export const activeSort = writable<string[]>(defaultSort);
export const activeFilters = writable<Filters>({ type: [], status: [] });
export const appTheme = writable<Theme>();
export const importedList = writable<
| { data: string; type: "text-list" | "tmdb" | "movary" | "watcharr" | "myanimelist" | "ryot" }
| {
data: string;
type: "text-list" | "tmdb" | "movary" | "watcharr" | "myanimelist" | "ryot" | "todomovies";
}
| undefined
>();
export const parsedImportedList = writable<ImportedList[] | undefined>();
@@ -39,7 +42,6 @@ export const clearAllStores = () => {
watchedList.set([]);
notifications.set([]);
activeSort.set(defaultSort);
activeFilters.set({ type: [], status: [] });
importedList.set(undefined);
parsedImportedList.set(undefined);
searchQuery.set("");
@@ -48,6 +50,11 @@ export const clearAllStores = () => {
follows.set([]);
wlDetailedView.set([]);
tags.set([]);
clearActiveFilters();
};
export const clearActiveFilters = () => {
activeFilters.set({ type: [], status: [] });
};
if (browser) {
+77 -3
View File
@@ -8,6 +8,7 @@ export type Icon =
| "clock"
| "calendar"
| "thumb-down"
| "thumb-up"
| "play"
| "pause"
| "jellyfin"
@@ -15,7 +16,9 @@ export type Icon =
| "plex"
| "trash"
| "close"
| "close-circle"
| "filter"
| "filter-circle"
| "reel"
| "compass"
| "document"
@@ -37,6 +40,7 @@ export type Icon =
| "ryot"
| "trakt"
| "myanimelist"
| "todomovies"
| "themoviedb"
| "refresh"
| "gamepad"
@@ -45,13 +49,20 @@ export type Icon =
| "pin"
| "unpin"
| "sparkles"
| "tag";
| "tag"
| "ticket";
export type Theme = "light" | "dark";
export type WLDetailedViewOption = "statusRating" | "lastWatched" | "dateAdded" | "dateModified";
export type ExtraDetails = { lastWatched: string; dateAdded: string; dateModified: string };
export type ExtraDetailsGame = { dateAdded: string; dateModified: string };
export type PosterExtraDetails = {
dateAdded?: string;
dateModified?: string;
/**
* Only for shows.
*/
lastWatched?: string;
};
export enum UserType {
// Assume watcharr user if none of these...
@@ -178,6 +189,26 @@ export interface UserSettings {
includePreviouslyWatched: boolean;
country: string;
automateShowStatuses: boolean;
ratingSystem?: RatingSystem;
/**
* A rating step decided by the user, only
* applicable for OutOf10 and OutOf5 rating systems.
* Supported: 1, 0.5, 0.1 (must validate).
*/
ratingStep?: RatingStep;
}
export enum RatingSystem {
OutOf10, // default
OutOf100,
OutOf5,
Thumbs
}
export enum RatingStep {
One, // default
Point5,
Point1
}
export interface ChangePasswordForm {
@@ -869,6 +900,7 @@ export interface ImportedList {
activity?: Activity[];
watchedEpisodes?: WatchedEpisode[];
watchedSeasons?: WatchedSeason[];
tags?: TagAddRequest[];
}
export interface Filters {
@@ -1049,6 +1081,48 @@ export interface MovaryWatchlist extends MovaryExportBase {
addedAt: string;
}
export interface TodoMoviesExport {
Movie: TodoMoviesMovie[];
MovieList: TodoMoviesCustomList[];
}
export interface TodoMoviesMovie {
Attrs: {
tmdbID: number;
title: string;
isWatched: number;
insertionDate: {
Value: number;
Class: string;
};
myScore: number;
};
Rels: {
lists: {
Items: string[];
Entity: string;
};
};
ObjectID: string;
}
export interface TodoMoviesCustomList {
Attrs: {
colorInHex: string;
order: number;
iconFileName: string;
featuredListID: number;
name: string;
};
Rels: {
movies: {
Items: string[];
Entity: string;
};
};
ObjectID: string;
}
export interface Game {
id: number;
updatedAt: string;