Compare commits

...

21 Commits

Author SHA1 Message Date
IRHM a16c769273 v4.1.0 2026-07-19 14:15:42 +01:00
IRHM f022dc702c v4.1.0-dev1 2026-07-18 13:05:20 +01:00
IRHM c2f72dbd9c Remove dependabot.yml. 2026-07-18 12:58:30 +01:00
IRHM d777630e84 CHANGELOG: Add credit to SeasonsListEpisode change 2026-07-18 12:51:03 +01:00
IRHM f5bfa29877 SeasonsListEpisode: Show spoilers when status is set to FINISHED and allow changing status while spoilers are hidden
Based on PR by goestav: https://github.com/sbondCo/Watcharr/pull/1055

Different in these ways:
- Using $derived here for `ws` variable
- Using z-index to allow status button to be clicked while spoilers are hidden
- Don't hide spoilers again (if they are shown) when episode is deleted

Also refactored `handleStatusClick()` a bit by removing the redundant `ws` lookup (it can now just use `we` set at the top of the component).

Co-Authored-By: Goestav <27970303+goestav@users.noreply.github.com>
2026-07-18 11:48:52 +00:00
IRHM 9b64932af1 watched_refine: refineFilterStatus: Remove parenthesis around WHERE query.
It looks like GORM already adds the parenthesis around this for us in the final query, so ours was unnecessary
2026-07-18 09:50:53 +00:00
IRHM bf0594c1e8 Fix Include Previously Watched regression and update profile stats to not use the setting
Index also added to

Fixes https://github.com/sbondCo/Watcharr/issues/1027
2026-07-18 09:50:53 +00:00
IRHM fee82df59d CHANGELOG: v4.0.1 2026-07-16 10:29:15 +01:00
IRHM 61382de4d2 v4.0.1 2026-07-16 10:07:58 +01:00
IRHM e65ba9d1d9 v4.0.1-dev1 2026-07-16 09:40:47 +01:00
IRHM 6654d85448 testing workflow - no longer testing workflow cuz it works now yippee 2026-07-16 08:36:22 +00:00
IRHM eabdb47cfa testing workflow - gofmt 2026-07-16 08:36:22 +00:00
IRHM 643ca0fd38 testing workflow - go test 2026-07-16 08:36:22 +00:00
IRHM a08b19675a test-pr-server.yml: Set working directory to server to fix commands not running against our go code 2026-07-16 08:36:22 +00:00
IRHM 38fba0ac10 Watched: Also log the error on the "failed to restore existing watched entry" log. 2026-07-16 08:36:22 +00:00
IRHM f23552c170 Use an svg for the favicon and change its fill to white for dark themed browsers. 2026-07-16 08:36:22 +00:00
IRHM 448fdd9df3 SpinnerTiny: Fix circle animation appearing to skip 2026-07-16 08:36:22 +00:00
IRHM ca428327e2 Move notifications to its own component. 2026-07-16 08:36:22 +00:00
IRHM f7cca74a41 SpinnerTiny: Use svg instead of the css border trick to create the spinner fixing it looking more like a horseshoe than a circle on different browser scales (also added animation on the svg circle).
also: Fix color being wrong on dark theme.

svg fix inspired by mui :)
2026-07-16 08:36:22 +00:00
IRHM 86286b967f workflows/test-pr-server: Add test step 2026-07-16 08:36:22 +00:00
IRHM 8243838bfb Update CHANGELOG.md 2026-07-11 03:55:10 +01:00
22 changed files with 410 additions and 240 deletions
-27
View File
@@ -1,27 +0,0 @@
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
# Web UI
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
commit-message:
prefix: "ui"
# Server
- package-ecosystem: "gomod"
directory: "/server"
schedule:
interval: "weekly"
commit-message:
prefix: "server"
# Workflows
- package-ecosystem: "github-actions"
directory: "/" # / will look in /.github/workflows directory by default
schedule:
interval: "weekly"
commit-message:
prefix: "workflow"
+8 -1
View File
@@ -11,13 +11,20 @@ on:
- "server/**"
jobs:
format_check_go:
test_and_format_check_go:
runs-on: ubuntu-latest
defaults:
run:
working-directory: server
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
- name: go test all packages
run: go test ./...
- name: gofmt test
run: |
outp=$(gofmt -l .)
+65 -3
View File
@@ -2,9 +2,59 @@
These changes are awaiting release:
## Changed
- Profile: Stats no longer care about the `Include Previously Watched` setting. All previously watched items will be counted in the stats now.
- Activity: Added `index` to `WatchedID` column to speed up queries.
- SeasonsListEpisode when `Hide Spoilers` is on (thanks [@goestav]!):
- Show episode spoilers if its status is `FINISHED`;
- Allow changing status without showing spoilers (useful for when setting an episode to PLANNED, etc).
## Fixed
- `Include Previously Watched` regression (fixes https://github.com/sbondCo/Watcharr/issues/1027).
## Maintenance
- Remove `dependabot.yml`.
# [4.0.1] - 2026-07-16T10:12:00Z
## Changed
- Move notifications to its own component.
- Use an `svg` for the favicon and change its `fill` to `white` for dark themed browsers.
## Fixed
- SpinnerTiny: Use svg instead of the css border trick to create the spinner fixing it looking more like a horseshoe than a circle on different browser scales (also added animation on the svg circle).
- SpinnerTiny: Fix color being wrong on dark theme.
- Watched: Also log the `error` on the "failed to restore existing watched entry" log.
## Maintenance
- workflows/test-pr-server: Add test step
## Etc
- **Package**: https://github.com/sbondCo/Watcharr/pkgs/container/watcharr/1036344762?tag=v4.0.1 or on [docker hub](https://hub.docker.com/layers/sbondco/watcharr/v4.0.1/images/sha256-3546329532facf6f55c779bb229ce997258ff1a89c4e5f9d66a92cc65ae80396).
# [4.0.0] - 2026-07-11T02:34:00Z
> [!CAUTION]
> It is always highly recommended that you perform a backup before updating (https://watcharr.app/docs/server_config/backup)!
> [!NOTE]
> v4 is finally here! Don't worry about the major version bump, all migrations will be handled automatically when you start the upgraded version.
>
> Have a read of the Data Migrations section below to get an understanding of the migrations we are applying in v4. You'll also notice that on the first start there will be a delay while migrations are applied.
> [!NOTE]
> This release contains security fixes. If your server is exposed to the public and you have signing up enabled, you should update sooner rather than later!
## Data migrations
This is the first time migrations are being taken place when updating Watcharr, please be mindful of that and **ensure you have your existing database backed up** incase of any errors.
This is the first time migrations are taking place when updating Watcharr, please be mindful of that and **ensure you have your existing database backed up** incase of any errors.
### Backfilling `plays` data from users Activity.
@@ -51,6 +101,7 @@ If you backup your database by copying the .db file (while your server is stoppe
- Star and Play icons color.
- Activity: Fixed automation tooltip going out of bounds by moving it to top.
- Make `image` package a lot more robust (thanks [@4qu4r1um]).
- GHSA-5q73-v9hv-4cf3
## Removed
@@ -61,6 +112,14 @@ If you backup your database by copying the .db file (while your server is stoppe
- Backup: Also note that `watcharr.db-wal` should be backed up along with the .db file since v3.
## New Contributors
- [@GreatGatsby102] made their first contribution in https://github.com/sbondCo/Watcharr/pull/1040
## Etc
- **Package**: https://github.com/sbondCo/Watcharr/pkgs/container/watcharr/1020169511?tag=v4.0.0 or on [docker hub](https://hub.docker.com/layers/sbondco/watcharr/v4.0.0/images/sha256-cd39b4fc0578ca374b2c7d65b410e29dc2859918a322e35e6b01f8e55d250f10).
# [3.0.1] - 2026-03-09
Hi All, delivered straight to your inbox today; you have bug fixes, some even improving general quality of life!!
@@ -191,7 +250,7 @@ Many thanks to everyone who has worked on this release!
## New
- About modal (accessible via face menu) by [@Clusters] in https://github.com/sbondCo/Watcharr/pull/811
- System app theme (automatically swaps between light/dark themes depending on system config) by @antoniosarro in https://github.com/sbondCo/Watcharr/pull/822
- System app theme (automatically swaps between light/dark themes depending on system config) by [@antoniosarro] in https://github.com/sbondCo/Watcharr/pull/822
- Search shortcut (`Ctrl+S`) by [@IRHM] in https://github.com/sbondCo/Watcharr/pull/886
## Fixed
@@ -1633,7 +1692,9 @@ Welcome to Watcharr :popcorn:, hope it is enjoyed and improves anyone's experien
<!-- Version Changelog References (newest first) -->
[Unreleased]: https://github.com/sbondCo/Watcharr/compare/v3.0.1...HEAD
[Unreleased]: https://github.com/sbondCo/Watcharr/compare/v4.0.0...HEAD
[4.0.1]: https://github.com/sbondCo/Watcharr/compare/v4.0.0...v4.0.1
[4.0.0]: https://github.com/sbondCo/Watcharr/compare/v3.0.1...v4.0.0
[3.0.1]: https://github.com/sbondCo/Watcharr/compare/v3.0.0...v3.0.1
[3.0.0]: https://github.com/sbondCo/Watcharr/compare/v2.1.1...v3.0.0
[2.1.1]: https://github.com/sbondCo/Watcharr/compare/v2.1.0...v2.1.1
@@ -1734,3 +1795,4 @@ Welcome to Watcharr :popcorn:, hope it is enjoyed and improves anyone's experien
[@Dredsen]: https://github.com/Dredsen
[@GreatGatsby102]: https://github.com/GreatGatsby102
[@4qu4r1um]: https://github.com/4qu4r1um
[@goestav]: https://github.com/goestav
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "watcharr",
"version": "4.0.0",
"version": "4.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "watcharr",
"version": "4.0.0",
"version": "4.1.0",
"license": "GPL-3.0-only",
"dependencies": {
"axios": "^1.9.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "watcharr",
"license": "GPL-3.0-only",
"version": "4.0.0",
"version": "4.1.0",
"private": true,
"scripts": {
"dev": "vite dev",
+1 -1
View File
@@ -1 +1 @@
4.0.0
4.1.0
+1 -1
View File
@@ -46,7 +46,7 @@ type Activity struct {
// secured (users can only view their own activities).
UserID uint `json:"-" gorm:"not null"`
// ID of watched list item this activity is linked to.
WatchedID uint `json:"watchedId" gorm:"not null"`
WatchedID uint `json:"watchedId" gorm:"not null;index"`
// Type of activity.
Type ActivityType `json:"type" gorm:"not null"`
// Holds custom data (ex, if rating changed, this can
@@ -14,6 +14,10 @@ import (
// Auth middleware
// If db is passed, extra user info from the database will be fetched.
//
// **NOTE:** Instead of providing the `db` parameter, it is probably better to
// fetch what you need in the handler directly! We might follow that pattern
// from now on and potentially remove `db` from this func in the future.
func AuthRequired(db *gorm.DB, cfg *config.ServerConfig) gin.HandlerFunc {
return func(c *gin.Context) {
slog.Debug("AuthRequired middleware hit")
+29 -51
View File
@@ -1,7 +1,6 @@
package profile
import (
"encoding/json"
"errors"
"log/slog"
"time"
@@ -28,63 +27,38 @@ func NewService(db *gorm.DB) *Service {
}
}
// Check if content has been previsouly watched by looking for related activity.
// Checks if item has been previously watched by scanning for any activity
// that counts as a play.
func (s *Service) hasBeenPreviouslyWatched(a *[]entity.Activity) bool {
wp := false
var relatedActivity []entity.Activity
for _, v := range *a {
if v.Type == entity.ADDED_WATCHED ||
v.Type == entity.IMPORTED_ADDED_WATCHED ||
v.Type == entity.IMPORTED_WATCHED ||
v.Type == entity.STATUS_CHANGED {
relatedActivity = append(relatedActivity, v)
if v.CountAsPlay {
return true
}
}
if len(relatedActivity) <= 0 {
return false
}
for _, ra := range relatedActivity {
if ra.Type == entity.IMPORTED_ADDED_WATCHED {
wp = true
break
} else if ra.Type == entity.ADDED_WATCHED || ra.Type == entity.IMPORTED_WATCHED {
if ra.Data == "" {
continue
}
var v map[string]any
err := json.Unmarshal([]byte(ra.Data), &v)
if err != nil {
slog.Error("Checking ADDED_WATCHED or IMPORTED_WATCHED.. failed to parse json data", "error", err)
continue
}
if status, ok := v["status"]; ok {
if status == "FINISHED" {
wp = true
break
}
}
} else if ra.Type == entity.STATUS_CHANGED {
if ra.Data == "FINISHED" {
wp = true
break
}
}
}
return wp
return false
}
// Gets any data required for profile page
func (s *Service) getProfile(userId uint) (Profile, error) {
// Get user.
user := new(entity.User)
res := s.db.Model(&entity.User{}).Where("id = ?", userId).Take(&user)
if res.Error != nil {
slog.Error("Failed to get profile:", "error", res.Error.Error())
slog.Error("Failed to get profile:",
"error", res.Error)
return Profile{}, errors.New("failed to get profile")
}
// Process stats.
watched := new([]entity.Watched)
res = s.db.Model(&entity.Watched{}).Preload("Content").Preload("Activity").Where("user_id = ?", userId).Find(&watched)
res = s.db.Model(&entity.Watched{}).
Preload("Content").
Preload("Activity").
Where("user_id = ?", userId).
Find(&watched)
if res.Error != nil {
slog.Error("Profile: Failed to get watched for processing:", "error", res.Error.Error())
slog.Error("Profile: Failed to get watched for processing:",
"error", res.Error)
return Profile{}, errors.New("failed to get watched for processing")
}
var (
@@ -95,11 +69,12 @@ func (s *Service) getProfile(userId uint) (Profile, error) {
)
for _, w := range *watched {
isFinished := false
if w.Status == entity.FINISHED {
isFinished = true
} else if *user.IncludePreviouslyWatched && s.hasBeenPreviouslyWatched(&w.Activity) {
// If status is not finished and user has IncludePreviouslyWatched enabled,
// then we can also check if content hasBeenPreviouslyWatched.
// Note: Deliberately always checking `hasBeenPreviouslyWatched` for any
// items without status set to FINISHED without checking users
// `IncludePreviouslyWatched` setting, because that setting is useful
// for filters, BUT not for these stats. I think it is always expected
// that all previously watched stuff is included in finished stats.
if w.Status == entity.FINISHED || s.hasBeenPreviouslyWatched(&w.Activity) {
isFinished = true
}
if isFinished {
@@ -107,7 +82,8 @@ func (s *Service) getProfile(userId uint) (Profile, error) {
continue
}
c := *w.Content
if c.Type == entity.SHOW {
switch c.Type {
case entity.SHOW:
showsWatched++
// This aint a science, just a very inaccurate guesstimate.
if c.NumberOfEpisodes != 0 {
@@ -116,9 +92,11 @@ func (s *Service) getProfile(userId uint) (Profile, error) {
showRuntime = c.Runtime
}
showsWatchedRuntime += showRuntime * c.NumberOfEpisodes
slog.Debug("calcualted", "show", c.Title, "runti", showRuntime*c.NumberOfEpisodes)
slog.Debug("profile stat calculated",
"show", c.Title,
"runti", showRuntime*c.NumberOfEpisodes)
}
} else if c.Type == entity.MOVIE {
case entity.MOVIE:
moviesWatched++
moviesWatchedRuntime += c.Runtime
}
+1 -1
View File
@@ -65,7 +65,7 @@ func (s *Service) UserUpdate(userId uint, ur entity.UserSettings) (entity.UserSe
}
func (s *Service) UserGetSettings(userId uint) (entity.UserSettings, error) {
slog.Debug("user update request running", "user_id", userId)
slog.Debug("UserGetSettings: Request running.", "user_id", userId)
user := new(entity.User)
res := s.db.Where("id = ?", userId).Take(&user)
if res.Error != nil {
+20 -4
View File
@@ -22,11 +22,16 @@ type GameProvider interface {
GetOrCache(igdbID int) (entity.Game, error)
}
type UserProvider interface {
UserGetSettings(userId uint) (entity.UserSettings, error)
}
type Service struct {
db *gorm.DB
cp ContentProvider
gameProvider GameProvider
activityProvider domain.ActivityAddProvider
userProvider UserProvider
}
func NewService(
@@ -34,12 +39,14 @@ func NewService(
cp ContentProvider,
gameProvider GameProvider,
activityProvider domain.ActivityAddProvider,
userProvider UserProvider,
) *Service {
return &Service{
db,
cp,
gameProvider,
activityProvider,
userProvider,
}
}
@@ -74,8 +81,16 @@ func (s *Service) GetWatchedPage(
"user_id", userId,
"pagination_params", pp,
"wr", wr)
watched := new([]entity.Watched)
pRes := &util.PaginationResponse[entity.Watched, util.None]{}
// Get user settings.
userSettings, err := s.userProvider.UserGetSettings(userId)
if err != nil {
return *pRes, errors.New("failed to get user settings")
}
watched := new([]entity.Watched)
res := s.db.
Model(&entity.Watched{}).
Where(&entity.Watched{UserID: userId})
@@ -101,7 +116,7 @@ func (s *Service) GetWatchedPage(
Preload("WatchedSeasons").
Preload("WatchedEpisodes").
// Apply filters first.
Scopes(watchedRefineFilter(wr)).
Scopes(watchedRefineFilter(wr, &userSettings)).
// Then count results (after filter);
Count(&pRes.TotalResults).
// Now calculate pagination properties with a TotalResults
@@ -164,7 +179,7 @@ func (s *Service) getPublicWatched(
Preload("WatchedSeasons").
Preload("WatchedEpisodes").
// Apply filters first.
Scopes(watchedRefineFilter(wr)).
Scopes(watchedRefineFilter(wr, nil)).
// Then count results (after filter);
Count(&pRes.TotalResults).
// Now calculate pagination properties with a TotalResults
@@ -443,7 +458,8 @@ func (s *Service) AddWatched(
&watched,
); err != nil {
// Try to restore the entry if unique contraint hit.
slog.Error("AddWatched: Failed to restore existing watched entry.")
slog.Error("AddWatched: Failed to restore existing watched entry.",
"error", err)
// Returns watched too because handlers of certain errors
// may need it (and it's ID since we could have fetched it here)
return watched, err
+36 -4
View File
@@ -38,15 +38,44 @@ func refineFilterType(db *gorm.DB, ft []util.SupportedMedia) {
}
// Applies 'Status' filter.
func refineFilterStatus(db *gorm.DB, f []entity.WatchedStatus) {
func refineFilterStatus(
db *gorm.DB,
f []entity.WatchedStatus,
userSettings *entity.UserSettings,
) {
if len(f) <= 0 {
return
}
// Process the input data.
fIncludesFinished := false
for i := range f {
// Ensure string **case** is valid WatchedStatus by converting to uppercase.
f[i] = entity.WatchedStatus(strings.ToUpper(string(f[i])))
if f[i] == entity.FINISHED {
fIncludesFinished = true
slog.Debug("refineFilterStatus: f includes FINISHED")
}
}
// Apply the query.
if fIncludesFinished &&
userSettings != nil && util.Deref(userSettings.IncludePreviouslyWatched, false) {
slog.Debug("refineFilterStatus: Performing query that includes previously watched.")
db.
// If status IN `f` OR any activity counts as a play for this
// watched item.
// NOTE: GORM adds parenthesis around this WHERE so that the OR
// doesn't confuse the whole WHERE on the main query, so we don't
// need to do that.
Where(`watcheds.status IN ? OR EXISTS (
SELECT 1
FROM activities
WHERE activities.watched_id = watcheds.id
AND activities.count_as_play = 1
)`, f)
} else {
slog.Debug("refineFilterStatus: Performing standard query.")
db.Where("watcheds.status IN ?", f)
}
db.Where("watcheds.status IN ?", f)
}
// Applies sorts to list.
@@ -125,11 +154,14 @@ func refineSortPinned(db *gorm.DB) {
// list data.
// gorm scope for applying filters to watched
func watchedRefineFilter(wr domain.WatchedGetPageRequest) func(db *gorm.DB) *gorm.DB {
func watchedRefineFilter(
wr domain.WatchedGetPageRequest,
userSettings *entity.UserSettings,
) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
// Apply filters
refineFilterType(db, wr.FilterType)
refineFilterStatus(db, wr.FilterStatus)
refineFilterStatus(db, wr.FilterStatus, userSettings)
return db
}
}
+6 -1
View File
@@ -206,7 +206,12 @@ func main() {
userService := user.NewService(db)
userManageService := user.NewManageService(db)
gameService := game.NewService(db, &br.Cfg.TWITCH, activityService)
watchedService := watched.NewService(db, contentService, gameService, activityService)
watchedService := watched.NewService(
db,
contentService,
gameService,
activityService,
userService)
watchedSeasonService := season.NewService(db, activityService)
watchedEpisodeService := episode.NewService(
db,
+1
View File
@@ -2,6 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<link rel="apple-touch-icon" href="%sveltekit.assets%/logo-col.png" />
<meta name="viewport" content="width=device-width" />
+40 -16
View File
@@ -1,23 +1,32 @@
<script lang="ts">
interface Props {
style?: string;
}
let { style = "" }: Props = $props();
</script>
<div class="loader" {style}></div>
<span class="loader">
<svg viewBox="0 0 50 50">
<circle
cx="25"
cy="25"
r="20"
fill="none"
stroke="currentColor"
stroke-width="6"
stroke-linecap="round"
/>
</svg>
</span>
<style lang="scss">
.loader {
$animDur: 1.4s;
display: flex;
width: 16px;
height: 12px;
border: 2px solid $text-color;
border-bottom-color: transparent;
border-radius: 50%;
display: inline-block;
box-sizing: border-box;
animation: rotation 1s linear infinite;
height: 16px;
animation: rotation $animDur linear infinite;
svg {
circle {
stroke-dasharray: 80px 200px;
stroke-dashoffset: 0px;
animation: circle-size $animDur ease-in-out infinite;
}
}
}
@keyframes rotation {
@@ -28,4 +37,19 @@
transform: rotate(360deg);
}
}
@keyframes circle-size {
0% {
stroke-dasharray: 1px 200px;
stroke-dashoffset: 0px;
}
50% {
stroke-dasharray: 100px 200px;
stroke-dashoffset: -15px;
}
100% {
stroke-dasharray: 1px 200px;
stroke-dashoffset: -124px;
}
}
</style>
+112
View File
@@ -0,0 +1,112 @@
<script lang="ts">
import { store } from "@/store.svelte";
import SpinnerTiny from "./SpinnerTiny.svelte";
import { unNotify } from "./util/notify";
import Icon from "./Icon.svelte";
</script>
<div id="notifications">
{#each store.notifications as n}
<div class={`${n.type} notif`}>
{#if n.type === "loading"}
<SpinnerTiny />
{/if}
<!-- only comes from our strings (which may have html) -->
<!-- eslint-disable-next-line -->
<span>{@html n.text}</span>
<button
class="plain"
onclick={() => {
unNotify(n.id);
}}
>
<Icon i="close" />
</button>
</div>
{/each}
</div>
<style lang="scss">
#notifications {
display: flex;
flex-flow: column;
gap: 10px;
position: fixed;
bottom: 0;
left: 50%;
transform: translateX(-50%);
margin-bottom: 8px;
z-index: 99999;
.notif {
display: flex;
flex-flow: row;
align-items: center;
min-width: 200px;
color: black;
background-color: white;
border-radius: 8px;
border: 1px solid rgba($color: #000000, $alpha: 0.2);
box-shadow: 0 4px 10px rgba($color: #000000, $alpha: 0.2);
animation: comein 250ms ease forwards;
position: relative;
&.loading {
padding-left: 10px;
}
@keyframes comein {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
&.error {
color: white;
background-color: $error;
border: 1px solid $error;
span {
border-color: rgba($color: white, $alpha: 0.5);
}
}
&.success {
color: white;
background-color: $success;
border: 1px solid $success;
span {
border-color: rgba($color: white, $alpha: 0.5);
}
}
span {
width: 100%;
height: 100%;
padding-right: 12px;
border-right: 1px solid rgba($color: black, $alpha: 0.2);
padding: 10px 12px;
padding-left: 9px;
a {
color: white;
text-decoration: underline;
}
}
button {
display: flex;
align-items: center;
margin: 8px;
width: 22px;
height: 100%;
color: inherit;
}
}
}
</style>
+58 -16
View File
@@ -10,6 +10,7 @@
import { notify } from "../util/notify";
import { store } from "@/store.svelte";
import { removeWatchedEpisode, updateWatchedEpisode } from "./api";
import { onMount } from "svelte";
interface Props {
ep: TMDBSeasonDetailsEpisode;
@@ -18,32 +19,65 @@
let { ep, watchedItem }: Props = $props();
const we = $derived(
watchedItem?.watchedEpisodes?.find(
(s) =>
s.seasonNumber === ep.season_number &&
s.episodeNumber === ep.episode_number,
),
);
let isHidden: boolean = $state(!!store?.userSettings?.hideSpoilers);
function handleStatusClick(type: WatchedStatus | "DELETE") {
/**
* Re-sets `isHidden` state.
*/
function reSetIsHidden() {
// If the episode status is "FINISHED", ensure `isHidden` is set to
// `false` (so finished episodes aren't blurred when hideSpoilers is on).
if (we?.status == "FINISHED") {
isHidden = false;
}
}
onMount(() => {
reSetIsHidden();
});
async function handleStatusClick(type: WatchedStatus | "DELETE") {
if (!watchedItem) {
console.error("SeasonListEpisode: handleStatusClick: No watched item.");
return;
}
if (type === "DELETE") {
const ws = watchedItem.watchedEpisodes?.find(
(s) =>
s.seasonNumber === ep.season_number &&
s.episodeNumber === ep.episode_number,
);
if (!ws) {
if (!we || !we.id) {
notify({
text: "Failed to find watched episode id. Please try refreshing.",
type: "error",
});
console.error(
"handleStatusClick(DELETE): `we` doesn't exist or have an id",
we,
);
return;
}
removeWatchedEpisode(watchedItem, ws.id);
removeWatchedEpisode(watchedItem, we.id);
// NOTE: Similar to below where we `reSetIsHidden` to unhide spoilers
// automatically if status is set to FINISHED, we WONT do the opposite
// here and re-hide the spoilers (if unhidden) after removing an episode
// because that would probably be annoying to users (eg: click to
// show spoilers, then delete episode, spoilers re-hidden automatically).
return;
}
updateWatchedEpisode(watchedItem, ep.season_number, ep.episode_number, {
status: type,
});
await updateWatchedEpisode(
watchedItem,
ep.season_number,
ep.episode_number,
{
status: type,
},
);
reSetIsHidden();
}
function handleStarClick(rating: number) {
@@ -90,11 +124,6 @@
<span class="overview">{ep.overview}</span>
</div>
{#if watchedItem}
{@const we = watchedItem.watchedEpisodes?.find(
(s) =>
s.seasonNumber === ep.season_number &&
s.episodeNumber === ep.episode_number,
)}
<div class="status-rating-ctr">
<div class="rating" style={"width: 45px"}>
<PosterRating
@@ -221,6 +250,19 @@
min-height: 40px;
height: 40px;
overflow: visible;
/* z-index of 2 so the button is higher than .spoiler-text
which makes it clickable while whole ep is still hidden.
Which is useful if you don't want spoilers while setting
the episode to WATCHING, etc. */
z-index: 2;
&:hover {
/* On hover, the z-index is higher than all other status
buttons on the page to avoid the active one being put
below others (making it unuseable). */
z-index: 3;
}
}
}
}
+1 -1
View File
@@ -615,7 +615,7 @@
<td class="icon-cell">
<div>
{#if !l.state}
<SpinnerTiny style="width: 13px;" />
<SpinnerTiny />
{:else if l.state === ImportResponseType.IMPORT_SUCCESS}
<Icon i="check" wh={22} />
{:else if l.state === ImportResponseType.IMPORT_NOTFOUND}
+1 -3
View File
@@ -297,7 +297,7 @@
<Setting
title="Include Previously Watched"
desc="Deprecated: This setting is due to be removed because I think TRUE is the only useful value (the removal will go through soon, please give feedback if you have any opinions!)."
desc="Should previously finished items be included in the 'Finished' status filter?"
row
>
<Checkbox
@@ -308,8 +308,6 @@
includePreviouslyWatchedDisabled = true;
updateUserSetting("includePreviouslyWatched", on, () => {
includePreviouslyWatchedDisabled = false;
// Get profile stats again
getProfilePromise = getProfile();
});
}}
/>
+3 -24
View File
@@ -1,8 +1,5 @@
<script lang="ts">
import Icon from "@/lib/Icon.svelte";
import SpinnerTiny from "@/lib/SpinnerTiny.svelte";
import { unNotify } from "@/lib/util/notify";
import { store } from "@/store.svelte";
import Notifications from "@/lib/notifications.svelte";
import { onMount } from "svelte";
import { pwaInfo } from "virtual:pwa-info";
@@ -37,26 +34,8 @@
</svelte:head>
<div id="tooltip"></div>
<div id="notifications">
{#each store.notifications as n}
<div class={`${n.type} notif`}>
{#if n.type === "loading"}
<SpinnerTiny />
{/if}
<!-- only comes from our strings (which may have html) -->
<!-- eslint-disable-next-line -->
<span>{@html n.text}</span>
<button
class="plain"
onclick={() => {
unNotify(n.id);
}}
>
<Icon i="close" />
</button>
</div>
{/each}
</div>
<Notifications />
{@render children?.()}
-83
View File
@@ -235,89 +235,6 @@
}
:global {
#notifications {
display: flex;
flex-flow: column;
gap: 10px;
position: fixed;
bottom: 0;
left: 50%;
transform: translateX(-50%);
margin-bottom: 8px;
z-index: 99999;
.notif {
display: flex;
flex-flow: row;
align-items: center;
min-width: 200px;
color: black;
background-color: white;
border-radius: 8px;
border: 1px solid rgba($color: #000000, $alpha: 0.2);
box-shadow: 0 4px 10px rgba($color: #000000, $alpha: 0.2);
animation: comein 250ms ease forwards;
position: relative;
&.loading {
padding-left: 10px;
}
@keyframes comein {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
&.error {
color: white;
background-color: $error;
border: 1px solid $error;
span {
border-color: rgba($color: white, $alpha: 0.5);
}
}
&.success {
color: white;
background-color: $success;
border: 1px solid $success;
span {
border-color: rgba($color: white, $alpha: 0.5);
}
}
span {
width: 100%;
height: 100%;
padding-right: 12px;
border-right: 1px solid rgba($color: black, $alpha: 0.2);
padding: 10px 12px;
padding-left: 9px;
a {
color: white;
text-decoration: underline;
}
}
button {
display: flex;
align-items: center;
margin: 8px;
width: 22px;
height: 100%;
color: inherit;
}
}
}
.small-scrollbar {
@supports selector(::-webkit-scrollbar) {
&::-webkit-scrollbar {
+20
View File
@@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" fill="none" viewBox="0 0 512 512">
<style>
/* For dark themed browsers, we make the favicon a more visible color. */
@media (prefers-color-scheme: dark) {
.fav-fill {
fill: #fff !important;
}
.fav-outline {
stroke: #fff !important;
}
}
</style>
<path class="fav-fill"
d="M272.375 449.62 257.1 374.883l-21.23 99.733L147.329 482l-53.075-26.552L10.5 59.107l95.922-7.901 53.074 26.552 11.65 62.56 19.159-91.703 84.27-6.994 50.874 26.552 16.44 80.175 20.712-109.836L448.426 31 501.5 57.552 419.818 468.79l-94.369 7.9zm94.498-405.797-41.424 224.852q-.517 4.793-1.294 12.305-.777 7.383-1.424 13.47l-.518 6.088h-1.553l-.518-5.829q-.518-5.827-1.553-13.47-1.035-7.771-2.201-12.046L269.786 47.45l-75.339 6.347-41.294 207.496q-1.036 6.347-2.719 16.45-1.553 10.103-2.33 18.392l-.776 8.16h-1.683l-.518-7.642q-.518-7.77-1.812-18.133t-2.848-18.263L101.762 57.034l-84.919 7.383 81.682 385.202 79.482-6.346 45.695-212.16q1.036-5.31 2.33-16.19a549 549 0 0 0 2.072-20.465l.906-9.584 1.553-.518.777 9.325q.777 9.197 2.071 20.076 1.425 10.88 2.46 16.19l40.776 214.88 85.436-7.383 79.482-399.968z"
style="fill:#000;fill-opacity:1" />
<path class="fav-outline"
d="M272.375 449.62 257.1 374.883l-21.23 99.733L147.329 482l-53.075-26.552L10.5 59.107l95.922-7.901 53.074 26.552 11.65 62.56 19.159-91.703 84.27-6.994 50.874 26.552 16.44 80.175 20.712-109.836L448.426 31 501.5 57.552 419.818 468.79l-94.369 7.9zm94.498-405.797-41.424 224.852q-.517 4.793-1.294 12.305-.777 7.383-1.424 13.47l-.518 6.088h-1.553l-.518-5.829q-.518-5.827-1.553-13.47-1.035-7.771-2.201-12.046L269.786 47.45l-75.339 6.347-41.294 207.496q-1.036 6.347-2.719 16.45-1.553 10.103-2.33 18.392l-.776 8.16h-1.683l-.518-7.642q-.518-7.77-1.812-18.133t-2.848-18.263L101.762 57.034l-84.919 7.383 81.682 385.202 79.482-6.346 45.695-212.16q1.036-5.31 2.33-16.19a549 549 0 0 0 2.072-20.465l.906-9.584 1.553-.518.777 9.325q.777 9.197 2.071 20.076 1.425 10.88 2.46 16.19l40.776 214.88 85.436-7.383 79.482-399.968z"
style="fill:none;stroke-width:10;stroke:#000;stroke-opacity:1" />
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB