mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-07-31 14:33:50 -04:00
Linked Series (#1230)
* Implemented the ability to link different series together through Edit Series. CSS pending. * Fixed up the css for related cards to show the relation * Working on making all tabs in edit seris modal save in one go. Taking a break. * Some fixes for Robbie to help with styling on * Linked series pill, center library * Centering library detail and related pill spacing - Library detail cards are now centered if total number of items is > 6 or if mobile. - Added ability to determine if mobile (viewport width <= 480px - Fixed related card spacing - Fixed related card pill spacing * Updating relation form spacing * Fixed a bug in card detail layout when there is no pagination, we create one in a way that all items render at once. * Only auto-close side nav on phones, not tablets * Fixed a bug where we had flipped state on sideNavCollapsed$ * Cleaned up some misleading comments * Implemented RBS back in and now if you have a relationship besides prequel/sequel, the target series will show a link back to it's parent. * Added Parentto pipe * Missed a relationship type Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
This commit is contained in:
parent
7253765f1d
commit
4206ae3e22
@ -14,4 +14,11 @@ public class ChapterSortComparerZeroFirstTests
|
|||||||
{
|
{
|
||||||
Assert.Equal(expected, input.OrderBy(f => f, new ChapterSortComparerZeroFirst()).ToArray());
|
Assert.Equal(expected, input.OrderBy(f => f, new ChapterSortComparerZeroFirst()).ToArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(new[] {1.0, 0.5, 0.3}, new[] {0.3, 0.5, 1.0})]
|
||||||
|
public void ChapterSortComparerZeroFirstTest_Doubles(double[] input, double[] expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, input.OrderBy(f => f, new ChapterSortComparerZeroFirst()).ToArray());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -11,6 +11,10 @@ public class NumericComparerTests
|
|||||||
new[] {"x1.jpg", "x10.jpg", "x3.jpg", "x4.jpg", "x11.jpg"},
|
new[] {"x1.jpg", "x10.jpg", "x3.jpg", "x4.jpg", "x11.jpg"},
|
||||||
new[] {"x1.jpg", "x3.jpg", "x4.jpg", "x10.jpg", "x11.jpg"}
|
new[] {"x1.jpg", "x3.jpg", "x4.jpg", "x10.jpg", "x11.jpg"}
|
||||||
)]
|
)]
|
||||||
|
[InlineData(
|
||||||
|
new[] {"x1.0.jpg", "0.5.jpg", "0.3.jpg"},
|
||||||
|
new[] {"0.3.jpg", "0.5.jpg", "x1.0.jpg",}
|
||||||
|
)]
|
||||||
public void NumericComparerTest(string[] input, string[] expected)
|
public void NumericComparerTest(string[] input, string[] expected)
|
||||||
{
|
{
|
||||||
var nc = new NumericComparer();
|
var nc = new NumericComparer();
|
||||||
|
@ -125,5 +125,6 @@ namespace API.Tests.Services
|
|||||||
// }
|
// }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -6,8 +6,10 @@ using API.Data;
|
|||||||
using API.Data.Repositories;
|
using API.Data.Repositories;
|
||||||
using API.DTOs;
|
using API.DTOs;
|
||||||
using API.DTOs.Filtering;
|
using API.DTOs.Filtering;
|
||||||
|
using API.DTOs.SeriesDetail;
|
||||||
using API.Entities;
|
using API.Entities;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
|
using API.Entities.Metadata;
|
||||||
using API.Extensions;
|
using API.Extensions;
|
||||||
using API.Helpers;
|
using API.Helpers;
|
||||||
using API.Services;
|
using API.Services;
|
||||||
@ -339,5 +341,79 @@ namespace API.Controllers
|
|||||||
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
|
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
|
||||||
return await _seriesService.GetSeriesDetail(seriesId, userId);
|
return await _seriesService.GetSeriesDetail(seriesId, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fetches the related series for a given series
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="seriesId"></param>
|
||||||
|
/// <param name="relation">Type of Relationship to pull back</param>
|
||||||
|
/// <returns></returns>
|
||||||
|
[HttpGet("related")]
|
||||||
|
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetRelatedSeries(int seriesId, RelationKind relation)
|
||||||
|
{
|
||||||
|
// Send back a custom DTO with each type or maybe sorted in some way
|
||||||
|
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
|
||||||
|
return Ok(await _unitOfWork.SeriesRepository.GetSeriesForRelationKind(userId, seriesId, relation));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("all-related")]
|
||||||
|
public async Task<ActionResult<RelatedSeriesDto>> GetAllRelatedSeries(int seriesId)
|
||||||
|
{
|
||||||
|
// Send back a custom DTO with each type or maybe sorted in some way
|
||||||
|
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
|
||||||
|
return Ok(await _unitOfWork.SeriesRepository.GetRelatedSeries(userId, seriesId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy="RequireAdminRole")]
|
||||||
|
[HttpPost("update-related")]
|
||||||
|
public async Task<ActionResult> UpdateRelatedSeries(UpdateRelatedSeriesDto dto)
|
||||||
|
{
|
||||||
|
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(dto.SeriesId, SeriesIncludes.Related);
|
||||||
|
|
||||||
|
UpdateRelationForKind(dto.Adaptations, series.Relations.Where(r => r.RelationKind == RelationKind.Adaptation).ToList(), series, RelationKind.Adaptation);
|
||||||
|
UpdateRelationForKind(dto.Characters, series.Relations.Where(r => r.RelationKind == RelationKind.Character).ToList(), series, RelationKind.Character);
|
||||||
|
UpdateRelationForKind(dto.Contains, series.Relations.Where(r => r.RelationKind == RelationKind.Contains).ToList(), series, RelationKind.Contains);
|
||||||
|
UpdateRelationForKind(dto.Others, series.Relations.Where(r => r.RelationKind == RelationKind.Other).ToList(), series, RelationKind.Other);
|
||||||
|
UpdateRelationForKind(dto.SideStories, series.Relations.Where(r => r.RelationKind == RelationKind.SideStory).ToList(), series, RelationKind.SideStory);
|
||||||
|
UpdateRelationForKind(dto.SpinOffs, series.Relations.Where(r => r.RelationKind == RelationKind.SpinOff).ToList(), series, RelationKind.SpinOff);
|
||||||
|
UpdateRelationForKind(dto.AlternativeSettings, series.Relations.Where(r => r.RelationKind == RelationKind.AlternativeSetting).ToList(), series, RelationKind.AlternativeSetting);
|
||||||
|
UpdateRelationForKind(dto.AlternativeVersions, series.Relations.Where(r => r.RelationKind == RelationKind.AlternativeVersion).ToList(), series, RelationKind.AlternativeVersion);
|
||||||
|
UpdateRelationForKind(dto.Doujinshis, series.Relations.Where(r => r.RelationKind == RelationKind.Doujinshi).ToList(), series, RelationKind.Doujinshi);
|
||||||
|
UpdateRelationForKind(dto.Prequels, series.Relations.Where(r => r.RelationKind == RelationKind.Prequel).ToList(), series, RelationKind.Prequel);
|
||||||
|
UpdateRelationForKind(dto.Sequels, series.Relations.Where(r => r.RelationKind == RelationKind.Sequel).ToList(), series, RelationKind.Sequel);
|
||||||
|
|
||||||
|
if (!_unitOfWork.HasChanges()) return Ok();
|
||||||
|
if (await _unitOfWork.CommitAsync()) return Ok();
|
||||||
|
|
||||||
|
|
||||||
|
return BadRequest("There was an issue updating relationships");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateRelationForKind(IList<int> dtoTargetSeriesIds, IEnumerable<SeriesRelation> adaptations, Series series, RelationKind kind)
|
||||||
|
{
|
||||||
|
foreach (var adaptation in adaptations.Where(adaptation => !dtoTargetSeriesIds.Contains(adaptation.TargetSeriesId)))
|
||||||
|
{
|
||||||
|
// If the seriesId isn't in dto, it means we've removed or reclassified
|
||||||
|
series.Relations.Remove(adaptation);
|
||||||
|
}
|
||||||
|
|
||||||
|
// At this point, we only have things to add
|
||||||
|
foreach (var targetSeriesId in dtoTargetSeriesIds)
|
||||||
|
{
|
||||||
|
// This ensures we don't allow any duplicates to be added
|
||||||
|
if (series.Relations.SingleOrDefault(r =>
|
||||||
|
r.RelationKind == kind && r.TargetSeriesId == targetSeriesId) !=
|
||||||
|
null) continue;
|
||||||
|
|
||||||
|
series.Relations.Add(new SeriesRelation()
|
||||||
|
{
|
||||||
|
Series = series,
|
||||||
|
SeriesId = series.Id,
|
||||||
|
TargetSeriesId = targetSeriesId,
|
||||||
|
RelationKind = kind
|
||||||
|
});
|
||||||
|
_unitOfWork.SeriesRepository.Update(series);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
25
API/DTOs/SeriesDetail/RelatedSeriesDto.cs
Normal file
25
API/DTOs/SeriesDetail/RelatedSeriesDto.cs
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using API.Entities.Enums;
|
||||||
|
|
||||||
|
namespace API.DTOs.SeriesDetail;
|
||||||
|
|
||||||
|
public class RelatedSeriesDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The parent relationship Series
|
||||||
|
/// </summary>
|
||||||
|
public int SourceSeriesId { get; set; }
|
||||||
|
|
||||||
|
public IEnumerable<SeriesDto> Sequels { get; set; }
|
||||||
|
public IEnumerable<SeriesDto> Prequels { get; set; }
|
||||||
|
public IEnumerable<SeriesDto> SpinOffs { get; set; }
|
||||||
|
public IEnumerable<SeriesDto> Adaptations { get; set; }
|
||||||
|
public IEnumerable<SeriesDto> SideStories { get; set; }
|
||||||
|
public IEnumerable<SeriesDto> Characters { get; set; }
|
||||||
|
public IEnumerable<SeriesDto> Contains { get; set; }
|
||||||
|
public IEnumerable<SeriesDto> Others { get; set; }
|
||||||
|
public IEnumerable<SeriesDto> AlternativeSettings { get; set; }
|
||||||
|
public IEnumerable<SeriesDto> AlternativeVersions { get; set; }
|
||||||
|
public IEnumerable<SeriesDto> Doujinshis { get; set; }
|
||||||
|
public IEnumerable<SeriesDto> Parent { get; set; }
|
||||||
|
}
|
19
API/DTOs/SeriesDetail/UpdateRelatedSeriesDto.cs
Normal file
19
API/DTOs/SeriesDetail/UpdateRelatedSeriesDto.cs
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace API.DTOs.SeriesDetail;
|
||||||
|
|
||||||
|
public class UpdateRelatedSeriesDto
|
||||||
|
{
|
||||||
|
public int SeriesId { get; set; }
|
||||||
|
public IList<int> Adaptations { get; set; }
|
||||||
|
public IList<int> Characters { get; set; }
|
||||||
|
public IList<int> Contains { get; set; }
|
||||||
|
public IList<int> Others { get; set; }
|
||||||
|
public IList<int> Prequels { get; set; }
|
||||||
|
public IList<int> Sequels { get; set; }
|
||||||
|
public IList<int> SideStories { get; set; }
|
||||||
|
public IList<int> SpinOffs { get; set; }
|
||||||
|
public IList<int> AlternativeSettings { get; set; }
|
||||||
|
public IList<int> AlternativeVersions { get; set; }
|
||||||
|
public IList<int> Doujinshis { get; set; }
|
||||||
|
}
|
@ -41,24 +41,36 @@ namespace API.Data
|
|||||||
public DbSet<Genre> Genre { get; set; }
|
public DbSet<Genre> Genre { get; set; }
|
||||||
public DbSet<Tag> Tag { get; set; }
|
public DbSet<Tag> Tag { get; set; }
|
||||||
public DbSet<SiteTheme> SiteTheme { get; set; }
|
public DbSet<SiteTheme> SiteTheme { get; set; }
|
||||||
|
public DbSet<SeriesRelation> SeriesRelation { get; set; }
|
||||||
|
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder builder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
base.OnModelCreating(builder);
|
base.OnModelCreating(modelBuilder);
|
||||||
|
|
||||||
|
|
||||||
builder.Entity<AppUser>()
|
modelBuilder.Entity<AppUser>()
|
||||||
.HasMany(ur => ur.UserRoles)
|
.HasMany(ur => ur.UserRoles)
|
||||||
.WithOne(u => u.User)
|
.WithOne(u => u.User)
|
||||||
.HasForeignKey(ur => ur.UserId)
|
.HasForeignKey(ur => ur.UserId)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
builder.Entity<AppRole>()
|
modelBuilder.Entity<AppRole>()
|
||||||
.HasMany(ur => ur.UserRoles)
|
.HasMany(ur => ur.UserRoles)
|
||||||
.WithOne(u => u.Role)
|
.WithOne(u => u.Role)
|
||||||
.HasForeignKey(ur => ur.RoleId)
|
.HasForeignKey(ur => ur.RoleId)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
|
modelBuilder.Entity<SeriesRelation>()
|
||||||
|
.HasOne(pt => pt.Series)
|
||||||
|
.WithMany(p => p.Relations)
|
||||||
|
.HasForeignKey(pt => pt.SeriesId)
|
||||||
|
.OnDelete(DeleteBehavior.ClientCascade);
|
||||||
|
|
||||||
|
modelBuilder.Entity<SeriesRelation>()
|
||||||
|
.HasOne(pt => pt.TargetSeries)
|
||||||
|
.WithMany(t => t.RelationOf)
|
||||||
|
.HasForeignKey(pt => pt.TargetSeriesId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
1513
API/Data/Migrations/20220421214448_SeriesRelations.Designer.cs
generated
Normal file
1513
API/Data/Migrations/20220421214448_SeriesRelations.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
55
API/Data/Migrations/20220421214448_SeriesRelations.cs
Normal file
55
API/Data/Migrations/20220421214448_SeriesRelations.cs
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace API.Data.Migrations
|
||||||
|
{
|
||||||
|
public partial class SeriesRelations : Migration
|
||||||
|
{
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "SeriesRelation",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
.Annotation("Sqlite:Autoincrement", true),
|
||||||
|
RelationKind = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
TargetSeriesId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
SeriesId = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_SeriesRelation", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_SeriesRelation_Series_SeriesId",
|
||||||
|
column: x => x.SeriesId,
|
||||||
|
principalTable: "Series",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_SeriesRelation_Series_TargetSeriesId",
|
||||||
|
column: x => x.TargetSeriesId,
|
||||||
|
principalTable: "Series",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_SeriesRelation_SeriesId",
|
||||||
|
table: "SeriesRelation",
|
||||||
|
column: "SeriesId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_SeriesRelation_TargetSeriesId",
|
||||||
|
table: "SeriesRelation",
|
||||||
|
column: "TargetSeriesId");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "SeriesRelation");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -592,6 +592,30 @@ namespace API.Data.Migrations
|
|||||||
b.ToTable("SeriesMetadata");
|
b.ToTable("SeriesMetadata");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("RelationKind")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("SeriesId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("TargetSeriesId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("SeriesId");
|
||||||
|
|
||||||
|
b.HasIndex("TargetSeriesId");
|
||||||
|
|
||||||
|
b.ToTable("SeriesRelation");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.Person", b =>
|
modelBuilder.Entity("API.Entities.Person", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@ -1182,6 +1206,25 @@ namespace API.Data.Migrations
|
|||||||
b.Navigation("Series");
|
b.Navigation("Series");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("API.Entities.Series", "Series")
|
||||||
|
.WithMany("Relations")
|
||||||
|
.HasForeignKey("SeriesId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("API.Entities.Series", "TargetSeries")
|
||||||
|
.WithMany("RelationOf")
|
||||||
|
.HasForeignKey("TargetSeriesId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Series");
|
||||||
|
|
||||||
|
b.Navigation("TargetSeries");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.ReadingList", b =>
|
modelBuilder.Entity("API.Entities.ReadingList", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("API.Entities.AppUser", "AppUser")
|
b.HasOne("API.Entities.AppUser", "AppUser")
|
||||||
@ -1451,6 +1494,10 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.Navigation("Ratings");
|
b.Navigation("Ratings");
|
||||||
|
|
||||||
|
b.Navigation("RelationOf");
|
||||||
|
|
||||||
|
b.Navigation("Relations");
|
||||||
|
|
||||||
b.Navigation("Volumes");
|
b.Navigation("Volumes");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -84,7 +84,6 @@ public class CollectionTagRepository : ICollectionTagRepository
|
|||||||
public async Task<IEnumerable<CollectionTagDto>> GetAllTagDtosAsync()
|
public async Task<IEnumerable<CollectionTagDto>> GetAllTagDtosAsync()
|
||||||
{
|
{
|
||||||
return await _context.CollectionTag
|
return await _context.CollectionTag
|
||||||
.Select(c => c)
|
|
||||||
.OrderBy(c => c.NormalizedTitle)
|
.OrderBy(c => c.NormalizedTitle)
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.ProjectTo<CollectionTagDto>(_mapper.ConfigurationProvider)
|
.ProjectTo<CollectionTagDto>(_mapper.ConfigurationProvider)
|
||||||
|
@ -11,6 +11,7 @@ using API.DTOs.Filtering;
|
|||||||
using API.DTOs.Metadata;
|
using API.DTOs.Metadata;
|
||||||
using API.DTOs.ReadingLists;
|
using API.DTOs.ReadingLists;
|
||||||
using API.DTOs.Search;
|
using API.DTOs.Search;
|
||||||
|
using API.DTOs.SeriesDetail;
|
||||||
using API.Entities;
|
using API.Entities;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
using API.Entities.Metadata;
|
using API.Entities.Metadata;
|
||||||
@ -24,6 +25,17 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
|
|
||||||
namespace API.Data.Repositories;
|
namespace API.Data.Repositories;
|
||||||
|
|
||||||
|
[Flags]
|
||||||
|
public enum SeriesIncludes
|
||||||
|
{
|
||||||
|
None = 1,
|
||||||
|
Volumes = 2,
|
||||||
|
Metadata = 4,
|
||||||
|
Related = 8,
|
||||||
|
//Related = 16,
|
||||||
|
//UserPreferences = 32
|
||||||
|
}
|
||||||
|
|
||||||
internal class RecentlyAddedSeries
|
internal class RecentlyAddedSeries
|
||||||
{
|
{
|
||||||
public int LibraryId { get; init; }
|
public int LibraryId { get; init; }
|
||||||
@ -68,7 +80,7 @@ public interface ISeriesRepository
|
|||||||
Task<IEnumerable<Series>> GetSeriesForLibraryIdAsync(int libraryId);
|
Task<IEnumerable<Series>> GetSeriesForLibraryIdAsync(int libraryId);
|
||||||
Task<SeriesDto> GetSeriesDtoByIdAsync(int seriesId, int userId);
|
Task<SeriesDto> GetSeriesDtoByIdAsync(int seriesId, int userId);
|
||||||
Task<bool> DeleteSeriesAsync(int seriesId);
|
Task<bool> DeleteSeriesAsync(int seriesId);
|
||||||
Task<Series> GetSeriesByIdAsync(int seriesId);
|
Task<Series> GetSeriesByIdAsync(int seriesId, SeriesIncludes includes = SeriesIncludes.Volumes | SeriesIncludes.Metadata);
|
||||||
Task<IList<Series>> GetSeriesByIdsAsync(IList<int> seriesIds);
|
Task<IList<Series>> GetSeriesByIdsAsync(IList<int> seriesIds);
|
||||||
Task<int[]> GetChapterIdsForSeriesAsync(IList<int> seriesIds);
|
Task<int[]> GetChapterIdsForSeriesAsync(IList<int> seriesIds);
|
||||||
Task<IDictionary<int, IList<int>>> GetChapterIdWithSeriesIdForSeriesAsync(int[] seriesIds);
|
Task<IDictionary<int, IList<int>>> GetChapterIdWithSeriesIdForSeriesAsync(int[] seriesIds);
|
||||||
@ -96,6 +108,9 @@ public interface ISeriesRepository
|
|||||||
Task<IList<LanguageDto>> GetAllLanguagesForLibrariesAsync(List<int> libraryIds);
|
Task<IList<LanguageDto>> GetAllLanguagesForLibrariesAsync(List<int> libraryIds);
|
||||||
IEnumerable<PublicationStatusDto> GetAllPublicationStatusesDtosForLibrariesAsync(List<int> libraryIds);
|
IEnumerable<PublicationStatusDto> GetAllPublicationStatusesDtosForLibrariesAsync(List<int> libraryIds);
|
||||||
Task<IEnumerable<GroupedSeriesDto>> GetRecentlyUpdatedSeries(int userId, int pageSize = 30);
|
Task<IEnumerable<GroupedSeriesDto>> GetRecentlyUpdatedSeries(int userId, int pageSize = 30);
|
||||||
|
Task<RelatedSeriesDto> GetRelatedSeries(int userId, int seriesId);
|
||||||
|
|
||||||
|
Task<IEnumerable<SeriesDto>> GetSeriesForRelationKind(int userId, int seriesId, RelationKind kind);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class SeriesRepository : ISeriesRepository
|
public class SeriesRepository : ISeriesRepository
|
||||||
@ -376,19 +391,35 @@ public class SeriesRepository : ISeriesRepository
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="seriesId"></param>
|
/// <param name="seriesId"></param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public async Task<Series> GetSeriesByIdAsync(int seriesId)
|
public async Task<Series> GetSeriesByIdAsync(int seriesId, SeriesIncludes includes = SeriesIncludes.Volumes | SeriesIncludes.Metadata)
|
||||||
{
|
{
|
||||||
return await _context.Series
|
var query = _context.Series
|
||||||
.Include(s => s.Volumes)
|
|
||||||
.Include(s => s.Metadata)
|
|
||||||
.ThenInclude(m => m.CollectionTags)
|
|
||||||
.Include(s => s.Metadata)
|
|
||||||
.ThenInclude(m => m.Genres)
|
|
||||||
.Include(s => s.Metadata)
|
|
||||||
.ThenInclude(m => m.People)
|
|
||||||
.Where(s => s.Id == seriesId)
|
.Where(s => s.Id == seriesId)
|
||||||
.AsSplitQuery()
|
.AsSplitQuery();
|
||||||
.SingleOrDefaultAsync();
|
|
||||||
|
if (includes.HasFlag(SeriesIncludes.Volumes))
|
||||||
|
{
|
||||||
|
query = query.Include(s => s.Volumes);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (includes.HasFlag(SeriesIncludes.Related))
|
||||||
|
{
|
||||||
|
query = query.Include(s => s.Relations)
|
||||||
|
.ThenInclude(r => r.TargetSeries)
|
||||||
|
.Include(s => s.RelationOf);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (includes.HasFlag(SeriesIncludes.Metadata))
|
||||||
|
{
|
||||||
|
query = query.Include(s => s.Metadata)
|
||||||
|
.ThenInclude(m => m.CollectionTags)
|
||||||
|
.Include(s => s.Metadata)
|
||||||
|
.ThenInclude(m => m.Genres)
|
||||||
|
.Include(s => s.Metadata)
|
||||||
|
.ThenInclude(m => m.People);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await query.SingleOrDefaultAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -939,6 +970,79 @@ public class SeriesRepository : ISeriesRepository
|
|||||||
return seriesMap.Values.AsEnumerable();
|
return seriesMap.Values.AsEnumerable();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<SeriesDto>> GetSeriesForRelationKind(int userId, int seriesId, RelationKind kind)
|
||||||
|
{
|
||||||
|
var libraryIds = _context.AppUser
|
||||||
|
.Where(u => u.Id == userId)
|
||||||
|
.SelectMany(l => l.Libraries.Select(lib => lib.Id));
|
||||||
|
var usersSeriesIds = _context.Series
|
||||||
|
.Where(s => libraryIds.Contains(s.LibraryId))
|
||||||
|
.Select(s => s.Id);
|
||||||
|
|
||||||
|
var targetSeries = _context.SeriesRelation
|
||||||
|
.Where(sr =>
|
||||||
|
sr.SeriesId == seriesId && sr.RelationKind == kind && usersSeriesIds.Contains(sr.TargetSeriesId))
|
||||||
|
.Include(sr => sr.TargetSeries)
|
||||||
|
.AsSplitQuery()
|
||||||
|
.AsNoTracking()
|
||||||
|
.Select(sr => sr.TargetSeriesId);
|
||||||
|
|
||||||
|
return await _context.Series
|
||||||
|
.Where(s => targetSeries.Contains(s.Id))
|
||||||
|
.AsSplitQuery()
|
||||||
|
.AsNoTracking()
|
||||||
|
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RelatedSeriesDto> GetRelatedSeries(int userId, int seriesId)
|
||||||
|
{
|
||||||
|
var libraryIds = _context.AppUser
|
||||||
|
.Where(u => u.Id == userId)
|
||||||
|
.SelectMany(l => l.Libraries.Select(lib => lib.Id));
|
||||||
|
var usersSeriesIds = _context.Series
|
||||||
|
.Where(s => libraryIds.Contains(s.LibraryId))
|
||||||
|
.Select(s => s.Id);
|
||||||
|
|
||||||
|
return new RelatedSeriesDto()
|
||||||
|
{
|
||||||
|
SourceSeriesId = seriesId,
|
||||||
|
Adaptations = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Adaptation),
|
||||||
|
Characters = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Character),
|
||||||
|
Prequels = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Prequel),
|
||||||
|
Sequels = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Sequel),
|
||||||
|
Contains = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Contains),
|
||||||
|
SideStories = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.SideStory),
|
||||||
|
SpinOffs = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.SpinOff),
|
||||||
|
Others = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Other),
|
||||||
|
AlternativeSettings = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.AlternativeSetting),
|
||||||
|
AlternativeVersions = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.AlternativeVersion),
|
||||||
|
Doujinshis = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Doujinshi),
|
||||||
|
Parent = await _context.Series
|
||||||
|
.SelectMany(s =>
|
||||||
|
s.RelationOf.Where(r => r.TargetSeriesId == seriesId
|
||||||
|
&& usersSeriesIds.Contains(r.TargetSeriesId)
|
||||||
|
&& r.RelationKind != RelationKind.Prequel
|
||||||
|
&& r.RelationKind != RelationKind.Sequel)
|
||||||
|
.Select(sr => sr.Series))
|
||||||
|
.AsSplitQuery()
|
||||||
|
.AsNoTracking()
|
||||||
|
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
|
||||||
|
.ToListAsync()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IEnumerable<SeriesDto>> GetRelatedSeriesQuery(int seriesId, IEnumerable<int> usersSeriesIds, RelationKind kind)
|
||||||
|
{
|
||||||
|
return await _context.Series.SelectMany(s =>
|
||||||
|
s.Relations.Where(sr => sr.RelationKind == kind && sr.SeriesId == seriesId && usersSeriesIds.Contains(sr.TargetSeriesId))
|
||||||
|
.Select(sr => sr.TargetSeries))
|
||||||
|
.AsSplitQuery()
|
||||||
|
.AsNoTracking()
|
||||||
|
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<IEnumerable<RecentlyAddedSeries>> GetRecentlyAddedChaptersQuery(int userId, int maxRecords = 3000)
|
private async Task<IEnumerable<RecentlyAddedSeries>> GetRecentlyAddedChaptersQuery(int userId, int maxRecords = 3000)
|
||||||
{
|
{
|
||||||
var libraries = await _context.AppUser
|
var libraries = await _context.AppUser
|
||||||
|
66
API/Entities/Enums/RelationKind.cs
Normal file
66
API/Entities/Enums/RelationKind.cs
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
|
||||||
|
namespace API.Entities.Enums;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a relationship between Series
|
||||||
|
/// </summary>
|
||||||
|
public enum RelationKind
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Story that occurred before the original.
|
||||||
|
/// </summary>
|
||||||
|
[Description("Prequel")]
|
||||||
|
Prequel = 1,
|
||||||
|
/// <summary>
|
||||||
|
/// Direct continuation of the story.
|
||||||
|
/// </summary>
|
||||||
|
[Description("Sequel")]
|
||||||
|
Sequel = 2,
|
||||||
|
/// <summary>
|
||||||
|
/// Uses characters of a different series, but is not an alternate setting or story.
|
||||||
|
/// </summary>
|
||||||
|
[Description("Spin Off")]
|
||||||
|
SpinOff = 3,
|
||||||
|
/// <summary>
|
||||||
|
/// Manga/Anime/Light Novel adaptation
|
||||||
|
/// </summary>
|
||||||
|
[Description("Adaptation")]
|
||||||
|
Adaptation = 4,
|
||||||
|
/// <summary>
|
||||||
|
/// Takes place sometime during the parent storyline.
|
||||||
|
/// </summary>
|
||||||
|
[Description("Side Story")]
|
||||||
|
SideStory = 5,
|
||||||
|
/// <summary>
|
||||||
|
/// When characters appear in both series, but is not a spin-off
|
||||||
|
/// </summary>
|
||||||
|
[Description("Character")]
|
||||||
|
Character = 6,
|
||||||
|
/// <summary>
|
||||||
|
/// When the story contains another story, useful for One-Shots
|
||||||
|
/// </summary>
|
||||||
|
[Description("Contains")]
|
||||||
|
Contains = 7,
|
||||||
|
/// <summary>
|
||||||
|
/// When nothing else fits
|
||||||
|
/// </summary>
|
||||||
|
[Description("Other")]
|
||||||
|
Other = 8,
|
||||||
|
/// <summary>
|
||||||
|
/// Same universe/world/reality/timeline, completely different characters
|
||||||
|
/// </summary>
|
||||||
|
[Description("Alternative Setting")]
|
||||||
|
AlternativeSetting = 9,
|
||||||
|
/// <summary>
|
||||||
|
/// Same setting, same characters, story is told differently
|
||||||
|
/// </summary>
|
||||||
|
[Description("Alternative Version")]
|
||||||
|
AlternativeVersion = 10,
|
||||||
|
/// <summary>
|
||||||
|
/// Doujinshi or Fan work
|
||||||
|
/// </summary>
|
||||||
|
[Description("Doujinshi")]
|
||||||
|
Doujinshi = 11
|
||||||
|
|
||||||
|
}
|
25
API/Entities/Metadata/SeriesRelation.cs
Normal file
25
API/Entities/Metadata/SeriesRelation.cs
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using API.Entities.Enums;
|
||||||
|
|
||||||
|
namespace API.Entities.Metadata;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A relation flows between one series and another.
|
||||||
|
/// Series ---kind---> target
|
||||||
|
/// </summary>
|
||||||
|
public class SeriesRelation
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public RelationKind RelationKind { get; set; }
|
||||||
|
|
||||||
|
public virtual Series TargetSeries { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// A is Sequel to B. In this example, TargetSeries is A. B will hold the foreign key.
|
||||||
|
/// </summary>
|
||||||
|
public int TargetSeriesId { get; set; }
|
||||||
|
|
||||||
|
// Relationships
|
||||||
|
public virtual Series Series { get; set; }
|
||||||
|
public int SeriesId { get; set; }
|
||||||
|
}
|
@ -66,9 +66,18 @@ public class Series : IEntityDate
|
|||||||
public DateTime LastChapterAdded { get; set; }
|
public DateTime LastChapterAdded { get; set; }
|
||||||
|
|
||||||
public SeriesMetadata Metadata { get; set; }
|
public SeriesMetadata Metadata { get; set; }
|
||||||
|
|
||||||
public ICollection<AppUserRating> Ratings { get; set; } = new List<AppUserRating>();
|
public ICollection<AppUserRating> Ratings { get; set; } = new List<AppUserRating>();
|
||||||
public ICollection<AppUserProgress> Progress { get; set; } = new List<AppUserProgress>();
|
public ICollection<AppUserProgress> Progress { get; set; } = new List<AppUserProgress>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Relations to other Series, like Sequels, Prequels, etc
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>1 to Many relationship</remarks>
|
||||||
|
public virtual ICollection<SeriesRelation> Relations { get; set; } = new List<SeriesRelation>();
|
||||||
|
public virtual ICollection<SeriesRelation> RelationOf { get; set; } = new List<SeriesRelation>();
|
||||||
|
|
||||||
|
|
||||||
// Relationships
|
// Relationships
|
||||||
public List<Volume> Volumes { get; set; }
|
public List<Volume> Volumes { get; set; }
|
||||||
public Library Library { get; set; }
|
public Library Library { get; set; }
|
||||||
|
@ -6,6 +6,7 @@ using API.DTOs.Metadata;
|
|||||||
using API.DTOs.Reader;
|
using API.DTOs.Reader;
|
||||||
using API.DTOs.ReadingLists;
|
using API.DTOs.ReadingLists;
|
||||||
using API.DTOs.Search;
|
using API.DTOs.Search;
|
||||||
|
using API.DTOs.SeriesDetail;
|
||||||
using API.DTOs.Settings;
|
using API.DTOs.Settings;
|
||||||
using API.DTOs.Theme;
|
using API.DTOs.Theme;
|
||||||
using API.Entities;
|
using API.Entities;
|
||||||
@ -96,6 +97,10 @@ namespace API.Helpers
|
|||||||
opt =>
|
opt =>
|
||||||
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Editor)));
|
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Editor)));
|
||||||
|
|
||||||
|
// CreateMap<SeriesRelation, RelatedSeriesDto>()
|
||||||
|
// .ForMember(dest => dest.Adaptations,
|
||||||
|
// opt =>
|
||||||
|
// opt.MapFrom(src => src.Where(p => p.Role == PersonRole.Writer)))
|
||||||
|
|
||||||
CreateMap<AppUser, UserDto>();
|
CreateMap<AppUser, UserDto>();
|
||||||
CreateMap<SiteTheme, SiteThemeDto>();
|
CreateMap<SiteTheme, SiteThemeDto>();
|
||||||
|
@ -272,13 +272,8 @@ public class ScannerService : IScannerService
|
|||||||
|
|
||||||
|
|
||||||
_logger.LogInformation("[ScannerService] Beginning file scan on {LibraryName}", library.Name);
|
_logger.LogInformation("[ScannerService] Beginning file scan on {LibraryName}", library.Name);
|
||||||
// await _eventHub.SendMessageAsync(SignalREvents.NotificationProgress,
|
|
||||||
// MessageFactory.ScanLibraryProgressEvent(libraryId, 0F));
|
|
||||||
|
|
||||||
|
|
||||||
var (totalFiles, scanElapsedTime, series) = await ScanFiles(library, library.Folders.Select(fp => fp.Path));
|
var (totalFiles, scanElapsedTime, series) = await ScanFiles(library, library.Folders.Select(fp => fp.Path));
|
||||||
// var scanner = new ParseScannedFiles(_logger, _directoryService, _readingItemService);
|
|
||||||
// var series = scanner.ScanLibrariesForSeries(library.Type, library.Folders.Select(fp => fp.Path), out var totalFiles, out var scanElapsedTime);
|
|
||||||
_logger.LogInformation("[ScannerService] Finished file scan. Updating database");
|
_logger.LogInformation("[ScannerService] Finished file scan. Updating database");
|
||||||
|
|
||||||
foreach (var folderPath in library.Folders)
|
foreach (var folderPath in library.Folders)
|
||||||
@ -305,8 +300,6 @@ public class ScannerService : IScannerService
|
|||||||
|
|
||||||
await CleanupDbEntities();
|
await CleanupDbEntities();
|
||||||
|
|
||||||
// await _eventHub.SendMessageAsync(SignalREvents.NotificationProgress,
|
|
||||||
// MessageFactory.ScanLibraryProgressEvent(libraryId, 1F));
|
|
||||||
BackgroundJob.Enqueue(() => _metadataService.RefreshMetadata(libraryId, false));
|
BackgroundJob.Enqueue(() => _metadataService.RefreshMetadata(libraryId, false));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
17
UI/Web/src/app/_models/series-detail/related-series.ts
Normal file
17
UI/Web/src/app/_models/series-detail/related-series.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { Series } from "../series";
|
||||||
|
|
||||||
|
export interface RelatedSeries {
|
||||||
|
sourceSeriesId: number;
|
||||||
|
sequels: Array<Series>;
|
||||||
|
prequels: Array<Series>;
|
||||||
|
spinOffs: Array<Series>;
|
||||||
|
adaptations: Array<Series>;
|
||||||
|
sideStories: Array<Series>;
|
||||||
|
characters: Array<Series>;
|
||||||
|
contains: Array<Series>;
|
||||||
|
others: Array<Series>;
|
||||||
|
alternativeSettings: Array<Series>;
|
||||||
|
alternativeVersions: Array<Series>;
|
||||||
|
doujinshis: Array<Series>;
|
||||||
|
parent: Array<Series>;
|
||||||
|
}
|
31
UI/Web/src/app/_models/series-detail/relation-kind.ts
Normal file
31
UI/Web/src/app/_models/series-detail/relation-kind.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
export enum RelationKind {
|
||||||
|
Prequel = 1,
|
||||||
|
Sequel = 2,
|
||||||
|
SpinOff = 3,
|
||||||
|
Adaptation = 4,
|
||||||
|
SideStory = 5,
|
||||||
|
Character = 6,
|
||||||
|
Contains = 7,
|
||||||
|
Other = 8,
|
||||||
|
AlternativeSetting = 9,
|
||||||
|
AlternativeVersion = 10,
|
||||||
|
Doujinshi = 11,
|
||||||
|
/**
|
||||||
|
* This is UI only. Backend will generate Parent series for everything but Prequel/Sequel
|
||||||
|
*/
|
||||||
|
Parent = 12
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RelationKinds = [
|
||||||
|
{text: 'Prequel', value: RelationKind.Prequel},
|
||||||
|
{text: 'Sequel', value: RelationKind.Sequel},
|
||||||
|
{text: 'Spin Off', value: RelationKind.SpinOff},
|
||||||
|
{text: 'Adaptation', value: RelationKind.Adaptation},
|
||||||
|
{text: 'Alternative Setting', value: RelationKind.AlternativeSetting},
|
||||||
|
{text: 'Alternative Version', value: RelationKind.AlternativeVersion},
|
||||||
|
{text: 'Side Story', value: RelationKind.SideStory},
|
||||||
|
{text: 'Character', value: RelationKind.Character},
|
||||||
|
{text: 'Contains', value: RelationKind.Contains},
|
||||||
|
{text: 'Doujinshi', value: RelationKind.Doujinshi},
|
||||||
|
{text: 'Other', value: RelationKind.Other},
|
||||||
|
];
|
@ -4,7 +4,6 @@ import { of } from 'rxjs';
|
|||||||
import { map, take } from 'rxjs/operators';
|
import { map, take } from 'rxjs/operators';
|
||||||
import { environment } from 'src/environments/environment';
|
import { environment } from 'src/environments/environment';
|
||||||
import { Library, LibraryType } from '../_models/library';
|
import { Library, LibraryType } from '../_models/library';
|
||||||
import { SearchResult } from '../_models/search-result';
|
|
||||||
import { SearchResultGroup } from '../_models/search/search-result-group';
|
import { SearchResultGroup } from '../_models/search/search-result-group';
|
||||||
|
|
||||||
|
|
||||||
|
@ -9,6 +9,8 @@ import { CollectionTag } from '../_models/collection-tag';
|
|||||||
import { PaginatedResult } from '../_models/pagination';
|
import { PaginatedResult } from '../_models/pagination';
|
||||||
import { RecentlyAddedItem } from '../_models/recently-added-item';
|
import { RecentlyAddedItem } from '../_models/recently-added-item';
|
||||||
import { Series } from '../_models/series';
|
import { Series } from '../_models/series';
|
||||||
|
import { RelatedSeries } from '../_models/series-detail/related-series';
|
||||||
|
import { RelationKind } from '../_models/series-detail/relation-kind';
|
||||||
import { SeriesDetail } from '../_models/series-detail/series-detail';
|
import { SeriesDetail } from '../_models/series-detail/series-detail';
|
||||||
import { SeriesFilter } from '../_models/series-filter';
|
import { SeriesFilter } from '../_models/series-filter';
|
||||||
import { SeriesGroup } from '../_models/series-group';
|
import { SeriesGroup } from '../_models/series-group';
|
||||||
@ -182,6 +184,19 @@ export class SeriesService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getRelatedForSeries(seriesId: number) {
|
||||||
|
return this.httpClient.get<RelatedSeries>(this.baseUrl + 'series/all-related?seriesId=' + seriesId);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRelationships(seriesId: number, adaptations: Array<number>, characters: Array<number>,
|
||||||
|
contains: Array<number>, others: Array<number>, prequels: Array<number>,
|
||||||
|
sequels: Array<number>, sideStories: Array<number>, spinOffs: Array<number>,
|
||||||
|
alternativeSettings: Array<number>, alternativeVersions: Array<number>, doujinshis: Array<number>) {
|
||||||
|
return this.httpClient.post(this.baseUrl + 'series/update-related?seriesId=' + seriesId,
|
||||||
|
{seriesId, adaptations, characters, sequels, prequels, contains, others, sideStories, spinOffs,
|
||||||
|
alternativeSettings, alternativeVersions, doujinshis});
|
||||||
|
}
|
||||||
|
|
||||||
getSeriesDetail(seriesId: number) {
|
getSeriesDetail(seriesId: number) {
|
||||||
return this.httpClient.get<SeriesDetail>(this.baseUrl + 'series/series-detail?seriesId=' + seriesId);
|
return this.httpClient.get<SeriesDetail>(this.baseUrl + 'series/series-detail?seriesId=' + seriesId);
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
<app-nav-header></app-nav-header>
|
<app-nav-header></app-nav-header>
|
||||||
<div [ngClass]="{'closed' : !(navService?.sideNavCollapsed$ | async), 'content-wrapper': navService.sideNavVisibility$ | async}">
|
<div [ngClass]="{'closed' : (navService?.sideNavCollapsed$ | async), 'content-wrapper': navService.sideNavVisibility$ | async}">
|
||||||
<a id="content"></a>
|
<a id="content"></a>
|
||||||
<app-side-nav *ngIf="navService.sideNavVisibility$ | async"></app-side-nav>
|
<app-side-nav *ngIf="navService.sideNavVisibility$ | async"></app-side-nav>
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<div style="padding-top: 10px; padding-bottom: 65px;" *ngIf="navService.sideNavVisibility$ | async else noSideNav">
|
<div style="padding-top: 10px; padding-bottom: 65px;" *ngIf="navService.sideNavVisibility$ | async else noSideNav">
|
||||||
<div class="companion-bar" [ngClass]="{'companion-bar-content': (navService?.sideNavCollapsed$ | async)}">
|
<div class="companion-bar" [ngClass]="{'companion-bar-content': !(navService?.sideNavCollapsed$ | async)}">
|
||||||
<router-outlet></router-outlet>
|
<router-outlet></router-outlet>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -330,7 +330,13 @@
|
|||||||
</ng-template>
|
</ng-template>
|
||||||
</li>
|
</li>
|
||||||
<li [ngbNavItem]="tabs[4]">
|
<li [ngbNavItem]="tabs[4]">
|
||||||
<a ngbNavLink>{{tabs[4]}}</a>
|
<a ngbNavLink>{{tabs[4]}}</a>
|
||||||
|
<ng-template ngbNavContent>
|
||||||
|
<app-edit-series-relation [series]="series" [save]="saveNestedComponents"></app-edit-series-relation>
|
||||||
|
</ng-template>
|
||||||
|
</li>
|
||||||
|
<li [ngbNavItem]="tabs[5]">
|
||||||
|
<a ngbNavLink>{{tabs[5]}}</a>
|
||||||
<ng-template ngbNavContent>
|
<ng-template ngbNavContent>
|
||||||
<h4>Information</h4>
|
<h4>Information</h4>
|
||||||
<div class="row g-0 mb-2">
|
<div class="row g-0 mb-2">
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
|
import { Component, EventEmitter, Input, OnDestroy, OnInit } from '@angular/core';
|
||||||
import { FormBuilder, FormControl, FormGroup } from '@angular/forms';
|
import { FormBuilder, FormControl, FormGroup } from '@angular/forms';
|
||||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
||||||
import { forkJoin, Observable, of, Subject } from 'rxjs';
|
import { forkJoin, Observable, of, Subject } from 'rxjs';
|
||||||
@ -39,7 +39,7 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
isCollapsed = true;
|
isCollapsed = true;
|
||||||
volumeCollapsed: any = {};
|
volumeCollapsed: any = {};
|
||||||
tabs = ['General', 'Metadata', 'People', 'Cover Image', 'Info'];
|
tabs = ['General', 'Metadata', 'People', 'Cover Image', 'Related', 'Info'];
|
||||||
active = this.tabs[0];
|
active = this.tabs[0];
|
||||||
editSeriesForm!: FormGroup;
|
editSeriesForm!: FormGroup;
|
||||||
libraryName: string | undefined = undefined;
|
libraryName: string | undefined = undefined;
|
||||||
@ -73,6 +73,8 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
coverImageReset = false;
|
coverImageReset = false;
|
||||||
|
|
||||||
|
saveNestedComponents: EventEmitter<void> = new EventEmitter();
|
||||||
|
|
||||||
get Breakpoint(): typeof Breakpoint {
|
get Breakpoint(): typeof Breakpoint {
|
||||||
return Breakpoint;
|
return Breakpoint;
|
||||||
}
|
}
|
||||||
@ -420,6 +422,9 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
|
|||||||
apis.push(this.uploadService.updateSeriesCoverImage(model.id, this.selectedCover));
|
apis.push(this.uploadService.updateSeriesCoverImage(model.id, this.selectedCover));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.saveNestedComponents.emit();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
forkJoin(apis).subscribe(results => {
|
forkJoin(apis).subscribe(results => {
|
||||||
this.modal.close({success: true, series: model, coverImageUpdate: selectedIndex > 0});
|
this.modal.close({success: true, series: model, coverImageUpdate: selectedIndex > 0});
|
||||||
|
@ -1,24 +1,24 @@
|
|||||||
|
|
||||||
<div class="row mt-2 g-0 pb-2" *ngIf="header !== undefined && header.length > 0">
|
<div class="row mt-2 g-0 pb-2" *ngIf="header !== undefined && header.length > 0">
|
||||||
<div class="col me-auto">
|
<div class="col me-auto">
|
||||||
<h2 style="display: inline-block">
|
<h2 style="display: inline-block">
|
||||||
<span *ngIf="actions.length > 0" class="">
|
<span *ngIf="actions.length > 0" class="">
|
||||||
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="header"></app-card-actionables>
|
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="header"></app-card-actionables>
|
||||||
</span>
|
</span>
|
||||||
<span *ngIf="header !== undefined && header.length > 0">
|
<span *ngIf="header !== undefined && header.length > 0">
|
||||||
{{header}}
|
{{header}}
|
||||||
<span class="badge bg-primary rounded-pill" attr.aria-label="{{pagination.totalItems}} total items" *ngIf="pagination != undefined">{{pagination.totalItems}}</span>
|
<span class="badge bg-primary rounded-pill" attr.aria-label="{{pagination.totalItems}} total items" *ngIf="pagination != undefined">{{pagination.totalItems}}</span>
|
||||||
</span>
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<app-metadata-filter [filterSettings]="filterSettings" [filterOpen]="filterOpen" (applyFilter)="applyMetadataFilter($event)"></app-metadata-filter>
|
<app-metadata-filter [filterSettings]="filterSettings" [filterOpen]="filterOpen" (applyFilter)="applyMetadataFilter($event)"></app-metadata-filter>
|
||||||
|
|
||||||
<ng-container [ngTemplateOutlet]="paginationTemplate" [ngTemplateOutletContext]="{ id: 'top' }"></ng-container>
|
<ng-container [ngTemplateOutlet]="paginationTemplate" [ngTemplateOutletContext]="{ id: 'top' }"></ng-container>
|
||||||
|
|
||||||
|
<ng-container *ngIf="pagination.totalItems > 6 || isMobile; else cardTemplate">
|
||||||
<div class="row g-0 mt-2 mb-2">
|
<div class="d-flex justify-content-center row g-0 mt-2 mb-2">
|
||||||
<div class="col-auto ps-1 pe-1 mt-2 mb-2" *ngFor="let item of items; trackBy:trackByIdentity; index as i">
|
<div class="col-auto ps-1 pe-1 mt-2 mb-2" *ngFor="let item of items; trackBy:trackByIdentity; index as i">
|
||||||
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
|
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
|
||||||
</div>
|
</div>
|
||||||
@ -27,50 +27,63 @@
|
|||||||
<ng-container [ngTemplateOutlet]="noDataTemplate"></ng-container>
|
<ng-container [ngTemplateOutlet]="noDataTemplate"></ng-container>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
<ng-container [ngTemplateOutlet]="paginationTemplate" [ngTemplateOutletContext]="{ id: 'bottom' }"></ng-container>
|
<ng-container [ngTemplateOutlet]="paginationTemplate" [ngTemplateOutletContext]="{ id: 'bottom' }"></ng-container>
|
||||||
|
|
||||||
|
<ng-template #cardTemplate>
|
||||||
|
<div class="row g-0 mt-2 mb-2">
|
||||||
|
<div class="col-auto ps-1 pe-1 mt-2 mb-2" *ngFor="let item of items; trackBy:trackByIdentity; index as i">
|
||||||
|
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p *ngIf="items.length === 0 && !isLoading">
|
||||||
|
There is no data
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
<ng-template #paginationTemplate let-id="id">
|
<ng-template #paginationTemplate let-id="id">
|
||||||
<div class="d-flex justify-content-center mb-0" *ngIf="pagination && items.length > 0">
|
<div class="d-flex justify-content-center mb-0" *ngIf="pagination && items.length > 0">
|
||||||
<ngb-pagination
|
<ngb-pagination
|
||||||
*ngIf="pagination.totalPages > 1"
|
*ngIf="pagination.totalPages > 1"
|
||||||
[maxSize]="8"
|
[maxSize]="8"
|
||||||
[rotate]="true"
|
[rotate]="true"
|
||||||
[ellipses]="false"
|
[ellipses]="false"
|
||||||
[(page)]="pagination.currentPage"
|
[(page)]="pagination.currentPage"
|
||||||
[pageSize]="pagination.itemsPerPage"
|
[pageSize]="pagination.itemsPerPage"
|
||||||
(pageChange)="onPageChange($event)"
|
(pageChange)="onPageChange($event)"
|
||||||
[collectionSize]="pagination.totalItems">
|
[collectionSize]="pagination.totalItems">
|
||||||
|
|
||||||
<ng-template ngbPaginationPages let-page let-pages="pages" *ngIf="pagination.totalItems / pagination.itemsPerPage > 20">
|
<ng-template ngbPaginationPages let-page let-pages="pages" *ngIf="pagination.totalItems / pagination.itemsPerPage > 20">
|
||||||
<li class="ngb-custom-pages-item" *ngIf="pagination.totalPages > 1">
|
<li class="ngb-custom-pages-item" *ngIf="pagination.totalPages > 1">
|
||||||
<div class="d-flex flex-nowrap px-2">
|
<div class="d-flex flex-nowrap px-2">
|
||||||
<label
|
<label
|
||||||
id="paginationInputLabel-{{id}}"
|
id="paginationInputLabel-{{id}}"
|
||||||
for="paginationInput-{{id}}"
|
for="paginationInput-{{id}}"
|
||||||
class="col-form-label me-2 ms-1 form-label"
|
class="col-form-label me-2 ms-1 form-label"
|
||||||
>Page</label>
|
>Page</label>
|
||||||
<input #i
|
<input #i
|
||||||
type="text"
|
type="text"
|
||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
pattern="[0-9]*"
|
pattern="[0-9]*"
|
||||||
class="form-control custom-pages-input"
|
class="form-control custom-pages-input"
|
||||||
id="paginationInput-{{id}}"
|
id="paginationInput-{{id}}"
|
||||||
[value]="page"
|
[value]="page"
|
||||||
(keyup.enter)="selectPageStr(i.value)"
|
(keyup.enter)="selectPageStr(i.value)"
|
||||||
(blur)="selectPageStr(i.value)"
|
(blur)="selectPageStr(i.value)"
|
||||||
(input)="formatInput($any($event).target)"
|
(input)="formatInput($any($event).target)"
|
||||||
attr.aria-labelledby="paginationInputLabel-{{id}} paginationDescription-{{id}}"
|
attr.aria-labelledby="paginationInputLabel-{{id}} paginationDescription-{{id}}"
|
||||||
[ngStyle]="{width: (0.5 + pagination.currentPage + '').length + 'rem'} "
|
[ngStyle]="{width: (0.5 + pagination.currentPage + '').length + 'rem'} "
|
||||||
/>
|
/>
|
||||||
<span id="paginationDescription-{{id}}" class="col-form-label text-nowrap px-2">
|
<span id="paginationDescription-{{id}}" class="col-form-label text-nowrap px-2">
|
||||||
of {{pagination.totalPages}}</span>
|
of {{pagination.totalPages}}</span>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
</ngb-pagination>
|
</ngb-pagination>
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
<div class="mx-auto" *ngIf="isLoading" style="width: 200px;">
|
<div class="mx-auto" *ngIf="isLoading" style="width: 200px;">
|
||||||
|
@ -52,6 +52,7 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
|
|
||||||
private onDestory: Subject<void> = new Subject();
|
private onDestory: Subject<void> = new Subject();
|
||||||
|
isMobile: boolean = false;
|
||||||
|
|
||||||
constructor(private seriesService: SeriesService) {
|
constructor(private seriesService: SeriesService) {
|
||||||
this.filter = this.seriesService.createSeriesFilter();
|
this.filter = this.seriesService.createSeriesFilter();
|
||||||
@ -62,9 +63,15 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
|
|
||||||
if (this.filterSettings === undefined) {
|
if (this.filterSettings === undefined) {
|
||||||
console.log('filter settings was empty, creating our own');
|
|
||||||
this.filterSettings = new FilterSettings();
|
this.filterSettings = new FilterSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.pagination === undefined) {
|
||||||
|
this.pagination = {currentPage: 1, itemsPerPage: this.items.length, totalItems: this.items.length, totalPages: 1}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isMobile = window.innerWidth <= 480;
|
||||||
|
window.onresize = () => this.isMobile = window.innerWidth <= 480;
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
|
@ -30,6 +30,11 @@
|
|||||||
<span class="badge bg-primary">{{count}}</span>
|
<span class="badge bg-primary">{{count}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-overlay"></div>
|
<div class="card-overlay"></div>
|
||||||
|
<div class="overlay-information" *ngIf="overlayInformation !== '' || overlayInformation !== undefined">
|
||||||
|
<div class="position-relative">
|
||||||
|
<span class="card-title library mx-auto" style="width: auto;" [ngbTooltip]="overlayInformation" placement="top">{{overlayInformation}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-body" *ngIf="title.length > 0 || actions.length > 0">
|
<div class="card-body" *ngIf="title.length > 0 || actions.length > 0">
|
||||||
|
@ -100,6 +100,14 @@ $image-width: 160px;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.overlay-information {
|
||||||
|
position: absolute;
|
||||||
|
top: 5px;
|
||||||
|
left: 5px;
|
||||||
|
border-radius: 15px;
|
||||||
|
padding: 0 10px;
|
||||||
|
background-color: var(--card-bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
.overlay {
|
.overlay {
|
||||||
height: $image-height;
|
height: $image-height;
|
||||||
|
@ -71,11 +71,15 @@ export class CardItemComponent implements OnInit, OnDestroy {
|
|||||||
/**
|
/**
|
||||||
* This will supress the cannot read archive warning when total pages is 0
|
* This will supress the cannot read archive warning when total pages is 0
|
||||||
*/
|
*/
|
||||||
@Input() supressArchiveWarning: boolean = false;
|
@Input() supressArchiveWarning: boolean = false;
|
||||||
/**
|
/**
|
||||||
* The number of updates/items within the card. If less than 2, will not be shown.
|
* The number of updates/items within the card. If less than 2, will not be shown.
|
||||||
*/
|
*/
|
||||||
@Input() count: number = 0;
|
@Input() count: number = 0;
|
||||||
|
/**
|
||||||
|
* Additional information to show on the overlay area. Will always render.
|
||||||
|
*/
|
||||||
|
@Input() overlayInformation: string = '';
|
||||||
/**
|
/**
|
||||||
* Event emitted when item is clicked
|
* Event emitted when item is clicked
|
||||||
*/
|
*/
|
||||||
|
@ -21,6 +21,7 @@ import { PipeModule } from '../pipe/pipe.module';
|
|||||||
import { ChapterMetadataDetailComponent } from './chapter-metadata-detail/chapter-metadata-detail.component';
|
import { ChapterMetadataDetailComponent } from './chapter-metadata-detail/chapter-metadata-detail.component';
|
||||||
import { FileInfoComponent } from './file-info/file-info.component';
|
import { FileInfoComponent } from './file-info/file-info.component';
|
||||||
import { MetadataFilterModule } from '../metadata-filter/metadata-filter.module';
|
import { MetadataFilterModule } from '../metadata-filter/metadata-filter.module';
|
||||||
|
import { EditSeriesRelationComponent } from './edit-series-relation/edit-series-relation.component';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -39,19 +40,20 @@ import { MetadataFilterModule } from '../metadata-filter/metadata-filter.module'
|
|||||||
BulkAddToCollectionComponent,
|
BulkAddToCollectionComponent,
|
||||||
ChapterMetadataDetailComponent,
|
ChapterMetadataDetailComponent,
|
||||||
FileInfoComponent,
|
FileInfoComponent,
|
||||||
|
EditSeriesRelationComponent,
|
||||||
],
|
],
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
RouterModule,
|
RouterModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
FormsModule, // EditCollectionsModal
|
FormsModule, // EditCollectionsModal
|
||||||
|
|
||||||
PipeModule,
|
PipeModule,
|
||||||
SharedModule,
|
SharedModule,
|
||||||
TypeaheadModule, // edit series modal
|
TypeaheadModule, // edit series modal
|
||||||
|
|
||||||
MetadataFilterModule,
|
MetadataFilterModule,
|
||||||
|
|
||||||
NgbNavModule,
|
NgbNavModule,
|
||||||
NgbTooltipModule, // Card item
|
NgbTooltipModule, // Card item
|
||||||
NgbCollapseModule,
|
NgbCollapseModule,
|
||||||
@ -79,7 +81,8 @@ import { MetadataFilterModule } from '../metadata-filter/metadata-filter.module'
|
|||||||
CardDetailLayoutComponent,
|
CardDetailLayoutComponent,
|
||||||
CardDetailsModalComponent,
|
CardDetailsModalComponent,
|
||||||
BulkOperationsComponent,
|
BulkOperationsComponent,
|
||||||
ChapterMetadataDetailComponent
|
ChapterMetadataDetailComponent,
|
||||||
|
EditSeriesRelationComponent
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class CardsModule { }
|
export class CardsModule { }
|
||||||
|
@ -0,0 +1,36 @@
|
|||||||
|
<div class="container-fluid">
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Not sure what relationship to add? See our <a href="https://wiki.kavitareader.com/en/guides/get-started-using-your-library/series-relationships" target="_blank" referrerpolicy="no-refer">wiki for hints</a>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="row g-0" *ngIf="relations.length > 0">
|
||||||
|
<label class="form-label col-md-7">Target Series</label>
|
||||||
|
<label class="form-label col-md-5">Relationship</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form>
|
||||||
|
<div class="row g-0 mb-3" *ngFor="let relation of relations; let idx = index; let isLast = last;">
|
||||||
|
<div class="col-sm-12 col-md-7">
|
||||||
|
<app-typeahead (selectedData)="updateSeries($event, relation)" [settings]="relation.typeaheadSettings">
|
||||||
|
<ng-template #badgeItem let-item let-position="idx">
|
||||||
|
{{item.name}} ({{libraryNames[item.libraryId]}})
|
||||||
|
</ng-template>
|
||||||
|
<ng-template #optionItem let-item let-position="idx">
|
||||||
|
{{item.name}} ({{libraryNames[item.libraryId]}})
|
||||||
|
</ng-template>
|
||||||
|
</app-typeahead>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-auto col-md-3">
|
||||||
|
<select class="form-select" [formControl]="relation.formControl">
|
||||||
|
<option *ngFor="let opt of relationOptions" [value]="opt.value">{{opt.text}}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button class="col-sm-auto col-md-2 btn btn-outline-secondary" (click)="removeRelation(idx)">Remove</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="row g-0 mt-3 mb-3">
|
||||||
|
<button class="btn btn-outline-secondary col-md-12" (click)="addNewRelation()">Add Relationship</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -0,0 +1,150 @@
|
|||||||
|
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core';
|
||||||
|
import { FormControl } from '@angular/forms';
|
||||||
|
import { map, Subject, Observable, of, firstValueFrom, takeUntil, ReplaySubject } from 'rxjs';
|
||||||
|
import { UtilityService } from 'src/app/shared/_services/utility.service';
|
||||||
|
import { TypeaheadSettings } from 'src/app/typeahead/typeahead-settings';
|
||||||
|
import { SearchResult } from 'src/app/_models/search-result';
|
||||||
|
import { Series } from 'src/app/_models/series';
|
||||||
|
import { RelationKind, RelationKinds } from 'src/app/_models/series-detail/relation-kind';
|
||||||
|
import { ImageService } from 'src/app/_services/image.service';
|
||||||
|
import { LibraryService } from 'src/app/_services/library.service';
|
||||||
|
import { SeriesService } from 'src/app/_services/series.service';
|
||||||
|
|
||||||
|
interface RelationControl {
|
||||||
|
series: {id: number, name: string} | undefined; // Will add type as well
|
||||||
|
typeaheadSettings: TypeaheadSettings<SearchResult>;
|
||||||
|
formControl: FormControl;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-edit-series-relation',
|
||||||
|
templateUrl: './edit-series-relation.component.html',
|
||||||
|
styleUrls: ['./edit-series-relation.component.scss']
|
||||||
|
})
|
||||||
|
export class EditSeriesRelationComponent implements OnInit, OnDestroy {
|
||||||
|
|
||||||
|
@Input() series!: Series;
|
||||||
|
/**
|
||||||
|
* This will tell the component to save based on it's internal state
|
||||||
|
*/
|
||||||
|
@Input() save: EventEmitter<void> = new EventEmitter();
|
||||||
|
|
||||||
|
@Output() saveApi = new ReplaySubject(1);
|
||||||
|
relationOptions = RelationKinds;
|
||||||
|
|
||||||
|
relations: Array<RelationControl> = [];
|
||||||
|
seriesSettings: TypeaheadSettings<SearchResult> = new TypeaheadSettings();
|
||||||
|
libraryNames: {[key:number]: string} = {};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
private onDestroy: Subject<void> = new Subject<void>();
|
||||||
|
|
||||||
|
constructor(private seriesService: SeriesService, private utilityService: UtilityService,
|
||||||
|
public imageService: ImageService, private libraryService: LibraryService) { }
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.seriesService.getRelatedForSeries(this.series.id).subscribe(async relations => {
|
||||||
|
this.setupRelationRows(relations.prequels, RelationKind.Prequel);
|
||||||
|
this.setupRelationRows(relations.sequels, RelationKind.Sequel);
|
||||||
|
this.setupRelationRows(relations.sideStories, RelationKind.SideStory);
|
||||||
|
this.setupRelationRows(relations.spinOffs, RelationKind.SpinOff);
|
||||||
|
this.setupRelationRows(relations.adaptations, RelationKind.Adaptation);
|
||||||
|
this.setupRelationRows(relations.others, RelationKind.Other);
|
||||||
|
this.setupRelationRows(relations.characters, RelationKind.Character);
|
||||||
|
this.setupRelationRows(relations.alternativeSettings, RelationKind.AlternativeSetting);
|
||||||
|
this.setupRelationRows(relations.alternativeVersions, RelationKind.AlternativeVersion);
|
||||||
|
this.setupRelationRows(relations.doujinshis, RelationKind.Doujinshi);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.libraryService.getLibraryNames().subscribe(names => {
|
||||||
|
this.libraryNames = names;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.save.pipe(takeUntil(this.onDestroy)).subscribe(() => this.saveState());
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.onDestroy.next();
|
||||||
|
this.onDestroy.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
setupRelationRows(relations: Array<Series>, kind: RelationKind) {
|
||||||
|
relations.map(async item => {
|
||||||
|
const settings = await firstValueFrom(this.createSeriesTypeahead(item, kind));
|
||||||
|
return {series: item, typeaheadSettings: settings, formControl: new FormControl(kind, [])}
|
||||||
|
}).forEach(async p => {
|
||||||
|
this.relations.push(await p);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async addNewRelation() {
|
||||||
|
this.relations.push({series: undefined, formControl: new FormControl(RelationKind.Adaptation, []), typeaheadSettings: await firstValueFrom(this.createSeriesTypeahead(undefined, RelationKind.Adaptation))});
|
||||||
|
}
|
||||||
|
|
||||||
|
removeRelation(index: number) {
|
||||||
|
this.relations.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
updateSeries(event: Array<SearchResult | undefined>, relation: RelationControl) {
|
||||||
|
if (event[0] === undefined) {
|
||||||
|
relation.series = undefined;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
relation.series = {id: event[0].seriesId, name: event[0].name};
|
||||||
|
}
|
||||||
|
|
||||||
|
createSeriesTypeahead(series: Series | undefined, relationship: RelationKind): Observable<TypeaheadSettings<SearchResult>> {
|
||||||
|
const seriesSettings = new TypeaheadSettings<SearchResult>();
|
||||||
|
seriesSettings.minCharacters = 0;
|
||||||
|
seriesSettings.multiple = false;
|
||||||
|
seriesSettings.id = 'format';
|
||||||
|
seriesSettings.unique = true;
|
||||||
|
seriesSettings.addIfNonExisting = false;
|
||||||
|
seriesSettings.fetchFn = (searchFilter: string) => this.libraryService.search(searchFilter).pipe(
|
||||||
|
map(group => group.series),
|
||||||
|
map(items => seriesSettings.compareFn(items, searchFilter)),
|
||||||
|
map(series => series.filter(s => s.seriesId !== this.series.id)),
|
||||||
|
);
|
||||||
|
|
||||||
|
seriesSettings.compareFn = (options: SearchResult[], filter: string) => {
|
||||||
|
return options.filter(m => this.utilityService.filter(m.name, filter));
|
||||||
|
}
|
||||||
|
|
||||||
|
seriesSettings.selectionCompareFn = (a: SearchResult, b: SearchResult) => {
|
||||||
|
return a.seriesId == b.seriesId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (series !== undefined) {
|
||||||
|
return this.libraryService.search(series.name).pipe(
|
||||||
|
map(group => group.series), map(results => {
|
||||||
|
seriesSettings.savedData = results.filter(s => s.seriesId === series.id);
|
||||||
|
return seriesSettings;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return of(seriesSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveState() {
|
||||||
|
const adaptations = this.relations.filter(item => (parseInt(item.formControl.value, 10) as RelationKind) === RelationKind.Adaptation && item.series !== undefined).map(item => item.series!.id);
|
||||||
|
const characters = this.relations.filter(item => (parseInt(item.formControl.value, 10) as RelationKind) === RelationKind.Character && item.series !== undefined).map(item => item.series!.id);
|
||||||
|
const contains = this.relations.filter(item => (parseInt(item.formControl.value, 10) as RelationKind) === RelationKind.Contains && item.series !== undefined).map(item => item.series!.id);
|
||||||
|
const others = this.relations.filter(item => (parseInt(item.formControl.value, 10) as RelationKind) === RelationKind.Other && item.series !== undefined).map(item => item.series!.id);
|
||||||
|
const prequels = this.relations.filter(item => (parseInt(item.formControl.value, 10) as RelationKind) === RelationKind.Prequel && item.series !== undefined).map(item => item.series!.id);
|
||||||
|
const sequels = this.relations.filter(item => (parseInt(item.formControl.value, 10) as RelationKind) === RelationKind.Sequel && item.series !== undefined).map(item => item.series!.id);
|
||||||
|
const sideStories = this.relations.filter(item => (parseInt(item.formControl.value, 10) as RelationKind) === RelationKind.SideStory && item.series !== undefined).map(item => item.series!.id);
|
||||||
|
const spinOffs = this.relations.filter(item => (parseInt(item.formControl.value, 10) as RelationKind) === RelationKind.SpinOff && item.series !== undefined).map(item => item.series!.id);
|
||||||
|
const alternativeSettings = this.relations.filter(item => (parseInt(item.formControl.value, 10) as RelationKind) === RelationKind.AlternativeSetting && item.series !== undefined).map(item => item.series!.id);
|
||||||
|
const alternativeVersions = this.relations.filter(item => (parseInt(item.formControl.value, 10) as RelationKind) === RelationKind.AlternativeVersion && item.series !== undefined).map(item => item.series!.id);
|
||||||
|
const doujinshis = this.relations.filter(item => (parseInt(item.formControl.value, 10) as RelationKind) === RelationKind.Doujinshi && item.series !== undefined).map(item => item.series!.id);
|
||||||
|
|
||||||
|
// TODO: We can actually emit this onto an observable and in main parent, use mergeMap into the forkJoin
|
||||||
|
|
||||||
|
//this.saveApi.next(this.seriesService.updateRelationships(this.series.id, adaptations, characters, contains, others, prequels, sequels, sideStories, spinOffs, alternativeSettings, alternativeVersions, doujinshis));
|
||||||
|
this.seriesService.updateRelationships(this.series.id, adaptations, characters, contains, others, prequels, sequels, sideStories, spinOffs, alternativeSettings, alternativeVersions, doujinshis).subscribe(() => {});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -2,5 +2,6 @@
|
|||||||
<app-card-item [title]="data.name" [actions]="actions" [suppressLibraryLink]="suppressLibraryLink" [imageUrl]="imageUrl"
|
<app-card-item [title]="data.name" [actions]="actions" [suppressLibraryLink]="suppressLibraryLink" [imageUrl]="imageUrl"
|
||||||
[entity]="data" [total]="data.pages" [read]="data.pagesRead" (clicked)="handleClick()"
|
[entity]="data" [total]="data.pages" [read]="data.pagesRead" (clicked)="handleClick()"
|
||||||
[allowSelection]="allowSelection" (selection)="selection.emit(selected)" [selected]="selected"
|
[allowSelection]="allowSelection" (selection)="selection.emit(selected)" [selected]="selected"
|
||||||
|
[overlayInformation]="(relation | relationship)"
|
||||||
></app-card-item>
|
></app-card-item>
|
||||||
</ng-container>
|
</ng-container>
|
@ -13,6 +13,7 @@ import { ActionService } from 'src/app/_services/action.service';
|
|||||||
import { EditSeriesModalComponent } from '../_modals/edit-series-modal/edit-series-modal.component';
|
import { EditSeriesModalComponent } from '../_modals/edit-series-modal/edit-series-modal.component';
|
||||||
import { MessageHubService } from 'src/app/_services/message-hub.service';
|
import { MessageHubService } from 'src/app/_services/message-hub.service';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
|
import { RelationKind } from 'src/app/_models/series-detail/relation-kind';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-series-card',
|
selector: 'app-series-card',
|
||||||
@ -26,11 +27,15 @@ export class SeriesCardComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
/**
|
/**
|
||||||
* If the entity is selected or not.
|
* If the entity is selected or not.
|
||||||
*/
|
*/
|
||||||
@Input() selected: boolean = false;
|
@Input() selected: boolean = false;
|
||||||
/**
|
/**
|
||||||
* If the entity should show selection code
|
* If the entity should show selection code
|
||||||
*/
|
*/
|
||||||
@Input() allowSelection: boolean = false;
|
@Input() allowSelection: boolean = false;
|
||||||
|
/**
|
||||||
|
* If the Series has a relationship to display
|
||||||
|
*/
|
||||||
|
@Input() relation: RelationKind | undefined = undefined;
|
||||||
|
|
||||||
@Output() clicked = new EventEmitter<Series>();
|
@Output() clicked = new EventEmitter<Series>();
|
||||||
@Output() reload = new EventEmitter<boolean>();
|
@Output() reload = new EventEmitter<boolean>();
|
||||||
|
@ -9,97 +9,97 @@
|
|||||||
<label for="nav-search" class="form-label visually-hidden">Search series</label>
|
<label for="nav-search" class="form-label visually-hidden">Search series</label>
|
||||||
<div class="ng-autocomplete">
|
<div class="ng-autocomplete">
|
||||||
<app-grouped-typeahead
|
<app-grouped-typeahead
|
||||||
#search
|
#search
|
||||||
id="nav-search"
|
id="nav-search"
|
||||||
[minQueryLength]="2"
|
[minQueryLength]="2"
|
||||||
initialValue=""
|
initialValue=""
|
||||||
placeholder="Search…"
|
placeholder="Search…"
|
||||||
[grouppedData]="searchResults"
|
[grouppedData]="searchResults"
|
||||||
(inputChanged)="onChangeSearch($event)"
|
(inputChanged)="onChangeSearch($event)"
|
||||||
(clearField)="clearSearch()"
|
(clearField)="clearSearch()"
|
||||||
(focusChanged)="focusUpdate($event)"
|
(focusChanged)="focusUpdate($event)"
|
||||||
>
|
>
|
||||||
|
|
||||||
<ng-template #libraryTemplate let-item>
|
<ng-template #libraryTemplate let-item>
|
||||||
<div style="display: flex;padding: 5px;" (click)="clickLibraryResult(item)">
|
<div style="display: flex;padding: 5px;" (click)="clickLibraryResult(item)">
|
||||||
<div class="ms-1">
|
<div class="ms-1">
|
||||||
<span>{{item.name}}</span>
|
<span>{{item.name}}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ng-template>
|
||||||
</ng-template>
|
|
||||||
|
|
||||||
<ng-template #seriesTemplate let-item>
|
<ng-template #seriesTemplate let-item>
|
||||||
<div style="display: flex;padding: 5px;" (click)="clickSeriesSearchResult(item)">
|
<div style="display: flex;padding: 5px;" (click)="clickSeriesSearchResult(item)">
|
||||||
<div style="width: 24px" class="me-1">
|
<div style="width: 24px" class="me-1">
|
||||||
<app-image class="me-3 search-result" width="24px" [imageUrl]="imageService.getSeriesCoverImage(item.seriesId)"></app-image>
|
<app-image class="me-3 search-result" width="24px" [imageUrl]="imageService.getSeriesCoverImage(item.seriesId)"></app-image>
|
||||||
|
</div>
|
||||||
|
<div class="ms-1">
|
||||||
|
<app-series-format [format]="item.format"></app-series-format>
|
||||||
|
<span *ngIf="item.name.toLowerCase().trim().indexOf(searchTerm) >= 0; else localizedName">{{item.name}}</span>
|
||||||
|
<ng-template #localizedName>
|
||||||
|
<span [innerHTML]="item.localizedName"></span>
|
||||||
|
</ng-template>
|
||||||
|
<div class="form-text" style="font-size: 0.8rem;">in {{item.libraryName}}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ms-1">
|
</ng-template>
|
||||||
<app-series-format [format]="item.format"></app-series-format>
|
|
||||||
<span *ngIf="item.name.toLowerCase().trim().indexOf(searchTerm) >= 0; else localizedName">{{item.name}}</span>
|
|
||||||
<ng-template #localizedName>
|
|
||||||
<span [innerHTML]="item.localizedName"></span>
|
|
||||||
</ng-template>
|
|
||||||
<div class="form-text" style="font-size: 0.8rem;">in {{item.libraryName}}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ng-template>
|
|
||||||
|
|
||||||
<ng-template #collectionTemplate let-item>
|
<ng-template #collectionTemplate let-item>
|
||||||
<div style="display: flex;padding: 5px;" (click)="clickCollectionSearchResult(item)">
|
<div style="display: flex;padding: 5px;" (click)="clickCollectionSearchResult(item)">
|
||||||
<div style="width: 24px" class="me-1">
|
<div style="width: 24px" class="me-1">
|
||||||
<app-image class="me-3 search-result" width="24px" [imageUrl]="imageService.getCollectionCoverImage(item.id)"></app-image>
|
<app-image class="me-3 search-result" width="24px" [imageUrl]="imageService.getCollectionCoverImage(item.id)"></app-image>
|
||||||
|
</div>
|
||||||
|
<div class="ms-1">
|
||||||
|
<span>{{item.title}}</span>
|
||||||
|
<span *ngIf="item.promoted">
|
||||||
|
<i class="fa fa-angle-double-up" aria-hidden="true" title="Promoted"></i>
|
||||||
|
<span class="visually-hidden">(promoted)</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ms-1">
|
</ng-template>
|
||||||
<span>{{item.title}}</span>
|
|
||||||
<span *ngIf="item.promoted">
|
|
||||||
<i class="fa fa-angle-double-up" aria-hidden="true" title="Promoted"></i>
|
|
||||||
<span class="visually-hidden">(promoted)</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ng-template>
|
|
||||||
|
|
||||||
<ng-template #readingListTemplate let-item>
|
<ng-template #readingListTemplate let-item>
|
||||||
<div style="display: flex;padding: 5px;" (click)="clickReadingListSearchResult(item)">
|
<div style="display: flex;padding: 5px;" (click)="clickReadingListSearchResult(item)">
|
||||||
<div class="ms-1">
|
<div class="ms-1">
|
||||||
<span>{{item.title}}</span>
|
<span>{{item.title}}</span>
|
||||||
<span *ngIf="item.promoted">
|
<span *ngIf="item.promoted">
|
||||||
<i class="fa fa-angle-double-up" aria-hidden="true" title="Promoted"></i>
|
<i class="fa fa-angle-double-up" aria-hidden="true" title="Promoted"></i>
|
||||||
<span class="visually-hidden">(promoted)</span>
|
<span class="visually-hidden">(promoted)</span>
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ng-template>
|
||||||
</ng-template>
|
|
||||||
|
|
||||||
<ng-template #tagTemplate let-item>
|
<ng-template #tagTemplate let-item>
|
||||||
<div style="display: flex;padding: 5px;" (click)="goTo('tags', item.id)">
|
<div style="display: flex;padding: 5px;" (click)="goTo('tags', item.id)">
|
||||||
<div class="ms-1">
|
<div class="ms-1">
|
||||||
<span>{{item.title}}</span>
|
<span>{{item.title}}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ng-template>
|
||||||
</ng-template>
|
|
||||||
|
|
||||||
<ng-template #personTemplate let-item>
|
<ng-template #personTemplate let-item>
|
||||||
<div style="display: flex;padding: 5px;" class="clickable" (click)="goToPerson(item.role, item.id)">
|
<div style="display: flex;padding: 5px;" class="clickable" (click)="goToPerson(item.role, item.id)">
|
||||||
<div class="ms-1">
|
<div class="ms-1">
|
||||||
|
|
||||||
<div [innerHTML]="item.name"></div>
|
<div [innerHTML]="item.name"></div>
|
||||||
<div>{{item.role | personRole}}</div>
|
<div>{{item.role | personRole}}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ng-template>
|
||||||
</ng-template>
|
|
||||||
|
|
||||||
<ng-template #genreTemplate let-item>
|
<ng-template #genreTemplate let-item>
|
||||||
<div style="display: flex;padding: 5px;" class="clickable" (click)="goTo('genres', item.id)">
|
<div style="display: flex;padding: 5px;" class="clickable" (click)="goTo('genres', item.id)">
|
||||||
<div class="ms-1">
|
<div class="ms-1">
|
||||||
<div [innerHTML]="item.title"></div>
|
<div [innerHTML]="item.title"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ng-template>
|
||||||
</ng-template>
|
|
||||||
|
|
||||||
<ng-template #noResultsTemplate let-notFound>
|
<ng-template #noResultsTemplate let-notFound>
|
||||||
No results found
|
No results found
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
</app-grouped-typeahead>
|
</app-grouped-typeahead>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import { DOCUMENT } from '@angular/common';
|
import { DOCUMENT } from '@angular/common';
|
||||||
import { Component, HostListener, Inject, OnDestroy, OnInit, ViewChild } from '@angular/core';
|
import { Component, HostListener, Inject, OnDestroy, OnInit, ViewChild } from '@angular/core';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { from, fromEvent, Subject } from 'rxjs';
|
import { fromEvent, Subject } from 'rxjs';
|
||||||
import { takeUntil } from 'rxjs/operators';
|
import { takeUntil } from 'rxjs/operators';
|
||||||
import { ScrollService } from '../scroll.service';
|
import { ScrollService } from '../scroll.service';
|
||||||
import { FilterQueryParam, FilterUtilitiesService } from '../shared/_services/filter-utilities.service';
|
import { FilterQueryParam } from '../shared/_services/filter-utilities.service';
|
||||||
import { CollectionTag } from '../_models/collection-tag';
|
import { CollectionTag } from '../_models/collection-tag';
|
||||||
import { Library } from '../_models/library';
|
import { Library } from '../_models/library';
|
||||||
import { PersonRole } from '../_models/person';
|
import { PersonRole } from '../_models/person';
|
||||||
@ -46,19 +46,20 @@ export class NavHeaderComponent implements OnInit, OnDestroy {
|
|||||||
searchFocused: boolean = false;
|
searchFocused: boolean = false;
|
||||||
private readonly onDestroy = new Subject<void>();
|
private readonly onDestroy = new Subject<void>();
|
||||||
|
|
||||||
constructor(public accountService: AccountService, private router: Router, public navService: NavService,
|
constructor(public accountService: AccountService, private router: Router, public navService: NavService,
|
||||||
private libraryService: LibraryService, public imageService: ImageService, @Inject(DOCUMENT) private document: Document,
|
private libraryService: LibraryService, public imageService: ImageService, @Inject(DOCUMENT) private document: Document,
|
||||||
private scrollService: ScrollService, private filterUtilityService: FilterUtilitiesService) { }
|
private scrollService: ScrollService) { }
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {}
|
||||||
fromEvent(this.document.body, 'scroll').pipe(takeUntil(this.onDestroy)).subscribe(() => {
|
|
||||||
const offset = this.scrollService.scrollPosition;
|
@HostListener('body:scroll', [])
|
||||||
if (offset > 100) {
|
checkBackToTopNeeded() {
|
||||||
this.backToTopNeeded = true;
|
const offset = this.scrollService.scrollPosition;
|
||||||
} else if (offset < 40) {
|
if (offset > 100) {
|
||||||
this.backToTopNeeded = false;
|
this.backToTopNeeded = true;
|
||||||
}
|
} else if (offset < 40) {
|
||||||
})
|
this.backToTopNeeded = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
@ -77,7 +78,7 @@ export class NavHeaderComponent implements OnInit, OnDestroy {
|
|||||||
this.document.getElementById('content')?.focus();
|
this.document.getElementById('content')?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
onChangeSearch(val: string) {
|
onChangeSearch(val: string) {
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
|
@ -5,6 +5,7 @@ import { PublicationStatusPipe } from './publication-status.pipe';
|
|||||||
import { SentenceCasePipe } from './sentence-case.pipe';
|
import { SentenceCasePipe } from './sentence-case.pipe';
|
||||||
import { PersonRolePipe } from './person-role.pipe';
|
import { PersonRolePipe } from './person-role.pipe';
|
||||||
import { SafeHtmlPipe } from './safe-html.pipe';
|
import { SafeHtmlPipe } from './safe-html.pipe';
|
||||||
|
import { RelationshipPipe } from './relationship.pipe';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -14,7 +15,8 @@ import { SafeHtmlPipe } from './safe-html.pipe';
|
|||||||
PersonRolePipe,
|
PersonRolePipe,
|
||||||
PublicationStatusPipe,
|
PublicationStatusPipe,
|
||||||
SentenceCasePipe,
|
SentenceCasePipe,
|
||||||
SafeHtmlPipe
|
SafeHtmlPipe,
|
||||||
|
RelationshipPipe
|
||||||
],
|
],
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
@ -24,7 +26,8 @@ import { SafeHtmlPipe } from './safe-html.pipe';
|
|||||||
PersonRolePipe,
|
PersonRolePipe,
|
||||||
PublicationStatusPipe,
|
PublicationStatusPipe,
|
||||||
SentenceCasePipe,
|
SentenceCasePipe,
|
||||||
SafeHtmlPipe
|
SafeHtmlPipe,
|
||||||
|
RelationshipPipe
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class PipeModule { }
|
export class PipeModule { }
|
||||||
|
8
UI/Web/src/app/pipe/relationship.pipe.spec.ts
Normal file
8
UI/Web/src/app/pipe/relationship.pipe.spec.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { RelationshipPipe } from './relationship.pipe';
|
||||||
|
|
||||||
|
describe('RelationshipPipe', () => {
|
||||||
|
it('create an instance', () => {
|
||||||
|
const pipe = new RelationshipPipe();
|
||||||
|
expect(pipe).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
41
UI/Web/src/app/pipe/relationship.pipe.ts
Normal file
41
UI/Web/src/app/pipe/relationship.pipe.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { Pipe, PipeTransform } from '@angular/core';
|
||||||
|
import { RelationKind } from '../_models/series-detail/relation-kind';
|
||||||
|
|
||||||
|
@Pipe({
|
||||||
|
name: 'relationship'
|
||||||
|
})
|
||||||
|
export class RelationshipPipe implements PipeTransform {
|
||||||
|
|
||||||
|
transform(relationship: RelationKind | undefined): string {
|
||||||
|
if (relationship === undefined) return '';
|
||||||
|
switch (relationship) {
|
||||||
|
case RelationKind.Adaptation:
|
||||||
|
return 'Adaptaion';
|
||||||
|
case RelationKind.AlternativeSetting:
|
||||||
|
return 'Alternative Setting';
|
||||||
|
case RelationKind.AlternativeVersion:
|
||||||
|
return 'Alternative Version';
|
||||||
|
case RelationKind.Character:
|
||||||
|
return 'Character';
|
||||||
|
case RelationKind.Contains:
|
||||||
|
return 'Contains';
|
||||||
|
case RelationKind.Doujinshi:
|
||||||
|
return 'Doujinshi';
|
||||||
|
case RelationKind.Other:
|
||||||
|
return 'Other';
|
||||||
|
case RelationKind.Prequel:
|
||||||
|
return 'Prequel';
|
||||||
|
case RelationKind.Sequel:
|
||||||
|
return 'Sequel';
|
||||||
|
case RelationKind.SideStory:
|
||||||
|
return 'Side Story';
|
||||||
|
case RelationKind.SpinOff:
|
||||||
|
return 'Spin Off';
|
||||||
|
case RelationKind.Parent:
|
||||||
|
return 'Parent';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -69,6 +69,17 @@
|
|||||||
<ng-container>
|
<ng-container>
|
||||||
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
|
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
|
||||||
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTabId" class="nav nav-tabs mb-2" [destroyOnHide]="false" (navChange)="onNavChange($event)">
|
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTabId" class="nav nav-tabs mb-2" [destroyOnHide]="false" (navChange)="onNavChange($event)">
|
||||||
|
<li [ngbNavItem]="TabID.Related" *ngIf="hasRelations">
|
||||||
|
<a ngbNavLink>Related</a>
|
||||||
|
<ng-template ngbNavContent>
|
||||||
|
<div class="row g-0">
|
||||||
|
<ng-container *ngFor="let item of relations; let idx = index; trackBy: trackByRelatedSeriesIdentiy">
|
||||||
|
<app-series-card class="col-auto p-2" [data]="item.series" [libraryId]="item.series.libraryId" [relation]="item.relation"></app-series-card>
|
||||||
|
<!--(reload)="reloadInProgress($event)" (dataChanged)="reloadInProgress($event)"-->
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
</li>
|
||||||
<li [ngbNavItem]="TabID.Specials" *ngIf="hasSpecials">
|
<li [ngbNavItem]="TabID.Specials" *ngIf="hasSpecials">
|
||||||
<a ngbNavLink>Specials</a>
|
<a ngbNavLink>Specials</a>
|
||||||
<ng-template ngbNavContent>
|
<ng-template ngbNavContent>
|
||||||
|
@ -33,9 +33,16 @@ import { ReaderService } from '../_services/reader.service';
|
|||||||
import { ReadingListService } from '../_services/reading-list.service';
|
import { ReadingListService } from '../_services/reading-list.service';
|
||||||
import { SeriesService } from '../_services/series.service';
|
import { SeriesService } from '../_services/series.service';
|
||||||
import { NavService } from '../_services/nav.service';
|
import { NavService } from '../_services/nav.service';
|
||||||
|
import { RelationKind } from '../_models/series-detail/relation-kind';
|
||||||
|
import { RelatedSeries } from '../_models/series-detail/related-series';
|
||||||
|
|
||||||
|
interface RelatedSeris {
|
||||||
|
series: Series;
|
||||||
|
relation: RelationKind;
|
||||||
|
}
|
||||||
|
|
||||||
enum TabID {
|
enum TabID {
|
||||||
|
Related = 0,
|
||||||
Specials = 1,
|
Specials = 1,
|
||||||
Storyline = 2,
|
Storyline = 2,
|
||||||
Volumes = 3,
|
Volumes = 3,
|
||||||
@ -106,6 +113,16 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
|
|||||||
* Track by function for Chapter to tell when to refresh card data
|
* Track by function for Chapter to tell when to refresh card data
|
||||||
*/
|
*/
|
||||||
trackByChapterIdentity = (index: number, item: Chapter) => `${item.title}_${item.number}_${item.pagesRead}`;
|
trackByChapterIdentity = (index: number, item: Chapter) => `${item.title}_${item.number}_${item.pagesRead}`;
|
||||||
|
trackByRelatedSeriesIdentiy = (index: number, item: RelatedSeris) => `${item.series.name}_${item.series.libraryId}_${item.series.pagesRead}_${item.relation}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Are there any related series
|
||||||
|
*/
|
||||||
|
hasRelations: boolean = false;
|
||||||
|
/**
|
||||||
|
* Related Series. Sorted by backend
|
||||||
|
*/
|
||||||
|
relations: Array<RelatedSeris> = [];
|
||||||
|
|
||||||
bulkActionCallback = (action: Action, data: any) => {
|
bulkActionCallback = (action: Action, data: any) => {
|
||||||
if (this.series === undefined) {
|
if (this.series === undefined) {
|
||||||
@ -356,6 +373,27 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
|
|||||||
this.volumeActions = this.actionFactoryService.getVolumeActions(this.handleVolumeActionCallback.bind(this));
|
this.volumeActions = this.actionFactoryService.getVolumeActions(this.handleVolumeActionCallback.bind(this));
|
||||||
this.chapterActions = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this));
|
this.chapterActions = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this));
|
||||||
|
|
||||||
|
// TODO: Move this to a forkJoin?
|
||||||
|
this.seriesService.getRelatedForSeries(this.seriesId).subscribe((relations: RelatedSeries) => {
|
||||||
|
this.relations = [
|
||||||
|
...relations.prequels.map(item => this.createRelatedSeries(item, RelationKind.Prequel)),
|
||||||
|
...relations.sequels.map(item => this.createRelatedSeries(item, RelationKind.Sequel)),
|
||||||
|
...relations.sideStories.map(item => this.createRelatedSeries(item, RelationKind.SideStory)),
|
||||||
|
...relations.spinOffs.map(item => this.createRelatedSeries(item, RelationKind.SpinOff)),
|
||||||
|
...relations.adaptations.map(item => this.createRelatedSeries(item, RelationKind.Adaptation)),
|
||||||
|
...relations.contains.map(item => this.createRelatedSeries(item, RelationKind.Contains)),
|
||||||
|
...relations.characters.map(item => this.createRelatedSeries(item, RelationKind.Character)),
|
||||||
|
...relations.others.map(item => this.createRelatedSeries(item, RelationKind.Other)),
|
||||||
|
...relations.alternativeSettings.map(item => this.createRelatedSeries(item, RelationKind.AlternativeSetting)),
|
||||||
|
...relations.alternativeVersions.map(item => this.createRelatedSeries(item, RelationKind.AlternativeVersion)),
|
||||||
|
...relations.doujinshis.map(item => this.createRelatedSeries(item, RelationKind.Doujinshi)),
|
||||||
|
...relations.parent.map(item => this.createRelatedSeries(item, RelationKind.Parent)),
|
||||||
|
];
|
||||||
|
if (this.relations.length > 0) {
|
||||||
|
this.hasRelations = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this.seriesService.getSeriesDetail(this.seriesId).subscribe(detail => {
|
this.seriesService.getSeriesDetail(this.seriesId).subscribe(detail => {
|
||||||
this.hasSpecials = detail.specials.length > 0;
|
this.hasSpecials = detail.specials.length > 0;
|
||||||
this.specials = detail.specials;
|
this.specials = detail.specials;
|
||||||
@ -372,6 +410,10 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createRelatedSeries(series: Series, relation: RelationKind) {
|
||||||
|
return {series, relation} as RelatedSeris;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This will update the selected tab
|
* This will update the selected tab
|
||||||
*
|
*
|
||||||
|
@ -47,7 +47,7 @@ export class SideNavCompanionBarComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
// If user opens side nav while filter is open on mobile, then collapse filter (as it doesn't render well) TODO: Change this when we have new drawer
|
// If user opens side nav while filter is open on mobile, then collapse filter (as it doesn't render well) TODO: Change this when we have new drawer
|
||||||
this.navService.sideNavCollapsed$.pipe(takeUntil(this.onDestroy)).subscribe(sideNavCollapsed => {
|
this.navService.sideNavCollapsed$.pipe(takeUntil(this.onDestroy)).subscribe(sideNavCollapsed => {
|
||||||
if (this.isFilterOpen && sideNavCollapsed && this.utilityService.getActiveBreakpoint() < Breakpoint.Desktop) {
|
if (this.isFilterOpen && sideNavCollapsed && this.utilityService.getActiveBreakpoint() < Breakpoint.Tablet) {
|
||||||
this.isFilterOpen = false;
|
this.isFilterOpen = false;
|
||||||
this.filterOpen.emit(this.isFilterOpen);
|
this.filterOpen.emit(this.isFilterOpen);
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
<ng-container *ngIf="link === undefined || link.length === 0; else useLink">
|
<ng-container *ngIf="link === undefined || link.length === 0; else useLink">
|
||||||
<div class="side-nav-item" [ngClass]="{'closed': !(navService?.sideNavCollapsed$ | async), 'active': highlighted}">
|
<div class="side-nav-item" [ngClass]="{'closed': (navService?.sideNavCollapsed$ | async), 'active': highlighted}">
|
||||||
<ng-container [ngTemplateOutlet]="inner"></ng-container>
|
<ng-container [ngTemplateOutlet]="inner"></ng-container>
|
||||||
</div>
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-template #useLink>
|
<ng-template #useLink>
|
||||||
<a class="side-nav-item" href="javascript:void(0);" [ngClass]="{'closed': !(navService?.sideNavCollapsed$ | async), 'active': highlighted}" [routerLink]="link">
|
<a class="side-nav-item" href="javascript:void(0);" [ngClass]="{'closed': (navService?.sideNavCollapsed$ | async), 'active': highlighted}" [routerLink]="link">
|
||||||
<ng-container [ngTemplateOutlet]="inner"></ng-container>
|
<ng-container [ngTemplateOutlet]="inner"></ng-container>
|
||||||
</a>
|
</a>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<ng-container>
|
<ng-container>
|
||||||
<div class="side-nav" [ngClass]="{'closed' : !(navService?.sideNavCollapsed$ | async), 'hidden' :!(navService?.sideNavVisibility$ | async)}" *ngIf="accountService.currentUser$ | async as user">
|
<div class="side-nav" [ngClass]="{'closed' : (navService?.sideNavCollapsed$ | async), 'hidden' :!(navService?.sideNavVisibility$ | async)}" *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
|
Todo: This will be customize dashboard/side nav controls
|
||||||
@ -26,5 +26,5 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
</app-side-nav-item>
|
</app-side-nav-item>
|
||||||
</div>
|
</div>
|
||||||
<div class="side-nav-overlay" (click)="navService?.toggleSideNav()" [ngClass]="{'closed' : !(navService?.sideNavCollapsed$ | async)}"></div>
|
<div class="side-nav-overlay" (click)="navService?.toggleSideNav()" [ngClass]="{'closed' : (navService?.sideNavCollapsed$ | async)}"></div>
|
||||||
</ng-container>
|
</ng-container>
|
@ -60,9 +60,14 @@ export class SideNavComponent implements OnInit, OnDestroy {
|
|||||||
takeUntil(this.onDestroy),
|
takeUntil(this.onDestroy),
|
||||||
map(evt => evt as NavigationEnd))
|
map(evt => evt as NavigationEnd))
|
||||||
.subscribe((evt: NavigationEnd) => {
|
.subscribe((evt: NavigationEnd) => {
|
||||||
if (this.utilityService.getActiveBreakpoint() < Breakpoint.Desktop) {
|
if (this.utilityService.getActiveBreakpoint() < Breakpoint.Tablet) {
|
||||||
// collapse side nav
|
// collapse side nav
|
||||||
this.navService.toggleSideNav();
|
this.navService.sideNavCollapsed$.pipe(take(1)).subscribe(collapsed => {
|
||||||
|
console.log('Side nav collapsed: ', collapsed);
|
||||||
|
if (!collapsed) {
|
||||||
|
this.navService.toggleSideNav();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user