mirror of
https://github.com/sbondCo/Watcharr.git
synced 2026-08-07 07:14:44 +00:00
Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0730a99df4 | |||
| 46a043e5b9 | |||
| 87689b201e | |||
| 8d34513ab7 | |||
| ed9a9aae7e | |||
| 48814c213a | |||
| 5a70b8a240 | |||
| 31ce74545a | |||
| 2b78581062 | |||
| 6d5aa0feff | |||
| 7cfbc6c344 | |||
| 64e7929714 | |||
| b2f817b639 | |||
| 7a3e5f4354 | |||
| f033a50632 | |||
| e3ace9f2fe | |||
| f07c2c797b | |||
| 1a8da9137a | |||
| 2476833a19 | |||
| 9f89d2101c | |||
| c3db5054b4 | |||
| f96562e2df | |||
| 22163950ef | |||
| c80d5cabcb | |||
| d3cfc9258f | |||
| 74ff9f04dd | |||
| 28f13836a1 | |||
| 242e70c9ec | |||
| 4bf2e0b376 | |||
| 144244bd5e | |||
| 473f8f6319 | |||
| b86ab4229f | |||
| 8f20589faa | |||
| 33c2000182 | |||
| 0505d265fe | |||
| f8e5a668f8 | |||
| 447ee2e82f | |||
| fe7730dbcc | |||
| 09ac804730 | |||
| 0f24623384 | |||
| 964c71382a | |||
| 65f8f67e4d | |||
| 8b92d42634 | |||
| 1cab95370b | |||
| b5fac676e9 | |||
| b67b0acc9a | |||
| c2fe4486d9 | |||
| 88fea6a8bd | |||
| 6b64aa0d89 | |||
| df3955cfe1 | |||
| 8359be7bee | |||
| 15f4a98ccb | |||
| 02e9654791 | |||
| 8fd570ad5c | |||
| bd10d6854a | |||
| 6249134dd6 | |||
| 5d425cadd6 | |||
| 695015b481 | |||
| 8d81b96692 | |||
| 0bc9aa612c | |||
| 968212e189 | |||
| 4ab2267867 | |||
| 2cfe39f276 | |||
| a155e1eeea | |||
| 81299f30de | |||
| 17349e8367 | |||
| f2faad3886 | |||
| 451a5923d4 | |||
| 5e3ed9ceed | |||
| 57f29c054a | |||
| be10df1c57 | |||
| baba2fad4d | |||
| bad4f505ef | |||
| 4390f02f4d | |||
| a790e0d4e0 |
@@ -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:
|
||||
Generated
+593
-386
File diff suppressed because it is too large
Load Diff
+6
-6
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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
@@ -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"`
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
export interface StayInViewOptions {
|
||||
/**
|
||||
* If the `node` contains an element (like an arrow for menus),
|
||||
* that should be shifted over to account for any shifting
|
||||
* of the `node` itself, then pass a selector for it here.
|
||||
*/
|
||||
elToShiftSelector?: string;
|
||||
}
|
||||
|
||||
export default function stayInView(node: HTMLElement, opts: StayInViewOptions) {
|
||||
console.debug("stayInView: Initial opts:", opts);
|
||||
let { elToShiftSelector } = opts;
|
||||
let viewDeb: ReturnType<typeof setTimeout>;
|
||||
|
||||
/**
|
||||
* Move element to in view, if it isn't.
|
||||
*
|
||||
* Currently only supports moving `node` back into view if oob
|
||||
* on the left. May need to support other sides and/or resizing
|
||||
* when `node` still wont fit after shifting it in the future.
|
||||
*/
|
||||
const getInView = () => {
|
||||
const nrect = node.getBoundingClientRect();
|
||||
const brect = document.body.getBoundingClientRect();
|
||||
console.debug("stayInView->getInView: Called.", nrect, brect);
|
||||
if (nrect.x <= brect.x) {
|
||||
const diff = nrect.x - brect.x + 10;
|
||||
console.debug(
|
||||
"stayInView->getInView: Node is out of bounds on the left, shifting forwards to:",
|
||||
diff
|
||||
);
|
||||
node.style.left = `${diff}px`;
|
||||
if (elToShiftSelector) {
|
||||
const elToShift = node.querySelector(elToShiftSelector) as HTMLElement;
|
||||
if (elToShift) {
|
||||
console.debug("stayInView->getInView: Shifting elToShift.");
|
||||
const nrectNew = node.getBoundingClientRect();
|
||||
const arrowDiff = nrectNew.left - nrect.left;
|
||||
elToShift.style.left = `${elToShift.offsetLeft - arrowDiff}px`;
|
||||
} else {
|
||||
console.warn("elToShift not found.", elToShiftSelector);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getInViewDeb = () => {
|
||||
clearTimeout(viewDeb);
|
||||
viewDeb = setTimeout(getInView, 200);
|
||||
};
|
||||
|
||||
window.addEventListener("resize", getInViewDeb);
|
||||
getInView();
|
||||
|
||||
return {
|
||||
update(opts: StayInViewOptions) {
|
||||
console.debug("stayInView: Opts updated", opts);
|
||||
elToShiftSelector = opts.elToShiftSelector;
|
||||
getInView();
|
||||
},
|
||||
destroy() {
|
||||
window.removeEventListener("resize", getInViewDeb);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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%);
|
||||
|
||||
@@ -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
@@ -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%);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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%;
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user