Side Nav Redesign (#2310)

This commit is contained in:
Joe Milazzo 2023-10-14 10:07:53 -05:00 committed by GitHub
parent 5c2ebb87cc
commit 00dddaefae
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
88 changed files with 5971 additions and 572 deletions

View File

@ -123,30 +123,32 @@ public class ReadingListServiceTests
public async Task AddChaptersToReadingList_ShouldAddFirstItem_AsOrderZero()
{
await ResetDb();
_context.AppUser.Add(new AppUserBuilder("majora2007", "")
.WithLibrary(new LibraryBuilder("Test LIb", LibraryType.Book)
.WithSeries(new SeriesBuilder("Test")
.WithMetadata(new SeriesMetadataBuilder().Build())
.WithVolumes(new List<Volume>()
{
new VolumeBuilder("0")
.WithChapter(new ChapterBuilder("1")
.WithAgeRating(AgeRating.Everyone)
.Build()
)
.WithChapter(new ChapterBuilder("2")
.WithAgeRating(AgeRating.X18Plus)
.Build()
)
.WithChapter(new ChapterBuilder("3")
.WithAgeRating(AgeRating.X18Plus)
.Build()
)
var library = new LibraryBuilder("Test Lib", LibraryType.Book)
.WithSeries(new SeriesBuilder("Test")
.WithMetadata(new SeriesMetadataBuilder().Build())
.WithVolumes(new List<Volume>()
{
new VolumeBuilder("0")
.WithChapter(new ChapterBuilder("1")
.WithAgeRating(AgeRating.Everyone)
.Build()
})
.Build())
.Build()
)
)
.WithChapter(new ChapterBuilder("2")
.WithAgeRating(AgeRating.X18Plus)
.Build()
)
.WithChapter(new ChapterBuilder("3")
.WithAgeRating(AgeRating.X18Plus)
.Build()
)
.Build()
})
.Build())
.Build();
await _context.SaveChangesAsync();
_context.AppUser.Add(new AppUserBuilder("majora2007", "")
.WithLibrary(library)
.Build()
);
@ -763,16 +765,17 @@ public class ReadingListServiceTests
.Build()
);
// NOTE: WithLibrary creates a SideNavStream hence why we need to use the same instance for multiple users to avoid an id conflict
var library = new LibraryBuilder("Test LIb 2", LibraryType.Book)
.WithSeries(fablesSeries)
.Build();
_context.AppUser.Add(new AppUserBuilder("majora2007", string.Empty)
.WithLibrary(new LibraryBuilder("Test LIb 2", LibraryType.Book)
.WithSeries(fablesSeries)
.Build())
.WithLibrary(library)
.Build()
);
_context.AppUser.Add(new AppUserBuilder("admin", string.Empty)
.WithLibrary(new LibraryBuilder("Test LIb 2", LibraryType.Book)
.WithSeries(fablesSeries)
.Build())
.WithLibrary(library)
.Build()
);
await _unitOfWork.CommitAsync();

View File

@ -8,7 +8,6 @@ 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;
@ -1046,123 +1045,4 @@ 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

@ -87,6 +87,10 @@ public class FilterController : BaseApiController
// This needs to delete any dashboard filters that have it too
var streams = await _unitOfWork.UserRepository.GetDashboardStreamWithFilter(filter.Id);
_unitOfWork.UserRepository.Delete(streams);
var streams2 = await _unitOfWork.UserRepository.GetSideNavStreamWithFilter(filter.Id);
_unitOfWork.UserRepository.Delete(streams2);
_unitOfWork.AppUserSmartFilterRepository.Delete(filter);
await _unitOfWork.CommitAsync();
return Ok();

View File

@ -21,6 +21,7 @@ using AutoMapper;
using EasyCaching.Core;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration.UserSecrets;
using Microsoft.Extensions.Logging;
using TaskScheduler = API.Services.TaskScheduler;
@ -97,6 +98,27 @@ public class LibraryController : BaseApiController
admin.Libraries.Add(library);
}
var userIds = admins.Select(u => u.Id).Append(User.GetUserId()).ToList();
var userNeedingNewLibrary = (await _unitOfWork.UserRepository.GetAllUsersAsync(AppUserIncludes.SideNavStreams))
.Where(u => userIds.Contains(u.Id))
.ToList();
foreach (var user in userNeedingNewLibrary)
{
var maxCount = user.SideNavStreams.Select(s => s.Order).Max();
user.SideNavStreams.Add(new AppUserSideNavStream()
{
Name = library.Name,
Order = maxCount + 1,
IsProvided = false,
StreamType = SideNavStreamType.Library,
LibraryId = library.Id,
Visible = true,
});
_unitOfWork.UserRepository.Update(user);
}
if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-library"));
@ -105,6 +127,8 @@ public class LibraryController : BaseApiController
_taskScheduler.ScanLibrary(library.Id);
await _eventHub.SendMessageAsync(MessageFactory.LibraryModified,
MessageFactory.LibraryModifiedEvent(library.Id, "create"), false);
await _eventHub.SendMessageAsync(MessageFactory.SideNavUpdate,
MessageFactory.SideNavUpdateEvent(User.GetUserId()), false);
await _libraryCacheProvider.RemoveByPrefixAsync(CacheKey);
return Ok();
}
@ -329,9 +353,15 @@ public class LibraryController : BaseApiController
_unitOfWork.LibraryRepository.Delete(library);
var streams = await _unitOfWork.UserRepository.GetSideNavStreamsByLibraryId(library.Id);
_unitOfWork.UserRepository.Delete(streams);
await _unitOfWork.CommitAsync();
await _libraryCacheProvider.RemoveByPrefixAsync(CacheKey);
await _eventHub.SendMessageAsync(MessageFactory.SideNavUpdate,
MessageFactory.SideNavUpdateEvent(User.GetUserId()), false);
if (chapterIds.Any())
{

View File

@ -50,4 +50,18 @@ public class PluginController : BaseApiController
KavitaVersion = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion)).Value
};
}
/// <summary>
/// Returns the version of the Kavita install
/// </summary>
/// <param name="apiKey">Required for authenticating to get result</param>
/// <returns></returns>
[AllowAnonymous]
[HttpGet("version")]
public async Task<ActionResult<string>> GetVersion([Required] string apiKey)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
if (userId <= 0) return Unauthorized();
return Ok((await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion)).Value);
}
}

View File

@ -0,0 +1,186 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using API.Data;
using API.DTOs.Dashboard;
using API.DTOs.SideNav;
using API.Extensions;
using API.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace API.Controllers;
/// <summary>
/// Responsible for anything that deals with Streams (SmartFilters, ExternalSource, DashboardStream, SideNavStream)
/// </summary>
public class StreamController : BaseApiController
{
private readonly IStreamService _streamService;
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<StreamController> _logger;
public StreamController(IStreamService streamService, IUnitOfWork unitOfWork, ILogger<StreamController> logger)
{
_streamService = streamService;
_unitOfWork = unitOfWork;
_logger = logger;
}
/// <summary>
/// Returns the layout of the user's dashboard
/// </summary>
/// <returns></returns>
[HttpGet("dashboard")]
public async Task<ActionResult<IEnumerable<DashboardStreamDto>>> GetDashboardLayout(bool visibleOnly = true)
{
return Ok(await _streamService.GetDashboardStreams(User.GetUserId(), visibleOnly));
}
/// <summary>
/// Return's the user's side nav
/// </summary>
[HttpGet("sidenav")]
public async Task<ActionResult<IEnumerable<SideNavStreamDto>>> GetSideNav(bool visibleOnly = true)
{
return Ok(await _streamService.GetSidenavStreams(User.GetUserId(), visibleOnly));
}
/// <summary>
/// Return's the user's external sources
/// </summary>
[HttpGet("external-sources")]
public async Task<ActionResult<IEnumerable<ExternalSourceDto>>> GetExternalSources()
{
return Ok(await _streamService.GetExternalSources(User.GetUserId()));
}
/// <summary>
/// Create an external Source
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("create-external-source")]
public async Task<ActionResult<ExternalSourceDto>> CreateExternalSource(ExternalSourceDto dto)
{
// Check if a host and api key exists for the current user
return Ok(await _streamService.CreateExternalSource(User.GetUserId(), dto));
}
/// <summary>
/// Updates an existing external source
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("update-external-source")]
public async Task<ActionResult<ExternalSourceDto>> UpdateExternalSource(ExternalSourceDto dto)
{
// Check if a host and api key exists for the current user
return Ok(await _streamService.UpdateExternalSource(User.GetUserId(), dto));
}
/// <summary>
/// Validates the external source by host is unique (for this user)
/// </summary>
/// <param name="host"></param>
/// <returns></returns>
[HttpGet("external-source-exists")]
public async Task<ActionResult<bool>> ExternalSourceExists(string host, string name, string apiKey)
{
return Ok(await _unitOfWork.AppUserExternalSourceRepository.ExternalSourceExists(User.GetUserId(), host, name, apiKey));
}
/// <summary>
/// Delete's the external source
/// </summary>
/// <param name="externalSourceId"></param>
/// <returns></returns>
[HttpDelete("delete-external-source")]
public async Task<ActionResult> ExternalSourceExists(int externalSourceId)
{
await _streamService.DeleteExternalSource(User.GetUserId(), externalSourceId);
return Ok();
}
/// <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)
{
return Ok(await _streamService.CreateDashboardStreamFromSmartFilter(User.GetUserId(), smartFilterId));
}
/// <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)
{
await _streamService.UpdateDashboardStream(User.GetUserId(), dto);
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(UpdateStreamPositionDto dto)
{
await _streamService.UpdateDashboardStreamPosition(User.GetUserId(), dto);
return Ok();
}
/// <summary>
/// Creates a SideNav Stream from a SmartFilter and adds it to the user's sidenav as visible
/// </summary>
/// <param name="smartFilterId"></param>
/// <returns></returns>
[HttpPost("add-sidenav-stream")]
public async Task<ActionResult<SideNavStreamDto>> AddSideNav([FromQuery] int smartFilterId)
{
return Ok(await _streamService.CreateSideNavStreamFromSmartFilter(User.GetUserId(), smartFilterId));
}
/// <summary>
/// Creates a SideNav Stream from a SmartFilter and adds it to the user's sidenav as visible
/// </summary>
/// <param name="externalSourceId"></param>
/// <returns></returns>
[HttpPost("add-sidenav-stream-from-external-source")]
public async Task<ActionResult<SideNavStreamDto>> AddSideNavFromExternalSource([FromQuery] int externalSourceId)
{
return Ok(await _streamService.CreateSideNavStreamFromExternalSource(User.GetUserId(), externalSourceId));
}
/// <summary>
/// Updates the visibility of a dashboard stream
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("update-sidenav-stream")]
public async Task<ActionResult> UpdateSideNavStream(SideNavStreamDto dto)
{
await _streamService.UpdateSideNavStream(User.GetUserId(), dto);
return Ok();
}
/// <summary>
/// Updates the position of a dashboard stream
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("update-sidenav-position")]
public async Task<ActionResult> UpdateSideNavStreamPosition(UpdateStreamPositionDto dto)
{
await _streamService.UpdateSideNavStreamPosition(User.GetUserId(), dto);
return Ok();
}
}

View File

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

View File

@ -0,0 +1,11 @@
using System;
namespace API.DTOs.SideNav;
public class ExternalSourceDto
{
public required int Id { get; set; } = 0;
public required string Name { get; set; }
public required string Host { get; set; }
public required string ApiKey { get; set; }
}

View File

@ -0,0 +1,39 @@
using API.Entities;
using API.Entities.Enums;
namespace API.DTOs.SideNav;
public class SideNavStreamDto
{
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>
/// External Source Url if configured
/// </summary>
public int ExternalSourceId { get; set; }
public ExternalSourceDto? ExternalSource { get; set; }
/// <summary>
/// For system provided
/// </summary>
public SideNavStreamType StreamType { get; set; }
public bool Visible { get; set; }
public int? LibraryId { get; set; }
/// <summary>
/// Only available for SideNavStreamType.Library
/// </summary>
public LibraryDto? Library { get; set; }
}

View File

@ -56,6 +56,8 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
public DbSet<AppUserTableOfContent> AppUserTableOfContent { get; set; } = null!;
public DbSet<AppUserSmartFilter> AppUserSmartFilter { get; set; } = null!;
public DbSet<AppUserDashboardStream> AppUserDashboardStream { get; set; } = null!;
public DbSet<AppUserSideNavStream> AppUserSideNavStream { get; set; } = null!;
public DbSet<AppUserExternalSource> AppUserExternalSource { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder builder)
@ -128,6 +130,13 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
builder.Entity<AppUserDashboardStream>()
.HasIndex(e => e.Visible)
.IsUnique(false);
builder.Entity<AppUserSideNavStream>()
.Property(b => b.StreamType)
.HasDefaultValue(SideNavStreamType.SmartFilter);
builder.Entity<AppUserSideNavStream>()
.HasIndex(e => e.Visible)
.IsUnique(false);
}

View File

@ -0,0 +1,52 @@
using System.Linq;
using System.Threading.Tasks;
using API.Data.Repositories;
using API.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace API.Data.ManualMigrations;
/// <summary>
/// Introduced in v0.7.8.7 and v0.7.9, this adds SideNavStream's for all Libraries a User has access to
/// </summary>
public static class MigrateUserLibrarySideNavStream
{
public static async Task Migrate(IUnitOfWork unitOfWork, DataContext dataContext, ILogger<Program> logger)
{
logger.LogCritical("Running MigrateUserLibrarySideNavStream migration - Please be patient, this may take some time. This is not an error");
var usersWithLibraryStreams = await dataContext.AppUser.Include(u => u.SideNavStreams)
.AnyAsync(u => u.SideNavStreams.Count > 0 && u.SideNavStreams.Any(s => s.LibraryId > 0));
if (usersWithLibraryStreams)
{
logger.LogCritical("Running MigrateUserLibrarySideNavStream migration - complete. Nothing to do");
return;
}
var users = await unitOfWork.UserRepository.GetAllUsersAsync(AppUserIncludes.SideNavStreams);
foreach (var user in users)
{
var userLibraries = await unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id);
foreach (var lib in userLibraries)
{
var prevMaxOrder = user.SideNavStreams.Max(s => s.Order);
user.SideNavStreams.Add(new AppUserSideNavStream()
{
Name = lib.Name,
LibraryId = lib.Id,
IsProvided = false,
Visible = true,
StreamType = SideNavStreamType.Library,
Order = prevMaxOrder + 1
});
}
unitOfWork.UserRepository.Update(user);
}
await unitOfWork.CommitAsync();
logger.LogCritical("Running MigrateUserLibrarySideNavStream migration - Completed. This is not an error");
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,98 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class SideNavStreamAndExternalSource : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AppUserExternalSource",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(type: "TEXT", nullable: true),
Host = table.Column<string>(type: "TEXT", nullable: true),
ApiKey = table.Column<string>(type: "TEXT", nullable: true),
AppUserId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AppUserExternalSource", x => x.Id);
table.ForeignKey(
name: "FK_AppUserExternalSource_AspNetUsers_AppUserId",
column: x => x.AppUserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AppUserSideNavStream",
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),
LibraryId = table.Column<int>(type: "INTEGER", nullable: true),
ExternalSourceId = table.Column<int>(type: "INTEGER", nullable: true),
StreamType = table.Column<int>(type: "INTEGER", nullable: false, defaultValue: 5),
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_AppUserSideNavStream", x => x.Id);
table.ForeignKey(
name: "FK_AppUserSideNavStream_AppUserSmartFilter_SmartFilterId",
column: x => x.SmartFilterId,
principalTable: "AppUserSmartFilter",
principalColumn: "Id");
table.ForeignKey(
name: "FK_AppUserSideNavStream_AspNetUsers_AppUserId",
column: x => x.AppUserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_AppUserExternalSource_AppUserId",
table: "AppUserExternalSource",
column: "AppUserId");
migrationBuilder.CreateIndex(
name: "IX_AppUserSideNavStream_AppUserId",
table: "AppUserSideNavStream",
column: "AppUserId");
migrationBuilder.CreateIndex(
name: "IX_AppUserSideNavStream_SmartFilterId",
table: "AppUserSideNavStream",
column: "SmartFilterId");
migrationBuilder.CreateIndex(
name: "IX_AppUserSideNavStream_Visible",
table: "AppUserSideNavStream",
column: "Visible");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AppUserExternalSource");
migrationBuilder.DropTable(
name: "AppUserSideNavStream");
}
}
}

View File

@ -15,7 +15,7 @@ namespace API.Data.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "7.0.10");
modelBuilder.HasAnnotation("ProductVersion", "7.0.11");
modelBuilder.Entity("API.Entities.AppRole", b =>
{
@ -223,6 +223,31 @@ namespace API.Data.Migrations
b.ToTable("AppUserDashboardStream");
});
modelBuilder.Entity("API.Entities.AppUserExternalSource", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ApiKey")
.HasColumnType("TEXT");
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<string>("Host")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AppUserId");
b.ToTable("AppUserExternalSource");
});
modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b =>
{
b.Property<int>("Id")
@ -456,6 +481,52 @@ namespace API.Data.Migrations
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("API.Entities.AppUserSideNavStream", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<int?>("ExternalSourceId")
.HasColumnType("INTEGER");
b.Property<bool>("IsProvided")
.HasColumnType("INTEGER");
b.Property<int?>("LibraryId")
.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(5);
b.Property<bool>("Visible")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AppUserId");
b.HasIndex("SmartFilterId");
b.HasIndex("Visible");
b.ToTable("AppUserSideNavStream");
});
modelBuilder.Entity("API.Entities.AppUserSmartFilter", b =>
{
b.Property<int>("Id")
@ -1790,6 +1861,17 @@ namespace API.Data.Migrations
b.Navigation("SmartFilter");
});
modelBuilder.Entity("API.Entities.AppUserExternalSource", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
.WithMany("ExternalSources")
.HasForeignKey("AppUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AppUser");
});
modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
@ -1887,6 +1969,23 @@ namespace API.Data.Migrations
b.Navigation("User");
});
modelBuilder.Entity("API.Entities.AppUserSideNavStream", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
.WithMany("SideNavStreams")
.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.AppUserSmartFilter", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
@ -2303,6 +2402,8 @@ namespace API.Data.Migrations
b.Navigation("Devices");
b.Navigation("ExternalSources");
b.Navigation("Progresses");
b.Navigation("Ratings");
@ -2311,6 +2412,8 @@ namespace API.Data.Migrations
b.Navigation("ScrobbleHolds");
b.Navigation("SideNavStreams");
b.Navigation("SmartFilters");
b.Navigation("TableOfContents");

View File

@ -0,0 +1,78 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.DTOs.SideNav;
using API.Entities;
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Kavita.Common.Helpers;
using Microsoft.EntityFrameworkCore;
namespace API.Data.Repositories;
public interface IAppUserExternalSourceRepository
{
void Update(AppUserExternalSource source);
void Delete(AppUserExternalSource source);
Task<AppUserExternalSource> GetById(int externalSourceId);
Task<IList<ExternalSourceDto>> GetExternalSources(int userId);
Task<bool> ExternalSourceExists(int userId, string name, string host, string apiKey);
}
public class AppUserExternalSourceRepository : IAppUserExternalSourceRepository
{
private readonly DataContext _context;
private readonly IMapper _mapper;
public AppUserExternalSourceRepository(DataContext context, IMapper mapper)
{
_context = context;
_mapper = mapper;
}
public void Update(AppUserExternalSource source)
{
_context.Entry(source).State = EntityState.Modified;
}
public void Delete(AppUserExternalSource source)
{
_context.AppUserExternalSource.Remove(source);
}
public async Task<AppUserExternalSource> GetById(int externalSourceId)
{
return await _context.AppUserExternalSource
.Where(s => s.Id == externalSourceId)
.FirstOrDefaultAsync();
}
public async Task<IList<ExternalSourceDto>> GetExternalSources(int userId)
{
return await _context.AppUserExternalSource.Where(s => s.AppUserId == userId)
.ProjectTo<ExternalSourceDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
/// <summary>
/// Checks if all the properties match exactly. This will allow a user to setup 2 External Sources with different Users
/// </summary>
/// <param name="userId"></param>
/// <param name="host"></param>
/// <param name="name"></param>
/// <param name="apiKey"></param>
/// <returns></returns>
public async Task<bool> ExternalSourceExists(int userId, string name, string host, string apiKey)
{
host = host.Trim();
if (string.IsNullOrEmpty(host) || string.IsNullOrEmpty(name) || string.IsNullOrEmpty(apiKey)) return false;
var hostWithEndingSlash = UrlHelper.EnsureEndsWithSlash(host)!;
return await _context.AppUserExternalSource
.Where(s => s.AppUserId == userId )
.Where(s => s.Host.ToUpper().Equals(hostWithEndingSlash.ToUpper())
&& s.Name.ToUpper().Equals(name.ToUpper())
&& s.ApiKey.Equals(apiKey))
.AnyAsync();
}
}

View File

@ -1,5 +1,4 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
@ -11,11 +10,11 @@ using API.DTOs.Filtering.v2;
using API.DTOs.Reader;
using API.DTOs.Scrobbling;
using API.DTOs.SeriesDetail;
using API.DTOs.SideNav;
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;
@ -37,7 +36,9 @@ public enum AppUserIncludes
Devices = 256,
ScrobbleHolds = 512,
SmartFilters = 1024,
DashboardStreams = 2048
DashboardStreams = 2048,
SideNavStreams = 4096,
ExternalSources = 8192 // 2^13
}
public interface IUserRepository
@ -46,10 +47,12 @@ public interface IUserRepository
void Update(AppUserPreferences preferences);
void Update(AppUserBookmark bookmark);
void Update(AppUserDashboardStream stream);
void Update(AppUserSideNavStream stream);
void Add(AppUserBookmark bookmark);
void Delete(AppUser? user);
void Delete(AppUserBookmark bookmark);
void Delete(IList<AppUserDashboardStream> streams);
void Delete(IEnumerable<AppUserDashboardStream> streams);
void Delete(IEnumerable<AppUserSideNavStream> streams);
Task<IEnumerable<MemberDto>> GetEmailConfirmedMemberDtosAsync(bool emailConfirmed = true);
Task<IEnumerable<AppUser>> GetAdminUsersAsync();
Task<bool> IsUserAdminAsync(AppUser? user);
@ -83,6 +86,11 @@ public interface IUserRepository
Task<IList<DashboardStreamDto>> GetDashboardStreams(int userId, bool visibleOnly = false);
Task<AppUserDashboardStream?> GetDashboardStream(int streamId);
Task<IList<AppUserDashboardStream>> GetDashboardStreamWithFilter(int filterId);
Task<IList<SideNavStreamDto>> GetSideNavStreams(int userId, bool visibleOnly = false);
Task<AppUserSideNavStream?> GetSideNavStream(int streamId);
Task<IList<AppUserSideNavStream>> GetSideNavStreamWithFilter(int filterId);
Task<IList<AppUserSideNavStream>> GetSideNavStreamsByLibraryId(int libraryId);
Task<IList<AppUserSideNavStream>> GetSideNavStreamWithExternalSource(int externalSourceId);
}
public class UserRepository : IUserRepository
@ -118,6 +126,11 @@ public class UserRepository : IUserRepository
_context.Entry(stream).State = EntityState.Modified;
}
public void Update(AppUserSideNavStream stream)
{
_context.Entry(stream).State = EntityState.Modified;
}
public void Add(AppUserBookmark bookmark)
{
_context.AppUserBookmark.Add(bookmark);
@ -134,11 +147,16 @@ public class UserRepository : IUserRepository
_context.AppUserBookmark.Remove(bookmark);
}
public void Delete(IList<AppUserDashboardStream> streams)
public void Delete(IEnumerable<AppUserDashboardStream> streams)
{
_context.AppUserDashboardStream.RemoveRange(streams);
}
public void Delete(IEnumerable<AppUserSideNavStream> streams)
{
_context.AppUserSideNavStream.RemoveRange(streams);
}
/// <summary>
/// A one stop shop to get a tracked AppUser instance with any number of JOINs generated by passing bitwise flags.
/// </summary>
@ -353,6 +371,89 @@ public class UserRepository : IUserRepository
.ToListAsync();
}
public async Task<IList<SideNavStreamDto>> GetSideNavStreams(int userId, bool visibleOnly = false)
{
var sideNavStreams = await _context.AppUserSideNavStream
.Where(d => d.AppUserId == userId)
.WhereIf(visibleOnly, d => d.Visible)
.OrderBy(d => d.Order)
.Include(d => d.SmartFilter)
.Select(d => new SideNavStreamDto()
{
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,
LibraryId = d.LibraryId ?? 0,
ExternalSourceId = d.ExternalSourceId ?? 0,
StreamType = d.StreamType,
Order = d.Order,
Visible = d.Visible
})
.ToListAsync();
var libraryIds = sideNavStreams.Where(d => d.StreamType == SideNavStreamType.Library)
.Select(d => d.LibraryId)
.ToList();
var libraryDtos = _context.Library
.Where(l => libraryIds.Contains(l.Id))
.ProjectTo<LibraryDto>(_mapper.ConfigurationProvider)
.ToList();
foreach (var dto in sideNavStreams.Where(dto => dto.StreamType == SideNavStreamType.Library))
{
dto.Library = libraryDtos.FirstOrDefault(l => l.Id == dto.LibraryId);
}
var externalSourceIds = sideNavStreams.Where(d => d.StreamType == SideNavStreamType.ExternalSource)
.Select(d => d.ExternalSourceId)
.ToList();
var externalSourceDtos = _context.AppUserExternalSource
.Where(l => externalSourceIds.Contains(l.Id))
.ProjectTo<ExternalSourceDto>(_mapper.ConfigurationProvider)
.ToList();
foreach (var dto in sideNavStreams.Where(dto => dto.StreamType == SideNavStreamType.ExternalSource))
{
dto.ExternalSource = externalSourceDtos.FirstOrDefault(l => l.Id == dto.ExternalSourceId);
}
return sideNavStreams;
}
public async Task<AppUserSideNavStream> GetSideNavStream(int streamId)
{
return await _context.AppUserSideNavStream
.Include(d => d.SmartFilter)
.FirstOrDefaultAsync(d => d.Id == streamId);
}
public async Task<IList<AppUserSideNavStream>> GetSideNavStreamWithFilter(int filterId)
{
return await _context.AppUserSideNavStream
.Include(d => d.SmartFilter)
.Where(d => d.SmartFilter != null && d.SmartFilter.Id == filterId)
.ToListAsync();
}
public async Task<IList<AppUserSideNavStream>> GetSideNavStreamsByLibraryId(int libraryId)
{
return await _context.AppUserSideNavStream
.Where(d => d.LibraryId == libraryId)
.ToListAsync();
}
public async Task<IList<AppUserSideNavStream>> GetSideNavStreamWithExternalSource(int externalSourceId)
{
return await _context.AppUserSideNavStream
.Where(d => d.ExternalSourceId == externalSourceId)
.ToListAsync();
}
public async Task<IEnumerable<AppUser>> GetAdminUsersAsync()
{
return await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole);

View File

@ -44,7 +44,7 @@ public static class Seed
{
new()
{
Name = "On Deck",
Name = "on-deck",
StreamType = DashboardStreamType.OnDeck,
Order = 0,
IsProvided = true,
@ -52,7 +52,7 @@ public static class Seed
},
new()
{
Name = "Recently Updated",
Name = "recently-updated",
StreamType = DashboardStreamType.RecentlyUpdated,
Order = 1,
IsProvided = true,
@ -60,7 +60,7 @@ public static class Seed
},
new()
{
Name = "Newly Added",
Name = "newly-added",
StreamType = DashboardStreamType.NewlyAdded,
Order = 2,
IsProvided = true,
@ -68,7 +68,7 @@ public static class Seed
},
new()
{
Name = "More In",
Name = "more-in-genre",
StreamType = DashboardStreamType.MoreInGenre,
Order = 3,
IsProvided = true,
@ -76,6 +76,50 @@ public static class Seed
},
}.ToArray());
public static readonly ImmutableArray<AppUserSideNavStream> DefaultSideNavStreams = ImmutableArray.Create(new[]
{
new AppUserSideNavStream()
{
Name = "want-to-read",
StreamType = SideNavStreamType.WantToRead,
Order = 1,
IsProvided = true,
Visible = true
},
new AppUserSideNavStream()
{
Name = "collections",
StreamType = SideNavStreamType.Collections,
Order = 2,
IsProvided = true,
Visible = true
},
new AppUserSideNavStream()
{
Name = "reading-lists",
StreamType = SideNavStreamType.ReadingLists,
Order = 3,
IsProvided = true,
Visible = true
},
new AppUserSideNavStream()
{
Name = "bookmarks",
StreamType = SideNavStreamType.Bookmarks,
Order = 4,
IsProvided = true,
Visible = true
},
new AppUserSideNavStream()
{
Name = "all-series",
StreamType = SideNavStreamType.AllSeries,
Order = 5,
IsProvided = true,
Visible = true
}
});
public static async Task SeedRoles(RoleManager<AppRole> roleManager)
{
var roles = typeof(PolicyConstants)
@ -137,6 +181,31 @@ public static class Seed
}
}
public static async Task SeedDefaultSideNavStreams(IUnitOfWork unitOfWork)
{
var allUsers = await unitOfWork.UserRepository.GetAllUsersAsync(AppUserIncludes.SideNavStreams);
foreach (var user in allUsers)
{
if (user.SideNavStreams.Count != 0) continue;
user.SideNavStreams ??= new List<AppUserSideNavStream>();
foreach (var defaultStream in DefaultSideNavStreams)
{
var newStream = new AppUserSideNavStream()
{
Name = defaultStream.Name,
IsProvided = defaultStream.IsProvided,
Order = defaultStream.Order,
StreamType = defaultStream.StreamType,
Visible = defaultStream.Visible,
};
user.SideNavStreams.Add(newStream);
}
unitOfWork.UserRepository.Update(user);
await unitOfWork.CommitAsync();
}
}
public static async Task SeedSettings(DataContext context, IDirectoryService directoryService)
{
await context.Database.EnsureCreatedAsync();

View File

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

View File

@ -76,6 +76,11 @@ public class AppUser : IdentityUser<int>, IHasConcurrencyToken
/// 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!;
/// <summary>
/// An ordered list of Streams (pre-configured) or Smart Filters that makes up the User's SideNav
/// </summary>
public IList<AppUserSideNavStream> SideNavStreams { get; set; } = null!;
public IList<AppUserExternalSource> ExternalSources { get; set; } = null!;
/// <inheritdoc />

View File

@ -0,0 +1,12 @@
namespace API.Entities;
public class AppUserExternalSource
{
public int Id { get; set; }
public required string Name { get; set; }
public required string Host { get; set; }
public required string ApiKey { get; set; }
public int AppUserId { get; set; }
public AppUser AppUser { get; set; }
}

View File

@ -0,0 +1,34 @@
namespace API.Entities;
public class AppUserSideNavStream
{
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>
/// Library Id is for StreamType.Library only
/// </summary>
public int? LibraryId { get; set; }
/// <summary>
/// Only set for StreamType.ExternalSource
/// </summary>
public int? ExternalSourceId { get; set; }
/// <summary>
/// For system provided
/// </summary>
public SideNavStreamType 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,13 @@
namespace API.Entities;
public enum SideNavStreamType
{
Collections = 1,
ReadingLists = 2,
Bookmarks = 3,
Library = 4,
SmartFilter = 5,
ExternalSource = 6,
AllSeries = 7,
WantToRead = 8,
}

View File

@ -51,6 +51,7 @@ public static class ApplicationServiceExtensions
services.AddScoped<IMediaErrorService, MediaErrorService>();
services.AddScoped<IMediaConversionService, MediaConversionService>();
services.AddScoped<IRecommendationService, RecommendationService>();
services.AddScoped<IStreamService, StreamService>();
services.AddScoped<IScannerService, ScannerService>();
services.AddScoped<IMetadataService, MetadataService>();

View File

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

View File

@ -16,6 +16,7 @@ using API.DTOs.Scrobbling;
using API.DTOs.Search;
using API.DTOs.SeriesDetail;
using API.DTOs.Settings;
using API.DTOs.SideNav;
using API.DTOs.Theme;
using API.Entities;
using API.Entities.Enums;
@ -54,6 +55,7 @@ public class AutoMapperProfiles : Profile
CreateMap<AgeRating, AgeRatingDto>();
CreateMap<PublicationStatus, PublicationStatusDto>();
CreateMap<MediaError, MediaErrorDto>();
CreateMap<AppUserExternalSource, ExternalSourceDto>();
CreateMap<ScrobbleHold, ScrobbleHoldDto>()
.ForMember(dest => dest.LibraryId,
opt =>

View File

@ -29,20 +29,39 @@ public class AppUserBuilder : IEntityBuilder<AppUser>
Progresses = new List<AppUserProgress>(),
Devices = new List<Device>(),
Id = 0,
DashboardStreams = new List<AppUserDashboardStream>()
DashboardStreams = new List<AppUserDashboardStream>(),
SideNavStreams = new List<AppUserSideNavStream>()
};
foreach (var s in Seed.DefaultStreams)
{
_appUser.DashboardStreams.Add(s);
}
foreach (var s in Seed.DefaultSideNavStreams)
{
_appUser.SideNavStreams.Add(s);
}
}
public AppUserBuilder WithLibrary(Library library)
public AppUserBuilder WithLibrary(Library library, bool createSideNavStream = false)
{
_appUser.Libraries.Add(library);
if (!createSideNavStream) return this;
if (library.Id != 0 && _appUser.SideNavStreams.Any(s => s.LibraryId == library.Id)) return this;
_appUser.SideNavStreams.Add(new AppUserSideNavStream()
{
Name = library.Name,
IsProvided = false,
Visible = true,
LibraryId = library.Id,
StreamType = SideNavStreamType.Library,
Order = _appUser.SideNavStreams.Max(s => s.Order) + 1,
});
return this;
}
public AppUserBuilder WithLocale(string locale)
{
_appUser.UserPreferences.Locale = locale;

View File

@ -158,6 +158,13 @@
"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",
"smart-filter-already-in-use": "There is an existing stream with this Smart Filter",
"dashboard-stream-doesnt-exist": "Dashboard Stream doesn't exist",
"sidenav-stream-doesnt-exist": "SideNav Stream doesn't exist",
"external-source-already-exists": "External Source already exists",
"external-source-required": "ApiKey and Host required",
"external-source-doesnt-exist": "External Source doesn't exist",
"external-source-already-in-use": "There is an existing stream with this External Source",
"not-authenticated": "User is not authenticated",
"unable-to-register-k+": "Unable to register license due to error. Reach out to Kavita+ Support",

View File

@ -64,6 +64,7 @@ public class Program
using var scope = host.Services.CreateScope();
var services = scope.ServiceProvider;
var unitOfWork = services.GetRequiredService<IUnitOfWork>();
try
{
@ -87,10 +88,12 @@ public class Program
await context.Database.MigrateAsync();
await Seed.SeedRoles(services.GetRequiredService<RoleManager<AppRole>>());
await Seed.SeedSettings(context, directoryService);
await Seed.SeedThemes(context);
await Seed.SeedDefaultStreams(services.GetRequiredService<IUnitOfWork>());
await Seed.SeedDefaultStreams(unitOfWork);
await Seed.SeedDefaultSideNavStreams(unitOfWork);
await Seed.SeedUserApiKeys(context);
}
catch (Exception ex)
@ -106,7 +109,6 @@ public class Program
}
// Update the logger with the log level
var unitOfWork = services.GetRequiredService<IUnitOfWork>();
var settings = await unitOfWork.SettingsRepository.GetSettingsDtoAsync();
LogLevelOptions.SwitchLogLevel(settings.LoggingLevel);

View File

@ -0,0 +1,354 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.Data.Repositories;
using API.DTOs.Dashboard;
using API.DTOs.SideNav;
using API.Entities;
using API.Entities.Enums;
using API.SignalR;
using Kavita.Common;
using Kavita.Common.Helpers;
namespace API.Services;
/// <summary>
/// For SideNavStream and DashboardStream manipulation
/// </summary>
public interface IStreamService
{
Task<IEnumerable<DashboardStreamDto>> GetDashboardStreams(int userId, bool visibleOnly = true);
Task<IEnumerable<SideNavStreamDto>> GetSidenavStreams(int userId, bool visibleOnly = true);
Task<IEnumerable<ExternalSourceDto>> GetExternalSources(int userId);
Task<DashboardStreamDto> CreateDashboardStreamFromSmartFilter(int userId, int smartFilterId);
Task UpdateDashboardStream(int userId, DashboardStreamDto dto);
Task UpdateDashboardStreamPosition(int userId, UpdateStreamPositionDto dto);
Task<SideNavStreamDto> CreateSideNavStreamFromSmartFilter(int userId, int smartFilterId);
Task<SideNavStreamDto> CreateSideNavStreamFromExternalSource(int userId, int externalSourceId);
Task UpdateSideNavStream(int userId, SideNavStreamDto dto);
Task UpdateSideNavStreamPosition(int userId, UpdateStreamPositionDto dto);
Task<ExternalSourceDto> CreateExternalSource(int userId, ExternalSourceDto dto);
Task<ExternalSourceDto> UpdateExternalSource(int userId, ExternalSourceDto dto);
Task DeleteExternalSource(int userId, int externalSourceId);
}
public class StreamService : IStreamService
{
private readonly IUnitOfWork _unitOfWork;
private readonly IEventHub _eventHub;
private readonly ILocalizationService _localizationService;
public StreamService(IUnitOfWork unitOfWork, IEventHub eventHub, ILocalizationService localizationService)
{
_unitOfWork = unitOfWork;
_eventHub = eventHub;
_localizationService = localizationService;
}
public async Task<IEnumerable<DashboardStreamDto>> GetDashboardStreams(int userId, bool visibleOnly = true)
{
return await _unitOfWork.UserRepository.GetDashboardStreams(userId, visibleOnly);
}
public async Task<IEnumerable<SideNavStreamDto>> GetSidenavStreams(int userId, bool visibleOnly = true)
{
return await _unitOfWork.UserRepository.GetSideNavStreams(userId, visibleOnly);
}
public async Task<IEnumerable<ExternalSourceDto>> GetExternalSources(int userId)
{
return await _unitOfWork.AppUserExternalSourceRepository.GetExternalSources(userId);
}
public async Task<DashboardStreamDto> CreateDashboardStreamFromSmartFilter(int userId, int smartFilterId)
{
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.DashboardStreams);
if (user == null) throw new KavitaException(await _localizationService.Translate(userId, "no-user"));
var smartFilter = await _unitOfWork.AppUserSmartFilterRepository.GetById(smartFilterId);
if (smartFilter == null) throw new KavitaException(await _localizationService.Translate(userId, "smart-filter-doesnt-exist"));
var stream = user?.DashboardStreams.FirstOrDefault(d => d.SmartFilter?.Id == smartFilterId);
if (stream != null) throw new KavitaException(await _localizationService.Translate(userId, "smart-filter-already-in-use"));
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),
userId);
return ret;
}
public async Task UpdateDashboardStream(int userId, DashboardStreamDto dto)
{
var stream = await _unitOfWork.UserRepository.GetDashboardStream(dto.Id);
if (stream == null) throw new KavitaException(await _localizationService.Translate(userId, "dashboard-stream-doesnt-exist"));
stream.Visible = dto.Visible;
_unitOfWork.UserRepository.Update(stream);
await _unitOfWork.CommitAsync();
await _eventHub.SendMessageToAsync(MessageFactory.DashboardUpdate, MessageFactory.DashboardUpdateEvent(userId),
userId);
}
public async Task UpdateDashboardStreamPosition(int userId, UpdateStreamPositionDto dto)
{
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId,
AppUserIncludes.DashboardStreams);
var stream = user?.DashboardStreams.FirstOrDefault(d => d.Id == dto.Id);
if (stream == null)
throw new KavitaException(await _localizationService.Translate(userId, "dashboard-stream-doesnt-exist"));
if (stream.Order == dto.ToPosition) return ;
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);
}
public async Task<SideNavStreamDto> CreateSideNavStreamFromSmartFilter(int userId, int smartFilterId)
{
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.SideNavStreams);
if (user == null) throw new KavitaException(await _localizationService.Translate(userId, "no-user"));
var smartFilter = await _unitOfWork.AppUserSmartFilterRepository.GetById(smartFilterId);
if (smartFilter == null) throw new KavitaException(await _localizationService.Translate(userId, "smart-filter-doesnt-exist"));
var stream = user?.SideNavStreams.FirstOrDefault(d => d.SmartFilter?.Id == smartFilterId);
if (stream != null) throw new KavitaException(await _localizationService.Translate(userId, "smart-filter-already-in-use"));
var maxOrder = user!.SideNavStreams.Max(d => d.Order);
var createdStream = new AppUserSideNavStream()
{
Name = smartFilter.Name,
IsProvided = false,
StreamType = SideNavStreamType.SmartFilter,
Visible = true,
Order = maxOrder + 1,
SmartFilter = smartFilter
};
user.SideNavStreams.Add(createdStream);
_unitOfWork.UserRepository.Update(user);
await _unitOfWork.CommitAsync();
var ret = new SideNavStreamDto()
{
Name = createdStream.Name,
IsProvided = createdStream.IsProvided,
Visible = createdStream.Visible,
Order = createdStream.Order,
SmartFilterEncoded = smartFilter.Filter,
StreamType = createdStream.StreamType
};
await _eventHub.SendMessageToAsync(MessageFactory.SideNavUpdate, MessageFactory.SideNavUpdateEvent(userId),
userId);
return ret;
}
public async Task<SideNavStreamDto> CreateSideNavStreamFromExternalSource(int userId, int externalSourceId)
{
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.SideNavStreams);
if (user == null) throw new KavitaException(await _localizationService.Translate(userId, "no-user"));
var externalSource = await _unitOfWork.AppUserExternalSourceRepository.GetById(externalSourceId);
if (externalSource == null) throw new KavitaException(await _localizationService.Translate(userId, "external-source-doesnt-exist"));
var stream = user?.SideNavStreams.FirstOrDefault(d => d.ExternalSourceId == externalSourceId);
if (stream != null) throw new KavitaException(await _localizationService.Translate(userId, "external-source-already-in-use"));
var maxOrder = user!.SideNavStreams.Max(d => d.Order);
var createdStream = new AppUserSideNavStream()
{
Name = externalSource.Name,
IsProvided = false,
StreamType = SideNavStreamType.ExternalSource,
Visible = true,
Order = maxOrder + 1,
ExternalSourceId = externalSource.Id
};
user.SideNavStreams.Add(createdStream);
_unitOfWork.UserRepository.Update(user);
await _unitOfWork.CommitAsync();
var ret = new SideNavStreamDto()
{
Name = createdStream.Name,
IsProvided = createdStream.IsProvided,
Visible = createdStream.Visible,
Order = createdStream.Order,
StreamType = createdStream.StreamType,
ExternalSource = new ExternalSourceDto()
{
Host = externalSource.Host,
Id = externalSource.Id,
Name = externalSource.Name,
ApiKey = externalSource.ApiKey
}
};
await _eventHub.SendMessageToAsync(MessageFactory.SideNavUpdate, MessageFactory.SideNavUpdateEvent(userId),
userId);
return ret;
}
public async Task UpdateSideNavStream(int userId, SideNavStreamDto dto)
{
var stream = await _unitOfWork.UserRepository.GetSideNavStream(dto.Id);
if (stream == null)
throw new KavitaException(await _localizationService.Translate(userId, "sidenav-stream-doesnt-exist"));
stream.Visible = dto.Visible;
_unitOfWork.UserRepository.Update(stream);
await _unitOfWork.CommitAsync();
await _eventHub.SendMessageToAsync(MessageFactory.SideNavUpdate, MessageFactory.SideNavUpdateEvent(userId),
userId);
}
public async Task UpdateSideNavStreamPosition(int userId, UpdateStreamPositionDto dto)
{
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId,
AppUserIncludes.SideNavStreams);
var stream = user?.SideNavStreams.FirstOrDefault(d => d.Id == dto.Id);
if (stream == null) throw new KavitaException(await _localizationService.Translate(userId, "sidenav-stream-doesnt-exist"));
if (stream.Order == dto.ToPosition) return;
var list = user!.SideNavStreams.ToList();
ReorderItems(list, stream.Id, dto.ToPosition);
user.SideNavStreams = list;
_unitOfWork.UserRepository.Update(user);
await _unitOfWork.CommitAsync();
await _eventHub.SendMessageToAsync(MessageFactory.SideNavUpdate, MessageFactory.SideNavUpdateEvent(userId),
userId);
}
public async Task<ExternalSourceDto> CreateExternalSource(int userId, ExternalSourceDto dto)
{
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId,
AppUserIncludes.ExternalSources);
if (user == null) throw new KavitaException("not-authenticated");
if (user.ExternalSources.Any(s => s.Host == dto.Host))
{
throw new KavitaException("external-source-already-exists");
}
if (string.IsNullOrEmpty(dto.ApiKey) || string.IsNullOrEmpty(dto.Name)) throw new KavitaException("external-source-required");
if (!UrlHelper.StartsWithHttpOrHttps(dto.Host)) throw new KavitaException("external-source-host-format");
var newSource = new AppUserExternalSource()
{
Name = dto.Name,
Host = UrlHelper.EnsureEndsWithSlash(
UrlHelper.EnsureStartsWithHttpOrHttps(dto.Host)),
ApiKey = dto.ApiKey
};
user.ExternalSources.Add(newSource);
_unitOfWork.UserRepository.Update(user);
await _unitOfWork.CommitAsync();
dto.Id = newSource.Id;
return dto;
}
public async Task<ExternalSourceDto> UpdateExternalSource(int userId, ExternalSourceDto dto)
{
var source = await _unitOfWork.AppUserExternalSourceRepository.GetById(dto.Id);
if (source == null) throw new KavitaException("external-source-doesnt-exist");
if (source.AppUserId != userId) throw new KavitaException("external-source-doesnt-exist");
if (string.IsNullOrEmpty(dto.ApiKey) || string.IsNullOrEmpty(dto.Host) || string.IsNullOrEmpty(dto.Name)) throw new KavitaException("external-source-required");
source.Host = UrlHelper.EnsureEndsWithSlash(
UrlHelper.EnsureStartsWithHttpOrHttps(dto.Host));
source.ApiKey = dto.ApiKey;
source.Name = dto.Name;
_unitOfWork.AppUserExternalSourceRepository.Update(source);
await _unitOfWork.CommitAsync();
dto.Host = source.Host;
return dto;
}
public async Task DeleteExternalSource(int userId, int externalSourceId)
{
var source = await _unitOfWork.AppUserExternalSourceRepository.GetById(externalSourceId);
if (source == null) throw new KavitaException("external-source-doesnt-exist");
if (source.AppUserId != userId) throw new KavitaException("external-source-doesnt-exist");
_unitOfWork.AppUserExternalSourceRepository.Delete(source);
// Find all SideNav's with this source and delete them as well
var streams2 = await _unitOfWork.UserRepository.GetSideNavStreamWithExternalSource(externalSourceId);
_unitOfWork.UserRepository.Delete(streams2);
await _unitOfWork.CommitAsync();
}
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;
}
}
private static void ReorderItems(List<AppUserSideNavStream> 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

@ -5,6 +5,7 @@ using System.Threading.Tasks;
using API.DTOs.Update;
using API.SignalR;
using Flurl.Http;
using HtmlAgilityPack;
using Kavita.Common.EnvironmentInfo;
using Kavita.Common.Helpers;
using MarkdownDeep;
@ -103,6 +104,7 @@ public class VersionUpdaterService : IVersionUpdaterService
};
}
public async Task PushUpdate(UpdateNotificationDto? update)
{
if (update == null) return;

View File

@ -126,6 +126,10 @@ public static class MessageFactory
/// Order, Visibility, etc has changed on the Dashboard. UI will refresh the layout
/// </summary>
public const string DashboardUpdate = "DashboardUpdate";
/// <summary>
/// Order, Visibility, etc has changed on the Sidenav. UI will refresh the layout
/// </summary>
public const string SideNavUpdate = "SideNavUpdate";
public static SignalRMessage DashboardUpdateEvent(int userId)
{
@ -142,6 +146,21 @@ public static class MessageFactory
};
}
public static SignalRMessage SideNavUpdateEvent(int userId)
{
return new SignalRMessage()
{
Name = SideNavUpdate,
Title = "SideNav Update",
Progress = ProgressType.None,
EventType = ProgressEventType.Single,
Body = new
{
UserId = userId
}
};
}
public static SignalRMessage ScanSeriesEvent(int libraryId, int seriesId, string seriesName)
{

View File

@ -250,6 +250,9 @@ public class Startup
// v0.7.6
await MigrateExistingRatings.Migrate(dataContext, logger);
// v0.7.9
await MigrateUserLibrarySideNavStream.Migrate(unitOfWork, dataContext, logger);
// Update the version in the DB after all migrations are run
var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion);
installVersion.Value = BuildInfo.Version.ToString();

View File

@ -2,6 +2,7 @@
using System.IO;
using System.Text.Json;
using Kavita.Common.EnvironmentInfo;
using Kavita.Common.Helpers;
using Microsoft.Extensions.Hosting;
namespace Kavita.Common;
@ -214,13 +215,8 @@ public static class Configuration
var baseUrl = jsonObj.BaseUrl;
if (!string.IsNullOrEmpty(baseUrl))
{
baseUrl = !baseUrl.StartsWith('/')
? $"/{baseUrl}"
: baseUrl;
baseUrl = !baseUrl.EndsWith('/')
? $"{baseUrl}/"
: baseUrl;
baseUrl = UrlHelper.EnsureStartsWithSlash(baseUrl);
baseUrl = UrlHelper.EnsureEndsWithSlash(baseUrl);
return baseUrl;
}

View File

@ -0,0 +1,41 @@
namespace Kavita.Common.Helpers;
#nullable enable
public static class UrlHelper
{
public static bool StartsWithHttpOrHttps(string? url)
{
if (string.IsNullOrEmpty(url)) return false;
return url.StartsWith("http://") || url.StartsWith("https://");
}
public static string? EnsureStartsWithHttpOrHttps(string? url)
{
if (string.IsNullOrEmpty(url)) return url;
if (!url.StartsWith("http://") && !url.StartsWith("https://"))
{
// URL doesn't start with "http://" or "https://", so add "http://"
return "http://" + url;
}
return url;
}
public static string? EnsureEndsWithSlash(string? url)
{
if (string.IsNullOrEmpty(url)) return url;
return !url.EndsWith('/')
? $"{url}/"
: url;
}
public static string? EnsureStartsWithSlash(string? url)
{
if (string.IsNullOrEmpty(url)) return url;
return !url.StartsWith('/')
? $"/{url}"
: url;
}
}

View File

@ -31,12 +31,13 @@ your reading collection with your friends and family!
- Ability to manage users with rich Role-based management for age restrictions, abilities within the app, etc
- Rich web readers supporting webtoon, continuous reading mode (continue without leaving the reader), virtual pages (epub), etc
- Full Localization Support
- Ability to customize your dashboard and side nav with smart filters
## Support
[![Reddit](https://img.shields.io/badge/reddit-discussion-FF4500.svg?maxAge=60)](https://www.reddit.com/r/KavitaManga/)
[![Discord](https://img.shields.io/badge/discord-chat-7289DA.svg?maxAge=60)](https://discord.gg/eczRp9eeem)
[![GitHub - Bugs and Feature Requests Only](https://img.shields.io/badge/github-issues-red.svg?maxAge=60)](https://github.com/Kareadita/Kavita/issues)
[![GitHub - Bugs Only](https://img.shields.io/badge/github-issues-red.svg?maxAge=60)](https://github.com/Kareadita/Kavita/issues)
## Demo
If you want to try out Kavita, we have a demo up:
@ -102,9 +103,6 @@ Thank you to [<img src="/Logo/jetbrains.svg" alt="" width="32"> JetBrains](http:
* [<img src="/Logo/rider.svg" alt="" width="32"> Rider](http://www.jetbrains.com/rider/)
* [<img src="/Logo/dottrace.svg" alt="" width="32"> dotTrace](http://www.jetbrains.com/dottrace/)
## Palace-Designs
We would like to extend a big thank you to [<img src="/Logo/hosting-sponsor.png" alt="" width="128">](https://www.palace-designs.com/) who hosts our infrastructure pro-bono.
## Localization
Thank you to [Weblate](https://hosted.weblate.org/engage/kavita/) who hosts our localization infrastructure pro-bono. If you want to see Kavita in your language, please help us localize.

View File

@ -0,0 +1,8 @@
export interface CommonStream {
id: number;
name: string;
isProvided: boolean;
order: number;
visible: boolean;
smartFilterEncoded?: string;
}

View File

@ -1,7 +1,8 @@
import {Observable} from "rxjs";
import {StreamType} from "./stream-type.enum";
import {CommonStream} from "../common-stream";
export interface DashboardStream {
export interface DashboardStream extends CommonStream {
id: number;
name: string;
isProvided: boolean;
@ -12,3 +13,5 @@ export interface DashboardStream {
order: number;
visible: boolean;
}

View File

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

View File

@ -0,0 +1,6 @@
export interface ExternalSource {
id: number;
name: string;
host: string;
apiKey: string;
}

View File

@ -0,0 +1,10 @@
export enum SideNavStreamType {
Collections = 1,
ReadingLists = 2,
Bookmarks = 3,
Library = 4,
SmartFilter = 5,
ExternalSource = 6,
AllSeries = 7,
WantToRead = 8,
}

View File

@ -0,0 +1,18 @@
import {SideNavStreamType} from "./sidenav-stream-type.enum";
import {Library, LibraryType} from "../library";
import {CommonStream} from "../common-stream";
import {ExternalSource} from "./external-source";
export interface SideNavStream extends CommonStream {
name: string;
order: number;
libraryId?: number;
isProvided: boolean;
streamType: SideNavStreamType;
library?: Library;
visible: boolean;
smartFilterId: number;
smartFilterEncoded?: string;
externalSource?: ExternalSource;
}

View File

@ -12,18 +12,18 @@ export class DashboardService {
constructor(private httpClient: HttpClient) { }
getDashboardStreams(visibleOnly = true) {
return this.httpClient.get<Array<DashboardStream>>(this.baseUrl + 'account/dashboard?visibleOnly=' + visibleOnly);
return this.httpClient.get<Array<DashboardStream>>(this.baseUrl + 'stream/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);
return this.httpClient.post(this.baseUrl + 'stream/update-dashboard-position', {streamName, id: dashboardStreamId, fromPosition, toPosition}, TextResonse);
}
updateDashboardStream(stream: DashboardStream) {
return this.httpClient.post(this.baseUrl + 'account/update-dashboard-stream', stream, TextResonse);
return this.httpClient.post(this.baseUrl + 'stream/update-dashboard-stream', stream, TextResonse);
}
createDashboardStream(smartFilterId: number) {
return this.httpClient.post<DashboardStream>(this.baseUrl + 'account/add-dashboard-stream?smartFilterId=' + smartFilterId, {});
return this.httpClient.post<DashboardStream>(this.baseUrl + 'stream/add-dashboard-stream?smartFilterId=' + smartFilterId, {});
}
}

View File

@ -8,6 +8,7 @@ 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";
import {SideNavUpdateEvent} from "../_models/events/sidenav-update-event";
export enum EVENTS {
UpdateAvailable = 'UpdateAvailable',
@ -86,7 +87,11 @@ export enum EVENTS {
/**
* User's dashboard needs to be re-rendered
*/
DashboardUpdate = 'DashboardUpdate'
DashboardUpdate = 'DashboardUpdate',
/**
* User's sidenav needs to be re-rendered
*/
SideNavUpdate = 'SideNavUpdate'
}
export interface Message<T> {
@ -187,6 +192,12 @@ export class MessageHubService {
payload: resp.body as DashboardUpdateEvent
});
});
this.hubConnection.on(EVENTS.SideNavUpdate, resp => {
this.messagesSource.next({
event: EVENTS.SideNavUpdate,
payload: resp.body as SideNavUpdateEvent
});
});
this.hubConnection.on(EVENTS.NotificationProgress, (resp: NotificationProgressEvent) => {
this.messagesSource.next({

View File

@ -1,6 +1,11 @@
import { DOCUMENT } from '@angular/common';
import { Inject, Injectable, Renderer2, RendererFactory2 } from '@angular/core';
import { ReplaySubject, take } from 'rxjs';
import {HttpClient} from "@angular/common/http";
import {environment} from "../../environments/environment";
import {SideNavStream} from "../_models/sidenav/sidenav-stream";
import {TextResonse} from "../_types/text-response";
import {DashboardStream} from "../_models/dashboard/dashboard-stream";
@Injectable({
providedIn: 'root'
@ -27,15 +32,36 @@ export class NavService {
sideNavVisibility$ = this.sideNavVisibilitySource.asObservable();
private renderer: Renderer2;
baseUrl = environment.apiUrl;
constructor(@Inject(DOCUMENT) private document: Document, rendererFactory: RendererFactory2) {
constructor(@Inject(DOCUMENT) private document: Document, rendererFactory: RendererFactory2, private httpClient: HttpClient) {
this.renderer = rendererFactory.createRenderer(null, null);
this.showNavBar();
const sideNavState = (localStorage.getItem(this.localStorageSideNavKey) === 'true') || false;
this.sideNavCollapseSource.next(sideNavState);
this.showSideNav();
}
getSideNavStreams(visibleOnly = true) {
return this.httpClient.get<Array<SideNavStream>>(this.baseUrl + 'stream/sidenav?visibleOnly=' + visibleOnly);
}
updateSideNavStreamPosition(streamName: string, sideNavStreamId: number, fromPosition: number, toPosition: number) {
return this.httpClient.post(this.baseUrl + 'stream/update-sidenav-position', {streamName, id: sideNavStreamId, fromPosition, toPosition}, TextResonse);
}
updateSideNavStream(stream: SideNavStream) {
return this.httpClient.post(this.baseUrl + 'stream/update-sidenav-stream', stream, TextResonse);
}
createSideNavStream(smartFilterId: number) {
return this.httpClient.post<SideNavStream>(this.baseUrl + 'stream/add-sidenav-stream?smartFilterId=' + smartFilterId, {});
}
createSideNavStreamFromExternalSource(externalSourceId: number) {
return this.httpClient.post<SideNavStream>(this.baseUrl + 'stream/add-sidenav-stream-from-external-source?externalSourceId=' + externalSourceId, {});
}
/**
* Shows the top nav bar. This should be visible on all pages except the reader.
*/
@ -47,7 +73,7 @@ export class NavService {
}
/**
* Hides the top nav bar.
* Hides the top nav bar.
*/
hideNavBar() {
this.renderer.setStyle(this.document.querySelector('body'), 'margin-top', '0px');

View File

@ -9,13 +9,17 @@
::ng-deep .changelog {
h1 {
font-size: 26px;
font-size: 26px;
}
p, ul {
margin-bottom: 0px;
}
img {
max-width: 100% !important;
}
}

View File

@ -89,7 +89,9 @@
<label for="release-year" class="form-label">{{t('release-year-label')}}</label>
<div class="input-group {{metadata.releaseYearLocked ? 'lock-active' : ''}}">
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: metadata, field: 'releaseYearLocked' }"></ng-container>
<input type="number" inputmode="numeric" class="form-control" id="release-year" formControlName="releaseYear" maxlength="4" minlength="4" [class.is-invalid]="editSeriesForm.get('releaseYear')?.invalid && editSeriesForm.get('releaseYear')?.touched">
<input type="number" inputmode="numeric" class="form-control" id="release-year" formControlName="releaseYear"
maxlength="4" minlength="4"
[class.is-invalid]="editSeriesForm.get('releaseYear')?.invalid && editSeriesForm.get('releaseYear')?.touched">
<ng-container *ngIf="editSeriesForm.get('releaseYear')?.errors as errors">
<p class="invalid-feedback" *ngIf="errors.pattern">
This must be a valid year greater than 1000 and 4 characters long

View File

@ -0,0 +1,36 @@
import { Injectable } from '@angular/core';
import {environment} from "../environments/environment";
import {HttpClient} from "@angular/common/http";
import {ExternalSource} from "./_models/sidenav/external-source";
import {TextResonse} from "./_types/text-response";
import {map} from "rxjs/operators";
@Injectable({
providedIn: 'root'
})
export class ExternalSourceService {
baseUrl = environment.apiUrl;
constructor(private httpClient: HttpClient) { }
getExternalSources() {
return this.httpClient.get<Array<ExternalSource>>(this.baseUrl + 'stream/external-sources');
}
createSource(source: ExternalSource) {
return this.httpClient.post<ExternalSource>(this.baseUrl + 'stream/create-external-source', source);
}
updateSource(source: ExternalSource) {
return this.httpClient.post<ExternalSource>(this.baseUrl + 'stream/update-external-source', source);
}
deleteSource(externalSourceId: number) {
return this.httpClient.delete(this.baseUrl + 'stream/delete-external-source?externalSourceId=' + externalSourceId);
}
sourceExists(name: string, host: string, apiKey: string) {
return this.httpClient.get<string>(this.baseUrl + `stream/external-source-exists?host=${encodeURIComponent(host)}&name=${name}&apiKey=${apiKey}`, TextResonse)
.pipe(map(s => s == 'true'));
}
}

View File

@ -443,7 +443,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
get LayoutMode() { return LayoutMode; }
get ReadingDirection() { return ReadingDirection; }
get Breakpoint() { return Breakpoint; }
get FittingOption() { return this.generalSettingsForm.get('fittingOption')?.value || FITTING_OPTION.HEIGHT; }
get FittingOption() { return this.generalSettingsForm?.get('fittingOption')?.value || FITTING_OPTION.HEIGHT; }
get ReadingAreaWidth() {
return this.readingArea?.nativeElement.scrollWidth - this.readingArea?.nativeElement.clientWidth;
}

View File

@ -0,0 +1,15 @@
import { Pipe, PipeTransform } from '@angular/core';
import {translate} from "@ngneat/transloco";
@Pipe({
name: 'streamName',
standalone: true,
pure: true
})
export class StreamNamePipe implements PipeTransform {
transform(value: string): unknown {
return translate('stream-pipe.' + value);
}
}

View File

@ -15,7 +15,7 @@
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
<button class="btn btn-icon float-end" (click)="removeItem(item, i)" *ngIf="showRemoveButton">
<button class="btn btn-icon float-end" (click)="removeItem(item, i)" *ngIf="showRemoveButton" [disabled]="disabled">
<i class="fa fa-times" aria-hidden="true"></i>
<span class="visually-hidden" attr.aria-labelledby="item.id--{{i}}">{{t('remove-item-alt')}}</span>
</button>
@ -34,12 +34,12 @@
<input id="reorder-{{i}}" class="form-control" type="number" inputmode="numeric" min="0" [max]="items.length - 1" [value]="i" style="width: 60px"
(focusout)="updateIndex(i, item)" (keydown.enter)="updateIndex(i, item)" aria-describedby="instructions">
</div>
<i *ngIf="!accessibilityMode" class="fa fa-grip-vertical drag-handle" aria-hidden="true" cdkDragHandle></i>
<i *ngIf="!accessibilityMode && !disabled" class="fa fa-grip-vertical drag-handle" aria-hidden="true" cdkDragHandle></i>
</div>
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
<button class="btn btn-icon float-end" (click)="removeItem(item, i)" *ngIf="showRemoveButton">
<button class="btn btn-icon float-end" (click)="removeItem(item, i)" *ngIf="showRemoveButton" [disabled]="disabled">
<i class="fa fa-times" aria-hidden="true"></i>
<span class="visually-hidden" attr.aria-labelledby="item.id--{{i}}">{{t('remove-item-alt')}}</span>
</button>

View File

@ -8,6 +8,7 @@ export interface IndexUpdateEvent {
fromPosition: number;
toPosition: number;
item: any;
fromAccessibilityMode: boolean;
}
export interface ItemRemoveEvent {
@ -35,6 +36,10 @@ export class DraggableOrderedListComponent {
* Parent scroll for virtualize pagination
*/
@Input() parentScroll!: Element | Window;
/**
* Disables drag and drop functionality. Useful if a filter is present which will skew actual index.
*/
@Input() disabled: boolean = false;
@Input() trackByIdentity: TrackByFunction<any> = (index: number, item: any) => `${item.id}_${item.order}_${item.title}`;
@Output() orderUpdated: EventEmitter<IndexUpdateEvent> = new EventEmitter<IndexUpdateEvent>();
@Output() itemRemove: EventEmitter<ItemRemoveEvent> = new EventEmitter<ItemRemoveEvent>();
@ -52,21 +57,23 @@ export class DraggableOrderedListComponent {
this.orderUpdated.emit({
fromPosition: event.previousIndex,
toPosition: event.currentIndex,
item: this.items[event.currentIndex]
item: this.items[event.currentIndex],
fromAccessibilityMode: false
});
this.cdRef.markForCheck();
}
updateIndex(previousIndex: number, item: any) {
// get the new value of the input
var inputElem = <HTMLInputElement>document.querySelector('#reorder-' + previousIndex);
const inputElem = <HTMLInputElement>document.querySelector('#reorder-' + previousIndex);
const newIndex = parseInt(inputElem.value, 10);
if (previousIndex === newIndex) return;
moveItemInArray(this.items, previousIndex, newIndex);
this.orderUpdated.emit({
fromPosition: previousIndex,
toPosition: newIndex,
item: this.items[newIndex]
item: this.items[newIndex],
fromAccessibilityMode: true
});
this.cdRef.markForCheck();
}

View File

@ -2,8 +2,13 @@
width: 100%;
word-wrap: break-word;
white-space: pre-wrap;
}
img {
max-width: 100%;
::ng-deep .update-body {
img {
max-width: 100%;
}
}

View File

@ -1,29 +1,37 @@
<ng-container *transloco="let t; read: 'customize-dashboard-modal'">
<div class="modal-header">
<h4 class="modal-title">{{t('title')}}</h4>
<h4 class="modal-title">{{t('title-' + activeTab)}}</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>
{{t('add')}}
</button>
<div class="modal-body scrollable-modal {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? '' : 'd-flex'}}">
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTab" class="nav-pills" orientation="{{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'horizontal' : 'vertical'}}" style="min-width: 154px;">
<li [ngbNavItem]="TabID.Dashboard">
<a ngbNavLink>{{t(TabID.Dashboard)}}</a>
<ng-template ngbNavContent>
<app-customize-dashboard-streams></app-customize-dashboard-streams>
</ng-template>
</li>
<li class="list-group-item" *ngIf="smartFilters.length === 0">
{{t('no-data')}}
<li [ngbNavItem]="TabID.SideNav">
<a ngbNavLink>{{t(TabID.SideNav)}}</a>
<ng-template ngbNavContent>
<app-customize-sidenav-streams></app-customize-sidenav-streams>
</ng-template>
</li>
<li [ngbNavItem]="TabID.SmartFilters">
<a ngbNavLink>{{t(TabID.SmartFilters)}}</a>
<ng-template ngbNavContent>
<app-manage-smart-filters></app-manage-smart-filters>
</ng-template>
</li>
<li [ngbNavItem]="TabID.ExternalSources">
<a ngbNavLink>{{t(TabID.ExternalSources)}}</a>
<ng-template ngbNavContent>
<app-manage-external-sources></app-manage-external-sources>
</ng-template>
</li>
</ul>
<div [ngbNavOutlet]="nav" class="tab-content {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'mt-3' : 'ms-4 flex-fill'}}"></div>
</div>
<div class="modal-footer">

View File

@ -1,24 +1 @@
::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

@ -2,7 +2,7 @@ import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject} from '@an
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 {NgbActiveModal, NgbNav, NgbNavContent, NgbNavItem, NgbNavLink, NgbNavOutlet} from "@ng-bootstrap/ng-bootstrap";
import {
DraggableOrderedListComponent,
IndexUpdateEvent
@ -12,60 +12,43 @@ import {
} 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 {DashboardStreamListItemComponent} from "../dashboard-stream-list-item/dashboard-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";
import {Breakpoint, UtilityService} from "../../../shared/_services/utility.service";
import {CustomizeDashboardStreamsComponent} from "../customize-dashboard-streams/customize-dashboard-streams.component";
import {CustomizeSidenavStreamsComponent} from "../customize-sidenav-streams/customize-sidenav-streams.component";
import {ManageExternalSourcesComponent} from "../manage-external-sources/manage-external-sources.component";
import {ManageSmartFiltersComponent} from "../manage-smart-filters/manage-smart-filters.component";
enum TabID {
Dashboard = 'dashboard',
SideNav = 'sidenav',
SmartFilters = 'smart-filters',
ExternalSources = 'external-sources'
}
@Component({
selector: 'app-customize-dashboard-modal',
standalone: true,
imports: [CommonModule, SafeHtmlPipe, TranslocoDirective, DraggableOrderedListComponent, ReadingListItemComponent, StreamListItemComponent],
imports: [CommonModule, SafeHtmlPipe, TranslocoDirective, DraggableOrderedListComponent, ReadingListItemComponent, DashboardStreamListItemComponent,
NgbNav, NgbNavContent, NgbNavLink, NgbNavItem, NgbNavOutlet, CustomizeDashboardStreamsComponent, CustomizeSidenavStreamsComponent, ManageExternalSourcesComponent, ManageSmartFiltersComponent],
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;
activeTab = TabID.Dashboard;
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();
}
public readonly utilityService = inject(UtilityService);
private readonly modal = inject(NgbActiveModal);
protected readonly TabID = TabID;
protected readonly Breakpoint = Breakpoint;
close() {
this.modal.close();
}
}

View File

@ -0,0 +1,31 @@
<ng-container *transloco="let t; read: 'customize-dashboard-streams'">
<app-draggable-ordered-list [items]="items" (orderUpdated)="orderUpdated($event)" [accessibilityMode]="accessibilityMode"
[showRemoveButton]="false">
<ng-template #draggableItem let-position="idx" let-item>
<app-dashboard-stream-list-item [item]="item" [position]="position" (hide)="updateVisibility($event, position)"></app-dashboard-stream-list-item>
</ng-template>
</app-draggable-ordered-list>
<h5>Smart Filters</h5>
<form [formGroup]="listForm">
<div class="mb-3" *ngIf="smartFilters.length >= 3">
<label for="filter" class="form-label">{{t('filter')}}</label>
<div class="input-group">
<input id="filter" autocomplete="off" class="form-control" formControlName="filterQuery" type="text" aria-describedby="reset-input">
<button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="resetFilter()">{{t('clear')}}</button>
</div>
</div>
</form>
<ul class="list-group filter-list">
<li class="filter list-group-item" *ngFor="let filter of smartFilters | filter: filterList">
{{filter.name}}
<button class="btn btn-icon" (click)="addFilterToStream(filter)">
<i class="fa fa-plus" aria-hidden="true"></i>
{{t('add')}}
</button>
</li>
<li class="list-group-item" *ngIf="smartFilters.length === 0">
{{t('no-data')}}
</li>
</ul>
</ng-container>

View File

@ -0,0 +1,24 @@
::ng-deep .drag-handle {
margin-top: 100% !important;
}
app-dashboard-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,77 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject} from '@angular/core';
import {CommonModule} from '@angular/common';
import {
DraggableOrderedListComponent, IndexUpdateEvent
} from "../../../reading-list/_components/draggable-ordered-list/draggable-ordered-list.component";
import {DashboardStreamListItemComponent} from "../dashboard-stream-list-item/dashboard-stream-list-item.component";
import {DashboardStream} from "../../../_models/dashboard/dashboard-stream";
import {SmartFilter} from "../../../_models/metadata/v2/smart-filter";
import {DashboardService} from "../../../_services/dashboard.service";
import {FilterService} from "../../../_services/filter.service";
import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap";
import {forkJoin} from "rxjs";
import {TranslocoDirective} from "@ngneat/transloco";
import {CommonStream} from "../../../_models/common-stream";
import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
import {FilterPipe} from "../../../pipe/filter.pipe";
@Component({
selector: 'app-customize-dashboard-streams',
standalone: true,
imports: [CommonModule, DraggableOrderedListComponent, DashboardStreamListItemComponent, TranslocoDirective, ReactiveFormsModule, FilterPipe],
templateUrl: './customize-dashboard-streams.component.html',
styleUrls: ['./customize-dashboard-streams.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CustomizeDashboardStreamsComponent {
items: DashboardStream[] = [];
smartFilters: SmartFilter[] = [];
accessibilityMode: boolean = false;
private readonly dashboardService = inject(DashboardService);
private readonly filterService = inject(FilterService);
private readonly cdRef = inject(ChangeDetectorRef);
listForm: FormGroup = new FormGroup({
'filterQuery': new FormControl('', [])
});
filterList = (listItem: SmartFilter) => {
const filterVal = (this.listForm.value.filterQuery || '').toLowerCase();
return listItem.name.toLowerCase().indexOf(filterVal) >= 0;
}
resetFilter() {
this.listForm.get('filterQuery')?.setValue('');
this.cdRef.markForCheck();
}
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 = [...this.items, stream];
this.cdRef.markForCheck();
});
}
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();
}
}

View File

@ -0,0 +1,70 @@
<ng-container *transloco="let t; read: 'customize-sidenav-streams'">
<form [formGroup]="listForm">
<div class="row g-0 mb-3 justify-content-between">
<div class="col-9" *ngIf="items.length >= 3">
<label for="sidenav-stream-filter" class="form-label">{{t('filter')}}</label>
<div class="input-group">
<input id="sidenav-stream-filter" autocomplete="off" class="form-control" formControlName="filterSideNavStream" type="text" aria-describedby="reset-sidenav-stream-input">
<button class="btn btn-outline-secondary" type="button" id="reset-sidenav-stream-input" (click)="resetSideNavFilter()">{{t('clear')}}</button>
</div>
<span role="alert" class="mt-1" *ngIf="listForm.get('filterSideNavStream')?.value">{{t('reorder-when-filter-present')}}</span>
</div>
<div class="col-3">
<div class="form-check form-check-inline" style="margin-top: 35px; margin-left: 10px">
<input class="form-check-input" type="checkbox" id="accessibility-mode" [value]="accessibilityMode" (change)="updateAccessibilityMode()">
<label class="form-check-label" for="accessibility-mode">{{t('order-numbers-label')}}</label>
</div>
</div>
</div>
<app-draggable-ordered-list [items]="items | filter: filterSideNavStreams" (orderUpdated)="orderUpdated($event)" [accessibilityMode]="accessibilityMode"
[showRemoveButton]="false" [disabled]="listForm.get('filterSideNavStream')?.value">
<ng-template #draggableItem let-position="idx" let-item>
<app-sidenav-stream-list-item [item]="item" [position]="position" (hide)="updateVisibility($event, position)"></app-sidenav-stream-list-item>
</ng-template>
</app-draggable-ordered-list>
<h5>{{t('smart-filters-title')}}</h5>
<div class="mb-3" *ngIf="smartFilters.length >= 6">
<label for="smart-filter-filter" class="form-label">{{t('filter')}}</label>
<div class="input-group">
<input id="smart-filter-filter" autocomplete="off" class="form-control" formControlName="filterSmartFilter" type="text" aria-describedby="reset-smart-filter-input">
<button class="btn btn-outline-secondary" type="button" id="reset-smart-filter-input" (click)="resetSmartFilterFilter()">{{t('clear')}}</button>
</div>
<span role="alert" class="mt-1" *ngIf="listForm.get('filterSideNavStream')?.value">{{t('reorder-when-filter-present')}}</span>
</div>
<ul class="list-group filter-list">
<li class="filter list-group-item" *ngFor="let filter of smartFilters | filter: filterSmartFilters">
{{filter.name}}
<button class="btn btn-icon" (click)="addFilterToStream(filter)">
<i class="fa fa-plus" aria-hidden="true"></i>
{{t('add')}}
</button>
</li>
<li class="list-group-item" *ngIf="smartFilters.length === 0">
{{t('no-data')}}
</li>
</ul>
<h5 class="mt-3">{{t('external-sources-title')}}</h5>
<div class="mb-3" *ngIf="externalSources.length >= 6">
<label for="external-source-filter" class="form-label">{{t('filter')}}</label>
<div class="input-group">
<input id="external-source-filter" autocomplete="off" class="form-control" formControlName="filterSmartFilter" type="text" aria-describedby="reset-external-source-input">
<button class="btn btn-outline-secondary" type="button" id="reset-external-source-input" (click)="resetExternalSourceFilter()">{{t('clear')}}</button>
</div>
<span role="alert" class="mt-1" *ngIf="listForm.get('filterSideNavStream')?.value">{{t('reorder-when-filter-present')}}</span>
</div>
<ul class="list-group filter-list">
<li class="filter list-group-item" *ngFor="let source of externalSources | filter: filterExternalSources">
{{source.host}}
<button class="btn btn-icon" (click)="addExternalSourceToStream(source)">
<i class="fa fa-plus" aria-hidden="true"></i>
{{t('add')}}
</button>
</li>
<li class="list-group-item" *ngIf="externalSources.length === 0">
{{t('no-data-external-source')}}
</li>
</ul>
</form>
</ng-container>

View File

@ -0,0 +1,24 @@
::ng-deep .drag-handle {
margin-top: 100% !important;
}
app-sidenav-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,135 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject} from '@angular/core';
import {CommonModule} from '@angular/common';
import {SmartFilter} from "../../../_models/metadata/v2/smart-filter";
import {FilterService} from "../../../_services/filter.service";
import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap";
import {forkJoin} from "rxjs";
import {
DraggableOrderedListComponent,
IndexUpdateEvent
} from "../../../reading-list/_components/draggable-ordered-list/draggable-ordered-list.component";
import {SideNavStream} from "../../../_models/sidenav/sidenav-stream";
import {NavService} from "../../../_services/nav.service";
import {DashboardStreamListItemComponent} from "../dashboard-stream-list-item/dashboard-stream-list-item.component";
import {CommonStream} from "../../../_models/common-stream";
import {TranslocoDirective} from "@ngneat/transloco";
import {SidenavStreamListItemComponent} from "../sidenav-stream-list-item/sidenav-stream-list-item.component";
import {ExternalSourceService} from "../../../external-source.service";
import {ExternalSource} from "../../../_models/sidenav/external-source";
import {StreamType} from "../../../_models/dashboard/stream-type.enum";
import {SideNavStreamType} from "../../../_models/sidenav/sidenav-stream-type.enum";
import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
import {FilterPipe} from "../../../pipe/filter.pipe";
@Component({
selector: 'app-customize-sidenav-streams',
standalone: true,
imports: [CommonModule, DraggableOrderedListComponent, DashboardStreamListItemComponent, TranslocoDirective, SidenavStreamListItemComponent, ReactiveFormsModule, FilterPipe],
templateUrl: './customize-sidenav-streams.component.html',
styleUrls: ['./customize-sidenav-streams.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CustomizeSidenavStreamsComponent {
items: SideNavStream[] = [];
smartFilters: SmartFilter[] = [];
externalSources: ExternalSource[] = [];
accessibilityMode: boolean = false;
listForm: FormGroup = new FormGroup({
'filterSideNavStream': new FormControl('', []),
'filterSmartFilter': new FormControl('', []),
'filterExternalSource': new FormControl('', []),
});
filterSideNavStreams = (listItem: SideNavStream) => {
const filterVal = (this.listForm.value.filterSideNavStream || '').toLowerCase();
return listItem.name.toLowerCase().indexOf(filterVal) >= 0;
}
filterSmartFilters = (listItem: SmartFilter) => {
const filterVal = (this.listForm.value.filterSmartFilter || '').toLowerCase();
return listItem.name.toLowerCase().indexOf(filterVal) >= 0;
}
filterExternalSources = (listItem: ExternalSource) => {
const filterVal = (this.listForm.value.filterExternalSource || '').toLowerCase();
return listItem.name.toLowerCase().indexOf(filterVal) >= 0;
}
private readonly sideNavService = inject(NavService);
private readonly filterService = inject(FilterService);
private readonly externalSourceService = inject(ExternalSourceService);
private readonly cdRef = inject(ChangeDetectorRef);
constructor(public modal: NgbActiveModal) {
forkJoin([this.sideNavService.getSideNavStreams(false),
this.filterService.getAllFilters(), this.externalSourceService.getExternalSources()
]).subscribe(results => {
this.items = results[0];
const existingSmartFilterStreams = new Set(results[0].filter(d => !d.isProvided && d.streamType === SideNavStreamType.SmartFilter).map(d => d.name));
this.smartFilters = results[1].filter(d => !existingSmartFilterStreams.has(d.name));
const existingExternalSourceStreams = new Set(results[0].filter(d => !d.isProvided && d.streamType === SideNavStreamType.ExternalSource).map(d => d.name));
this.externalSources = results[2].filter(d => !existingExternalSourceStreams.has(d.name));
this.cdRef.markForCheck();
});
}
resetSideNavFilter() {
this.listForm.get('filterSideNavStream')?.setValue('');
this.cdRef.markForCheck();
}
resetSmartFilterFilter() {
this.listForm.get('filterSmartFilter')?.setValue('');
this.cdRef.markForCheck();
}
resetExternalSourceFilter() {
this.listForm.get('filterExternalSource')?.setValue('');
this.cdRef.markForCheck();
}
addFilterToStream(filter: SmartFilter) {
this.sideNavService.createSideNavStream(filter.id).subscribe(stream => {
this.smartFilters = this.smartFilters.filter(d => d.name !== filter.name);
this.items = [...this.items, stream];
this.cdRef.markForCheck();
});
}
addExternalSourceToStream(externalSource: ExternalSource) {
this.sideNavService.createSideNavStreamFromExternalSource(externalSource.id).subscribe(stream => {
this.externalSources = this.externalSources.filter(d => d.name !== externalSource.name);
this.items = [...this.items, stream];
this.cdRef.markForCheck();
});
}
updateAccessibilityMode() {
this.accessibilityMode = !this.accessibilityMode;
this.cdRef.markForCheck();
}
orderUpdated(event: IndexUpdateEvent) {
this.sideNavService.updateSideNavStreamPosition(event.item.name, event.item.id, event.fromPosition, event.toPosition).subscribe(() => {
if (event.fromAccessibilityMode) {
this.sideNavService.getSideNavStreams(false).subscribe((data) => {
this.items = [...data];
this.cdRef.markForCheck();
})
}
});
}
updateVisibility(item: SideNavStream, position: number) {
const stream = this.items.filter(s => s.id == item.id)[0];
stream.visible = !stream.visible;
this.sideNavService.updateSideNavStream(stream).subscribe();
this.cdRef.markForCheck();
}
}

View File

@ -2,7 +2,12 @@
<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 *ngIf="item.isProvided; else nonProvidedTitle">
{{item.name | streamName }}
</span>
<ng-template #nonProvidedTitle>
{{item.name}}
</ng-template>
<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>

View File

@ -14,22 +14,22 @@ 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";
import {SideNavStream} from "../../../_models/sidenav/sidenav-stream";
import {CommonStream} from "../../../_models/common-stream";
import {StreamNamePipe} from "../../../pipe/stream-name.pipe";
@Component({
selector: 'app-stream-list-item',
selector: 'app-dashboard-stream-list-item',
standalone: true,
imports: [CommonModule, ImageComponent, MangaFormatIconPipe, MangaFormatPipe, NgbProgressbar, TranslocoDirective],
templateUrl: './stream-list-item.component.html',
styleUrls: ['./stream-list-item.component.scss'],
imports: [CommonModule, ImageComponent, MangaFormatIconPipe, MangaFormatPipe, NgbProgressbar, TranslocoDirective, StreamNamePipe],
templateUrl: './dashboard-stream-list-item.component.html',
styleUrls: ['./dashboard-stream-list-item.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class StreamListItemComponent {
export class DashboardStreamListItemComponent {
@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

@ -0,0 +1,83 @@
<ng-container *transloco="let t; read:'edit-external-source-item'">
<div class="card mt-2">
<div class="card-body">
<div class="card-title">
<div class="container-fluid row mb-2">
<div class="col-9 col-sm-9"><h4 id="anilist-token-header">{{source.name || t('title')}}</h4></div>
<div class="col-3 text-end">
<button class="btn btn-primary btn-sm me-1" (click)="toggleViewMode()">
<ng-container *ngIf="isViewMode; else editMode">
<i *ngIf="isViewMode" class="fa-solid fa-pen" aria-hidden="true"></i>
<span class="visually-hidden">
{{t('edit')}}
</span>
</ng-container>
<ng-template #editMode>
{{t('cancel')}}
</ng-template>
</button>
<button class="btn btn-danger btn-sm" (click)="delete()">
<span class="visually-hidden">{{t('delete')}}</span>
<i class="fa-solid fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
</div>
<div #collapse="ngbCollapse" [(ngbCollapse)]="isViewMode">
<form [formGroup]="formGroup">
<div class="form-group mb-3">
<label for="host">{{t('name-label')}}</label>
<input id="name" class="form-control" formControlName="name" type="text"
[class.is-invalid]="formGroup.get('name')?.invalid && formGroup.get('name')?.touched" aria-describedby="name-validations">
<div id="name-validations" class="invalid-feedback" *ngIf="hasErrors('name')">
<div *ngIf="formGroup.get('name')?.errors?.required">
{{t('required')}}
</div>
<div *ngIf="formGroup.get('name')?.errors?.notUnique">
{{t('not-unique')}}
</div>
</div>
</div>
<div class="form-group mb-3">
<label for="host">{{t('host-label')}}</label>
<input id="host" class="form-control" formControlName="host" type="url"
[class.is-invalid]="formGroup.get('host')?.invalid && formGroup.get('host')?.touched" aria-describedby="host-validations">
<ng-container *ngIf="formGroup.get('host')?.errors as errors">
<div id="host-validations" class="invalid-feedback">
<div *ngIf="errors.required">
{{t('required')}}
</div>
<div *ngIf="errors.pattern">
{{t('pattern')}}
</div>
<div *ngIf="errors.notUnique">
{{t('not-unique')}}
</div>
</div>
</ng-container>
</div>
<div class="form-group mb-3">
<label for="api-key">{{t('api-key-label')}}</label>
<input id="api-key" class="form-control" formControlName="apiKey" type="text"
[class.is-invalid]="formGroup.get('apiKey')?.invalid && formGroup.get('apiKey')?.touched" aria-describedby="api-key-validations">
<div id="api-key-validations" class="invalid-feedback" *ngIf="hasErrors('apiKey')">
<div *ngIf="formGroup.get('apiKey')?.errors?.required">
{{t('required')}}
</div>
</div>
</div>
</form>
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
<button type="submit" class="flex-fill btn btn-primary" aria-describedby="anilist-token-header" (click)="saveForm()">{{t('save')}}</button>
</div>
</div>
</div>
</div>
</ng-container>

View File

@ -0,0 +1,107 @@
import {ChangeDetectorRef, Component, DestroyRef, EventEmitter, inject, Input, OnInit, Output} from '@angular/core';
import { CommonModule } from '@angular/common';
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms";
import {ExternalSource} from "../../../_models/sidenav/external-source";
import {NgbCollapse} from "@ng-bootstrap/ng-bootstrap";
import {translate, TranslocoDirective} from "@ngneat/transloco";
import {ExternalSourceService} from "../../../external-source.service";
import {distinctUntilChanged, filter, tap} from "rxjs/operators";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {switchMap} from "rxjs";
import {ToastrModule, ToastrService} from "ngx-toastr";
@Component({
selector: 'app-edit-external-source-item',
standalone: true,
imports: [CommonModule, NgbCollapse, ReactiveFormsModule, TranslocoDirective],
templateUrl: './edit-external-source-item.component.html',
styleUrls: ['./edit-external-source-item.component.scss']
})
export class EditExternalSourceItemComponent implements OnInit {
@Input({required: true}) source!: ExternalSource;
@Output() sourceUpdate = new EventEmitter<ExternalSource>();
@Output() sourceDelete = new EventEmitter<ExternalSource>();
@Input() isViewMode: boolean = true;
formGroup: FormGroup = new FormGroup({});
private readonly destroyRef = inject(DestroyRef);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly externalSourceService = inject(ExternalSourceService);
private readonly toastr = inject(ToastrService);
hasErrors(controlName: string) {
const errors = this.formGroup.get(controlName)?.errors;
return Object.values(errors || []).filter(v => v).length > 0;
}
constructor() {}
ngOnInit(): void {
this.formGroup.addControl('name', new FormControl(this.source.name, [Validators.required]));
this.formGroup.addControl('host', new FormControl(this.source.host, [Validators.required, Validators.pattern(/^(http:|https:)+[^\s]+[\w]\/?$/)]));
this.formGroup.addControl('apiKey', new FormControl(this.source.apiKey, [Validators.required]));
this.cdRef.markForCheck();
}
resetForm() {
this.formGroup.get('host')?.setValue(this.source.host);
this.formGroup.get('name')?.setValue(this.source.name);
this.formGroup.get('apiKey')?.setValue(this.source.apiKey);
this.cdRef.markForCheck();
}
saveForm() {
if (this.source === undefined) return;
const model = this.formGroup.value;
this.externalSourceService.sourceExists(model.host, model.name, model.apiKey).subscribe(exists => {
if (exists) {
this.toastr.error(translate('toasts.external-source-already-exists'));
return;
}
if (this.source.id === 0) {
// We need to create a new one
this.externalSourceService.createSource({id: 0, ...this.formGroup.value}).subscribe((updatedSource) => {
this.source = {...updatedSource};
this.sourceUpdate.emit(this.source);
this.toggleViewMode();
});
return;
}
this.externalSourceService.updateSource({id: this.source.id, ...this.formGroup.value}).subscribe((updatedSource) => {
this.source!.host = this.formGroup.value.host;
this.source!.apiKey = this.formGroup.value.apiKey;
this.source!.name = this.formGroup.value.name;
this.sourceUpdate.emit(this.source);
this.toggleViewMode();
});
});
}
delete() {
if (this.source.id === 0) {
this.sourceDelete.emit(this.source);
if (!this.isViewMode) {
this.toggleViewMode();
}
return;
}
this.externalSourceService.deleteSource(this.source.id).subscribe(() => {
this.sourceDelete.emit(this.source);
if (!this.isViewMode) {
this.toggleViewMode();
}
});
}
toggleViewMode() {
this.isViewMode = !this.isViewMode;
if (!this.isViewMode) {
this.resetForm();
}
}
}

View File

@ -0,0 +1,28 @@
<ng-container *transloco="let t; read: 'manage-external-sources'">
<p>
{{t('description')}}
<a href="https://wiki.kavitareader.com/en/guides/customization/external-sources" target="_blank" rel="noopener noreferrer">{{t('help-link')}}</a>
</p>
<form class="row g-0 justify-content-between mb-3" [formGroup]="listForm">
<div class="col-9">
<label for="filter" class="form-label">{{t('filter')}}</label>
<div class="input-group">
<input id="filter" autocomplete="off" class="form-control" formControlName="filterQuery" type="text" aria-describedby="reset-input">
<button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="resetFilter()">{{t('clear')}}</button>
</div>
</div>
<div class="col-2 ms-2">
<button class="btn btn-primary" style="margin-top: 30px" (click)="addNewExternalSource()">{{t('add-source')}}</button>
</div>
</form>
<ng-container *ngFor="let externalSource of externalSources | filter: filterList; let idx = index">
<app-edit-external-source-item [source]="externalSource"
(sourceUpdate)="updateSource(idx, $event)"
(sourceDelete)="deleteSource(idx, $event)"
[isViewMode]="externalSource.id !== 0"></app-edit-external-source-item>
</ng-container>
</ng-container>

View File

@ -0,0 +1,66 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject} from '@angular/core';
import {CommonModule, NgOptimizedImage} from '@angular/common';
import {FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators} from "@angular/forms";
import {NgbCollapse, NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
import {translate, TranslocoDirective} from "@ngneat/transloco";
import {AccountService} from "../../../_services/account.service";
import {ToastrService} from "ngx-toastr";
import {EditExternalSourceItemComponent} from "../edit-external-source-item/edit-external-source-item.component";
import {ExternalSource} from "../../../_models/sidenav/external-source";
import {ExternalSourceService} from "../../../external-source.service";
import {FilterPipe} from "../../../pipe/filter.pipe";
import {SmartFilter} from "../../../_models/metadata/v2/smart-filter";
@Component({
selector: 'app-manage-external-sources',
standalone: true,
imports: [CommonModule, FormsModule, NgOptimizedImage, NgbTooltip, ReactiveFormsModule, TranslocoDirective, NgbCollapse, EditExternalSourceItemComponent, FilterPipe],
templateUrl: './manage-external-sources.component.html',
styleUrls: ['./manage-external-sources.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ManageExternalSourcesComponent {
externalSources: Array<ExternalSource> = [];
private readonly cdRef = inject(ChangeDetectorRef);
private readonly externalSourceService = inject(ExternalSourceService);
listForm: FormGroup = new FormGroup({
'filterQuery': new FormControl('', [])
});
filterList = (listItem: ExternalSource) => {
const filterVal = (this.listForm.value.filterQuery || '').toLowerCase();
return listItem.name.toLowerCase().indexOf(filterVal) >= 0 || listItem.host.toLowerCase().indexOf(filterVal) >= 0;
}
constructor(public accountService: AccountService) {
this.externalSourceService.getExternalSources().subscribe(data => {
this.externalSources = data;
this.cdRef.markForCheck();
});
}
resetFilter() {
this.listForm.get('filterQuery')?.setValue('');
this.cdRef.markForCheck();
}
addNewExternalSource() {
this.externalSources.unshift({id: 0, name: '', host: '', apiKey: ''});
this.cdRef.markForCheck();
}
updateSource(index: number, updatedSource: ExternalSource) {
this.externalSources[index] = updatedSource;
this.cdRef.markForCheck();
}
deleteSource(index: number, updatedSource: ExternalSource) {
this.externalSources.splice(index, 1);
this.resetFilter();
this.cdRef.markForCheck();
}
}

View File

@ -0,0 +1,25 @@
<ng-container *transloco="let t; read:'manage-smart-filters'">
<form [formGroup]="listForm">
<div class="mb-3" *ngIf="filters.length >= 3">
<label for="filter" class="form-label">{{t('filter')}}</label>
<div class="input-group">
<input id="filter" autocomplete="off" class="form-control" formControlName="filterQuery" type="text" aria-describedby="reset-input">
<button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="resetFilter()">{{t('clear')}}</button>
</div>
</div>
</form>
<ul>
<li class="list-group-item" *ngFor="let f of filters | filter: filterList">
<a [href]="'all-series?' + f.filter" target="_blank">{{f.name}}</a>
<button class="btn btn-danger float-end" (click)="deleteFilter(f)">
<i class="fa-solid fa-trash" aria-hidden="true"></i>
<span class="visually-hidden">{{t('delete')}}</span>
</button>
</li>
<li class="list-group-item" *ngIf="filters.length === 0">
{{t('no-data')}}
</li>
</ul>
</ng-container>

View File

@ -1,16 +1,18 @@
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 {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 {ConfirmService} from "../../../shared/confirm.service";
import {translate, TranslocoDirective} from "@ngneat/transloco";
import {ToastrService} from "ngx-toastr";
import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
import {FilterPipe} from "../../../pipe/filter.pipe";
@Component({
selector: 'app-manage-smart-filters',
standalone: true,
imports: [CommonModule],
imports: [CommonModule, ReactiveFormsModule, TranslocoDirective, FilterPipe],
templateUrl: './manage-smart-filters.component.html',
styleUrls: ['./manage-smart-filters.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
@ -23,6 +25,19 @@ export class ManageSmartFiltersComponent {
private readonly cdRef = inject(ChangeDetectorRef);
private readonly toastr = inject(ToastrService);
filters: Array<SmartFilter> = [];
listForm: FormGroup = new FormGroup({
'filterQuery': new FormControl('', [])
});
filterList = (listItem: SmartFilter) => {
const filterVal = (this.listForm.value.filterQuery || '').toLowerCase();
return listItem.name.toLowerCase().indexOf(filterVal) >= 0;
}
resetFilter() {
this.listForm.get('filterQuery')?.setValue('');
this.cdRef.markForCheck();
}
constructor() {
this.loadData();
@ -44,6 +59,7 @@ export class ManageSmartFiltersComponent {
this.filterService.deleteFilter(f.id).subscribe(() => {
this.toastr.success(translate('toasts.smart-filter-deleted'));
this.resetFilter();
this.loadData();
});
}

View File

@ -11,9 +11,16 @@
</a>
</ng-container>
<ng-template #internal>
<a class="side-nav-item" href="javascript:void(0);" [ngClass]="{'closed': (navService.sideNavCollapsed$ | async), 'active': highlighted}" [routerLink]="link">
<ng-container *ngIf="queryParams && queryParams !== {}; else regInternalLink">
<a class="side-nav-item" href="javascript:void(0);" [ngClass]="{'closed': (navService.sideNavCollapsed$ | async), 'active': highlighted}" (click)="openLink()">
<ng-container [ngTemplateOutlet]="inner"></ng-container>
</a>
</ng-container>
<ng-template #regInternalLink>
<a class="side-nav-item" href="javascript:void(0);" [ngClass]="{'closed': (navService.sideNavCollapsed$ | async), 'active': highlighted}" [routerLink]="link" [queryParams]="queryParams">
<ng-container [ngTemplateOutlet]="inner"></ng-container>
</a>
</ng-template>
</ng-template>
</ng-template>

View File

@ -41,6 +41,11 @@ export class SideNavItemComponent implements OnInit {
* If external, link will be used as full href and rel will be applied
*/
@Input() external: boolean = false;
/**
* If using a link, then you can pass optional queryParameters
*/
@Input() queryParams: any | undefined = undefined;
@Input() comparisonMethod: 'startsWith' | 'equals' = 'equals';
private readonly destroyRef = inject(DestroyRef);
@ -54,8 +59,9 @@ export class SideNavItemComponent implements OnInit {
takeUntilDestroyed(this.destroyRef),
map(evt => evt as NavigationEnd))
.subscribe((evt: NavigationEnd) => {
this.updateHighlight(evt.url.split('?')[0]);
const tokens = evt.url.split('?');
const [token1, token2 = undefined] = tokens;
this.updateHighlight(token1, token2);
});
}
@ -66,23 +72,31 @@ export class SideNavItemComponent implements OnInit {
}
updateHighlight(page: string) {
updateHighlight(page: string, queryParams?: string) {
if (this.link === undefined) {
this.highlighted = false;
this.cdRef.markForCheck();
return;
}
if (!page.endsWith('/')) {
if (!page.endsWith('/') && !queryParams) {
page = page + '/';
}
if (this.comparisonMethod === 'equals' && page === this.link) {
this.highlighted = true;
this.cdRef.markForCheck();
return;
}
if (this.comparisonMethod === 'startsWith' && page.startsWith(this.link)) {
if (queryParams && queryParams === this.queryParams) {
this.highlighted = true;
this.cdRef.markForCheck();
return;
}
this.highlighted = true;
this.cdRef.markForCheck();
return;
@ -92,4 +106,12 @@ export class SideNavItemComponent implements OnInit {
this.cdRef.markForCheck();
}
openLink() {
if (Object.keys(this.queryParams).length === 0) {
this.router.navigateByUrl(this.link!);
return
}
this.router.navigateByUrl(this.link + '?' + this.queryParams);
}
}

View File

@ -1,38 +1,74 @@
<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>-->
<!-- <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/">
<ng-container actions>
<app-card-actionables [actions]="homeActions" [labelBy]="t('home')" iconClass="fa-ellipsis-v" (actionHandler)="handleHomeActions()"></app-card-actionables>
</ng-container>
</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>
</ng-container>
</app-side-nav-item>
<app-side-nav-item icon="fa-bookmark" [title]="t('bookmarks')" link="/bookmarks/"></app-side-nav-item>
<app-side-nav-item icon="fa-regular fa-rectangle-list" [title]="t('all-series')" link="/all-series/" *ngIf="libraries.length > 0"></app-side-nav-item>
<div class="mb-2 mt-3 ms-2 me-2" *ngIf="libraries.length > 10 && (navService?.sideNavCollapsed$ | async) === false">
<ng-container *ngIf="navStreams$ | async as streams">
<ng-container *ngIf="showAll">
<app-side-nav-item icon="fa fa-chevron-left" [title]="t('back')" (click)="showLess()"></app-side-nav-item>
<div class="mb-2 mt-3 ms-2 me-2" *ngIf="streams.length > 10 && (navService?.sideNavCollapsed$ | async) === false">
<label for="filter" class="form-label visually-hidden">{{t('filter-label')}}</label>
<div class="form-group">
<input id="filter" autocomplete="off" class="form-control" [(ngModel)]="filterQuery" type="text" aria-describedby="reset-input">
<button type="button" [attr.aria-label]="t('clear')" class="btn-close" id="reset-input" (click)="filterQuery = '';"></button>
<input id="filter" autocomplete="off" class="form-control" [(ngModel)]="filterQuery" type="text" aria-describedby="reset-input">
<button type="button" [attr.aria-label]="t('clear')" class="btn-close" id="reset-input" (click)="filterQuery = '';"></button>
</div>
</div>
<app-side-nav-item *ngFor="let library of libraries | filter: filterLibrary" [link]="'/library/' + library.id + '/'"
[icon]="getLibraryTypeIcon(library.type)" [imageUrl]="getLibraryImage(library)" [title]="library.name" [comparisonMethod]="'startsWith'">
<ng-container actions>
<app-card-actionables [actions]="actions" [labelBy]="library.name" iconClass="fa-ellipsis-v" (actionHandler)="performAction($event, library)"></app-card-actionables>
</div>
</ng-container>
<ng-container *ngFor="let navStream of streams | filter: filterLibrary">
<ng-container [ngSwitch]="navStream.streamType">
<ng-container *ngSwitchCase="SideNavStreamType.Library">
<app-side-nav-item [link]="'/library/' + navStream.libraryId + '/'"
[icon]="getLibraryTypeIcon(navStream.library!.type)" [imageUrl]="getLibraryImage(navStream.library!)" [title]="navStream.name" [comparisonMethod]="'startsWith'">
<ng-container actions>
<app-card-actionables [actions]="actions" [labelBy]="navStream.name" iconClass="fa-ellipsis-v"
(actionHandler)="performAction($event, navStream.library!)"></app-card-actionables>
</ng-container>
</app-side-nav-item>
</ng-container>
</app-side-nav-item>
<ng-container *ngSwitchCase="SideNavStreamType.AllSeries">
<app-side-nav-item icon="fa-regular fa-rectangle-list" [title]="t('all-series')" link="/all-series/"></app-side-nav-item>
</ng-container>
<ng-container *ngSwitchCase="SideNavStreamType.Bookmarks">
<app-side-nav-item icon="fa-bookmark" [title]="t('bookmarks')" link="/bookmarks/"></app-side-nav-item>
</ng-container>
<ng-container *ngSwitchCase="SideNavStreamType.ReadingLists">
<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>
</ng-container>
</app-side-nav-item>
</ng-container>
<ng-container *ngSwitchCase="SideNavStreamType.Collections">
<app-side-nav-item icon="fa-list" [title]="t('collections')" link="/collections/"></app-side-nav-item>
</ng-container>
<ng-container *ngSwitchCase="SideNavStreamType.WantToRead">
<app-side-nav-item icon="fa-star" [title]="t('want-to-read')" link="/want-to-read/"></app-side-nav-item>
</ng-container>
<ng-container *ngSwitchCase="SideNavStreamType.SmartFilter">
<app-side-nav-item icon="fa-bars-staggered" [title]="navStream.name" link="/all-series" [queryParams]="navStream.smartFilterEncoded"></app-side-nav-item>
</ng-container>
<ng-container *ngSwitchCase="SideNavStreamType.ExternalSource">
<app-side-nav-item icon="fa-server" [title]="navStream.name" [link]="navStream.externalSource.host + 'login?apiKey=' + navStream.externalSource.apiKey" [external]="true"></app-side-nav-item>
</ng-container>
</ng-container>
</ng-container>
<ng-container *ngIf="totalSize > 10 && !showAll">
<app-side-nav-item icon="fa fa-chevron-right" [title]="t('more')" (click)="showMore()"></app-side-nav-item>
</ng-container>
</ng-container>
</div>
<div class="side-nav-overlay" (click)="toggleNavBar()" [ngClass]="{'closed' : (navService.sideNavCollapsed$ | async)}"></div>
<div class="bottom" [ngClass]="{'closed' : (navService.sideNavCollapsed$ | async),

View File

@ -8,7 +8,7 @@ import {
} from '@angular/core';
import { NavigationEnd, Router } from '@angular/router';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import {filter, map, shareReplay, take} from 'rxjs/operators';
import {distinctUntilChanged, filter, map, shareReplay, take, tap} from 'rxjs/operators';
import { ImportCblModalComponent } from 'src/app/reading-list/_modals/import-cbl-modal/import-cbl-modal.component';
import { ImageService } from 'src/app/_services/image.service';
import { EVENTS, MessageHubService } from 'src/app/_services/message-hub.service';
@ -20,7 +20,7 @@ import { ActionService } from '../../../_services/action.service';
import { LibraryService } from '../../../_services/library.service';
import { NavService } from '../../../_services/nav.service';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {switchMap} from "rxjs";
import {BehaviorSubject, merge, Observable, of, ReplaySubject, startWith, switchMap} from "rxjs";
import {CommonModule} from "@angular/common";
import {SideNavItemComponent} from "../side-nav-item/side-nav-item.component";
import {FilterPipe} from "../../../pipe/filter.pipe";
@ -29,6 +29,8 @@ 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";
import {SideNavStream} from "../../../_models/sidenav/sidenav-stream";
import {SideNavStreamType} from "../../../_models/sidenav/sidenav-stream-type.enum";
@Component({
selector: 'app-side-nav',
@ -41,19 +43,78 @@ import {CustomizeDashboardModalComponent} from "../customize-dashboard-modal/cus
export class SideNavComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef);
private readonly actionFactoryService = inject(ActionFactoryService);
libraries: Library[] = [];
actions: ActionItem<Library>[] = [];
cachedData: SideNavStream[] | null = null;
actions: ActionItem<Library>[] = this.actionFactoryService.getLibraryActions(this.handleAction.bind(this));
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;
}
constructor(private libraryService: LibraryService,
filterQuery: string = '';
filterLibrary = (stream: SideNavStream) => {
return stream.name.toLowerCase().indexOf((this.filterQuery || '').toLowerCase()) >= 0;
}
showAll: boolean = false;
totalSize = 0;
protected readonly SideNavStreamType = SideNavStreamType;
private showAllSubject = new BehaviorSubject<boolean>(false);
showAll$ = this.showAllSubject.asObservable();
private loadDataSubject = new ReplaySubject<void>();
loadData$ = this.loadDataSubject.asObservable();
loadDataOnInit$: Observable<SideNavStream[]> = this.loadData$.pipe(
switchMap(() => {
if (this.cachedData != null) {
return of(this.cachedData);
}
return this.navService.getSideNavStreams().pipe(
map(data => {
this.cachedData = data; // Cache the data after initial load
return data;
})
);
})
);
navStreams$ = merge(
this.showAll$.pipe(
startWith(false),
distinctUntilChanged(),
tap(showAll => this.showAll = showAll),
switchMap(showAll =>
showAll
? this.loadDataOnInit$.pipe(
tap(d => this.totalSize = d.length),
)
: this.loadDataOnInit$.pipe(
tap(d => this.totalSize = d.length),
map(d => d.slice(0, 10))
)
),
takeUntilDestroyed(this.destroyRef),
), this.messageHub.messages$.pipe(
filter(event => event.event === EVENTS.LibraryModified || event.event === EVENTS.SideNavUpdate),
tap(() => {
this.cachedData = null; // Reset cached data to null to get latest
}),
switchMap(() => {
if (this.showAll) return this.loadDataOnInit$;
else return this.loadDataOnInit$.pipe(map(d => d.slice(0, 10)))
}), // Reload data when events occur
takeUntilDestroyed(this.destroyRef),
)
).pipe(
startWith(null),
filter(data => data !== null),
takeUntilDestroyed(this.destroyRef),
);
constructor(
public utilityService: UtilityService, private messageHub: MessageHubService,
private actionFactoryService: ActionFactoryService, private actionService: ActionService,
private actionService: ActionService,
public navService: NavService, private router: Router, private readonly cdRef: ChangeDetectorRef,
private ngbModal: NgbModal, private imageService: ImageService, public readonly accountService: AccountService) {
@ -74,20 +135,7 @@ export class SideNavComponent implements OnInit {
ngOnInit(): void {
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
if (!user) return;
this.libraryService.getLibraries().pipe(take(1), shareReplay()).subscribe((libraries: Library[]) => {
this.libraries = libraries;
this.cdRef.markForCheck();
});
this.actions = this.actionFactoryService.getLibraryActions(this.handleAction.bind(this));
this.cdRef.markForCheck();
});
// TODO: Investigate this, as it might be expensive
this.messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef), filter(event => event.event === EVENTS.LibraryModified)).subscribe(event => {
this.libraryService.getLibraries().pipe(take(1), shareReplay()).subscribe((libraries: Library[]) => {
this.libraries = [...libraries];
this.cdRef.markForCheck();
});
this.loadDataSubject.next();
});
}
@ -112,10 +160,8 @@ 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'});
}
@ -141,8 +187,17 @@ export class SideNavComponent implements OnInit {
return null;
}
toggleNavBar() {
this.navService.toggleSideNav();
}
showMore() {
this.showAllSubject.next(true);
}
showLess() {
this.showAllSubject.next(false);
}
}

View File

@ -0,0 +1,45 @@
<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}}">
<span *ngIf="item.isProvided; else nonProvidedTitle">
{{item.name | streamName }}
</span>
<ng-template #nonProvidedTitle>
{{item.name}}
</ng-template>
<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">
<ng-container *ngIf="item.isProvided; else nonProvided">{{t('provided')}}</ng-container>
<ng-template #nonProvided>
<ng-container [ngSwitch]="item.streamType">
<ng-container *ngSwitchCase="SideNavStreamType.Library">{{t('library')}}</ng-container>
<ng-container *ngSwitchCase="SideNavStreamType.SmartFilter">{{t('smart-filter')}}</ng-container>
<ng-container *ngSwitchCase="SideNavStreamType.ExternalSource">{{t('external-source')}}</ng-container>
</ng-container>
</ng-template>
</div>
<div class="ps-1" *ngIf="!item.isProvided">
<ng-container [ngSwitch]="item.streamType">
<ng-container *ngSwitchCase="SideNavStreamType.Library">
<a [href]="'/library/' + this.item.libraryId" target="_blank">{{item.library?.name}}</a>
</ng-container>
<ng-container *ngSwitchCase="SideNavStreamType.SmartFilter">
<a [href]="'/all-series?' + this.item.smartFilterEncoded" target="_blank">{{t('load-filter')}}</a>
</ng-container>
<ng-container *ngSwitchCase="SideNavStreamType.ExternalSource">
<a [href]="item.externalSource!.host! + 'login?apiKey=' + item.externalSource!.apiKey" target="_blank" rel="noopener noreferrer">{{item.externalSource!.host!}}</a>
</ng-container>
</ng-container>
</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,21 @@
import {ChangeDetectionStrategy, Component, EventEmitter, Input, Output} from '@angular/core';
import {CommonModule} from '@angular/common';
import {SideNavStream} from "../../../_models/sidenav/sidenav-stream";
import {StreamNamePipe} from "../../../pipe/stream-name.pipe";
import {TranslocoDirective} from "@ngneat/transloco";
import {SideNavStreamType} from "../../../_models/sidenav/sidenav-stream-type.enum";
@Component({
selector: 'app-sidenav-stream-list-item',
standalone: true,
imports: [CommonModule, StreamNamePipe, TranslocoDirective],
templateUrl: './sidenav-stream-list-item.component.html',
styleUrls: ['./sidenav-stream-list-item.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class SidenavStreamListItemComponent {
@Input({required: true}) item!: SideNavStream;
@Input({required: true}) position: number = 0;
@Output() hide: EventEmitter<SideNavStream> = new EventEmitter<SideNavStream>();
protected readonly SideNavStreamType = SideNavStreamType;
}

View File

@ -12,7 +12,7 @@
<button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="listForm.get('filterQuery')?.setValue('');">{{t('clear')}}</button>
</div>
</div>
<div class="list-group">
<ul class="list-group">
<li class="list-group-item d-flex justify-content-between align-items-center" *ngFor="let item of items | filter: filterList; let i = index">
{{item}}
<button class="btn btn-primary" *ngIf="clicked !== undefined" (click)="handleClick(item)">
@ -20,7 +20,7 @@
<span class="visually-hidden">{{t('open-filtered-search',{item: item})}}</span>
</button>
</li>
</div>
</ul>
</form>
</div>
<div class="modal-footer">

View File

@ -4,8 +4,8 @@
<ng-template #tooltip>{{tooltipText}}</ng-template>
<div class="input-group">
<input #apiKey [type]="InputType" readonly class="form-control" id="api-key--{{title}}" aria-describedby="button-addon4" [value]="key" (click)="selectAll()">
<button class="btn btn-outline-secondary" type="button" (click)="show()" [title]="t('show')" *ngIf="hideData">
<span class="visually-hidden">t('show')</span><i class="fa fa-eye" aria-hidden="true"></i>
<button class="btn btn-outline-secondary" type="button" (click)="toggleVisibility()" [title]="isDataHidden ? t('show') : t('hide')" *ngIf="hideData">
<span class="visually-hidden">{{isDataHidden ? t('show') : t('hide')}}</span><i class="fa {{isDataHidden ? 'fa-eye' : 'fa-eye-slash'}}" aria-hidden="true"></i>
</button>
<button class="btn btn-outline-secondary" type="button" (click)="copy()" [title]="t('copy')">
<span class="visually-hidden">Copy</span><i class="fa fa-copy" aria-hidden="true"></i>

View File

@ -35,8 +35,10 @@ export class ApiKeyComponent implements OnInit {
key: string = '';
private readonly destroyRef = inject(DestroyRef);
isDataHidden: boolean = this.hideData;
get InputType() {
return this.hideData ? 'password' : 'text';
return (this.hideData && this.isDataHidden) ? 'password' : 'text';
}
constructor(private confirmService: ConfirmService, private accountService: AccountService, private toastr: ToastrService, private clipboard: Clipboard,
@ -83,8 +85,8 @@ export class ApiKeyComponent implements OnInit {
}
}
show() {
this.inputElem.nativeElement.setAttribute('type', 'text');
toggleVisibility() {
this.isDataHidden = !this.isDataHidden;
this.cdRef.markForCheck();
}

View File

@ -1,11 +0,0 @@
<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

@ -428,9 +428,6 @@
<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

@ -48,8 +48,7 @@ import { NgbNav, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavContent, NgbAccor
import { SideNavCompanionBarComponent } from '../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
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";
import {translate, TranslocoDirective} from "@ngneat/transloco";
enum AccordionPanelID {
ImageReader = 'image-reader',
@ -64,9 +63,7 @@ enum FragmentID {
Theme = 'theme',
Devices = 'devices',
Stats = 'stats',
SmartFilters = 'smart-filters',
Scrobbling = 'scrobbling'
}
@Component({
@ -79,7 +76,7 @@ enum FragmentID {
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, ManageSmartFiltersComponent]
TranslocoDirective]
})
export class UserPreferencesComponent implements OnInit, OnDestroy {
@ -110,7 +107,6 @@ 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'}];

View File

@ -33,7 +33,7 @@
"user-scrobble-history": {
"title": "Scrobble History",
"description": "Here you will find any scrobble events linked with your account. In order for events to exist, you must have an active scrobble provider configured. All events that have been processed will clear after a month. If there are non-processed events, it is likely these cannot form matches upstream. Please reach out to your admin to get them corrected.",
"filter-label": "Filter",
"filter-label": "{{common.filter}}",
"created-header": "Created",
"last-modified-header": "Last Modified",
"type-header": "Type",
@ -270,6 +270,7 @@
"api-key": {
"copy": "Copy",
"show": "Show",
"hide": "Hide",
"regen-warning": "Regenerating your API key will invalidate any existing clients.",
"no-key": "ERROR - KEY NOT SET",
"confirm-reset": "This will invalidate any OPDS configurations you have setup. Are you sure you want to continue?",
@ -301,8 +302,8 @@
"generic-list-modal": {
"close": "{{common.close}}",
"clear": "Clear",
"filter": "Filter",
"clear": "{{common.clear}}",
"filter": "{{common.filter}}",
"open-filtered-search": "Open a filtered search for {{item}}"
},
@ -756,10 +757,12 @@
"collections": "Collections",
"reading-lists": "Reading Lists",
"bookmarks": "Bookmarks",
"filter-label": "Filter",
"filter-label": "{{common.filter}}",
"all-series": "All Series",
"clear": "Clear",
"donate": "Donate"
"clear": "{{common.clear}}",
"donate": "Donate",
"back": "Back",
"more": "More"
},
"library-settings-modal": {
@ -994,7 +997,7 @@
"title": "Add to Collection",
"promoted": "{{common.promoted}}",
"close": "{{common.close}}",
"filter-label": "Filter",
"filter-label": "{{common.filter}}",
"clear": "{{common.clear}}",
"no-data": "No collections created yet",
"loading": "{{common.loading}}",
@ -1019,7 +1022,7 @@
"manage-alerts": {
"description-part-1": "This table contains issues found during scan or reading of your media. This list is non-managed. You can clear it at any time and use Library (Force) Scan to perform analysis. A list of some common errors and what they mean can be found on the ",
"description-part-2": "wiki.",
"filter-label": "Filter",
"filter-label": "{{common.filter}}",
"clear-alerts": "Clear Alerts",
"extension-header": "Extension",
"file-header": "File",
@ -1087,7 +1090,7 @@
"manage-scrobble-errors": {
"description": "This table contains issues found during scrobbling. This list is non-managed. You can clear it at any time and wait for the next scrobble upload to see. If there is an unknown series, you are best correcting the series name or localized series name or adding a weblink for the providers.",
"filter-label": "Filter",
"filter-label": "{{common.filter}}",
"clear-errors": "Clear Errors",
"series-header": "Series",
"created-header": "Created",
@ -1332,7 +1335,9 @@
"remove": "{{common.remove}}",
"load-filter": "Load Filter",
"provided": "Provided",
"smart-filter": "Smart Filter"
"smart-filter": "Smart Filter",
"library": "Library",
"external-source": "External Source"
},
"reading-list-detail": {
@ -1404,7 +1409,7 @@
"add-to-list-modal": {
"title": "Add to Reading List",
"close": "{{common.close}}",
"filter-label": "Filter",
"filter-label": "{{common.filter}}",
"promoted-alt": "Promoted",
"no-data": "No lists created yet",
"loading": "{{common.loading}}",
@ -1505,7 +1510,7 @@
},
"metadata-filter": {
"filter-title": "Filter",
"filter-title": "{{common.filter}}",
"sort-by-label": "Sort By",
"filter-name-label": "Filter Name",
"ascending-alt": "Ascending",
@ -1737,11 +1742,77 @@
},
"customize-dashboard-modal": {
"title": "Customize Dashboard",
"no-data": "All Smart filters added to Dashboard or none created yet.",
"title-dashboard": "Customize Dashboard",
"title-sidenav": "Customize Side Nav",
"title-external-sources": "External Sources",
"title-smart-filters": "Smart Filters",
"close": "{{common.close}}",
"dashboard": "Dashboard",
"sidenav": "Side Nav",
"external-sources": "External Sources",
"smart-filters": "Smart Filters"
},
"customize-dashboard-streams": {
"no-data": "All Smart filters added to Dashboard or none created yet.",
"save": "{{common.save}}",
"add": "{{common.add}}"
"add": "{{common.add}}",
"filter": "{{common.filter}}",
"clear": "{{common.clear}}"
},
"customize-sidenav-streams": {
"no-data": "All Smart filters added to Side Nav or none created yet.",
"no-data-external-source": "All External Sources added to Side Nav or none created yet.",
"save": "{{common.save}}",
"add": "{{common.add}}",
"filter": "{{common.filter}}",
"clear": "{{common.clear}}",
"smart-filters-title": "Smart Filters",
"external-sources-title": "{{customize-dashboard-modal.external-sources}}",
"reorder-when-filter-present": "You cannot reorder items via drag & drop while a filter is present. Use {{customize-sidenav-streams.order-numbers-label}}",
"order-numbers-label": "{{reading-list-detail.order-numbers-label}}"
},
"manage-external-sources": {
"add-source": "Add",
"help-link": "More information",
"description": "Add External Servers to your account and then add them to your Side Nav for a quick way to switch between your and your friend's server.",
"clear": "{{common.clear}}",
"filter": "{{common.filter}}"
},
"manage-smart-filters": {
"delete": "{{common.delete}}",
"no-data": "No Smart Filters created",
"filter": "{{common.filter}}",
"clear": "{{common.clear}}"
},
"edit-external-source-item": {
"not-unique": "External source exists with this host. Ensure you don't have duplicates",
"title": "New External Source",
"host-label": "Host",
"name-label": "Name",
"api-key-label": "API Key",
"save": "{{common.save}}",
"edit": "{{common.edit}}",
"cancel": "{{common.cancel}}",
"delete": "{{common.delete}}",
"pattern": "Host must be a valid http(s):// url",
"required": "{{validation.required-field}}"
},
"stream-pipe": {
"on-deck": "{{dashboard.on-deck-title}}",
"recently-updated": "{{dashboard.recently-updated-title}}",
"newly-added": "{{dashboard.recently-added-title}}",
"more-in-genre": "{{dashboard.more-in-genre-title}}",
"want-to-read": "{{side-nav.want-to-read}}",
"collections": "{{side-nav.collections}}",
"reading-lists": "{{side-nav.reading-lists}}",
"bookmarks": "{{side-nav.bookmarks}}",
"all-series": "{{side-nav.all-series}}"
},
"filter-field-pipe": {
@ -1875,7 +1946,8 @@
"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"
"smart-filter-updated": "Created/Updated smart filter",
"external-source-already-exists": "An External Source already exists with the same Name/Host/API Key"
},
"actionable": {
@ -1900,7 +1972,7 @@
"read-incognito": "Read Incognito",
"details": "Details",
"view-series": "View Series",
"clear": "Clear",
"clear": "{{common.clear}}",
"import-cbl": "Import CBL",
"read": "Read",
"add-rule-group-and": "Add Rule Group (AND)",
@ -1950,6 +2022,8 @@
"common": {
"reset-to-default": "Reset to Default",
"close": "Close",
"clear": "Clear",
"filter": "Filter",
"cancel": "Cancel",
"create": "Create",
"save": "Save",

File diff suppressed because it is too large Load Diff