Smart Filters & Dashboard Customization (#2282)

Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
This commit is contained in:
Joe Milazzo 2023-09-12 11:24:47 -07:00 committed by GitHub
parent 3d501c9532
commit 84f85b4f24
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
92 changed files with 7149 additions and 555 deletions

View File

@ -0,0 +1,40 @@
using System.Linq;
using API.DTOs.Filtering;
using API.DTOs.Filtering.v2;
using API.Entities.Enums;
using API.Helpers;
using Xunit;
namespace API.Tests.Helpers;
public class SmartFilterHelperTests
{
[Fact]
public void Test_Decode()
{
var encoded = """
stmts=comparison%3D5%26field%3D18%26value%3D6%2Ccomparison%3D0%26field%3D4%26value%3D0%2Ccomparison%3D7%26field%3D1%26value%3Da&sortOptions=sortField=1&isAscending=true&limitTo=0&combination=1
""";
var filter = SmartFilterHelper.Decode(encoded);
Assert.Equal(0, filter.LimitTo);
Assert.Equal(SortField.SortName, filter.SortOptions.SortField);
Assert.True(filter.SortOptions.IsAscending);
Assert.Null(filter.Name);
var list = filter.Statements.ToList();
AssertStatementSame(list[2], FilterField.SeriesName, FilterComparison.Matches, "a");
AssertStatementSame(list[1], FilterField.AgeRating, FilterComparison.Equal, (int) AgeRating.Unknown + "");
AssertStatementSame(list[0], FilterField.Genres, FilterComparison.Contains, "6");
}
private void AssertStatementSame(FilterStatementDto statement, FilterField field, FilterComparison combination, string value)
{
Assert.Equal(statement.Field, field);
Assert.Equal(statement.Comparison, combination);
Assert.Equal(statement.Value, value);
}
}

View File

@ -8,6 +8,7 @@ using API.Data;
using API.Data.Repositories;
using API.DTOs;
using API.DTOs.Account;
using API.DTOs.Dashboard;
using API.DTOs.Email;
using API.Entities;
using API.Entities.Enums;
@ -1035,4 +1036,123 @@ public class AccountController : BaseApiController
return Ok(origin + "/" + baseUrl + "api/opds/" + user!.ApiKey);
}
/// <summary>
/// Returns the layout of the user's dashboard
/// </summary>
/// <returns></returns>
[HttpGet("dashboard")]
public async Task<ActionResult<IEnumerable<DashboardStreamDto>>> GetDashboardLayout(bool visibleOnly = true)
{
var streams = await _unitOfWork.UserRepository.GetDashboardStreams(User.GetUserId(), visibleOnly);
return Ok(streams);
}
/// <summary>
/// Creates a Dashboard Stream from a SmartFilter and adds it to the user's dashboard as visible
/// </summary>
/// <param name="smartFilterId"></param>
/// <returns></returns>
[HttpPost("add-dashboard-stream")]
public async Task<ActionResult<DashboardStreamDto>> AddDashboard([FromQuery] int smartFilterId)
{
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.DashboardStreams);
if (user == null) return Unauthorized();
var smartFilter = await _unitOfWork.AppUserSmartFilterRepository.GetById(smartFilterId);
if (smartFilter == null) return NoContent();
var stream = user?.DashboardStreams.FirstOrDefault(d => d.SmartFilter?.Id == smartFilterId);
if (stream != null) return BadRequest("There is an existing stream with this Filter");
var maxOrder = user!.DashboardStreams.Max(d => d.Order);
var createdStream = new AppUserDashboardStream()
{
Name = smartFilter.Name,
IsProvided = false,
StreamType = DashboardStreamType.SmartFilter,
Visible = true,
Order = maxOrder + 1,
SmartFilter = smartFilter
};
user.DashboardStreams.Add(createdStream);
_unitOfWork.UserRepository.Update(user);
await _unitOfWork.CommitAsync();
var ret = new DashboardStreamDto()
{
Name = createdStream.Name,
IsProvided = createdStream.IsProvided,
Visible = createdStream.Visible,
Order = createdStream.Order,
SmartFilterEncoded = smartFilter.Filter,
StreamType = createdStream.StreamType
};
await _eventHub.SendMessageToAsync(MessageFactory.DashboardUpdate, MessageFactory.DashboardUpdateEvent(user.Id),
User.GetUserId());
return Ok(ret);
}
/// <summary>
/// Updates the visibility of a dashboard stream
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("update-dashboard-stream")]
public async Task<ActionResult> UpdateDashboardStream(DashboardStreamDto dto)
{
var stream = await _unitOfWork.UserRepository.GetDashboardStream(dto.Id);
if (stream == null) return BadRequest();
stream.Visible = dto.Visible;
_unitOfWork.UserRepository.Update(stream);
await _unitOfWork.CommitAsync();
var userId = User.GetUserId();
await _eventHub.SendMessageToAsync(MessageFactory.DashboardUpdate, MessageFactory.DashboardUpdateEvent(userId),
userId);
return Ok();
}
/// <summary>
/// Updates the position of a dashboard stream
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("update-dashboard-position")]
public async Task<ActionResult> UpdateDashboardStreamPosition(UpdateDashboardStreamPositionDto dto)
{
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(),
AppUserIncludes.DashboardStreams);
var stream = user?.DashboardStreams.FirstOrDefault(d => d.Id == dto.DashboardStreamId);
if (stream == null) return BadRequest();
if (stream.Order == dto.ToPosition) return Ok();
var list = user!.DashboardStreams.ToList();
ReorderItems(list, stream.Id, dto.ToPosition);
user.DashboardStreams = list;
_unitOfWork.UserRepository.Update(user);
await _unitOfWork.CommitAsync();
await _eventHub.SendMessageToAsync(MessageFactory.DashboardUpdate, MessageFactory.DashboardUpdateEvent(user.Id),
user.Id);
return Ok();
}
private static void ReorderItems(List<AppUserDashboardStream> items, int itemId, int toPosition)
{
var item = items.Find(r => r.Id == itemId);
if (item != null)
{
items.Remove(item);
items.Insert(toPosition, item);
}
for (var i = 0; i < items.Count; i++)
{
items[i].Order = i;
}
}
}

View File

@ -1,8 +1,15 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.Data.Repositories;
using API.DTOs.Dashboard;
using API.DTOs.Filtering.v2;
using API.Entities;
using API.Extensions;
using API.Helpers;
using EasyCaching.Core;
using Microsoft.AspNetCore.Mvc;
@ -22,38 +29,66 @@ public class FilterController : BaseApiController
_cacheFactory = cacheFactory;
}
[HttpGet]
public async Task<ActionResult<FilterV2Dto?>> GetFilter(string name)
/// <summary>
/// Creates or Updates the filter
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("update")]
public async Task<ActionResult> CreateOrUpdateSmartFilter(FilterV2Dto dto)
{
var provider = _cacheFactory.GetCachingProvider(EasyCacheProfiles.Filter);
if (string.IsNullOrEmpty(name)) return Ok(null);
var filter = await provider.GetAsync<FilterV2Dto>(name);
if (filter.HasValue)
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.SmartFilters);
if (user == null) return Unauthorized();
if (string.IsNullOrWhiteSpace(dto.Name)) return BadRequest("Name must be set");
if (Seed.DefaultStreams.Any(s => s.Name.Equals(dto.Name, StringComparison.InvariantCultureIgnoreCase)))
{
filter.Value.Name = name;
return Ok(filter.Value);
return BadRequest("You cannot use the name of a system provided stream");
}
return Ok(null);
// I might just want to use DashboardStream instead of a separate entity. It will drastically simplify implementation
var existingFilter =
user.SmartFilters.FirstOrDefault(f => f.Name.Equals(dto.Name, StringComparison.InvariantCultureIgnoreCase));
if (existingFilter != null)
{
// Update the filter
existingFilter.Filter = SmartFilterHelper.Encode(dto);
_unitOfWork.AppUserSmartFilterRepository.Update(existingFilter);
}
else
{
existingFilter = new AppUserSmartFilter()
{
Name = dto.Name,
Filter = SmartFilterHelper.Encode(dto)
};
user.SmartFilters.Add(existingFilter);
_unitOfWork.UserRepository.Update(user);
}
if (!_unitOfWork.HasChanges()) return Ok();
await _unitOfWork.CommitAsync();
return Ok();
}
/// <summary>
/// Caches the filter in the backend and returns a temp string for retrieving.
/// </summary>
/// <remarks>The cache line lives for only 1 hour</remarks>
/// <param name="filterDto"></param>
/// <returns></returns>
[HttpPost("create-temp")]
public async Task<ActionResult<string>> CreateTempFilter(FilterV2Dto filterDto)
[HttpGet]
public ActionResult<IEnumerable<SmartFilterDto>> GetFilters()
{
var provider = _cacheFactory.GetCachingProvider(EasyCacheProfiles.Filter);
var name = filterDto.Name;
if (string.IsNullOrEmpty(filterDto.Name))
{
name = Guid.NewGuid().ToString();
}
return Ok(_unitOfWork.AppUserSmartFilterRepository.GetAllDtosByUserId(User.GetUserId()));
}
await provider.SetAsync(name, filterDto, TimeSpan.FromHours(1));
return name;
[HttpDelete]
public async Task<ActionResult> DeleteFilter(int filterId)
{
var filter = await _unitOfWork.AppUserSmartFilterRepository.GetById(filterId);
if (filter == null) return Ok();
// This needs to delete any dashboard filters that have it too
var streams = await _unitOfWork.UserRepository.GetDashboardStreamWithFilter(filter.Id);
_unitOfWork.UserRepository.Delete(streams);
_unitOfWork.AppUserSmartFilterRepository.Delete(filter);
await _unitOfWork.CommitAsync();
return Ok();
}
}

View File

@ -1,4 +1,5 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using API.DTOs.Filtering;
@ -19,11 +20,26 @@ public class LocaleController : BaseApiController
[HttpGet]
public ActionResult<IEnumerable<string>> GetAllLocales()
{
var languages = _localizationService.GetLocales().Select(c => new CultureInfo(c)).Select(c =>
new LanguageDto()
var languages = _localizationService.GetLocales().Select(c =>
{
Title = c.DisplayName,
IsoCode = c.IetfLanguageTag
try
{
var cult = new CultureInfo(c);
return new LanguageDto()
{
Title = cult.DisplayName,
IsoCode = cult.IetfLanguageTag
};
}
catch (Exception ex)
{
// Some OS' don't have all culture codes supported like PT_BR, thus we need to default
return new LanguageDto()
{
Title = c,
IsoCode = c
};
}
})
.Where(l => !string.IsNullOrEmpty(l.IsoCode))
.OrderBy(d => d.Title);

View File

@ -102,32 +102,68 @@ public class OpdsController : BaseApiController
var feed = CreateFeed("Kavita", string.Empty, apiKey, prefix);
SetFeedId(feed, "root");
feed.Entries.Add(new FeedEntry()
// Get the user's customized dashboard
var streams = await _unitOfWork.UserRepository.GetDashboardStreams(userId, true);
foreach (var stream in streams)
{
Id = "onDeck",
Title = await _localizationService.Translate(userId, "on-deck"),
Content = new FeedEntryContent()
switch (stream.StreamType)
{
Text = await _localizationService.Translate(userId, "browse-on-deck")
},
Links = new List<FeedLink>()
{
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/on-deck"),
case DashboardStreamType.OnDeck:
feed.Entries.Add(new FeedEntry()
{
Id = "onDeck",
Title = await _localizationService.Translate(userId, "on-deck"),
Content = new FeedEntryContent()
{
Text = await _localizationService.Translate(userId, "browse-on-deck")
},
Links = new List<FeedLink>()
{
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/on-deck"),
}
});
break;
case DashboardStreamType.NewlyAdded:
feed.Entries.Add(new FeedEntry()
{
Id = "recentlyAdded",
Title = await _localizationService.Translate(userId, "recently-added"),
Content = new FeedEntryContent()
{
Text = await _localizationService.Translate(userId, "browse-recently-added")
},
Links = new List<FeedLink>()
{
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/recently-added"),
}
});
break;
case DashboardStreamType.RecentlyUpdated:
// TODO: See if we can implement this and use (count) on series name for number of updates
break;
case DashboardStreamType.MoreInGenre:
// TODO: See if we can implement this
break;
case DashboardStreamType.SmartFilter:
feed.Entries.Add(new FeedEntry()
{
Id = "smartFilter-" + stream.Id,
Title = stream.Name,
Content = new FeedEntryContent()
{
Text = stream.Name
},
Links = new List<FeedLink>()
{
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/smart-filter/{stream.SmartFilterId}/"),
}
});
break;
}
});
feed.Entries.Add(new FeedEntry()
{
Id = "recentlyAdded",
Title = await _localizationService.Translate(userId, "recently-added"),
Content = new FeedEntryContent()
{
Text = await _localizationService.Translate(userId, "browse-recently-added")
},
Links = new List<FeedLink>()
{
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/recently-added"),
}
});
}
feed.Entries.Add(new FeedEntry()
{
Id = "readingList",
@ -180,6 +216,19 @@ public class OpdsController : BaseApiController
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/collections"),
}
});
feed.Entries.Add(new FeedEntry()
{
Id = "allSmartFilters",
Title = await _localizationService.Translate(userId, "smart-filters"),
Content = new FeedEntryContent()
{
Text = await _localizationService.Translate(userId, "browse-smart-filters")
},
Links = new List<FeedLink>()
{
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/smart-filters"),
}
});
return CreateXmlResult(SerializeXml(feed));
}
@ -196,6 +245,67 @@ public class OpdsController : BaseApiController
return new Tuple<string, string>(baseUrl, prefix);
}
/// <summary>
/// Returns the Series matching this smart filter. If FromDashboard, will only return 20 records.
/// </summary>
/// <returns></returns>
[HttpGet("{apiKey}/smart-filter/{filterId}")]
[Produces("application/xml")]
public async Task<IActionResult> GetSmartFilter(string apiKey, int filterId)
{
var userId = await GetUser(apiKey);
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
return BadRequest(await _localizationService.Translate(userId, "opds-disabled"));
var (baseUrl, prefix) = await GetPrefix();
var filter = await _unitOfWork.AppUserSmartFilterRepository.GetById(filterId);
if (filter == null) return BadRequest(_localizationService.Translate(userId, "smart-filter-doesnt-exist"));
var feed = CreateFeed(await _localizationService.Translate(userId, "smartFilter-" + filter.Id), $"{prefix}{apiKey}/smart-filter/{filter.Id}/", apiKey, prefix);
SetFeedId(feed, "smartFilter-" + filter.Id);
var decodedFilter = SmartFilterHelper.Decode(filter.Filter);
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdV2Async(userId, UserParams.Default,
decodedFilter);
var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(series.Select(s => s.Id));
foreach (var seriesDto in series)
{
feed.Entries.Add(CreateSeries(seriesDto, seriesMetadatas.First(s => s.SeriesId == seriesDto.Id), apiKey, prefix, baseUrl));
}
AddPagination(feed, series, $"{prefix}{apiKey}/smart-filter/{filterId}/");
return CreateXmlResult(SerializeXml(feed));
}
[HttpGet("{apiKey}/smart-filters")]
[Produces("application/xml")]
public async Task<IActionResult> GetSmartFilters(string apiKey)
{
var userId = await GetUser(apiKey);
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
return BadRequest(await _localizationService.Translate(userId, "opds-disabled"));
var (baseUrl, prefix) = await GetPrefix();
var filters = _unitOfWork.AppUserSmartFilterRepository.GetAllDtosByUserId(userId);
var feed = CreateFeed(await _localizationService.Translate(userId, "smartFilters"), $"{prefix}{apiKey}/smart-filters", apiKey, prefix);
SetFeedId(feed, "smartFilters");
foreach (var filter in filters)
{
feed.Entries.Add(new FeedEntry()
{
Id = filter.Id.ToString(),
Title = filter.Name,
Links = new List<FeedLink>()
{
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/smart-filter/{filter.Id}")
}
});
}
return CreateXmlResult(SerializeXml(feed));
}
[HttpGet("{apiKey}/libraries")]
[Produces("application/xml")]

View File

@ -6,6 +6,7 @@ using API.Constants;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
using API.DTOs.Dashboard;
using API.DTOs.Filtering;
using API.DTOs.Filtering.v2;
using API.DTOs.Metadata;

View File

@ -0,0 +1,30 @@
using API.DTOs.Filtering.v2;
using API.Entities;
using API.Entities.Enums;
namespace API.DTOs.Dashboard;
public class DashboardStreamDto
{
public int Id { get; set; }
public required string Name { get; set; }
/// <summary>
/// Is System Provided
/// </summary>
public bool IsProvided { get; set; }
/// <summary>
/// Sort Order on the Dashboard
/// </summary>
public int Order { get; set; }
/// <summary>
/// If Not IsProvided, the appropriate smart filter
/// </summary>
/// <remarks>Encoded filter</remarks>
public string? SmartFilterEncoded { get; set; }
public int? SmartFilterId { get; set; }
/// <summary>
/// For system provided
/// </summary>
public DashboardStreamType StreamType { get; set; }
public bool Visible { get; set; }
}

View File

@ -1,7 +1,7 @@
using System;
using API.Entities.Enums;
namespace API.DTOs;
namespace API.DTOs.Dashboard;
/// <summary>
/// This is a representation of a Series with some amount of underlying files within it. This is used for Recently Updated Series section
/// </summary>

View File

@ -1,7 +1,7 @@
using System;
using API.Entities.Enums;
namespace API.DTOs;
namespace API.DTOs.Dashboard;
/// <summary>
/// A mesh of data for Recently added volume/chapters

View File

@ -0,0 +1,13 @@
using API.DTOs.Filtering.v2;
namespace API.DTOs.Dashboard;
public class SmartFilterDto
{
public int Id { get; set; }
public required string Name { get; set; }
/// <summary>
/// This is the Filter url encoded. It is decoded and reconstructed into a <see cref="FilterV2Dto"/>
/// </summary>
public required string Filter { get; set; }
}

View File

@ -0,0 +1,9 @@
namespace API.DTOs.Dashboard;
public class UpdateDashboardStreamPositionDto
{
public int FromPosition { get; set; }
public int ToPosition { get; set; }
public int DashboardStreamId { get; set; }
public string StreamName { get; set; }
}

View File

@ -25,5 +25,9 @@ public enum SortField
/// <summary>
/// Release Year of the Series
/// </summary>
ReleaseYear = 6
ReleaseYear = 6,
/// <summary>
/// Last time the user had any reading progress
/// </summary>
ReadProgress = 7,
}

View File

@ -36,5 +36,10 @@ public enum FilterField
/// <summary>
/// File path
/// </summary>
FilePath = 25
FilePath = 25,
/// <summary>
/// On Want To Read or Not
/// </summary>
WantToRead = 26
}

View File

@ -10,6 +10,10 @@ namespace API.DTOs.Filtering.v2;
/// </summary>
public class FilterV2Dto
{
/// <summary>
/// Not used in the UI.
/// </summary>
public int Id { get; set; }
/// <summary>
/// The name of the filter
/// </summary>

View File

@ -54,6 +54,8 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
public DbSet<ScrobbleHold> ScrobbleHold { get; set; } = null!;
public DbSet<AppUserOnDeckRemoval> AppUserOnDeckRemoval { get; set; } = null!;
public DbSet<AppUserTableOfContent> AppUserTableOfContent { get; set; } = null!;
public DbSet<AppUserSmartFilter> AppUserSmartFilter { get; set; } = null!;
public DbSet<AppUserDashboardStream> AppUserDashboardStream { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder builder)
@ -119,6 +121,13 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
builder.Entity<Chapter>()
.Property(b => b.ISBN)
.HasDefaultValue(string.Empty);
builder.Entity<AppUserDashboardStream>()
.Property(b => b.StreamType)
.HasDefaultValue(DashboardStreamType.SmartFilter);
builder.Entity<AppUserDashboardStream>()
.HasIndex(e => e.Visible)
.IsUnique(false);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,47 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class SmartFilters : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AppUserSmartFilter",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(type: "TEXT", nullable: true),
Filter = table.Column<string>(type: "TEXT", nullable: true),
AppUserId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AppUserSmartFilter", x => x.Id);
table.ForeignKey(
name: "FK_AppUserSmartFilter_AspNetUsers_AppUserId",
column: x => x.AppUserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_AppUserSmartFilter_AppUserId",
table: "AppUserSmartFilter",
column: "AppUserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AppUserSmartFilter");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,66 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class DashboardStream : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AppUserDashboardStream",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(type: "TEXT", nullable: true),
IsProvided = table.Column<bool>(type: "INTEGER", nullable: false),
Order = table.Column<int>(type: "INTEGER", nullable: false),
StreamType = table.Column<int>(type: "INTEGER", nullable: false, defaultValue: 4),
Visible = table.Column<bool>(type: "INTEGER", nullable: false),
SmartFilterId = table.Column<int>(type: "INTEGER", nullable: true),
AppUserId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AppUserDashboardStream", x => x.Id);
table.ForeignKey(
name: "FK_AppUserDashboardStream_AppUserSmartFilter_SmartFilterId",
column: x => x.SmartFilterId,
principalTable: "AppUserSmartFilter",
principalColumn: "Id");
table.ForeignKey(
name: "FK_AppUserDashboardStream_AspNetUsers_AppUserId",
column: x => x.AppUserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_AppUserDashboardStream_AppUserId",
table: "AppUserDashboardStream",
column: "AppUserId");
migrationBuilder.CreateIndex(
name: "IX_AppUserDashboardStream_SmartFilterId",
table: "AppUserDashboardStream",
column: "SmartFilterId");
migrationBuilder.CreateIndex(
name: "IX_AppUserDashboardStream_Visible",
table: "AppUserDashboardStream",
column: "Visible");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AppUserDashboardStream");
}
}
}

View File

@ -180,7 +180,47 @@ namespace API.Data.Migrations
b.HasIndex("AppUserId");
b.ToTable("AppUserBookmark", (string)null);
b.ToTable("AppUserBookmark");
});
modelBuilder.Entity("API.Entities.AppUserDashboardStream", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<bool>("IsProvided")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int>("Order")
.HasColumnType("INTEGER");
b.Property<int?>("SmartFilterId")
.HasColumnType("INTEGER");
b.Property<int>("StreamType")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(4);
b.Property<bool>("Visible")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AppUserId");
b.HasIndex("SmartFilterId");
b.HasIndex("Visible");
b.ToTable("AppUserDashboardStream");
});
modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b =>
@ -201,7 +241,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId");
b.ToTable("AppUserOnDeckRemoval", (string)null);
b.ToTable("AppUserOnDeckRemoval");
});
modelBuilder.Entity("API.Entities.AppUserPreferences", b =>
@ -315,7 +355,7 @@ namespace API.Data.Migrations
b.HasIndex("ThemeId");
b.ToTable("AppUserPreferences", (string)null);
b.ToTable("AppUserPreferences");
});
modelBuilder.Entity("API.Entities.AppUserProgress", b =>
@ -365,7 +405,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId");
b.ToTable("AppUserProgresses", (string)null);
b.ToTable("AppUserProgresses");
});
modelBuilder.Entity("API.Entities.AppUserRating", b =>
@ -398,7 +438,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId");
b.ToTable("AppUserRating", (string)null);
b.ToTable("AppUserRating");
});
modelBuilder.Entity("API.Entities.AppUserRole", b =>
@ -416,6 +456,28 @@ namespace API.Data.Migrations
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("API.Entities.AppUserSmartFilter", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<string>("Filter")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AppUserId");
b.ToTable("AppUserSmartFilter");
});
modelBuilder.Entity("API.Entities.AppUserTableOfContent", b =>
{
b.Property<int>("Id")
@ -466,7 +528,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId");
b.ToTable("AppUserTableOfContent", (string)null);
b.ToTable("AppUserTableOfContent");
});
modelBuilder.Entity("API.Entities.Chapter", b =>
@ -576,7 +638,7 @@ namespace API.Data.Migrations
b.HasIndex("VolumeId");
b.ToTable("Chapter", (string)null);
b.ToTable("Chapter");
});
modelBuilder.Entity("API.Entities.CollectionTag", b =>
@ -611,7 +673,7 @@ namespace API.Data.Migrations
b.HasIndex("Id", "Promoted")
.IsUnique();
b.ToTable("CollectionTag", (string)null);
b.ToTable("CollectionTag");
});
modelBuilder.Entity("API.Entities.Device", b =>
@ -657,7 +719,7 @@ namespace API.Data.Migrations
b.HasIndex("AppUserId");
b.ToTable("Device", (string)null);
b.ToTable("Device");
});
modelBuilder.Entity("API.Entities.FolderPath", b =>
@ -679,7 +741,7 @@ namespace API.Data.Migrations
b.HasIndex("LibraryId");
b.ToTable("FolderPath", (string)null);
b.ToTable("FolderPath");
});
modelBuilder.Entity("API.Entities.Genre", b =>
@ -699,7 +761,7 @@ namespace API.Data.Migrations
b.HasIndex("NormalizedTitle")
.IsUnique();
b.ToTable("Genre", (string)null);
b.ToTable("Genre");
});
modelBuilder.Entity("API.Entities.Library", b =>
@ -757,7 +819,7 @@ namespace API.Data.Migrations
b.HasKey("Id");
b.ToTable("Library", (string)null);
b.ToTable("Library");
});
modelBuilder.Entity("API.Entities.MangaFile", b =>
@ -806,7 +868,7 @@ namespace API.Data.Migrations
b.HasIndex("ChapterId");
b.ToTable("MangaFile", (string)null);
b.ToTable("MangaFile");
});
modelBuilder.Entity("API.Entities.MediaError", b =>
@ -841,7 +903,7 @@ namespace API.Data.Migrations
b.HasKey("Id");
b.ToTable("MediaError", (string)null);
b.ToTable("MediaError");
});
modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b =>
@ -942,7 +1004,7 @@ namespace API.Data.Migrations
b.HasIndex("Id", "SeriesId")
.IsUnique();
b.ToTable("SeriesMetadata", (string)null);
b.ToTable("SeriesMetadata");
});
modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b =>
@ -966,7 +1028,7 @@ namespace API.Data.Migrations
b.HasIndex("TargetSeriesId");
b.ToTable("SeriesRelation", (string)null);
b.ToTable("SeriesRelation");
});
modelBuilder.Entity("API.Entities.Person", b =>
@ -986,7 +1048,7 @@ namespace API.Data.Migrations
b.HasKey("Id");
b.ToTable("Person", (string)null);
b.ToTable("Person");
});
modelBuilder.Entity("API.Entities.ReadingList", b =>
@ -1049,7 +1111,7 @@ namespace API.Data.Migrations
b.HasIndex("AppUserId");
b.ToTable("ReadingList", (string)null);
b.ToTable("ReadingList");
});
modelBuilder.Entity("API.Entities.ReadingListItem", b =>
@ -1083,7 +1145,7 @@ namespace API.Data.Migrations
b.HasIndex("VolumeId");
b.ToTable("ReadingListItem", (string)null);
b.ToTable("ReadingListItem");
});
modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b =>
@ -1128,7 +1190,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId");
b.ToTable("ScrobbleError", (string)null);
b.ToTable("ScrobbleError");
});
modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b =>
@ -1188,8 +1250,8 @@ namespace API.Data.Migrations
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.Property<float?>("VolumeNumber")
.HasColumnType("REAL");
b.Property<int?>("VolumeNumber")
.HasColumnType("INTEGER");
b.HasKey("Id");
@ -1199,7 +1261,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId");
b.ToTable("ScrobbleEvent", (string)null);
b.ToTable("ScrobbleEvent");
});
modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b =>
@ -1232,7 +1294,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId");
b.ToTable("ScrobbleHold", (string)null);
b.ToTable("ScrobbleHold");
});
modelBuilder.Entity("API.Entities.Series", b =>
@ -1328,7 +1390,7 @@ namespace API.Data.Migrations
b.HasIndex("LibraryId");
b.ToTable("Series", (string)null);
b.ToTable("Series");
});
modelBuilder.Entity("API.Entities.ServerSetting", b =>
@ -1345,7 +1407,7 @@ namespace API.Data.Migrations
b.HasKey("Key");
b.ToTable("ServerSetting", (string)null);
b.ToTable("ServerSetting");
});
modelBuilder.Entity("API.Entities.ServerStatistics", b =>
@ -1383,7 +1445,7 @@ namespace API.Data.Migrations
b.HasKey("Id");
b.ToTable("ServerStatistics", (string)null);
b.ToTable("ServerStatistics");
});
modelBuilder.Entity("API.Entities.SiteTheme", b =>
@ -1421,7 +1483,7 @@ namespace API.Data.Migrations
b.HasKey("Id");
b.ToTable("SiteTheme", (string)null);
b.ToTable("SiteTheme");
});
modelBuilder.Entity("API.Entities.Tag", b =>
@ -1441,7 +1503,7 @@ namespace API.Data.Migrations
b.HasIndex("NormalizedTitle")
.IsUnique();
b.ToTable("Tag", (string)null);
b.ToTable("Tag");
});
modelBuilder.Entity("API.Entities.Volume", b =>
@ -1477,8 +1539,8 @@ namespace API.Data.Migrations
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<float>("Number")
.HasColumnType("REAL");
b.Property<int>("Number")
.HasColumnType("INTEGER");
b.Property<int>("Pages")
.HasColumnType("INTEGER");
@ -1493,7 +1555,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId");
b.ToTable("Volume", (string)null);
b.ToTable("Volume");
});
modelBuilder.Entity("AppUserLibrary", b =>
@ -1508,7 +1570,7 @@ namespace API.Data.Migrations
b.HasIndex("LibrariesId");
b.ToTable("AppUserLibrary", (string)null);
b.ToTable("AppUserLibrary");
});
modelBuilder.Entity("ChapterGenre", b =>
@ -1523,7 +1585,7 @@ namespace API.Data.Migrations
b.HasIndex("GenresId");
b.ToTable("ChapterGenre", (string)null);
b.ToTable("ChapterGenre");
});
modelBuilder.Entity("ChapterPerson", b =>
@ -1538,7 +1600,7 @@ namespace API.Data.Migrations
b.HasIndex("PeopleId");
b.ToTable("ChapterPerson", (string)null);
b.ToTable("ChapterPerson");
});
modelBuilder.Entity("ChapterTag", b =>
@ -1553,7 +1615,7 @@ namespace API.Data.Migrations
b.HasIndex("TagsId");
b.ToTable("ChapterTag", (string)null);
b.ToTable("ChapterTag");
});
modelBuilder.Entity("CollectionTagSeriesMetadata", b =>
@ -1568,7 +1630,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesMetadatasId");
b.ToTable("CollectionTagSeriesMetadata", (string)null);
b.ToTable("CollectionTagSeriesMetadata");
});
modelBuilder.Entity("GenreSeriesMetadata", b =>
@ -1583,7 +1645,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesMetadatasId");
b.ToTable("GenreSeriesMetadata", (string)null);
b.ToTable("GenreSeriesMetadata");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<int>", b =>
@ -1682,7 +1744,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesMetadatasId");
b.ToTable("PersonSeriesMetadata", (string)null);
b.ToTable("PersonSeriesMetadata");
});
modelBuilder.Entity("SeriesMetadataTag", b =>
@ -1697,7 +1759,7 @@ namespace API.Data.Migrations
b.HasIndex("TagsId");
b.ToTable("SeriesMetadataTag", (string)null);
b.ToTable("SeriesMetadataTag");
});
modelBuilder.Entity("API.Entities.AppUserBookmark", b =>
@ -1711,6 +1773,23 @@ namespace API.Data.Migrations
b.Navigation("AppUser");
});
modelBuilder.Entity("API.Entities.AppUserDashboardStream", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
.WithMany("DashboardStreams")
.HasForeignKey("AppUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter")
.WithMany()
.HasForeignKey("SmartFilterId");
b.Navigation("AppUser");
b.Navigation("SmartFilter");
});
modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
@ -1808,6 +1887,17 @@ namespace API.Data.Migrations
b.Navigation("User");
});
modelBuilder.Entity("API.Entities.AppUserSmartFilter", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
.WithMany("SmartFilters")
.HasForeignKey("AppUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AppUser");
});
modelBuilder.Entity("API.Entities.AppUserTableOfContent", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
@ -2209,6 +2299,8 @@ namespace API.Data.Migrations
{
b.Navigation("Bookmarks");
b.Navigation("DashboardStreams");
b.Navigation("Devices");
b.Navigation("Progresses");
@ -2219,6 +2311,8 @@ namespace API.Data.Migrations
b.Navigation("ScrobbleHolds");
b.Navigation("SmartFilters");
b.Navigation("TableOfContents");
b.Navigation("UserPreferences");

View File

@ -0,0 +1,60 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.DTOs.Dashboard;
using API.Entities;
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Microsoft.EntityFrameworkCore;
namespace API.Data.Repositories;
public interface IAppUserSmartFilterRepository
{
void Update(AppUserSmartFilter filter);
void Attach(AppUserSmartFilter filter);
void Delete(AppUserSmartFilter filter);
IEnumerable<SmartFilterDto> GetAllDtosByUserId(int userId);
Task<AppUserSmartFilter?> GetById(int smartFilterId);
}
public class AppUserSmartFilterRepository : IAppUserSmartFilterRepository
{
private readonly DataContext _context;
private readonly IMapper _mapper;
public AppUserSmartFilterRepository(DataContext context, IMapper mapper)
{
_context = context;
_mapper = mapper;
}
public void Update(AppUserSmartFilter filter)
{
_context.Entry(filter).State = EntityState.Modified;
}
public void Attach(AppUserSmartFilter filter)
{
_context.AppUserSmartFilter.Attach(filter);
}
public void Delete(AppUserSmartFilter filter)
{
_context.AppUserSmartFilter.Remove(filter);
}
public IEnumerable<SmartFilterDto> GetAllDtosByUserId(int userId)
{
return _context.AppUserSmartFilter
.Where(f => f.AppUserId == userId)
.ProjectTo<SmartFilterDto>(_mapper.ConfigurationProvider)
.AsEnumerable();
}
public async Task<AppUserSmartFilter?> GetById(int smartFilterId)
{
return await _context.AppUserSmartFilter.FirstOrDefaultAsync(d => d.Id == smartFilterId);
}
}

View File

@ -8,6 +8,7 @@ using API.Data.Misc;
using API.Data.Scanner;
using API.DTOs;
using API.DTOs.CollectionTags;
using API.DTOs.Dashboard;
using API.DTOs.Filtering;
using API.DTOs.Filtering.v2;
using API.DTOs.Metadata;
@ -952,6 +953,9 @@ public class SeriesRepository : ISeriesRepository
// First setup any FilterField.Libraries in the statements, as these don't have any traditional query statements applied here
query = ApplyLibraryFilter(filter, query);
query = ApplyWantToReadFilter(filter, query, userId);
query = BuildFilterQuery(userId, filter, query);
@ -968,6 +972,24 @@ public class SeriesRepository : ISeriesRepository
.AsSplitQuery(), filter.LimitTo);
}
private IQueryable<Series> ApplyWantToReadFilter(FilterV2Dto filter, IQueryable<Series> query, int userId)
{
var wantToReadStmt = filter.Statements.FirstOrDefault(stmt => stmt.Field == FilterField.WantToRead);
if (wantToReadStmt == null) return query;
var seriesIds = _context.AppUser.Where(u => u.Id == userId).SelectMany(u => u.WantToRead).Select(s => s.Id);
if (bool.Parse(wantToReadStmt.Value))
{
query = query.Where(s => seriesIds.Contains(s.Id));
}
else
{
query = query.Where(s => !seriesIds.Contains(s.Id));
}
return query;
}
private static IQueryable<Series> ApplyLibraryFilter(FilterV2Dto filter, IQueryable<Series> query)
{
var filterIncludeLibs = new List<int>();
@ -1060,6 +1082,9 @@ public class SeriesRepository : ISeriesRepository
FilterField.Libraries =>
// This is handled in the code before this as it's handled in a more general, combined manner
query,
FilterField.WantToRead =>
// This is handled in the higher level of code as it's more general
query,
FilterField.ReadProgress => query.HasReadingProgress(true, statement.Comparison, (int) value, userId),
FilterField.Formats => query.HasFormat(true, statement.Comparison, (IList<MangaFormat>) value),
FilterField.ReleaseYear => query.HasReleaseYear(true, statement.Comparison, (int) value),

View File

@ -6,7 +6,7 @@ using System.Threading.Tasks;
using API.Constants;
using API.DTOs;
using API.DTOs.Account;
using API.DTOs.Filtering;
using API.DTOs.Dashboard;
using API.DTOs.Filtering.v2;
using API.DTOs.Reader;
using API.DTOs.Scrobbling;
@ -15,6 +15,7 @@ using API.Entities;
using API.Extensions;
using API.Extensions.QueryExtensions;
using API.Extensions.QueryExtensions.Filtering;
using API.Helpers;
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Microsoft.AspNetCore.Identity;
@ -34,8 +35,9 @@ public enum AppUserIncludes
WantToRead = 64,
ReadingListsWithItems = 128,
Devices = 256,
ScrobbleHolds = 512
ScrobbleHolds = 512,
SmartFilters = 1024,
DashboardStreams = 2048
}
public interface IUserRepository
@ -43,9 +45,11 @@ public interface IUserRepository
void Update(AppUser user);
void Update(AppUserPreferences preferences);
void Update(AppUserBookmark bookmark);
void Update(AppUserDashboardStream stream);
void Add(AppUserBookmark bookmark);
public void Delete(AppUser? user);
void Delete(AppUser? user);
void Delete(AppUserBookmark bookmark);
void Delete(IList<AppUserDashboardStream> streams);
Task<IEnumerable<MemberDto>> GetEmailConfirmedMemberDtosAsync(bool emailConfirmed = true);
Task<IEnumerable<AppUser>> GetAdminUsersAsync();
Task<bool> IsUserAdminAsync(AppUser? user);
@ -76,6 +80,9 @@ public interface IUserRepository
Task<bool> HasHoldOnSeries(int userId, int seriesId);
Task<IList<ScrobbleHoldDto>> GetHolds(int userId);
Task<string> GetLocale(int userId);
Task<IList<DashboardStreamDto>> GetDashboardStreams(int userId, bool visibleOnly = false);
Task<AppUserDashboardStream?> GetDashboardStream(int streamId);
Task<IList<AppUserDashboardStream>> GetDashboardStreamWithFilter(int filterId);
}
public class UserRepository : IUserRepository
@ -106,6 +113,11 @@ public class UserRepository : IUserRepository
_context.Entry(bookmark).State = EntityState.Modified;
}
public void Update(AppUserDashboardStream stream)
{
_context.Entry(stream).State = EntityState.Modified;
}
public void Add(AppUserBookmark bookmark)
{
_context.AppUserBookmark.Add(bookmark);
@ -122,6 +134,11 @@ public class UserRepository : IUserRepository
_context.AppUserBookmark.Remove(bookmark);
}
public void Delete(IList<AppUserDashboardStream> streams)
{
_context.AppUserDashboardStream.RemoveRange(streams);
}
/// <summary>
/// A one stop shop to get a tracked AppUser instance with any number of JOINs generated by passing bitwise flags.
/// </summary>
@ -300,6 +317,42 @@ public class UserRepository : IUserRepository
.SingleAsync();
}
public async Task<IList<DashboardStreamDto>> GetDashboardStreams(int userId, bool visibleOnly = false)
{
return await _context.AppUserDashboardStream
.Where(d => d.AppUserId == userId)
.WhereIf(visibleOnly, d => d.Visible)
.OrderBy(d => d.Order)
.Include(d => d.SmartFilter)
.Select(d => new DashboardStreamDto()
{
Id = d.Id,
Name = d.Name,
IsProvided = d.IsProvided,
SmartFilterId = d.SmartFilter == null ? 0 : d.SmartFilter.Id,
SmartFilterEncoded = d.SmartFilter == null ? null : d.SmartFilter.Filter,
StreamType = d.StreamType,
Order = d.Order,
Visible = d.Visible
})
.ToListAsync();
}
public async Task<AppUserDashboardStream?> GetDashboardStream(int streamId)
{
return await _context.AppUserDashboardStream
.Include(d => d.SmartFilter)
.FirstOrDefaultAsync(d => d.Id == streamId);
}
public async Task<IList<AppUserDashboardStream>> GetDashboardStreamWithFilter(int filterId)
{
return await _context.AppUserDashboardStream
.Include(d => d.SmartFilter)
.Where(d => d.SmartFilter != null && d.SmartFilter.Id == filterId)
.ToListAsync();
}
public async Task<IEnumerable<AppUser>> GetAdminUsersAsync()
{
return await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole);

View File

@ -6,6 +6,7 @@ using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using API.Constants;
using API.Data.Repositories;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Enums.Theme;
@ -38,6 +39,43 @@ public static class Seed
}
}.ToArray());
public static readonly ImmutableArray<AppUserDashboardStream> DefaultStreams = ImmutableArray.Create(
new List<AppUserDashboardStream>
{
new()
{
Name = "On Deck",
StreamType = DashboardStreamType.OnDeck,
Order = 0,
IsProvided = true,
Visible = true
},
new()
{
Name = "Recently Updated",
StreamType = DashboardStreamType.RecentlyUpdated,
Order = 1,
IsProvided = true,
Visible = true
},
new()
{
Name = "Newly Added",
StreamType = DashboardStreamType.NewlyAdded,
Order = 2,
IsProvided = true,
Visible = true
},
new()
{
Name = "More In",
StreamType = DashboardStreamType.MoreInGenre,
Order = 3,
IsProvided = true,
Visible = false
},
}.ToArray());
public static async Task SeedRoles(RoleManager<AppRole> roleManager)
{
var roles = typeof(PolicyConstants)
@ -74,6 +112,31 @@ public static class Seed
await context.SaveChangesAsync();
}
public static async Task SeedDefaultStreams(IUnitOfWork unitOfWork)
{
var allUsers = await unitOfWork.UserRepository.GetAllUsersAsync(AppUserIncludes.DashboardStreams);
foreach (var user in allUsers)
{
if (user.DashboardStreams.Count != 0) continue;
user.DashboardStreams ??= new List<AppUserDashboardStream>();
foreach (var defaultStream in DefaultStreams)
{
var newStream = new AppUserDashboardStream
{
Name = defaultStream.Name,
IsProvided = defaultStream.IsProvided,
Order = defaultStream.Order,
StreamType = defaultStream.StreamType,
Visible = defaultStream.Visible,
};
user.DashboardStreams.Add(newStream);
}
unitOfWork.UserRepository.Update(user);
await unitOfWork.CommitAsync();
}
}
public static async Task SeedSettings(DataContext context, IDirectoryService directoryService)
{
await context.Database.EnsureCreatedAsync();

View File

@ -28,6 +28,7 @@ public interface IUnitOfWork
IMediaErrorRepository MediaErrorRepository { get; }
IScrobbleRepository ScrobbleRepository { get; }
IUserTableOfContentRepository UserTableOfContentRepository { get; }
IAppUserSmartFilterRepository AppUserSmartFilterRepository { get; }
bool Commit();
Task<bool> CommitAsync();
bool HasChanges();
@ -68,6 +69,7 @@ public class UnitOfWork : IUnitOfWork
public IMediaErrorRepository MediaErrorRepository => new MediaErrorRepository(_context, _mapper);
public IScrobbleRepository ScrobbleRepository => new ScrobbleRepository(_context, _mapper);
public IUserTableOfContentRepository UserTableOfContentRepository => new UserTableOfContentRepository(_context, _mapper);
public IAppUserSmartFilterRepository AppUserSmartFilterRepository => new AppUserSmartFilterRepository(_context, _mapper);
/// <summary>
/// Commits changes to the DB. Completes the open transaction.

View File

@ -67,6 +67,15 @@ public class AppUser : IdentityUser<int>, IHasConcurrencyToken
/// A list of Series the user doesn't want scrobbling for
/// </summary>
public ICollection<ScrobbleHold> ScrobbleHolds { get; set; } = null!;
/// <summary>
/// A collection of user Smart Filters for their account
/// </summary>
public ICollection<AppUserSmartFilter> SmartFilters { get; set; } = null!;
/// <summary>
/// An ordered list of Streams (pre-configured) or Smart Filters that makes up the User's Dashboard
/// </summary>
public IList<AppUserDashboardStream> DashboardStreams { get; set; } = null!;
/// <inheritdoc />

View File

@ -0,0 +1,29 @@
using API.Entities.Enums;
namespace API.Entities;
public class AppUserDashboardStream
{
public int Id { get; set; }
public required string Name { get; set; }
/// <summary>
/// Is System Provided
/// </summary>
public bool IsProvided { get; set; }
/// <summary>
/// Sort Order on the Dashboard
/// </summary>
public int Order { get; set; }
/// <summary>
/// For system provided
/// </summary>
public DashboardStreamType StreamType { get; set; }
public bool Visible { get; set; }
/// <summary>
/// If Not IsProvided, the appropriate smart filter
/// </summary>
public AppUserSmartFilter? SmartFilter { get; set; }
public int AppUserId { get; set; }
public AppUser AppUser { get; set; }
}

View File

@ -0,0 +1,19 @@
using API.DTOs.Filtering.v2;
namespace API.Entities;
/// <summary>
/// Represents a Saved user Filter
/// </summary>
public class AppUserSmartFilter
{
public int Id { get; set; }
public required string Name { get; set; }
/// <summary>
/// This is the Filter url encoded. It is decoded and reconstructed into a <see cref="FilterV2Dto"/>
/// </summary>
public required string Filter { get; set; }
public int AppUserId { get; set; }
public AppUser AppUser { get; set; }
}

View File

@ -0,0 +1,14 @@
namespace API.Entities.Enums;
public enum DashboardStreamType
{
OnDeck = 1,
RecentlyUpdated = 2,
NewlyAdded = 3,
SmartFilter = 4,
/// <summary>
/// More In Genre
/// </summary>
MoreInGenre = 5
}

View File

@ -14,7 +14,7 @@ namespace API.Extensions.QueryExtensions.Filtering;
public static class SeriesFilter
{
private const float FloatingPointTolerance = 0.01f;
public static IQueryable<Series> HasLanguage(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, IList<string> languages)
{
@ -94,7 +94,7 @@ public static class SeriesFilter
switch (comparison)
{
case FilterComparison.Equal:
return queryable.Where(s => s.Ratings.Any(r => r.Rating == rating && r.AppUserId == userId));
return queryable.Where(s => s.Ratings.Any(r => Math.Abs(r.Rating - rating) < FloatingPointTolerance && r.AppUserId == userId));
case FilterComparison.GreaterThan:
return queryable.Where(s => s.Ratings.Any(r => r.Rating > rating && r.AppUserId == userId));
case FilterComparison.GreaterThanEqual:
@ -252,7 +252,7 @@ public static class SeriesFilter
switch (comparison)
{
case FilterComparison.Equal:
subQuery = subQuery.Where(s => s.Percentage == readProgress);
subQuery = subQuery.Where(s => Math.Abs(s.Percentage - readProgress) < FloatingPointTolerance);
break;
case FilterComparison.GreaterThan:
subQuery = subQuery.Where(s => s.Percentage > readProgress);
@ -267,7 +267,7 @@ public static class SeriesFilter
subQuery = subQuery.Where(s => s.Percentage <= readProgress);
break;
case FilterComparison.NotEqual:
subQuery = subQuery.Where(s => s.Percentage != readProgress);
subQuery = subQuery.Where(s => Math.Abs(s.Percentage - readProgress) > FloatingPointTolerance);
break;
case FilterComparison.Matches:
case FilterComparison.Contains:

View File

@ -31,6 +31,7 @@ public static class SeriesSort
SortField.LastChapterAdded => query.OrderBy(s => s.LastChapterAdded),
SortField.TimeToRead => query.OrderBy(s => s.AvgHoursToRead),
SortField.ReleaseYear => query.OrderBy(s => s.Metadata.ReleaseYear),
//SortField.ReadProgress => query.OrderBy()
_ => query
};
}

View File

@ -130,6 +130,17 @@ public static class IncludesExtensions
query = query.Include(u => u.ScrobbleHolds);
}
if (includeFlags.HasFlag(AppUserIncludes.SmartFilters))
{
query = query.Include(u => u.SmartFilters);
}
if (includeFlags.HasFlag(AppUserIncludes.DashboardStreams))
{
query = query.Include(u => u.DashboardStreams)
.ThenInclude(s => s.SmartFilter);
}
return query.AsSplitQuery();
}

View File

@ -4,7 +4,10 @@ using API.Data.Migrations;
using API.DTOs;
using API.DTOs.Account;
using API.DTOs.CollectionTags;
using API.DTOs.Dashboard;
using API.DTOs.Device;
using API.DTOs.Filtering;
using API.DTOs.Filtering.v2;
using API.DTOs.MediaErrors;
using API.DTOs.Metadata;
using API.DTOs.Reader;
@ -226,5 +229,12 @@ public class AutoMapperProfiles : Profile
CreateMap<Device, DeviceDto>();
CreateMap<AppUserTableOfContent, PersonalToCDto>();
CreateMap<AppUserSmartFilter, SmartFilterDto>();
CreateMap<AppUserDashboardStream, DashboardStreamDto>();
// CreateMap<AppUserDashboardStream, DashboardStreamDto>()
// .ForMember(dest => dest.SmartFilterEncoded,
// opt => opt.MapFrom(src => src.SmartFilter));
}
}

View File

@ -28,8 +28,13 @@ public class AppUserBuilder : IEntityBuilder<AppUser>
Ratings = new List<AppUserRating>(),
Progresses = new List<AppUserProgress>(),
Devices = new List<Device>(),
Id = 0
Id = 0,
DashboardStreams = new List<AppUserDashboardStream>()
};
foreach (var s in Seed.DefaultStreams)
{
_appUser.DashboardStreams.Add(s);
}
}
public AppUserBuilder WithLibrary(Library library)

View File

@ -0,0 +1,24 @@
using API.DTOs.Filtering.v2;
using API.Entities;
namespace API.Helpers.Builders;
public class SmartFilterBuilder : IEntityBuilder<AppUserSmartFilter>
{
private AppUserSmartFilter _smartFilter;
public AppUserSmartFilter Build() => _smartFilter;
public SmartFilterBuilder(FilterV2Dto filter)
{
_smartFilter = new AppUserSmartFilter()
{
Name = filter.Name,
Filter = SmartFilterHelper.Encode(filter)
};
}
// public SmartFilterBuilder WithName(string name)
// {
//
// }
}

View File

@ -67,6 +67,7 @@ public static class FilterFieldValueConverter
FilterField.Libraries => (value.Split(',')
.Select(int.Parse)
.ToList(), typeof(IList<int>)),
FilterField.WantToRead => (bool.Parse(value), typeof(bool)),
FilterField.ReadProgress => (int.Parse(value), typeof(int)),
FilterField.Formats => (value.Split(',')
.Select(x => (MangaFormat) Enum.Parse(typeof(MangaFormat), x))

View File

@ -0,0 +1,140 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using API.DTOs.Filtering;
using API.DTOs.Filtering.v2;
namespace API.Helpers;
public static class SmartFilterHelper
{
private const string SortOptionsKey = "sortOptions=";
private const string StatementsKey = "stmts=";
private const string LimitToKey = "limitTo=";
private const string CombinationKey = "combination=";
public static FilterV2Dto Decode(string? encodedFilter)
{
if (string.IsNullOrWhiteSpace(encodedFilter))
{
return new FilterV2Dto(); // Create a default filter if the input is empty
}
string[] parts = encodedFilter.Split('&');
var filter = new FilterV2Dto();
foreach (var part in parts)
{
if (part.StartsWith(SortOptionsKey))
{
filter.SortOptions = DecodeSortOptions(part.Substring(SortOptionsKey.Length));
}
else if (part.StartsWith(LimitToKey))
{
filter.LimitTo = int.Parse(part.Substring(LimitToKey.Length));
}
else if (part.StartsWith(CombinationKey))
{
filter.Combination = Enum.Parse<FilterCombination>(part.Split("=")[1]);
}
else if (part.StartsWith(StatementsKey))
{
filter.Statements = DecodeFilterStatementDtos(part.Substring(StatementsKey.Length));
}
else if (part.StartsWith("name="))
{
filter.Name = HttpUtility.UrlDecode(part.Substring(5));
}
}
return filter;
}
public static string Encode(FilterV2Dto filter)
{
if (filter == null)
return string.Empty;
var encodedStatements = EncodeFilterStatementDtos(filter.Statements);
var encodedSortOptions = filter.SortOptions != null
? $"{SortOptionsKey}{EncodeSortOptions(filter.SortOptions)}"
: "";
var encodedLimitTo = $"{LimitToKey}{filter.LimitTo}";
return $"{EncodeName(filter.Name)}{encodedStatements}&{encodedSortOptions}&{encodedLimitTo}&{CombinationKey}{(int) filter.Combination}";
}
private static string EncodeName(string name)
{
return string.IsNullOrWhiteSpace(name) ? string.Empty : $"name={HttpUtility.UrlEncode(name)}&";
}
private static string EncodeSortOptions(SortOptions sortOptions)
{
return $"sortField={(int) sortOptions.SortField}&isAscending={sortOptions.IsAscending}";
}
private static string EncodeFilterStatementDtos(ICollection<FilterStatementDto> statements)
{
if (statements == null || statements.Count == 0)
return string.Empty;
var encodedStatements = StatementsKey + HttpUtility.UrlEncode(string.Join(",", statements.Select(EncodeFilterStatementDto)));
return encodedStatements;
}
private static string EncodeFilterStatementDto(FilterStatementDto statement)
{
var encodedComparison = $"comparison={(int) statement.Comparison}";
var encodedField = $"field={(int) statement.Field}";
var encodedValue = $"value={HttpUtility.UrlEncode(statement.Value)}";
return $"{encodedComparison}&{encodedField}&{encodedValue}";
}
private static List<FilterStatementDto> DecodeFilterStatementDtos(string encodedStatements)
{
encodedStatements = HttpUtility.UrlDecode(encodedStatements);
string[] statementStrings = encodedStatements.Split(',');
var statements = new List<FilterStatementDto>();
foreach (var statementString in statementStrings)
{
var parts = statementString.Split('&');
if (parts.Length < 3)
continue;
statements.Add(new FilterStatementDto
{
Comparison = Enum.Parse<FilterComparison>(parts[0].Split("=")[1]),
Field = Enum.Parse<FilterField>(parts[1].Split("=")[1]),
Value = HttpUtility.UrlDecode(parts[2].Split("=")[1])
});
}
return statements;
}
private static SortOptions DecodeSortOptions(string encodedSortOptions)
{
string[] parts = encodedSortOptions.Split('&');
var sortFieldPart = parts.FirstOrDefault(part => part.StartsWith("sortField="));
var isAscendingPart = parts.FirstOrDefault(part => part.StartsWith("isAscending="));
var isAscending = isAscendingPart?.Substring(11).Equals("true", StringComparison.OrdinalIgnoreCase) ?? true;
if (sortFieldPart != null)
{
var sortField = Enum.Parse<SortField>(sortFieldPart.Split("=")[1]);
return new SortOptions
{
SortField = sortField,
IsAscending = isAscending
};
}
return null;
}
}

View File

@ -150,11 +150,14 @@
"browse-libraries": "Browse by Libraries",
"collections": "All Collections",
"browse-collections": "Browse by Collections",
"smart-filters": "Smart Filters",
"browse-smart-filters": "Browse by Smart Filters",
"reading-list-restricted": "Reading list does not exist or you don't have access",
"query-required": "You must pass a query parameter",
"search": "Search",
"search-description": "Search for Series, Collections, or Reading Lists",
"favicon-doesnt-exist": "Favicon does not exist",
"smart-filter-doesnt-exist": "Smart Filter doesn't exist",
"not-authenticated": "User is not authenticated",
"unable-to-register-k+": "Unable to register license due to error. Reach out to Kavita+ Support",

View File

@ -90,6 +90,7 @@ public class Program
await Seed.SeedRoles(services.GetRequiredService<RoleManager<AppRole>>());
await Seed.SeedSettings(context, directoryService);
await Seed.SeedThemes(context);
await Seed.SeedDefaultStreams(services.GetRequiredService<IUnitOfWork>());
await Seed.SeedUserApiKeys(context);
}
catch (Exception ex)

View File

@ -122,6 +122,25 @@ public static class MessageFactory
/// A Scrobbling Key has expired and needs rotation
/// </summary>
public const string ScrobblingKeyExpired = "ScrobblingKeyExpired";
/// <summary>
/// Order, Visibility, etc has changed on the Dashboard. UI will refresh the layout
/// </summary>
public const string DashboardUpdate = "DashboardUpdate";
public static SignalRMessage DashboardUpdateEvent(int userId)
{
return new SignalRMessage()
{
Name = DashboardUpdate,
Title = "Dashboard Update",
Progress = ProgressType.None,
EventType = ProgressEventType.Single,
Body = new
{
UserId = userId
}
};
}
public static SignalRMessage ScanSeriesEvent(int libraryId, int seriesId, string seriesName)

View File

@ -21,7 +21,7 @@
"@fortawesome/fontawesome-free": "^6.4.2",
"@iharbeck/ngx-virtual-scroller": "^16.0.0",
"@iplab/ngx-file-upload": "^16.0.1",
"@microsoft/signalr": "^7.0.10",
"@microsoft/signalr": "^7.0.11",
"@ng-bootstrap/ng-bootstrap": "^15.1.1",
"@ngneat/transloco": "^5.0.7",
"@ngneat/transloco-locale": "^5.1.1",
@ -3142,9 +3142,9 @@
"dev": true
},
"node_modules/@microsoft/signalr": {
"version": "7.0.10",
"resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-7.0.10.tgz",
"integrity": "sha512-tOEn32i5EatAx4sZbzmLgcBc2VbKQmx+F4rI2/Ioq2MnBaYcFxbDzOoZgISIS4IR9H1ij/sKoU8zQOAFC8GJKg==",
"version": "7.0.11",
"resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-7.0.11.tgz",
"integrity": "sha512-//6ipnYKhHf2MJgM+MQSlgB5L/pcYeZ+v4w6YAr4epRM1iSDQ6WjUkCVX2ZMxcY06XGlLzggs3Z9ZIcL9ws9KQ==",
"dependencies": {
"abort-controller": "^3.0.0",
"eventsource": "^2.0.2",

View File

@ -26,7 +26,7 @@
"@fortawesome/fontawesome-free": "^6.4.2",
"@iharbeck/ngx-virtual-scroller": "^16.0.0",
"@iplab/ngx-file-upload": "^16.0.1",
"@microsoft/signalr": "^7.0.10",
"@microsoft/signalr": "^7.0.11",
"@ng-bootstrap/ng-bootstrap": "^15.1.1",
"@ngneat/transloco": "^5.0.7",
"@ngneat/transloco-locale": "^5.1.1",

View File

@ -0,0 +1,14 @@
import {Observable} from "rxjs";
import {StreamType} from "./stream-type.enum";
export interface DashboardStream {
id: number;
name: string;
isProvided: boolean;
api: Observable<any[]>;
smartFilterId: number;
smartFilterEncoded?: string;
streamType: StreamType;
order: number;
visible: boolean;
}

View File

@ -0,0 +1,7 @@
export enum StreamType {
OnDeck = 1,
RecentlyUpdated = 2,
NewlyAdded = 3,
SmartFilter = 4,
MoreInGenre = 5
}

View File

@ -0,0 +1,3 @@
export interface DashboardUpdateEvent {
userId: number;
}

View File

@ -1,4 +1,4 @@
export interface LibraryModifiedEvent {
libraryId: number;
action: 'create' | 'delelte';
}
action: 'create' | 'delete';
}

View File

@ -26,7 +26,8 @@ export enum FilterField
ReleaseYear = 22,
ReadTime = 23,
Path = 24,
FilePath = 25
FilePath = 25,
WantToRead = 26
}
export const allFields = Object.keys(FilterField)

View File

@ -0,0 +1,5 @@
export interface SmartFilter {
id: number;
name: string;
filter: string;
}

View File

@ -58,7 +58,7 @@ export class AccountService {
filter(userUpdateEvent => userUpdateEvent.userName === this.currentUser?.username),
switchMap(() => this.refreshAccount()))
.subscribe(() => {});
}
}
hasAdminRole(user: User) {
return user && user.roles.includes(Role.Admin);

View File

@ -0,0 +1,29 @@
import { Injectable } from '@angular/core';
import {TextResonse} from "../_types/text-response";
import {HttpClient} from "@angular/common/http";
import {environment} from "../../environments/environment";
import {DashboardStream} from "../_models/dashboard/dashboard-stream";
@Injectable({
providedIn: 'root'
})
export class DashboardService {
baseUrl = environment.apiUrl;
constructor(private httpClient: HttpClient) { }
getDashboardStreams(visibleOnly = true) {
return this.httpClient.get<Array<DashboardStream>>(this.baseUrl + 'account/dashboard?visibleOnly=' + visibleOnly);
}
updateDashboardStreamPosition(streamName: string, dashboardStreamId: number, fromPosition: number, toPosition: number) {
return this.httpClient.post(this.baseUrl + 'account/update-dashboard-position', {streamName, dashboardStreamId, fromPosition, toPosition}, TextResonse);
}
updateDashboardStream(stream: DashboardStream) {
return this.httpClient.post(this.baseUrl + 'account/update-dashboard-stream', stream, TextResonse);
}
createDashboardStream(smartFilterId: number) {
return this.httpClient.post<DashboardStream>(this.baseUrl + 'account/add-dashboard-stream?smartFilterId=' + smartFilterId, {});
}
}

View File

@ -0,0 +1,26 @@
import { Injectable } from '@angular/core';
import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2";
import {environment} from "../../environments/environment";
import {HttpClient} from "@angular/common/http";
import {JumpKey} from "../_models/jumpbar/jump-key";
import {SmartFilter} from "../_models/metadata/v2/smart-filter";
@Injectable({
providedIn: 'root'
})
export class FilterService {
baseUrl = environment.apiUrl;
constructor(private httpClient: HttpClient) { }
saveFilter(filter: SeriesFilterV2) {
return this.httpClient.post(this.baseUrl + 'filter/update', filter);
}
getAllFilters() {
return this.httpClient.get<Array<SmartFilter>>(this.baseUrl + 'filter');
}
deleteFilter(filterId: number) {
return this.httpClient.delete(this.baseUrl + 'filter?filterId=' + filterId);
}
}

View File

@ -7,6 +7,7 @@ import { NotificationProgressEvent } from '../_models/events/notification-progre
import { ThemeProgressEvent } from '../_models/events/theme-progress-event';
import { UserUpdateEvent } from '../_models/events/user-update-event';
import { User } from '../_models/user';
import {DashboardUpdateEvent} from "../_models/events/dashboard-update-event";
export enum EVENTS {
UpdateAvailable = 'UpdateAvailable',
@ -82,6 +83,10 @@ export enum EVENTS {
* A scrobbling token has expired
*/
ScrobblingKeyExpired = 'ScrobblingKeyExpired',
/**
* User's dashboard needs to be re-rendered
*/
DashboardUpdate = 'DashboardUpdate'
}
export interface Message<T> {
@ -109,7 +114,6 @@ export class MessageHubService {
*/
public onlineUsers$ = this.onlineUsersSource.asObservable();
isAdmin: boolean = false;
constructor() {}
@ -181,6 +185,13 @@ export class MessageHubService {
});
});
this.hubConnection.on(EVENTS.DashboardUpdate, resp => {
console.log('dashboard update event came in')
this.messagesSource.next({
event: EVENTS.DashboardUpdate,
payload: resp.body as DashboardUpdateEvent
});
});
this.hubConnection.on(EVENTS.NotificationProgress, (resp: NotificationProgressEvent) => {
this.messagesSource.next({

View File

@ -23,14 +23,16 @@
</div>
<div class="card-footer bg-transparent text-muted">
<ng-container *ngIf="isMyReview; else normalReview">
<i class="d-md-none fa-solid fa-star me-1" aria-hidden="true" [title]="t('your-review')"></i>
</ng-container>
<ng-template #normalReview>
<img class="me-1" [ngSrc]="review.provider | providerImage" width="20" height="20" alt="">
</ng-template>
{{(isMyReview ? '' : review.username | defaultValue:'')}}
<span style="float: right" *ngIf="review.isExternal">{{t('rating-percentage', {r: review.score})}}</span>
<div class="review-user">
<ng-container *ngIf="isMyReview; else normalReview">
<i class="d-md-none fa-solid fa-star me-1" aria-hidden="true" [title]="t('your-review')"></i>
</ng-container>
<ng-template #normalReview>
<img class="me-1" [ngSrc]="review.provider | providerImage" width="20" height="20" alt="">
</ng-template>
{{(isMyReview ? '' : review.username | defaultValue:'')}}
</div>
<span class="review-score" *ngIf="review.isExternal">{{t('rating-percentage', {r: review.score})}}</span>
</div>
</div>
</div>

View File

@ -41,5 +41,9 @@
}
.card-footer {
font-size: 13px
font-size: 13px;
display: flex;
max-width: 305px;
justify-content: space-between;
margin: 0 auto;
}

View File

@ -1,36 +1,86 @@
<app-side-nav-companion-bar></app-side-nav-companion-bar>
<ng-container *transloco="let t; read: 'dashboard'">
<ng-container *ngIf="libraries$ | async as libraries">
<ng-container *ngIf="libraries.length === 0 && !isLoading">
<div class="mt-3" *ngIf="isAdmin$ | async as isAdmin">
<div *ngIf="isAdmin" class="d-flex justify-content-center">
<p>{{t('no-libraries')}} <a routerLink="/admin/dashboard" fragment="libraries">{{t('server-settings-link')}}</a>.</p>
</div>
<div *ngIf="!isAdmin" class="d-flex justify-content-center">
<p>{{t('not-granted')}}</p>
</div>
</div>
</ng-container>
<ng-container *ngIf="libraries$ | async as libraries">
<ng-container *ngIf="libraries.length === 0 && !isLoadingAdmin">
<div class="mt-3" *ngIf="isAdmin$ | async as isAdmin">
<div *ngIf="isAdmin" class="d-flex justify-content-center">
<p>{{t('no-libraries')}} <a routerLink="/admin/dashboard" fragment="libraries">{{t('server-settings-link')}}</a>.</p>
</div>
<div *ngIf="!isAdmin" class="d-flex justify-content-center">
<p>{{t('not-granted')}}</p>
</div>
</div>
</ng-container>
</ng-container>
<ng-container *ngFor="let stream of streams">
<ng-container [ngSwitch]="stream.streamType">
<ng-container *ngSwitchCase="StreamType.OnDeck" [ngTemplateOutlet]="onDeck" [ngTemplateOutletContext]="{ stream: stream }"></ng-container>
<ng-container *ngSwitchCase="StreamType.RecentlyUpdated" [ngTemplateOutlet]="recentlyUpdated" [ngTemplateOutletContext]="{ stream: stream }"></ng-container>
<ng-container *ngSwitchCase="StreamType.NewlyAdded" [ngTemplateOutlet]="newlyUpdated" [ngTemplateOutletContext]="{ stream: stream }"></ng-container>
<ng-container *ngSwitchCase="StreamType.SmartFilter" [ngTemplateOutlet]="smartFilter" [ngTemplateOutletContext]="{ stream: stream }"></ng-container>
<ng-container *ngSwitchCase="StreamType.MoreInGenre" [ngTemplateOutlet]="moreInGenre" [ngTemplateOutletContext]="{ stream: stream }"></ng-container>
</ng-container>
<app-carousel-reel [items]="inProgress" [title]="t('on-deck-title')" (sectionClick)="handleSectionClick('on deck')">
<ng-template #carouselItem let-item>
<ng-template #smartFilter let-stream: DashboardStream>
<ng-container *ngIf="(stream.api | async) as data">
<app-carousel-reel [items]="data" [title]="stream.name" (sectionClick)="handleFilterSectionClick(stream)">
<ng-template #carouselItem let-item>
<app-series-card [data]="item" [libraryId]="item.libraryId" [suppressLibraryLink]="libraryId !== 0" [isOnDeck]="false"
(reload)="reloadStream(item.id)" (dataChanged)="reloadStream(item.id)"></app-series-card>
</ng-template>
</app-carousel-reel>
</ng-container>
</ng-template>
<ng-template #onDeck let-stream: DashboardStream>
<ng-container *ngIf="(stream.api | async) as data">
<app-carousel-reel [items]="data" [title]="t('on-deck-title')" (sectionClick)="handleSectionClick('on deck')">
<ng-template #carouselItem let-item>
<app-series-card [data]="item" [libraryId]="item.libraryId" [suppressLibraryLink]="libraryId !== 0" [isOnDeck]="true"
(reload)="reloadInProgress($event)" (dataChanged)="reloadInProgress($event)"></app-series-card>
</ng-template>
</app-carousel-reel>
(reload)="reloadStream(stream.id)" (dataChanged)="reloadStream(stream.id)"></app-series-card>
</ng-template>
</app-carousel-reel>
</ng-container>
</ng-template>
<app-carousel-reel [items]="recentlyUpdatedSeries" [title]="t('recently-updated-title')" (sectionClick)="handleSectionClick('recently updated series')">
<ng-template #carouselItem let-item>
<ng-template #recentlyUpdated let-stream: DashboardStream>
<ng-container *ngIf="(stream.api | async) as data">
<app-carousel-reel [items]="data" [title]="t('recently-updated-title')" (sectionClick)="handleSectionClick('recently updated series')">
<ng-template #carouselItem let-item>
<app-card-item [entity]="item" [title]="item.seriesName" [suppressLibraryLink]="libraryId !== 0" [imageUrl]="imageService.getSeriesCoverImage(item.seriesId)"
[suppressArchiveWarning]="true" (clicked)="handleRecentlyAddedChapterClick(item)" [count]="item.count"></app-card-item>
</ng-template>
</app-carousel-reel>
[suppressArchiveWarning]="true" (clicked)="handleRecentlyAddedChapterClick(item)" [count]="item.count"></app-card-item>
</ng-template>
</app-carousel-reel>
</ng-container>
</ng-template>
<ng-template #newlyUpdated let-stream: DashboardStream>
<ng-container *ngIf="(stream.api | async) as data">
<app-carousel-reel [items]="data" [title]="t('recently-added-title')" (sectionClick)="handleSectionClick('newly added series')">
<ng-template #carouselItem let-item>
<app-series-card [data]="item" [libraryId]="item.libraryId" [suppressLibraryLink]="libraryId !== 0" (dataChanged)="reloadStream(stream.id)"></app-series-card>
</ng-template>
</app-carousel-reel>
</ng-container>
</ng-template>
<ng-template #moreInGenre let-stream: DashboardStream>
<ng-container *ngIf="(stream.api | async) as data">
<app-carousel-reel [items]="data" [title]="t('more-in-genre-title', {genre: genre?.title})" (sectionClick)="handleSectionClick('more in genre')">
<ng-template #carouselItem let-item>
<app-series-card [data]="item" [libraryId]="item.libraryId" [suppressLibraryLink]="libraryId !== 0" (dataChanged)="reloadStream(stream.id)"></app-series-card>
</ng-template>
</app-carousel-reel>
</ng-container>
</ng-template>
<app-carousel-reel [items]="recentlyAddedSeries" [title]="t('recently-added-title')" (sectionClick)="handleSectionClick('newly added series')">
<ng-template #carouselItem let-item>
<app-series-card [data]="item" [libraryId]="item.libraryId" [suppressLibraryLink]="libraryId !== 0" (dataChanged)="loadRecentlyAddedSeries()"></app-series-card>
</ng-template>
</app-carousel-reel>
</ng-container>
<app-loading [loading]="isLoadingDashboard"></app-loading>
</ng-container>

View File

@ -1,16 +1,13 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, Input, OnInit} from '@angular/core';
import {Title} from '@angular/platform-browser';
import {Router, RouterLink} from '@angular/router';
import {Observable, of, ReplaySubject} from 'rxjs';
import {debounceTime, map, shareReplay, take, tap} from 'rxjs/operators';
import {Observable, of, ReplaySubject, Subject, switchMap} from 'rxjs';
import {map, shareReplay, take, tap, throttleTime} from 'rxjs/operators';
import {FilterUtilitiesService} from 'src/app/shared/_services/filter-utilities.service';
import {SeriesAddedEvent} from 'src/app/_models/events/series-added-event';
import {SeriesRemovedEvent} from 'src/app/_models/events/series-removed-event';
import {Library} from 'src/app/_models/library';
import {RecentlyAddedItem} from 'src/app/_models/recently-added-item';
import {Series} from 'src/app/_models/series';
import {SortField} from 'src/app/_models/metadata/series-filter';
import {SeriesGroup} from 'src/app/_models/series-group';
import {AccountService} from 'src/app/_services/account.service';
import {ImageService} from 'src/app/_services/image.service';
import {LibraryService} from 'src/app/_services/library.service';
@ -20,13 +17,21 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {CardItemComponent} from '../../cards/card-item/card-item.component';
import {SeriesCardComponent} from '../../cards/series-card/series-card.component';
import {CarouselReelComponent} from '../../carousel/_components/carousel-reel/carousel-reel.component';
import {AsyncPipe, NgIf} from '@angular/common';
import {AsyncPipe, NgForOf, NgIf, NgSwitch, NgSwitchCase, NgTemplateOutlet} from '@angular/common';
import {
SideNavCompanionBarComponent
} from '../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
import {TranslocoDirective} from "@ngneat/transloco";
import {translate, TranslocoDirective} from "@ngneat/transloco";
import {FilterField} from "../../_models/metadata/v2/filter-field";
import {FilterComparison} from "../../_models/metadata/v2/filter-comparison";
import {DashboardService} from "../../_services/dashboard.service";
import {MetadataService} from "../../_services/metadata.service";
import {RecommendationService} from "../../_services/recommendation.service";
import {Genre} from "../../_models/metadata/genre";
import {DashboardStream} from "../../_models/dashboard/dashboard-stream";
import {StreamType} from "../../_models/dashboard/stream-type.enum";
import {SeriesRemovedEvent} from "../../_models/events/series-removed-event";
import {LoadingComponent} from "../../shared/loading/loading.component";
@Component({
selector: 'app-dashboard',
@ -34,7 +39,8 @@ import {FilterComparison} from "../../_models/metadata/v2/filter-comparison";
styleUrls: ['./dashboard.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [SideNavCompanionBarComponent, NgIf, RouterLink, CarouselReelComponent, SeriesCardComponent, CardItemComponent, AsyncPipe, TranslocoDirective]
imports: [SideNavCompanionBarComponent, NgIf, RouterLink, CarouselReelComponent, SeriesCardComponent,
CardItemComponent, AsyncPipe, TranslocoDirective, NgSwitchCase, NgSwitch, NgForOf, NgTemplateOutlet, LoadingComponent]
})
export class DashboardComponent implements OnInit {
@ -44,13 +50,14 @@ export class DashboardComponent implements OnInit {
@Input() libraryId: number = 0;
libraries$: Observable<Library[]> = of([]);
isLoading = true;
isLoadingAdmin = true;
isLoadingDashboard = true;
isAdmin$: Observable<boolean> = of(false);
recentlyUpdatedSeries: SeriesGroup[] = [];
inProgress: Series[] = [];
recentlyAddedSeries: Series[] = [];
streams: Array<DashboardStream> = [];
genre: Genre | undefined;
refreshStreams$ = new Subject<void>();
/**
* We use this Replay subject to slow the amount of times we reload the UI
@ -58,112 +65,133 @@ export class DashboardComponent implements OnInit {
private loadRecentlyAdded$: ReplaySubject<void> = new ReplaySubject<void>();
private readonly destroyRef = inject(DestroyRef);
private readonly filterUtilityService = inject(FilterUtilitiesService);
private readonly metadataService = inject(MetadataService);
private readonly recommendationService = inject(RecommendationService);
protected readonly StreamType = StreamType;
constructor(public accountService: AccountService, private libraryService: LibraryService,
private seriesService: SeriesService, private router: Router,
private titleService: Title, public imageService: ImageService,
private messageHub: MessageHubService, private readonly cdRef: ChangeDetectorRef) {
this.messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(res => {
if (res.event === EVENTS.SeriesAdded) {
const seriesAddedEvent = res.payload as SeriesAddedEvent;
private messageHub: MessageHubService, private readonly cdRef: ChangeDetectorRef,
private dashboardService: DashboardService) {
this.seriesService.getSeries(seriesAddedEvent.seriesId).subscribe(series => {
if (this.recentlyAddedSeries.filter(s => s.id === series.id).length > 0) return;
this.recentlyAddedSeries = [series, ...this.recentlyAddedSeries];
this.cdRef.markForCheck();
});
} else if (res.event === EVENTS.SeriesRemoved) {
const seriesRemovedEvent = res.payload as SeriesRemovedEvent;
this.loadDashboard();
this.inProgress = this.inProgress.filter(item => item.id != seriesRemovedEvent.seriesId);
this.recentlyAddedSeries = this.recentlyAddedSeries.filter(item => item.id != seriesRemovedEvent.seriesId);
this.recentlyUpdatedSeries = this.recentlyUpdatedSeries.filter(item => item.seriesId != seriesRemovedEvent.seriesId);
this.cdRef.markForCheck();
} else if (res.event === EVENTS.ScanSeries) {
// We don't have events for when series are updated, but we do get events when a scan update occurs. Refresh recentlyAdded at that time.
this.loadRecentlyAdded$.next();
}
});
this.refreshStreams$.pipe(takeUntilDestroyed(this.destroyRef), throttleTime(10_000),
tap(() => {
this.loadDashboard()
}))
.subscribe();
this.isAdmin$ = this.accountService.currentUser$.pipe(
takeUntilDestroyed(this.destroyRef),
map(user => (user && this.accountService.hasAdminRole(user)) || false),
shareReplay()
);
this.loadRecentlyAdded$.pipe(debounceTime(1000), takeUntilDestroyed(this.destroyRef)).subscribe(() => {
this.loadRecentlyUpdated();
this.loadRecentlyAddedSeries();
this.cdRef.markForCheck();
});
// TODO: Solve how Websockets will work with these dyanamic streams
this.messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(res => {
if (res.event === EVENTS.DashboardUpdate) {
console.log('dashboard update triggered')
this.refreshStreams$.next();
} else if (res.event === EVENTS.SeriesAdded) {
// const seriesAddedEvent = res.payload as SeriesAddedEvent;
// this.seriesService.getSeries(seriesAddedEvent.seriesId).subscribe(series => {
// if (this.recentlyAddedSeries.filter(s => s.id === series.id).length > 0) return;
// this.recentlyAddedSeries = [series, ...this.recentlyAddedSeries];
// this.cdRef.markForCheck();
// });
this.refreshStreams$.next();
} else if (res.event === EVENTS.SeriesRemoved) {
//const seriesRemovedEvent = res.payload as SeriesRemovedEvent;
//
// this.inProgress = this.inProgress.filter(item => item.id != seriesRemovedEvent.seriesId);
// this.recentlyAddedSeries = this.recentlyAddedSeries.filter(item => item.id != seriesRemovedEvent.seriesId);
// this.recentlyUpdatedSeries = this.recentlyUpdatedSeries.filter(item => item.seriesId != seriesRemovedEvent.seriesId);
// this.cdRef.markForCheck();
this.refreshStreams$.next();
} else if (res.event === EVENTS.ScanSeries) {
// We don't have events for when series are updated, but we do get events when a scan update occurs. Refresh recentlyAdded at that time.
this.loadRecentlyAdded$.next();
this.refreshStreams$.next();
}
});
this.isAdmin$ = this.accountService.currentUser$.pipe(
takeUntilDestroyed(this.destroyRef),
map(user => (user && this.accountService.hasAdminRole(user)) || false),
shareReplay({bufferSize: 1, refCount: true})
);
}
ngOnInit(): void {
this.titleService.setTitle('Kavita - Dashboard');
this.isLoading = true;
this.titleService.setTitle('Kavita');
this.isLoadingAdmin = true;
this.cdRef.markForCheck();
this.libraries$ = this.libraryService.getLibraries().pipe(take(1), takeUntilDestroyed(this.destroyRef), tap((libs) => {
this.isLoading = false;
this.isLoadingAdmin = false;
this.cdRef.markForCheck();
}));
this.reloadSeries();
}
reloadSeries() {
this.loadOnDeck();
this.loadRecentlyUpdated();
this.loadRecentlyAddedSeries();
}
reloadInProgress(series: Series | number) {
this.loadOnDeck();
}
loadOnDeck() {
let api = this.seriesService.getOnDeck(0, 1, 30);
if (this.libraryId > 0) {
api = this.seriesService.getOnDeck(this.libraryId, 1, 30);
}
api.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((updatedSeries) => {
this.inProgress = updatedSeries.result;
this.cdRef.markForCheck();
});
}
loadRecentlyAddedSeries() {
let api = this.seriesService.getRecentlyAdded(1, 30);
if (this.libraryId > 0) {
const filter = this.filterUtilityService.createSeriesV2Filter();
filter.statements.push({field: FilterField.Libraries, value: this.libraryId + '', comparison: FilterComparison.Equal});
api = this.seriesService.getRecentlyAdded(1, 30, filter);
}
api.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((updatedSeries) => {
this.recentlyAddedSeries = updatedSeries.result;
this.cdRef.markForCheck();
});
}
loadRecentlyUpdated() {
let api = this.seriesService.getRecentlyUpdatedSeries();
if (this.libraryId > 0) {
api = this.seriesService.getRecentlyUpdatedSeries();
}
api.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(updatedSeries => {
this.recentlyUpdatedSeries = updatedSeries.filter(group => {
if (this.libraryId === 0) return true;
return group.libraryId === this.libraryId;
loadDashboard() {
this.isLoadingDashboard = true;
this.cdRef.markForCheck();
this.dashboardService.getDashboardStreams().subscribe(streams => {
this.streams = streams;
this.streams.forEach(s => {
switch (s.streamType) {
case StreamType.OnDeck:
s.api = this.seriesService.getOnDeck(0, 1, 20)
.pipe(map(d => d.result), takeUntilDestroyed(this.destroyRef), shareReplay({bufferSize: 1, refCount: true}));
break;
case StreamType.NewlyAdded:
s.api = this.seriesService.getRecentlyAdded(1, 20)
.pipe(map(d => d.result), takeUntilDestroyed(this.destroyRef), shareReplay({bufferSize: 1, refCount: true}));
break;
case StreamType.RecentlyUpdated:
s.api = this.seriesService.getRecentlyUpdatedSeries();
break;
case StreamType.SmartFilter:
s.api = this.seriesService.getAllSeriesV2(0, 20, this.filterUtilityService.decodeSeriesFilter(s.smartFilterEncoded!))
.pipe(map(d => d.result), takeUntilDestroyed(this.destroyRef), shareReplay({bufferSize: 1, refCount: true}));
break;
case StreamType.MoreInGenre:
s.api = this.metadataService.getAllGenres().pipe(
map(genres => {
this.genre = genres[Math.floor(Math.random() * genres.length)];
return this.genre;
}),
switchMap(genre => this.recommendationService.getMoreIn(0, genre.id, 0, 30)),
map(p => p.result),
takeUntilDestroyed(this.destroyRef),
shareReplay({bufferSize: 1, refCount: true})
);
break;
}
});
this.isLoadingDashboard = false;
this.cdRef.markForCheck();
});
}
handleRecentlyAddedChapterClick(item: RecentlyAddedItem) {
this.router.navigate(['library', item.libraryId, 'series', item.seriesId]);
reloadStream(streamId: number) {
const index = this.streams.findIndex(s => s.id === streamId);
if (index < 0) return;
this.streams[index] = {...this.streams[index]};
console.log('swapped out stream: ', this.streams[index]);
this.cdRef.detectChanges();
}
async handleRecentlyAddedChapterClick(item: RecentlyAddedItem) {
await this.router.navigate(['library', item.libraryId, 'series', item.seriesId]);
}
async handleFilterSectionClick(stream: DashboardStream) {
await this.router.navigateByUrl('all-series?' + stream.smartFilterEncoded);
}
handleSectionClick(sectionTitle: string) {
@ -180,7 +208,7 @@ export class DashboardComponent implements OnInit {
} else if (sectionTitle.toLowerCase() === 'on deck') {
const params: any = {};
params['page'] = 1;
params['title'] = 'On Deck';
params['title'] = translate('dashboard.on-deck-title');
const filter = this.filterUtilityService.createSeriesV2Filter();
filter.statements.push({field: FilterField.ReadProgress, comparison: FilterComparison.GreaterThan, value: '0'});
@ -190,16 +218,23 @@ export class DashboardComponent implements OnInit {
filter.sortOptions.isAscending = false;
}
this.filterUtilityService.applyFilterWithParams(['all-series'], filter, params)
}else if (sectionTitle.toLowerCase() === 'newly added series') {
} else if (sectionTitle.toLowerCase() === 'newly added series') {
const params: any = {};
params['page'] = 1;
params['title'] = 'Newly Added';
params['title'] = translate('dashboard.recently-added-title');
const filter = this.filterUtilityService.createSeriesV2Filter();
if (filter.sortOptions) {
filter.sortOptions.sortField = SortField.Created;
filter.sortOptions.isAscending = false;
}
this.filterUtilityService.applyFilterWithParams(['all-series'], filter, params)
} else if (sectionTitle.toLowerCase() === 'more in genre') {
const params: any = {};
params['page'] = 1;
params['title'] = translate('more-in-genre-title', {genre: this.genre?.title});
const filter = this.filterUtilityService.createSeriesV2Filter();
filter.statements.push({field: FilterField.Genres, value: this.genre?.id + '', comparison: FilterComparison.MustContains});
this.filterUtilityService.applyFilterWithParams(['all-series'], filter, params)
}
}

View File

@ -4,45 +4,26 @@
<app-card-actionables [actions]="actions" (actionHandler)="performAction($event)"></app-card-actionables>
<span>{{libraryName}}</span>
</h2>
<div main>
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav nav-pills" style="flex-wrap: nowrap;">
<li *ngFor="let tab of tabs" [ngbNavItem]="tab">
<a ngbNavLink>
<span class="d-none d-sm-flex align-items-center"><i class="fa {{tab.icon}} me-1" style="padding-right: 5px;" aria-hidden="true"></i> {{t('library-detail.' + tab.title) | sentenceCase}}</span>
<span class="d-flex d-sm-none">
<i class="fa {{tab.icon}}" aria-hidden="true"></i>
</span>
</a>
<ng-template ngbNavContent>
<ng-container *ngIf="tab.title === 'recommended-tab'">
<app-library-recommended [libraryId]="libraryId"></app-library-recommended>
</ng-container>
<ng-container *ngIf="tab.title === 'library-tab'">
<app-card-detail-layout
[isLoading]="loadingSeries"
[items]="series"
[pagination]="pagination"
[filterSettings]="filterSettings"
[trackByIdentity]="trackByIdentity"
[filterOpen]="filterOpen"
[jumpBarKeys]="jumpKeys"
[refresh]="refresh"
(applyFilter)="updateFilter($event)"
>
<ng-template #cardItem let-item let-position="idx">
<app-series-card [data]="item" [libraryId]="libraryId" [suppressLibraryLink]="true" (reload)="loadPage()"
(selection)="bulkSelectionService.handleCardSelection('series', position, series.length, $event)"
[selected]="bulkSelectionService.isCardSelected('series', position)" [allowSelection]="true"></app-series-card>
</ng-template>
</app-card-detail-layout>
</ng-container>
</ng-template>
</li>
</ul>
</div>
<h6 subtitle class="subtitle-with-actionables" *ngIf="active.fragment === ''">{{t('common.series-count', {num: pagination.totalItems | number})}} </h6>
</app-side-nav-companion-bar>
<h6 subtitle class="subtitle-with-actionables" *ngIf="active.fragment === ''">{{t('common.series-count', {num: pagination.totalItems | number})}} </h6>
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
<div [ngbNavOutlet]="nav"></div>
<app-card-detail-layout
[isLoading]="loadingSeries"
[items]="series"
[pagination]="pagination"
[filterSettings]="filterSettings"
[trackByIdentity]="trackByIdentity"
[filterOpen]="filterOpen"
[jumpBarKeys]="jumpKeys"
[refresh]="refresh"
(applyFilter)="updateFilter($event)"
>
<ng-template #cardItem let-item let-position="idx">
<app-series-card [data]="item" [libraryId]="libraryId" [suppressLibraryLink]="true" (reload)="loadPage()"
(selection)="bulkSelectionService.handleCardSelection('series', position, series.length, $event)"
[selected]="bulkSelectionService.isCardSelected('series', position)" [allowSelection]="true"></app-series-card>
</ng-template>
</app-card-detail-layout>
</ng-container>

View File

@ -33,7 +33,6 @@ import {SentenceCasePipe} from '../pipe/sentence-case.pipe';
import {BulkOperationsComponent} from '../cards/bulk-operations/bulk-operations.component';
import {SeriesCardComponent} from '../cards/series-card/series-card.component';
import {CardDetailLayoutComponent} from '../cards/card-detail-layout/card-detail-layout.component';
import {LibraryRecommendedComponent} from './library-recommended/library-recommended.component';
import {DecimalPipe, NgFor, NgIf} from '@angular/common';
import {NgbNav, NgbNavContent, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavOutlet} from '@ng-bootstrap/ng-bootstrap';
import {
@ -52,7 +51,8 @@ import {CardActionablesComponent} from "../_single-module/card-actionables/card-
styleUrls: ['./library-detail.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [SideNavCompanionBarComponent, CardActionablesComponent, NgbNav, NgFor, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavContent, NgIf, LibraryRecommendedComponent, CardDetailLayoutComponent, SeriesCardComponent, BulkOperationsComponent, NgbNavOutlet, DecimalPipe, SentenceCasePipe, TranslocoDirective]
imports: [SideNavCompanionBarComponent, CardActionablesComponent, NgbNav, NgFor, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavContent, NgIf
, CardDetailLayoutComponent, SeriesCardComponent, BulkOperationsComponent, NgbNavOutlet, DecimalPipe, SentenceCasePipe, TranslocoDirective]
})
export class LibraryDetailComponent implements OnInit {
@ -284,9 +284,5 @@ export class LibraryDetailComponent implements OnInit {
});
}
seriesClicked(series: Series) {
this.router.navigate(['library', this.libraryId, 'series', series.id]);
}
trackByIdentity = (index: number, item: Series) => `${item.id}_${item.name}_${item.localizedName}_${item.pagesRead}`;
}

View File

@ -3,7 +3,6 @@ import { CommonModule } from '@angular/common';
import { LibraryDetailComponent } from './library-detail.component';
import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
import { LibraryDetailRoutingModule } from './library-detail-routing.module';
import { LibraryRecommendedComponent } from './library-recommended/library-recommended.component';
import {SentenceCasePipe} from "../pipe/sentence-case.pipe";
import {CardDetailLayoutComponent} from "../cards/card-detail-layout/card-detail-layout.component";
@ -27,7 +26,7 @@ import {CardActionablesComponent} from "../_single-module/card-actionables/card-
SeriesCardComponent,
BulkOperationsComponent,
SideNavCompanionBarComponent,
LibraryDetailComponent, LibraryRecommendedComponent
LibraryDetailComponent,
]
})
export class LibraryDetailModule { }

View File

@ -1,61 +0,0 @@
<ng-container *transloco="let t; read: 'library-recommended'">
<ng-container *ngIf="all$ | async as all">
<p *ngIf="all.length === 0">
{{t('no-data')}}
</p>
</ng-container>
<ng-container *ngIf="onDeck$ | async as onDeck">
<app-carousel-reel [items]="onDeck" [title]="t('on-deck')">
<ng-template #carouselItem let-item let-position="idx">
<app-series-card [data]="item" [libraryId]="item.libraryId" [suppressLibraryLink]="libraryId !== 0" (reload)="reloadInProgress($event)" (dataChanged)="reloadInProgress($event)"></app-series-card>
</ng-template>
</app-carousel-reel>
</ng-container>
<ng-container *ngIf="quickReads$ | async as quickReads">
<app-carousel-reel [items]="quickReads" [title]="t('quick-reads')">
<ng-template #carouselItem let-item let-position="idx">
<app-series-card [data]="item" [libraryId]="item.libraryId" [suppressLibraryLink]="libraryId !== 0" (reload)="reloadInProgress($event)" (dataChanged)="reloadInProgress($event)"></app-series-card>
</ng-template>
</app-carousel-reel>
</ng-container>
<ng-container *ngIf="quickCatchups$ | async as quickCatchups">
<app-carousel-reel [items]="quickCatchups" [title]="t('quick-catchups')">
<ng-template #carouselItem let-item let-position="idx">
<app-series-card [data]="item" [libraryId]="item.libraryId" [suppressLibraryLink]="libraryId !== 0" (reload)="reloadInProgress($event)" (dataChanged)="reloadInProgress($event)"></app-series-card>
</ng-template>
</app-carousel-reel>
</ng-container>
<ng-container *ngIf="highlyRated$ | async as highlyRated">
<app-carousel-reel [items]="highlyRated" [title]="t('highly-rated')">
<ng-template #carouselItem let-item let-position="idx">
<app-series-card [data]="item" [libraryId]="item.libraryId" [suppressLibraryLink]="libraryId !== 0" (reload)="reloadInProgress($event)" (dataChanged)="reloadInProgress($event)"></app-series-card>
</ng-template>
</app-carousel-reel>
</ng-container>
<ng-container *ngIf="rediscover$ | async as rediscover">
<app-carousel-reel [items]="rediscover" [title]="t('rediscover')">
<ng-template #carouselItem let-item let-position="idx">
<app-series-card [data]="item" [libraryId]="item.libraryId" [suppressLibraryLink]="libraryId !== 0" (reload)="reloadInProgress($event)" (dataChanged)="reloadInProgress($event)"></app-series-card>
</ng-template>
</app-carousel-reel>
</ng-container>
<ng-container *ngIf="genre$ | async as genre">
<ng-container *ngIf="moreIn$ | async as moreIn">
<ng-container *ngIf="moreIn.length > 1">
<app-carousel-reel [items]="moreIn" [title]="t('more-in-genre', {genre: genre.title})">
<ng-template #carouselItem let-item let-position="idx">
<app-series-card [data]="item" [libraryId]="item.libraryId" [suppressLibraryLink]="libraryId !== 0" (reload)="reloadInProgress($event)" (dataChanged)="reloadInProgress($event)"></app-series-card>
</ng-template>
</app-carousel-reel>
</ng-container>
</ng-container>
</ng-container>
</ng-container>

View File

@ -1,91 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
inject,
Input,
OnInit
} from '@angular/core';
import { filter, map, merge, Observable, shareReplay } from 'rxjs';
import { Genre } from 'src/app/_models/metadata/genre';
import { Series } from 'src/app/_models/series';
import { MetadataService } from 'src/app/_services/metadata.service';
import { RecommendationService } from 'src/app/_services/recommendation.service';
import { SeriesService } from 'src/app/_services/series.service';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import { SeriesCardComponent } from '../../cards/series-card/series-card.component';
import { CarouselReelComponent } from '../../carousel/_components/carousel-reel/carousel-reel.component';
import { NgIf, AsyncPipe } from '@angular/common';
import {TranslocoDirective} from "@ngneat/transloco";
@Component({
selector: 'app-library-recommended',
templateUrl: './library-recommended.component.html',
styleUrls: ['./library-recommended.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [NgIf, CarouselReelComponent, SeriesCardComponent, AsyncPipe, TranslocoDirective]
})
export class LibraryRecommendedComponent implements OnInit {
@Input() libraryId: number = 0;
private readonly destroyRef = inject(DestroyRef);
quickReads$!: Observable<Series[]>;
quickCatchups$!: Observable<Series[]>;
highlyRated$!: Observable<Series[]>;
onDeck$!: Observable<Series[]>;
rediscover$!: Observable<Series[]>;
moreIn$!: Observable<Series[]>;
genre$!: Observable<Genre>;
all$!: Observable<any>;
constructor(private recommendationService: RecommendationService, private seriesService: SeriesService,
private metadataService: MetadataService) { }
ngOnInit(): void {
this.quickReads$ = this.recommendationService.getQuickReads(this.libraryId, 0, 30)
.pipe(takeUntilDestroyed(this.destroyRef), map(p => p.result), shareReplay());
this.quickCatchups$ = this.recommendationService.getQuickCatchupReads(this.libraryId, 0, 30)
.pipe(takeUntilDestroyed(this.destroyRef), map(p => p.result), shareReplay());
this.highlyRated$ = this.recommendationService.getHighlyRated(this.libraryId, 0, 30)
.pipe(takeUntilDestroyed(this.destroyRef), map(p => p.result), shareReplay());
this.rediscover$ = this.recommendationService.getRediscover(this.libraryId, 0, 30)
.pipe(takeUntilDestroyed(this.destroyRef), map(p => p.result), shareReplay());
this.onDeck$ = this.seriesService.getOnDeck(this.libraryId, 0, 30)
.pipe(takeUntilDestroyed(this.destroyRef), map(p => p.result), shareReplay());
this.genre$ = this.metadataService.getAllGenres([this.libraryId]).pipe(
takeUntilDestroyed(this.destroyRef),
map(genres => genres[Math.floor(Math.random() * genres.length)]),
shareReplay()
);
this.genre$.subscribe(genre => {
this.moreIn$ = this.recommendationService.getMoreIn(this.libraryId, genre.id, 0, 30).pipe(takeUntilDestroyed(this.destroyRef), map(p => p.result), shareReplay());
});
this.all$ = merge(this.quickReads$, this.quickCatchups$, this.highlyRated$, this.rediscover$, this.onDeck$, this.genre$).pipe(takeUntilDestroyed(this.destroyRef));
}
reloadInProgress(series: Series | number) {
if (Number.isInteger(series)) {
if (!series) {return;}
}
// If the update to Series doesn't affect the requirement to be in this stream, then ignore update request
const seriesObj = (series as Series);
if (seriesObj.pagesRead !== seriesObj.pages && seriesObj.pagesRead !== 0) {
return;
}
this.quickReads$ = this.quickReads$.pipe(filter(series => !series.includes(seriesObj)));
this.quickCatchups$ = this.quickCatchups$.pipe(filter(series => !series.includes(seriesObj)));
}
}

View File

@ -22,6 +22,9 @@
<ng-container *ngSwitchCase="PredicateType.Number">
<input type="number" inputmode="numeric" class="form-control me-2" formControlName="filterValue" min="0">
</ng-container>
<ng-container *ngSwitchCase="PredicateType.Boolean">
<input type="checkbox" class="form-check-input mt-2 me-2" style="font-size: 1.5rem" formControlName="filterValue">
</ng-container>
<ng-container *ngSwitchCase="PredicateType.Dropdown">
<ng-container *ngIf="dropdownOptions$ | async as opts">
<ng-container *ngTemplateOutlet="dropdown; context: { options: opts, multipleAllowed: MultipleDropdownAllowed }"></ng-container>

View File

@ -30,6 +30,7 @@ enum PredicateType {
Text = 1,
Number = 2,
Dropdown = 3,
Boolean = 4
}
const StringFields = [FilterField.SeriesName, FilterField.Summary, FilterField.Path, FilterField.FilePath];
@ -41,6 +42,7 @@ const DropdownFields = [FilterField.PublicationStatus, FilterField.Languages, Fi
FilterField.Writers, FilterField.Genres, FilterField.Libraries,
FilterField.Formats, FilterField.CollectionTags, FilterField.Tags
];
const BooleanFields = [FilterField.WantToRead]
const DropdownFieldsWithoutMustContains = [
FilterField.Libraries, FilterField.Formats, FilterField.AgeRating, FilterField.PublicationStatus
@ -69,6 +71,9 @@ const DropdownComparisons = [FilterComparison.Equal,
FilterComparison.Contains,
FilterComparison.NotContains,
FilterComparison.MustContains];
const BooleanComparisons = [
FilterComparison.Equal
]
@Component({
selector: 'app-metadata-row-filter',
@ -155,7 +160,11 @@ export class MetadataFilterRowComponent implements OnInit {
stmt.value = stmt.value + '';
}
if (!stmt.value && stmt.field !== FilterField.SeriesName) return;
if (typeof stmt.value === 'boolean') {
stmt.value = stmt.value + '';
}
if (!stmt.value && (stmt.field !== FilterField.SeriesName && !BooleanFields.includes(stmt.field))) return;
this.filterStatement.emit(stmt);
});
@ -172,6 +181,8 @@ export class MetadataFilterRowComponent implements OnInit {
if (StringFields.includes(this.preset.field)) {
this.formGroup.get('filterValue')?.patchValue(val);
} else if (BooleanFields.includes(this.preset.field)) {
this.formGroup.get('filterValue')?.patchValue(val);
} else if (DropdownFields.includes(this.preset.field)) {
if (this.MultipleDropdownAllowed || val.includes(',')) {
this.formGroup.get('filterValue')?.patchValue(val.split(',').map(d => parseInt(d, 10)));
@ -270,6 +281,16 @@ export class MetadataFilterRowComponent implements OnInit {
return;
}
if (BooleanFields.includes(inputVal)) {
this.validComparisons$.next(BooleanComparisons);
this.predicateType$.next(PredicateType.Boolean);
if (this.loaded) {
this.formGroup.get('filterValue')?.patchValue(false);
}
return;
}
if (DropdownFields.includes(inputVal)) {
let comps = [...DropdownComparisons];
if (DropdownFieldsThatIncludeNumberComparisons.includes(inputVal)) {

View File

@ -62,6 +62,8 @@ export class FilterFieldPipe implements PipeTransform {
return translate('filter-field-pipe.path');
case FilterField.FilePath:
return translate('filter-field-pipe.file-path');
case FilterField.WantToRead:
return translate('filter-field-pipe.want-to-read');
default:
throw new Error(`Invalid FilterField value: ${value}`);
}

View File

@ -7,4 +7,5 @@ export class FilterSettings {
* The number of statements that can be on the filter. Set to 1 to disable adding more.
*/
statementLimit: number = 0;
saveDisabled: boolean = false;
}

View File

@ -48,6 +48,11 @@
<option *ngFor="let field of allSortFields" [value]="field">{{field | sortField}}</option>
</select>
</div>
<div class="col-md-3 col-sm-10">
<label for="filter-name" class="form-label">{{t('filter-name-label')}}</label>
<input id="filter-name" type="text" class="form-control" formControlName="name">
</div>
<ng-container *ngIf="utilityService.getActiveBreakpoint() > Breakpoint.Tablet" [ngTemplateOutlet]="buttons"></ng-container>
</div>
<div class="row mb-3" *ngIf="utilityService.getActiveBreakpoint() <= Breakpoint.Tablet">
@ -58,12 +63,18 @@
</ng-template>
<ng-template #buttons>
<!-- TODO: I might want to put a Clear button which blanks out the whole filter -->
<div class="col-md-2 col-sm-6 mt-4">
<div class="col-md-1 col-sm-6 mt-4 pt-1">
<button class="btn btn-secondary col-12" (click)="clear()">{{t('reset')}}</button>
</div>
<div class="col-md-2 col-sm-6 mt-4">
<div class="col-md-1 col-sm-6 mt-4 pt-1">
<button class="btn btn-primary col-12" (click)="apply()">{{t('apply')}}</button>
</div>
<div class="col-md-1 col-sm-6 mt-4 pt-1">
<button class="btn btn-primary col-12" (click)="save()" [disabled]="filterSettings.saveDisabled || !this.sortGroup.get('name')?.value">
<!-- TODO: Icon here -->
{{t('save')}}
</button>
</div>
</ng-template>
</ng-container>

View File

@ -11,8 +11,7 @@ import {
Output
} from '@angular/core';
import {FormControl, FormGroup, FormsModule, ReactiveFormsModule} from '@angular/forms';
import {NgbCollapse, NgbRating, NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
import {FilterUtilitiesService} from '../shared/_services/filter-utilities.service';
import {NgbCollapse, NgbModal, NgbRating, NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
import {Breakpoint, UtilityService} from '../shared/_services/utility.service';
import {Library} from '../_models/library';
import {allSortFields, FilterEvent, FilterItem, SortField} from '../_models/metadata/series-filter';
@ -23,10 +22,14 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {TypeaheadComponent} from '../typeahead/_components/typeahead.component';
import {DrawerComponent} from '../shared/drawer/drawer.component';
import {AsyncPipe, NgForOf, NgIf, NgTemplateOutlet} from '@angular/common';
import {TranslocoModule} from "@ngneat/transloco";
import {translate, TranslocoModule} from "@ngneat/transloco";
import {SortFieldPipe} from "../pipe/sort-field.pipe";
import {MetadataBuilderComponent} from "./_components/metadata-builder/metadata-builder.component";
import {allFields} from "../_models/metadata/v2/filter-field";
import {MetadataService} from "../_services/metadata.service";
import {FilterUtilitiesService} from "../shared/_services/filter-utilities.service";
import {FilterService} from "../_services/filter.service";
import {ToastrService} from "ngx-toastr";
@Component({
selector: 'app-metadata-filter',
@ -81,9 +84,10 @@ export class MetadataFilterComponent implements OnInit {
private readonly cdRef = inject(ChangeDetectorRef);
private readonly toastr = inject(ToastrService);
constructor(public toggleService: ToggleService) {}
constructor(public toggleService: ToggleService, private filterService: FilterService) {}
ngOnInit(): void {
if (this.filterSettings === undefined) {
@ -141,7 +145,8 @@ export class MetadataFilterComponent implements OnInit {
this.sortGroup = new FormGroup({
sortField: new FormControl({value: this.filterV2?.sortOptions?.sortField || SortField.SortName, disabled: this.filterSettings.sortDisabled}, []),
limitTo: new FormControl(this.filterV2?.limitTo || 0, [])
limitTo: new FormControl(this.filterV2?.limitTo || 0, []),
name: new FormControl(this.filterV2?.name || '', [])
});
this.sortGroup.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
@ -153,6 +158,7 @@ export class MetadataFilterComponent implements OnInit {
}
this.filterV2!.sortOptions!.sortField = parseInt(this.sortGroup.get('sortField')?.value, 10);
this.filterV2!.limitTo = Math.max(parseInt(this.sortGroup.get('limitTo')?.value || '0', 10), 0);
this.filterV2!.name = this.sortGroup.get('name')?.value || '';
this.cdRef.markForCheck();
});
@ -190,6 +196,15 @@ export class MetadataFilterComponent implements OnInit {
this.cdRef.markForCheck();
}
save() {
if (!this.filterV2) return;
this.filterV2.name = this.sortGroup.get('name')?.value;
this.filterService.saveFilter(this.filterV2).subscribe(() => {
this.toastr.success(translate('toasts.smart-filter-updated'));
this.apply();
})
}
toggleSelected() {
this.toggleService.toggle();
this.cdRef.markForCheck();

View File

@ -41,7 +41,7 @@
<button class="btn btn-icon float-end" (click)="removeItem(item, i)" *ngIf="showRemoveButton">
<i class="fa fa-times" aria-hidden="true"></i>
<span class="visually-hidden" attr.aria-labelledby="item.id--{{i}}">{{t('remove-item')}}</span>
<span class="visually-hidden" attr.aria-labelledby="item.id--{{i}}">{{t('remove-item-alt')}}</span>
</button>
</div>
</div>

View File

@ -1,28 +1,27 @@
.example-list {
min-width: 500px;
width: 100%;
max-width: 100%;
min-height: 60px;
display: block;
border-radius: 4px;
overflow: hidden;
}
.example-box {
margin: 5px 0;
display: flex;
flex-direction: row;
box-sizing: border-box;
font-size: 14px;
max-height: 140px;
height: 140px;
.drag-handle {
cursor: move;
font-size: 24px;
// TODO: This needs to be calculation based
margin-top: 215%;
}
}
.cdk-drag-preview {
box-sizing: border-box;
border-radius: 4px;
@ -30,19 +29,20 @@
0 8px 10px 1px rgba(0, 0, 0, 0.14),
0 3px 14px 2px rgba(0, 0, 0, 0.12);
}
.cdk-drag-placeholder {
opacity: 0;
}
.cdk-drag-animating {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
.example-box:last-child {
border: none;
margin-bottom: 20px;
}
.example-list.cdk-drop-list-dragging .example-box:not(.cdk-drag-placeholder) {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
@ -70,4 +70,4 @@
virtual-scroller.empty {
display: none;
}
}

View File

@ -178,7 +178,7 @@ export class ReadingListDetailComponent implements OnInit {
orderUpdated(event: IndexUpdateEvent) {
if (!this.readingList) return;
this.readingListService.updatePosition(this.readingList.id, event.item.id, event.fromPosition, event.toPosition).subscribe(() => { /* No Operation */ });
this.readingListService.updatePosition(this.readingList.id, event.item.id, event.fromPosition, event.toPosition).subscribe();
}
itemRemoved(item: ReadingListItem, position: number) {

View File

@ -1,5 +1,5 @@
<ng-container *transloco="let t; read: 'reading-list-item'">
<div class="d-flex flex-row g-0 mb-2">
<div class="d-flex flex-row g-0 mb-2 reading-list-item">
<div class="pe-2">
<app-image width="106px" maxHeight="125px" class="img-top me-3" [imageUrl]="imageService.getChapterCoverImage(item.chapterId)"></app-image>
<ng-container *ngIf="item.pagesRead === 0 && item.pagesTotal > 0">

View File

@ -1,5 +1,10 @@
$image-height: 125px;
.reading-list-item {
max-height: 140px;
height: 140px;
}
.progress-banner {
height: 5px;
@ -9,12 +14,6 @@ $image-height: 125px;
}
}
.list-item-container {
background: var(--card-list-item-bg-color);
border-radius: 5px;
position: relative;
}
.badge-container {
border-radius: 4px;
display: block;
@ -34,4 +33,4 @@ $image-height: 125px;
border-style: solid;
border-width: 0 var(--card-progress-triangle-size) var(--card-progress-triangle-size) 0;
border-color: transparent var(--primary-color) transparent transparent;
}
}

View File

@ -107,6 +107,49 @@ export class FilterUtilitiesService {
}).join(','));
}
decodeSeriesFilter(encodedFilter: string) {
const filter = this.metadataService.createDefaultFilterDto();
if (encodedFilter.includes('name=')) {
filter.name = decodeURIComponent(encodedFilter).split('name=')[1].split('&')[0];
}
const stmtsStartIndex = encodedFilter.indexOf(statementsKey);
let endIndex = encodedFilter.indexOf('&' + sortOptionsKey);
if (endIndex < 0) {
endIndex = encodedFilter.indexOf('&' + limitToKey);
}
if (stmtsStartIndex !== -1 || endIndex !== -1) {
// +1 is for the =
const stmtsEncoded = encodedFilter.substring(stmtsStartIndex + statementsKey.length, endIndex);
filter.statements = this.decodeFilterStatements(stmtsEncoded);
}
if (encodedFilter.includes(sortOptionsKey)) {
const optionsStartIndex = encodedFilter.indexOf('&' + sortOptionsKey);
const endIndex = encodedFilter.indexOf('&' + limitToKey);
const sortOptionsEncoded = encodedFilter.substring(optionsStartIndex + sortOptionsKey.length + 1, endIndex);
const sortOptions = this.decodeSortOptions(sortOptionsEncoded);
if (sortOptions) {
filter.sortOptions = sortOptions;
}
}
if (encodedFilter.includes(limitToKey)) {
const limitTo = decodeURIComponent(encodedFilter).split(limitToKey)[1].split('&')[0];
filter.limitTo = parseInt(limitTo, 10);
}
if (encodedFilter.includes(combinationKey)) {
const combo = decodeURIComponent(encodedFilter).split(combinationKey)[1].split('&')[0];;
filter.combination = parseInt(combo, 10) as FilterCombination;
}
return filter;
}
filterPresetsFromUrlV2(snapshot: ActivatedRouteSnapshot): SeriesFilterV2 {
const filter = this.metadataService.createDefaultFilterDto();
if (!window.location.href.includes('?')) return filter;

View File

@ -2,4 +2,8 @@
width: 100%;
word-wrap: break-word;
white-space: pre-wrap;
}
}
img {
max-width: 100%;
}

View File

@ -0,0 +1,32 @@
<ng-container *transloco="let t; read: 'customize-dashboard-modal'">
<div class="modal-header">
<h4 class="modal-title">{{t('title')}}</h4>
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>
</div>
<div class="modal-body">
<app-draggable-ordered-list [items]="items" (orderUpdated)="orderUpdated($event)" [accessibilityMode]="accessibilityMode"
[showRemoveButton]="false">
<ng-template #draggableItem let-position="idx" let-item>
<app-stream-list-item [item]="item" [position]="position" (hide)="updateVisibility($event, position)"></app-stream-list-item>
</ng-template>
</app-draggable-ordered-list>
<h5>Smart Filters</h5>
<ul class="list-group filter-list">
<li class="filter list-group-item" *ngFor="let filter of smartFilters">
{{filter.name}}
<button class="btn btn-icon" (click)="addFilterToStream(filter)">
<i class="fa fa-plus" aria-hidden="true"></i>
Add
</button>
</li>
<li class="list-group-item" *ngIf="smartFilters.length === 0">
All Smart filters added to Dashboard or none created yet.
</li>
</ul>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" (click)="close()">{{t('close')}}</button>
</div>
</ng-container>

View File

@ -0,0 +1,24 @@
::ng-deep .drag-handle {
margin-top: 100% !important;
}
app-stream-list-item {
flex-grow: 1;
}
.filter-list {
margin: 0;
padding:0;
.filter {
padding: 0.5rem 1rem;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
border-radius: 5px;
margin: 5px 0;
color: var(--list-group-hover-text-color);
background-color: var(--list-group-hover-bg-color);
}
}

View File

@ -0,0 +1,72 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject} from '@angular/core';
import {CommonModule} from '@angular/common';
import {SafeHtmlPipe} from "../../../pipe/safe-html.pipe";
import {TranslocoDirective} from "@ngneat/transloco";
import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap";
import {
DraggableOrderedListComponent,
IndexUpdateEvent
} from "../../../reading-list/_components/draggable-ordered-list/draggable-ordered-list.component";
import {
ReadingListItemComponent
} from "../../../reading-list/_components/reading-list-item/reading-list-item.component";
import {forkJoin} from "rxjs";
import {FilterService} from "../../../_services/filter.service";
import {StreamListItemComponent} from "../stream-list-item/stream-list-item.component";
import {SmartFilter} from "../../../_models/metadata/v2/smart-filter";
import {DashboardService} from "../../../_services/dashboard.service";
import {DashboardStream} from "../../../_models/dashboard/dashboard-stream";
@Component({
selector: 'app-customize-dashboard-modal',
standalone: true,
imports: [CommonModule, SafeHtmlPipe, TranslocoDirective, DraggableOrderedListComponent, ReadingListItemComponent, StreamListItemComponent],
templateUrl: './customize-dashboard-modal.component.html',
styleUrls: ['./customize-dashboard-modal.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CustomizeDashboardModalComponent {
items: DashboardStream[] = [];
smartFilters: SmartFilter[] = [];
accessibilityMode: boolean = false;
private readonly dashboardService = inject(DashboardService);
private readonly filterService = inject(FilterService);
private readonly cdRef = inject(ChangeDetectorRef);
constructor(public modal: NgbActiveModal) {
forkJoin([this.dashboardService.getDashboardStreams(false), this.filterService.getAllFilters()]).subscribe(results => {
this.items = results[0];
const smartFilterStreams = new Set(results[0].filter(d => !d.isProvided).map(d => d.name));
this.smartFilters = results[1].filter(d => !smartFilterStreams.has(d.name));
this.cdRef.markForCheck();
});
}
addFilterToStream(filter: SmartFilter) {
this.dashboardService.createDashboardStream(filter.id).subscribe(stream => {
this.smartFilters = this.smartFilters.filter(d => d.name !== filter.name);
this.items.push(stream);
this.cdRef.detectChanges();
});
}
orderUpdated(event: IndexUpdateEvent) {
this.dashboardService.updateDashboardStreamPosition(event.item.name, event.item.id, event.fromPosition, event.toPosition).subscribe();
}
updateVisibility(item: DashboardStream, position: number) {
this.items[position].visible = !this.items[position].visible;
this.dashboardService.updateDashboardStream(this.items[position]).subscribe();
this.cdRef.markForCheck();
}
close() {
this.modal.close();
}
}

View File

@ -1,18 +1,21 @@
<ng-container *transloco="let t; read: 'side-nav'">
<div class="side-nav" [ngClass]="{'closed' : (navService.sideNavCollapsed$ | async), 'hidden': (navService.sideNavVisibility$ | async) === false, 'no-donate': (accountService.hasValidLicense$ | async) === true}" *ngIf="accountService.currentUser$ | async as user">
<!-- <app-side-nav-item icon="fa-user-circle align-self-center phone-hidden" [title]="user.username | sentenceCase" link="/preferences/">
<ng-container actions>
Todo: This will be customize dashboard/side nav controls
<a href="/preferences/" title="User Settings"><span class="visually-hidden">User Settings</span></a>
</ng-container>
</app-side-nav-item> -->
<!-- <app-side-nav-item icon="fa-user-circle align-self-center phone-hidden" [title]="user.username | sentenceCase" link="/preferences/">-->
<!-- <ng-container actions>-->
<!-- <a href="/preferences/" title="User Settings"><span class="visually-hidden">User Settings</span></a>-->
<!-- </ng-container>-->
<!-- </app-side-nav-item>-->
<app-side-nav-item icon="fa-home" [title]="t('home')" link="/libraries/"></app-side-nav-item>
<app-side-nav-item icon="fa-home" [title]="t('home')" link="/libraries/">
<ng-container actions>
<app-card-actionables [actions]="homeActions" [labelBy]="t('reading-lists')" iconClass="fa-ellipsis-v" (actionHandler)="handleHomeActions()"></app-card-actionables>
</ng-container>
</app-side-nav-item>
<app-side-nav-item icon="fa-star" [title]="t('want-to-read')" link="/want-to-read/"></app-side-nav-item>
<app-side-nav-item icon="fa-list" [title]="t('collections')" link="/collections/"></app-side-nav-item>
<app-side-nav-item icon="fa-list-ol" [title]="t('reading-lists')" link="/lists/">
<ng-container actions>
<app-card-actionables [actions]="readingListActions" labelBy="t('reading-lists')" iconClass="fa-ellipsis-v" (actionHandler)="importCbl()"></app-card-actionables>
<app-card-actionables [actions]="readingListActions" [labelBy]="t('reading-lists')" iconClass="fa-ellipsis-v" (actionHandler)="importCbl()"></app-card-actionables>
</ng-container>
</app-side-nav-item>
<app-side-nav-item icon="fa-bookmark" [title]="t('bookmarks')" link="/bookmarks/"></app-side-nav-item>

View File

@ -27,11 +27,13 @@ import {FilterPipe} from "../../../pipe/filter.pipe";
import {FormsModule} from "@angular/forms";
import {TranslocoDirective} from "@ngneat/transloco";
import {CardActionablesComponent} from "../../../_single-module/card-actionables/card-actionables.component";
import {SentenceCasePipe} from "../../../pipe/sentence-case.pipe";
import {CustomizeDashboardModalComponent} from "../customize-dashboard-modal/customize-dashboard-modal.component";
@Component({
selector: 'app-side-nav',
standalone: true,
imports: [CommonModule, SideNavItemComponent, CardActionablesComponent, FilterPipe, FormsModule, TranslocoDirective],
imports: [CommonModule, SideNavItemComponent, CardActionablesComponent, FilterPipe, FormsModule, TranslocoDirective, SentenceCasePipe],
templateUrl: './side-nav.component.html',
styleUrls: ['./side-nav.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
@ -43,6 +45,7 @@ export class SideNavComponent implements OnInit {
libraries: Library[] = [];
actions: ActionItem<Library>[] = [];
readingListActions = [{action: Action.Import, title: 'import-cbl', children: [], requiresAdmin: true, callback: this.importCbl.bind(this)}];
homeActions = [{action: Action.Edit, title: 'customize', children: [], requiresAdmin: false, callback: this.handleHomeActions.bind(this)}];
filterQuery: string = '';
filterLibrary = (library: Library) => {
return library.name.toLowerCase().indexOf((this.filterQuery || '').toLowerCase()) >= 0;
@ -107,6 +110,12 @@ export class SideNavComponent implements OnInit {
}
}
handleHomeActions() {
this.ngbModal.open(CustomizeDashboardModalComponent, {size: 'xl'});
// TODO: If on /, then refresh the page layout
}
importCbl() {
this.ngbModal.open(ImportCblModalComponent, {size: 'xl'});
}

View File

@ -0,0 +1,23 @@
<ng-container *transloco="let t; read: 'stream-list-item'">
<div class="row pt-2 g-0 list-item">
<div class="g-0">
<h5 class="mb-1 pb-0" id="item.id--{{position}}">
{{item.name}}
<span class="float-end">
<button class="btn btn-icon p-0" (click)="hide.emit(item)">
<i class="me-1" [ngClass]="{'fas fa-eye': item.visible, 'fa-solid fa-eye-slash': !item.visible}" aria-hidden="true"></i>
<span class="visually-hidden">{{t('remove')}}</span>
</button>
</span>
</h5>
<div class="meta">
<div class="ps-1">
{{t(item.isProvided ? 'provided' : 'smart-filter')}}
</div>
<div class="ps-1" *ngIf="!item.isProvided">
<a [href]="'/all-series?' + this.item.smartFilterEncoded" target="_blank">{{t('load-filter')}}</a>
</div>
</div>
</div>
</div>
</ng-container>

View File

@ -0,0 +1,8 @@
.list-item {
height: 60px;
max-height: 60px;
}
.meta {
display: flex;
}

View File

@ -0,0 +1,35 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
EventEmitter,
inject,
Input,
Output
} from '@angular/core';
import {CommonModule} from '@angular/common';
import {ImageComponent} from "../../../shared/image/image.component";
import {MangaFormatIconPipe} from "../../../pipe/manga-format-icon.pipe";
import {MangaFormatPipe} from "../../../pipe/manga-format.pipe";
import {NgbProgressbar} from "@ng-bootstrap/ng-bootstrap";
import {TranslocoDirective} from "@ngneat/transloco";
import {DashboardStream} from "../../../_models/dashboard/dashboard-stream";
@Component({
selector: 'app-stream-list-item',
standalone: true,
imports: [CommonModule, ImageComponent, MangaFormatIconPipe, MangaFormatPipe, NgbProgressbar, TranslocoDirective],
templateUrl: './stream-list-item.component.html',
styleUrls: ['./stream-list-item.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class StreamListItemComponent {
@Input({required: true}) item!: DashboardStream;
@Input({required: true}) position: number = 0;
@Output() hide: EventEmitter<DashboardStream> = new EventEmitter<DashboardStream>();
private readonly cdRef = inject(ChangeDetectorRef);
}

View File

@ -23,7 +23,7 @@ import {TopReadersComponent} from '../top-readers/top-readers.component';
import {StatListComponent} from '../stat-list/stat-list.component';
import {IconAndTitleComponent} from '../../../shared/icon-and-title/icon-and-title.component';
import {AsyncPipe, DecimalPipe, NgIf} from '@angular/common';
import {TranslocoDirective, TranslocoService} from "@ngneat/transloco";
import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco";
import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison";
import {FilterField} from "../../../_models/metadata/v2/filter-field";
@ -62,8 +62,6 @@ export class ServerStatsComponent {
this.breakpointSubject.next(this.utilityService.getActiveBreakpoint());
}
translocoService = inject(TranslocoService);
get Breakpoint() { return Breakpoint; }
constructor(private statService: StatisticsService, private router: Router, private imageService: ImageService,
@ -115,7 +113,7 @@ export class ServerStatsComponent {
this.metadataService.getAllGenres().subscribe(genres => {
const ref = this.modalService.open(GenericListModalComponent, { scrollable: true });
ref.componentInstance.items = genres.map(t => t.title);
ref.componentInstance.title = this.translocoService.translate('server-stats.genres');
ref.componentInstance.title = translate('server-stats.genres');
ref.componentInstance.clicked = (item: string) => {
this.filterUtilityService.applyFilter(['all-series'], FilterField.Genres, FilterComparison.Contains, genres.filter(g => g.title === item)[0].id + '');
};
@ -126,7 +124,7 @@ export class ServerStatsComponent {
this.metadataService.getAllTags().subscribe(tags => {
const ref = this.modalService.open(GenericListModalComponent, { scrollable: true });
ref.componentInstance.items = tags.map(t => t.title);
ref.componentInstance.title = this.translocoService.translate('server-stats.tags');
ref.componentInstance.title = translate('server-stats.tags');
ref.componentInstance.clicked = (item: string) => {
this.filterUtilityService.applyFilter(['all-series'], FilterField.Tags, FilterComparison.Contains, tags.filter(g => g.title === item)[0].id + '');
};
@ -137,7 +135,7 @@ export class ServerStatsComponent {
this.metadataService.getAllPeople().subscribe(people => {
const ref = this.modalService.open(GenericListModalComponent, { scrollable: true });
ref.componentInstance.items = [...new Set(people.map(person => person.name))];
ref.componentInstance.title = this.translocoService.translate('server-stats.people');
ref.componentInstance.title = translate('server-stats.people');
});
}

View File

@ -1,6 +1,6 @@
import {inject, Pipe, PipeTransform} from '@angular/core';
import { DayOfWeek } from 'src/app/_services/statistics.service';
import {TranslocoService} from "@ngneat/transloco";
import {translate, TranslocoService} from "@ngneat/transloco";
@Pipe({
name: 'dayOfWeek',
@ -8,24 +8,22 @@ import {TranslocoService} from "@ngneat/transloco";
})
export class DayOfWeekPipe implements PipeTransform {
translocoService = inject(TranslocoService);
transform(value: DayOfWeek): string {
switch(value) {
case DayOfWeek.Monday:
return this.translocoService.translate('day-of-week-pipe.monday');
return translate('day-of-week-pipe.monday');
case DayOfWeek.Tuesday:
return this.translocoService.translate('day-of-week-pipe.tuesday');
return translate('day-of-week-pipe.tuesday');
case DayOfWeek.Wednesday:
return this.translocoService.translate('day-of-week-pipe.wednesday');
return translate('day-of-week-pipe.wednesday');
case DayOfWeek.Thursday:
return this.translocoService.translate('day-of-week-pipe.thursday');
return translate('day-of-week-pipe.thursday');
case DayOfWeek.Friday:
return this.translocoService.translate('day-of-week-pipe.friday');
return translate('day-of-week-pipe.friday');
case DayOfWeek.Saturday:
return this.translocoService.translate('day-of-week-pipe.saturday');
return translate('day-of-week-pipe.saturday');
case DayOfWeek.Sunday:
return this.translocoService.translate('day-of-week-pipe.sunday');
return translate('day-of-week-pipe.sunday');
}
}

View File

@ -0,0 +1,11 @@
<ul>
<li class="list-group-item" *ngFor="let f of filters">
<span (click)="loadFilter(f)">{{f.name}}</span>
<button class="btn btn-danger float-end" (click)="deleteFilter(f)">
<i class="fa-solid fa-trash" aria-hidden="true"></i>
<span class="visually-hidden">Delete</span>
</button>
</li>
<li class="list-group-item" *ngIf="filters.length === 0">No Smart Filters created</li>
</ul>

View File

@ -0,0 +1,21 @@
ul {
margin:0;
padding: 0;
li {
padding: 0.5rem 1rem;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
border-radius: 5px;
margin: 5px 0;
color: var(--list-group-hover-text-color);
background-color: var(--list-group-hover-bg-color);
span {
cursor: pointer;
}
}
}

View File

@ -0,0 +1,51 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject} from '@angular/core';
import {CommonModule} from '@angular/common';
import {FilterService} from "../../_services/filter.service";
import {SmartFilter} from "../../_models/metadata/v2/smart-filter";
import {Router} from "@angular/router";
import {ConfirmService} from "../../shared/confirm.service";
import {translate} from "@ngneat/transloco";
import {ToastrService} from "ngx-toastr";
@Component({
selector: 'app-manage-smart-filters',
standalone: true,
imports: [CommonModule],
templateUrl: './manage-smart-filters.component.html',
styleUrls: ['./manage-smart-filters.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ManageSmartFiltersComponent {
private readonly filterService = inject(FilterService);
private readonly confirmService = inject(ConfirmService);
private readonly router = inject(Router);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly toastr = inject(ToastrService);
filters: Array<SmartFilter> = [];
constructor() {
this.loadData();
}
loadData() {
this.filterService.getAllFilters().subscribe(filters => {
this.filters = filters;
this.cdRef.markForCheck();
});
}
async loadFilter(f: SmartFilter) {
await this.router.navigateByUrl('all-series?' + f.filter);
}
async deleteFilter(f: SmartFilter) {
if (!await this.confirmService.confirm(translate('toasts.confirm-delete-smart-filter'))) return;
this.filterService.deleteFilter(f.id).subscribe(() => {
this.toastr.success(translate('toasts.smart-filter-deleted'));
this.loadData();
});
}
}

View File

@ -428,6 +428,9 @@
<ng-container *ngIf="tab.fragment === FragmentID.Stats">
<app-user-stats></app-user-stats>
</ng-container>
<ng-container *ngIf="tab.fragment === FragmentID.SmartFilters">
<app-manage-smart-filters></app-manage-smart-filters>
</ng-container>
<ng-container *ngIf="tab.fragment === FragmentID.Scrobbling">
<app-user-scrobble-history></app-user-scrobble-history>
<app-user-holds></app-user-holds>

View File

@ -49,6 +49,7 @@ import { SideNavCompanionBarComponent } from '../../sidenav/_components/side-nav
import {LocalizationService} from "../../_services/localization.service";
import {Language} from "../../_models/metadata/language";
import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco";
import {ManageSmartFiltersComponent} from "../manage-smart-filters/manage-smart-filters.component";
enum AccordionPanelID {
ImageReader = 'image-reader',
@ -63,6 +64,7 @@ enum FragmentID {
Theme = 'theme',
Devices = 'devices',
Stats = 'stats',
SmartFilters = 'smart-filters',
Scrobbling = 'scrobbling'
}
@ -76,7 +78,8 @@ enum FragmentID {
imports: [SideNavCompanionBarComponent, NgbNav, NgFor, NgbNavItem, NgbNavItemRole, NgbNavLink, RouterLink, NgbNavContent, NgIf, ChangeEmailComponent,
ChangePasswordComponent, ChangeAgeRestrictionComponent, AnilistKeyComponent, ReactiveFormsModule, NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader,
NgbAccordionToggle, NgbAccordionButton, NgbCollapse, NgbAccordionCollapse, NgbAccordionBody, NgbTooltip, NgTemplateOutlet, ColorPickerModule, ApiKeyComponent,
ThemeManagerComponent, ManageDevicesComponent, UserStatsComponent, UserScrobbleHistoryComponent, UserHoldsComponent, NgbNavOutlet, TitleCasePipe, SentenceCasePipe, TranslocoDirective]
ThemeManagerComponent, ManageDevicesComponent, UserStatsComponent, UserScrobbleHistoryComponent, UserHoldsComponent, NgbNavOutlet, TitleCasePipe, SentenceCasePipe,
TranslocoDirective, ManageSmartFiltersComponent]
})
export class UserPreferencesComponent implements OnInit, OnDestroy {
@ -107,6 +110,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
{title: '3rd-party-clients-tab', fragment: FragmentID.Clients},
{title: 'theme-tab', fragment: FragmentID.Theme},
{title: 'devices-tab', fragment: FragmentID.Devices},
{title: 'smart-filters-tab', fragment: FragmentID.SmartFilters},
{title: 'stats-tab', fragment: FragmentID.Stats},
];
locales: Array<Language> = [{title: 'English', isoCode: 'en'}];
@ -115,7 +119,6 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
opdsUrl: string = '';
makeUrl: (val: string) => string = (val: string) => { return this.opdsUrl; };
private readonly destroyRef = inject(DestroyRef);
private readonly trasnlocoService = inject(TranslocoService);
get AccordionPanelID() {
return AccordionPanelID;
@ -304,7 +307,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
};
this.observableHandles.push(this.accountService.updatePreferences(data).subscribe((updatedPrefs) => {
this.toastr.success(this.trasnlocoService.translate('user-preferences.success-toast'));
this.toastr.success(translate('user-preferences.success-toast'));
if (this.user) {
this.user.preferences = updatedPrefs;
this.cdRef.markForCheck();

View File

@ -14,7 +14,8 @@
"not-granted": "You haven't been granted access to any libraries.",
"on-deck-title": "On Deck",
"recently-updated-title": "Recently Updated Series",
"recently-added-title": "Newly Added Series"
"recently-added-title": "Newly Added Series",
"more-in-genre-title": "More In {{genre}}"
},
"edit-user": {
@ -98,6 +99,7 @@
"devices-tab": "Devices",
"stats-tab": "Stats",
"scrobbling-tab": "Scrobbling",
"smart-filters-tab": "Smart Filters",
"success-toast": "User preferences updated",
"global-settings-title": "Global Settings",
@ -1324,6 +1326,13 @@
"read": "{{common.read}}"
},
"stream-list-item": {
"remove": "{{common.remove}}",
"load-filter": "Load Filter",
"provided": "Provided",
"smart-filter": "Smart Filter"
},
"reading-list-detail": {
"item-count": "{{common.item-count}}",
"page-settings-title": "Page Settings",
@ -1494,10 +1503,12 @@
"metadata-filter": {
"filter-title": "Filter",
"sort-by-label": "Sort By",
"filter-name-label": "Filter Name",
"ascending-alt": "Ascending",
"descending-alt": "Descending",
"reset": "{{common.reset}}",
"apply": "{{common.apply}}",
"save": "{{common.save}}",
"limit-label": "Limit To",
"format-label": "Format",
@ -1707,6 +1718,12 @@
"remove-rule": "Remove Row"
},
"customize-dashboard-modal": {
"title": "Customize Dashboard",
"close": "{{common.close}}",
"save": "{{common.save}}"
},
"filter-field-pipe": {
"age-rating": "Age Rating",
"characters": "Characters",
@ -1733,7 +1750,8 @@
"user-rating": "User Rating",
"writers": "Writers",
"path": "Path",
"file-path": "File Path"
"file-path": "File Path",
"want-to-read": "Want to Read"
},
"filter-comparison-pipe": {
@ -1755,6 +1773,8 @@
"must-contains": "Must Contains"
},
"toasts": {
"regen-cover": "A job has been enqueued to regenerate the cover image",
"no-pages": "There are no pages. Kavita was not able to read this archive.",
@ -1831,7 +1851,10 @@
"confirm-library-delete": "Are you sure you want to delete the {{name}} library? You cannot undo this action.",
"confirm-library-type-change": "Changing library type will trigger a new scan with different parsing rules and may lead to series being re-created and hence you may loose progress and bookmarks. You should backup before you do this. Are you sure you want to continue?",
"confirm-download-size": "The {{entityType}} is {{size}}. Are you sure you want to continue?",
"list-doesnt-exist": "This list doesn't exist"
"list-doesnt-exist": "This list doesn't exist",
"confirm-delete-smart-filter": "Are you sure you want to delete this Smart Filter?",
"smart-filter-deleted": "Smart Filter Deleted",
"smart-filter-updated": "Created/Updated smart filter"
},
"actionable": {
@ -1861,8 +1884,8 @@
"read": "Read",
"add-rule-group-and": "Add Rule Group (AND)",
"add-rule-group-or": "Add Rule Group (OR)",
"remove-rule-group": "Remove Rule Group"
"remove-rule-group": "Remove Rule Group",
"customize": "Customize"
},
"preferences": {

View File

@ -7,7 +7,7 @@
"name": "GPL-3.0",
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
},
"version": "0.7.8.0"
"version": "0.7.8.2"
},
"servers": [
{
@ -875,6 +875,162 @@
}
}
},
"/api/Account/dashboard": {
"get": {
"tags": [
"Account"
],
"summary": "Returns the layout of the user's dashboard",
"parameters": [
{
"name": "visibleOnly",
"in": "query",
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Success",
"content": {
"text/plain": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DashboardStreamDto"
}
}
},
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DashboardStreamDto"
}
}
},
"text/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DashboardStreamDto"
}
}
}
}
}
}
}
},
"/api/Account/add-dashboard-stream": {
"post": {
"tags": [
"Account"
],
"summary": "Creates a Dashboard Stream from a SmartFilter and adds it to the user's dashboard as visible",
"parameters": [
{
"name": "smartFilterId",
"in": "query",
"description": "",
"schema": {
"type": "integer",
"format": "int32"
}
}
],
"responses": {
"200": {
"description": "Success",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/DashboardStreamDto"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/DashboardStreamDto"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/DashboardStreamDto"
}
}
}
}
}
}
},
"/api/Account/update-dashboard-stream": {
"post": {
"tags": [
"Account"
],
"summary": "Updates the visibility of a dashboard stream",
"requestBody": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DashboardStreamDto"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/DashboardStreamDto"
}
},
"application/*+json": {
"schema": {
"$ref": "#/components/schemas/DashboardStreamDto"
}
}
}
},
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/Account/update-dashboard-position": {
"post": {
"tags": [
"Account"
],
"summary": "Updates the position of a dashboard stream",
"requestBody": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateDashboardStreamPositionDto"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/UpdateDashboardStreamPositionDto"
}
},
"application/*+json": {
"schema": {
"$ref": "#/components/schemas/UpdateDashboardStreamPositionDto"
}
}
}
},
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/Admin/exists": {
"get": {
"tags": [
@ -1931,51 +2087,12 @@
}
}
},
"/api/Filter": {
"get": {
"tags": [
"Filter"
],
"parameters": [
{
"name": "name",
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Success",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/FilterV2Dto"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/FilterV2Dto"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/FilterV2Dto"
}
}
}
}
}
}
},
"/api/Filter/create-temp": {
"/api/Filter/update": {
"post": {
"tags": [
"Filter"
],
"summary": "Caches the filter in the backend and returns a temp string for retrieving.",
"description": "The cache line lives for only 1 hour",
"summary": "Creates or Updates the filter",
"requestBody": {
"description": "",
"content": {
@ -1996,28 +2113,69 @@
}
}
},
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/Filter": {
"get": {
"tags": [
"Filter"
],
"responses": {
"200": {
"description": "Success",
"content": {
"text/plain": {
"schema": {
"type": "string"
"type": "array",
"items": {
"$ref": "#/components/schemas/SmartFilterDto"
}
}
},
"application/json": {
"schema": {
"type": "string"
"type": "array",
"items": {
"$ref": "#/components/schemas/SmartFilterDto"
}
}
},
"text/json": {
"schema": {
"type": "string"
"type": "array",
"items": {
"$ref": "#/components/schemas/SmartFilterDto"
}
}
}
}
}
}
},
"delete": {
"tags": [
"Filter"
],
"parameters": [
{
"name": "filterId",
"in": "query",
"schema": {
"type": "integer",
"format": "int32"
}
}
],
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/Health": {
@ -3485,6 +3643,60 @@
}
}
},
"/api/Opds/{apiKey}/smart-filter/{filterId}": {
"get": {
"tags": [
"Opds"
],
"summary": "Returns the Series matching this smart filter. If FromDashboard, will only return 20 records.",
"parameters": [
{
"name": "apiKey",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "filterId",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int32"
}
}
],
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/Opds/{apiKey}/smart-filters": {
"get": {
"tags": [
"Opds"
],
"parameters": [
{
"name": "apiKey",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/Opds/{apiKey}/libraries": {
"get": {
"tags": [
@ -11552,6 +11764,22 @@
"description": "A list of Series the user doesn't want scrobbling for",
"nullable": true
},
"smartFilters": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AppUserSmartFilter"
},
"description": "A collection of user Smart Filters for their account",
"nullable": true
},
"dashboardStreams": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AppUserDashboardStream"
},
"description": "An ordered list of Streams (pre-configured) or Smart Filters that makes up the User's Dashboard",
"nullable": true
},
"rowVersion": {
"type": "integer",
"format": "int32",
@ -11612,6 +11840,54 @@
"additionalProperties": false,
"description": "Represents a saved page in a Chapter entity for a given user."
},
"AppUserDashboardStream": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"format": "int32"
},
"name": {
"type": "string",
"nullable": true
},
"isProvided": {
"type": "boolean",
"description": "Is System Provided"
},
"order": {
"type": "integer",
"description": "Sort Order on the Dashboard",
"format": "int32"
},
"streamType": {
"enum": [
1,
2,
3,
4,
5
],
"type": "integer",
"description": "For system provided",
"format": "int32"
},
"visible": {
"type": "boolean"
},
"smartFilter": {
"$ref": "#/components/schemas/AppUserSmartFilter"
},
"appUserId": {
"type": "integer",
"format": "int32"
},
"appUser": {
"$ref": "#/components/schemas/AppUser"
}
},
"additionalProperties": false
},
"AppUserPreferences": {
"type": "object",
"properties": {
@ -11930,6 +12206,33 @@
},
"additionalProperties": false
},
"AppUserSmartFilter": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"format": "int32"
},
"name": {
"type": "string",
"nullable": true
},
"filter": {
"type": "string",
"description": "This is the Filter url encoded. It is decoded and reconstructed into a API.DTOs.Filtering.v2.FilterV2Dto",
"nullable": true
},
"appUserId": {
"type": "integer",
"format": "int32"
},
"appUser": {
"$ref": "#/components/schemas/AppUser"
}
},
"additionalProperties": false,
"description": "Represents a Saved user Filter"
},
"AppUserTableOfContent": {
"type": "object",
"properties": {
@ -13184,6 +13487,54 @@
},
"additionalProperties": false
},
"DashboardStreamDto": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"format": "int32"
},
"name": {
"type": "string",
"nullable": true
},
"isProvided": {
"type": "boolean",
"description": "Is System Provided"
},
"order": {
"type": "integer",
"description": "Sort Order on the Dashboard",
"format": "int32"
},
"smartFilterEncoded": {
"type": "string",
"description": "If Not IsProvided, the appropriate smart filter",
"nullable": true
},
"smartFilterId": {
"type": "integer",
"format": "int32",
"nullable": true
},
"streamType": {
"enum": [
1,
2,
3,
4,
5
],
"type": "integer",
"description": "For system provided",
"format": "int32"
},
"visible": {
"type": "boolean"
}
},
"additionalProperties": false
},
"DateTimePagesReadOnADayCount": {
"type": "object",
"properties": {
@ -13790,7 +14141,8 @@
22,
23,
24,
25
25,
26
],
"type": "integer",
"description": "Represents the field which will dictate the value type and the Extension used for filtering",
@ -13806,6 +14158,11 @@
"FilterV2Dto": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"description": "Not used in the UI.",
"format": "int32"
},
"name": {
"type": "string",
"description": "The name of the filter",
@ -17056,6 +17413,25 @@
"additionalProperties": false,
"description": "Represents a set of css overrides the user can upload to Kavita and will load into webui"
},
"SmartFilterDto": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"format": "int32"
},
"name": {
"type": "string",
"nullable": true
},
"filter": {
"type": "string",
"description": "This is the Filter url encoded. It is decoded and reconstructed into a API.DTOs.Filtering.v2.FilterV2Dto",
"nullable": true
}
},
"additionalProperties": false
},
"SortOptions": {
"type": "object",
"properties": {
@ -17066,7 +17442,8 @@
3,
4,
5,
6
6,
7
],
"type": "integer",
"format": "int32"
@ -17212,6 +17589,28 @@
},
"additionalProperties": false
},
"UpdateDashboardStreamPositionDto": {
"type": "object",
"properties": {
"fromPosition": {
"type": "integer",
"format": "int32"
},
"toPosition": {
"type": "integer",
"format": "int32"
},
"dashboardStreamId": {
"type": "integer",
"format": "int32"
},
"streamName": {
"type": "string",
"nullable": true
}
},
"additionalProperties": false
},
"UpdateDefaultThemeDto": {
"type": "object",
"properties": {