mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-05-24 00:52:23 -04:00
Smart Filters & Dashboard Customization (#2282)
Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
This commit is contained in:
parent
3d501c9532
commit
84f85b4f24
40
API.Tests/Helpers/SmartFilterHelperTests.cs
Normal file
40
API.Tests/Helpers/SmartFilterHelperTests.cs
Normal file
@ -0,0 +1,40 @@
|
||||
using System.Linq;
|
||||
using API.DTOs.Filtering;
|
||||
using API.DTOs.Filtering.v2;
|
||||
using API.Entities.Enums;
|
||||
using API.Helpers;
|
||||
using Xunit;
|
||||
|
||||
namespace API.Tests.Helpers;
|
||||
|
||||
public class SmartFilterHelperTests
|
||||
{
|
||||
[Fact]
|
||||
public void Test_Decode()
|
||||
{
|
||||
var encoded = """
|
||||
stmts=comparison%3D5%26field%3D18%26value%3D6%2Ccomparison%3D0%26field%3D4%26value%3D0%2Ccomparison%3D7%26field%3D1%26value%3Da&sortOptions=sortField=1&isAscending=true&limitTo=0&combination=1
|
||||
""";
|
||||
|
||||
var filter = SmartFilterHelper.Decode(encoded);
|
||||
|
||||
Assert.Equal(0, filter.LimitTo);
|
||||
Assert.Equal(SortField.SortName, filter.SortOptions.SortField);
|
||||
Assert.True(filter.SortOptions.IsAscending);
|
||||
Assert.Null(filter.Name);
|
||||
|
||||
var list = filter.Statements.ToList();
|
||||
AssertStatementSame(list[2], FilterField.SeriesName, FilterComparison.Matches, "a");
|
||||
AssertStatementSame(list[1], FilterField.AgeRating, FilterComparison.Equal, (int) AgeRating.Unknown + "");
|
||||
AssertStatementSame(list[0], FilterField.Genres, FilterComparison.Contains, "6");
|
||||
|
||||
}
|
||||
|
||||
private void AssertStatementSame(FilterStatementDto statement, FilterField field, FilterComparison combination, string value)
|
||||
{
|
||||
Assert.Equal(statement.Field, field);
|
||||
Assert.Equal(statement.Comparison, combination);
|
||||
Assert.Equal(statement.Value, value);
|
||||
}
|
||||
|
||||
}
|
@ -8,6 +8,7 @@ using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Account;
|
||||
using API.DTOs.Dashboard;
|
||||
using API.DTOs.Email;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
@ -1035,4 +1036,123 @@ public class AccountController : BaseApiController
|
||||
return Ok(origin + "/" + baseUrl + "api/opds/" + user!.ApiKey);
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the layout of the user's dashboard
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[HttpGet("dashboard")]
|
||||
public async Task<ActionResult<IEnumerable<DashboardStreamDto>>> GetDashboardLayout(bool visibleOnly = true)
|
||||
{
|
||||
var streams = await _unitOfWork.UserRepository.GetDashboardStreams(User.GetUserId(), visibleOnly);
|
||||
return Ok(streams);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a Dashboard Stream from a SmartFilter and adds it to the user's dashboard as visible
|
||||
/// </summary>
|
||||
/// <param name="smartFilterId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("add-dashboard-stream")]
|
||||
public async Task<ActionResult<DashboardStreamDto>> AddDashboard([FromQuery] int smartFilterId)
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.DashboardStreams);
|
||||
if (user == null) return Unauthorized();
|
||||
|
||||
var smartFilter = await _unitOfWork.AppUserSmartFilterRepository.GetById(smartFilterId);
|
||||
if (smartFilter == null) return NoContent();
|
||||
|
||||
var stream = user?.DashboardStreams.FirstOrDefault(d => d.SmartFilter?.Id == smartFilterId);
|
||||
if (stream != null) return BadRequest("There is an existing stream with this Filter");
|
||||
|
||||
var maxOrder = user!.DashboardStreams.Max(d => d.Order);
|
||||
var createdStream = new AppUserDashboardStream()
|
||||
{
|
||||
Name = smartFilter.Name,
|
||||
IsProvided = false,
|
||||
StreamType = DashboardStreamType.SmartFilter,
|
||||
Visible = true,
|
||||
Order = maxOrder + 1,
|
||||
SmartFilter = smartFilter
|
||||
};
|
||||
|
||||
user.DashboardStreams.Add(createdStream);
|
||||
_unitOfWork.UserRepository.Update(user);
|
||||
await _unitOfWork.CommitAsync();
|
||||
|
||||
var ret = new DashboardStreamDto()
|
||||
{
|
||||
Name = createdStream.Name,
|
||||
IsProvided = createdStream.IsProvided,
|
||||
Visible = createdStream.Visible,
|
||||
Order = createdStream.Order,
|
||||
SmartFilterEncoded = smartFilter.Filter,
|
||||
StreamType = createdStream.StreamType
|
||||
};
|
||||
|
||||
|
||||
await _eventHub.SendMessageToAsync(MessageFactory.DashboardUpdate, MessageFactory.DashboardUpdateEvent(user.Id),
|
||||
User.GetUserId());
|
||||
return Ok(ret);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the visibility of a dashboard stream
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("update-dashboard-stream")]
|
||||
public async Task<ActionResult> UpdateDashboardStream(DashboardStreamDto dto)
|
||||
{
|
||||
var stream = await _unitOfWork.UserRepository.GetDashboardStream(dto.Id);
|
||||
if (stream == null) return BadRequest();
|
||||
stream.Visible = dto.Visible;
|
||||
|
||||
_unitOfWork.UserRepository.Update(stream);
|
||||
await _unitOfWork.CommitAsync();
|
||||
var userId = User.GetUserId();
|
||||
await _eventHub.SendMessageToAsync(MessageFactory.DashboardUpdate, MessageFactory.DashboardUpdateEvent(userId),
|
||||
userId);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the position of a dashboard stream
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("update-dashboard-position")]
|
||||
public async Task<ActionResult> UpdateDashboardStreamPosition(UpdateDashboardStreamPositionDto dto)
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(),
|
||||
AppUserIncludes.DashboardStreams);
|
||||
var stream = user?.DashboardStreams.FirstOrDefault(d => d.Id == dto.DashboardStreamId);
|
||||
if (stream == null) return BadRequest();
|
||||
if (stream.Order == dto.ToPosition) return Ok();
|
||||
|
||||
var list = user!.DashboardStreams.ToList();
|
||||
ReorderItems(list, stream.Id, dto.ToPosition);
|
||||
user.DashboardStreams = list;
|
||||
|
||||
_unitOfWork.UserRepository.Update(user);
|
||||
await _unitOfWork.CommitAsync();
|
||||
await _eventHub.SendMessageToAsync(MessageFactory.DashboardUpdate, MessageFactory.DashboardUpdateEvent(user.Id),
|
||||
user.Id);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
private static void ReorderItems(List<AppUserDashboardStream> items, int itemId, int toPosition)
|
||||
{
|
||||
var item = items.Find(r => r.Id == itemId);
|
||||
if (item != null)
|
||||
{
|
||||
items.Remove(item);
|
||||
items.Insert(toPosition, item);
|
||||
}
|
||||
|
||||
for (var i = 0; i < items.Count; i++)
|
||||
{
|
||||
items[i].Order = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,15 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Constants;
|
||||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs.Dashboard;
|
||||
using API.DTOs.Filtering.v2;
|
||||
using API.Entities;
|
||||
using API.Extensions;
|
||||
using API.Helpers;
|
||||
using EasyCaching.Core;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
@ -22,38 +29,66 @@ public class FilterController : BaseApiController
|
||||
_cacheFactory = cacheFactory;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<FilterV2Dto?>> GetFilter(string name)
|
||||
/// <summary>
|
||||
/// Creates or Updates the filter
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("update")]
|
||||
public async Task<ActionResult> CreateOrUpdateSmartFilter(FilterV2Dto dto)
|
||||
{
|
||||
var provider = _cacheFactory.GetCachingProvider(EasyCacheProfiles.Filter);
|
||||
if (string.IsNullOrEmpty(name)) return Ok(null);
|
||||
var filter = await provider.GetAsync<FilterV2Dto>(name);
|
||||
if (filter.HasValue)
|
||||
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.SmartFilters);
|
||||
if (user == null) return Unauthorized();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(dto.Name)) return BadRequest("Name must be set");
|
||||
if (Seed.DefaultStreams.Any(s => s.Name.Equals(dto.Name, StringComparison.InvariantCultureIgnoreCase)))
|
||||
{
|
||||
filter.Value.Name = name;
|
||||
return Ok(filter.Value);
|
||||
return BadRequest("You cannot use the name of a system provided stream");
|
||||
}
|
||||
|
||||
return Ok(null);
|
||||
// I might just want to use DashboardStream instead of a separate entity. It will drastically simplify implementation
|
||||
|
||||
var existingFilter =
|
||||
user.SmartFilters.FirstOrDefault(f => f.Name.Equals(dto.Name, StringComparison.InvariantCultureIgnoreCase));
|
||||
if (existingFilter != null)
|
||||
{
|
||||
// Update the filter
|
||||
existingFilter.Filter = SmartFilterHelper.Encode(dto);
|
||||
_unitOfWork.AppUserSmartFilterRepository.Update(existingFilter);
|
||||
}
|
||||
else
|
||||
{
|
||||
existingFilter = new AppUserSmartFilter()
|
||||
{
|
||||
Name = dto.Name,
|
||||
Filter = SmartFilterHelper.Encode(dto)
|
||||
};
|
||||
user.SmartFilters.Add(existingFilter);
|
||||
_unitOfWork.UserRepository.Update(user);
|
||||
}
|
||||
|
||||
if (!_unitOfWork.HasChanges()) return Ok();
|
||||
await _unitOfWork.CommitAsync();
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Caches the filter in the backend and returns a temp string for retrieving.
|
||||
/// </summary>
|
||||
/// <remarks>The cache line lives for only 1 hour</remarks>
|
||||
/// <param name="filterDto"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("create-temp")]
|
||||
public async Task<ActionResult<string>> CreateTempFilter(FilterV2Dto filterDto)
|
||||
[HttpGet]
|
||||
public ActionResult<IEnumerable<SmartFilterDto>> GetFilters()
|
||||
{
|
||||
var provider = _cacheFactory.GetCachingProvider(EasyCacheProfiles.Filter);
|
||||
var name = filterDto.Name;
|
||||
if (string.IsNullOrEmpty(filterDto.Name))
|
||||
{
|
||||
name = Guid.NewGuid().ToString();
|
||||
}
|
||||
return Ok(_unitOfWork.AppUserSmartFilterRepository.GetAllDtosByUserId(User.GetUserId()));
|
||||
}
|
||||
|
||||
await provider.SetAsync(name, filterDto, TimeSpan.FromHours(1));
|
||||
return name;
|
||||
[HttpDelete]
|
||||
public async Task<ActionResult> DeleteFilter(int filterId)
|
||||
{
|
||||
var filter = await _unitOfWork.AppUserSmartFilterRepository.GetById(filterId);
|
||||
if (filter == null) return Ok();
|
||||
// This needs to delete any dashboard filters that have it too
|
||||
var streams = await _unitOfWork.UserRepository.GetDashboardStreamWithFilter(filter.Id);
|
||||
_unitOfWork.UserRepository.Delete(streams);
|
||||
_unitOfWork.AppUserSmartFilterRepository.Delete(filter);
|
||||
await _unitOfWork.CommitAsync();
|
||||
return Ok();
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using API.DTOs.Filtering;
|
||||
@ -19,11 +20,26 @@ public class LocaleController : BaseApiController
|
||||
[HttpGet]
|
||||
public ActionResult<IEnumerable<string>> GetAllLocales()
|
||||
{
|
||||
var languages = _localizationService.GetLocales().Select(c => new CultureInfo(c)).Select(c =>
|
||||
new LanguageDto()
|
||||
var languages = _localizationService.GetLocales().Select(c =>
|
||||
{
|
||||
Title = c.DisplayName,
|
||||
IsoCode = c.IetfLanguageTag
|
||||
try
|
||||
{
|
||||
var cult = new CultureInfo(c);
|
||||
return new LanguageDto()
|
||||
{
|
||||
Title = cult.DisplayName,
|
||||
IsoCode = cult.IetfLanguageTag
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Some OS' don't have all culture codes supported like PT_BR, thus we need to default
|
||||
return new LanguageDto()
|
||||
{
|
||||
Title = c,
|
||||
IsoCode = c
|
||||
};
|
||||
}
|
||||
})
|
||||
.Where(l => !string.IsNullOrEmpty(l.IsoCode))
|
||||
.OrderBy(d => d.Title);
|
||||
|
@ -102,32 +102,68 @@ public class OpdsController : BaseApiController
|
||||
|
||||
var feed = CreateFeed("Kavita", string.Empty, apiKey, prefix);
|
||||
SetFeedId(feed, "root");
|
||||
feed.Entries.Add(new FeedEntry()
|
||||
|
||||
// Get the user's customized dashboard
|
||||
var streams = await _unitOfWork.UserRepository.GetDashboardStreams(userId, true);
|
||||
foreach (var stream in streams)
|
||||
{
|
||||
Id = "onDeck",
|
||||
Title = await _localizationService.Translate(userId, "on-deck"),
|
||||
Content = new FeedEntryContent()
|
||||
switch (stream.StreamType)
|
||||
{
|
||||
Text = await _localizationService.Translate(userId, "browse-on-deck")
|
||||
},
|
||||
Links = new List<FeedLink>()
|
||||
{
|
||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/on-deck"),
|
||||
case DashboardStreamType.OnDeck:
|
||||
feed.Entries.Add(new FeedEntry()
|
||||
{
|
||||
Id = "onDeck",
|
||||
Title = await _localizationService.Translate(userId, "on-deck"),
|
||||
Content = new FeedEntryContent()
|
||||
{
|
||||
Text = await _localizationService.Translate(userId, "browse-on-deck")
|
||||
},
|
||||
Links = new List<FeedLink>()
|
||||
{
|
||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/on-deck"),
|
||||
}
|
||||
});
|
||||
break;
|
||||
case DashboardStreamType.NewlyAdded:
|
||||
feed.Entries.Add(new FeedEntry()
|
||||
{
|
||||
Id = "recentlyAdded",
|
||||
Title = await _localizationService.Translate(userId, "recently-added"),
|
||||
Content = new FeedEntryContent()
|
||||
{
|
||||
Text = await _localizationService.Translate(userId, "browse-recently-added")
|
||||
},
|
||||
Links = new List<FeedLink>()
|
||||
{
|
||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/recently-added"),
|
||||
}
|
||||
});
|
||||
break;
|
||||
case DashboardStreamType.RecentlyUpdated:
|
||||
// TODO: See if we can implement this and use (count) on series name for number of updates
|
||||
break;
|
||||
case DashboardStreamType.MoreInGenre:
|
||||
// TODO: See if we can implement this
|
||||
break;
|
||||
case DashboardStreamType.SmartFilter:
|
||||
|
||||
feed.Entries.Add(new FeedEntry()
|
||||
{
|
||||
Id = "smartFilter-" + stream.Id,
|
||||
Title = stream.Name,
|
||||
Content = new FeedEntryContent()
|
||||
{
|
||||
Text = stream.Name
|
||||
},
|
||||
Links = new List<FeedLink>()
|
||||
{
|
||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/smart-filter/{stream.SmartFilterId}/"),
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
});
|
||||
feed.Entries.Add(new FeedEntry()
|
||||
{
|
||||
Id = "recentlyAdded",
|
||||
Title = await _localizationService.Translate(userId, "recently-added"),
|
||||
Content = new FeedEntryContent()
|
||||
{
|
||||
Text = await _localizationService.Translate(userId, "browse-recently-added")
|
||||
},
|
||||
Links = new List<FeedLink>()
|
||||
{
|
||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/recently-added"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
feed.Entries.Add(new FeedEntry()
|
||||
{
|
||||
Id = "readingList",
|
||||
@ -180,6 +216,19 @@ public class OpdsController : BaseApiController
|
||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/collections"),
|
||||
}
|
||||
});
|
||||
feed.Entries.Add(new FeedEntry()
|
||||
{
|
||||
Id = "allSmartFilters",
|
||||
Title = await _localizationService.Translate(userId, "smart-filters"),
|
||||
Content = new FeedEntryContent()
|
||||
{
|
||||
Text = await _localizationService.Translate(userId, "browse-smart-filters")
|
||||
},
|
||||
Links = new List<FeedLink>()
|
||||
{
|
||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/smart-filters"),
|
||||
}
|
||||
});
|
||||
return CreateXmlResult(SerializeXml(feed));
|
||||
}
|
||||
|
||||
@ -196,6 +245,67 @@ public class OpdsController : BaseApiController
|
||||
return new Tuple<string, string>(baseUrl, prefix);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the Series matching this smart filter. If FromDashboard, will only return 20 records.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[HttpGet("{apiKey}/smart-filter/{filterId}")]
|
||||
[Produces("application/xml")]
|
||||
public async Task<IActionResult> GetSmartFilter(string apiKey, int filterId)
|
||||
{
|
||||
var userId = await GetUser(apiKey);
|
||||
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
||||
return BadRequest(await _localizationService.Translate(userId, "opds-disabled"));
|
||||
var (baseUrl, prefix) = await GetPrefix();
|
||||
|
||||
|
||||
var filter = await _unitOfWork.AppUserSmartFilterRepository.GetById(filterId);
|
||||
if (filter == null) return BadRequest(_localizationService.Translate(userId, "smart-filter-doesnt-exist"));
|
||||
var feed = CreateFeed(await _localizationService.Translate(userId, "smartFilter-" + filter.Id), $"{prefix}{apiKey}/smart-filter/{filter.Id}/", apiKey, prefix);
|
||||
SetFeedId(feed, "smartFilter-" + filter.Id);
|
||||
|
||||
var decodedFilter = SmartFilterHelper.Decode(filter.Filter);
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdV2Async(userId, UserParams.Default,
|
||||
decodedFilter);
|
||||
var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(series.Select(s => s.Id));
|
||||
|
||||
foreach (var seriesDto in series)
|
||||
{
|
||||
feed.Entries.Add(CreateSeries(seriesDto, seriesMetadatas.First(s => s.SeriesId == seriesDto.Id), apiKey, prefix, baseUrl));
|
||||
}
|
||||
|
||||
AddPagination(feed, series, $"{prefix}{apiKey}/smart-filter/{filterId}/");
|
||||
return CreateXmlResult(SerializeXml(feed));
|
||||
}
|
||||
|
||||
[HttpGet("{apiKey}/smart-filters")]
|
||||
[Produces("application/xml")]
|
||||
public async Task<IActionResult> GetSmartFilters(string apiKey)
|
||||
{
|
||||
var userId = await GetUser(apiKey);
|
||||
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
||||
return BadRequest(await _localizationService.Translate(userId, "opds-disabled"));
|
||||
var (baseUrl, prefix) = await GetPrefix();
|
||||
|
||||
var filters = _unitOfWork.AppUserSmartFilterRepository.GetAllDtosByUserId(userId);
|
||||
var feed = CreateFeed(await _localizationService.Translate(userId, "smartFilters"), $"{prefix}{apiKey}/smart-filters", apiKey, prefix);
|
||||
SetFeedId(feed, "smartFilters");
|
||||
foreach (var filter in filters)
|
||||
{
|
||||
feed.Entries.Add(new FeedEntry()
|
||||
{
|
||||
Id = filter.Id.ToString(),
|
||||
Title = filter.Name,
|
||||
Links = new List<FeedLink>()
|
||||
{
|
||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/smart-filter/{filter.Id}")
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return CreateXmlResult(SerializeXml(feed));
|
||||
}
|
||||
|
||||
|
||||
[HttpGet("{apiKey}/libraries")]
|
||||
[Produces("application/xml")]
|
||||
|
@ -6,6 +6,7 @@ using API.Constants;
|
||||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Dashboard;
|
||||
using API.DTOs.Filtering;
|
||||
using API.DTOs.Filtering.v2;
|
||||
using API.DTOs.Metadata;
|
||||
|
30
API/DTOs/Dashboard/DashboardStreamDto.cs
Normal file
30
API/DTOs/Dashboard/DashboardStreamDto.cs
Normal file
@ -0,0 +1,30 @@
|
||||
using API.DTOs.Filtering.v2;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
|
||||
namespace API.DTOs.Dashboard;
|
||||
|
||||
public class DashboardStreamDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public required string Name { get; set; }
|
||||
/// <summary>
|
||||
/// Is System Provided
|
||||
/// </summary>
|
||||
public bool IsProvided { get; set; }
|
||||
/// <summary>
|
||||
/// Sort Order on the Dashboard
|
||||
/// </summary>
|
||||
public int Order { get; set; }
|
||||
/// <summary>
|
||||
/// If Not IsProvided, the appropriate smart filter
|
||||
/// </summary>
|
||||
/// <remarks>Encoded filter</remarks>
|
||||
public string? SmartFilterEncoded { get; set; }
|
||||
public int? SmartFilterId { get; set; }
|
||||
/// <summary>
|
||||
/// For system provided
|
||||
/// </summary>
|
||||
public DashboardStreamType StreamType { get; set; }
|
||||
public bool Visible { get; set; }
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
using System;
|
||||
using API.Entities.Enums;
|
||||
|
||||
namespace API.DTOs;
|
||||
namespace API.DTOs.Dashboard;
|
||||
/// <summary>
|
||||
/// This is a representation of a Series with some amount of underlying files within it. This is used for Recently Updated Series section
|
||||
/// </summary>
|
@ -1,7 +1,7 @@
|
||||
using System;
|
||||
using API.Entities.Enums;
|
||||
|
||||
namespace API.DTOs;
|
||||
namespace API.DTOs.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// A mesh of data for Recently added volume/chapters
|
13
API/DTOs/Dashboard/SmartFilterDto.cs
Normal file
13
API/DTOs/Dashboard/SmartFilterDto.cs
Normal file
@ -0,0 +1,13 @@
|
||||
using API.DTOs.Filtering.v2;
|
||||
|
||||
namespace API.DTOs.Dashboard;
|
||||
|
||||
public class SmartFilterDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public required string Name { get; set; }
|
||||
/// <summary>
|
||||
/// This is the Filter url encoded. It is decoded and reconstructed into a <see cref="FilterV2Dto"/>
|
||||
/// </summary>
|
||||
public required string Filter { get; set; }
|
||||
}
|
9
API/DTOs/Dashboard/UpdateDashboardStreamPositionDto.cs
Normal file
9
API/DTOs/Dashboard/UpdateDashboardStreamPositionDto.cs
Normal file
@ -0,0 +1,9 @@
|
||||
namespace API.DTOs.Dashboard;
|
||||
|
||||
public class UpdateDashboardStreamPositionDto
|
||||
{
|
||||
public int FromPosition { get; set; }
|
||||
public int ToPosition { get; set; }
|
||||
public int DashboardStreamId { get; set; }
|
||||
public string StreamName { get; set; }
|
||||
}
|
@ -25,5 +25,9 @@ public enum SortField
|
||||
/// <summary>
|
||||
/// Release Year of the Series
|
||||
/// </summary>
|
||||
ReleaseYear = 6
|
||||
ReleaseYear = 6,
|
||||
/// <summary>
|
||||
/// Last time the user had any reading progress
|
||||
/// </summary>
|
||||
ReadProgress = 7,
|
||||
}
|
||||
|
@ -36,5 +36,10 @@ public enum FilterField
|
||||
/// <summary>
|
||||
/// File path
|
||||
/// </summary>
|
||||
FilePath = 25
|
||||
FilePath = 25,
|
||||
/// <summary>
|
||||
/// On Want To Read or Not
|
||||
/// </summary>
|
||||
WantToRead = 26
|
||||
|
||||
}
|
||||
|
@ -10,6 +10,10 @@ namespace API.DTOs.Filtering.v2;
|
||||
/// </summary>
|
||||
public class FilterV2Dto
|
||||
{
|
||||
/// <summary>
|
||||
/// Not used in the UI.
|
||||
/// </summary>
|
||||
public int Id { get; set; }
|
||||
/// <summary>
|
||||
/// The name of the filter
|
||||
/// </summary>
|
||||
|
@ -54,6 +54,8 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
|
||||
public DbSet<ScrobbleHold> ScrobbleHold { get; set; } = null!;
|
||||
public DbSet<AppUserOnDeckRemoval> AppUserOnDeckRemoval { get; set; } = null!;
|
||||
public DbSet<AppUserTableOfContent> AppUserTableOfContent { get; set; } = null!;
|
||||
public DbSet<AppUserSmartFilter> AppUserSmartFilter { get; set; } = null!;
|
||||
public DbSet<AppUserDashboardStream> AppUserDashboardStream { get; set; } = null!;
|
||||
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
@ -119,6 +121,13 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
|
||||
builder.Entity<Chapter>()
|
||||
.Property(b => b.ISBN)
|
||||
.HasDefaultValue(string.Empty);
|
||||
|
||||
builder.Entity<AppUserDashboardStream>()
|
||||
.Property(b => b.StreamType)
|
||||
.HasDefaultValue(DashboardStreamType.SmartFilter);
|
||||
builder.Entity<AppUserDashboardStream>()
|
||||
.HasIndex(e => e.Visible)
|
||||
.IsUnique(false);
|
||||
}
|
||||
|
||||
|
||||
|
2310
API/Data/Migrations/20230904184205_SmartFilters.Designer.cs
generated
Normal file
2310
API/Data/Migrations/20230904184205_SmartFilters.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
47
API/Data/Migrations/20230904184205_SmartFilters.cs
Normal file
47
API/Data/Migrations/20230904184205_SmartFilters.cs
Normal file
@ -0,0 +1,47 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace API.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class SmartFilters : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AppUserSmartFilter",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Name = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Filter = table.Column<string>(type: "TEXT", nullable: true),
|
||||
AppUserId = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AppUserSmartFilter", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_AppUserSmartFilter_AspNetUsers_AppUserId",
|
||||
column: x => x.AppUserId,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AppUserSmartFilter_AppUserId",
|
||||
table: "AppUserSmartFilter",
|
||||
column: "AppUserId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "AppUserSmartFilter");
|
||||
}
|
||||
}
|
||||
}
|
2369
API/Data/Migrations/20230908190713_DashboardStream.Designer.cs
generated
Normal file
2369
API/Data/Migrations/20230908190713_DashboardStream.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
66
API/Data/Migrations/20230908190713_DashboardStream.cs
Normal file
66
API/Data/Migrations/20230908190713_DashboardStream.cs
Normal file
@ -0,0 +1,66 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace API.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class DashboardStream : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AppUserDashboardStream",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Name = table.Column<string>(type: "TEXT", nullable: true),
|
||||
IsProvided = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
Order = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
StreamType = table.Column<int>(type: "INTEGER", nullable: false, defaultValue: 4),
|
||||
Visible = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
SmartFilterId = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
AppUserId = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AppUserDashboardStream", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_AppUserDashboardStream_AppUserSmartFilter_SmartFilterId",
|
||||
column: x => x.SmartFilterId,
|
||||
principalTable: "AppUserSmartFilter",
|
||||
principalColumn: "Id");
|
||||
table.ForeignKey(
|
||||
name: "FK_AppUserDashboardStream_AspNetUsers_AppUserId",
|
||||
column: x => x.AppUserId,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AppUserDashboardStream_AppUserId",
|
||||
table: "AppUserDashboardStream",
|
||||
column: "AppUserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AppUserDashboardStream_SmartFilterId",
|
||||
table: "AppUserDashboardStream",
|
||||
column: "SmartFilterId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AppUserDashboardStream_Visible",
|
||||
table: "AppUserDashboardStream",
|
||||
column: "Visible");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "AppUserDashboardStream");
|
||||
}
|
||||
}
|
||||
}
|
@ -180,7 +180,47 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasIndex("AppUserId");
|
||||
|
||||
b.ToTable("AppUserBookmark", (string)null);
|
||||
b.ToTable("AppUserBookmark");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserDashboardStream", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("AppUserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsProvided")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Order")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("SmartFilterId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("StreamType")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(4);
|
||||
|
||||
b.Property<bool>("Visible")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AppUserId");
|
||||
|
||||
b.HasIndex("SmartFilterId");
|
||||
|
||||
b.HasIndex("Visible");
|
||||
|
||||
b.ToTable("AppUserDashboardStream");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b =>
|
||||
@ -201,7 +241,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("AppUserOnDeckRemoval", (string)null);
|
||||
b.ToTable("AppUserOnDeckRemoval");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserPreferences", b =>
|
||||
@ -315,7 +355,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasIndex("ThemeId");
|
||||
|
||||
b.ToTable("AppUserPreferences", (string)null);
|
||||
b.ToTable("AppUserPreferences");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserProgress", b =>
|
||||
@ -365,7 +405,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("AppUserProgresses", (string)null);
|
||||
b.ToTable("AppUserProgresses");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserRating", b =>
|
||||
@ -398,7 +438,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("AppUserRating", (string)null);
|
||||
b.ToTable("AppUserRating");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserRole", b =>
|
||||
@ -416,6 +456,28 @@ namespace API.Data.Migrations
|
||||
b.ToTable("AspNetUserRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserSmartFilter", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("AppUserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Filter")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AppUserId");
|
||||
|
||||
b.ToTable("AppUserSmartFilter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserTableOfContent", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@ -466,7 +528,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("AppUserTableOfContent", (string)null);
|
||||
b.ToTable("AppUserTableOfContent");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Chapter", b =>
|
||||
@ -576,7 +638,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasIndex("VolumeId");
|
||||
|
||||
b.ToTable("Chapter", (string)null);
|
||||
b.ToTable("Chapter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.CollectionTag", b =>
|
||||
@ -611,7 +673,7 @@ namespace API.Data.Migrations
|
||||
b.HasIndex("Id", "Promoted")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CollectionTag", (string)null);
|
||||
b.ToTable("CollectionTag");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Device", b =>
|
||||
@ -657,7 +719,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasIndex("AppUserId");
|
||||
|
||||
b.ToTable("Device", (string)null);
|
||||
b.ToTable("Device");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.FolderPath", b =>
|
||||
@ -679,7 +741,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasIndex("LibraryId");
|
||||
|
||||
b.ToTable("FolderPath", (string)null);
|
||||
b.ToTable("FolderPath");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Genre", b =>
|
||||
@ -699,7 +761,7 @@ namespace API.Data.Migrations
|
||||
b.HasIndex("NormalizedTitle")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Genre", (string)null);
|
||||
b.ToTable("Genre");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Library", b =>
|
||||
@ -757,7 +819,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Library", (string)null);
|
||||
b.ToTable("Library");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.MangaFile", b =>
|
||||
@ -806,7 +868,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasIndex("ChapterId");
|
||||
|
||||
b.ToTable("MangaFile", (string)null);
|
||||
b.ToTable("MangaFile");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.MediaError", b =>
|
||||
@ -841,7 +903,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("MediaError", (string)null);
|
||||
b.ToTable("MediaError");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b =>
|
||||
@ -942,7 +1004,7 @@ namespace API.Data.Migrations
|
||||
b.HasIndex("Id", "SeriesId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("SeriesMetadata", (string)null);
|
||||
b.ToTable("SeriesMetadata");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b =>
|
||||
@ -966,7 +1028,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasIndex("TargetSeriesId");
|
||||
|
||||
b.ToTable("SeriesRelation", (string)null);
|
||||
b.ToTable("SeriesRelation");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Person", b =>
|
||||
@ -986,7 +1048,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Person", (string)null);
|
||||
b.ToTable("Person");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.ReadingList", b =>
|
||||
@ -1049,7 +1111,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasIndex("AppUserId");
|
||||
|
||||
b.ToTable("ReadingList", (string)null);
|
||||
b.ToTable("ReadingList");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.ReadingListItem", b =>
|
||||
@ -1083,7 +1145,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasIndex("VolumeId");
|
||||
|
||||
b.ToTable("ReadingListItem", (string)null);
|
||||
b.ToTable("ReadingListItem");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b =>
|
||||
@ -1128,7 +1190,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("ScrobbleError", (string)null);
|
||||
b.ToTable("ScrobbleError");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b =>
|
||||
@ -1188,8 +1250,8 @@ namespace API.Data.Migrations
|
||||
b.Property<int>("SeriesId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<float?>("VolumeNumber")
|
||||
.HasColumnType("REAL");
|
||||
b.Property<int?>("VolumeNumber")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
@ -1199,7 +1261,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("ScrobbleEvent", (string)null);
|
||||
b.ToTable("ScrobbleEvent");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b =>
|
||||
@ -1232,7 +1294,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("ScrobbleHold", (string)null);
|
||||
b.ToTable("ScrobbleHold");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Series", b =>
|
||||
@ -1328,7 +1390,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasIndex("LibraryId");
|
||||
|
||||
b.ToTable("Series", (string)null);
|
||||
b.ToTable("Series");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.ServerSetting", b =>
|
||||
@ -1345,7 +1407,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasKey("Key");
|
||||
|
||||
b.ToTable("ServerSetting", (string)null);
|
||||
b.ToTable("ServerSetting");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.ServerStatistics", b =>
|
||||
@ -1383,7 +1445,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("ServerStatistics", (string)null);
|
||||
b.ToTable("ServerStatistics");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.SiteTheme", b =>
|
||||
@ -1421,7 +1483,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("SiteTheme", (string)null);
|
||||
b.ToTable("SiteTheme");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Tag", b =>
|
||||
@ -1441,7 +1503,7 @@ namespace API.Data.Migrations
|
||||
b.HasIndex("NormalizedTitle")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Tag", (string)null);
|
||||
b.ToTable("Tag");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Volume", b =>
|
||||
@ -1477,8 +1539,8 @@ namespace API.Data.Migrations
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<float>("Number")
|
||||
.HasColumnType("REAL");
|
||||
b.Property<int>("Number")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Pages")
|
||||
.HasColumnType("INTEGER");
|
||||
@ -1493,7 +1555,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("Volume", (string)null);
|
||||
b.ToTable("Volume");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AppUserLibrary", b =>
|
||||
@ -1508,7 +1570,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasIndex("LibrariesId");
|
||||
|
||||
b.ToTable("AppUserLibrary", (string)null);
|
||||
b.ToTable("AppUserLibrary");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ChapterGenre", b =>
|
||||
@ -1523,7 +1585,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasIndex("GenresId");
|
||||
|
||||
b.ToTable("ChapterGenre", (string)null);
|
||||
b.ToTable("ChapterGenre");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ChapterPerson", b =>
|
||||
@ -1538,7 +1600,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasIndex("PeopleId");
|
||||
|
||||
b.ToTable("ChapterPerson", (string)null);
|
||||
b.ToTable("ChapterPerson");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ChapterTag", b =>
|
||||
@ -1553,7 +1615,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasIndex("TagsId");
|
||||
|
||||
b.ToTable("ChapterTag", (string)null);
|
||||
b.ToTable("ChapterTag");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CollectionTagSeriesMetadata", b =>
|
||||
@ -1568,7 +1630,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasIndex("SeriesMetadatasId");
|
||||
|
||||
b.ToTable("CollectionTagSeriesMetadata", (string)null);
|
||||
b.ToTable("CollectionTagSeriesMetadata");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GenreSeriesMetadata", b =>
|
||||
@ -1583,7 +1645,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasIndex("SeriesMetadatasId");
|
||||
|
||||
b.ToTable("GenreSeriesMetadata", (string)null);
|
||||
b.ToTable("GenreSeriesMetadata");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<int>", b =>
|
||||
@ -1682,7 +1744,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasIndex("SeriesMetadatasId");
|
||||
|
||||
b.ToTable("PersonSeriesMetadata", (string)null);
|
||||
b.ToTable("PersonSeriesMetadata");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SeriesMetadataTag", b =>
|
||||
@ -1697,7 +1759,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasIndex("TagsId");
|
||||
|
||||
b.ToTable("SeriesMetadataTag", (string)null);
|
||||
b.ToTable("SeriesMetadataTag");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserBookmark", b =>
|
||||
@ -1711,6 +1773,23 @@ namespace API.Data.Migrations
|
||||
b.Navigation("AppUser");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserDashboardStream", b =>
|
||||
{
|
||||
b.HasOne("API.Entities.AppUser", "AppUser")
|
||||
.WithMany("DashboardStreams")
|
||||
.HasForeignKey("AppUserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter")
|
||||
.WithMany()
|
||||
.HasForeignKey("SmartFilterId");
|
||||
|
||||
b.Navigation("AppUser");
|
||||
|
||||
b.Navigation("SmartFilter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b =>
|
||||
{
|
||||
b.HasOne("API.Entities.AppUser", "AppUser")
|
||||
@ -1808,6 +1887,17 @@ namespace API.Data.Migrations
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserSmartFilter", b =>
|
||||
{
|
||||
b.HasOne("API.Entities.AppUser", "AppUser")
|
||||
.WithMany("SmartFilters")
|
||||
.HasForeignKey("AppUserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("AppUser");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserTableOfContent", b =>
|
||||
{
|
||||
b.HasOne("API.Entities.AppUser", "AppUser")
|
||||
@ -2209,6 +2299,8 @@ namespace API.Data.Migrations
|
||||
{
|
||||
b.Navigation("Bookmarks");
|
||||
|
||||
b.Navigation("DashboardStreams");
|
||||
|
||||
b.Navigation("Devices");
|
||||
|
||||
b.Navigation("Progresses");
|
||||
@ -2219,6 +2311,8 @@ namespace API.Data.Migrations
|
||||
|
||||
b.Navigation("ScrobbleHolds");
|
||||
|
||||
b.Navigation("SmartFilters");
|
||||
|
||||
b.Navigation("TableOfContents");
|
||||
|
||||
b.Navigation("UserPreferences");
|
||||
|
60
API/Data/Repositories/AppUserSmartFilterRepository.cs
Normal file
60
API/Data/Repositories/AppUserSmartFilterRepository.cs
Normal file
@ -0,0 +1,60 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.DTOs.Dashboard;
|
||||
using API.Entities;
|
||||
using AutoMapper;
|
||||
using AutoMapper.QueryableExtensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace API.Data.Repositories;
|
||||
|
||||
public interface IAppUserSmartFilterRepository
|
||||
{
|
||||
void Update(AppUserSmartFilter filter);
|
||||
void Attach(AppUserSmartFilter filter);
|
||||
void Delete(AppUserSmartFilter filter);
|
||||
IEnumerable<SmartFilterDto> GetAllDtosByUserId(int userId);
|
||||
Task<AppUserSmartFilter?> GetById(int smartFilterId);
|
||||
|
||||
}
|
||||
|
||||
public class AppUserSmartFilterRepository : IAppUserSmartFilterRepository
|
||||
{
|
||||
private readonly DataContext _context;
|
||||
private readonly IMapper _mapper;
|
||||
|
||||
public AppUserSmartFilterRepository(DataContext context, IMapper mapper)
|
||||
{
|
||||
_context = context;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
public void Update(AppUserSmartFilter filter)
|
||||
{
|
||||
_context.Entry(filter).State = EntityState.Modified;
|
||||
}
|
||||
|
||||
public void Attach(AppUserSmartFilter filter)
|
||||
{
|
||||
_context.AppUserSmartFilter.Attach(filter);
|
||||
}
|
||||
|
||||
public void Delete(AppUserSmartFilter filter)
|
||||
{
|
||||
_context.AppUserSmartFilter.Remove(filter);
|
||||
}
|
||||
|
||||
public IEnumerable<SmartFilterDto> GetAllDtosByUserId(int userId)
|
||||
{
|
||||
return _context.AppUserSmartFilter
|
||||
.Where(f => f.AppUserId == userId)
|
||||
.ProjectTo<SmartFilterDto>(_mapper.ConfigurationProvider)
|
||||
.AsEnumerable();
|
||||
}
|
||||
|
||||
public async Task<AppUserSmartFilter?> GetById(int smartFilterId)
|
||||
{
|
||||
return await _context.AppUserSmartFilter.FirstOrDefaultAsync(d => d.Id == smartFilterId);
|
||||
}
|
||||
}
|
@ -8,6 +8,7 @@ using API.Data.Misc;
|
||||
using API.Data.Scanner;
|
||||
using API.DTOs;
|
||||
using API.DTOs.CollectionTags;
|
||||
using API.DTOs.Dashboard;
|
||||
using API.DTOs.Filtering;
|
||||
using API.DTOs.Filtering.v2;
|
||||
using API.DTOs.Metadata;
|
||||
@ -952,6 +953,9 @@ public class SeriesRepository : ISeriesRepository
|
||||
// First setup any FilterField.Libraries in the statements, as these don't have any traditional query statements applied here
|
||||
query = ApplyLibraryFilter(filter, query);
|
||||
|
||||
query = ApplyWantToReadFilter(filter, query, userId);
|
||||
|
||||
|
||||
query = BuildFilterQuery(userId, filter, query);
|
||||
|
||||
|
||||
@ -968,6 +972,24 @@ public class SeriesRepository : ISeriesRepository
|
||||
.AsSplitQuery(), filter.LimitTo);
|
||||
}
|
||||
|
||||
private IQueryable<Series> ApplyWantToReadFilter(FilterV2Dto filter, IQueryable<Series> query, int userId)
|
||||
{
|
||||
var wantToReadStmt = filter.Statements.FirstOrDefault(stmt => stmt.Field == FilterField.WantToRead);
|
||||
if (wantToReadStmt == null) return query;
|
||||
|
||||
var seriesIds = _context.AppUser.Where(u => u.Id == userId).SelectMany(u => u.WantToRead).Select(s => s.Id);
|
||||
if (bool.Parse(wantToReadStmt.Value))
|
||||
{
|
||||
query = query.Where(s => seriesIds.Contains(s.Id));
|
||||
}
|
||||
else
|
||||
{
|
||||
query = query.Where(s => !seriesIds.Contains(s.Id));
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
private static IQueryable<Series> ApplyLibraryFilter(FilterV2Dto filter, IQueryable<Series> query)
|
||||
{
|
||||
var filterIncludeLibs = new List<int>();
|
||||
@ -1060,6 +1082,9 @@ public class SeriesRepository : ISeriesRepository
|
||||
FilterField.Libraries =>
|
||||
// This is handled in the code before this as it's handled in a more general, combined manner
|
||||
query,
|
||||
FilterField.WantToRead =>
|
||||
// This is handled in the higher level of code as it's more general
|
||||
query,
|
||||
FilterField.ReadProgress => query.HasReadingProgress(true, statement.Comparison, (int) value, userId),
|
||||
FilterField.Formats => query.HasFormat(true, statement.Comparison, (IList<MangaFormat>) value),
|
||||
FilterField.ReleaseYear => query.HasReleaseYear(true, statement.Comparison, (int) value),
|
||||
|
@ -6,7 +6,7 @@ using System.Threading.Tasks;
|
||||
using API.Constants;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Account;
|
||||
using API.DTOs.Filtering;
|
||||
using API.DTOs.Dashboard;
|
||||
using API.DTOs.Filtering.v2;
|
||||
using API.DTOs.Reader;
|
||||
using API.DTOs.Scrobbling;
|
||||
@ -15,6 +15,7 @@ using API.Entities;
|
||||
using API.Extensions;
|
||||
using API.Extensions.QueryExtensions;
|
||||
using API.Extensions.QueryExtensions.Filtering;
|
||||
using API.Helpers;
|
||||
using AutoMapper;
|
||||
using AutoMapper.QueryableExtensions;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
@ -34,8 +35,9 @@ public enum AppUserIncludes
|
||||
WantToRead = 64,
|
||||
ReadingListsWithItems = 128,
|
||||
Devices = 256,
|
||||
ScrobbleHolds = 512
|
||||
|
||||
ScrobbleHolds = 512,
|
||||
SmartFilters = 1024,
|
||||
DashboardStreams = 2048
|
||||
}
|
||||
|
||||
public interface IUserRepository
|
||||
@ -43,9 +45,11 @@ public interface IUserRepository
|
||||
void Update(AppUser user);
|
||||
void Update(AppUserPreferences preferences);
|
||||
void Update(AppUserBookmark bookmark);
|
||||
void Update(AppUserDashboardStream stream);
|
||||
void Add(AppUserBookmark bookmark);
|
||||
public void Delete(AppUser? user);
|
||||
void Delete(AppUser? user);
|
||||
void Delete(AppUserBookmark bookmark);
|
||||
void Delete(IList<AppUserDashboardStream> streams);
|
||||
Task<IEnumerable<MemberDto>> GetEmailConfirmedMemberDtosAsync(bool emailConfirmed = true);
|
||||
Task<IEnumerable<AppUser>> GetAdminUsersAsync();
|
||||
Task<bool> IsUserAdminAsync(AppUser? user);
|
||||
@ -76,6 +80,9 @@ public interface IUserRepository
|
||||
Task<bool> HasHoldOnSeries(int userId, int seriesId);
|
||||
Task<IList<ScrobbleHoldDto>> GetHolds(int userId);
|
||||
Task<string> GetLocale(int userId);
|
||||
Task<IList<DashboardStreamDto>> GetDashboardStreams(int userId, bool visibleOnly = false);
|
||||
Task<AppUserDashboardStream?> GetDashboardStream(int streamId);
|
||||
Task<IList<AppUserDashboardStream>> GetDashboardStreamWithFilter(int filterId);
|
||||
}
|
||||
|
||||
public class UserRepository : IUserRepository
|
||||
@ -106,6 +113,11 @@ public class UserRepository : IUserRepository
|
||||
_context.Entry(bookmark).State = EntityState.Modified;
|
||||
}
|
||||
|
||||
public void Update(AppUserDashboardStream stream)
|
||||
{
|
||||
_context.Entry(stream).State = EntityState.Modified;
|
||||
}
|
||||
|
||||
public void Add(AppUserBookmark bookmark)
|
||||
{
|
||||
_context.AppUserBookmark.Add(bookmark);
|
||||
@ -122,6 +134,11 @@ public class UserRepository : IUserRepository
|
||||
_context.AppUserBookmark.Remove(bookmark);
|
||||
}
|
||||
|
||||
public void Delete(IList<AppUserDashboardStream> streams)
|
||||
{
|
||||
_context.AppUserDashboardStream.RemoveRange(streams);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A one stop shop to get a tracked AppUser instance with any number of JOINs generated by passing bitwise flags.
|
||||
/// </summary>
|
||||
@ -300,6 +317,42 @@ public class UserRepository : IUserRepository
|
||||
.SingleAsync();
|
||||
}
|
||||
|
||||
public async Task<IList<DashboardStreamDto>> GetDashboardStreams(int userId, bool visibleOnly = false)
|
||||
{
|
||||
return await _context.AppUserDashboardStream
|
||||
.Where(d => d.AppUserId == userId)
|
||||
.WhereIf(visibleOnly, d => d.Visible)
|
||||
.OrderBy(d => d.Order)
|
||||
.Include(d => d.SmartFilter)
|
||||
.Select(d => new DashboardStreamDto()
|
||||
{
|
||||
Id = d.Id,
|
||||
Name = d.Name,
|
||||
IsProvided = d.IsProvided,
|
||||
SmartFilterId = d.SmartFilter == null ? 0 : d.SmartFilter.Id,
|
||||
SmartFilterEncoded = d.SmartFilter == null ? null : d.SmartFilter.Filter,
|
||||
StreamType = d.StreamType,
|
||||
Order = d.Order,
|
||||
Visible = d.Visible
|
||||
})
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<AppUserDashboardStream?> GetDashboardStream(int streamId)
|
||||
{
|
||||
return await _context.AppUserDashboardStream
|
||||
.Include(d => d.SmartFilter)
|
||||
.FirstOrDefaultAsync(d => d.Id == streamId);
|
||||
}
|
||||
|
||||
public async Task<IList<AppUserDashboardStream>> GetDashboardStreamWithFilter(int filterId)
|
||||
{
|
||||
return await _context.AppUserDashboardStream
|
||||
.Include(d => d.SmartFilter)
|
||||
.Where(d => d.SmartFilter != null && d.SmartFilter.Id == filterId)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<AppUser>> GetAdminUsersAsync()
|
||||
{
|
||||
return await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole);
|
||||
|
@ -6,6 +6,7 @@ using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
using API.Constants;
|
||||
using API.Data.Repositories;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Enums.Theme;
|
||||
@ -38,6 +39,43 @@ public static class Seed
|
||||
}
|
||||
}.ToArray());
|
||||
|
||||
public static readonly ImmutableArray<AppUserDashboardStream> DefaultStreams = ImmutableArray.Create(
|
||||
new List<AppUserDashboardStream>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Name = "On Deck",
|
||||
StreamType = DashboardStreamType.OnDeck,
|
||||
Order = 0,
|
||||
IsProvided = true,
|
||||
Visible = true
|
||||
},
|
||||
new()
|
||||
{
|
||||
Name = "Recently Updated",
|
||||
StreamType = DashboardStreamType.RecentlyUpdated,
|
||||
Order = 1,
|
||||
IsProvided = true,
|
||||
Visible = true
|
||||
},
|
||||
new()
|
||||
{
|
||||
Name = "Newly Added",
|
||||
StreamType = DashboardStreamType.NewlyAdded,
|
||||
Order = 2,
|
||||
IsProvided = true,
|
||||
Visible = true
|
||||
},
|
||||
new()
|
||||
{
|
||||
Name = "More In",
|
||||
StreamType = DashboardStreamType.MoreInGenre,
|
||||
Order = 3,
|
||||
IsProvided = true,
|
||||
Visible = false
|
||||
},
|
||||
}.ToArray());
|
||||
|
||||
public static async Task SeedRoles(RoleManager<AppRole> roleManager)
|
||||
{
|
||||
var roles = typeof(PolicyConstants)
|
||||
@ -74,6 +112,31 @@ public static class Seed
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public static async Task SeedDefaultStreams(IUnitOfWork unitOfWork)
|
||||
{
|
||||
var allUsers = await unitOfWork.UserRepository.GetAllUsersAsync(AppUserIncludes.DashboardStreams);
|
||||
foreach (var user in allUsers)
|
||||
{
|
||||
if (user.DashboardStreams.Count != 0) continue;
|
||||
user.DashboardStreams ??= new List<AppUserDashboardStream>();
|
||||
foreach (var defaultStream in DefaultStreams)
|
||||
{
|
||||
var newStream = new AppUserDashboardStream
|
||||
{
|
||||
Name = defaultStream.Name,
|
||||
IsProvided = defaultStream.IsProvided,
|
||||
Order = defaultStream.Order,
|
||||
StreamType = defaultStream.StreamType,
|
||||
Visible = defaultStream.Visible,
|
||||
};
|
||||
|
||||
user.DashboardStreams.Add(newStream);
|
||||
}
|
||||
unitOfWork.UserRepository.Update(user);
|
||||
await unitOfWork.CommitAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task SeedSettings(DataContext context, IDirectoryService directoryService)
|
||||
{
|
||||
await context.Database.EnsureCreatedAsync();
|
||||
|
@ -28,6 +28,7 @@ public interface IUnitOfWork
|
||||
IMediaErrorRepository MediaErrorRepository { get; }
|
||||
IScrobbleRepository ScrobbleRepository { get; }
|
||||
IUserTableOfContentRepository UserTableOfContentRepository { get; }
|
||||
IAppUserSmartFilterRepository AppUserSmartFilterRepository { get; }
|
||||
bool Commit();
|
||||
Task<bool> CommitAsync();
|
||||
bool HasChanges();
|
||||
@ -68,6 +69,7 @@ public class UnitOfWork : IUnitOfWork
|
||||
public IMediaErrorRepository MediaErrorRepository => new MediaErrorRepository(_context, _mapper);
|
||||
public IScrobbleRepository ScrobbleRepository => new ScrobbleRepository(_context, _mapper);
|
||||
public IUserTableOfContentRepository UserTableOfContentRepository => new UserTableOfContentRepository(_context, _mapper);
|
||||
public IAppUserSmartFilterRepository AppUserSmartFilterRepository => new AppUserSmartFilterRepository(_context, _mapper);
|
||||
|
||||
/// <summary>
|
||||
/// Commits changes to the DB. Completes the open transaction.
|
||||
|
@ -67,6 +67,15 @@ public class AppUser : IdentityUser<int>, IHasConcurrencyToken
|
||||
/// A list of Series the user doesn't want scrobbling for
|
||||
/// </summary>
|
||||
public ICollection<ScrobbleHold> ScrobbleHolds { get; set; } = null!;
|
||||
/// <summary>
|
||||
/// A collection of user Smart Filters for their account
|
||||
/// </summary>
|
||||
public ICollection<AppUserSmartFilter> SmartFilters { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// An ordered list of Streams (pre-configured) or Smart Filters that makes up the User's Dashboard
|
||||
/// </summary>
|
||||
public IList<AppUserDashboardStream> DashboardStreams { get; set; } = null!;
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
|
29
API/Entities/AppUserDashboardStream.cs
Normal file
29
API/Entities/AppUserDashboardStream.cs
Normal file
@ -0,0 +1,29 @@
|
||||
using API.Entities.Enums;
|
||||
|
||||
|
||||
namespace API.Entities;
|
||||
|
||||
public class AppUserDashboardStream
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public required string Name { get; set; }
|
||||
/// <summary>
|
||||
/// Is System Provided
|
||||
/// </summary>
|
||||
public bool IsProvided { get; set; }
|
||||
/// <summary>
|
||||
/// Sort Order on the Dashboard
|
||||
/// </summary>
|
||||
public int Order { get; set; }
|
||||
/// <summary>
|
||||
/// For system provided
|
||||
/// </summary>
|
||||
public DashboardStreamType StreamType { get; set; }
|
||||
public bool Visible { get; set; }
|
||||
/// <summary>
|
||||
/// If Not IsProvided, the appropriate smart filter
|
||||
/// </summary>
|
||||
public AppUserSmartFilter? SmartFilter { get; set; }
|
||||
public int AppUserId { get; set; }
|
||||
public AppUser AppUser { get; set; }
|
||||
}
|
19
API/Entities/AppUserSmartFilter.cs
Normal file
19
API/Entities/AppUserSmartFilter.cs
Normal file
@ -0,0 +1,19 @@
|
||||
using API.DTOs.Filtering.v2;
|
||||
|
||||
namespace API.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a Saved user Filter
|
||||
/// </summary>
|
||||
public class AppUserSmartFilter
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public required string Name { get; set; }
|
||||
/// <summary>
|
||||
/// This is the Filter url encoded. It is decoded and reconstructed into a <see cref="FilterV2Dto"/>
|
||||
/// </summary>
|
||||
public required string Filter { get; set; }
|
||||
|
||||
public int AppUserId { get; set; }
|
||||
public AppUser AppUser { get; set; }
|
||||
}
|
14
API/Entities/Enums/DashboardStreamType.cs
Normal file
14
API/Entities/Enums/DashboardStreamType.cs
Normal file
@ -0,0 +1,14 @@
|
||||
namespace API.Entities.Enums;
|
||||
|
||||
public enum DashboardStreamType
|
||||
{
|
||||
OnDeck = 1,
|
||||
RecentlyUpdated = 2,
|
||||
NewlyAdded = 3,
|
||||
SmartFilter = 4,
|
||||
/// <summary>
|
||||
/// More In Genre
|
||||
/// </summary>
|
||||
MoreInGenre = 5
|
||||
|
||||
}
|
@ -14,7 +14,7 @@ namespace API.Extensions.QueryExtensions.Filtering;
|
||||
|
||||
public static class SeriesFilter
|
||||
{
|
||||
|
||||
private const float FloatingPointTolerance = 0.01f;
|
||||
public static IQueryable<Series> HasLanguage(this IQueryable<Series> queryable, bool condition,
|
||||
FilterComparison comparison, IList<string> languages)
|
||||
{
|
||||
@ -94,7 +94,7 @@ public static class SeriesFilter
|
||||
switch (comparison)
|
||||
{
|
||||
case FilterComparison.Equal:
|
||||
return queryable.Where(s => s.Ratings.Any(r => r.Rating == rating && r.AppUserId == userId));
|
||||
return queryable.Where(s => s.Ratings.Any(r => Math.Abs(r.Rating - rating) < FloatingPointTolerance && r.AppUserId == userId));
|
||||
case FilterComparison.GreaterThan:
|
||||
return queryable.Where(s => s.Ratings.Any(r => r.Rating > rating && r.AppUserId == userId));
|
||||
case FilterComparison.GreaterThanEqual:
|
||||
@ -252,7 +252,7 @@ public static class SeriesFilter
|
||||
switch (comparison)
|
||||
{
|
||||
case FilterComparison.Equal:
|
||||
subQuery = subQuery.Where(s => s.Percentage == readProgress);
|
||||
subQuery = subQuery.Where(s => Math.Abs(s.Percentage - readProgress) < FloatingPointTolerance);
|
||||
break;
|
||||
case FilterComparison.GreaterThan:
|
||||
subQuery = subQuery.Where(s => s.Percentage > readProgress);
|
||||
@ -267,7 +267,7 @@ public static class SeriesFilter
|
||||
subQuery = subQuery.Where(s => s.Percentage <= readProgress);
|
||||
break;
|
||||
case FilterComparison.NotEqual:
|
||||
subQuery = subQuery.Where(s => s.Percentage != readProgress);
|
||||
subQuery = subQuery.Where(s => Math.Abs(s.Percentage - readProgress) > FloatingPointTolerance);
|
||||
break;
|
||||
case FilterComparison.Matches:
|
||||
case FilterComparison.Contains:
|
||||
|
@ -31,6 +31,7 @@ public static class SeriesSort
|
||||
SortField.LastChapterAdded => query.OrderBy(s => s.LastChapterAdded),
|
||||
SortField.TimeToRead => query.OrderBy(s => s.AvgHoursToRead),
|
||||
SortField.ReleaseYear => query.OrderBy(s => s.Metadata.ReleaseYear),
|
||||
//SortField.ReadProgress => query.OrderBy()
|
||||
_ => query
|
||||
};
|
||||
}
|
||||
|
@ -130,6 +130,17 @@ public static class IncludesExtensions
|
||||
query = query.Include(u => u.ScrobbleHolds);
|
||||
}
|
||||
|
||||
if (includeFlags.HasFlag(AppUserIncludes.SmartFilters))
|
||||
{
|
||||
query = query.Include(u => u.SmartFilters);
|
||||
}
|
||||
|
||||
if (includeFlags.HasFlag(AppUserIncludes.DashboardStreams))
|
||||
{
|
||||
query = query.Include(u => u.DashboardStreams)
|
||||
.ThenInclude(s => s.SmartFilter);
|
||||
}
|
||||
|
||||
return query.AsSplitQuery();
|
||||
}
|
||||
|
||||
|
@ -4,7 +4,10 @@ using API.Data.Migrations;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Account;
|
||||
using API.DTOs.CollectionTags;
|
||||
using API.DTOs.Dashboard;
|
||||
using API.DTOs.Device;
|
||||
using API.DTOs.Filtering;
|
||||
using API.DTOs.Filtering.v2;
|
||||
using API.DTOs.MediaErrors;
|
||||
using API.DTOs.Metadata;
|
||||
using API.DTOs.Reader;
|
||||
@ -226,5 +229,12 @@ public class AutoMapperProfiles : Profile
|
||||
CreateMap<Device, DeviceDto>();
|
||||
CreateMap<AppUserTableOfContent, PersonalToCDto>();
|
||||
|
||||
|
||||
CreateMap<AppUserSmartFilter, SmartFilterDto>();
|
||||
CreateMap<AppUserDashboardStream, DashboardStreamDto>();
|
||||
// CreateMap<AppUserDashboardStream, DashboardStreamDto>()
|
||||
// .ForMember(dest => dest.SmartFilterEncoded,
|
||||
// opt => opt.MapFrom(src => src.SmartFilter));
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -28,8 +28,13 @@ public class AppUserBuilder : IEntityBuilder<AppUser>
|
||||
Ratings = new List<AppUserRating>(),
|
||||
Progresses = new List<AppUserProgress>(),
|
||||
Devices = new List<Device>(),
|
||||
Id = 0
|
||||
Id = 0,
|
||||
DashboardStreams = new List<AppUserDashboardStream>()
|
||||
};
|
||||
foreach (var s in Seed.DefaultStreams)
|
||||
{
|
||||
_appUser.DashboardStreams.Add(s);
|
||||
}
|
||||
}
|
||||
|
||||
public AppUserBuilder WithLibrary(Library library)
|
||||
|
24
API/Helpers/Builders/SmartFilterBuilder.cs
Normal file
24
API/Helpers/Builders/SmartFilterBuilder.cs
Normal file
@ -0,0 +1,24 @@
|
||||
using API.DTOs.Filtering.v2;
|
||||
using API.Entities;
|
||||
|
||||
namespace API.Helpers.Builders;
|
||||
|
||||
public class SmartFilterBuilder : IEntityBuilder<AppUserSmartFilter>
|
||||
{
|
||||
private AppUserSmartFilter _smartFilter;
|
||||
public AppUserSmartFilter Build() => _smartFilter;
|
||||
|
||||
public SmartFilterBuilder(FilterV2Dto filter)
|
||||
{
|
||||
_smartFilter = new AppUserSmartFilter()
|
||||
{
|
||||
Name = filter.Name,
|
||||
Filter = SmartFilterHelper.Encode(filter)
|
||||
};
|
||||
}
|
||||
|
||||
// public SmartFilterBuilder WithName(string name)
|
||||
// {
|
||||
//
|
||||
// }
|
||||
}
|
@ -67,6 +67,7 @@ public static class FilterFieldValueConverter
|
||||
FilterField.Libraries => (value.Split(',')
|
||||
.Select(int.Parse)
|
||||
.ToList(), typeof(IList<int>)),
|
||||
FilterField.WantToRead => (bool.Parse(value), typeof(bool)),
|
||||
FilterField.ReadProgress => (int.Parse(value), typeof(int)),
|
||||
FilterField.Formats => (value.Split(',')
|
||||
.Select(x => (MangaFormat) Enum.Parse(typeof(MangaFormat), x))
|
||||
|
140
API/Helpers/SmartFilterHelper.cs
Normal file
140
API/Helpers/SmartFilterHelper.cs
Normal file
@ -0,0 +1,140 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Web;
|
||||
using API.DTOs.Filtering;
|
||||
using API.DTOs.Filtering.v2;
|
||||
|
||||
namespace API.Helpers;
|
||||
|
||||
public static class SmartFilterHelper
|
||||
{
|
||||
private const string SortOptionsKey = "sortOptions=";
|
||||
private const string StatementsKey = "stmts=";
|
||||
private const string LimitToKey = "limitTo=";
|
||||
private const string CombinationKey = "combination=";
|
||||
|
||||
public static FilterV2Dto Decode(string? encodedFilter)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(encodedFilter))
|
||||
{
|
||||
return new FilterV2Dto(); // Create a default filter if the input is empty
|
||||
}
|
||||
|
||||
string[] parts = encodedFilter.Split('&');
|
||||
var filter = new FilterV2Dto();
|
||||
|
||||
foreach (var part in parts)
|
||||
{
|
||||
if (part.StartsWith(SortOptionsKey))
|
||||
{
|
||||
filter.SortOptions = DecodeSortOptions(part.Substring(SortOptionsKey.Length));
|
||||
}
|
||||
else if (part.StartsWith(LimitToKey))
|
||||
{
|
||||
filter.LimitTo = int.Parse(part.Substring(LimitToKey.Length));
|
||||
}
|
||||
else if (part.StartsWith(CombinationKey))
|
||||
{
|
||||
filter.Combination = Enum.Parse<FilterCombination>(part.Split("=")[1]);
|
||||
}
|
||||
else if (part.StartsWith(StatementsKey))
|
||||
{
|
||||
filter.Statements = DecodeFilterStatementDtos(part.Substring(StatementsKey.Length));
|
||||
}
|
||||
else if (part.StartsWith("name="))
|
||||
{
|
||||
filter.Name = HttpUtility.UrlDecode(part.Substring(5));
|
||||
}
|
||||
}
|
||||
|
||||
return filter;
|
||||
}
|
||||
|
||||
public static string Encode(FilterV2Dto filter)
|
||||
{
|
||||
if (filter == null)
|
||||
return string.Empty;
|
||||
|
||||
var encodedStatements = EncodeFilterStatementDtos(filter.Statements);
|
||||
var encodedSortOptions = filter.SortOptions != null
|
||||
? $"{SortOptionsKey}{EncodeSortOptions(filter.SortOptions)}"
|
||||
: "";
|
||||
var encodedLimitTo = $"{LimitToKey}{filter.LimitTo}";
|
||||
|
||||
return $"{EncodeName(filter.Name)}{encodedStatements}&{encodedSortOptions}&{encodedLimitTo}&{CombinationKey}{(int) filter.Combination}";
|
||||
}
|
||||
|
||||
private static string EncodeName(string name)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(name) ? string.Empty : $"name={HttpUtility.UrlEncode(name)}&";
|
||||
}
|
||||
|
||||
private static string EncodeSortOptions(SortOptions sortOptions)
|
||||
{
|
||||
return $"sortField={(int) sortOptions.SortField}&isAscending={sortOptions.IsAscending}";
|
||||
}
|
||||
|
||||
private static string EncodeFilterStatementDtos(ICollection<FilterStatementDto> statements)
|
||||
{
|
||||
if (statements == null || statements.Count == 0)
|
||||
return string.Empty;
|
||||
|
||||
var encodedStatements = StatementsKey + HttpUtility.UrlEncode(string.Join(",", statements.Select(EncodeFilterStatementDto)));
|
||||
return encodedStatements;
|
||||
}
|
||||
|
||||
private static string EncodeFilterStatementDto(FilterStatementDto statement)
|
||||
{
|
||||
var encodedComparison = $"comparison={(int) statement.Comparison}";
|
||||
var encodedField = $"field={(int) statement.Field}";
|
||||
var encodedValue = $"value={HttpUtility.UrlEncode(statement.Value)}";
|
||||
|
||||
return $"{encodedComparison}&{encodedField}&{encodedValue}";
|
||||
}
|
||||
|
||||
private static List<FilterStatementDto> DecodeFilterStatementDtos(string encodedStatements)
|
||||
{
|
||||
encodedStatements = HttpUtility.UrlDecode(encodedStatements);
|
||||
string[] statementStrings = encodedStatements.Split(',');
|
||||
|
||||
var statements = new List<FilterStatementDto>();
|
||||
|
||||
foreach (var statementString in statementStrings)
|
||||
{
|
||||
var parts = statementString.Split('&');
|
||||
if (parts.Length < 3)
|
||||
continue;
|
||||
|
||||
statements.Add(new FilterStatementDto
|
||||
{
|
||||
Comparison = Enum.Parse<FilterComparison>(parts[0].Split("=")[1]),
|
||||
Field = Enum.Parse<FilterField>(parts[1].Split("=")[1]),
|
||||
Value = HttpUtility.UrlDecode(parts[2].Split("=")[1])
|
||||
});
|
||||
}
|
||||
|
||||
return statements;
|
||||
}
|
||||
|
||||
private static SortOptions DecodeSortOptions(string encodedSortOptions)
|
||||
{
|
||||
string[] parts = encodedSortOptions.Split('&');
|
||||
var sortFieldPart = parts.FirstOrDefault(part => part.StartsWith("sortField="));
|
||||
var isAscendingPart = parts.FirstOrDefault(part => part.StartsWith("isAscending="));
|
||||
|
||||
var isAscending = isAscendingPart?.Substring(11).Equals("true", StringComparison.OrdinalIgnoreCase) ?? true;
|
||||
if (sortFieldPart != null)
|
||||
{
|
||||
var sortField = Enum.Parse<SortField>(sortFieldPart.Split("=")[1]);
|
||||
|
||||
return new SortOptions
|
||||
{
|
||||
SortField = sortField,
|
||||
IsAscending = isAscending
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
@ -150,11 +150,14 @@
|
||||
"browse-libraries": "Browse by Libraries",
|
||||
"collections": "All Collections",
|
||||
"browse-collections": "Browse by Collections",
|
||||
"smart-filters": "Smart Filters",
|
||||
"browse-smart-filters": "Browse by Smart Filters",
|
||||
"reading-list-restricted": "Reading list does not exist or you don't have access",
|
||||
"query-required": "You must pass a query parameter",
|
||||
"search": "Search",
|
||||
"search-description": "Search for Series, Collections, or Reading Lists",
|
||||
"favicon-doesnt-exist": "Favicon does not exist",
|
||||
"smart-filter-doesnt-exist": "Smart Filter doesn't exist",
|
||||
|
||||
"not-authenticated": "User is not authenticated",
|
||||
"unable-to-register-k+": "Unable to register license due to error. Reach out to Kavita+ Support",
|
||||
|
@ -90,6 +90,7 @@ public class Program
|
||||
await Seed.SeedRoles(services.GetRequiredService<RoleManager<AppRole>>());
|
||||
await Seed.SeedSettings(context, directoryService);
|
||||
await Seed.SeedThemes(context);
|
||||
await Seed.SeedDefaultStreams(services.GetRequiredService<IUnitOfWork>());
|
||||
await Seed.SeedUserApiKeys(context);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
@ -122,6 +122,25 @@ public static class MessageFactory
|
||||
/// A Scrobbling Key has expired and needs rotation
|
||||
/// </summary>
|
||||
public const string ScrobblingKeyExpired = "ScrobblingKeyExpired";
|
||||
/// <summary>
|
||||
/// Order, Visibility, etc has changed on the Dashboard. UI will refresh the layout
|
||||
/// </summary>
|
||||
public const string DashboardUpdate = "DashboardUpdate";
|
||||
|
||||
public static SignalRMessage DashboardUpdateEvent(int userId)
|
||||
{
|
||||
return new SignalRMessage()
|
||||
{
|
||||
Name = DashboardUpdate,
|
||||
Title = "Dashboard Update",
|
||||
Progress = ProgressType.None,
|
||||
EventType = ProgressEventType.Single,
|
||||
Body = new
|
||||
{
|
||||
UserId = userId
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
public static SignalRMessage ScanSeriesEvent(int libraryId, int seriesId, string seriesName)
|
||||
|
8
UI/Web/package-lock.json
generated
8
UI/Web/package-lock.json
generated
@ -21,7 +21,7 @@
|
||||
"@fortawesome/fontawesome-free": "^6.4.2",
|
||||
"@iharbeck/ngx-virtual-scroller": "^16.0.0",
|
||||
"@iplab/ngx-file-upload": "^16.0.1",
|
||||
"@microsoft/signalr": "^7.0.10",
|
||||
"@microsoft/signalr": "^7.0.11",
|
||||
"@ng-bootstrap/ng-bootstrap": "^15.1.1",
|
||||
"@ngneat/transloco": "^5.0.7",
|
||||
"@ngneat/transloco-locale": "^5.1.1",
|
||||
@ -3142,9 +3142,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@microsoft/signalr": {
|
||||
"version": "7.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-7.0.10.tgz",
|
||||
"integrity": "sha512-tOEn32i5EatAx4sZbzmLgcBc2VbKQmx+F4rI2/Ioq2MnBaYcFxbDzOoZgISIS4IR9H1ij/sKoU8zQOAFC8GJKg==",
|
||||
"version": "7.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-7.0.11.tgz",
|
||||
"integrity": "sha512-//6ipnYKhHf2MJgM+MQSlgB5L/pcYeZ+v4w6YAr4epRM1iSDQ6WjUkCVX2ZMxcY06XGlLzggs3Z9ZIcL9ws9KQ==",
|
||||
"dependencies": {
|
||||
"abort-controller": "^3.0.0",
|
||||
"eventsource": "^2.0.2",
|
||||
|
@ -26,7 +26,7 @@
|
||||
"@fortawesome/fontawesome-free": "^6.4.2",
|
||||
"@iharbeck/ngx-virtual-scroller": "^16.0.0",
|
||||
"@iplab/ngx-file-upload": "^16.0.1",
|
||||
"@microsoft/signalr": "^7.0.10",
|
||||
"@microsoft/signalr": "^7.0.11",
|
||||
"@ng-bootstrap/ng-bootstrap": "^15.1.1",
|
||||
"@ngneat/transloco": "^5.0.7",
|
||||
"@ngneat/transloco-locale": "^5.1.1",
|
||||
|
14
UI/Web/src/app/_models/dashboard/dashboard-stream.ts
Normal file
14
UI/Web/src/app/_models/dashboard/dashboard-stream.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import {Observable} from "rxjs";
|
||||
import {StreamType} from "./stream-type.enum";
|
||||
|
||||
export interface DashboardStream {
|
||||
id: number;
|
||||
name: string;
|
||||
isProvided: boolean;
|
||||
api: Observable<any[]>;
|
||||
smartFilterId: number;
|
||||
smartFilterEncoded?: string;
|
||||
streamType: StreamType;
|
||||
order: number;
|
||||
visible: boolean;
|
||||
}
|
7
UI/Web/src/app/_models/dashboard/stream-type.enum.ts
Normal file
7
UI/Web/src/app/_models/dashboard/stream-type.enum.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export enum StreamType {
|
||||
OnDeck = 1,
|
||||
RecentlyUpdated = 2,
|
||||
NewlyAdded = 3,
|
||||
SmartFilter = 4,
|
||||
MoreInGenre = 5
|
||||
}
|
3
UI/Web/src/app/_models/events/dashboard-update-event.ts
Normal file
3
UI/Web/src/app/_models/events/dashboard-update-event.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export interface DashboardUpdateEvent {
|
||||
userId: number;
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
export interface LibraryModifiedEvent {
|
||||
libraryId: number;
|
||||
action: 'create' | 'delelte';
|
||||
}
|
||||
action: 'create' | 'delete';
|
||||
}
|
||||
|
@ -26,7 +26,8 @@ export enum FilterField
|
||||
ReleaseYear = 22,
|
||||
ReadTime = 23,
|
||||
Path = 24,
|
||||
FilePath = 25
|
||||
FilePath = 25,
|
||||
WantToRead = 26
|
||||
}
|
||||
|
||||
export const allFields = Object.keys(FilterField)
|
||||
|
5
UI/Web/src/app/_models/metadata/v2/smart-filter.ts
Normal file
5
UI/Web/src/app/_models/metadata/v2/smart-filter.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export interface SmartFilter {
|
||||
id: number;
|
||||
name: string;
|
||||
filter: string;
|
||||
}
|
@ -58,7 +58,7 @@ export class AccountService {
|
||||
filter(userUpdateEvent => userUpdateEvent.userName === this.currentUser?.username),
|
||||
switchMap(() => this.refreshAccount()))
|
||||
.subscribe(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
hasAdminRole(user: User) {
|
||||
return user && user.roles.includes(Role.Admin);
|
||||
|
29
UI/Web/src/app/_services/dashboard.service.ts
Normal file
29
UI/Web/src/app/_services/dashboard.service.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import {TextResonse} from "../_types/text-response";
|
||||
import {HttpClient} from "@angular/common/http";
|
||||
import {environment} from "../../environments/environment";
|
||||
import {DashboardStream} from "../_models/dashboard/dashboard-stream";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class DashboardService {
|
||||
baseUrl = environment.apiUrl;
|
||||
constructor(private httpClient: HttpClient) { }
|
||||
|
||||
getDashboardStreams(visibleOnly = true) {
|
||||
return this.httpClient.get<Array<DashboardStream>>(this.baseUrl + 'account/dashboard?visibleOnly=' + visibleOnly);
|
||||
}
|
||||
|
||||
updateDashboardStreamPosition(streamName: string, dashboardStreamId: number, fromPosition: number, toPosition: number) {
|
||||
return this.httpClient.post(this.baseUrl + 'account/update-dashboard-position', {streamName, dashboardStreamId, fromPosition, toPosition}, TextResonse);
|
||||
}
|
||||
|
||||
updateDashboardStream(stream: DashboardStream) {
|
||||
return this.httpClient.post(this.baseUrl + 'account/update-dashboard-stream', stream, TextResonse);
|
||||
}
|
||||
|
||||
createDashboardStream(smartFilterId: number) {
|
||||
return this.httpClient.post<DashboardStream>(this.baseUrl + 'account/add-dashboard-stream?smartFilterId=' + smartFilterId, {});
|
||||
}
|
||||
}
|
26
UI/Web/src/app/_services/filter.service.ts
Normal file
26
UI/Web/src/app/_services/filter.service.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2";
|
||||
import {environment} from "../../environments/environment";
|
||||
import {HttpClient} from "@angular/common/http";
|
||||
import {JumpKey} from "../_models/jumpbar/jump-key";
|
||||
import {SmartFilter} from "../_models/metadata/v2/smart-filter";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class FilterService {
|
||||
|
||||
baseUrl = environment.apiUrl;
|
||||
constructor(private httpClient: HttpClient) { }
|
||||
|
||||
saveFilter(filter: SeriesFilterV2) {
|
||||
return this.httpClient.post(this.baseUrl + 'filter/update', filter);
|
||||
}
|
||||
getAllFilters() {
|
||||
return this.httpClient.get<Array<SmartFilter>>(this.baseUrl + 'filter');
|
||||
}
|
||||
deleteFilter(filterId: number) {
|
||||
return this.httpClient.delete(this.baseUrl + 'filter?filterId=' + filterId);
|
||||
}
|
||||
|
||||
}
|
@ -7,6 +7,7 @@ import { NotificationProgressEvent } from '../_models/events/notification-progre
|
||||
import { ThemeProgressEvent } from '../_models/events/theme-progress-event';
|
||||
import { UserUpdateEvent } from '../_models/events/user-update-event';
|
||||
import { User } from '../_models/user';
|
||||
import {DashboardUpdateEvent} from "../_models/events/dashboard-update-event";
|
||||
|
||||
export enum EVENTS {
|
||||
UpdateAvailable = 'UpdateAvailable',
|
||||
@ -82,6 +83,10 @@ export enum EVENTS {
|
||||
* A scrobbling token has expired
|
||||
*/
|
||||
ScrobblingKeyExpired = 'ScrobblingKeyExpired',
|
||||
/**
|
||||
* User's dashboard needs to be re-rendered
|
||||
*/
|
||||
DashboardUpdate = 'DashboardUpdate'
|
||||
}
|
||||
|
||||
export interface Message<T> {
|
||||
@ -109,7 +114,6 @@ export class MessageHubService {
|
||||
*/
|
||||
public onlineUsers$ = this.onlineUsersSource.asObservable();
|
||||
|
||||
|
||||
isAdmin: boolean = false;
|
||||
|
||||
constructor() {}
|
||||
@ -181,6 +185,13 @@ export class MessageHubService {
|
||||
});
|
||||
});
|
||||
|
||||
this.hubConnection.on(EVENTS.DashboardUpdate, resp => {
|
||||
console.log('dashboard update event came in')
|
||||
this.messagesSource.next({
|
||||
event: EVENTS.DashboardUpdate,
|
||||
payload: resp.body as DashboardUpdateEvent
|
||||
});
|
||||
});
|
||||
|
||||
this.hubConnection.on(EVENTS.NotificationProgress, (resp: NotificationProgressEvent) => {
|
||||
this.messagesSource.next({
|
||||
|
@ -23,14 +23,16 @@
|
||||
</div>
|
||||
|
||||
<div class="card-footer bg-transparent text-muted">
|
||||
<ng-container *ngIf="isMyReview; else normalReview">
|
||||
<i class="d-md-none fa-solid fa-star me-1" aria-hidden="true" [title]="t('your-review')"></i>
|
||||
</ng-container>
|
||||
<ng-template #normalReview>
|
||||
<img class="me-1" [ngSrc]="review.provider | providerImage" width="20" height="20" alt="">
|
||||
</ng-template>
|
||||
{{(isMyReview ? '' : review.username | defaultValue:'')}}
|
||||
<span style="float: right" *ngIf="review.isExternal">{{t('rating-percentage', {r: review.score})}}</span>
|
||||
<div class="review-user">
|
||||
<ng-container *ngIf="isMyReview; else normalReview">
|
||||
<i class="d-md-none fa-solid fa-star me-1" aria-hidden="true" [title]="t('your-review')"></i>
|
||||
</ng-container>
|
||||
<ng-template #normalReview>
|
||||
<img class="me-1" [ngSrc]="review.provider | providerImage" width="20" height="20" alt="">
|
||||
</ng-template>
|
||||
{{(isMyReview ? '' : review.username | defaultValue:'')}}
|
||||
</div>
|
||||
<span class="review-score" *ngIf="review.isExternal">{{t('rating-percentage', {r: review.score})}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -41,5 +41,9 @@
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
font-size: 13px
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
max-width: 305px;
|
||||
justify-content: space-between;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
@ -1,36 +1,86 @@
|
||||
<app-side-nav-companion-bar></app-side-nav-companion-bar>
|
||||
|
||||
<ng-container *transloco="let t; read: 'dashboard'">
|
||||
<ng-container *ngIf="libraries$ | async as libraries">
|
||||
<ng-container *ngIf="libraries.length === 0 && !isLoading">
|
||||
<div class="mt-3" *ngIf="isAdmin$ | async as isAdmin">
|
||||
<div *ngIf="isAdmin" class="d-flex justify-content-center">
|
||||
<p>{{t('no-libraries')}} <a routerLink="/admin/dashboard" fragment="libraries">{{t('server-settings-link')}}</a>.</p>
|
||||
</div>
|
||||
<div *ngIf="!isAdmin" class="d-flex justify-content-center">
|
||||
<p>{{t('not-granted')}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="libraries$ | async as libraries">
|
||||
<ng-container *ngIf="libraries.length === 0 && !isLoadingAdmin">
|
||||
<div class="mt-3" *ngIf="isAdmin$ | async as isAdmin">
|
||||
<div *ngIf="isAdmin" class="d-flex justify-content-center">
|
||||
<p>{{t('no-libraries')}} <a routerLink="/admin/dashboard" fragment="libraries">{{t('server-settings-link')}}</a>.</p>
|
||||
</div>
|
||||
<div *ngIf="!isAdmin" class="d-flex justify-content-center">
|
||||
<p>{{t('not-granted')}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngFor="let stream of streams">
|
||||
<ng-container [ngSwitch]="stream.streamType">
|
||||
<ng-container *ngSwitchCase="StreamType.OnDeck" [ngTemplateOutlet]="onDeck" [ngTemplateOutletContext]="{ stream: stream }"></ng-container>
|
||||
|
||||
<ng-container *ngSwitchCase="StreamType.RecentlyUpdated" [ngTemplateOutlet]="recentlyUpdated" [ngTemplateOutletContext]="{ stream: stream }"></ng-container>
|
||||
|
||||
<ng-container *ngSwitchCase="StreamType.NewlyAdded" [ngTemplateOutlet]="newlyUpdated" [ngTemplateOutletContext]="{ stream: stream }"></ng-container>
|
||||
|
||||
<ng-container *ngSwitchCase="StreamType.SmartFilter" [ngTemplateOutlet]="smartFilter" [ngTemplateOutletContext]="{ stream: stream }"></ng-container>
|
||||
|
||||
<ng-container *ngSwitchCase="StreamType.MoreInGenre" [ngTemplateOutlet]="moreInGenre" [ngTemplateOutletContext]="{ stream: stream }"></ng-container>
|
||||
</ng-container>
|
||||
|
||||
<app-carousel-reel [items]="inProgress" [title]="t('on-deck-title')" (sectionClick)="handleSectionClick('on deck')">
|
||||
<ng-template #carouselItem let-item>
|
||||
<ng-template #smartFilter let-stream: DashboardStream>
|
||||
<ng-container *ngIf="(stream.api | async) as data">
|
||||
<app-carousel-reel [items]="data" [title]="stream.name" (sectionClick)="handleFilterSectionClick(stream)">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-series-card [data]="item" [libraryId]="item.libraryId" [suppressLibraryLink]="libraryId !== 0" [isOnDeck]="false"
|
||||
(reload)="reloadStream(item.id)" (dataChanged)="reloadStream(item.id)"></app-series-card>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #onDeck let-stream: DashboardStream>
|
||||
<ng-container *ngIf="(stream.api | async) as data">
|
||||
<app-carousel-reel [items]="data" [title]="t('on-deck-title')" (sectionClick)="handleSectionClick('on deck')">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-series-card [data]="item" [libraryId]="item.libraryId" [suppressLibraryLink]="libraryId !== 0" [isOnDeck]="true"
|
||||
(reload)="reloadInProgress($event)" (dataChanged)="reloadInProgress($event)"></app-series-card>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
(reload)="reloadStream(stream.id)" (dataChanged)="reloadStream(stream.id)"></app-series-card>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
|
||||
<app-carousel-reel [items]="recentlyUpdatedSeries" [title]="t('recently-updated-title')" (sectionClick)="handleSectionClick('recently updated series')">
|
||||
<ng-template #carouselItem let-item>
|
||||
<ng-template #recentlyUpdated let-stream: DashboardStream>
|
||||
<ng-container *ngIf="(stream.api | async) as data">
|
||||
<app-carousel-reel [items]="data" [title]="t('recently-updated-title')" (sectionClick)="handleSectionClick('recently updated series')">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-card-item [entity]="item" [title]="item.seriesName" [suppressLibraryLink]="libraryId !== 0" [imageUrl]="imageService.getSeriesCoverImage(item.seriesId)"
|
||||
[suppressArchiveWarning]="true" (clicked)="handleRecentlyAddedChapterClick(item)" [count]="item.count"></app-card-item>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
[suppressArchiveWarning]="true" (clicked)="handleRecentlyAddedChapterClick(item)" [count]="item.count"></app-card-item>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #newlyUpdated let-stream: DashboardStream>
|
||||
<ng-container *ngIf="(stream.api | async) as data">
|
||||
<app-carousel-reel [items]="data" [title]="t('recently-added-title')" (sectionClick)="handleSectionClick('newly added series')">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-series-card [data]="item" [libraryId]="item.libraryId" [suppressLibraryLink]="libraryId !== 0" (dataChanged)="reloadStream(stream.id)"></app-series-card>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #moreInGenre let-stream: DashboardStream>
|
||||
<ng-container *ngIf="(stream.api | async) as data">
|
||||
<app-carousel-reel [items]="data" [title]="t('more-in-genre-title', {genre: genre?.title})" (sectionClick)="handleSectionClick('more in genre')">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-series-card [data]="item" [libraryId]="item.libraryId" [suppressLibraryLink]="libraryId !== 0" (dataChanged)="reloadStream(stream.id)"></app-series-card>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
|
||||
<app-carousel-reel [items]="recentlyAddedSeries" [title]="t('recently-added-title')" (sectionClick)="handleSectionClick('newly added series')">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-series-card [data]="item" [libraryId]="item.libraryId" [suppressLibraryLink]="libraryId !== 0" (dataChanged)="loadRecentlyAddedSeries()"></app-series-card>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</ng-container>
|
||||
|
||||
<app-loading [loading]="isLoadingDashboard"></app-loading>
|
||||
</ng-container>
|
||||
|
@ -1,16 +1,13 @@
|
||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, Input, OnInit} from '@angular/core';
|
||||
import {Title} from '@angular/platform-browser';
|
||||
import {Router, RouterLink} from '@angular/router';
|
||||
import {Observable, of, ReplaySubject} from 'rxjs';
|
||||
import {debounceTime, map, shareReplay, take, tap} from 'rxjs/operators';
|
||||
import {Observable, of, ReplaySubject, Subject, switchMap} from 'rxjs';
|
||||
import {map, shareReplay, take, tap, throttleTime} from 'rxjs/operators';
|
||||
import {FilterUtilitiesService} from 'src/app/shared/_services/filter-utilities.service';
|
||||
import {SeriesAddedEvent} from 'src/app/_models/events/series-added-event';
|
||||
import {SeriesRemovedEvent} from 'src/app/_models/events/series-removed-event';
|
||||
import {Library} from 'src/app/_models/library';
|
||||
import {RecentlyAddedItem} from 'src/app/_models/recently-added-item';
|
||||
import {Series} from 'src/app/_models/series';
|
||||
import {SortField} from 'src/app/_models/metadata/series-filter';
|
||||
import {SeriesGroup} from 'src/app/_models/series-group';
|
||||
import {AccountService} from 'src/app/_services/account.service';
|
||||
import {ImageService} from 'src/app/_services/image.service';
|
||||
import {LibraryService} from 'src/app/_services/library.service';
|
||||
@ -20,13 +17,21 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {CardItemComponent} from '../../cards/card-item/card-item.component';
|
||||
import {SeriesCardComponent} from '../../cards/series-card/series-card.component';
|
||||
import {CarouselReelComponent} from '../../carousel/_components/carousel-reel/carousel-reel.component';
|
||||
import {AsyncPipe, NgIf} from '@angular/common';
|
||||
import {AsyncPipe, NgForOf, NgIf, NgSwitch, NgSwitchCase, NgTemplateOutlet} from '@angular/common';
|
||||
import {
|
||||
SideNavCompanionBarComponent
|
||||
} from '../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
|
||||
import {TranslocoDirective} from "@ngneat/transloco";
|
||||
import {translate, TranslocoDirective} from "@ngneat/transloco";
|
||||
import {FilterField} from "../../_models/metadata/v2/filter-field";
|
||||
import {FilterComparison} from "../../_models/metadata/v2/filter-comparison";
|
||||
import {DashboardService} from "../../_services/dashboard.service";
|
||||
import {MetadataService} from "../../_services/metadata.service";
|
||||
import {RecommendationService} from "../../_services/recommendation.service";
|
||||
import {Genre} from "../../_models/metadata/genre";
|
||||
import {DashboardStream} from "../../_models/dashboard/dashboard-stream";
|
||||
import {StreamType} from "../../_models/dashboard/stream-type.enum";
|
||||
import {SeriesRemovedEvent} from "../../_models/events/series-removed-event";
|
||||
import {LoadingComponent} from "../../shared/loading/loading.component";
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
@ -34,7 +39,8 @@ import {FilterComparison} from "../../_models/metadata/v2/filter-comparison";
|
||||
styleUrls: ['./dashboard.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [SideNavCompanionBarComponent, NgIf, RouterLink, CarouselReelComponent, SeriesCardComponent, CardItemComponent, AsyncPipe, TranslocoDirective]
|
||||
imports: [SideNavCompanionBarComponent, NgIf, RouterLink, CarouselReelComponent, SeriesCardComponent,
|
||||
CardItemComponent, AsyncPipe, TranslocoDirective, NgSwitchCase, NgSwitch, NgForOf, NgTemplateOutlet, LoadingComponent]
|
||||
})
|
||||
export class DashboardComponent implements OnInit {
|
||||
|
||||
@ -44,13 +50,14 @@ export class DashboardComponent implements OnInit {
|
||||
@Input() libraryId: number = 0;
|
||||
|
||||
libraries$: Observable<Library[]> = of([]);
|
||||
isLoading = true;
|
||||
|
||||
isLoadingAdmin = true;
|
||||
isLoadingDashboard = true;
|
||||
isAdmin$: Observable<boolean> = of(false);
|
||||
|
||||
recentlyUpdatedSeries: SeriesGroup[] = [];
|
||||
inProgress: Series[] = [];
|
||||
recentlyAddedSeries: Series[] = [];
|
||||
streams: Array<DashboardStream> = [];
|
||||
genre: Genre | undefined;
|
||||
refreshStreams$ = new Subject<void>();
|
||||
|
||||
|
||||
/**
|
||||
* We use this Replay subject to slow the amount of times we reload the UI
|
||||
@ -58,112 +65,133 @@ export class DashboardComponent implements OnInit {
|
||||
private loadRecentlyAdded$: ReplaySubject<void> = new ReplaySubject<void>();
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly filterUtilityService = inject(FilterUtilitiesService);
|
||||
private readonly metadataService = inject(MetadataService);
|
||||
private readonly recommendationService = inject(RecommendationService);
|
||||
protected readonly StreamType = StreamType;
|
||||
|
||||
|
||||
|
||||
constructor(public accountService: AccountService, private libraryService: LibraryService,
|
||||
private seriesService: SeriesService, private router: Router,
|
||||
private titleService: Title, public imageService: ImageService,
|
||||
private messageHub: MessageHubService, private readonly cdRef: ChangeDetectorRef) {
|
||||
|
||||
this.messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(res => {
|
||||
if (res.event === EVENTS.SeriesAdded) {
|
||||
const seriesAddedEvent = res.payload as SeriesAddedEvent;
|
||||
private messageHub: MessageHubService, private readonly cdRef: ChangeDetectorRef,
|
||||
private dashboardService: DashboardService) {
|
||||
|
||||
|
||||
this.seriesService.getSeries(seriesAddedEvent.seriesId).subscribe(series => {
|
||||
if (this.recentlyAddedSeries.filter(s => s.id === series.id).length > 0) return;
|
||||
this.recentlyAddedSeries = [series, ...this.recentlyAddedSeries];
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
} else if (res.event === EVENTS.SeriesRemoved) {
|
||||
const seriesRemovedEvent = res.payload as SeriesRemovedEvent;
|
||||
this.loadDashboard();
|
||||
|
||||
this.inProgress = this.inProgress.filter(item => item.id != seriesRemovedEvent.seriesId);
|
||||
this.recentlyAddedSeries = this.recentlyAddedSeries.filter(item => item.id != seriesRemovedEvent.seriesId);
|
||||
this.recentlyUpdatedSeries = this.recentlyUpdatedSeries.filter(item => item.seriesId != seriesRemovedEvent.seriesId);
|
||||
this.cdRef.markForCheck();
|
||||
} else if (res.event === EVENTS.ScanSeries) {
|
||||
// We don't have events for when series are updated, but we do get events when a scan update occurs. Refresh recentlyAdded at that time.
|
||||
this.loadRecentlyAdded$.next();
|
||||
}
|
||||
});
|
||||
this.refreshStreams$.pipe(takeUntilDestroyed(this.destroyRef), throttleTime(10_000),
|
||||
tap(() => {
|
||||
this.loadDashboard()
|
||||
}))
|
||||
.subscribe();
|
||||
|
||||
this.isAdmin$ = this.accountService.currentUser$.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
map(user => (user && this.accountService.hasAdminRole(user)) || false),
|
||||
shareReplay()
|
||||
);
|
||||
|
||||
this.loadRecentlyAdded$.pipe(debounceTime(1000), takeUntilDestroyed(this.destroyRef)).subscribe(() => {
|
||||
this.loadRecentlyUpdated();
|
||||
this.loadRecentlyAddedSeries();
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
// TODO: Solve how Websockets will work with these dyanamic streams
|
||||
this.messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(res => {
|
||||
|
||||
if (res.event === EVENTS.DashboardUpdate) {
|
||||
console.log('dashboard update triggered')
|
||||
this.refreshStreams$.next();
|
||||
} else if (res.event === EVENTS.SeriesAdded) {
|
||||
// const seriesAddedEvent = res.payload as SeriesAddedEvent;
|
||||
|
||||
// this.seriesService.getSeries(seriesAddedEvent.seriesId).subscribe(series => {
|
||||
// if (this.recentlyAddedSeries.filter(s => s.id === series.id).length > 0) return;
|
||||
// this.recentlyAddedSeries = [series, ...this.recentlyAddedSeries];
|
||||
// this.cdRef.markForCheck();
|
||||
// });
|
||||
this.refreshStreams$.next();
|
||||
} else if (res.event === EVENTS.SeriesRemoved) {
|
||||
//const seriesRemovedEvent = res.payload as SeriesRemovedEvent;
|
||||
|
||||
//
|
||||
// this.inProgress = this.inProgress.filter(item => item.id != seriesRemovedEvent.seriesId);
|
||||
// this.recentlyAddedSeries = this.recentlyAddedSeries.filter(item => item.id != seriesRemovedEvent.seriesId);
|
||||
// this.recentlyUpdatedSeries = this.recentlyUpdatedSeries.filter(item => item.seriesId != seriesRemovedEvent.seriesId);
|
||||
// this.cdRef.markForCheck();
|
||||
this.refreshStreams$.next();
|
||||
} else if (res.event === EVENTS.ScanSeries) {
|
||||
// We don't have events for when series are updated, but we do get events when a scan update occurs. Refresh recentlyAdded at that time.
|
||||
this.loadRecentlyAdded$.next();
|
||||
this.refreshStreams$.next();
|
||||
}
|
||||
});
|
||||
|
||||
this.isAdmin$ = this.accountService.currentUser$.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
map(user => (user && this.accountService.hasAdminRole(user)) || false),
|
||||
shareReplay({bufferSize: 1, refCount: true})
|
||||
);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.titleService.setTitle('Kavita - Dashboard');
|
||||
this.isLoading = true;
|
||||
this.titleService.setTitle('Kavita');
|
||||
this.isLoadingAdmin = true;
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
this.libraries$ = this.libraryService.getLibraries().pipe(take(1), takeUntilDestroyed(this.destroyRef), tap((libs) => {
|
||||
this.isLoading = false;
|
||||
this.isLoadingAdmin = false;
|
||||
this.cdRef.markForCheck();
|
||||
}));
|
||||
|
||||
this.reloadSeries();
|
||||
}
|
||||
|
||||
reloadSeries() {
|
||||
this.loadOnDeck();
|
||||
this.loadRecentlyUpdated();
|
||||
this.loadRecentlyAddedSeries();
|
||||
}
|
||||
|
||||
reloadInProgress(series: Series | number) {
|
||||
this.loadOnDeck();
|
||||
}
|
||||
|
||||
loadOnDeck() {
|
||||
let api = this.seriesService.getOnDeck(0, 1, 30);
|
||||
if (this.libraryId > 0) {
|
||||
api = this.seriesService.getOnDeck(this.libraryId, 1, 30);
|
||||
}
|
||||
api.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((updatedSeries) => {
|
||||
this.inProgress = updatedSeries.result;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
loadRecentlyAddedSeries() {
|
||||
let api = this.seriesService.getRecentlyAdded(1, 30);
|
||||
if (this.libraryId > 0) {
|
||||
const filter = this.filterUtilityService.createSeriesV2Filter();
|
||||
filter.statements.push({field: FilterField.Libraries, value: this.libraryId + '', comparison: FilterComparison.Equal});
|
||||
api = this.seriesService.getRecentlyAdded(1, 30, filter);
|
||||
}
|
||||
api.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((updatedSeries) => {
|
||||
this.recentlyAddedSeries = updatedSeries.result;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
loadRecentlyUpdated() {
|
||||
let api = this.seriesService.getRecentlyUpdatedSeries();
|
||||
if (this.libraryId > 0) {
|
||||
api = this.seriesService.getRecentlyUpdatedSeries();
|
||||
}
|
||||
api.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(updatedSeries => {
|
||||
this.recentlyUpdatedSeries = updatedSeries.filter(group => {
|
||||
if (this.libraryId === 0) return true;
|
||||
return group.libraryId === this.libraryId;
|
||||
loadDashboard() {
|
||||
this.isLoadingDashboard = true;
|
||||
this.cdRef.markForCheck();
|
||||
this.dashboardService.getDashboardStreams().subscribe(streams => {
|
||||
this.streams = streams;
|
||||
this.streams.forEach(s => {
|
||||
switch (s.streamType) {
|
||||
case StreamType.OnDeck:
|
||||
s.api = this.seriesService.getOnDeck(0, 1, 20)
|
||||
.pipe(map(d => d.result), takeUntilDestroyed(this.destroyRef), shareReplay({bufferSize: 1, refCount: true}));
|
||||
break;
|
||||
case StreamType.NewlyAdded:
|
||||
s.api = this.seriesService.getRecentlyAdded(1, 20)
|
||||
.pipe(map(d => d.result), takeUntilDestroyed(this.destroyRef), shareReplay({bufferSize: 1, refCount: true}));
|
||||
break;
|
||||
case StreamType.RecentlyUpdated:
|
||||
s.api = this.seriesService.getRecentlyUpdatedSeries();
|
||||
break;
|
||||
case StreamType.SmartFilter:
|
||||
s.api = this.seriesService.getAllSeriesV2(0, 20, this.filterUtilityService.decodeSeriesFilter(s.smartFilterEncoded!))
|
||||
.pipe(map(d => d.result), takeUntilDestroyed(this.destroyRef), shareReplay({bufferSize: 1, refCount: true}));
|
||||
break;
|
||||
case StreamType.MoreInGenre:
|
||||
s.api = this.metadataService.getAllGenres().pipe(
|
||||
map(genres => {
|
||||
this.genre = genres[Math.floor(Math.random() * genres.length)];
|
||||
return this.genre;
|
||||
}),
|
||||
switchMap(genre => this.recommendationService.getMoreIn(0, genre.id, 0, 30)),
|
||||
map(p => p.result),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
shareReplay({bufferSize: 1, refCount: true})
|
||||
);
|
||||
break;
|
||||
}
|
||||
});
|
||||
this.isLoadingDashboard = false;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
handleRecentlyAddedChapterClick(item: RecentlyAddedItem) {
|
||||
this.router.navigate(['library', item.libraryId, 'series', item.seriesId]);
|
||||
reloadStream(streamId: number) {
|
||||
const index = this.streams.findIndex(s => s.id === streamId);
|
||||
if (index < 0) return;
|
||||
this.streams[index] = {...this.streams[index]};
|
||||
console.log('swapped out stream: ', this.streams[index]);
|
||||
this.cdRef.detectChanges();
|
||||
}
|
||||
|
||||
async handleRecentlyAddedChapterClick(item: RecentlyAddedItem) {
|
||||
await this.router.navigate(['library', item.libraryId, 'series', item.seriesId]);
|
||||
}
|
||||
|
||||
async handleFilterSectionClick(stream: DashboardStream) {
|
||||
await this.router.navigateByUrl('all-series?' + stream.smartFilterEncoded);
|
||||
}
|
||||
|
||||
handleSectionClick(sectionTitle: string) {
|
||||
@ -180,7 +208,7 @@ export class DashboardComponent implements OnInit {
|
||||
} else if (sectionTitle.toLowerCase() === 'on deck') {
|
||||
const params: any = {};
|
||||
params['page'] = 1;
|
||||
params['title'] = 'On Deck';
|
||||
params['title'] = translate('dashboard.on-deck-title');
|
||||
|
||||
const filter = this.filterUtilityService.createSeriesV2Filter();
|
||||
filter.statements.push({field: FilterField.ReadProgress, comparison: FilterComparison.GreaterThan, value: '0'});
|
||||
@ -190,16 +218,23 @@ export class DashboardComponent implements OnInit {
|
||||
filter.sortOptions.isAscending = false;
|
||||
}
|
||||
this.filterUtilityService.applyFilterWithParams(['all-series'], filter, params)
|
||||
}else if (sectionTitle.toLowerCase() === 'newly added series') {
|
||||
} else if (sectionTitle.toLowerCase() === 'newly added series') {
|
||||
const params: any = {};
|
||||
params['page'] = 1;
|
||||
params['title'] = 'Newly Added';
|
||||
params['title'] = translate('dashboard.recently-added-title');
|
||||
const filter = this.filterUtilityService.createSeriesV2Filter();
|
||||
if (filter.sortOptions) {
|
||||
filter.sortOptions.sortField = SortField.Created;
|
||||
filter.sortOptions.isAscending = false;
|
||||
}
|
||||
this.filterUtilityService.applyFilterWithParams(['all-series'], filter, params)
|
||||
} else if (sectionTitle.toLowerCase() === 'more in genre') {
|
||||
const params: any = {};
|
||||
params['page'] = 1;
|
||||
params['title'] = translate('more-in-genre-title', {genre: this.genre?.title});
|
||||
const filter = this.filterUtilityService.createSeriesV2Filter();
|
||||
filter.statements.push({field: FilterField.Genres, value: this.genre?.id + '', comparison: FilterComparison.MustContains});
|
||||
this.filterUtilityService.applyFilterWithParams(['all-series'], filter, params)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -4,45 +4,26 @@
|
||||
<app-card-actionables [actions]="actions" (actionHandler)="performAction($event)"></app-card-actionables>
|
||||
<span>{{libraryName}}</span>
|
||||
</h2>
|
||||
<div main>
|
||||
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav nav-pills" style="flex-wrap: nowrap;">
|
||||
<li *ngFor="let tab of tabs" [ngbNavItem]="tab">
|
||||
<a ngbNavLink>
|
||||
<span class="d-none d-sm-flex align-items-center"><i class="fa {{tab.icon}} me-1" style="padding-right: 5px;" aria-hidden="true"></i> {{t('library-detail.' + tab.title) | sentenceCase}}</span>
|
||||
<span class="d-flex d-sm-none">
|
||||
<i class="fa {{tab.icon}}" aria-hidden="true"></i>
|
||||
</span>
|
||||
</a>
|
||||
<ng-template ngbNavContent>
|
||||
<ng-container *ngIf="tab.title === 'recommended-tab'">
|
||||
<app-library-recommended [libraryId]="libraryId"></app-library-recommended>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="tab.title === 'library-tab'">
|
||||
<app-card-detail-layout
|
||||
[isLoading]="loadingSeries"
|
||||
[items]="series"
|
||||
[pagination]="pagination"
|
||||
[filterSettings]="filterSettings"
|
||||
[trackByIdentity]="trackByIdentity"
|
||||
[filterOpen]="filterOpen"
|
||||
[jumpBarKeys]="jumpKeys"
|
||||
[refresh]="refresh"
|
||||
(applyFilter)="updateFilter($event)"
|
||||
>
|
||||
<ng-template #cardItem let-item let-position="idx">
|
||||
<app-series-card [data]="item" [libraryId]="libraryId" [suppressLibraryLink]="true" (reload)="loadPage()"
|
||||
(selection)="bulkSelectionService.handleCardSelection('series', position, series.length, $event)"
|
||||
[selected]="bulkSelectionService.isCardSelected('series', position)" [allowSelection]="true"></app-series-card>
|
||||
</ng-template>
|
||||
</app-card-detail-layout>
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<h6 subtitle class="subtitle-with-actionables" *ngIf="active.fragment === ''">{{t('common.series-count', {num: pagination.totalItems | number})}} </h6>
|
||||
</app-side-nav-companion-bar>
|
||||
<h6 subtitle class="subtitle-with-actionables" *ngIf="active.fragment === ''">{{t('common.series-count', {num: pagination.totalItems | number})}} </h6>
|
||||
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
|
||||
<div [ngbNavOutlet]="nav"></div>
|
||||
<app-card-detail-layout
|
||||
[isLoading]="loadingSeries"
|
||||
[items]="series"
|
||||
[pagination]="pagination"
|
||||
[filterSettings]="filterSettings"
|
||||
[trackByIdentity]="trackByIdentity"
|
||||
[filterOpen]="filterOpen"
|
||||
[jumpBarKeys]="jumpKeys"
|
||||
[refresh]="refresh"
|
||||
(applyFilter)="updateFilter($event)"
|
||||
>
|
||||
<ng-template #cardItem let-item let-position="idx">
|
||||
<app-series-card [data]="item" [libraryId]="libraryId" [suppressLibraryLink]="true" (reload)="loadPage()"
|
||||
(selection)="bulkSelectionService.handleCardSelection('series', position, series.length, $event)"
|
||||
[selected]="bulkSelectionService.isCardSelected('series', position)" [allowSelection]="true"></app-series-card>
|
||||
</ng-template>
|
||||
</app-card-detail-layout>
|
||||
|
||||
|
||||
</ng-container>
|
||||
|
@ -33,7 +33,6 @@ import {SentenceCasePipe} from '../pipe/sentence-case.pipe';
|
||||
import {BulkOperationsComponent} from '../cards/bulk-operations/bulk-operations.component';
|
||||
import {SeriesCardComponent} from '../cards/series-card/series-card.component';
|
||||
import {CardDetailLayoutComponent} from '../cards/card-detail-layout/card-detail-layout.component';
|
||||
import {LibraryRecommendedComponent} from './library-recommended/library-recommended.component';
|
||||
import {DecimalPipe, NgFor, NgIf} from '@angular/common';
|
||||
import {NgbNav, NgbNavContent, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavOutlet} from '@ng-bootstrap/ng-bootstrap';
|
||||
import {
|
||||
@ -52,7 +51,8 @@ import {CardActionablesComponent} from "../_single-module/card-actionables/card-
|
||||
styleUrls: ['./library-detail.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [SideNavCompanionBarComponent, CardActionablesComponent, NgbNav, NgFor, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavContent, NgIf, LibraryRecommendedComponent, CardDetailLayoutComponent, SeriesCardComponent, BulkOperationsComponent, NgbNavOutlet, DecimalPipe, SentenceCasePipe, TranslocoDirective]
|
||||
imports: [SideNavCompanionBarComponent, CardActionablesComponent, NgbNav, NgFor, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavContent, NgIf
|
||||
, CardDetailLayoutComponent, SeriesCardComponent, BulkOperationsComponent, NgbNavOutlet, DecimalPipe, SentenceCasePipe, TranslocoDirective]
|
||||
})
|
||||
export class LibraryDetailComponent implements OnInit {
|
||||
|
||||
@ -284,9 +284,5 @@ export class LibraryDetailComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
seriesClicked(series: Series) {
|
||||
this.router.navigate(['library', this.libraryId, 'series', series.id]);
|
||||
}
|
||||
|
||||
trackByIdentity = (index: number, item: Series) => `${item.id}_${item.name}_${item.localizedName}_${item.pagesRead}`;
|
||||
}
|
||||
|
@ -3,7 +3,6 @@ import { CommonModule } from '@angular/common';
|
||||
import { LibraryDetailComponent } from './library-detail.component';
|
||||
import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { LibraryDetailRoutingModule } from './library-detail-routing.module';
|
||||
import { LibraryRecommendedComponent } from './library-recommended/library-recommended.component';
|
||||
|
||||
import {SentenceCasePipe} from "../pipe/sentence-case.pipe";
|
||||
import {CardDetailLayoutComponent} from "../cards/card-detail-layout/card-detail-layout.component";
|
||||
@ -27,7 +26,7 @@ import {CardActionablesComponent} from "../_single-module/card-actionables/card-
|
||||
SeriesCardComponent,
|
||||
BulkOperationsComponent,
|
||||
SideNavCompanionBarComponent,
|
||||
LibraryDetailComponent, LibraryRecommendedComponent
|
||||
LibraryDetailComponent,
|
||||
]
|
||||
})
|
||||
export class LibraryDetailModule { }
|
||||
|
@ -1,61 +0,0 @@
|
||||
<ng-container *transloco="let t; read: 'library-recommended'">
|
||||
|
||||
<ng-container *ngIf="all$ | async as all">
|
||||
<p *ngIf="all.length === 0">
|
||||
{{t('no-data')}}
|
||||
</p>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="onDeck$ | async as onDeck">
|
||||
<app-carousel-reel [items]="onDeck" [title]="t('on-deck')">
|
||||
<ng-template #carouselItem let-item let-position="idx">
|
||||
<app-series-card [data]="item" [libraryId]="item.libraryId" [suppressLibraryLink]="libraryId !== 0" (reload)="reloadInProgress($event)" (dataChanged)="reloadInProgress($event)"></app-series-card>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="quickReads$ | async as quickReads">
|
||||
<app-carousel-reel [items]="quickReads" [title]="t('quick-reads')">
|
||||
<ng-template #carouselItem let-item let-position="idx">
|
||||
<app-series-card [data]="item" [libraryId]="item.libraryId" [suppressLibraryLink]="libraryId !== 0" (reload)="reloadInProgress($event)" (dataChanged)="reloadInProgress($event)"></app-series-card>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="quickCatchups$ | async as quickCatchups">
|
||||
<app-carousel-reel [items]="quickCatchups" [title]="t('quick-catchups')">
|
||||
<ng-template #carouselItem let-item let-position="idx">
|
||||
<app-series-card [data]="item" [libraryId]="item.libraryId" [suppressLibraryLink]="libraryId !== 0" (reload)="reloadInProgress($event)" (dataChanged)="reloadInProgress($event)"></app-series-card>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="highlyRated$ | async as highlyRated">
|
||||
<app-carousel-reel [items]="highlyRated" [title]="t('highly-rated')">
|
||||
<ng-template #carouselItem let-item let-position="idx">
|
||||
<app-series-card [data]="item" [libraryId]="item.libraryId" [suppressLibraryLink]="libraryId !== 0" (reload)="reloadInProgress($event)" (dataChanged)="reloadInProgress($event)"></app-series-card>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="rediscover$ | async as rediscover">
|
||||
<app-carousel-reel [items]="rediscover" [title]="t('rediscover')">
|
||||
<ng-template #carouselItem let-item let-position="idx">
|
||||
<app-series-card [data]="item" [libraryId]="item.libraryId" [suppressLibraryLink]="libraryId !== 0" (reload)="reloadInProgress($event)" (dataChanged)="reloadInProgress($event)"></app-series-card>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="genre$ | async as genre">
|
||||
<ng-container *ngIf="moreIn$ | async as moreIn">
|
||||
<ng-container *ngIf="moreIn.length > 1">
|
||||
<app-carousel-reel [items]="moreIn" [title]="t('more-in-genre', {genre: genre.title})">
|
||||
<ng-template #carouselItem let-item let-position="idx">
|
||||
<app-series-card [data]="item" [libraryId]="item.libraryId" [suppressLibraryLink]="libraryId !== 0" (reload)="reloadInProgress($event)" (dataChanged)="reloadInProgress($event)"></app-series-card>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
</ng-container>
|
@ -1,91 +0,0 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
DestroyRef,
|
||||
inject,
|
||||
Input,
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import { filter, map, merge, Observable, shareReplay } from 'rxjs';
|
||||
import { Genre } from 'src/app/_models/metadata/genre';
|
||||
import { Series } from 'src/app/_models/series';
|
||||
import { MetadataService } from 'src/app/_services/metadata.service';
|
||||
import { RecommendationService } from 'src/app/_services/recommendation.service';
|
||||
import { SeriesService } from 'src/app/_services/series.service';
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import { SeriesCardComponent } from '../../cards/series-card/series-card.component';
|
||||
import { CarouselReelComponent } from '../../carousel/_components/carousel-reel/carousel-reel.component';
|
||||
import { NgIf, AsyncPipe } from '@angular/common';
|
||||
import {TranslocoDirective} from "@ngneat/transloco";
|
||||
|
||||
@Component({
|
||||
selector: 'app-library-recommended',
|
||||
templateUrl: './library-recommended.component.html',
|
||||
styleUrls: ['./library-recommended.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [NgIf, CarouselReelComponent, SeriesCardComponent, AsyncPipe, TranslocoDirective]
|
||||
})
|
||||
export class LibraryRecommendedComponent implements OnInit {
|
||||
|
||||
@Input() libraryId: number = 0;
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
quickReads$!: Observable<Series[]>;
|
||||
quickCatchups$!: Observable<Series[]>;
|
||||
highlyRated$!: Observable<Series[]>;
|
||||
onDeck$!: Observable<Series[]>;
|
||||
rediscover$!: Observable<Series[]>;
|
||||
moreIn$!: Observable<Series[]>;
|
||||
genre$!: Observable<Genre>;
|
||||
|
||||
all$!: Observable<any>;
|
||||
|
||||
constructor(private recommendationService: RecommendationService, private seriesService: SeriesService,
|
||||
private metadataService: MetadataService) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
|
||||
this.quickReads$ = this.recommendationService.getQuickReads(this.libraryId, 0, 30)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef), map(p => p.result), shareReplay());
|
||||
|
||||
this.quickCatchups$ = this.recommendationService.getQuickCatchupReads(this.libraryId, 0, 30)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef), map(p => p.result), shareReplay());
|
||||
|
||||
this.highlyRated$ = this.recommendationService.getHighlyRated(this.libraryId, 0, 30)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef), map(p => p.result), shareReplay());
|
||||
|
||||
this.rediscover$ = this.recommendationService.getRediscover(this.libraryId, 0, 30)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef), map(p => p.result), shareReplay());
|
||||
|
||||
this.onDeck$ = this.seriesService.getOnDeck(this.libraryId, 0, 30)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef), map(p => p.result), shareReplay());
|
||||
|
||||
this.genre$ = this.metadataService.getAllGenres([this.libraryId]).pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
map(genres => genres[Math.floor(Math.random() * genres.length)]),
|
||||
shareReplay()
|
||||
);
|
||||
this.genre$.subscribe(genre => {
|
||||
this.moreIn$ = this.recommendationService.getMoreIn(this.libraryId, genre.id, 0, 30).pipe(takeUntilDestroyed(this.destroyRef), map(p => p.result), shareReplay());
|
||||
});
|
||||
|
||||
this.all$ = merge(this.quickReads$, this.quickCatchups$, this.highlyRated$, this.rediscover$, this.onDeck$, this.genre$).pipe(takeUntilDestroyed(this.destroyRef));
|
||||
}
|
||||
|
||||
|
||||
reloadInProgress(series: Series | number) {
|
||||
if (Number.isInteger(series)) {
|
||||
if (!series) {return;}
|
||||
}
|
||||
// If the update to Series doesn't affect the requirement to be in this stream, then ignore update request
|
||||
const seriesObj = (series as Series);
|
||||
if (seriesObj.pagesRead !== seriesObj.pages && seriesObj.pagesRead !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.quickReads$ = this.quickReads$.pipe(filter(series => !series.includes(seriesObj)));
|
||||
this.quickCatchups$ = this.quickCatchups$.pipe(filter(series => !series.includes(seriesObj)));
|
||||
}
|
||||
|
||||
}
|
@ -22,6 +22,9 @@
|
||||
<ng-container *ngSwitchCase="PredicateType.Number">
|
||||
<input type="number" inputmode="numeric" class="form-control me-2" formControlName="filterValue" min="0">
|
||||
</ng-container>
|
||||
<ng-container *ngSwitchCase="PredicateType.Boolean">
|
||||
<input type="checkbox" class="form-check-input mt-2 me-2" style="font-size: 1.5rem" formControlName="filterValue">
|
||||
</ng-container>
|
||||
<ng-container *ngSwitchCase="PredicateType.Dropdown">
|
||||
<ng-container *ngIf="dropdownOptions$ | async as opts">
|
||||
<ng-container *ngTemplateOutlet="dropdown; context: { options: opts, multipleAllowed: MultipleDropdownAllowed }"></ng-container>
|
||||
|
@ -30,6 +30,7 @@ enum PredicateType {
|
||||
Text = 1,
|
||||
Number = 2,
|
||||
Dropdown = 3,
|
||||
Boolean = 4
|
||||
}
|
||||
|
||||
const StringFields = [FilterField.SeriesName, FilterField.Summary, FilterField.Path, FilterField.FilePath];
|
||||
@ -41,6 +42,7 @@ const DropdownFields = [FilterField.PublicationStatus, FilterField.Languages, Fi
|
||||
FilterField.Writers, FilterField.Genres, FilterField.Libraries,
|
||||
FilterField.Formats, FilterField.CollectionTags, FilterField.Tags
|
||||
];
|
||||
const BooleanFields = [FilterField.WantToRead]
|
||||
|
||||
const DropdownFieldsWithoutMustContains = [
|
||||
FilterField.Libraries, FilterField.Formats, FilterField.AgeRating, FilterField.PublicationStatus
|
||||
@ -69,6 +71,9 @@ const DropdownComparisons = [FilterComparison.Equal,
|
||||
FilterComparison.Contains,
|
||||
FilterComparison.NotContains,
|
||||
FilterComparison.MustContains];
|
||||
const BooleanComparisons = [
|
||||
FilterComparison.Equal
|
||||
]
|
||||
|
||||
@Component({
|
||||
selector: 'app-metadata-row-filter',
|
||||
@ -155,7 +160,11 @@ export class MetadataFilterRowComponent implements OnInit {
|
||||
stmt.value = stmt.value + '';
|
||||
}
|
||||
|
||||
if (!stmt.value && stmt.field !== FilterField.SeriesName) return;
|
||||
if (typeof stmt.value === 'boolean') {
|
||||
stmt.value = stmt.value + '';
|
||||
}
|
||||
|
||||
if (!stmt.value && (stmt.field !== FilterField.SeriesName && !BooleanFields.includes(stmt.field))) return;
|
||||
this.filterStatement.emit(stmt);
|
||||
});
|
||||
|
||||
@ -172,6 +181,8 @@ export class MetadataFilterRowComponent implements OnInit {
|
||||
|
||||
if (StringFields.includes(this.preset.field)) {
|
||||
this.formGroup.get('filterValue')?.patchValue(val);
|
||||
} else if (BooleanFields.includes(this.preset.field)) {
|
||||
this.formGroup.get('filterValue')?.patchValue(val);
|
||||
} else if (DropdownFields.includes(this.preset.field)) {
|
||||
if (this.MultipleDropdownAllowed || val.includes(',')) {
|
||||
this.formGroup.get('filterValue')?.patchValue(val.split(',').map(d => parseInt(d, 10)));
|
||||
@ -270,6 +281,16 @@ export class MetadataFilterRowComponent implements OnInit {
|
||||
return;
|
||||
}
|
||||
|
||||
if (BooleanFields.includes(inputVal)) {
|
||||
this.validComparisons$.next(BooleanComparisons);
|
||||
this.predicateType$.next(PredicateType.Boolean);
|
||||
|
||||
if (this.loaded) {
|
||||
this.formGroup.get('filterValue')?.patchValue(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (DropdownFields.includes(inputVal)) {
|
||||
let comps = [...DropdownComparisons];
|
||||
if (DropdownFieldsThatIncludeNumberComparisons.includes(inputVal)) {
|
||||
|
@ -62,6 +62,8 @@ export class FilterFieldPipe implements PipeTransform {
|
||||
return translate('filter-field-pipe.path');
|
||||
case FilterField.FilePath:
|
||||
return translate('filter-field-pipe.file-path');
|
||||
case FilterField.WantToRead:
|
||||
return translate('filter-field-pipe.want-to-read');
|
||||
default:
|
||||
throw new Error(`Invalid FilterField value: ${value}`);
|
||||
}
|
||||
|
@ -7,4 +7,5 @@ export class FilterSettings {
|
||||
* The number of statements that can be on the filter. Set to 1 to disable adding more.
|
||||
*/
|
||||
statementLimit: number = 0;
|
||||
saveDisabled: boolean = false;
|
||||
}
|
||||
|
@ -48,6 +48,11 @@
|
||||
<option *ngFor="let field of allSortFields" [value]="field">{{field | sortField}}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-10">
|
||||
<label for="filter-name" class="form-label">{{t('filter-name-label')}}</label>
|
||||
<input id="filter-name" type="text" class="form-control" formControlName="name">
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="utilityService.getActiveBreakpoint() > Breakpoint.Tablet" [ngTemplateOutlet]="buttons"></ng-container>
|
||||
</div>
|
||||
<div class="row mb-3" *ngIf="utilityService.getActiveBreakpoint() <= Breakpoint.Tablet">
|
||||
@ -58,12 +63,18 @@
|
||||
</ng-template>
|
||||
<ng-template #buttons>
|
||||
<!-- TODO: I might want to put a Clear button which blanks out the whole filter -->
|
||||
<div class="col-md-2 col-sm-6 mt-4">
|
||||
<div class="col-md-1 col-sm-6 mt-4 pt-1">
|
||||
<button class="btn btn-secondary col-12" (click)="clear()">{{t('reset')}}</button>
|
||||
</div>
|
||||
<div class="col-md-2 col-sm-6 mt-4">
|
||||
<div class="col-md-1 col-sm-6 mt-4 pt-1">
|
||||
<button class="btn btn-primary col-12" (click)="apply()">{{t('apply')}}</button>
|
||||
</div>
|
||||
<div class="col-md-1 col-sm-6 mt-4 pt-1">
|
||||
<button class="btn btn-primary col-12" (click)="save()" [disabled]="filterSettings.saveDisabled || !this.sortGroup.get('name')?.value">
|
||||
<!-- TODO: Icon here -->
|
||||
{{t('save')}}
|
||||
</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
|
||||
|
@ -11,8 +11,7 @@ import {
|
||||
Output
|
||||
} from '@angular/core';
|
||||
import {FormControl, FormGroup, FormsModule, ReactiveFormsModule} from '@angular/forms';
|
||||
import {NgbCollapse, NgbRating, NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
|
||||
import {FilterUtilitiesService} from '../shared/_services/filter-utilities.service';
|
||||
import {NgbCollapse, NgbModal, NgbRating, NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
|
||||
import {Breakpoint, UtilityService} from '../shared/_services/utility.service';
|
||||
import {Library} from '../_models/library';
|
||||
import {allSortFields, FilterEvent, FilterItem, SortField} from '../_models/metadata/series-filter';
|
||||
@ -23,10 +22,14 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {TypeaheadComponent} from '../typeahead/_components/typeahead.component';
|
||||
import {DrawerComponent} from '../shared/drawer/drawer.component';
|
||||
import {AsyncPipe, NgForOf, NgIf, NgTemplateOutlet} from '@angular/common';
|
||||
import {TranslocoModule} from "@ngneat/transloco";
|
||||
import {translate, TranslocoModule} from "@ngneat/transloco";
|
||||
import {SortFieldPipe} from "../pipe/sort-field.pipe";
|
||||
import {MetadataBuilderComponent} from "./_components/metadata-builder/metadata-builder.component";
|
||||
import {allFields} from "../_models/metadata/v2/filter-field";
|
||||
import {MetadataService} from "../_services/metadata.service";
|
||||
import {FilterUtilitiesService} from "../shared/_services/filter-utilities.service";
|
||||
import {FilterService} from "../_services/filter.service";
|
||||
import {ToastrService} from "ngx-toastr";
|
||||
|
||||
@Component({
|
||||
selector: 'app-metadata-filter',
|
||||
@ -81,9 +84,10 @@ export class MetadataFilterComponent implements OnInit {
|
||||
|
||||
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
|
||||
|
||||
constructor(public toggleService: ToggleService) {}
|
||||
constructor(public toggleService: ToggleService, private filterService: FilterService) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.filterSettings === undefined) {
|
||||
@ -141,7 +145,8 @@ export class MetadataFilterComponent implements OnInit {
|
||||
|
||||
this.sortGroup = new FormGroup({
|
||||
sortField: new FormControl({value: this.filterV2?.sortOptions?.sortField || SortField.SortName, disabled: this.filterSettings.sortDisabled}, []),
|
||||
limitTo: new FormControl(this.filterV2?.limitTo || 0, [])
|
||||
limitTo: new FormControl(this.filterV2?.limitTo || 0, []),
|
||||
name: new FormControl(this.filterV2?.name || '', [])
|
||||
});
|
||||
|
||||
this.sortGroup.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
|
||||
@ -153,6 +158,7 @@ export class MetadataFilterComponent implements OnInit {
|
||||
}
|
||||
this.filterV2!.sortOptions!.sortField = parseInt(this.sortGroup.get('sortField')?.value, 10);
|
||||
this.filterV2!.limitTo = Math.max(parseInt(this.sortGroup.get('limitTo')?.value || '0', 10), 0);
|
||||
this.filterV2!.name = this.sortGroup.get('name')?.value || '';
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
|
||||
@ -190,6 +196,15 @@ export class MetadataFilterComponent implements OnInit {
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
save() {
|
||||
if (!this.filterV2) return;
|
||||
this.filterV2.name = this.sortGroup.get('name')?.value;
|
||||
this.filterService.saveFilter(this.filterV2).subscribe(() => {
|
||||
this.toastr.success(translate('toasts.smart-filter-updated'));
|
||||
this.apply();
|
||||
})
|
||||
}
|
||||
|
||||
toggleSelected() {
|
||||
this.toggleService.toggle();
|
||||
this.cdRef.markForCheck();
|
||||
|
@ -41,7 +41,7 @@
|
||||
|
||||
<button class="btn btn-icon float-end" (click)="removeItem(item, i)" *ngIf="showRemoveButton">
|
||||
<i class="fa fa-times" aria-hidden="true"></i>
|
||||
<span class="visually-hidden" attr.aria-labelledby="item.id--{{i}}">{{t('remove-item')}}</span>
|
||||
<span class="visually-hidden" attr.aria-labelledby="item.id--{{i}}">{{t('remove-item-alt')}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,28 +1,27 @@
|
||||
.example-list {
|
||||
min-width: 500px;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-height: 60px;
|
||||
display: block;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
.example-box {
|
||||
margin: 5px 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
max-height: 140px;
|
||||
height: 140px;
|
||||
|
||||
.drag-handle {
|
||||
cursor: move;
|
||||
font-size: 24px;
|
||||
// TODO: This needs to be calculation based
|
||||
margin-top: 215%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.cdk-drag-preview {
|
||||
box-sizing: border-box;
|
||||
border-radius: 4px;
|
||||
@ -30,19 +29,20 @@
|
||||
0 8px 10px 1px rgba(0, 0, 0, 0.14),
|
||||
0 3px 14px 2px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
|
||||
.cdk-drag-placeholder {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
|
||||
.cdk-drag-animating {
|
||||
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
|
||||
.example-box:last-child {
|
||||
border: none;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
|
||||
.example-list.cdk-drop-list-dragging .example-box:not(.cdk-drag-placeholder) {
|
||||
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
@ -70,4 +70,4 @@
|
||||
|
||||
virtual-scroller.empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
@ -178,7 +178,7 @@ export class ReadingListDetailComponent implements OnInit {
|
||||
|
||||
orderUpdated(event: IndexUpdateEvent) {
|
||||
if (!this.readingList) return;
|
||||
this.readingListService.updatePosition(this.readingList.id, event.item.id, event.fromPosition, event.toPosition).subscribe(() => { /* No Operation */ });
|
||||
this.readingListService.updatePosition(this.readingList.id, event.item.id, event.fromPosition, event.toPosition).subscribe();
|
||||
}
|
||||
|
||||
itemRemoved(item: ReadingListItem, position: number) {
|
||||
|
@ -1,5 +1,5 @@
|
||||
<ng-container *transloco="let t; read: 'reading-list-item'">
|
||||
<div class="d-flex flex-row g-0 mb-2">
|
||||
<div class="d-flex flex-row g-0 mb-2 reading-list-item">
|
||||
<div class="pe-2">
|
||||
<app-image width="106px" maxHeight="125px" class="img-top me-3" [imageUrl]="imageService.getChapterCoverImage(item.chapterId)"></app-image>
|
||||
<ng-container *ngIf="item.pagesRead === 0 && item.pagesTotal > 0">
|
||||
|
@ -1,5 +1,10 @@
|
||||
$image-height: 125px;
|
||||
|
||||
.reading-list-item {
|
||||
max-height: 140px;
|
||||
height: 140px;
|
||||
}
|
||||
|
||||
.progress-banner {
|
||||
height: 5px;
|
||||
|
||||
@ -9,12 +14,6 @@ $image-height: 125px;
|
||||
}
|
||||
}
|
||||
|
||||
.list-item-container {
|
||||
background: var(--card-list-item-bg-color);
|
||||
border-radius: 5px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.badge-container {
|
||||
border-radius: 4px;
|
||||
display: block;
|
||||
@ -34,4 +33,4 @@ $image-height: 125px;
|
||||
border-style: solid;
|
||||
border-width: 0 var(--card-progress-triangle-size) var(--card-progress-triangle-size) 0;
|
||||
border-color: transparent var(--primary-color) transparent transparent;
|
||||
}
|
||||
}
|
||||
|
@ -107,6 +107,49 @@ export class FilterUtilitiesService {
|
||||
}).join(','));
|
||||
}
|
||||
|
||||
decodeSeriesFilter(encodedFilter: string) {
|
||||
const filter = this.metadataService.createDefaultFilterDto();
|
||||
|
||||
if (encodedFilter.includes('name=')) {
|
||||
filter.name = decodeURIComponent(encodedFilter).split('name=')[1].split('&')[0];
|
||||
}
|
||||
|
||||
const stmtsStartIndex = encodedFilter.indexOf(statementsKey);
|
||||
let endIndex = encodedFilter.indexOf('&' + sortOptionsKey);
|
||||
if (endIndex < 0) {
|
||||
endIndex = encodedFilter.indexOf('&' + limitToKey);
|
||||
}
|
||||
|
||||
if (stmtsStartIndex !== -1 || endIndex !== -1) {
|
||||
// +1 is for the =
|
||||
const stmtsEncoded = encodedFilter.substring(stmtsStartIndex + statementsKey.length, endIndex);
|
||||
filter.statements = this.decodeFilterStatements(stmtsEncoded);
|
||||
}
|
||||
|
||||
if (encodedFilter.includes(sortOptionsKey)) {
|
||||
const optionsStartIndex = encodedFilter.indexOf('&' + sortOptionsKey);
|
||||
const endIndex = encodedFilter.indexOf('&' + limitToKey);
|
||||
const sortOptionsEncoded = encodedFilter.substring(optionsStartIndex + sortOptionsKey.length + 1, endIndex);
|
||||
const sortOptions = this.decodeSortOptions(sortOptionsEncoded);
|
||||
if (sortOptions) {
|
||||
filter.sortOptions = sortOptions;
|
||||
}
|
||||
}
|
||||
|
||||
if (encodedFilter.includes(limitToKey)) {
|
||||
const limitTo = decodeURIComponent(encodedFilter).split(limitToKey)[1].split('&')[0];
|
||||
filter.limitTo = parseInt(limitTo, 10);
|
||||
}
|
||||
|
||||
if (encodedFilter.includes(combinationKey)) {
|
||||
const combo = decodeURIComponent(encodedFilter).split(combinationKey)[1].split('&')[0];;
|
||||
filter.combination = parseInt(combo, 10) as FilterCombination;
|
||||
}
|
||||
|
||||
return filter;
|
||||
}
|
||||
|
||||
|
||||
filterPresetsFromUrlV2(snapshot: ActivatedRouteSnapshot): SeriesFilterV2 {
|
||||
const filter = this.metadataService.createDefaultFilterDto();
|
||||
if (!window.location.href.includes('?')) return filter;
|
||||
|
@ -2,4 +2,8 @@
|
||||
width: 100%;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
@ -0,0 +1,32 @@
|
||||
<ng-container *transloco="let t; read: 'customize-dashboard-modal'">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title">{{t('title')}}</h4>
|
||||
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<app-draggable-ordered-list [items]="items" (orderUpdated)="orderUpdated($event)" [accessibilityMode]="accessibilityMode"
|
||||
[showRemoveButton]="false">
|
||||
<ng-template #draggableItem let-position="idx" let-item>
|
||||
<app-stream-list-item [item]="item" [position]="position" (hide)="updateVisibility($event, position)"></app-stream-list-item>
|
||||
</ng-template>
|
||||
</app-draggable-ordered-list>
|
||||
|
||||
<h5>Smart Filters</h5>
|
||||
<ul class="list-group filter-list">
|
||||
<li class="filter list-group-item" *ngFor="let filter of smartFilters">
|
||||
{{filter.name}}
|
||||
<button class="btn btn-icon" (click)="addFilterToStream(filter)">
|
||||
<i class="fa fa-plus" aria-hidden="true"></i>
|
||||
Add
|
||||
</button>
|
||||
</li>
|
||||
<li class="list-group-item" *ngIf="smartFilters.length === 0">
|
||||
All Smart filters added to Dashboard or none created yet.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-primary" (click)="close()">{{t('close')}}</button>
|
||||
</div>
|
||||
</ng-container>
|
@ -0,0 +1,24 @@
|
||||
::ng-deep .drag-handle {
|
||||
margin-top: 100% !important;
|
||||
}
|
||||
|
||||
app-stream-list-item {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.filter-list {
|
||||
margin: 0;
|
||||
padding:0;
|
||||
|
||||
.filter {
|
||||
padding: 0.5rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-radius: 5px;
|
||||
margin: 5px 0;
|
||||
color: var(--list-group-hover-text-color);
|
||||
background-color: var(--list-group-hover-bg-color);
|
||||
}
|
||||
}
|
@ -0,0 +1,72 @@
|
||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {SafeHtmlPipe} from "../../../pipe/safe-html.pipe";
|
||||
import {TranslocoDirective} from "@ngneat/transloco";
|
||||
import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap";
|
||||
import {
|
||||
DraggableOrderedListComponent,
|
||||
IndexUpdateEvent
|
||||
} from "../../../reading-list/_components/draggable-ordered-list/draggable-ordered-list.component";
|
||||
import {
|
||||
ReadingListItemComponent
|
||||
} from "../../../reading-list/_components/reading-list-item/reading-list-item.component";
|
||||
import {forkJoin} from "rxjs";
|
||||
import {FilterService} from "../../../_services/filter.service";
|
||||
import {StreamListItemComponent} from "../stream-list-item/stream-list-item.component";
|
||||
import {SmartFilter} from "../../../_models/metadata/v2/smart-filter";
|
||||
import {DashboardService} from "../../../_services/dashboard.service";
|
||||
import {DashboardStream} from "../../../_models/dashboard/dashboard-stream";
|
||||
|
||||
@Component({
|
||||
selector: 'app-customize-dashboard-modal',
|
||||
standalone: true,
|
||||
imports: [CommonModule, SafeHtmlPipe, TranslocoDirective, DraggableOrderedListComponent, ReadingListItemComponent, StreamListItemComponent],
|
||||
templateUrl: './customize-dashboard-modal.component.html',
|
||||
styleUrls: ['./customize-dashboard-modal.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class CustomizeDashboardModalComponent {
|
||||
|
||||
items: DashboardStream[] = [];
|
||||
smartFilters: SmartFilter[] = [];
|
||||
accessibilityMode: boolean = false;
|
||||
|
||||
private readonly dashboardService = inject(DashboardService);
|
||||
private readonly filterService = inject(FilterService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
|
||||
constructor(public modal: NgbActiveModal) {
|
||||
|
||||
forkJoin([this.dashboardService.getDashboardStreams(false), this.filterService.getAllFilters()]).subscribe(results => {
|
||||
this.items = results[0];
|
||||
const smartFilterStreams = new Set(results[0].filter(d => !d.isProvided).map(d => d.name));
|
||||
this.smartFilters = results[1].filter(d => !smartFilterStreams.has(d.name));
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
addFilterToStream(filter: SmartFilter) {
|
||||
this.dashboardService.createDashboardStream(filter.id).subscribe(stream => {
|
||||
this.smartFilters = this.smartFilters.filter(d => d.name !== filter.name);
|
||||
this.items.push(stream);
|
||||
this.cdRef.detectChanges();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
orderUpdated(event: IndexUpdateEvent) {
|
||||
this.dashboardService.updateDashboardStreamPosition(event.item.name, event.item.id, event.fromPosition, event.toPosition).subscribe();
|
||||
}
|
||||
|
||||
updateVisibility(item: DashboardStream, position: number) {
|
||||
this.items[position].visible = !this.items[position].visible;
|
||||
this.dashboardService.updateDashboardStream(this.items[position]).subscribe();
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
close() {
|
||||
this.modal.close();
|
||||
}
|
||||
|
||||
|
||||
}
|
@ -1,18 +1,21 @@
|
||||
<ng-container *transloco="let t; read: 'side-nav'">
|
||||
<div class="side-nav" [ngClass]="{'closed' : (navService.sideNavCollapsed$ | async), 'hidden': (navService.sideNavVisibility$ | async) === false, 'no-donate': (accountService.hasValidLicense$ | async) === true}" *ngIf="accountService.currentUser$ | async as user">
|
||||
<!-- <app-side-nav-item icon="fa-user-circle align-self-center phone-hidden" [title]="user.username | sentenceCase" link="/preferences/">
|
||||
<ng-container actions>
|
||||
Todo: This will be customize dashboard/side nav controls
|
||||
<a href="/preferences/" title="User Settings"><span class="visually-hidden">User Settings</span></a>
|
||||
</ng-container>
|
||||
</app-side-nav-item> -->
|
||||
<!-- <app-side-nav-item icon="fa-user-circle align-self-center phone-hidden" [title]="user.username | sentenceCase" link="/preferences/">-->
|
||||
<!-- <ng-container actions>-->
|
||||
<!-- <a href="/preferences/" title="User Settings"><span class="visually-hidden">User Settings</span></a>-->
|
||||
<!-- </ng-container>-->
|
||||
<!-- </app-side-nav-item>-->
|
||||
|
||||
<app-side-nav-item icon="fa-home" [title]="t('home')" link="/libraries/"></app-side-nav-item>
|
||||
<app-side-nav-item icon="fa-home" [title]="t('home')" link="/libraries/">
|
||||
<ng-container actions>
|
||||
<app-card-actionables [actions]="homeActions" [labelBy]="t('reading-lists')" iconClass="fa-ellipsis-v" (actionHandler)="handleHomeActions()"></app-card-actionables>
|
||||
</ng-container>
|
||||
</app-side-nav-item>
|
||||
<app-side-nav-item icon="fa-star" [title]="t('want-to-read')" link="/want-to-read/"></app-side-nav-item>
|
||||
<app-side-nav-item icon="fa-list" [title]="t('collections')" link="/collections/"></app-side-nav-item>
|
||||
<app-side-nav-item icon="fa-list-ol" [title]="t('reading-lists')" link="/lists/">
|
||||
<ng-container actions>
|
||||
<app-card-actionables [actions]="readingListActions" labelBy="t('reading-lists')" iconClass="fa-ellipsis-v" (actionHandler)="importCbl()"></app-card-actionables>
|
||||
<app-card-actionables [actions]="readingListActions" [labelBy]="t('reading-lists')" iconClass="fa-ellipsis-v" (actionHandler)="importCbl()"></app-card-actionables>
|
||||
</ng-container>
|
||||
</app-side-nav-item>
|
||||
<app-side-nav-item icon="fa-bookmark" [title]="t('bookmarks')" link="/bookmarks/"></app-side-nav-item>
|
||||
|
@ -27,11 +27,13 @@ import {FilterPipe} from "../../../pipe/filter.pipe";
|
||||
import {FormsModule} from "@angular/forms";
|
||||
import {TranslocoDirective} from "@ngneat/transloco";
|
||||
import {CardActionablesComponent} from "../../../_single-module/card-actionables/card-actionables.component";
|
||||
import {SentenceCasePipe} from "../../../pipe/sentence-case.pipe";
|
||||
import {CustomizeDashboardModalComponent} from "../customize-dashboard-modal/customize-dashboard-modal.component";
|
||||
|
||||
@Component({
|
||||
selector: 'app-side-nav',
|
||||
standalone: true,
|
||||
imports: [CommonModule, SideNavItemComponent, CardActionablesComponent, FilterPipe, FormsModule, TranslocoDirective],
|
||||
imports: [CommonModule, SideNavItemComponent, CardActionablesComponent, FilterPipe, FormsModule, TranslocoDirective, SentenceCasePipe],
|
||||
templateUrl: './side-nav.component.html',
|
||||
styleUrls: ['./side-nav.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
@ -43,6 +45,7 @@ export class SideNavComponent implements OnInit {
|
||||
libraries: Library[] = [];
|
||||
actions: ActionItem<Library>[] = [];
|
||||
readingListActions = [{action: Action.Import, title: 'import-cbl', children: [], requiresAdmin: true, callback: this.importCbl.bind(this)}];
|
||||
homeActions = [{action: Action.Edit, title: 'customize', children: [], requiresAdmin: false, callback: this.handleHomeActions.bind(this)}];
|
||||
filterQuery: string = '';
|
||||
filterLibrary = (library: Library) => {
|
||||
return library.name.toLowerCase().indexOf((this.filterQuery || '').toLowerCase()) >= 0;
|
||||
@ -107,6 +110,12 @@ export class SideNavComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
handleHomeActions() {
|
||||
this.ngbModal.open(CustomizeDashboardModalComponent, {size: 'xl'});
|
||||
// TODO: If on /, then refresh the page layout
|
||||
}
|
||||
|
||||
|
||||
importCbl() {
|
||||
this.ngbModal.open(ImportCblModalComponent, {size: 'xl'});
|
||||
}
|
||||
|
@ -0,0 +1,23 @@
|
||||
<ng-container *transloco="let t; read: 'stream-list-item'">
|
||||
<div class="row pt-2 g-0 list-item">
|
||||
<div class="g-0">
|
||||
<h5 class="mb-1 pb-0" id="item.id--{{position}}">
|
||||
{{item.name}}
|
||||
<span class="float-end">
|
||||
<button class="btn btn-icon p-0" (click)="hide.emit(item)">
|
||||
<i class="me-1" [ngClass]="{'fas fa-eye': item.visible, 'fa-solid fa-eye-slash': !item.visible}" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{t('remove')}}</span>
|
||||
</button>
|
||||
</span>
|
||||
</h5>
|
||||
<div class="meta">
|
||||
<div class="ps-1">
|
||||
{{t(item.isProvided ? 'provided' : 'smart-filter')}}
|
||||
</div>
|
||||
<div class="ps-1" *ngIf="!item.isProvided">
|
||||
<a [href]="'/all-series?' + this.item.smartFilterEncoded" target="_blank">{{t('load-filter')}}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
@ -0,0 +1,8 @@
|
||||
.list-item {
|
||||
height: 60px;
|
||||
max-height: 60px;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
}
|
@ -0,0 +1,35 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
EventEmitter,
|
||||
inject,
|
||||
Input,
|
||||
Output
|
||||
} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {ImageComponent} from "../../../shared/image/image.component";
|
||||
import {MangaFormatIconPipe} from "../../../pipe/manga-format-icon.pipe";
|
||||
import {MangaFormatPipe} from "../../../pipe/manga-format.pipe";
|
||||
import {NgbProgressbar} from "@ng-bootstrap/ng-bootstrap";
|
||||
import {TranslocoDirective} from "@ngneat/transloco";
|
||||
import {DashboardStream} from "../../../_models/dashboard/dashboard-stream";
|
||||
|
||||
@Component({
|
||||
selector: 'app-stream-list-item',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ImageComponent, MangaFormatIconPipe, MangaFormatPipe, NgbProgressbar, TranslocoDirective],
|
||||
templateUrl: './stream-list-item.component.html',
|
||||
styleUrls: ['./stream-list-item.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class StreamListItemComponent {
|
||||
@Input({required: true}) item!: DashboardStream;
|
||||
@Input({required: true}) position: number = 0;
|
||||
@Output() hide: EventEmitter<DashboardStream> = new EventEmitter<DashboardStream>();
|
||||
|
||||
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
|
||||
|
||||
}
|
@ -23,7 +23,7 @@ import {TopReadersComponent} from '../top-readers/top-readers.component';
|
||||
import {StatListComponent} from '../stat-list/stat-list.component';
|
||||
import {IconAndTitleComponent} from '../../../shared/icon-and-title/icon-and-title.component';
|
||||
import {AsyncPipe, DecimalPipe, NgIf} from '@angular/common';
|
||||
import {TranslocoDirective, TranslocoService} from "@ngneat/transloco";
|
||||
import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco";
|
||||
import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison";
|
||||
import {FilterField} from "../../../_models/metadata/v2/filter-field";
|
||||
|
||||
@ -62,8 +62,6 @@ export class ServerStatsComponent {
|
||||
this.breakpointSubject.next(this.utilityService.getActiveBreakpoint());
|
||||
}
|
||||
|
||||
|
||||
translocoService = inject(TranslocoService);
|
||||
get Breakpoint() { return Breakpoint; }
|
||||
|
||||
constructor(private statService: StatisticsService, private router: Router, private imageService: ImageService,
|
||||
@ -115,7 +113,7 @@ export class ServerStatsComponent {
|
||||
this.metadataService.getAllGenres().subscribe(genres => {
|
||||
const ref = this.modalService.open(GenericListModalComponent, { scrollable: true });
|
||||
ref.componentInstance.items = genres.map(t => t.title);
|
||||
ref.componentInstance.title = this.translocoService.translate('server-stats.genres');
|
||||
ref.componentInstance.title = translate('server-stats.genres');
|
||||
ref.componentInstance.clicked = (item: string) => {
|
||||
this.filterUtilityService.applyFilter(['all-series'], FilterField.Genres, FilterComparison.Contains, genres.filter(g => g.title === item)[0].id + '');
|
||||
};
|
||||
@ -126,7 +124,7 @@ export class ServerStatsComponent {
|
||||
this.metadataService.getAllTags().subscribe(tags => {
|
||||
const ref = this.modalService.open(GenericListModalComponent, { scrollable: true });
|
||||
ref.componentInstance.items = tags.map(t => t.title);
|
||||
ref.componentInstance.title = this.translocoService.translate('server-stats.tags');
|
||||
ref.componentInstance.title = translate('server-stats.tags');
|
||||
ref.componentInstance.clicked = (item: string) => {
|
||||
this.filterUtilityService.applyFilter(['all-series'], FilterField.Tags, FilterComparison.Contains, tags.filter(g => g.title === item)[0].id + '');
|
||||
};
|
||||
@ -137,7 +135,7 @@ export class ServerStatsComponent {
|
||||
this.metadataService.getAllPeople().subscribe(people => {
|
||||
const ref = this.modalService.open(GenericListModalComponent, { scrollable: true });
|
||||
ref.componentInstance.items = [...new Set(people.map(person => person.name))];
|
||||
ref.componentInstance.title = this.translocoService.translate('server-stats.people');
|
||||
ref.componentInstance.title = translate('server-stats.people');
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import {inject, Pipe, PipeTransform} from '@angular/core';
|
||||
import { DayOfWeek } from 'src/app/_services/statistics.service';
|
||||
import {TranslocoService} from "@ngneat/transloco";
|
||||
import {translate, TranslocoService} from "@ngneat/transloco";
|
||||
|
||||
@Pipe({
|
||||
name: 'dayOfWeek',
|
||||
@ -8,24 +8,22 @@ import {TranslocoService} from "@ngneat/transloco";
|
||||
})
|
||||
export class DayOfWeekPipe implements PipeTransform {
|
||||
|
||||
translocoService = inject(TranslocoService);
|
||||
|
||||
transform(value: DayOfWeek): string {
|
||||
switch(value) {
|
||||
case DayOfWeek.Monday:
|
||||
return this.translocoService.translate('day-of-week-pipe.monday');
|
||||
return translate('day-of-week-pipe.monday');
|
||||
case DayOfWeek.Tuesday:
|
||||
return this.translocoService.translate('day-of-week-pipe.tuesday');
|
||||
return translate('day-of-week-pipe.tuesday');
|
||||
case DayOfWeek.Wednesday:
|
||||
return this.translocoService.translate('day-of-week-pipe.wednesday');
|
||||
return translate('day-of-week-pipe.wednesday');
|
||||
case DayOfWeek.Thursday:
|
||||
return this.translocoService.translate('day-of-week-pipe.thursday');
|
||||
return translate('day-of-week-pipe.thursday');
|
||||
case DayOfWeek.Friday:
|
||||
return this.translocoService.translate('day-of-week-pipe.friday');
|
||||
return translate('day-of-week-pipe.friday');
|
||||
case DayOfWeek.Saturday:
|
||||
return this.translocoService.translate('day-of-week-pipe.saturday');
|
||||
return translate('day-of-week-pipe.saturday');
|
||||
case DayOfWeek.Sunday:
|
||||
return this.translocoService.translate('day-of-week-pipe.sunday');
|
||||
return translate('day-of-week-pipe.sunday');
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,11 @@
|
||||
<ul>
|
||||
<li class="list-group-item" *ngFor="let f of filters">
|
||||
<span (click)="loadFilter(f)">{{f.name}}</span>
|
||||
<button class="btn btn-danger float-end" (click)="deleteFilter(f)">
|
||||
<i class="fa-solid fa-trash" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">Delete</span>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<li class="list-group-item" *ngIf="filters.length === 0">No Smart Filters created</li>
|
||||
</ul>
|
@ -0,0 +1,21 @@
|
||||
|
||||
ul {
|
||||
margin:0;
|
||||
padding: 0;
|
||||
|
||||
li {
|
||||
padding: 0.5rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-radius: 5px;
|
||||
margin: 5px 0;
|
||||
color: var(--list-group-hover-text-color);
|
||||
background-color: var(--list-group-hover-bg-color);
|
||||
|
||||
span {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,51 @@
|
||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {FilterService} from "../../_services/filter.service";
|
||||
import {SmartFilter} from "../../_models/metadata/v2/smart-filter";
|
||||
import {Router} from "@angular/router";
|
||||
import {ConfirmService} from "../../shared/confirm.service";
|
||||
import {translate} from "@ngneat/transloco";
|
||||
import {ToastrService} from "ngx-toastr";
|
||||
|
||||
@Component({
|
||||
selector: 'app-manage-smart-filters',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './manage-smart-filters.component.html',
|
||||
styleUrls: ['./manage-smart-filters.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ManageSmartFiltersComponent {
|
||||
|
||||
private readonly filterService = inject(FilterService);
|
||||
private readonly confirmService = inject(ConfirmService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
filters: Array<SmartFilter> = [];
|
||||
|
||||
constructor() {
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
loadData() {
|
||||
this.filterService.getAllFilters().subscribe(filters => {
|
||||
this.filters = filters;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
async loadFilter(f: SmartFilter) {
|
||||
await this.router.navigateByUrl('all-series?' + f.filter);
|
||||
}
|
||||
|
||||
async deleteFilter(f: SmartFilter) {
|
||||
if (!await this.confirmService.confirm(translate('toasts.confirm-delete-smart-filter'))) return;
|
||||
|
||||
this.filterService.deleteFilter(f.id).subscribe(() => {
|
||||
this.toastr.success(translate('toasts.smart-filter-deleted'));
|
||||
this.loadData();
|
||||
});
|
||||
}
|
||||
|
||||
}
|
@ -428,6 +428,9 @@
|
||||
<ng-container *ngIf="tab.fragment === FragmentID.Stats">
|
||||
<app-user-stats></app-user-stats>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="tab.fragment === FragmentID.SmartFilters">
|
||||
<app-manage-smart-filters></app-manage-smart-filters>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="tab.fragment === FragmentID.Scrobbling">
|
||||
<app-user-scrobble-history></app-user-scrobble-history>
|
||||
<app-user-holds></app-user-holds>
|
||||
|
@ -49,6 +49,7 @@ import { SideNavCompanionBarComponent } from '../../sidenav/_components/side-nav
|
||||
import {LocalizationService} from "../../_services/localization.service";
|
||||
import {Language} from "../../_models/metadata/language";
|
||||
import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco";
|
||||
import {ManageSmartFiltersComponent} from "../manage-smart-filters/manage-smart-filters.component";
|
||||
|
||||
enum AccordionPanelID {
|
||||
ImageReader = 'image-reader',
|
||||
@ -63,6 +64,7 @@ enum FragmentID {
|
||||
Theme = 'theme',
|
||||
Devices = 'devices',
|
||||
Stats = 'stats',
|
||||
SmartFilters = 'smart-filters',
|
||||
Scrobbling = 'scrobbling'
|
||||
|
||||
}
|
||||
@ -76,7 +78,8 @@ enum FragmentID {
|
||||
imports: [SideNavCompanionBarComponent, NgbNav, NgFor, NgbNavItem, NgbNavItemRole, NgbNavLink, RouterLink, NgbNavContent, NgIf, ChangeEmailComponent,
|
||||
ChangePasswordComponent, ChangeAgeRestrictionComponent, AnilistKeyComponent, ReactiveFormsModule, NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader,
|
||||
NgbAccordionToggle, NgbAccordionButton, NgbCollapse, NgbAccordionCollapse, NgbAccordionBody, NgbTooltip, NgTemplateOutlet, ColorPickerModule, ApiKeyComponent,
|
||||
ThemeManagerComponent, ManageDevicesComponent, UserStatsComponent, UserScrobbleHistoryComponent, UserHoldsComponent, NgbNavOutlet, TitleCasePipe, SentenceCasePipe, TranslocoDirective]
|
||||
ThemeManagerComponent, ManageDevicesComponent, UserStatsComponent, UserScrobbleHistoryComponent, UserHoldsComponent, NgbNavOutlet, TitleCasePipe, SentenceCasePipe,
|
||||
TranslocoDirective, ManageSmartFiltersComponent]
|
||||
})
|
||||
export class UserPreferencesComponent implements OnInit, OnDestroy {
|
||||
|
||||
@ -107,6 +110,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
|
||||
{title: '3rd-party-clients-tab', fragment: FragmentID.Clients},
|
||||
{title: 'theme-tab', fragment: FragmentID.Theme},
|
||||
{title: 'devices-tab', fragment: FragmentID.Devices},
|
||||
{title: 'smart-filters-tab', fragment: FragmentID.SmartFilters},
|
||||
{title: 'stats-tab', fragment: FragmentID.Stats},
|
||||
];
|
||||
locales: Array<Language> = [{title: 'English', isoCode: 'en'}];
|
||||
@ -115,7 +119,6 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
|
||||
opdsUrl: string = '';
|
||||
makeUrl: (val: string) => string = (val: string) => { return this.opdsUrl; };
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly trasnlocoService = inject(TranslocoService);
|
||||
|
||||
get AccordionPanelID() {
|
||||
return AccordionPanelID;
|
||||
@ -304,7 +307,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
|
||||
};
|
||||
|
||||
this.observableHandles.push(this.accountService.updatePreferences(data).subscribe((updatedPrefs) => {
|
||||
this.toastr.success(this.trasnlocoService.translate('user-preferences.success-toast'));
|
||||
this.toastr.success(translate('user-preferences.success-toast'));
|
||||
if (this.user) {
|
||||
this.user.preferences = updatedPrefs;
|
||||
this.cdRef.markForCheck();
|
||||
|
@ -14,7 +14,8 @@
|
||||
"not-granted": "You haven't been granted access to any libraries.",
|
||||
"on-deck-title": "On Deck",
|
||||
"recently-updated-title": "Recently Updated Series",
|
||||
"recently-added-title": "Newly Added Series"
|
||||
"recently-added-title": "Newly Added Series",
|
||||
"more-in-genre-title": "More In {{genre}}"
|
||||
},
|
||||
|
||||
"edit-user": {
|
||||
@ -98,6 +99,7 @@
|
||||
"devices-tab": "Devices",
|
||||
"stats-tab": "Stats",
|
||||
"scrobbling-tab": "Scrobbling",
|
||||
"smart-filters-tab": "Smart Filters",
|
||||
"success-toast": "User preferences updated",
|
||||
|
||||
"global-settings-title": "Global Settings",
|
||||
@ -1324,6 +1326,13 @@
|
||||
"read": "{{common.read}}"
|
||||
},
|
||||
|
||||
"stream-list-item": {
|
||||
"remove": "{{common.remove}}",
|
||||
"load-filter": "Load Filter",
|
||||
"provided": "Provided",
|
||||
"smart-filter": "Smart Filter"
|
||||
},
|
||||
|
||||
"reading-list-detail": {
|
||||
"item-count": "{{common.item-count}}",
|
||||
"page-settings-title": "Page Settings",
|
||||
@ -1494,10 +1503,12 @@
|
||||
"metadata-filter": {
|
||||
"filter-title": "Filter",
|
||||
"sort-by-label": "Sort By",
|
||||
"filter-name-label": "Filter Name",
|
||||
"ascending-alt": "Ascending",
|
||||
"descending-alt": "Descending",
|
||||
"reset": "{{common.reset}}",
|
||||
"apply": "{{common.apply}}",
|
||||
"save": "{{common.save}}",
|
||||
"limit-label": "Limit To",
|
||||
|
||||
"format-label": "Format",
|
||||
@ -1707,6 +1718,12 @@
|
||||
"remove-rule": "Remove Row"
|
||||
},
|
||||
|
||||
"customize-dashboard-modal": {
|
||||
"title": "Customize Dashboard",
|
||||
"close": "{{common.close}}",
|
||||
"save": "{{common.save}}"
|
||||
},
|
||||
|
||||
"filter-field-pipe": {
|
||||
"age-rating": "Age Rating",
|
||||
"characters": "Characters",
|
||||
@ -1733,7 +1750,8 @@
|
||||
"user-rating": "User Rating",
|
||||
"writers": "Writers",
|
||||
"path": "Path",
|
||||
"file-path": "File Path"
|
||||
"file-path": "File Path",
|
||||
"want-to-read": "Want to Read"
|
||||
},
|
||||
|
||||
"filter-comparison-pipe": {
|
||||
@ -1755,6 +1773,8 @@
|
||||
"must-contains": "Must Contains"
|
||||
},
|
||||
|
||||
|
||||
|
||||
"toasts": {
|
||||
"regen-cover": "A job has been enqueued to regenerate the cover image",
|
||||
"no-pages": "There are no pages. Kavita was not able to read this archive.",
|
||||
@ -1831,7 +1851,10 @@
|
||||
"confirm-library-delete": "Are you sure you want to delete the {{name}} library? You cannot undo this action.",
|
||||
"confirm-library-type-change": "Changing library type will trigger a new scan with different parsing rules and may lead to series being re-created and hence you may loose progress and bookmarks. You should backup before you do this. Are you sure you want to continue?",
|
||||
"confirm-download-size": "The {{entityType}} is {{size}}. Are you sure you want to continue?",
|
||||
"list-doesnt-exist": "This list doesn't exist"
|
||||
"list-doesnt-exist": "This list doesn't exist",
|
||||
"confirm-delete-smart-filter": "Are you sure you want to delete this Smart Filter?",
|
||||
"smart-filter-deleted": "Smart Filter Deleted",
|
||||
"smart-filter-updated": "Created/Updated smart filter"
|
||||
},
|
||||
|
||||
"actionable": {
|
||||
@ -1861,8 +1884,8 @@
|
||||
"read": "Read",
|
||||
"add-rule-group-and": "Add Rule Group (AND)",
|
||||
"add-rule-group-or": "Add Rule Group (OR)",
|
||||
"remove-rule-group": "Remove Rule Group"
|
||||
|
||||
"remove-rule-group": "Remove Rule Group",
|
||||
"customize": "Customize"
|
||||
},
|
||||
|
||||
"preferences": {
|
||||
|
493
openapi.json
493
openapi.json
@ -7,7 +7,7 @@
|
||||
"name": "GPL-3.0",
|
||||
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
|
||||
},
|
||||
"version": "0.7.8.0"
|
||||
"version": "0.7.8.2"
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
@ -875,6 +875,162 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/Account/dashboard": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Account"
|
||||
],
|
||||
"summary": "Returns the layout of the user's dashboard",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "visibleOnly",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/DashboardStreamDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/DashboardStreamDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/DashboardStreamDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/Account/add-dashboard-stream": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Account"
|
||||
],
|
||||
"summary": "Creates a Dashboard Stream from a SmartFilter and adds it to the user's dashboard as visible",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "smartFilterId",
|
||||
"in": "query",
|
||||
"description": "",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DashboardStreamDto"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DashboardStreamDto"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DashboardStreamDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/Account/update-dashboard-stream": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Account"
|
||||
],
|
||||
"summary": "Updates the visibility of a dashboard stream",
|
||||
"requestBody": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DashboardStreamDto"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DashboardStreamDto"
|
||||
}
|
||||
},
|
||||
"application/*+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DashboardStreamDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/Account/update-dashboard-position": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Account"
|
||||
],
|
||||
"summary": "Updates the position of a dashboard stream",
|
||||
"requestBody": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpdateDashboardStreamPositionDto"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpdateDashboardStreamPositionDto"
|
||||
}
|
||||
},
|
||||
"application/*+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpdateDashboardStreamPositionDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/Admin/exists": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@ -1931,51 +2087,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/Filter": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Filter"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "name",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/FilterV2Dto"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/FilterV2Dto"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/FilterV2Dto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/Filter/create-temp": {
|
||||
"/api/Filter/update": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Filter"
|
||||
],
|
||||
"summary": "Caches the filter in the backend and returns a temp string for retrieving.",
|
||||
"description": "The cache line lives for only 1 hour",
|
||||
"summary": "Creates or Updates the filter",
|
||||
"requestBody": {
|
||||
"description": "",
|
||||
"content": {
|
||||
@ -1996,28 +2113,69 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/Filter": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Filter"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/SmartFilterDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/SmartFilterDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/SmartFilterDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Filter"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "filterId",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/Health": {
|
||||
@ -3485,6 +3643,60 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/Opds/{apiKey}/smart-filter/{filterId}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Opds"
|
||||
],
|
||||
"summary": "Returns the Series matching this smart filter. If FromDashboard, will only return 20 records.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "apiKey",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "filterId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/Opds/{apiKey}/smart-filters": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Opds"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "apiKey",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/Opds/{apiKey}/libraries": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@ -11552,6 +11764,22 @@
|
||||
"description": "A list of Series the user doesn't want scrobbling for",
|
||||
"nullable": true
|
||||
},
|
||||
"smartFilters": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/AppUserSmartFilter"
|
||||
},
|
||||
"description": "A collection of user Smart Filters for their account",
|
||||
"nullable": true
|
||||
},
|
||||
"dashboardStreams": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/AppUserDashboardStream"
|
||||
},
|
||||
"description": "An ordered list of Streams (pre-configured) or Smart Filters that makes up the User's Dashboard",
|
||||
"nullable": true
|
||||
},
|
||||
"rowVersion": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
@ -11612,6 +11840,54 @@
|
||||
"additionalProperties": false,
|
||||
"description": "Represents a saved page in a Chapter entity for a given user."
|
||||
},
|
||||
"AppUserDashboardStream": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"isProvided": {
|
||||
"type": "boolean",
|
||||
"description": "Is System Provided"
|
||||
},
|
||||
"order": {
|
||||
"type": "integer",
|
||||
"description": "Sort Order on the Dashboard",
|
||||
"format": "int32"
|
||||
},
|
||||
"streamType": {
|
||||
"enum": [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5
|
||||
],
|
||||
"type": "integer",
|
||||
"description": "For system provided",
|
||||
"format": "int32"
|
||||
},
|
||||
"visible": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"smartFilter": {
|
||||
"$ref": "#/components/schemas/AppUserSmartFilter"
|
||||
},
|
||||
"appUserId": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"appUser": {
|
||||
"$ref": "#/components/schemas/AppUser"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"AppUserPreferences": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@ -11930,6 +12206,33 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"AppUserSmartFilter": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"filter": {
|
||||
"type": "string",
|
||||
"description": "This is the Filter url encoded. It is decoded and reconstructed into a API.DTOs.Filtering.v2.FilterV2Dto",
|
||||
"nullable": true
|
||||
},
|
||||
"appUserId": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"appUser": {
|
||||
"$ref": "#/components/schemas/AppUser"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"description": "Represents a Saved user Filter"
|
||||
},
|
||||
"AppUserTableOfContent": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@ -13184,6 +13487,54 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"DashboardStreamDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"isProvided": {
|
||||
"type": "boolean",
|
||||
"description": "Is System Provided"
|
||||
},
|
||||
"order": {
|
||||
"type": "integer",
|
||||
"description": "Sort Order on the Dashboard",
|
||||
"format": "int32"
|
||||
},
|
||||
"smartFilterEncoded": {
|
||||
"type": "string",
|
||||
"description": "If Not IsProvided, the appropriate smart filter",
|
||||
"nullable": true
|
||||
},
|
||||
"smartFilterId": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"nullable": true
|
||||
},
|
||||
"streamType": {
|
||||
"enum": [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5
|
||||
],
|
||||
"type": "integer",
|
||||
"description": "For system provided",
|
||||
"format": "int32"
|
||||
},
|
||||
"visible": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"DateTimePagesReadOnADayCount": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@ -13790,7 +14141,8 @@
|
||||
22,
|
||||
23,
|
||||
24,
|
||||
25
|
||||
25,
|
||||
26
|
||||
],
|
||||
"type": "integer",
|
||||
"description": "Represents the field which will dictate the value type and the Extension used for filtering",
|
||||
@ -13806,6 +14158,11 @@
|
||||
"FilterV2Dto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"description": "Not used in the UI.",
|
||||
"format": "int32"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "The name of the filter",
|
||||
@ -17056,6 +17413,25 @@
|
||||
"additionalProperties": false,
|
||||
"description": "Represents a set of css overrides the user can upload to Kavita and will load into webui"
|
||||
},
|
||||
"SmartFilterDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"filter": {
|
||||
"type": "string",
|
||||
"description": "This is the Filter url encoded. It is decoded and reconstructed into a API.DTOs.Filtering.v2.FilterV2Dto",
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SortOptions": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@ -17066,7 +17442,8 @@
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
6
|
||||
6,
|
||||
7
|
||||
],
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
@ -17212,6 +17589,28 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"UpdateDashboardStreamPositionDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"fromPosition": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"toPosition": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"dashboardStreamId": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"streamName": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"UpdateDefaultThemeDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
Loading…
x
Reference in New Issue
Block a user