mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-06-03 13:44:31 -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.Data.Repositories;
|
||||||
using API.DTOs;
|
using API.DTOs;
|
||||||
using API.DTOs.Account;
|
using API.DTOs.Account;
|
||||||
|
using API.DTOs.Dashboard;
|
||||||
using API.DTOs.Email;
|
using API.DTOs.Email;
|
||||||
using API.Entities;
|
using API.Entities;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
@ -1035,4 +1036,123 @@ public class AccountController : BaseApiController
|
|||||||
return Ok(origin + "/" + baseUrl + "api/opds/" + user!.ApiKey);
|
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;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using API.Constants;
|
using API.Constants;
|
||||||
using API.Data;
|
using API.Data;
|
||||||
|
using API.Data.Repositories;
|
||||||
|
using API.DTOs.Dashboard;
|
||||||
using API.DTOs.Filtering.v2;
|
using API.DTOs.Filtering.v2;
|
||||||
|
using API.Entities;
|
||||||
|
using API.Extensions;
|
||||||
|
using API.Helpers;
|
||||||
using EasyCaching.Core;
|
using EasyCaching.Core;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
@ -22,38 +29,66 @@ public class FilterController : BaseApiController
|
|||||||
_cacheFactory = cacheFactory;
|
_cacheFactory = cacheFactory;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
/// <summary>
|
||||||
public async Task<ActionResult<FilterV2Dto?>> GetFilter(string name)
|
/// 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);
|
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.SmartFilters);
|
||||||
if (string.IsNullOrEmpty(name)) return Ok(null);
|
if (user == null) return Unauthorized();
|
||||||
var filter = await provider.GetAsync<FilterV2Dto>(name);
|
|
||||||
if (filter.HasValue)
|
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 BadRequest("You cannot use the name of a system provided stream");
|
||||||
return Ok(filter.Value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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>
|
[HttpGet]
|
||||||
/// Caches the filter in the backend and returns a temp string for retrieving.
|
public ActionResult<IEnumerable<SmartFilterDto>> GetFilters()
|
||||||
/// </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)
|
|
||||||
{
|
{
|
||||||
var provider = _cacheFactory.GetCachingProvider(EasyCacheProfiles.Filter);
|
return Ok(_unitOfWork.AppUserSmartFilterRepository.GetAllDtosByUserId(User.GetUserId()));
|
||||||
var name = filterDto.Name;
|
}
|
||||||
if (string.IsNullOrEmpty(filterDto.Name))
|
|
||||||
{
|
|
||||||
name = Guid.NewGuid().ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
await provider.SetAsync(name, filterDto, TimeSpan.FromHours(1));
|
[HttpDelete]
|
||||||
return name;
|
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.Globalization;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using API.DTOs.Filtering;
|
using API.DTOs.Filtering;
|
||||||
@ -19,11 +20,26 @@ public class LocaleController : BaseApiController
|
|||||||
[HttpGet]
|
[HttpGet]
|
||||||
public ActionResult<IEnumerable<string>> GetAllLocales()
|
public ActionResult<IEnumerable<string>> GetAllLocales()
|
||||||
{
|
{
|
||||||
var languages = _localizationService.GetLocales().Select(c => new CultureInfo(c)).Select(c =>
|
var languages = _localizationService.GetLocales().Select(c =>
|
||||||
new LanguageDto()
|
|
||||||
{
|
{
|
||||||
Title = c.DisplayName,
|
try
|
||||||
IsoCode = c.IetfLanguageTag
|
{
|
||||||
|
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))
|
.Where(l => !string.IsNullOrEmpty(l.IsoCode))
|
||||||
.OrderBy(d => d.Title);
|
.OrderBy(d => d.Title);
|
||||||
|
@ -102,32 +102,68 @@ public class OpdsController : BaseApiController
|
|||||||
|
|
||||||
var feed = CreateFeed("Kavita", string.Empty, apiKey, prefix);
|
var feed = CreateFeed("Kavita", string.Empty, apiKey, prefix);
|
||||||
SetFeedId(feed, "root");
|
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",
|
switch (stream.StreamType)
|
||||||
Title = await _localizationService.Translate(userId, "on-deck"),
|
|
||||||
Content = new FeedEntryContent()
|
|
||||||
{
|
{
|
||||||
Text = await _localizationService.Translate(userId, "browse-on-deck")
|
case DashboardStreamType.OnDeck:
|
||||||
},
|
feed.Entries.Add(new FeedEntry()
|
||||||
Links = new List<FeedLink>()
|
{
|
||||||
{
|
Id = "onDeck",
|
||||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/on-deck"),
|
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()
|
feed.Entries.Add(new FeedEntry()
|
||||||
{
|
{
|
||||||
Id = "readingList",
|
Id = "readingList",
|
||||||
@ -180,6 +216,19 @@ public class OpdsController : BaseApiController
|
|||||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/collections"),
|
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));
|
return CreateXmlResult(SerializeXml(feed));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -196,6 +245,67 @@ public class OpdsController : BaseApiController
|
|||||||
return new Tuple<string, string>(baseUrl, prefix);
|
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")]
|
[HttpGet("{apiKey}/libraries")]
|
||||||
[Produces("application/xml")]
|
[Produces("application/xml")]
|
||||||
|
@ -6,6 +6,7 @@ using API.Constants;
|
|||||||
using API.Data;
|
using API.Data;
|
||||||
using API.Data.Repositories;
|
using API.Data.Repositories;
|
||||||
using API.DTOs;
|
using API.DTOs;
|
||||||
|
using API.DTOs.Dashboard;
|
||||||
using API.DTOs.Filtering;
|
using API.DTOs.Filtering;
|
||||||
using API.DTOs.Filtering.v2;
|
using API.DTOs.Filtering.v2;
|
||||||
using API.DTOs.Metadata;
|
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 System;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
|
|
||||||
namespace API.DTOs;
|
namespace API.DTOs.Dashboard;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// This is a representation of a Series with some amount of underlying files within it. This is used for Recently Updated Series section
|
/// This is a representation of a Series with some amount of underlying files within it. This is used for Recently Updated Series section
|
||||||
/// </summary>
|
/// </summary>
|
@ -1,7 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
|
|
||||||
namespace API.DTOs;
|
namespace API.DTOs.Dashboard;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A mesh of data for Recently added volume/chapters
|
/// 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>
|
/// <summary>
|
||||||
/// Release Year of the Series
|
/// Release Year of the Series
|
||||||
/// </summary>
|
/// </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>
|
/// <summary>
|
||||||
/// File path
|
/// File path
|
||||||
/// </summary>
|
/// </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>
|
/// </summary>
|
||||||
public class FilterV2Dto
|
public class FilterV2Dto
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Not used in the UI.
|
||||||
|
/// </summary>
|
||||||
|
public int Id { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The name of the filter
|
/// The name of the filter
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -54,6 +54,8 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
|
|||||||
public DbSet<ScrobbleHold> ScrobbleHold { get; set; } = null!;
|
public DbSet<ScrobbleHold> ScrobbleHold { get; set; } = null!;
|
||||||
public DbSet<AppUserOnDeckRemoval> AppUserOnDeckRemoval { get; set; } = null!;
|
public DbSet<AppUserOnDeckRemoval> AppUserOnDeckRemoval { get; set; } = null!;
|
||||||
public DbSet<AppUserTableOfContent> AppUserTableOfContent { 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)
|
protected override void OnModelCreating(ModelBuilder builder)
|
||||||
@ -119,6 +121,13 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
|
|||||||
builder.Entity<Chapter>()
|
builder.Entity<Chapter>()
|
||||||
.Property(b => b.ISBN)
|
.Property(b => b.ISBN)
|
||||||
.HasDefaultValue(string.Empty);
|
.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.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 =>
|
modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b =>
|
||||||
@ -201,7 +241,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("SeriesId");
|
b.HasIndex("SeriesId");
|
||||||
|
|
||||||
b.ToTable("AppUserOnDeckRemoval", (string)null);
|
b.ToTable("AppUserOnDeckRemoval");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.AppUserPreferences", b =>
|
modelBuilder.Entity("API.Entities.AppUserPreferences", b =>
|
||||||
@ -315,7 +355,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("ThemeId");
|
b.HasIndex("ThemeId");
|
||||||
|
|
||||||
b.ToTable("AppUserPreferences", (string)null);
|
b.ToTable("AppUserPreferences");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.AppUserProgress", b =>
|
modelBuilder.Entity("API.Entities.AppUserProgress", b =>
|
||||||
@ -365,7 +405,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("SeriesId");
|
b.HasIndex("SeriesId");
|
||||||
|
|
||||||
b.ToTable("AppUserProgresses", (string)null);
|
b.ToTable("AppUserProgresses");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.AppUserRating", b =>
|
modelBuilder.Entity("API.Entities.AppUserRating", b =>
|
||||||
@ -398,7 +438,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("SeriesId");
|
b.HasIndex("SeriesId");
|
||||||
|
|
||||||
b.ToTable("AppUserRating", (string)null);
|
b.ToTable("AppUserRating");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.AppUserRole", b =>
|
modelBuilder.Entity("API.Entities.AppUserRole", b =>
|
||||||
@ -416,6 +456,28 @@ namespace API.Data.Migrations
|
|||||||
b.ToTable("AspNetUserRoles", (string)null);
|
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 =>
|
modelBuilder.Entity("API.Entities.AppUserTableOfContent", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@ -466,7 +528,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("SeriesId");
|
b.HasIndex("SeriesId");
|
||||||
|
|
||||||
b.ToTable("AppUserTableOfContent", (string)null);
|
b.ToTable("AppUserTableOfContent");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.Chapter", b =>
|
modelBuilder.Entity("API.Entities.Chapter", b =>
|
||||||
@ -576,7 +638,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("VolumeId");
|
b.HasIndex("VolumeId");
|
||||||
|
|
||||||
b.ToTable("Chapter", (string)null);
|
b.ToTable("Chapter");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.CollectionTag", b =>
|
modelBuilder.Entity("API.Entities.CollectionTag", b =>
|
||||||
@ -611,7 +673,7 @@ namespace API.Data.Migrations
|
|||||||
b.HasIndex("Id", "Promoted")
|
b.HasIndex("Id", "Promoted")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
b.ToTable("CollectionTag", (string)null);
|
b.ToTable("CollectionTag");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.Device", b =>
|
modelBuilder.Entity("API.Entities.Device", b =>
|
||||||
@ -657,7 +719,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("AppUserId");
|
b.HasIndex("AppUserId");
|
||||||
|
|
||||||
b.ToTable("Device", (string)null);
|
b.ToTable("Device");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.FolderPath", b =>
|
modelBuilder.Entity("API.Entities.FolderPath", b =>
|
||||||
@ -679,7 +741,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("LibraryId");
|
b.HasIndex("LibraryId");
|
||||||
|
|
||||||
b.ToTable("FolderPath", (string)null);
|
b.ToTable("FolderPath");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.Genre", b =>
|
modelBuilder.Entity("API.Entities.Genre", b =>
|
||||||
@ -699,7 +761,7 @@ namespace API.Data.Migrations
|
|||||||
b.HasIndex("NormalizedTitle")
|
b.HasIndex("NormalizedTitle")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
b.ToTable("Genre", (string)null);
|
b.ToTable("Genre");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.Library", b =>
|
modelBuilder.Entity("API.Entities.Library", b =>
|
||||||
@ -757,7 +819,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.ToTable("Library", (string)null);
|
b.ToTable("Library");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.MangaFile", b =>
|
modelBuilder.Entity("API.Entities.MangaFile", b =>
|
||||||
@ -806,7 +868,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("ChapterId");
|
b.HasIndex("ChapterId");
|
||||||
|
|
||||||
b.ToTable("MangaFile", (string)null);
|
b.ToTable("MangaFile");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.MediaError", b =>
|
modelBuilder.Entity("API.Entities.MediaError", b =>
|
||||||
@ -841,7 +903,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.ToTable("MediaError", (string)null);
|
b.ToTable("MediaError");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b =>
|
modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b =>
|
||||||
@ -942,7 +1004,7 @@ namespace API.Data.Migrations
|
|||||||
b.HasIndex("Id", "SeriesId")
|
b.HasIndex("Id", "SeriesId")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
b.ToTable("SeriesMetadata", (string)null);
|
b.ToTable("SeriesMetadata");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b =>
|
modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b =>
|
||||||
@ -966,7 +1028,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("TargetSeriesId");
|
b.HasIndex("TargetSeriesId");
|
||||||
|
|
||||||
b.ToTable("SeriesRelation", (string)null);
|
b.ToTable("SeriesRelation");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.Person", b =>
|
modelBuilder.Entity("API.Entities.Person", b =>
|
||||||
@ -986,7 +1048,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.ToTable("Person", (string)null);
|
b.ToTable("Person");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.ReadingList", b =>
|
modelBuilder.Entity("API.Entities.ReadingList", b =>
|
||||||
@ -1049,7 +1111,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("AppUserId");
|
b.HasIndex("AppUserId");
|
||||||
|
|
||||||
b.ToTable("ReadingList", (string)null);
|
b.ToTable("ReadingList");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.ReadingListItem", b =>
|
modelBuilder.Entity("API.Entities.ReadingListItem", b =>
|
||||||
@ -1083,7 +1145,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("VolumeId");
|
b.HasIndex("VolumeId");
|
||||||
|
|
||||||
b.ToTable("ReadingListItem", (string)null);
|
b.ToTable("ReadingListItem");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b =>
|
modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b =>
|
||||||
@ -1128,7 +1190,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("SeriesId");
|
b.HasIndex("SeriesId");
|
||||||
|
|
||||||
b.ToTable("ScrobbleError", (string)null);
|
b.ToTable("ScrobbleError");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b =>
|
modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b =>
|
||||||
@ -1188,8 +1250,8 @@ namespace API.Data.Migrations
|
|||||||
b.Property<int>("SeriesId")
|
b.Property<int>("SeriesId")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<float?>("VolumeNumber")
|
b.Property<int?>("VolumeNumber")
|
||||||
.HasColumnType("REAL");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
@ -1199,7 +1261,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("SeriesId");
|
b.HasIndex("SeriesId");
|
||||||
|
|
||||||
b.ToTable("ScrobbleEvent", (string)null);
|
b.ToTable("ScrobbleEvent");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b =>
|
modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b =>
|
||||||
@ -1232,7 +1294,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("SeriesId");
|
b.HasIndex("SeriesId");
|
||||||
|
|
||||||
b.ToTable("ScrobbleHold", (string)null);
|
b.ToTable("ScrobbleHold");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.Series", b =>
|
modelBuilder.Entity("API.Entities.Series", b =>
|
||||||
@ -1328,7 +1390,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("LibraryId");
|
b.HasIndex("LibraryId");
|
||||||
|
|
||||||
b.ToTable("Series", (string)null);
|
b.ToTable("Series");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.ServerSetting", b =>
|
modelBuilder.Entity("API.Entities.ServerSetting", b =>
|
||||||
@ -1345,7 +1407,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasKey("Key");
|
b.HasKey("Key");
|
||||||
|
|
||||||
b.ToTable("ServerSetting", (string)null);
|
b.ToTable("ServerSetting");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.ServerStatistics", b =>
|
modelBuilder.Entity("API.Entities.ServerStatistics", b =>
|
||||||
@ -1383,7 +1445,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.ToTable("ServerStatistics", (string)null);
|
b.ToTable("ServerStatistics");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.SiteTheme", b =>
|
modelBuilder.Entity("API.Entities.SiteTheme", b =>
|
||||||
@ -1421,7 +1483,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.ToTable("SiteTheme", (string)null);
|
b.ToTable("SiteTheme");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.Tag", b =>
|
modelBuilder.Entity("API.Entities.Tag", b =>
|
||||||
@ -1441,7 +1503,7 @@ namespace API.Data.Migrations
|
|||||||
b.HasIndex("NormalizedTitle")
|
b.HasIndex("NormalizedTitle")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
b.ToTable("Tag", (string)null);
|
b.ToTable("Tag");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.Volume", b =>
|
modelBuilder.Entity("API.Entities.Volume", b =>
|
||||||
@ -1477,8 +1539,8 @@ namespace API.Data.Migrations
|
|||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<float>("Number")
|
b.Property<int>("Number")
|
||||||
.HasColumnType("REAL");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<int>("Pages")
|
b.Property<int>("Pages")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
@ -1493,7 +1555,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("SeriesId");
|
b.HasIndex("SeriesId");
|
||||||
|
|
||||||
b.ToTable("Volume", (string)null);
|
b.ToTable("Volume");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("AppUserLibrary", b =>
|
modelBuilder.Entity("AppUserLibrary", b =>
|
||||||
@ -1508,7 +1570,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("LibrariesId");
|
b.HasIndex("LibrariesId");
|
||||||
|
|
||||||
b.ToTable("AppUserLibrary", (string)null);
|
b.ToTable("AppUserLibrary");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ChapterGenre", b =>
|
modelBuilder.Entity("ChapterGenre", b =>
|
||||||
@ -1523,7 +1585,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("GenresId");
|
b.HasIndex("GenresId");
|
||||||
|
|
||||||
b.ToTable("ChapterGenre", (string)null);
|
b.ToTable("ChapterGenre");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ChapterPerson", b =>
|
modelBuilder.Entity("ChapterPerson", b =>
|
||||||
@ -1538,7 +1600,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("PeopleId");
|
b.HasIndex("PeopleId");
|
||||||
|
|
||||||
b.ToTable("ChapterPerson", (string)null);
|
b.ToTable("ChapterPerson");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ChapterTag", b =>
|
modelBuilder.Entity("ChapterTag", b =>
|
||||||
@ -1553,7 +1615,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("TagsId");
|
b.HasIndex("TagsId");
|
||||||
|
|
||||||
b.ToTable("ChapterTag", (string)null);
|
b.ToTable("ChapterTag");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("CollectionTagSeriesMetadata", b =>
|
modelBuilder.Entity("CollectionTagSeriesMetadata", b =>
|
||||||
@ -1568,7 +1630,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("SeriesMetadatasId");
|
b.HasIndex("SeriesMetadatasId");
|
||||||
|
|
||||||
b.ToTable("CollectionTagSeriesMetadata", (string)null);
|
b.ToTable("CollectionTagSeriesMetadata");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("GenreSeriesMetadata", b =>
|
modelBuilder.Entity("GenreSeriesMetadata", b =>
|
||||||
@ -1583,7 +1645,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("SeriesMetadatasId");
|
b.HasIndex("SeriesMetadatasId");
|
||||||
|
|
||||||
b.ToTable("GenreSeriesMetadata", (string)null);
|
b.ToTable("GenreSeriesMetadata");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<int>", b =>
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<int>", b =>
|
||||||
@ -1682,7 +1744,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("SeriesMetadatasId");
|
b.HasIndex("SeriesMetadatasId");
|
||||||
|
|
||||||
b.ToTable("PersonSeriesMetadata", (string)null);
|
b.ToTable("PersonSeriesMetadata");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("SeriesMetadataTag", b =>
|
modelBuilder.Entity("SeriesMetadataTag", b =>
|
||||||
@ -1697,7 +1759,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("TagsId");
|
b.HasIndex("TagsId");
|
||||||
|
|
||||||
b.ToTable("SeriesMetadataTag", (string)null);
|
b.ToTable("SeriesMetadataTag");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.AppUserBookmark", b =>
|
modelBuilder.Entity("API.Entities.AppUserBookmark", b =>
|
||||||
@ -1711,6 +1773,23 @@ namespace API.Data.Migrations
|
|||||||
b.Navigation("AppUser");
|
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 =>
|
modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("API.Entities.AppUser", "AppUser")
|
b.HasOne("API.Entities.AppUser", "AppUser")
|
||||||
@ -1808,6 +1887,17 @@ namespace API.Data.Migrations
|
|||||||
b.Navigation("User");
|
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 =>
|
modelBuilder.Entity("API.Entities.AppUserTableOfContent", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("API.Entities.AppUser", "AppUser")
|
b.HasOne("API.Entities.AppUser", "AppUser")
|
||||||
@ -2209,6 +2299,8 @@ namespace API.Data.Migrations
|
|||||||
{
|
{
|
||||||
b.Navigation("Bookmarks");
|
b.Navigation("Bookmarks");
|
||||||
|
|
||||||
|
b.Navigation("DashboardStreams");
|
||||||
|
|
||||||
b.Navigation("Devices");
|
b.Navigation("Devices");
|
||||||
|
|
||||||
b.Navigation("Progresses");
|
b.Navigation("Progresses");
|
||||||
@ -2219,6 +2311,8 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.Navigation("ScrobbleHolds");
|
b.Navigation("ScrobbleHolds");
|
||||||
|
|
||||||
|
b.Navigation("SmartFilters");
|
||||||
|
|
||||||
b.Navigation("TableOfContents");
|
b.Navigation("TableOfContents");
|
||||||
|
|
||||||
b.Navigation("UserPreferences");
|
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.Data.Scanner;
|
||||||
using API.DTOs;
|
using API.DTOs;
|
||||||
using API.DTOs.CollectionTags;
|
using API.DTOs.CollectionTags;
|
||||||
|
using API.DTOs.Dashboard;
|
||||||
using API.DTOs.Filtering;
|
using API.DTOs.Filtering;
|
||||||
using API.DTOs.Filtering.v2;
|
using API.DTOs.Filtering.v2;
|
||||||
using API.DTOs.Metadata;
|
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
|
// First setup any FilterField.Libraries in the statements, as these don't have any traditional query statements applied here
|
||||||
query = ApplyLibraryFilter(filter, query);
|
query = ApplyLibraryFilter(filter, query);
|
||||||
|
|
||||||
|
query = ApplyWantToReadFilter(filter, query, userId);
|
||||||
|
|
||||||
|
|
||||||
query = BuildFilterQuery(userId, filter, query);
|
query = BuildFilterQuery(userId, filter, query);
|
||||||
|
|
||||||
|
|
||||||
@ -968,6 +972,24 @@ public class SeriesRepository : ISeriesRepository
|
|||||||
.AsSplitQuery(), filter.LimitTo);
|
.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)
|
private static IQueryable<Series> ApplyLibraryFilter(FilterV2Dto filter, IQueryable<Series> query)
|
||||||
{
|
{
|
||||||
var filterIncludeLibs = new List<int>();
|
var filterIncludeLibs = new List<int>();
|
||||||
@ -1060,6 +1082,9 @@ public class SeriesRepository : ISeriesRepository
|
|||||||
FilterField.Libraries =>
|
FilterField.Libraries =>
|
||||||
// This is handled in the code before this as it's handled in a more general, combined manner
|
// This is handled in the code before this as it's handled in a more general, combined manner
|
||||||
query,
|
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.ReadProgress => query.HasReadingProgress(true, statement.Comparison, (int) value, userId),
|
||||||
FilterField.Formats => query.HasFormat(true, statement.Comparison, (IList<MangaFormat>) value),
|
FilterField.Formats => query.HasFormat(true, statement.Comparison, (IList<MangaFormat>) value),
|
||||||
FilterField.ReleaseYear => query.HasReleaseYear(true, statement.Comparison, (int) value),
|
FilterField.ReleaseYear => query.HasReleaseYear(true, statement.Comparison, (int) value),
|
||||||
|
@ -6,7 +6,7 @@ using System.Threading.Tasks;
|
|||||||
using API.Constants;
|
using API.Constants;
|
||||||
using API.DTOs;
|
using API.DTOs;
|
||||||
using API.DTOs.Account;
|
using API.DTOs.Account;
|
||||||
using API.DTOs.Filtering;
|
using API.DTOs.Dashboard;
|
||||||
using API.DTOs.Filtering.v2;
|
using API.DTOs.Filtering.v2;
|
||||||
using API.DTOs.Reader;
|
using API.DTOs.Reader;
|
||||||
using API.DTOs.Scrobbling;
|
using API.DTOs.Scrobbling;
|
||||||
@ -15,6 +15,7 @@ using API.Entities;
|
|||||||
using API.Extensions;
|
using API.Extensions;
|
||||||
using API.Extensions.QueryExtensions;
|
using API.Extensions.QueryExtensions;
|
||||||
using API.Extensions.QueryExtensions.Filtering;
|
using API.Extensions.QueryExtensions.Filtering;
|
||||||
|
using API.Helpers;
|
||||||
using AutoMapper;
|
using AutoMapper;
|
||||||
using AutoMapper.QueryableExtensions;
|
using AutoMapper.QueryableExtensions;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
@ -34,8 +35,9 @@ public enum AppUserIncludes
|
|||||||
WantToRead = 64,
|
WantToRead = 64,
|
||||||
ReadingListsWithItems = 128,
|
ReadingListsWithItems = 128,
|
||||||
Devices = 256,
|
Devices = 256,
|
||||||
ScrobbleHolds = 512
|
ScrobbleHolds = 512,
|
||||||
|
SmartFilters = 1024,
|
||||||
|
DashboardStreams = 2048
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface IUserRepository
|
public interface IUserRepository
|
||||||
@ -43,9 +45,11 @@ public interface IUserRepository
|
|||||||
void Update(AppUser user);
|
void Update(AppUser user);
|
||||||
void Update(AppUserPreferences preferences);
|
void Update(AppUserPreferences preferences);
|
||||||
void Update(AppUserBookmark bookmark);
|
void Update(AppUserBookmark bookmark);
|
||||||
|
void Update(AppUserDashboardStream stream);
|
||||||
void Add(AppUserBookmark bookmark);
|
void Add(AppUserBookmark bookmark);
|
||||||
public void Delete(AppUser? user);
|
void Delete(AppUser? user);
|
||||||
void Delete(AppUserBookmark bookmark);
|
void Delete(AppUserBookmark bookmark);
|
||||||
|
void Delete(IList<AppUserDashboardStream> streams);
|
||||||
Task<IEnumerable<MemberDto>> GetEmailConfirmedMemberDtosAsync(bool emailConfirmed = true);
|
Task<IEnumerable<MemberDto>> GetEmailConfirmedMemberDtosAsync(bool emailConfirmed = true);
|
||||||
Task<IEnumerable<AppUser>> GetAdminUsersAsync();
|
Task<IEnumerable<AppUser>> GetAdminUsersAsync();
|
||||||
Task<bool> IsUserAdminAsync(AppUser? user);
|
Task<bool> IsUserAdminAsync(AppUser? user);
|
||||||
@ -76,6 +80,9 @@ public interface IUserRepository
|
|||||||
Task<bool> HasHoldOnSeries(int userId, int seriesId);
|
Task<bool> HasHoldOnSeries(int userId, int seriesId);
|
||||||
Task<IList<ScrobbleHoldDto>> GetHolds(int userId);
|
Task<IList<ScrobbleHoldDto>> GetHolds(int userId);
|
||||||
Task<string> GetLocale(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
|
public class UserRepository : IUserRepository
|
||||||
@ -106,6 +113,11 @@ public class UserRepository : IUserRepository
|
|||||||
_context.Entry(bookmark).State = EntityState.Modified;
|
_context.Entry(bookmark).State = EntityState.Modified;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void Update(AppUserDashboardStream stream)
|
||||||
|
{
|
||||||
|
_context.Entry(stream).State = EntityState.Modified;
|
||||||
|
}
|
||||||
|
|
||||||
public void Add(AppUserBookmark bookmark)
|
public void Add(AppUserBookmark bookmark)
|
||||||
{
|
{
|
||||||
_context.AppUserBookmark.Add(bookmark);
|
_context.AppUserBookmark.Add(bookmark);
|
||||||
@ -122,6 +134,11 @@ public class UserRepository : IUserRepository
|
|||||||
_context.AppUserBookmark.Remove(bookmark);
|
_context.AppUserBookmark.Remove(bookmark);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void Delete(IList<AppUserDashboardStream> streams)
|
||||||
|
{
|
||||||
|
_context.AppUserDashboardStream.RemoveRange(streams);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A one stop shop to get a tracked AppUser instance with any number of JOINs generated by passing bitwise flags.
|
/// A one stop shop to get a tracked AppUser instance with any number of JOINs generated by passing bitwise flags.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -300,6 +317,42 @@ public class UserRepository : IUserRepository
|
|||||||
.SingleAsync();
|
.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()
|
public async Task<IEnumerable<AppUser>> GetAdminUsersAsync()
|
||||||
{
|
{
|
||||||
return await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole);
|
return await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole);
|
||||||
|
@ -6,6 +6,7 @@ using System.Linq;
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using API.Constants;
|
using API.Constants;
|
||||||
|
using API.Data.Repositories;
|
||||||
using API.Entities;
|
using API.Entities;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
using API.Entities.Enums.Theme;
|
using API.Entities.Enums.Theme;
|
||||||
@ -38,6 +39,43 @@ public static class Seed
|
|||||||
}
|
}
|
||||||
}.ToArray());
|
}.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)
|
public static async Task SeedRoles(RoleManager<AppRole> roleManager)
|
||||||
{
|
{
|
||||||
var roles = typeof(PolicyConstants)
|
var roles = typeof(PolicyConstants)
|
||||||
@ -74,6 +112,31 @@ public static class Seed
|
|||||||
await context.SaveChangesAsync();
|
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)
|
public static async Task SeedSettings(DataContext context, IDirectoryService directoryService)
|
||||||
{
|
{
|
||||||
await context.Database.EnsureCreatedAsync();
|
await context.Database.EnsureCreatedAsync();
|
||||||
|
@ -28,6 +28,7 @@ public interface IUnitOfWork
|
|||||||
IMediaErrorRepository MediaErrorRepository { get; }
|
IMediaErrorRepository MediaErrorRepository { get; }
|
||||||
IScrobbleRepository ScrobbleRepository { get; }
|
IScrobbleRepository ScrobbleRepository { get; }
|
||||||
IUserTableOfContentRepository UserTableOfContentRepository { get; }
|
IUserTableOfContentRepository UserTableOfContentRepository { get; }
|
||||||
|
IAppUserSmartFilterRepository AppUserSmartFilterRepository { get; }
|
||||||
bool Commit();
|
bool Commit();
|
||||||
Task<bool> CommitAsync();
|
Task<bool> CommitAsync();
|
||||||
bool HasChanges();
|
bool HasChanges();
|
||||||
@ -68,6 +69,7 @@ public class UnitOfWork : IUnitOfWork
|
|||||||
public IMediaErrorRepository MediaErrorRepository => new MediaErrorRepository(_context, _mapper);
|
public IMediaErrorRepository MediaErrorRepository => new MediaErrorRepository(_context, _mapper);
|
||||||
public IScrobbleRepository ScrobbleRepository => new ScrobbleRepository(_context, _mapper);
|
public IScrobbleRepository ScrobbleRepository => new ScrobbleRepository(_context, _mapper);
|
||||||
public IUserTableOfContentRepository UserTableOfContentRepository => new UserTableOfContentRepository(_context, _mapper);
|
public IUserTableOfContentRepository UserTableOfContentRepository => new UserTableOfContentRepository(_context, _mapper);
|
||||||
|
public IAppUserSmartFilterRepository AppUserSmartFilterRepository => new AppUserSmartFilterRepository(_context, _mapper);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Commits changes to the DB. Completes the open transaction.
|
/// 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
|
/// A list of Series the user doesn't want scrobbling for
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public ICollection<ScrobbleHold> ScrobbleHolds { get; set; } = null!;
|
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 />
|
/// <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
|
public static class SeriesFilter
|
||||||
{
|
{
|
||||||
|
private const float FloatingPointTolerance = 0.01f;
|
||||||
public static IQueryable<Series> HasLanguage(this IQueryable<Series> queryable, bool condition,
|
public static IQueryable<Series> HasLanguage(this IQueryable<Series> queryable, bool condition,
|
||||||
FilterComparison comparison, IList<string> languages)
|
FilterComparison comparison, IList<string> languages)
|
||||||
{
|
{
|
||||||
@ -94,7 +94,7 @@ public static class SeriesFilter
|
|||||||
switch (comparison)
|
switch (comparison)
|
||||||
{
|
{
|
||||||
case FilterComparison.Equal:
|
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:
|
case FilterComparison.GreaterThan:
|
||||||
return queryable.Where(s => s.Ratings.Any(r => r.Rating > rating && r.AppUserId == userId));
|
return queryable.Where(s => s.Ratings.Any(r => r.Rating > rating && r.AppUserId == userId));
|
||||||
case FilterComparison.GreaterThanEqual:
|
case FilterComparison.GreaterThanEqual:
|
||||||
@ -252,7 +252,7 @@ public static class SeriesFilter
|
|||||||
switch (comparison)
|
switch (comparison)
|
||||||
{
|
{
|
||||||
case FilterComparison.Equal:
|
case FilterComparison.Equal:
|
||||||
subQuery = subQuery.Where(s => s.Percentage == readProgress);
|
subQuery = subQuery.Where(s => Math.Abs(s.Percentage - readProgress) < FloatingPointTolerance);
|
||||||
break;
|
break;
|
||||||
case FilterComparison.GreaterThan:
|
case FilterComparison.GreaterThan:
|
||||||
subQuery = subQuery.Where(s => s.Percentage > readProgress);
|
subQuery = subQuery.Where(s => s.Percentage > readProgress);
|
||||||
@ -267,7 +267,7 @@ public static class SeriesFilter
|
|||||||
subQuery = subQuery.Where(s => s.Percentage <= readProgress);
|
subQuery = subQuery.Where(s => s.Percentage <= readProgress);
|
||||||
break;
|
break;
|
||||||
case FilterComparison.NotEqual:
|
case FilterComparison.NotEqual:
|
||||||
subQuery = subQuery.Where(s => s.Percentage != readProgress);
|
subQuery = subQuery.Where(s => Math.Abs(s.Percentage - readProgress) > FloatingPointTolerance);
|
||||||
break;
|
break;
|
||||||
case FilterComparison.Matches:
|
case FilterComparison.Matches:
|
||||||
case FilterComparison.Contains:
|
case FilterComparison.Contains:
|
||||||
|
@ -31,6 +31,7 @@ public static class SeriesSort
|
|||||||
SortField.LastChapterAdded => query.OrderBy(s => s.LastChapterAdded),
|
SortField.LastChapterAdded => query.OrderBy(s => s.LastChapterAdded),
|
||||||
SortField.TimeToRead => query.OrderBy(s => s.AvgHoursToRead),
|
SortField.TimeToRead => query.OrderBy(s => s.AvgHoursToRead),
|
||||||
SortField.ReleaseYear => query.OrderBy(s => s.Metadata.ReleaseYear),
|
SortField.ReleaseYear => query.OrderBy(s => s.Metadata.ReleaseYear),
|
||||||
|
//SortField.ReadProgress => query.OrderBy()
|
||||||
_ => query
|
_ => query
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -130,6 +130,17 @@ public static class IncludesExtensions
|
|||||||
query = query.Include(u => u.ScrobbleHolds);
|
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();
|
return query.AsSplitQuery();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4,7 +4,10 @@ using API.Data.Migrations;
|
|||||||
using API.DTOs;
|
using API.DTOs;
|
||||||
using API.DTOs.Account;
|
using API.DTOs.Account;
|
||||||
using API.DTOs.CollectionTags;
|
using API.DTOs.CollectionTags;
|
||||||
|
using API.DTOs.Dashboard;
|
||||||
using API.DTOs.Device;
|
using API.DTOs.Device;
|
||||||
|
using API.DTOs.Filtering;
|
||||||
|
using API.DTOs.Filtering.v2;
|
||||||
using API.DTOs.MediaErrors;
|
using API.DTOs.MediaErrors;
|
||||||
using API.DTOs.Metadata;
|
using API.DTOs.Metadata;
|
||||||
using API.DTOs.Reader;
|
using API.DTOs.Reader;
|
||||||
@ -226,5 +229,12 @@ public class AutoMapperProfiles : Profile
|
|||||||
CreateMap<Device, DeviceDto>();
|
CreateMap<Device, DeviceDto>();
|
||||||
CreateMap<AppUserTableOfContent, PersonalToCDto>();
|
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>(),
|
Ratings = new List<AppUserRating>(),
|
||||||
Progresses = new List<AppUserProgress>(),
|
Progresses = new List<AppUserProgress>(),
|
||||||
Devices = new List<Device>(),
|
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)
|
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(',')
|
FilterField.Libraries => (value.Split(',')
|
||||||
.Select(int.Parse)
|
.Select(int.Parse)
|
||||||
.ToList(), typeof(IList<int>)),
|
.ToList(), typeof(IList<int>)),
|
||||||
|
FilterField.WantToRead => (bool.Parse(value), typeof(bool)),
|
||||||
FilterField.ReadProgress => (int.Parse(value), typeof(int)),
|
FilterField.ReadProgress => (int.Parse(value), typeof(int)),
|
||||||
FilterField.Formats => (value.Split(',')
|
FilterField.Formats => (value.Split(',')
|
||||||
.Select(x => (MangaFormat) Enum.Parse(typeof(MangaFormat), x))
|
.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",
|
"browse-libraries": "Browse by Libraries",
|
||||||
"collections": "All Collections",
|
"collections": "All Collections",
|
||||||
"browse-collections": "Browse by 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",
|
"reading-list-restricted": "Reading list does not exist or you don't have access",
|
||||||
"query-required": "You must pass a query parameter",
|
"query-required": "You must pass a query parameter",
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
"search-description": "Search for Series, Collections, or Reading Lists",
|
"search-description": "Search for Series, Collections, or Reading Lists",
|
||||||
"favicon-doesnt-exist": "Favicon does not exist",
|
"favicon-doesnt-exist": "Favicon does not exist",
|
||||||
|
"smart-filter-doesnt-exist": "Smart Filter doesn't exist",
|
||||||
|
|
||||||
"not-authenticated": "User is not authenticated",
|
"not-authenticated": "User is not authenticated",
|
||||||
"unable-to-register-k+": "Unable to register license due to error. Reach out to Kavita+ Support",
|
"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.SeedRoles(services.GetRequiredService<RoleManager<AppRole>>());
|
||||||
await Seed.SeedSettings(context, directoryService);
|
await Seed.SeedSettings(context, directoryService);
|
||||||
await Seed.SeedThemes(context);
|
await Seed.SeedThemes(context);
|
||||||
|
await Seed.SeedDefaultStreams(services.GetRequiredService<IUnitOfWork>());
|
||||||
await Seed.SeedUserApiKeys(context);
|
await Seed.SeedUserApiKeys(context);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
@ -122,6 +122,25 @@ public static class MessageFactory
|
|||||||
/// A Scrobbling Key has expired and needs rotation
|
/// A Scrobbling Key has expired and needs rotation
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const string ScrobblingKeyExpired = "ScrobblingKeyExpired";
|
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)
|
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",
|
"@fortawesome/fontawesome-free": "^6.4.2",
|
||||||
"@iharbeck/ngx-virtual-scroller": "^16.0.0",
|
"@iharbeck/ngx-virtual-scroller": "^16.0.0",
|
||||||
"@iplab/ngx-file-upload": "^16.0.1",
|
"@iplab/ngx-file-upload": "^16.0.1",
|
||||||
"@microsoft/signalr": "^7.0.10",
|
"@microsoft/signalr": "^7.0.11",
|
||||||
"@ng-bootstrap/ng-bootstrap": "^15.1.1",
|
"@ng-bootstrap/ng-bootstrap": "^15.1.1",
|
||||||
"@ngneat/transloco": "^5.0.7",
|
"@ngneat/transloco": "^5.0.7",
|
||||||
"@ngneat/transloco-locale": "^5.1.1",
|
"@ngneat/transloco-locale": "^5.1.1",
|
||||||
@ -3142,9 +3142,9 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@microsoft/signalr": {
|
"node_modules/@microsoft/signalr": {
|
||||||
"version": "7.0.10",
|
"version": "7.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-7.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-7.0.11.tgz",
|
||||||
"integrity": "sha512-tOEn32i5EatAx4sZbzmLgcBc2VbKQmx+F4rI2/Ioq2MnBaYcFxbDzOoZgISIS4IR9H1ij/sKoU8zQOAFC8GJKg==",
|
"integrity": "sha512-//6ipnYKhHf2MJgM+MQSlgB5L/pcYeZ+v4w6YAr4epRM1iSDQ6WjUkCVX2ZMxcY06XGlLzggs3Z9ZIcL9ws9KQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"abort-controller": "^3.0.0",
|
"abort-controller": "^3.0.0",
|
||||||
"eventsource": "^2.0.2",
|
"eventsource": "^2.0.2",
|
||||||
|
@ -26,7 +26,7 @@
|
|||||||
"@fortawesome/fontawesome-free": "^6.4.2",
|
"@fortawesome/fontawesome-free": "^6.4.2",
|
||||||
"@iharbeck/ngx-virtual-scroller": "^16.0.0",
|
"@iharbeck/ngx-virtual-scroller": "^16.0.0",
|
||||||
"@iplab/ngx-file-upload": "^16.0.1",
|
"@iplab/ngx-file-upload": "^16.0.1",
|
||||||
"@microsoft/signalr": "^7.0.10",
|
"@microsoft/signalr": "^7.0.11",
|
||||||
"@ng-bootstrap/ng-bootstrap": "^15.1.1",
|
"@ng-bootstrap/ng-bootstrap": "^15.1.1",
|
||||||
"@ngneat/transloco": "^5.0.7",
|
"@ngneat/transloco": "^5.0.7",
|
||||||
"@ngneat/transloco-locale": "^5.1.1",
|
"@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 {
|
export interface LibraryModifiedEvent {
|
||||||
libraryId: number;
|
libraryId: number;
|
||||||
action: 'create' | 'delelte';
|
action: 'create' | 'delete';
|
||||||
}
|
}
|
||||||
|
@ -26,7 +26,8 @@ export enum FilterField
|
|||||||
ReleaseYear = 22,
|
ReleaseYear = 22,
|
||||||
ReadTime = 23,
|
ReadTime = 23,
|
||||||
Path = 24,
|
Path = 24,
|
||||||
FilePath = 25
|
FilePath = 25,
|
||||||
|
WantToRead = 26
|
||||||
}
|
}
|
||||||
|
|
||||||
export const allFields = Object.keys(FilterField)
|
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),
|
filter(userUpdateEvent => userUpdateEvent.userName === this.currentUser?.username),
|
||||||
switchMap(() => this.refreshAccount()))
|
switchMap(() => this.refreshAccount()))
|
||||||
.subscribe(() => {});
|
.subscribe(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
hasAdminRole(user: User) {
|
hasAdminRole(user: User) {
|
||||||
return user && user.roles.includes(Role.Admin);
|
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 { ThemeProgressEvent } from '../_models/events/theme-progress-event';
|
||||||
import { UserUpdateEvent } from '../_models/events/user-update-event';
|
import { UserUpdateEvent } from '../_models/events/user-update-event';
|
||||||
import { User } from '../_models/user';
|
import { User } from '../_models/user';
|
||||||
|
import {DashboardUpdateEvent} from "../_models/events/dashboard-update-event";
|
||||||
|
|
||||||
export enum EVENTS {
|
export enum EVENTS {
|
||||||
UpdateAvailable = 'UpdateAvailable',
|
UpdateAvailable = 'UpdateAvailable',
|
||||||
@ -82,6 +83,10 @@ export enum EVENTS {
|
|||||||
* A scrobbling token has expired
|
* A scrobbling token has expired
|
||||||
*/
|
*/
|
||||||
ScrobblingKeyExpired = 'ScrobblingKeyExpired',
|
ScrobblingKeyExpired = 'ScrobblingKeyExpired',
|
||||||
|
/**
|
||||||
|
* User's dashboard needs to be re-rendered
|
||||||
|
*/
|
||||||
|
DashboardUpdate = 'DashboardUpdate'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Message<T> {
|
export interface Message<T> {
|
||||||
@ -109,7 +114,6 @@ export class MessageHubService {
|
|||||||
*/
|
*/
|
||||||
public onlineUsers$ = this.onlineUsersSource.asObservable();
|
public onlineUsers$ = this.onlineUsersSource.asObservable();
|
||||||
|
|
||||||
|
|
||||||
isAdmin: boolean = false;
|
isAdmin: boolean = false;
|
||||||
|
|
||||||
constructor() {}
|
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.hubConnection.on(EVENTS.NotificationProgress, (resp: NotificationProgressEvent) => {
|
||||||
this.messagesSource.next({
|
this.messagesSource.next({
|
||||||
|
@ -23,14 +23,16 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-footer bg-transparent text-muted">
|
<div class="card-footer bg-transparent text-muted">
|
||||||
<ng-container *ngIf="isMyReview; else normalReview">
|
<div class="review-user">
|
||||||
<i class="d-md-none fa-solid fa-star me-1" aria-hidden="true" [title]="t('your-review')"></i>
|
<ng-container *ngIf="isMyReview; else normalReview">
|
||||||
</ng-container>
|
<i class="d-md-none fa-solid fa-star me-1" aria-hidden="true" [title]="t('your-review')"></i>
|
||||||
<ng-template #normalReview>
|
</ng-container>
|
||||||
<img class="me-1" [ngSrc]="review.provider | providerImage" width="20" height="20" alt="">
|
<ng-template #normalReview>
|
||||||
</ng-template>
|
<img class="me-1" [ngSrc]="review.provider | providerImage" width="20" height="20" alt="">
|
||||||
{{(isMyReview ? '' : review.username | defaultValue:'')}}
|
</ng-template>
|
||||||
<span style="float: right" *ngIf="review.isExternal">{{t('rating-percentage', {r: review.score})}}</span>
|
{{(isMyReview ? '' : review.username | defaultValue:'')}}
|
||||||
|
</div>
|
||||||
|
<span class="review-score" *ngIf="review.isExternal">{{t('rating-percentage', {r: review.score})}}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -41,5 +41,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card-footer {
|
.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>
|
<app-side-nav-companion-bar></app-side-nav-companion-bar>
|
||||||
|
|
||||||
<ng-container *transloco="let t; read: 'dashboard'">
|
<ng-container *transloco="let t; read: 'dashboard'">
|
||||||
<ng-container *ngIf="libraries$ | async as libraries">
|
<ng-container *ngIf="libraries$ | async as libraries">
|
||||||
<ng-container *ngIf="libraries.length === 0 && !isLoading">
|
<ng-container *ngIf="libraries.length === 0 && !isLoadingAdmin">
|
||||||
<div class="mt-3" *ngIf="isAdmin$ | async as isAdmin">
|
<div class="mt-3" *ngIf="isAdmin$ | async as isAdmin">
|
||||||
<div *ngIf="isAdmin" class="d-flex justify-content-center">
|
<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>
|
<p>{{t('no-libraries')}} <a routerLink="/admin/dashboard" fragment="libraries">{{t('server-settings-link')}}</a>.</p>
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="!isAdmin" class="d-flex justify-content-center">
|
<div *ngIf="!isAdmin" class="d-flex justify-content-center">
|
||||||
<p>{{t('not-granted')}}</p>
|
<p>{{t('not-granted')}}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ng-container>
|
</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>
|
</ng-container>
|
||||||
|
|
||||||
<app-carousel-reel [items]="inProgress" [title]="t('on-deck-title')" (sectionClick)="handleSectionClick('on deck')">
|
<ng-template #smartFilter let-stream: DashboardStream>
|
||||||
<ng-template #carouselItem let-item>
|
<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"
|
<app-series-card [data]="item" [libraryId]="item.libraryId" [suppressLibraryLink]="libraryId !== 0" [isOnDeck]="true"
|
||||||
(reload)="reloadInProgress($event)" (dataChanged)="reloadInProgress($event)"></app-series-card>
|
(reload)="reloadStream(stream.id)" (dataChanged)="reloadStream(stream.id)"></app-series-card>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-carousel-reel>
|
</app-carousel-reel>
|
||||||
|
</ng-container>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
<app-carousel-reel [items]="recentlyUpdatedSeries" [title]="t('recently-updated-title')" (sectionClick)="handleSectionClick('recently updated series')">
|
<ng-template #recentlyUpdated let-stream: DashboardStream>
|
||||||
<ng-template #carouselItem let-item>
|
<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)"
|
<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>
|
[suppressArchiveWarning]="true" (clicked)="handleRecentlyAddedChapterClick(item)" [count]="item.count"></app-card-item>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-carousel-reel>
|
</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-container>
|
||||||
<ng-template #carouselItem let-item>
|
|
||||||
<app-series-card [data]="item" [libraryId]="item.libraryId" [suppressLibraryLink]="libraryId !== 0" (dataChanged)="loadRecentlyAddedSeries()"></app-series-card>
|
<app-loading [loading]="isLoadingDashboard"></app-loading>
|
||||||
</ng-template>
|
|
||||||
</app-carousel-reel>
|
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
@ -1,16 +1,13 @@
|
|||||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, Input, OnInit} from '@angular/core';
|
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, Input, OnInit} from '@angular/core';
|
||||||
import {Title} from '@angular/platform-browser';
|
import {Title} from '@angular/platform-browser';
|
||||||
import {Router, RouterLink} from '@angular/router';
|
import {Router, RouterLink} from '@angular/router';
|
||||||
import {Observable, of, ReplaySubject} from 'rxjs';
|
import {Observable, of, ReplaySubject, Subject, switchMap} from 'rxjs';
|
||||||
import {debounceTime, map, shareReplay, take, tap} from 'rxjs/operators';
|
import {map, shareReplay, take, tap, throttleTime} from 'rxjs/operators';
|
||||||
import {FilterUtilitiesService} from 'src/app/shared/_services/filter-utilities.service';
|
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 {Library} from 'src/app/_models/library';
|
||||||
import {RecentlyAddedItem} from 'src/app/_models/recently-added-item';
|
import {RecentlyAddedItem} from 'src/app/_models/recently-added-item';
|
||||||
import {Series} from 'src/app/_models/series';
|
import {Series} from 'src/app/_models/series';
|
||||||
import {SortField} from 'src/app/_models/metadata/series-filter';
|
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 {AccountService} from 'src/app/_services/account.service';
|
||||||
import {ImageService} from 'src/app/_services/image.service';
|
import {ImageService} from 'src/app/_services/image.service';
|
||||||
import {LibraryService} from 'src/app/_services/library.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 {CardItemComponent} from '../../cards/card-item/card-item.component';
|
||||||
import {SeriesCardComponent} from '../../cards/series-card/series-card.component';
|
import {SeriesCardComponent} from '../../cards/series-card/series-card.component';
|
||||||
import {CarouselReelComponent} from '../../carousel/_components/carousel-reel/carousel-reel.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 {
|
import {
|
||||||
SideNavCompanionBarComponent
|
SideNavCompanionBarComponent
|
||||||
} from '../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
|
} 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 {FilterField} from "../../_models/metadata/v2/filter-field";
|
||||||
import {FilterComparison} from "../../_models/metadata/v2/filter-comparison";
|
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({
|
@Component({
|
||||||
selector: 'app-dashboard',
|
selector: 'app-dashboard',
|
||||||
@ -34,7 +39,8 @@ import {FilterComparison} from "../../_models/metadata/v2/filter-comparison";
|
|||||||
styleUrls: ['./dashboard.component.scss'],
|
styleUrls: ['./dashboard.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
standalone: true,
|
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 {
|
export class DashboardComponent implements OnInit {
|
||||||
|
|
||||||
@ -44,13 +50,14 @@ export class DashboardComponent implements OnInit {
|
|||||||
@Input() libraryId: number = 0;
|
@Input() libraryId: number = 0;
|
||||||
|
|
||||||
libraries$: Observable<Library[]> = of([]);
|
libraries$: Observable<Library[]> = of([]);
|
||||||
isLoading = true;
|
isLoadingAdmin = true;
|
||||||
|
isLoadingDashboard = true;
|
||||||
isAdmin$: Observable<boolean> = of(false);
|
isAdmin$: Observable<boolean> = of(false);
|
||||||
|
|
||||||
recentlyUpdatedSeries: SeriesGroup[] = [];
|
streams: Array<DashboardStream> = [];
|
||||||
inProgress: Series[] = [];
|
genre: Genre | undefined;
|
||||||
recentlyAddedSeries: Series[] = [];
|
refreshStreams$ = new Subject<void>();
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* We use this Replay subject to slow the amount of times we reload the UI
|
* 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 loadRecentlyAdded$: ReplaySubject<void> = new ReplaySubject<void>();
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
private readonly filterUtilityService = inject(FilterUtilitiesService);
|
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,
|
constructor(public accountService: AccountService, private libraryService: LibraryService,
|
||||||
private seriesService: SeriesService, private router: Router,
|
private seriesService: SeriesService, private router: Router,
|
||||||
private titleService: Title, public imageService: ImageService,
|
private titleService: Title, public imageService: ImageService,
|
||||||
private messageHub: MessageHubService, private readonly cdRef: ChangeDetectorRef) {
|
private messageHub: MessageHubService, private readonly cdRef: ChangeDetectorRef,
|
||||||
|
private dashboardService: DashboardService) {
|
||||||
this.messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(res => {
|
|
||||||
if (res.event === EVENTS.SeriesAdded) {
|
|
||||||
const seriesAddedEvent = res.payload as SeriesAddedEvent;
|
|
||||||
|
|
||||||
|
|
||||||
this.seriesService.getSeries(seriesAddedEvent.seriesId).subscribe(series => {
|
this.loadDashboard();
|
||||||
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.inProgress = this.inProgress.filter(item => item.id != seriesRemovedEvent.seriesId);
|
this.refreshStreams$.pipe(takeUntilDestroyed(this.destroyRef), throttleTime(10_000),
|
||||||
this.recentlyAddedSeries = this.recentlyAddedSeries.filter(item => item.id != seriesRemovedEvent.seriesId);
|
tap(() => {
|
||||||
this.recentlyUpdatedSeries = this.recentlyUpdatedSeries.filter(item => item.seriesId != seriesRemovedEvent.seriesId);
|
this.loadDashboard()
|
||||||
this.cdRef.markForCheck();
|
}))
|
||||||
} else if (res.event === EVENTS.ScanSeries) {
|
.subscribe();
|
||||||
// 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.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(() => {
|
// TODO: Solve how Websockets will work with these dyanamic streams
|
||||||
this.loadRecentlyUpdated();
|
this.messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(res => {
|
||||||
this.loadRecentlyAddedSeries();
|
|
||||||
this.cdRef.markForCheck();
|
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 {
|
ngOnInit(): void {
|
||||||
this.titleService.setTitle('Kavita - Dashboard');
|
this.titleService.setTitle('Kavita');
|
||||||
this.isLoading = true;
|
this.isLoadingAdmin = true;
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
|
|
||||||
this.libraries$ = this.libraryService.getLibraries().pipe(take(1), takeUntilDestroyed(this.destroyRef), tap((libs) => {
|
this.libraries$ = this.libraryService.getLibraries().pipe(take(1), takeUntilDestroyed(this.destroyRef), tap((libs) => {
|
||||||
this.isLoading = false;
|
this.isLoadingAdmin = false;
|
||||||
this.cdRef.markForCheck();
|
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() {
|
loadDashboard() {
|
||||||
let api = this.seriesService.getRecentlyUpdatedSeries();
|
this.isLoadingDashboard = true;
|
||||||
if (this.libraryId > 0) {
|
this.cdRef.markForCheck();
|
||||||
api = this.seriesService.getRecentlyUpdatedSeries();
|
this.dashboardService.getDashboardStreams().subscribe(streams => {
|
||||||
}
|
this.streams = streams;
|
||||||
api.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(updatedSeries => {
|
this.streams.forEach(s => {
|
||||||
this.recentlyUpdatedSeries = updatedSeries.filter(group => {
|
switch (s.streamType) {
|
||||||
if (this.libraryId === 0) return true;
|
case StreamType.OnDeck:
|
||||||
return group.libraryId === this.libraryId;
|
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();
|
this.cdRef.markForCheck();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
handleRecentlyAddedChapterClick(item: RecentlyAddedItem) {
|
reloadStream(streamId: number) {
|
||||||
this.router.navigate(['library', item.libraryId, 'series', item.seriesId]);
|
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) {
|
handleSectionClick(sectionTitle: string) {
|
||||||
@ -180,7 +208,7 @@ export class DashboardComponent implements OnInit {
|
|||||||
} else if (sectionTitle.toLowerCase() === 'on deck') {
|
} else if (sectionTitle.toLowerCase() === 'on deck') {
|
||||||
const params: any = {};
|
const params: any = {};
|
||||||
params['page'] = 1;
|
params['page'] = 1;
|
||||||
params['title'] = 'On Deck';
|
params['title'] = translate('dashboard.on-deck-title');
|
||||||
|
|
||||||
const filter = this.filterUtilityService.createSeriesV2Filter();
|
const filter = this.filterUtilityService.createSeriesV2Filter();
|
||||||
filter.statements.push({field: FilterField.ReadProgress, comparison: FilterComparison.GreaterThan, value: '0'});
|
filter.statements.push({field: FilterField.ReadProgress, comparison: FilterComparison.GreaterThan, value: '0'});
|
||||||
@ -190,16 +218,23 @@ export class DashboardComponent implements OnInit {
|
|||||||
filter.sortOptions.isAscending = false;
|
filter.sortOptions.isAscending = false;
|
||||||
}
|
}
|
||||||
this.filterUtilityService.applyFilterWithParams(['all-series'], filter, params)
|
this.filterUtilityService.applyFilterWithParams(['all-series'], filter, params)
|
||||||
}else if (sectionTitle.toLowerCase() === 'newly added series') {
|
} else if (sectionTitle.toLowerCase() === 'newly added series') {
|
||||||
const params: any = {};
|
const params: any = {};
|
||||||
params['page'] = 1;
|
params['page'] = 1;
|
||||||
params['title'] = 'Newly Added';
|
params['title'] = translate('dashboard.recently-added-title');
|
||||||
const filter = this.filterUtilityService.createSeriesV2Filter();
|
const filter = this.filterUtilityService.createSeriesV2Filter();
|
||||||
if (filter.sortOptions) {
|
if (filter.sortOptions) {
|
||||||
filter.sortOptions.sortField = SortField.Created;
|
filter.sortOptions.sortField = SortField.Created;
|
||||||
filter.sortOptions.isAscending = false;
|
filter.sortOptions.isAscending = false;
|
||||||
}
|
}
|
||||||
this.filterUtilityService.applyFilterWithParams(['all-series'], filter, params)
|
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>
|
<app-card-actionables [actions]="actions" (actionHandler)="performAction($event)"></app-card-actionables>
|
||||||
<span>{{libraryName}}</span>
|
<span>{{libraryName}}</span>
|
||||||
</h2>
|
</h2>
|
||||||
<div main>
|
<h6 subtitle class="subtitle-with-actionables" *ngIf="active.fragment === ''">{{t('common.series-count', {num: pagination.totalItems | number})}} </h6>
|
||||||
<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>
|
|
||||||
</app-side-nav-companion-bar>
|
</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>
|
<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>
|
</ng-container>
|
||||||
|
@ -33,7 +33,6 @@ import {SentenceCasePipe} from '../pipe/sentence-case.pipe';
|
|||||||
import {BulkOperationsComponent} from '../cards/bulk-operations/bulk-operations.component';
|
import {BulkOperationsComponent} from '../cards/bulk-operations/bulk-operations.component';
|
||||||
import {SeriesCardComponent} from '../cards/series-card/series-card.component';
|
import {SeriesCardComponent} from '../cards/series-card/series-card.component';
|
||||||
import {CardDetailLayoutComponent} from '../cards/card-detail-layout/card-detail-layout.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 {DecimalPipe, NgFor, NgIf} from '@angular/common';
|
||||||
import {NgbNav, NgbNavContent, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavOutlet} from '@ng-bootstrap/ng-bootstrap';
|
import {NgbNav, NgbNavContent, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavOutlet} from '@ng-bootstrap/ng-bootstrap';
|
||||||
import {
|
import {
|
||||||
@ -52,7 +51,8 @@ import {CardActionablesComponent} from "../_single-module/card-actionables/card-
|
|||||||
styleUrls: ['./library-detail.component.scss'],
|
styleUrls: ['./library-detail.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
standalone: true,
|
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 {
|
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}`;
|
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 { LibraryDetailComponent } from './library-detail.component';
|
||||||
import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
|
import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
|
||||||
import { LibraryDetailRoutingModule } from './library-detail-routing.module';
|
import { LibraryDetailRoutingModule } from './library-detail-routing.module';
|
||||||
import { LibraryRecommendedComponent } from './library-recommended/library-recommended.component';
|
|
||||||
|
|
||||||
import {SentenceCasePipe} from "../pipe/sentence-case.pipe";
|
import {SentenceCasePipe} from "../pipe/sentence-case.pipe";
|
||||||
import {CardDetailLayoutComponent} from "../cards/card-detail-layout/card-detail-layout.component";
|
import {CardDetailLayoutComponent} from "../cards/card-detail-layout/card-detail-layout.component";
|
||||||
@ -27,7 +26,7 @@ import {CardActionablesComponent} from "../_single-module/card-actionables/card-
|
|||||||
SeriesCardComponent,
|
SeriesCardComponent,
|
||||||
BulkOperationsComponent,
|
BulkOperationsComponent,
|
||||||
SideNavCompanionBarComponent,
|
SideNavCompanionBarComponent,
|
||||||
LibraryDetailComponent, LibraryRecommendedComponent
|
LibraryDetailComponent,
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class LibraryDetailModule { }
|
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">
|
<ng-container *ngSwitchCase="PredicateType.Number">
|
||||||
<input type="number" inputmode="numeric" class="form-control me-2" formControlName="filterValue" min="0">
|
<input type="number" inputmode="numeric" class="form-control me-2" formControlName="filterValue" min="0">
|
||||||
</ng-container>
|
</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 *ngSwitchCase="PredicateType.Dropdown">
|
||||||
<ng-container *ngIf="dropdownOptions$ | async as opts">
|
<ng-container *ngIf="dropdownOptions$ | async as opts">
|
||||||
<ng-container *ngTemplateOutlet="dropdown; context: { options: opts, multipleAllowed: MultipleDropdownAllowed }"></ng-container>
|
<ng-container *ngTemplateOutlet="dropdown; context: { options: opts, multipleAllowed: MultipleDropdownAllowed }"></ng-container>
|
||||||
|
@ -30,6 +30,7 @@ enum PredicateType {
|
|||||||
Text = 1,
|
Text = 1,
|
||||||
Number = 2,
|
Number = 2,
|
||||||
Dropdown = 3,
|
Dropdown = 3,
|
||||||
|
Boolean = 4
|
||||||
}
|
}
|
||||||
|
|
||||||
const StringFields = [FilterField.SeriesName, FilterField.Summary, FilterField.Path, FilterField.FilePath];
|
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.Writers, FilterField.Genres, FilterField.Libraries,
|
||||||
FilterField.Formats, FilterField.CollectionTags, FilterField.Tags
|
FilterField.Formats, FilterField.CollectionTags, FilterField.Tags
|
||||||
];
|
];
|
||||||
|
const BooleanFields = [FilterField.WantToRead]
|
||||||
|
|
||||||
const DropdownFieldsWithoutMustContains = [
|
const DropdownFieldsWithoutMustContains = [
|
||||||
FilterField.Libraries, FilterField.Formats, FilterField.AgeRating, FilterField.PublicationStatus
|
FilterField.Libraries, FilterField.Formats, FilterField.AgeRating, FilterField.PublicationStatus
|
||||||
@ -69,6 +71,9 @@ const DropdownComparisons = [FilterComparison.Equal,
|
|||||||
FilterComparison.Contains,
|
FilterComparison.Contains,
|
||||||
FilterComparison.NotContains,
|
FilterComparison.NotContains,
|
||||||
FilterComparison.MustContains];
|
FilterComparison.MustContains];
|
||||||
|
const BooleanComparisons = [
|
||||||
|
FilterComparison.Equal
|
||||||
|
]
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-metadata-row-filter',
|
selector: 'app-metadata-row-filter',
|
||||||
@ -155,7 +160,11 @@ export class MetadataFilterRowComponent implements OnInit {
|
|||||||
stmt.value = stmt.value + '';
|
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);
|
this.filterStatement.emit(stmt);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -172,6 +181,8 @@ export class MetadataFilterRowComponent implements OnInit {
|
|||||||
|
|
||||||
if (StringFields.includes(this.preset.field)) {
|
if (StringFields.includes(this.preset.field)) {
|
||||||
this.formGroup.get('filterValue')?.patchValue(val);
|
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)) {
|
} else if (DropdownFields.includes(this.preset.field)) {
|
||||||
if (this.MultipleDropdownAllowed || val.includes(',')) {
|
if (this.MultipleDropdownAllowed || val.includes(',')) {
|
||||||
this.formGroup.get('filterValue')?.patchValue(val.split(',').map(d => parseInt(d, 10)));
|
this.formGroup.get('filterValue')?.patchValue(val.split(',').map(d => parseInt(d, 10)));
|
||||||
@ -270,6 +281,16 @@ export class MetadataFilterRowComponent implements OnInit {
|
|||||||
return;
|
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)) {
|
if (DropdownFields.includes(inputVal)) {
|
||||||
let comps = [...DropdownComparisons];
|
let comps = [...DropdownComparisons];
|
||||||
if (DropdownFieldsThatIncludeNumberComparisons.includes(inputVal)) {
|
if (DropdownFieldsThatIncludeNumberComparisons.includes(inputVal)) {
|
||||||
|
@ -62,6 +62,8 @@ export class FilterFieldPipe implements PipeTransform {
|
|||||||
return translate('filter-field-pipe.path');
|
return translate('filter-field-pipe.path');
|
||||||
case FilterField.FilePath:
|
case FilterField.FilePath:
|
||||||
return translate('filter-field-pipe.file-path');
|
return translate('filter-field-pipe.file-path');
|
||||||
|
case FilterField.WantToRead:
|
||||||
|
return translate('filter-field-pipe.want-to-read');
|
||||||
default:
|
default:
|
||||||
throw new Error(`Invalid FilterField value: ${value}`);
|
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.
|
* The number of statements that can be on the filter. Set to 1 to disable adding more.
|
||||||
*/
|
*/
|
||||||
statementLimit: number = 0;
|
statementLimit: number = 0;
|
||||||
|
saveDisabled: boolean = false;
|
||||||
}
|
}
|
||||||
|
@ -48,6 +48,11 @@
|
|||||||
<option *ngFor="let field of allSortFields" [value]="field">{{field | sortField}}</option>
|
<option *ngFor="let field of allSortFields" [value]="field">{{field | sortField}}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
<ng-container *ngIf="utilityService.getActiveBreakpoint() > Breakpoint.Tablet" [ngTemplateOutlet]="buttons"></ng-container>
|
||||||
</div>
|
</div>
|
||||||
<div class="row mb-3" *ngIf="utilityService.getActiveBreakpoint() <= Breakpoint.Tablet">
|
<div class="row mb-3" *ngIf="utilityService.getActiveBreakpoint() <= Breakpoint.Tablet">
|
||||||
@ -58,12 +63,18 @@
|
|||||||
</ng-template>
|
</ng-template>
|
||||||
<ng-template #buttons>
|
<ng-template #buttons>
|
||||||
<!-- TODO: I might want to put a Clear button which blanks out the whole filter -->
|
<!-- 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>
|
<button class="btn btn-secondary col-12" (click)="clear()">{{t('reset')}}</button>
|
||||||
</div>
|
</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>
|
<button class="btn btn-primary col-12" (click)="apply()">{{t('apply')}}</button>
|
||||||
</div>
|
</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-template>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
|
@ -11,8 +11,7 @@ import {
|
|||||||
Output
|
Output
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import {FormControl, FormGroup, FormsModule, ReactiveFormsModule} from '@angular/forms';
|
import {FormControl, FormGroup, FormsModule, ReactiveFormsModule} from '@angular/forms';
|
||||||
import {NgbCollapse, NgbRating, NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
|
import {NgbCollapse, NgbModal, NgbRating, NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
|
||||||
import {FilterUtilitiesService} from '../shared/_services/filter-utilities.service';
|
|
||||||
import {Breakpoint, UtilityService} from '../shared/_services/utility.service';
|
import {Breakpoint, UtilityService} from '../shared/_services/utility.service';
|
||||||
import {Library} from '../_models/library';
|
import {Library} from '../_models/library';
|
||||||
import {allSortFields, FilterEvent, FilterItem, SortField} from '../_models/metadata/series-filter';
|
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 {TypeaheadComponent} from '../typeahead/_components/typeahead.component';
|
||||||
import {DrawerComponent} from '../shared/drawer/drawer.component';
|
import {DrawerComponent} from '../shared/drawer/drawer.component';
|
||||||
import {AsyncPipe, NgForOf, NgIf, NgTemplateOutlet} from '@angular/common';
|
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 {SortFieldPipe} from "../pipe/sort-field.pipe";
|
||||||
import {MetadataBuilderComponent} from "./_components/metadata-builder/metadata-builder.component";
|
import {MetadataBuilderComponent} from "./_components/metadata-builder/metadata-builder.component";
|
||||||
import {allFields} from "../_models/metadata/v2/filter-field";
|
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({
|
@Component({
|
||||||
selector: 'app-metadata-filter',
|
selector: 'app-metadata-filter',
|
||||||
@ -81,9 +84,10 @@ export class MetadataFilterComponent implements OnInit {
|
|||||||
|
|
||||||
|
|
||||||
private readonly cdRef = inject(ChangeDetectorRef);
|
private readonly cdRef = inject(ChangeDetectorRef);
|
||||||
|
private readonly toastr = inject(ToastrService);
|
||||||
|
|
||||||
|
|
||||||
constructor(public toggleService: ToggleService) {}
|
constructor(public toggleService: ToggleService, private filterService: FilterService) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
if (this.filterSettings === undefined) {
|
if (this.filterSettings === undefined) {
|
||||||
@ -141,7 +145,8 @@ export class MetadataFilterComponent implements OnInit {
|
|||||||
|
|
||||||
this.sortGroup = new FormGroup({
|
this.sortGroup = new FormGroup({
|
||||||
sortField: new FormControl({value: this.filterV2?.sortOptions?.sortField || SortField.SortName, disabled: this.filterSettings.sortDisabled}, []),
|
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(() => {
|
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!.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!.limitTo = Math.max(parseInt(this.sortGroup.get('limitTo')?.value || '0', 10), 0);
|
||||||
|
this.filterV2!.name = this.sortGroup.get('name')?.value || '';
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -190,6 +196,15 @@ export class MetadataFilterComponent implements OnInit {
|
|||||||
this.cdRef.markForCheck();
|
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() {
|
toggleSelected() {
|
||||||
this.toggleService.toggle();
|
this.toggleService.toggle();
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
|
@ -41,7 +41,7 @@
|
|||||||
|
|
||||||
<button class="btn btn-icon float-end" (click)="removeItem(item, i)" *ngIf="showRemoveButton">
|
<button class="btn btn-icon float-end" (click)="removeItem(item, i)" *ngIf="showRemoveButton">
|
||||||
<i class="fa fa-times" aria-hidden="true"></i>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,28 +1,27 @@
|
|||||||
.example-list {
|
.example-list {
|
||||||
min-width: 500px;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
min-height: 60px;
|
min-height: 60px;
|
||||||
display: block;
|
display: block;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.example-box {
|
.example-box {
|
||||||
margin: 5px 0;
|
margin: 5px 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
max-height: 140px;
|
|
||||||
height: 140px;
|
|
||||||
|
|
||||||
.drag-handle {
|
.drag-handle {
|
||||||
cursor: move;
|
cursor: move;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
|
// TODO: This needs to be calculation based
|
||||||
margin-top: 215%;
|
margin-top: 215%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.cdk-drag-preview {
|
.cdk-drag-preview {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@ -30,19 +29,20 @@
|
|||||||
0 8px 10px 1px rgba(0, 0, 0, 0.14),
|
0 8px 10px 1px rgba(0, 0, 0, 0.14),
|
||||||
0 3px 14px 2px rgba(0, 0, 0, 0.12);
|
0 3px 14px 2px rgba(0, 0, 0, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cdk-drag-placeholder {
|
.cdk-drag-placeholder {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cdk-drag-animating {
|
.cdk-drag-animating {
|
||||||
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
|
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.example-box:last-child {
|
.example-box:last-child {
|
||||||
border: none;
|
border: none;
|
||||||
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.example-list.cdk-drop-list-dragging .example-box:not(.cdk-drag-placeholder) {
|
.example-list.cdk-drop-list-dragging .example-box:not(.cdk-drag-placeholder) {
|
||||||
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
|
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
@ -70,4 +70,4 @@
|
|||||||
|
|
||||||
virtual-scroller.empty {
|
virtual-scroller.empty {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
@ -178,7 +178,7 @@ export class ReadingListDetailComponent implements OnInit {
|
|||||||
|
|
||||||
orderUpdated(event: IndexUpdateEvent) {
|
orderUpdated(event: IndexUpdateEvent) {
|
||||||
if (!this.readingList) return;
|
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) {
|
itemRemoved(item: ReadingListItem, position: number) {
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<ng-container *transloco="let t; read: 'reading-list-item'">
|
<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">
|
<div class="pe-2">
|
||||||
<app-image width="106px" maxHeight="125px" class="img-top me-3" [imageUrl]="imageService.getChapterCoverImage(item.chapterId)"></app-image>
|
<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">
|
<ng-container *ngIf="item.pagesRead === 0 && item.pagesTotal > 0">
|
||||||
|
@ -1,5 +1,10 @@
|
|||||||
$image-height: 125px;
|
$image-height: 125px;
|
||||||
|
|
||||||
|
.reading-list-item {
|
||||||
|
max-height: 140px;
|
||||||
|
height: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
.progress-banner {
|
.progress-banner {
|
||||||
height: 5px;
|
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 {
|
.badge-container {
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
display: block;
|
display: block;
|
||||||
@ -34,4 +33,4 @@ $image-height: 125px;
|
|||||||
border-style: solid;
|
border-style: solid;
|
||||||
border-width: 0 var(--card-progress-triangle-size) var(--card-progress-triangle-size) 0;
|
border-width: 0 var(--card-progress-triangle-size) var(--card-progress-triangle-size) 0;
|
||||||
border-color: transparent var(--primary-color) transparent transparent;
|
border-color: transparent var(--primary-color) transparent transparent;
|
||||||
}
|
}
|
||||||
|
@ -107,6 +107,49 @@ export class FilterUtilitiesService {
|
|||||||
}).join(','));
|
}).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 {
|
filterPresetsFromUrlV2(snapshot: ActivatedRouteSnapshot): SeriesFilterV2 {
|
||||||
const filter = this.metadataService.createDefaultFilterDto();
|
const filter = this.metadataService.createDefaultFilterDto();
|
||||||
if (!window.location.href.includes('?')) return filter;
|
if (!window.location.href.includes('?')) return filter;
|
||||||
|
@ -2,4 +2,8 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
white-space: pre-wrap;
|
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'">
|
<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">
|
<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/">
|
<!-- <app-side-nav-item icon="fa-user-circle align-self-center phone-hidden" [title]="user.username | sentenceCase" link="/preferences/">-->
|
||||||
<ng-container actions>
|
<!-- <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>-->
|
||||||
<a href="/preferences/" title="User Settings"><span class="visually-hidden">User Settings</span></a>
|
<!-- </ng-container>-->
|
||||||
</ng-container>
|
<!-- </app-side-nav-item>-->
|
||||||
</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-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" [title]="t('collections')" link="/collections/"></app-side-nav-item>
|
||||||
<app-side-nav-item icon="fa-list-ol" [title]="t('reading-lists')" link="/lists/">
|
<app-side-nav-item icon="fa-list-ol" [title]="t('reading-lists')" link="/lists/">
|
||||||
<ng-container actions>
|
<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>
|
</ng-container>
|
||||||
</app-side-nav-item>
|
</app-side-nav-item>
|
||||||
<app-side-nav-item icon="fa-bookmark" [title]="t('bookmarks')" link="/bookmarks/"></app-side-nav-item>
|
<app-side-nav-item icon="fa-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 {FormsModule} from "@angular/forms";
|
||||||
import {TranslocoDirective} from "@ngneat/transloco";
|
import {TranslocoDirective} from "@ngneat/transloco";
|
||||||
import {CardActionablesComponent} from "../../../_single-module/card-actionables/card-actionables.component";
|
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({
|
@Component({
|
||||||
selector: 'app-side-nav',
|
selector: 'app-side-nav',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, SideNavItemComponent, CardActionablesComponent, FilterPipe, FormsModule, TranslocoDirective],
|
imports: [CommonModule, SideNavItemComponent, CardActionablesComponent, FilterPipe, FormsModule, TranslocoDirective, SentenceCasePipe],
|
||||||
templateUrl: './side-nav.component.html',
|
templateUrl: './side-nav.component.html',
|
||||||
styleUrls: ['./side-nav.component.scss'],
|
styleUrls: ['./side-nav.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
@ -43,6 +45,7 @@ export class SideNavComponent implements OnInit {
|
|||||||
libraries: Library[] = [];
|
libraries: Library[] = [];
|
||||||
actions: ActionItem<Library>[] = [];
|
actions: ActionItem<Library>[] = [];
|
||||||
readingListActions = [{action: Action.Import, title: 'import-cbl', children: [], requiresAdmin: true, callback: this.importCbl.bind(this)}];
|
readingListActions = [{action: Action.Import, title: 'import-cbl', children: [], requiresAdmin: true, callback: this.importCbl.bind(this)}];
|
||||||
|
homeActions = [{action: Action.Edit, title: 'customize', children: [], requiresAdmin: false, callback: this.handleHomeActions.bind(this)}];
|
||||||
filterQuery: string = '';
|
filterQuery: string = '';
|
||||||
filterLibrary = (library: Library) => {
|
filterLibrary = (library: Library) => {
|
||||||
return library.name.toLowerCase().indexOf((this.filterQuery || '').toLowerCase()) >= 0;
|
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() {
|
importCbl() {
|
||||||
this.ngbModal.open(ImportCblModalComponent, {size: 'xl'});
|
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 {StatListComponent} from '../stat-list/stat-list.component';
|
||||||
import {IconAndTitleComponent} from '../../../shared/icon-and-title/icon-and-title.component';
|
import {IconAndTitleComponent} from '../../../shared/icon-and-title/icon-and-title.component';
|
||||||
import {AsyncPipe, DecimalPipe, NgIf} from '@angular/common';
|
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 {FilterComparison} from "../../../_models/metadata/v2/filter-comparison";
|
||||||
import {FilterField} from "../../../_models/metadata/v2/filter-field";
|
import {FilterField} from "../../../_models/metadata/v2/filter-field";
|
||||||
|
|
||||||
@ -62,8 +62,6 @@ export class ServerStatsComponent {
|
|||||||
this.breakpointSubject.next(this.utilityService.getActiveBreakpoint());
|
this.breakpointSubject.next(this.utilityService.getActiveBreakpoint());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
translocoService = inject(TranslocoService);
|
|
||||||
get Breakpoint() { return Breakpoint; }
|
get Breakpoint() { return Breakpoint; }
|
||||||
|
|
||||||
constructor(private statService: StatisticsService, private router: Router, private imageService: ImageService,
|
constructor(private statService: StatisticsService, private router: Router, private imageService: ImageService,
|
||||||
@ -115,7 +113,7 @@ export class ServerStatsComponent {
|
|||||||
this.metadataService.getAllGenres().subscribe(genres => {
|
this.metadataService.getAllGenres().subscribe(genres => {
|
||||||
const ref = this.modalService.open(GenericListModalComponent, { scrollable: true });
|
const ref = this.modalService.open(GenericListModalComponent, { scrollable: true });
|
||||||
ref.componentInstance.items = genres.map(t => t.title);
|
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) => {
|
ref.componentInstance.clicked = (item: string) => {
|
||||||
this.filterUtilityService.applyFilter(['all-series'], FilterField.Genres, FilterComparison.Contains, genres.filter(g => g.title === item)[0].id + '');
|
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 => {
|
this.metadataService.getAllTags().subscribe(tags => {
|
||||||
const ref = this.modalService.open(GenericListModalComponent, { scrollable: true });
|
const ref = this.modalService.open(GenericListModalComponent, { scrollable: true });
|
||||||
ref.componentInstance.items = tags.map(t => t.title);
|
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) => {
|
ref.componentInstance.clicked = (item: string) => {
|
||||||
this.filterUtilityService.applyFilter(['all-series'], FilterField.Tags, FilterComparison.Contains, tags.filter(g => g.title === item)[0].id + '');
|
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 => {
|
this.metadataService.getAllPeople().subscribe(people => {
|
||||||
const ref = this.modalService.open(GenericListModalComponent, { scrollable: true });
|
const ref = this.modalService.open(GenericListModalComponent, { scrollable: true });
|
||||||
ref.componentInstance.items = [...new Set(people.map(person => person.name))];
|
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 {inject, Pipe, PipeTransform} from '@angular/core';
|
||||||
import { DayOfWeek } from 'src/app/_services/statistics.service';
|
import { DayOfWeek } from 'src/app/_services/statistics.service';
|
||||||
import {TranslocoService} from "@ngneat/transloco";
|
import {translate, TranslocoService} from "@ngneat/transloco";
|
||||||
|
|
||||||
@Pipe({
|
@Pipe({
|
||||||
name: 'dayOfWeek',
|
name: 'dayOfWeek',
|
||||||
@ -8,24 +8,22 @@ import {TranslocoService} from "@ngneat/transloco";
|
|||||||
})
|
})
|
||||||
export class DayOfWeekPipe implements PipeTransform {
|
export class DayOfWeekPipe implements PipeTransform {
|
||||||
|
|
||||||
translocoService = inject(TranslocoService);
|
|
||||||
|
|
||||||
transform(value: DayOfWeek): string {
|
transform(value: DayOfWeek): string {
|
||||||
switch(value) {
|
switch(value) {
|
||||||
case DayOfWeek.Monday:
|
case DayOfWeek.Monday:
|
||||||
return this.translocoService.translate('day-of-week-pipe.monday');
|
return translate('day-of-week-pipe.monday');
|
||||||
case DayOfWeek.Tuesday:
|
case DayOfWeek.Tuesday:
|
||||||
return this.translocoService.translate('day-of-week-pipe.tuesday');
|
return translate('day-of-week-pipe.tuesday');
|
||||||
case DayOfWeek.Wednesday:
|
case DayOfWeek.Wednesday:
|
||||||
return this.translocoService.translate('day-of-week-pipe.wednesday');
|
return translate('day-of-week-pipe.wednesday');
|
||||||
case DayOfWeek.Thursday:
|
case DayOfWeek.Thursday:
|
||||||
return this.translocoService.translate('day-of-week-pipe.thursday');
|
return translate('day-of-week-pipe.thursday');
|
||||||
case DayOfWeek.Friday:
|
case DayOfWeek.Friday:
|
||||||
return this.translocoService.translate('day-of-week-pipe.friday');
|
return translate('day-of-week-pipe.friday');
|
||||||
case DayOfWeek.Saturday:
|
case DayOfWeek.Saturday:
|
||||||
return this.translocoService.translate('day-of-week-pipe.saturday');
|
return translate('day-of-week-pipe.saturday');
|
||||||
case DayOfWeek.Sunday:
|
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">
|
<ng-container *ngIf="tab.fragment === FragmentID.Stats">
|
||||||
<app-user-stats></app-user-stats>
|
<app-user-stats></app-user-stats>
|
||||||
</ng-container>
|
</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">
|
<ng-container *ngIf="tab.fragment === FragmentID.Scrobbling">
|
||||||
<app-user-scrobble-history></app-user-scrobble-history>
|
<app-user-scrobble-history></app-user-scrobble-history>
|
||||||
<app-user-holds></app-user-holds>
|
<app-user-holds></app-user-holds>
|
||||||
|
@ -49,6 +49,7 @@ import { SideNavCompanionBarComponent } from '../../sidenav/_components/side-nav
|
|||||||
import {LocalizationService} from "../../_services/localization.service";
|
import {LocalizationService} from "../../_services/localization.service";
|
||||||
import {Language} from "../../_models/metadata/language";
|
import {Language} from "../../_models/metadata/language";
|
||||||
import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco";
|
import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco";
|
||||||
|
import {ManageSmartFiltersComponent} from "../manage-smart-filters/manage-smart-filters.component";
|
||||||
|
|
||||||
enum AccordionPanelID {
|
enum AccordionPanelID {
|
||||||
ImageReader = 'image-reader',
|
ImageReader = 'image-reader',
|
||||||
@ -63,6 +64,7 @@ enum FragmentID {
|
|||||||
Theme = 'theme',
|
Theme = 'theme',
|
||||||
Devices = 'devices',
|
Devices = 'devices',
|
||||||
Stats = 'stats',
|
Stats = 'stats',
|
||||||
|
SmartFilters = 'smart-filters',
|
||||||
Scrobbling = 'scrobbling'
|
Scrobbling = 'scrobbling'
|
||||||
|
|
||||||
}
|
}
|
||||||
@ -76,7 +78,8 @@ enum FragmentID {
|
|||||||
imports: [SideNavCompanionBarComponent, NgbNav, NgFor, NgbNavItem, NgbNavItemRole, NgbNavLink, RouterLink, NgbNavContent, NgIf, ChangeEmailComponent,
|
imports: [SideNavCompanionBarComponent, NgbNav, NgFor, NgbNavItem, NgbNavItemRole, NgbNavLink, RouterLink, NgbNavContent, NgIf, ChangeEmailComponent,
|
||||||
ChangePasswordComponent, ChangeAgeRestrictionComponent, AnilistKeyComponent, ReactiveFormsModule, NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader,
|
ChangePasswordComponent, ChangeAgeRestrictionComponent, AnilistKeyComponent, ReactiveFormsModule, NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader,
|
||||||
NgbAccordionToggle, NgbAccordionButton, NgbCollapse, NgbAccordionCollapse, NgbAccordionBody, NgbTooltip, NgTemplateOutlet, ColorPickerModule, ApiKeyComponent,
|
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 {
|
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: '3rd-party-clients-tab', fragment: FragmentID.Clients},
|
||||||
{title: 'theme-tab', fragment: FragmentID.Theme},
|
{title: 'theme-tab', fragment: FragmentID.Theme},
|
||||||
{title: 'devices-tab', fragment: FragmentID.Devices},
|
{title: 'devices-tab', fragment: FragmentID.Devices},
|
||||||
|
{title: 'smart-filters-tab', fragment: FragmentID.SmartFilters},
|
||||||
{title: 'stats-tab', fragment: FragmentID.Stats},
|
{title: 'stats-tab', fragment: FragmentID.Stats},
|
||||||
];
|
];
|
||||||
locales: Array<Language> = [{title: 'English', isoCode: 'en'}];
|
locales: Array<Language> = [{title: 'English', isoCode: 'en'}];
|
||||||
@ -115,7 +119,6 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
|
|||||||
opdsUrl: string = '';
|
opdsUrl: string = '';
|
||||||
makeUrl: (val: string) => string = (val: string) => { return this.opdsUrl; };
|
makeUrl: (val: string) => string = (val: string) => { return this.opdsUrl; };
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
private readonly trasnlocoService = inject(TranslocoService);
|
|
||||||
|
|
||||||
get AccordionPanelID() {
|
get AccordionPanelID() {
|
||||||
return AccordionPanelID;
|
return AccordionPanelID;
|
||||||
@ -304,7 +307,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.observableHandles.push(this.accountService.updatePreferences(data).subscribe((updatedPrefs) => {
|
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) {
|
if (this.user) {
|
||||||
this.user.preferences = updatedPrefs;
|
this.user.preferences = updatedPrefs;
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
|
@ -14,7 +14,8 @@
|
|||||||
"not-granted": "You haven't been granted access to any libraries.",
|
"not-granted": "You haven't been granted access to any libraries.",
|
||||||
"on-deck-title": "On Deck",
|
"on-deck-title": "On Deck",
|
||||||
"recently-updated-title": "Recently Updated Series",
|
"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": {
|
"edit-user": {
|
||||||
@ -98,6 +99,7 @@
|
|||||||
"devices-tab": "Devices",
|
"devices-tab": "Devices",
|
||||||
"stats-tab": "Stats",
|
"stats-tab": "Stats",
|
||||||
"scrobbling-tab": "Scrobbling",
|
"scrobbling-tab": "Scrobbling",
|
||||||
|
"smart-filters-tab": "Smart Filters",
|
||||||
"success-toast": "User preferences updated",
|
"success-toast": "User preferences updated",
|
||||||
|
|
||||||
"global-settings-title": "Global Settings",
|
"global-settings-title": "Global Settings",
|
||||||
@ -1324,6 +1326,13 @@
|
|||||||
"read": "{{common.read}}"
|
"read": "{{common.read}}"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"stream-list-item": {
|
||||||
|
"remove": "{{common.remove}}",
|
||||||
|
"load-filter": "Load Filter",
|
||||||
|
"provided": "Provided",
|
||||||
|
"smart-filter": "Smart Filter"
|
||||||
|
},
|
||||||
|
|
||||||
"reading-list-detail": {
|
"reading-list-detail": {
|
||||||
"item-count": "{{common.item-count}}",
|
"item-count": "{{common.item-count}}",
|
||||||
"page-settings-title": "Page Settings",
|
"page-settings-title": "Page Settings",
|
||||||
@ -1494,10 +1503,12 @@
|
|||||||
"metadata-filter": {
|
"metadata-filter": {
|
||||||
"filter-title": "Filter",
|
"filter-title": "Filter",
|
||||||
"sort-by-label": "Sort By",
|
"sort-by-label": "Sort By",
|
||||||
|
"filter-name-label": "Filter Name",
|
||||||
"ascending-alt": "Ascending",
|
"ascending-alt": "Ascending",
|
||||||
"descending-alt": "Descending",
|
"descending-alt": "Descending",
|
||||||
"reset": "{{common.reset}}",
|
"reset": "{{common.reset}}",
|
||||||
"apply": "{{common.apply}}",
|
"apply": "{{common.apply}}",
|
||||||
|
"save": "{{common.save}}",
|
||||||
"limit-label": "Limit To",
|
"limit-label": "Limit To",
|
||||||
|
|
||||||
"format-label": "Format",
|
"format-label": "Format",
|
||||||
@ -1707,6 +1718,12 @@
|
|||||||
"remove-rule": "Remove Row"
|
"remove-rule": "Remove Row"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"customize-dashboard-modal": {
|
||||||
|
"title": "Customize Dashboard",
|
||||||
|
"close": "{{common.close}}",
|
||||||
|
"save": "{{common.save}}"
|
||||||
|
},
|
||||||
|
|
||||||
"filter-field-pipe": {
|
"filter-field-pipe": {
|
||||||
"age-rating": "Age Rating",
|
"age-rating": "Age Rating",
|
||||||
"characters": "Characters",
|
"characters": "Characters",
|
||||||
@ -1733,7 +1750,8 @@
|
|||||||
"user-rating": "User Rating",
|
"user-rating": "User Rating",
|
||||||
"writers": "Writers",
|
"writers": "Writers",
|
||||||
"path": "Path",
|
"path": "Path",
|
||||||
"file-path": "File Path"
|
"file-path": "File Path",
|
||||||
|
"want-to-read": "Want to Read"
|
||||||
},
|
},
|
||||||
|
|
||||||
"filter-comparison-pipe": {
|
"filter-comparison-pipe": {
|
||||||
@ -1755,6 +1773,8 @@
|
|||||||
"must-contains": "Must Contains"
|
"must-contains": "Must Contains"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"regen-cover": "A job has been enqueued to regenerate the cover image",
|
"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.",
|
"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-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-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?",
|
"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": {
|
"actionable": {
|
||||||
@ -1861,8 +1884,8 @@
|
|||||||
"read": "Read",
|
"read": "Read",
|
||||||
"add-rule-group-and": "Add Rule Group (AND)",
|
"add-rule-group-and": "Add Rule Group (AND)",
|
||||||
"add-rule-group-or": "Add Rule Group (OR)",
|
"add-rule-group-or": "Add Rule Group (OR)",
|
||||||
"remove-rule-group": "Remove Rule Group"
|
"remove-rule-group": "Remove Rule Group",
|
||||||
|
"customize": "Customize"
|
||||||
},
|
},
|
||||||
|
|
||||||
"preferences": {
|
"preferences": {
|
||||||
|
493
openapi.json
493
openapi.json
@ -7,7 +7,7 @@
|
|||||||
"name": "GPL-3.0",
|
"name": "GPL-3.0",
|
||||||
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
|
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
|
||||||
},
|
},
|
||||||
"version": "0.7.8.0"
|
"version": "0.7.8.2"
|
||||||
},
|
},
|
||||||
"servers": [
|
"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": {
|
"/api/Admin/exists": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@ -1931,51 +2087,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/Filter": {
|
"/api/Filter/update": {
|
||||||
"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": {
|
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
"Filter"
|
"Filter"
|
||||||
],
|
],
|
||||||
"summary": "Caches the filter in the backend and returns a temp string for retrieving.",
|
"summary": "Creates or Updates the filter",
|
||||||
"description": "The cache line lives for only 1 hour",
|
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"description": "",
|
"description": "",
|
||||||
"content": {
|
"content": {
|
||||||
@ -1996,28 +2113,69 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/Filter": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Filter"
|
||||||
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Success",
|
"description": "Success",
|
||||||
"content": {
|
"content": {
|
||||||
"text/plain": {
|
"text/plain": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/SmartFilterDto"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/SmartFilterDto"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"text/json": {
|
"text/json": {
|
||||||
"schema": {
|
"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": {
|
"/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": {
|
"/api/Opds/{apiKey}/libraries": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@ -11552,6 +11764,22 @@
|
|||||||
"description": "A list of Series the user doesn't want scrobbling for",
|
"description": "A list of Series the user doesn't want scrobbling for",
|
||||||
"nullable": true
|
"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": {
|
"rowVersion": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"format": "int32",
|
"format": "int32",
|
||||||
@ -11612,6 +11840,54 @@
|
|||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"description": "Represents a saved page in a Chapter entity for a given user."
|
"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": {
|
"AppUserPreferences": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@ -11930,6 +12206,33 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": false
|
"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": {
|
"AppUserTableOfContent": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@ -13184,6 +13487,54 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": false
|
"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": {
|
"DateTimePagesReadOnADayCount": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@ -13790,7 +14141,8 @@
|
|||||||
22,
|
22,
|
||||||
23,
|
23,
|
||||||
24,
|
24,
|
||||||
25
|
25,
|
||||||
|
26
|
||||||
],
|
],
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"description": "Represents the field which will dictate the value type and the Extension used for filtering",
|
"description": "Represents the field which will dictate the value type and the Extension used for filtering",
|
||||||
@ -13806,6 +14158,11 @@
|
|||||||
"FilterV2Dto": {
|
"FilterV2Dto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Not used in the UI.",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "The name of the filter",
|
"description": "The name of the filter",
|
||||||
@ -17056,6 +17413,25 @@
|
|||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"description": "Represents a set of css overrides the user can upload to Kavita and will load into webui"
|
"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": {
|
"SortOptions": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@ -17066,7 +17442,8 @@
|
|||||||
3,
|
3,
|
||||||
4,
|
4,
|
||||||
5,
|
5,
|
||||||
6
|
6,
|
||||||
|
7
|
||||||
],
|
],
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"format": "int32"
|
"format": "int32"
|
||||||
@ -17212,6 +17589,28 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": false
|
"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": {
|
"UpdateDefaultThemeDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user