mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-05-24 00:52:23 -04:00
Side nav (#1155)
* adding back side-nav * Event Widget Update (#1098) * Took care of some notes in the code * Fixed an issue where Extra might get flagged as special too early, if in a word like Extraordinary * Moved Tag cleanup code into Scanner service. Added a SplitQuery to another heavy API. Refactored Scan loop to remove parallelism and use async instead. * Lots of rework on the codebase to support detailed messages and easier management of message sending. Need to take a break on this work. * Progress is being made, but slowly. Code is broken in this commit. * Progress is being made, but slowly. Code is broken in this commit. * Fixed merge issue * Fixed unit tests * CoverUpdate is now hooked into new ProgressEvent structure * Refactored code to remove custom observables and have everything use standard messages$ * Refactored a ton of instances to NotificationProgressEvent style and tons of the UI to respect that too. UI is still a bit buggy, but wholistically the work is done. * Working much better. Sometimes events come in too fast. Currently cover update progress doesn't display on UI * Fixed unit tests * Removed SignalREvent to minimize internal event types. Updated the UI to use progress bars. Finished SiteThemeService. * Merged metadata refresh progress events and changed library scan events to merge cleaner in the UI * Changed RefreshMetadataProgress to CoverUpdateProgress to reflect the event better. * Theme Cleanup (#1089) * Fixed e-ink theme not properly applying correctly * Fixed some seed changes. Changed card checkboxes to use our themed ones * Fixed recently added carousel not going to recently-added page * Fixed an issue where no results found would show when searching for a library name * Cleaned up list a bit, typeahead dropdown still needs work * Added a TODO to streamline series-card component * Removed ng-lazyload-image module since we don't use it. We use lazysizes * Darken card on hover * Fixing accordion focus style * ux pass updates - Fixed typeahead width - Fixed changelog download buttons - Fixed a select - Fixed various input box-shadows - Fixed all anchors to only have underline on hover - Added navtab hover and active effects * more ux pass - Fixed spacing on theme cards - Fixed some light theme issues - Exposed text-muted-color for theme card subtitle color * UX pass fixes - Changed back to bright green for primary on dark theme - Changed fa icon to black on e-ink * Merged changelog component * Fixed anchor buttons text decoration * Changed nav tabs to have a background color instead of open active state * When user is not authenticated, make sure we set default theme (dark) * Cleanup on carousel * Updated Users tab to use small buttons with icons to align with Library tab * Cleaned up brand to not underline, removed default link underline on hover in dropdown and pill tabs * Fixed collection detail posters not rendering Co-authored-by: Robbie Davis <robbie@therobbiedavis.com> * Bump versions by dotnet-bump-version. * Tweaked some of the emitting code * Some css, but pretty bad. Robbie please save me * Removed a todo * styling update * Only send filename on FileScanProgress * Some console.log spam cleanup * Various updates * Show events widget activity based on activeEvents * progress bar color updates * Code cleanup Co-authored-by: Robbie Davis <robbie@therobbiedavis.com> * Bump versions by dotnet-bump-version. * Scanner event hub fix (#1099) * Scanner event hub fix - Fixed an issue where the scanner would error when adding a new series because the series didn't have a library name yet. (develop) * Removing library.type * Bump versions by dotnet-bump-version. * Workflow update to add nightly versions (#1100) # Changed - Changed: Changed automated workflow to release individual nightly versions on dockerhub * Bump versions by dotnet-bump-version. * Updating GA to parse version (#1101) * Bump versions by dotnet-bump-version. * GA Fixes (#1103) **Strictly Repo Changes** # Fixed - Fixed: Fixed an issue where patch version was not being added to docker tag. * Bump versions by dotnet-bump-version. * Fixed specials being misaligned (#1106) # Fixed - Fixed: Fixed issue with specials not being properly aligned (develop) * Bump versions by dotnet-bump-version. * Bugfix/ux pass 2 (#1107) * Adding margin bottom to series detail tabs * Styling tag badges with green on dark - Added 3 new css vars * Removing underline from readmore * Fixing see more to be on one line * adding gutter to see more * Changing queue toasts to info * adding api key tooltip * Updating active accordion on user preference. * Fixing search bar and close btn position * Fixed a bug where entering book reader in dark mode then closing out, would leave you in a broken white state. * Fixed broken wiki links Co-authored-by: Joseph Milazzo <joseph.v.milazzo@gmail.com> * Bump versions by dotnet-bump-version. * Series Detail Refactor (#1118) * Fixed a bug where reading list and collection's summary wouldn't render newlines * Moved all the logic in the UI for Series Detail into the backend (messy code). We are averaging 400ms max with much optimizations available. Next step is to refactor out of controller and provide unit tests. * Unit tests for CleanSpecialTitle * Laid out foundation for testing major code in SeriesController. * Refactored code so that read doesn't need to be disabled on page load. SeriesId doesn't need the series to actually load. * Removed old property from Volume * Changed tagbadge font size to rem. * Refactored some methods from SeriesController.cs into SeriesService.cs * UpdateRating unit tested * Wrote unit tests for SeriesDetail * Worked up some code where books are rendered only as volumes. However, looks like I will need to use Chapters to better support series_index as floats. * Refactored Series Detail to change Volume Name on Book libraries to have book name and series_index. * Some cleanup on the code * DeleteMultipleSeries test is hard. Going to skip. * Removed some debug code and make all tabs Books for Book library Type * Bump versions by dotnet-bump-version. * Tachiyomi Bugfix (#1119) * Updated the dependencies for .NET 6.0.2 * Fixed a bad prev chapter logic where we would bleed into chapters from last volume instead of specials. * Fixed the get prev chapter code to properly walk the order according to documentation and updated some bad test cases * Updated side nav to float a bit and added user settings to it. * Refactored the code to hide/show sidenav to be more angular and decoupled * Moved Changelog out of admin dashboard and into a dedicated page in user menu. Added a wiki link from user menu * Introduced a side nav item for rendering each item and refactored code to use it. * Added a filter of side nav when there are more than 10 libraries. Added some themeing overrides for side nav. * Cleaned up the template code for side nav item so if there is no link, we don't generate that html directive * Refactored side nav into a module and migrated a few pipes into a pipe module for easy re-use * Added companion bar on reading list and collection. Updated modules to load pages and make side nav items clickable as anchors, so new tab works. * Moved metadata filter into separate component/module and the button in the companion bar. Needs cleanup. * Finished cleanup and refactoring of metadata filter into separate component. Removed filtering from Collections as it doesn't work and wasn't hooked up. * Tweaked the css on carousel component * Added to library detail and series-detail * Fixes and css vars * Stop destroying sidenav, animaton timing * Integrated side nav on the rest of the pages * Navbar now collapses to icons * mobile sidenav start * more mobile fixes * mobile tweaks * light and e-ink theme updates * white and eink dropdown color fixes * plex inspired side-nav * theme fixes * Making spacing more uniform across app * More fixes * fixing spacing on cards * actionable fix for sidenav * no scroll on mobile when sidenav is open * hide sidenav on pages * Adding card spacing * Adding ability to remove sidenav when in a reader * tidying up sidenav toggles * side-nav mobile updates * fixing up other themes * overlay fixes * Cleaned up the code to make the observables have better names. Removed a bunch of pointless subscriptions. Cleaned up methods that werent needed. Added jsdocs to help ensure the understandability of the 2 states for the side nav. * Integrated a highlight effect on side nav. Fixed a ton of places where the nav was being hidden when it shouldn't. * Fixed where active state wasn't working on all urls * misc fixes - smaller hamburger - z-index fixes - active fixes * Revert "Merge branch 'develop' into feature/side-nav-upgrade" This reverts commit 76b0d15a984692874e0cb57e821686ea703144cf, reversing changes made to b3ed55395473aa35577500596a211ad22a42631b. * Fixing edit-series modal spacing * Give the ability to jump to a library from admin manage libraries page * Fixed a bug with highlighting active item on side nav * Moved localized series title to companion bar via subtitle * Removed old title * Fixed a bug where clicking a link would reload the whole app, styling fixes on filter, fixed issue with initial load not setting active state, adjusted styles on active style. * code cleanup Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
This commit is contained in:
parent
20d48465a1
commit
62afacae55
2
.github/workflows/sonar-scan.yml
vendored
2
.github/workflows/sonar-scan.yml
vendored
@ -230,7 +230,7 @@ jobs:
|
||||
severity: info
|
||||
description: v${{steps.get-version.outputs.assembly-version}} - ${{ steps.findPr.outputs.title }}
|
||||
details: '${{ steps.parse-body.outputs.BODY }}'
|
||||
text: <@&950058626658234398> A new nightly build has been released for docker.
|
||||
text: <@&939225459156217917> <@&939225350775406643> A new nightly build has been released for docker.
|
||||
webhookUrl: ${{ secrets.DISCORD_DOCKER_UPDATE_URL }}
|
||||
|
||||
stable:
|
||||
|
@ -1650,61 +1650,6 @@ public class ReaderServiceTests
|
||||
Assert.Equal("Some Special Title", nextChapter.Range);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetContinuePoint_ShouldReturnFirstVolumeChapter_WhenPreExistingProgress()
|
||||
{
|
||||
var series = new Series()
|
||||
{
|
||||
Name = "Test",
|
||||
Library = new Library()
|
||||
{
|
||||
Name = "Test LIb",
|
||||
Type = LibraryType.Manga,
|
||||
},
|
||||
Volumes = new List<Volume>()
|
||||
{
|
||||
EntityFactory.CreateVolume("0", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("230", false, new List<MangaFile>(), 1),
|
||||
//EntityFactory.CreateChapter("231", false, new List<MangaFile>(), 1), (added later)
|
||||
}),
|
||||
EntityFactory.CreateVolume("1", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("1", false, new List<MangaFile>(), 1),
|
||||
EntityFactory.CreateChapter("2", false, new List<MangaFile>(), 1),
|
||||
}),
|
||||
EntityFactory.CreateVolume("2", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("0", false, new List<MangaFile>(), 1),
|
||||
//EntityFactory.CreateChapter("14.9", false, new List<MangaFile>(), 1), (added later)
|
||||
}),
|
||||
}
|
||||
};
|
||||
_context.Series.Add(series);
|
||||
|
||||
_context.AppUser.Add(new AppUser()
|
||||
{
|
||||
UserName = "majora2007"
|
||||
});
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
|
||||
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>());
|
||||
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress);
|
||||
await readerService.MarkSeriesAsRead(user, 1);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Add 2 new unread series to the Series
|
||||
series.Volumes[0].Chapters.Add(EntityFactory.CreateChapter("231", false, new List<MangaFile>(), 1));
|
||||
series.Volumes[2].Chapters.Add(EntityFactory.CreateChapter("14.9", false, new List<MangaFile>(), 1));
|
||||
_context.Series.Attach(series);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var nextChapter = await readerService.GetContinuePoint(1, 1);
|
||||
Assert.Equal("14.9", nextChapter.Range);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region MarkChaptersUntilAsRead
|
||||
@ -1838,126 +1783,5 @@ public class ReaderServiceTests
|
||||
#endregion
|
||||
|
||||
|
||||
#region MarkSeriesAsRead
|
||||
|
||||
[Fact]
|
||||
public async Task MarkSeriesAsReadTest()
|
||||
{
|
||||
await ResetDB();
|
||||
|
||||
_context.Series.Add(new Series()
|
||||
{
|
||||
Name = "Test",
|
||||
Library = new Library() {
|
||||
Name = "Test LIb",
|
||||
Type = LibraryType.Manga,
|
||||
},
|
||||
Volumes = new List<Volume>()
|
||||
{
|
||||
new Volume()
|
||||
{
|
||||
Chapters = new List<Chapter>()
|
||||
{
|
||||
new Chapter()
|
||||
{
|
||||
Pages = 1
|
||||
},
|
||||
new Chapter()
|
||||
{
|
||||
Pages = 2
|
||||
}
|
||||
}
|
||||
},
|
||||
new Volume()
|
||||
{
|
||||
Chapters = new List<Chapter>()
|
||||
{
|
||||
new Chapter()
|
||||
{
|
||||
Pages = 1
|
||||
},
|
||||
new Chapter()
|
||||
{
|
||||
Pages = 2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
_context.AppUser.Add(new AppUser()
|
||||
{
|
||||
UserName = "majora2007"
|
||||
});
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>());
|
||||
|
||||
await readerService.MarkSeriesAsRead(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
Assert.Equal(4, (await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses.Count);
|
||||
}
|
||||
|
||||
|
||||
#endregion
|
||||
|
||||
#region MarkSeriesAsUnread
|
||||
|
||||
[Fact]
|
||||
public async Task MarkSeriesAsUnreadTest()
|
||||
{
|
||||
await ResetDB();
|
||||
|
||||
_context.Series.Add(new Series()
|
||||
{
|
||||
Name = "Test",
|
||||
Library = new Library() {
|
||||
Name = "Test LIb",
|
||||
Type = LibraryType.Manga,
|
||||
},
|
||||
Volumes = new List<Volume>()
|
||||
{
|
||||
new Volume()
|
||||
{
|
||||
Chapters = new List<Chapter>()
|
||||
{
|
||||
new Chapter()
|
||||
{
|
||||
Pages = 1
|
||||
},
|
||||
new Chapter()
|
||||
{
|
||||
Pages = 2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
_context.AppUser.Add(new AppUser()
|
||||
{
|
||||
UserName = "majora2007"
|
||||
});
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>());
|
||||
|
||||
var volumes = (await _unitOfWork.VolumeRepository.GetVolumes(1)).ToList();
|
||||
readerService.MarkChaptersAsRead(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1, volumes.First().Chapters);
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
Assert.Equal(2, (await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses.Count);
|
||||
|
||||
await readerService.MarkSeriesAsUnread(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var progresses = (await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses;
|
||||
Assert.Equal(0, progresses.Max(p => p.PagesRead));
|
||||
Assert.Equal(2, progresses.Count);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
@ -338,12 +338,6 @@ namespace API.Controllers
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Invites a user to the server. Will generate a setup link for continuing setup. If the server is not accessible, no
|
||||
/// email will be sent.
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
[Authorize(Policy = "RequireAdminRole")]
|
||||
[HttpPost("invite")]
|
||||
public async Task<ActionResult<string>> InviteUser(InviteUserDto dto)
|
||||
@ -423,9 +417,7 @@ namespace API.Controllers
|
||||
|
||||
var emailLink = GenerateEmailLink(token, "confirm-email", dto.Email);
|
||||
_logger.LogCritical("[Invite User]: Email Link for {UserName}: {Link}", user.UserName, emailLink);
|
||||
var host = _environment.IsDevelopment() ? "localhost:4200" : Request.Host.ToString();
|
||||
var accessible = await _emailService.CheckIfAccessible(host);
|
||||
if (accessible)
|
||||
if (dto.SendEmail)
|
||||
{
|
||||
await _emailService.SendConfirmationEmail(new ConfirmationEmailDto()
|
||||
{
|
||||
@ -434,11 +426,7 @@ namespace API.Controllers
|
||||
ServerConfirmationLink = emailLink
|
||||
});
|
||||
}
|
||||
return Ok(new InviteUserResponse
|
||||
{
|
||||
EmailLink = emailLink,
|
||||
EmailSent = accessible
|
||||
});
|
||||
return Ok(emailLink);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
|
@ -109,7 +109,14 @@ namespace API.Controllers
|
||||
public async Task<ActionResult> MarkRead(MarkReadDto markReadDto)
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress);
|
||||
await _readerService.MarkSeriesAsRead(user, markReadDto.SeriesId);
|
||||
var volumes = await _unitOfWork.VolumeRepository.GetVolumes(markReadDto.SeriesId);
|
||||
user.Progresses ??= new List<AppUserProgress>();
|
||||
foreach (var volume in volumes)
|
||||
{
|
||||
_readerService.MarkChaptersAsRead(user, markReadDto.SeriesId, volume.Chapters);
|
||||
}
|
||||
|
||||
_unitOfWork.UserRepository.Update(user);
|
||||
|
||||
if (await _unitOfWork.CommitAsync())
|
||||
{
|
||||
@ -130,7 +137,14 @@ namespace API.Controllers
|
||||
public async Task<ActionResult> MarkUnread(MarkReadDto markReadDto)
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress);
|
||||
await _readerService.MarkSeriesAsUnread(user, markReadDto.SeriesId);
|
||||
var volumes = await _unitOfWork.VolumeRepository.GetVolumes(markReadDto.SeriesId);
|
||||
user.Progresses ??= new List<AppUserProgress>();
|
||||
foreach (var volume in volumes)
|
||||
{
|
||||
_readerService.MarkChaptersAsUnread(user, markReadDto.SeriesId, volume.Chapters);
|
||||
}
|
||||
|
||||
_unitOfWork.UserRepository.Update(user);
|
||||
|
||||
if (await _unitOfWork.CommitAsync())
|
||||
{
|
||||
|
@ -16,4 +16,6 @@ public class InviteUserDto
|
||||
/// A list of libraries to grant access to
|
||||
/// </summary>
|
||||
public IList<int> Libraries { get; init; }
|
||||
|
||||
public bool SendEmail { get; init; } = true;
|
||||
}
|
||||
|
@ -1,13 +0,0 @@
|
||||
namespace API.DTOs.Account;
|
||||
|
||||
public class InviteUserResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Email link used to setup the user account
|
||||
/// </summary>
|
||||
public string EmailLink { get; set; }
|
||||
/// <summary>
|
||||
/// Was an email sent (ie is this server accessible)
|
||||
/// </summary>
|
||||
public bool EmailSent { get; set; }
|
||||
}
|
@ -25,51 +25,51 @@ namespace API.DTOs.Filtering
|
||||
/// </summary>
|
||||
public IList<int> Genres { get; init; } = new List<int>();
|
||||
/// <summary>
|
||||
/// A list of Writers to restrict search to. Defaults to all Writers by passing an empty list
|
||||
/// A list of Writers to restrict search to. Defaults to all genres by passing an empty list
|
||||
/// </summary>
|
||||
public IList<int> Writers { get; init; } = new List<int>();
|
||||
/// <summary>
|
||||
/// A list of Penciller ids to restrict search to. Defaults to all Pencillers by passing an empty list
|
||||
/// A list of Penciller ids to restrict search to. Defaults to all genres by passing an empty list
|
||||
/// </summary>
|
||||
public IList<int> Penciller { get; init; } = new List<int>();
|
||||
/// <summary>
|
||||
/// A list of Inker ids to restrict search to. Defaults to all Inkers by passing an empty list
|
||||
/// A list of Inker ids to restrict search to. Defaults to all genres by passing an empty list
|
||||
/// </summary>
|
||||
public IList<int> Inker { get; init; } = new List<int>();
|
||||
/// <summary>
|
||||
/// A list of Colorist ids to restrict search to. Defaults to all Colorists by passing an empty list
|
||||
/// A list of Colorist ids to restrict search to. Defaults to all genres by passing an empty list
|
||||
/// </summary>
|
||||
public IList<int> Colorist { get; init; } = new List<int>();
|
||||
/// <summary>
|
||||
/// A list of Letterer ids to restrict search to. Defaults to all Letterers by passing an empty list
|
||||
/// A list of Letterer ids to restrict search to. Defaults to all genres by passing an empty list
|
||||
/// </summary>
|
||||
public IList<int> Letterer { get; init; } = new List<int>();
|
||||
/// <summary>
|
||||
/// A list of CoverArtist ids to restrict search to. Defaults to all CoverArtists by passing an empty list
|
||||
/// A list of CoverArtist ids to restrict search to. Defaults to all genres by passing an empty list
|
||||
/// </summary>
|
||||
public IList<int> CoverArtist { get; init; } = new List<int>();
|
||||
/// <summary>
|
||||
/// A list of Editor ids to restrict search to. Defaults to all Editors by passing an empty list
|
||||
/// A list of Editor ids to restrict search to. Defaults to all genres by passing an empty list
|
||||
/// </summary>
|
||||
public IList<int> Editor { get; init; } = new List<int>();
|
||||
/// <summary>
|
||||
/// A list of Publisher ids to restrict search to. Defaults to all Publishers by passing an empty list
|
||||
/// A list of Publisher ids to restrict search to. Defaults to all genres by passing an empty list
|
||||
/// </summary>
|
||||
public IList<int> Publisher { get; init; } = new List<int>();
|
||||
/// <summary>
|
||||
/// A list of Character ids to restrict search to. Defaults to all Characters by passing an empty list
|
||||
/// A list of Character ids to restrict search to. Defaults to all genres by passing an empty list
|
||||
/// </summary>
|
||||
public IList<int> Character { get; init; } = new List<int>();
|
||||
/// <summary>
|
||||
/// A list of Translator ids to restrict search to. Defaults to all Translatorss by passing an empty list
|
||||
/// A list of Translator ids to restrict search to. Defaults to all genres by passing an empty list
|
||||
/// </summary>
|
||||
public IList<int> Translators { get; init; } = new List<int>();
|
||||
/// <summary>
|
||||
/// A list of Collection Tag ids to restrict search to. Defaults to all Collection Tags by passing an empty list
|
||||
/// A list of Collection Tag ids to restrict search to. Defaults to all genres by passing an empty list
|
||||
/// </summary>
|
||||
public IList<int> CollectionTags { get; init; } = new List<int>();
|
||||
/// <summary>
|
||||
/// A list of Tag ids to restrict search to. Defaults to all Tags by passing an empty list
|
||||
/// A list of Tag ids to restrict search to. Defaults to all genres by passing an empty list
|
||||
/// </summary>
|
||||
public IList<int> Tags { get; init; } = new List<int>();
|
||||
/// <summary>
|
||||
|
@ -12,7 +12,6 @@ namespace API.DTOs.Reader
|
||||
public MangaFormat SeriesFormat { get; set; }
|
||||
public int SeriesId { get; set; }
|
||||
public int LibraryId { get; set; }
|
||||
public LibraryType LibraryType { get; set; }
|
||||
public string ChapterTitle { get; set; } = string.Empty;
|
||||
public int Pages { get; set; }
|
||||
public string FileName { get; set; }
|
||||
|
@ -81,8 +81,7 @@ public class ChapterRepository : IChapterRepository
|
||||
data.TitleName,
|
||||
SeriesFormat = series.Format,
|
||||
SeriesName = series.Name,
|
||||
series.LibraryId,
|
||||
LibraryType = series.Library.Type
|
||||
series.LibraryId
|
||||
})
|
||||
.Select(data => new ChapterInfoDto()
|
||||
{
|
||||
@ -90,13 +89,12 @@ public class ChapterRepository : IChapterRepository
|
||||
VolumeNumber = data.VolumeNumber + string.Empty,
|
||||
VolumeId = data.VolumeId,
|
||||
IsSpecial = data.IsSpecial,
|
||||
SeriesId = data.SeriesId,
|
||||
SeriesId =data.SeriesId,
|
||||
SeriesFormat = data.SeriesFormat,
|
||||
SeriesName = data.SeriesName,
|
||||
LibraryId = data.LibraryId,
|
||||
Pages = data.Pages,
|
||||
ChapterTitle = data.TitleName,
|
||||
LibraryType = data.LibraryType
|
||||
ChapterTitle = data.TitleName
|
||||
})
|
||||
.AsNoTracking()
|
||||
.AsSplitQuery()
|
||||
|
@ -79,8 +79,8 @@ public interface ISeriesRepository
|
||||
/// <returns></returns>
|
||||
Task AddSeriesModifiers(int userId, List<SeriesDto> series);
|
||||
Task<string> GetSeriesCoverImageAsync(int seriesId);
|
||||
Task<IEnumerable<SeriesDto>> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto filter, bool cutoffOnDate = true);
|
||||
Task<PagedList<SeriesDto>> GetRecentlyAdded(int libraryId, int userId, UserParams userParams, FilterDto filter);
|
||||
Task<IEnumerable<SeriesDto>> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto filter);
|
||||
Task<PagedList<SeriesDto>> GetRecentlyAdded(int libraryId, int userId, UserParams userParams, FilterDto filter); // NOTE: Probably put this in LibraryRepo
|
||||
Task<SeriesMetadataDto> GetSeriesMetadata(int seriesId);
|
||||
Task<PagedList<SeriesDto>> GetSeriesDtoForCollectionAsync(int collectionId, int userId, UserParams userParams);
|
||||
Task<IList<MangaFile>> GetFilesForSeries(int seriesId);
|
||||
@ -593,11 +593,11 @@ public class SeriesRepository : ISeriesRepository
|
||||
/// <param name="userParams">Pagination information</param>
|
||||
/// <param name="filter">Optional (default null) filter on query</param>
|
||||
/// <returns></returns>
|
||||
public async Task<IEnumerable<SeriesDto>> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto filter, bool cutoffOnDate = true)
|
||||
public async Task<IEnumerable<SeriesDto>> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto filter)
|
||||
{
|
||||
//var allSeriesWithProgress = await _context.AppUserProgresses.Select(p => p.SeriesId).ToListAsync();
|
||||
//var allChapters = await GetChapterIdsForSeriesAsync(allSeriesWithProgress);
|
||||
var cutoffProgressPoint = DateTime.Now - TimeSpan.FromDays(30);
|
||||
var cuttoffProgressPoint = DateTime.Now - TimeSpan.FromDays(30);
|
||||
var query = (await CreateFilteredSearchQueryable(userId, libraryId, filter))
|
||||
.Join(_context.AppUserProgresses, s => s.Id, progress => progress.SeriesId, (s, progress) =>
|
||||
new
|
||||
@ -612,12 +612,8 @@ public class SeriesRepository : ISeriesRepository
|
||||
// This is only taking into account chapters that have progress on them, not all chapters in said series
|
||||
LastChapterCreated = _context.Chapter.Where(c => progress.ChapterId == c.Id).Max(c => c.Created)
|
||||
//LastChapterCreated = _context.Chapter.Where(c => allChapters.Contains(c.Id)).Max(c => c.Created)
|
||||
});
|
||||
if (cutoffOnDate)
|
||||
{
|
||||
query = query.Where(d => d.LastReadingProgress >= cutoffProgressPoint);
|
||||
}
|
||||
|
||||
})
|
||||
.Where(d => d.LastReadingProgress >= cuttoffProgressPoint);
|
||||
// I think I need another Join statement. The problem is the chapters are still limited to progress
|
||||
|
||||
|
||||
|
@ -98,7 +98,7 @@ public class EmailService : IEmailService
|
||||
return await SendEmailWithPost(emailLink + "/api/email/email-password-reset", data);
|
||||
}
|
||||
|
||||
private static async Task<bool> SendEmailWithGet(string url, int timeoutSecs = 30)
|
||||
private static async Task<bool> SendEmailWithGet(string url)
|
||||
{
|
||||
try
|
||||
{
|
||||
@ -108,7 +108,7 @@ public class EmailService : IEmailService
|
||||
.WithHeader("x-api-key", "MsnvA2DfQqxSK5jh")
|
||||
.WithHeader("x-kavita-version", BuildInfo.Version)
|
||||
.WithHeader("Content-Type", "application/json")
|
||||
.WithTimeout(TimeSpan.FromSeconds(timeoutSecs))
|
||||
.WithTimeout(TimeSpan.FromSeconds(30))
|
||||
.GetStringAsync();
|
||||
|
||||
if (!string.IsNullOrEmpty(response) && bool.Parse(response))
|
||||
@ -124,7 +124,7 @@ public class EmailService : IEmailService
|
||||
}
|
||||
|
||||
|
||||
private static async Task<bool> SendEmailWithPost(string url, object data, int timeoutSecs = 30)
|
||||
private static async Task<bool> SendEmailWithPost(string url, object data)
|
||||
{
|
||||
try
|
||||
{
|
||||
@ -134,7 +134,7 @@ public class EmailService : IEmailService
|
||||
.WithHeader("x-api-key", "MsnvA2DfQqxSK5jh")
|
||||
.WithHeader("x-kavita-version", BuildInfo.Version)
|
||||
.WithHeader("Content-Type", "application/json")
|
||||
.WithTimeout(TimeSpan.FromSeconds(timeoutSecs))
|
||||
.WithTimeout(TimeSpan.FromSeconds(30))
|
||||
.PostJsonAsync(data);
|
||||
|
||||
if (response.StatusCode != StatusCodes.Status200OK)
|
||||
|
@ -16,8 +16,6 @@ namespace API.Services;
|
||||
|
||||
public interface IReaderService
|
||||
{
|
||||
Task MarkSeriesAsRead(AppUser user, int seriesId);
|
||||
Task MarkSeriesAsUnread(AppUser user, int seriesId);
|
||||
void MarkChaptersAsRead(AppUser user, int seriesId, IEnumerable<Chapter> chapters);
|
||||
void MarkChaptersAsUnread(AppUser user, int seriesId, IEnumerable<Chapter> chapters);
|
||||
Task<bool> SaveReadingProgress(ProgressDto progressDto, int userId);
|
||||
@ -47,40 +45,6 @@ public class ReaderService : IReaderService
|
||||
return Parser.Parser.NormalizePath(Path.Join(baseDirectory, $"{userId}", $"{seriesId}", $"{chapterId}"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Does not commit. Marks all entities under the series as read.
|
||||
/// </summary>
|
||||
/// <param name="user"></param>
|
||||
/// <param name="seriesId"></param>
|
||||
public async Task MarkSeriesAsRead(AppUser user, int seriesId)
|
||||
{
|
||||
var volumes = await _unitOfWork.VolumeRepository.GetVolumes(seriesId);
|
||||
user.Progresses ??= new List<AppUserProgress>();
|
||||
foreach (var volume in volumes)
|
||||
{
|
||||
MarkChaptersAsRead(user, seriesId, volume.Chapters);
|
||||
}
|
||||
|
||||
_unitOfWork.UserRepository.Update(user);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Does not commit. Marks all entities under the series as unread.
|
||||
/// </summary>
|
||||
/// <param name="user"></param>
|
||||
/// <param name="seriesId"></param>
|
||||
public async Task MarkSeriesAsUnread(AppUser user, int seriesId)
|
||||
{
|
||||
var volumes = await _unitOfWork.VolumeRepository.GetVolumes(seriesId);
|
||||
user.Progresses ??= new List<AppUserProgress>();
|
||||
foreach (var volume in volumes)
|
||||
{
|
||||
MarkChaptersAsUnread(user, seriesId, volume.Chapters);
|
||||
}
|
||||
|
||||
_unitOfWork.UserRepository.Update(user);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks all Chapters as Read by creating or updating UserProgress rows. Does not commit.
|
||||
/// </summary>
|
||||
@ -403,7 +367,7 @@ public class ReaderService : IReaderService
|
||||
.ToList();
|
||||
|
||||
// If there are any volumes that have progress, return those. If not, move on.
|
||||
var currentlyReadingChapter = volumeChapters.FirstOrDefault(chapter => chapter.PagesRead < chapter.Pages); // (removed for GetContinuePoint_ShouldReturnFirstVolumeChapter_WhenPreExistingProgress), not sure if needed && chapter.PagesRead > 0
|
||||
var currentlyReadingChapter = volumeChapters.FirstOrDefault(chapter => chapter.PagesRead < chapter.Pages && chapter.PagesRead > 0);
|
||||
if (currentlyReadingChapter != null) return currentlyReadingChapter;
|
||||
|
||||
// Check loose leaf chapters (and specials). First check if there are any
|
||||
|
@ -19,4 +19,4 @@
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
|
@ -1,10 +0,0 @@
|
||||
export interface InviteUserResponse {
|
||||
/**
|
||||
* Link to register new user
|
||||
*/
|
||||
emailLink: string;
|
||||
/**
|
||||
* If an email was sent to the invited user
|
||||
*/
|
||||
emailSent: boolean;
|
||||
}
|
@ -8,7 +8,6 @@ import { User } from '../_models/user';
|
||||
import { Router } from '@angular/router';
|
||||
import { MessageHubService } from './message-hub.service';
|
||||
import { ThemeService } from '../theme.service';
|
||||
import { InviteUserResponse } from '../_models/invite-user-response';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@ -22,9 +21,6 @@ export class AccountService implements OnDestroy {
|
||||
|
||||
// Stores values, when someone subscribes gives (1) of last values seen.
|
||||
private currentUserSource = new ReplaySubject<User | undefined>(1);
|
||||
/**
|
||||
*
|
||||
*/
|
||||
currentUser$ = this.currentUserSource.asObservable();
|
||||
|
||||
/**
|
||||
@ -63,7 +59,6 @@ export class AccountService implements OnDestroy {
|
||||
map((response: User) => {
|
||||
const user = response;
|
||||
if (user) {
|
||||
console.log('Login: ', user);
|
||||
this.setCurrentUser(user);
|
||||
this.messageHub.createHubConnection(user, this.hasAdminRole(user));
|
||||
}
|
||||
@ -135,8 +130,8 @@ export class AccountService implements OnDestroy {
|
||||
return this.httpClient.post<string>(this.baseUrl + 'account/resend-confirmation-email?userId=' + userId, {}, {responseType: 'text' as 'json'});
|
||||
}
|
||||
|
||||
inviteUser(model: {email: string, roles: Array<string>, libraries: Array<number>}) {
|
||||
return this.httpClient.post<InviteUserResponse>(this.baseUrl + 'account/invite', model);
|
||||
inviteUser(model: {email: string, roles: Array<string>, libraries: Array<number>, sendEmail: boolean}) {
|
||||
return this.httpClient.post<string>(this.baseUrl + 'account/invite', model, {responseType: 'text' as 'json'});
|
||||
}
|
||||
|
||||
confirmEmail(model: {email: string, username: string, password: string, token: string}) {
|
||||
|
@ -1,23 +1,71 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { ReplaySubject } from 'rxjs';
|
||||
import { ReplaySubject, take } from 'rxjs';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class NavService {
|
||||
public localStorageSideNavKey = 'kavita--sidenav--collapsed';
|
||||
|
||||
private navbarVisibleSource = new ReplaySubject<boolean>(1);
|
||||
/**
|
||||
* If the top Nav bar is rendered or not
|
||||
*/
|
||||
navbarVisible$ = this.navbarVisibleSource.asObservable();
|
||||
|
||||
private sideNavCollapseSource = new ReplaySubject<boolean>(1);
|
||||
/**
|
||||
* If the Side Nav is in a collapsed state or not.
|
||||
*/
|
||||
sideNavCollapsed$ = this.sideNavCollapseSource.asObservable();
|
||||
|
||||
private sideNavVisibilitySource = new ReplaySubject<boolean>(1);
|
||||
/**
|
||||
* If the side nav is rendered or not into the DOM.
|
||||
*/
|
||||
sideNavVisibility$ = this.sideNavVisibilitySource.asObservable();
|
||||
|
||||
constructor() {
|
||||
this.showNavBar();
|
||||
const sideNavState = (localStorage.getItem(this.localStorageSideNavKey) === 'true') || false;
|
||||
this.sideNavCollapseSource.next(sideNavState);
|
||||
this.showSideNav();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Shows the top nav bar. This should be visible on all pages except the reader.
|
||||
*/
|
||||
showNavBar() {
|
||||
this.navbarVisibleSource.next(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hides the top nav bar.
|
||||
*/
|
||||
hideNavBar() {
|
||||
this.navbarVisibleSource.next(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the side nav. When being visible, the side nav will automatically return to previous collapsed state.
|
||||
*/
|
||||
showSideNav() {
|
||||
this.sideNavVisibilitySource.next(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hides the side nav. This is useful for the readers and login page.
|
||||
*/
|
||||
hideSideNav() {
|
||||
this.sideNavVisibilitySource.next(false);
|
||||
}
|
||||
|
||||
toggleSideNav() {
|
||||
this.sideNavCollapseSource.pipe(take(1)).subscribe(val => {
|
||||
if (val === undefined) val = false;
|
||||
const newVal = !(val || false);
|
||||
this.sideNavCollapseSource.next(newVal);
|
||||
localStorage.setItem(this.localStorageSideNavKey, newVal + '');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -13,13 +13,13 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { ResetPasswordModalComponent } from './_modals/reset-password-modal/reset-password-modal.component';
|
||||
import { ManageSettingsComponent } from './manage-settings/manage-settings.component';
|
||||
import { ManageSystemComponent } from './manage-system/manage-system.component';
|
||||
import { ChangelogComponent } from './changelog/changelog.component';
|
||||
import { ChangelogComponent } from '../announcements/changelog/changelog.component';
|
||||
import { PipeModule } from '../pipe/pipe.module';
|
||||
import { InviteUserComponent } from './invite-user/invite-user.component';
|
||||
import { RoleSelectorComponent } from './role-selector/role-selector.component';
|
||||
import { LibrarySelectorComponent } from './library-selector/library-selector.component';
|
||||
import { EditUserComponent } from './edit-user/edit-user.component';
|
||||
import { UserSettingsModule } from '../user-settings/user-settings.module';
|
||||
import { SidenavModule } from '../sidenav/sidenav.module';
|
||||
|
||||
|
||||
|
||||
@ -35,7 +35,6 @@ import { UserSettingsModule } from '../user-settings/user-settings.module';
|
||||
ResetPasswordModalComponent,
|
||||
ManageSettingsComponent,
|
||||
ManageSystemComponent,
|
||||
ChangelogComponent,
|
||||
InviteUserComponent,
|
||||
RoleSelectorComponent,
|
||||
LibrarySelectorComponent,
|
||||
@ -51,7 +50,7 @@ import { UserSettingsModule } from '../user-settings/user-settings.module';
|
||||
NgbDropdownModule,
|
||||
SharedModule,
|
||||
PipeModule,
|
||||
UserSettingsModule // API-key componet
|
||||
SidenavModule
|
||||
],
|
||||
providers: []
|
||||
})
|
||||
|
@ -1,6 +1,9 @@
|
||||
<div class="container">
|
||||
<h2>Admin Dashboard</h2>
|
||||
|
||||
<app-side-nav-companion-bar>
|
||||
<h1 title>
|
||||
Admin Dashboard
|
||||
</h1>
|
||||
</app-side-nav-companion-bar>
|
||||
<div class="container-fluid">
|
||||
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav nav-tabs">
|
||||
<li *ngFor="let tab of tabs" [ngbNavItem]="tab">
|
||||
<a ngbNavLink routerLink="." [fragment]="tab.fragment">{{ tab.title | sentenceCase }}</a>
|
||||
@ -17,9 +20,6 @@
|
||||
<ng-container *ngIf="tab.fragment === 'system'">
|
||||
<app-manage-system></app-manage-system>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="tab.fragment === 'changelog'">
|
||||
<app-changelog></app-changelog>
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
</li>
|
||||
</ul>
|
||||
|
@ -3,6 +3,7 @@ import { ActivatedRoute } from '@angular/router';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { ServerService } from 'src/app/_services/server.service';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import { NavService } from '../../_services/nav.service';
|
||||
|
||||
|
||||
|
||||
@ -18,13 +19,12 @@ export class DashboardComponent implements OnInit {
|
||||
{title: 'Users', fragment: 'users'},
|
||||
{title: 'Libraries', fragment: 'libraries'},
|
||||
{title: 'System', fragment: 'system'},
|
||||
{title: 'Changelog', fragment: 'changelog'},
|
||||
];
|
||||
counter = this.tabs.length + 1;
|
||||
active = this.tabs[0];
|
||||
|
||||
constructor(public route: ActivatedRoute, private serverService: ServerService,
|
||||
private toastr: ToastrService, private titleService: Title) {
|
||||
private toastr: ToastrService, private titleService: Title, public navService: NavService) {
|
||||
this.route.fragment.subscribe(frag => {
|
||||
const tab = this.tabs.filter(item => item.fragment === frag);
|
||||
if (tab.length > 0) {
|
||||
|
@ -9,7 +9,13 @@
|
||||
Invite a user to your server. Enter their email in and we will send them an email to create an account.
|
||||
</p>
|
||||
|
||||
<form [formGroup]="inviteForm" *ngIf="emailLink === ''">
|
||||
<p *ngIf="!checkedAccessibility">
|
||||
<span class="spinner-border text-primary" style="width: 1.5rem; height: 1.5rem;" role="status" aria-hidden="true"></span>
|
||||
Checking accessibility of server...
|
||||
</p>
|
||||
|
||||
|
||||
<form [formGroup]="inviteForm">
|
||||
<div class="row g-0">
|
||||
<div class="mb-3" style="width:100%">
|
||||
<label for="email" class="form-label">Email</label>
|
||||
@ -22,6 +28,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="emailLink !== '' && checkedAccessibility && !accessible">
|
||||
<p>Use this link to finish setting up the user account due to your server not being accessible outside your local network.</p>
|
||||
<a class="email-link" href="{{emailLink}}" target="_blank">{{emailLink}}</a>
|
||||
</ng-container>
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="col-md-6">
|
||||
<app-role-selector (selected)="updateRoleSelection($event)" [allowAdmin]="true"></app-role-selector>
|
||||
@ -33,21 +44,12 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<ng-container *ngIf="emailLink !== ''">
|
||||
<h4>User invited</h4>
|
||||
<p>You can use the following link below to setup the account for your user or use the copy button. You may need to log out before using the link to register a new user.
|
||||
If your server is externallyaccessible, an email will have been sent to the user and the links can be used by them to finish setting up their account.
|
||||
</p>
|
||||
<a class="email-link" href="{{emailLink}}" target="_blank">Setup user's account</a>
|
||||
<app-api-key title="Invite Url" tooltipText="Copy this and paste in a new tab. You may need to log out." [showRefresh]="false" [transform]="makeLink"></app-api-key>
|
||||
</ng-container>
|
||||
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" (click)="close()">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" (click)="invite()" [disabled]="isSending || !inviteForm.valid || emailLink !== ''">
|
||||
<button type="button" class="btn btn-primary" (click)="invite()" [disabled]="isSending || !inviteForm.valid || !checkedAccessibility || emailLink !== ''">
|
||||
<span *ngIf="isSending" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
<span>{{isSending ? 'Inviting...' : 'Invite'}}</span>
|
||||
</button>
|
||||
|
@ -3,7 +3,6 @@ import { FormControl, FormGroup, Validators } from '@angular/forms';
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { ConfirmService } from 'src/app/shared/confirm.service';
|
||||
import { InviteUserResponse } from 'src/app/_models/invite-user-response';
|
||||
import { Library } from 'src/app/_models/library';
|
||||
import { AccountService } from 'src/app/_services/account.service';
|
||||
import { ServerService } from 'src/app/_services/server.service';
|
||||
@ -20,12 +19,15 @@ export class InviteUserComponent implements OnInit {
|
||||
*/
|
||||
isSending: boolean = false;
|
||||
inviteForm: FormGroup = new FormGroup({});
|
||||
/**
|
||||
* If a user would be able to load this server up externally
|
||||
*/
|
||||
accessible: boolean = true;
|
||||
checkedAccessibility: boolean = false;
|
||||
selectedRoles: Array<string> = [];
|
||||
selectedLibraries: Array<number> = [];
|
||||
emailLink: string = '';
|
||||
|
||||
makeLink: (val: string) => string = (val: string) => {return this.emailLink};
|
||||
|
||||
public get email() { return this.inviteForm.get('email'); }
|
||||
|
||||
constructor(public modal: NgbActiveModal, private accountService: AccountService, private serverService: ServerService,
|
||||
@ -33,6 +35,14 @@ export class InviteUserComponent implements OnInit {
|
||||
|
||||
ngOnInit(): void {
|
||||
this.inviteForm.addControl('email', new FormControl('', [Validators.required]));
|
||||
|
||||
this.serverService.isServerAccessible().subscribe(async (accessibile) => {
|
||||
if (!accessibile) {
|
||||
await this.confirmService.alert('This server is not accessible outside the network. You cannot invite via Email. You wil be given a link to finish registration with instead.');
|
||||
this.accessible = accessibile;
|
||||
}
|
||||
this.checkedAccessibility = true;
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
@ -47,10 +57,11 @@ export class InviteUserComponent implements OnInit {
|
||||
email,
|
||||
libraries: this.selectedLibraries,
|
||||
roles: this.selectedRoles,
|
||||
}).subscribe((data: InviteUserResponse) => {
|
||||
this.emailLink = data.emailLink;
|
||||
sendEmail: this.accessible
|
||||
}).subscribe(emailLink => {
|
||||
this.emailLink = emailLink;
|
||||
this.isSending = false;
|
||||
if (data.emailSent) {
|
||||
if (this.accessible) {
|
||||
this.toastr.info('Email sent to ' + email);
|
||||
this.modal.close(true);
|
||||
}
|
||||
|
@ -7,7 +7,7 @@
|
||||
<li *ngFor="let library of libraries; let idx = index; trackby: trackbyLibrary" class="list-group-item no-hover">
|
||||
<div>
|
||||
<h4>
|
||||
<span id="library-name--{{idx}}">{{library.name}}</span>
|
||||
<span id="library-name--{{idx}}"><a [routerLink]="'/library/' + library.id">{{library.name}}</a></span>
|
||||
<div class="spinner-border text-primary" style="width: 1.5rem; height: 1.5rem;" role="status" *ngIf="scanInProgress.hasOwnProperty(library.id) && scanInProgress[library.id].progress" title="Scan in progress. Started at {{scanInProgress[library.id].timestamp | date: 'short'}}">
|
||||
<span class="visually-hidden">Scan for {{library.name}} in progress</span>
|
||||
</div>
|
||||
|
@ -1,10 +1,16 @@
|
||||
<app-side-nav-companion-bar [hasFilter]="true" (filterOpen)="filterOpen.emit($event)">
|
||||
<h1 title>
|
||||
<app-card-actionables [actions]="actions"></app-card-actionables>
|
||||
All Series
|
||||
</h1>
|
||||
</app-side-nav-companion-bar>
|
||||
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
|
||||
<app-card-detail-layout header="All Series"
|
||||
<app-card-detail-layout
|
||||
[isLoading]="loadingSeries"
|
||||
[items]="series"
|
||||
[actions]="actions"
|
||||
[pagination]="pagination"
|
||||
[filterSettings]="filterSettings"
|
||||
[filterOpen]="filterOpen"
|
||||
(applyFilter)="updateFilter($event)"
|
||||
(pageChange)="onPageChange($event)"
|
||||
>
|
||||
|
@ -1,11 +1,12 @@
|
||||
import { Component, HostListener, OnDestroy, OnInit } from '@angular/core';
|
||||
import { Component, EventEmitter, HostListener, OnDestroy, OnInit } from '@angular/core';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { Subject } from 'rxjs';
|
||||
import { take, debounceTime, takeUntil } from 'rxjs/operators';
|
||||
import { BulkSelectionService } from '../cards/bulk-selection.service';
|
||||
import { FilterSettings } from '../cards/card-detail-layout/card-detail-layout.component';
|
||||
import { FilterSettings } from '../metadata-filter/filter-settings';
|
||||
import { KEY_CODES, UtilityService } from '../shared/_services/utility.service';
|
||||
import { SeriesAddedEvent } from '../_models/events/series-added-event';
|
||||
import { Library } from '../_models/library';
|
||||
import { Pagination } from '../_models/pagination';
|
||||
import { Series } from '../_models/series';
|
||||
@ -29,6 +30,7 @@ export class AllSeriesComponent implements OnInit, OnDestroy {
|
||||
filter: SeriesFilter | undefined = undefined;
|
||||
onDestroy: Subject<void> = new Subject<void>();
|
||||
filterSettings: FilterSettings = new FilterSettings();
|
||||
filterOpen: EventEmitter<boolean> = new EventEmitter();
|
||||
|
||||
bulkActionCallback = (action: Action, data: any) => {
|
||||
const selectedSeriesIndexies = this.bulkSelectionService.getSelectedCardsForSource('series');
|
||||
|
23
UI/Web/src/app/announcements/announcements-routing.module.ts
Normal file
23
UI/Web/src/app/announcements/announcements-routing.module.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { NgModule } from "@angular/core";
|
||||
import { Routes, RouterModule } from "@angular/router";
|
||||
import { AdminGuard } from "../_guards/admin.guard";
|
||||
import { AuthGuard } from "../_guards/auth.guard";
|
||||
import { AnnouncementsComponent } from "./announcements.component";
|
||||
|
||||
const routes: Routes = [
|
||||
{path: '**', component: AnnouncementsComponent, pathMatch: 'full', canActivate: [AuthGuard, AdminGuard]},
|
||||
{
|
||||
runGuardsAndResolvers: 'always',
|
||||
canActivate: [AuthGuard, AdminGuard],
|
||||
children: [
|
||||
{path: '/announcments', component: AnnouncementsComponent},
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class AnnouncementsRoutingModule { }
|
@ -0,0 +1,3 @@
|
||||
<h1>Announcements</h1>
|
||||
|
||||
<app-changelog></app-changelog>
|
15
UI/Web/src/app/announcements/announcements.component.ts
Normal file
15
UI/Web/src/app/announcements/announcements.component.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-announcements',
|
||||
templateUrl: './announcements.component.html',
|
||||
styleUrls: ['./announcements.component.scss']
|
||||
})
|
||||
export class AnnouncementsComponent implements OnInit {
|
||||
|
||||
constructor() { }
|
||||
|
||||
ngOnInit(): void {
|
||||
}
|
||||
|
||||
}
|
23
UI/Web/src/app/announcements/announcements.module.ts
Normal file
23
UI/Web/src/app/announcements/announcements.module.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { AnnouncementsComponent } from './announcements.component';
|
||||
import { ChangelogComponent } from './changelog/changelog.component';
|
||||
import { AnnouncementsRoutingModule } from './announcements-routing.module';
|
||||
import { SharedModule } from '../shared/shared.module';
|
||||
import { PipeModule } from '../pipe/pipe.module';
|
||||
|
||||
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
AnnouncementsComponent,
|
||||
ChangelogComponent
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
AnnouncementsRoutingModule,
|
||||
SharedModule,
|
||||
PipeModule
|
||||
]
|
||||
})
|
||||
export class AnnouncementsModule { }
|
@ -10,7 +10,7 @@
|
||||
|
||||
|
||||
<pre class="card-text update-body">
|
||||
<app-read-more class="float-end" [text]="update.updateBody" [maxLength]="500"></app-read-more>
|
||||
<app-read-more [text]="update.updateBody" [maxLength]="500"></app-read-more>
|
||||
</pre>
|
||||
<a *ngIf="!update.isDocker && update.updateVersion === installedVersion" href="{{update.updateUrl}}" class="btn disabled btn-{{indx === 0 ? 'primary' : 'secondary'}} float-end" target="_blank">Installed</a>
|
||||
<a *ngIf="!update.isDocker && update.updateVersion !== installedVersion" href="{{update.updateUrl}}" class="btn btn-{{indx === 0 ? 'primary' : 'secondary'}} float-end" target="_blank">Download</a>
|
@ -6,10 +6,13 @@ import { RecentlyAddedComponent } from './recently-added/recently-added.componen
|
||||
import { UserLoginComponent } from './user-login/user-login.component';
|
||||
import { AuthGuard } from './_guards/auth.guard';
|
||||
import { LibraryAccessGuard } from './_guards/library-access.guard';
|
||||
import { OnDeckComponent } from './on-deck/on-deck.component';
|
||||
import { DashboardComponent } from './dashboard/dashboard.component';
|
||||
import { AllSeriesComponent } from './all-series/all-series.component';
|
||||
import { AdminGuard } from './_guards/admin.guard';
|
||||
import { ThemeTestComponent } from './theme-test/theme-test.component';
|
||||
import { ReadingListsComponent } from './reading-list/reading-lists/reading-lists.component';
|
||||
import { AllCollectionsComponent } from './collections/all-collections/all-collections.component';
|
||||
|
||||
// TODO: Once we modularize the components, use this and measure performance impact: https://angular.io/guide/lazy-loading-ngmodules#preloading-modules
|
||||
|
||||
@ -39,6 +42,10 @@ const routes: Routes = [
|
||||
path: 'registration',
|
||||
loadChildren: () => import('../app/registration/registration.module').then(m => m.RegistrationModule)
|
||||
},
|
||||
{
|
||||
path: 'announcements',
|
||||
loadChildren: () => import('../app/announcements/announcements.module').then(m => m.AnnouncementsModule)
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
runGuardsAndResolvers: 'always',
|
||||
@ -63,6 +70,7 @@ const routes: Routes = [
|
||||
children: [
|
||||
{path: 'library', component: DashboardComponent},
|
||||
{path: 'recently-added', component: RecentlyAddedComponent},
|
||||
{path: 'on-deck', component: OnDeckComponent},
|
||||
{path: 'all-series', component: AllSeriesComponent},
|
||||
|
||||
]
|
||||
|
@ -1,5 +1,10 @@
|
||||
<app-nav-header></app-nav-header>
|
||||
<div [ngStyle]="(navService?.navbarVisible$ | async) ? {'padding-top': 'calc(57px)', 'height': '100%'} : {}">
|
||||
<div class="content-wrapper" [ngClass]="{'closed' : !(navService?.sideNavCollapsed$ | async)}">
|
||||
<a id="content"></a>
|
||||
<router-outlet></router-outlet>
|
||||
<app-side-nav *ngIf="navService.sideNavVisibility$ | async"></app-side-nav>
|
||||
<div class="container-fluid" style="padding-top: 10px; padding-bottom: 10px;">
|
||||
<div class="companion-bar" [ngClass]="{'companion-bar-content': (navService?.sideNavCollapsed$ | async)}">
|
||||
<router-outlet></router-outlet>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -0,0 +1,39 @@
|
||||
.content-wrapper {
|
||||
padding: calc(56px) 10px 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.companion-bar {
|
||||
transition: all var(--side-nav-companion-bar-transistion);
|
||||
margin-left: 40px;
|
||||
}
|
||||
|
||||
.companion-bar-content {
|
||||
margin-left: 190px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.container-fluid {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
padding: calc(56px) 10px 0;
|
||||
overflow: hidden;
|
||||
|
||||
&.closed {
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.companion-bar {
|
||||
margin-left: 0px;
|
||||
padding-left: 0px;
|
||||
}
|
||||
|
||||
.companion-bar-content {
|
||||
margin-left: 0px;
|
||||
width: auto;
|
||||
}
|
||||
}
|
@ -47,7 +47,6 @@ export class AppComponent implements OnInit {
|
||||
|
||||
ngOnInit(): void {
|
||||
this.setCurrentUser();
|
||||
|
||||
this.setDocHeight();
|
||||
}
|
||||
|
||||
|
@ -7,7 +7,8 @@ import { AppComponent } from './app.component';
|
||||
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
|
||||
import { NgbAccordionModule, NgbCollapseModule, NgbDropdownModule, NgbNavModule, NgbPaginationModule, NgbPopoverModule, NgbRatingModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import {
|
||||
NgbAccordionModule, NgbCollapseModule, NgbDropdownModule, NgbNavModule, NgbPaginationModule, NgbPopoverModule, NgbRatingModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { NavHeaderComponent } from './nav-header/nav-header.component';
|
||||
import { JwtInterceptor } from './_interceptors/jwt.interceptor';
|
||||
import { UserLoginComponent } from './user-login/user-login.component';
|
||||
@ -22,6 +23,7 @@ import { CarouselModule } from './carousel/carousel.module';
|
||||
|
||||
import { TypeaheadModule } from './typeahead/typeahead.module';
|
||||
import { RecentlyAddedComponent } from './recently-added/recently-added.component';
|
||||
import { OnDeckComponent } from './on-deck/on-deck.component';
|
||||
import { DashboardComponent } from './dashboard/dashboard.component';
|
||||
import { CardsModule } from './cards/cards.module';
|
||||
import { CollectionsModule } from './collections/collections.module';
|
||||
@ -35,6 +37,7 @@ import { GroupedTypeaheadComponent } from './grouped-typeahead/grouped-typeahead
|
||||
import { ThemeTestComponent } from './theme-test/theme-test.component';
|
||||
import { PipeModule } from './pipe/pipe.module';
|
||||
import { ColorPickerModule } from 'ngx-color-picker';
|
||||
import { SidenavModule } from './sidenav/sidenav.module';
|
||||
|
||||
|
||||
@NgModule({
|
||||
@ -42,11 +45,12 @@ import { ColorPickerModule } from 'ngx-color-picker';
|
||||
AppComponent,
|
||||
NavHeaderComponent,
|
||||
UserLoginComponent,
|
||||
LibraryComponent,
|
||||
LibraryDetailComponent,
|
||||
SeriesDetailComponent,
|
||||
LibraryComponent,
|
||||
LibraryDetailComponent,
|
||||
SeriesDetailComponent,
|
||||
ReviewSeriesModalComponent,
|
||||
RecentlyAddedComponent,
|
||||
OnDeckComponent,
|
||||
DashboardComponent,
|
||||
EventsWidgetComponent,
|
||||
SeriesMetadataDetailComponent,
|
||||
@ -68,7 +72,7 @@ import { ColorPickerModule } from 'ngx-color-picker';
|
||||
NgbNavModule,
|
||||
NgbPaginationModule,
|
||||
|
||||
NgbCollapseModule, // Login
|
||||
NgbCollapseModule, // Login
|
||||
|
||||
SharedModule,
|
||||
CarouselModule,
|
||||
@ -78,11 +82,14 @@ import { ColorPickerModule } from 'ngx-color-picker';
|
||||
ReadingListModule,
|
||||
RegistrationModule,
|
||||
|
||||
ColorPickerModule, // User preferences
|
||||
ColorPickerModule, // User preferences
|
||||
|
||||
NgbAccordionModule, // ThemeTest Component only
|
||||
PipeModule,
|
||||
|
||||
PipeModule,
|
||||
SidenavModule, // For sidenav
|
||||
|
||||
|
||||
ToastrModule.forRoot({
|
||||
positionClass: 'toast-bottom-right',
|
||||
@ -97,6 +104,7 @@ import { ColorPickerModule } from 'ngx-color-picker';
|
||||
{provide: HTTP_INTERCEPTORS, useClass: JwtInterceptor, multi: true},
|
||||
Title,
|
||||
{provide: SAVER, useFactory: getSaver},
|
||||
// { provide: APP_BASE_HREF, useFactory: (config: ConfigData) => config.baseUrl, deps: [ConfigData] },
|
||||
],
|
||||
entryComponents: [],
|
||||
bootstrap: [AppComponent]
|
||||
|
@ -6,6 +6,7 @@ import { SharedModule } from '../shared/shared.module';
|
||||
import { SafeStylePipe } from './safe-style.pipe';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
import { NgbProgressbarModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { PipeModule } from '../pipe/pipe.module';
|
||||
|
||||
|
||||
@NgModule({
|
||||
@ -16,7 +17,8 @@ import { NgbProgressbarModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstr
|
||||
ReactiveFormsModule,
|
||||
SharedModule,
|
||||
NgbProgressbarModule,
|
||||
NgbTooltipModule
|
||||
NgbTooltipModule,
|
||||
PipeModule,
|
||||
], exports: [
|
||||
BookReaderComponent,
|
||||
SafeStylePipe
|
||||
|
@ -263,6 +263,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
private scrollService: ScrollService, private utilityService: UtilityService, private libraryService: LibraryService,
|
||||
@Inject(DOCUMENT) private document: Document, private themeService: ThemeService) {
|
||||
this.navService.hideNavBar();
|
||||
this.navService.hideSideNav();
|
||||
|
||||
this.darkModeStyleElem = this.renderer.createElement('style');
|
||||
this.darkModeStyleElem.innerHTML = this.darkModeStyles;
|
||||
@ -388,6 +389,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
this.navService.showNavBar();
|
||||
this.navService.showSideNav();
|
||||
|
||||
const head = this.document.querySelector('head');
|
||||
this.renderer.removeChild(head, this.darkModeStyleElem);
|
||||
|
@ -27,7 +27,7 @@
|
||||
<span>
|
||||
<span *ngIf="chapterMetadata && chapterMetadata.releaseDate !== null">Release Date: {{chapterMetadata.releaseDate | date: 'shortDate' || '-'}}</span>
|
||||
</span>
|
||||
<span class="text-accent">{{data.pages}} pages</span>
|
||||
<span class="text-accent">{{chapter.pages}} pages</span>
|
||||
</div>
|
||||
<div class="row g-0">
|
||||
<div class="col-auto">
|
||||
@ -106,26 +106,24 @@
|
||||
<h4 *ngIf="!utilityService.isChapter(data)">{{utilityService.formatChapterName(libraryType) + 's'}}</h4>
|
||||
<ul class="list-unstyled">
|
||||
<li class="d-flex my-4" *ngFor="let chapter of chapters">
|
||||
<a (click)="readChapter(chapter)" href="javascript:void(0);" title="Read {{utilityService.formatChapterName(libraryType, true, false)}} {{formatChapterNumber(chapter)}}">
|
||||
<a (click)="readChapter(chapter)" href="javascript:void(0);" title="Read {{libraryType !== LibraryType.Comic ? 'Chapter ' : 'Issue #'}} {{chapter.number}}">
|
||||
<app-image class="me-2" width="74px" [imageUrl]="chapter.coverImage"></app-image>
|
||||
</a>
|
||||
<div class="flex-grow-1">
|
||||
<h5 class="mt-0 mb-1">
|
||||
<span >
|
||||
<span *ngIf="chapter.number !== '0'; else specialHeader">
|
||||
<span>
|
||||
<app-card-actionables (actionHandler)="performAction($event, chapter)" [actions]="chapterActions"
|
||||
[labelBy]="utilityService.formatChapterName(libraryType, true, true) + formatChapterNumber(chapter)"></app-card-actionables>
|
||||
<ng-container *ngIf="chapter.number !== '0'; else specialHeader">
|
||||
{{utilityService.formatChapterName(libraryType, true, false) }} {{formatChapterNumber(chapter)}}
|
||||
</ng-container>
|
||||
{{utilityService.formatChapterName(libraryType, true, false) }} {{formatChapterNumber(chapter)}}
|
||||
</span>
|
||||
<span class="badge bg-primary rounded-pill ms-1">
|
||||
<span class="badge bg-primary rounded-pill">
|
||||
<span *ngIf="chapter.pagesRead > 0 && chapter.pagesRead < chapter.pages">{{chapter.pagesRead}} / {{chapter.pages}}</span>
|
||||
<span *ngIf="chapter.pagesRead === 0">UNREAD</span>
|
||||
<span *ngIf="chapter.pagesRead === chapter.pages">READ</span>
|
||||
</span>
|
||||
</span>
|
||||
<ng-template #specialHeader>Files</ng-template>
|
||||
<ng-template #specialHeader>File(s)</ng-template>
|
||||
</h5>
|
||||
<ul class="list-group">
|
||||
<li *ngFor="let file of chapter.files" class="list-group-item no-hover">
|
||||
|
@ -112,7 +112,7 @@
|
||||
</div>
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="col-md-4 col-sm-12 pe-2">
|
||||
<div class="col-lg-4 col-md-12 pe-2">
|
||||
<div class="mb-3">
|
||||
<label for="language" class="form-label">Language</label>
|
||||
<app-typeahead (selectedData)="updateLanguage($event)" [settings]="languageSettings"
|
||||
@ -127,7 +127,7 @@
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 col-sm-12 pe-2">
|
||||
<div class="col-lg-4 col-md-12 pe-2">
|
||||
<div class="mb-3">
|
||||
<label for="age-rating" class="form-label">Age Rating</label>
|
||||
<div class="input-group {{metadata.ageRatingLocked ? 'lock-active' : ''}}">
|
||||
@ -138,7 +138,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<div class="col-lg-4 col-md-12">
|
||||
<div class="mb-3">
|
||||
<label for="publication-status" class="form-label">Publication Status</label>
|
||||
<div class="input-group {{metadata.publicationStatusLocked ? 'lock-active' : ''}}">
|
||||
|
@ -1,364 +1,25 @@
|
||||
<div class="container-fluid" style="padding-top: 10px">
|
||||
<div class="row g-0 pb-2">
|
||||
|
||||
<div class="row mt-2 g-0 pb-2">
|
||||
<div class="col me-auto">
|
||||
<h2 style="display: inline-block">
|
||||
<span *ngIf="actions.length > 0" class="">
|
||||
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="header"></app-card-actionables>
|
||||
</span>{{header}}
|
||||
<span class="badge bg-primary rounded-pill" attr.aria-label="{{pagination.totalItems}} total items" *ngIf="pagination != undefined">{{pagination.totalItems}}</span>
|
||||
</span>
|
||||
<span *ngIf="header !== undefined && header.length > 0">
|
||||
{{header}}
|
||||
<span class="badge bg-primary rounded-pill" attr.aria-label="{{pagination.totalItems}} total items" *ngIf="pagination != undefined">{{pagination.totalItems}}</span>
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="col-auto align-self-end">
|
||||
<button *ngIf="!filteringDisabled" class="btn btn-secondary btn-small" (click)="collapse.toggle()" [attr.aria-expanded]="!filteringCollapsed" placement="left" ngbTooltip="{{filteringCollapsed ? 'Open' : 'Close'}} Filtering and Sorting" attr.aria-label="{{filteringCollapsed ? 'Open' : 'Close'}} Filtering and Sorting">
|
||||
<i class="fa fa-filter" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">Sort / Filter</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="phone-hidden">
|
||||
<div #collapse="ngbCollapse" [(ngbCollapse)]="filteringCollapsed">
|
||||
<ng-container [ngTemplateOutlet]="filterSection"></ng-container>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="not-phone-hidden">
|
||||
<app-drawer #commentDrawer="drawer" [isOpen]="!filteringCollapsed" [style.--drawer-width]="'300px'" [style.--drawer-background-color]="'#010409'" (drawerClosed)="filteringCollapsed = !filteringCollapsed">
|
||||
<div header>
|
||||
<h2 style="margin-top: 0.5rem">Book Settings
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="commentDrawer.close()"></button>
|
||||
</h2>
|
||||
|
||||
</div>
|
||||
<div body class="drawer-body">
|
||||
<ng-container [ngTemplateOutlet]="filterSection"></ng-container>
|
||||
</div>
|
||||
</app-drawer>
|
||||
</div>
|
||||
|
||||
<ng-template #filterSection>
|
||||
<ng-template #globalFilterTooltip>This is library agnostic</ng-template>
|
||||
<div class="filter-section mx-auto pb-3">
|
||||
<div class="row justify-content-center g-0">
|
||||
<div class="col-md-2 me-3" *ngIf="!filterSettings.formatDisabled">
|
||||
<div class="mb-3">
|
||||
<label for="format" class="form-label">Format</label> <i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="globalFilterTooltip" role="button" tabindex="0"></i>
|
||||
<span class="visually-hidden" id="filter-global-format-help"><ng-container [ngTemplateOutlet]="globalFilterTooltip"></ng-container></span>
|
||||
<app-typeahead (selectedData)="updateFormatFilters($event)" [settings]="formatSettings" [reset]="resetTypeaheads">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.title}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.title}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 me-3"*ngIf="!filterSettings.libraryDisabled">
|
||||
<div class="mb-3">
|
||||
<label for="libraries" class="form-label">Libraries</label>
|
||||
<app-typeahead (selectedData)="updateLibraryFilters($event)" [settings]="librarySettings" [reset]="resetTypeaheads">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 me-3" *ngIf="!filterSettings.collectionDisabled">
|
||||
<div class="mb-3">
|
||||
<label for="collections" class="form-label">Collections</label> <i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="globalFilterTooltip" role="button" tabindex="0"></i>
|
||||
<span class="visually-hidden" id="filter-global-collections-help"><ng-container [ngTemplateOutlet]="globalFilterTooltip"></ng-container></span>
|
||||
<app-typeahead (selectedData)="updateCollectionFilters($event)" [settings]="collectionSettings" [reset]="resetTypeaheads">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.title}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.title}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 me-3" *ngIf="!filterSettings.genresDisabled">
|
||||
<div class="mb-3">
|
||||
<label for="genres" class="form-label">Genres</label>
|
||||
<app-typeahead (selectedData)="updateGenreFilters($event)" [settings]="genreSettings" [reset]="resetTypeaheads">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.title}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.title}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 me-3" *ngIf="!filterSettings.tagsDisabled">
|
||||
<div class="mb-3">
|
||||
<label for="tags" class="form-label">Tags</label>
|
||||
<app-typeahead (selectedData)="updateTagFilters($event)" [settings]="tagsSettings" [reset]="resetTypeaheads">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.title}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.title}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row justify-content-center g-0">
|
||||
<!-- The People row -->
|
||||
<div class="col-md-2 me-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.CoverArtist)">
|
||||
<div class="mb-3">
|
||||
<label for="cover-artist" class="form-label">Cover Artists</label>
|
||||
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.CoverArtist)" [settings]="getPersonsSettings(PersonRole.CoverArtist)" [reset]="resetTypeaheads">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 me-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Writer)">
|
||||
<div class="mb-3">
|
||||
<label for="writers" class="form-label">Writers</label>
|
||||
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Writer)" [settings]="getPersonsSettings(PersonRole.Writer)" [reset]="resetTypeaheads">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 me-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Publisher)">
|
||||
<div class="mb-3">
|
||||
<label for="publisher" class="form-label">Publisher</label>
|
||||
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Publisher)" [settings]="getPersonsSettings(PersonRole.Publisher)" [reset]="resetTypeaheads">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 me-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Penciller)">
|
||||
<div class="mb-3">
|
||||
<label for="penciller" class="form-label">Penciller</label>
|
||||
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Penciller)" [settings]="getPersonsSettings(PersonRole.Penciller)" [reset]="resetTypeaheads">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 me-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Letterer)">
|
||||
<div class="mb-3">
|
||||
<label for="letterer" class="form-label">Letterer</label>
|
||||
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Letterer)" [settings]="getPersonsSettings(PersonRole.Letterer)" [reset]="resetTypeaheads">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 me-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Inker)">
|
||||
<div class="mb-3">
|
||||
<label for="inker" class="form-label">Inker</label>
|
||||
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Inker)" [settings]="getPersonsSettings(PersonRole.Inker)" [reset]="resetTypeaheads">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 me-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Editor)">
|
||||
<div class="mb-3">
|
||||
<label for="editor" class="form-label">Editor</label>
|
||||
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Editor)" [settings]="getPersonsSettings(PersonRole.Editor)" [reset]="resetTypeaheads">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 me-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Colorist)">
|
||||
<div class="mb-3">
|
||||
<label for="colorist" class="form-label">Colorist</label>
|
||||
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Colorist)" [settings]="getPersonsSettings(PersonRole.Colorist)" [reset]="resetTypeaheads">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 me-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Character)">
|
||||
<div class="mb-3">
|
||||
<label for="character" class="form-label">Character</label>
|
||||
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Character)" [settings]="getPersonsSettings(PersonRole.Character)" [reset]="resetTypeaheads">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 me-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Translator)">
|
||||
<div class="mb-3">
|
||||
<label for="translators" class="form-label">Translators</label>
|
||||
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Translator)" [settings]="getPersonsSettings(PersonRole.Translator)" [reset]="resetTypeaheads">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row justify-content-center g-0">
|
||||
<div class="col-md-2 me-3" *ngIf="!filterSettings.readProgressDisabled">
|
||||
<label class="form-label">Read Progress</label>
|
||||
<form [formGroup]="readProgressGroup">
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="notread" formControlName="notRead">
|
||||
<label class="form-check-label" for="notread">Unread</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="inprogress" formControlName="inProgress">
|
||||
<label class="form-check-label" for="inprogress">In Progress</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="read" formControlName="read">
|
||||
<label class="form-check-label" for="read">Read</label>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 me-3" *ngIf="!filterSettings.ratingDisabled">
|
||||
<label for="ratings" class="form-label">Rating</label>
|
||||
<form class="form-inline">
|
||||
<ngb-rating class="rating-star" [(rate)]="filter.rating" (rateChange)="updateRating($event)" [resettable]="true">
|
||||
<ng-template let-fill="fill" let-index="index">
|
||||
<span class="star" [class.filled]="(index >= (filter.rating - 1)) && filter.rating > 0" [ngbTooltip]="(index + 1) + ' and up'">★</span>
|
||||
</ng-template>
|
||||
</ngb-rating>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 me-3" *ngIf="!filterSettings.ageRatingDisabled">
|
||||
<label for="age-rating" class="form-label">Age Rating</label>
|
||||
<app-typeahead (selectedData)="updateAgeRating($event)" [settings]="ageRatingSettings" [reset]="resetTypeaheads">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.title}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.title}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 me-3" *ngIf="!filterSettings.languageDisabled">
|
||||
<label for="languages" class="form-label">Language</label>
|
||||
<app-typeahead (selectedData)="updateLanguageRating($event)" [settings]="languageSettings" [reset]="resetTypeaheads">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.title}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.title}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 me-3" *ngIf="!filterSettings.publicationStatusDisabled">
|
||||
<label for="publication-status" class="form-label">Publication Status</label>
|
||||
<app-typeahead (selectedData)="updatePublicationStatus($event)" [settings]="publicationStatusSettings" [reset]="resetTypeaheads">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.title}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.title}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
<div class="col-md-2 me-3"></div>
|
||||
</div>
|
||||
<div class="row justify-content-center g-0 mt-2">
|
||||
<div class="col-md-2 me-3" *ngIf="!filterSettings.sortDisabled">
|
||||
<form [formGroup]="sortGroup">
|
||||
<div class="mb-3">
|
||||
<label for="sort-options" class="form-label">Sort By</label>
|
||||
<button class="btn btn-sm btn-secondary-outline" (click)="updateSortOrder()" style="height: 25px; padding-bottom: 0px;">
|
||||
<i class="fa fa-arrow-up" title="Ascending" *ngIf="isAscendingSort; else descSort"></i>
|
||||
<ng-template #descSort>
|
||||
<i class="fa fa-arrow-down" title="Descending"></i>
|
||||
</ng-template>
|
||||
</button>
|
||||
<select id="sort-options" class="form-select" formControlName="sortField" style="height: 38px;">
|
||||
<option [value]="SortField.SortName">Sort Name</option>
|
||||
<option [value]="SortField.Created">Created</option>
|
||||
<option [value]="SortField.LastModified">Last Modified</option>
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="col-md-2 me-3" *ngIf="filterSettings.sortDisabled"></div>
|
||||
<div class="col-md-2 me-3"></div>
|
||||
<div class="col-md-2 me-3"></div>
|
||||
<div class="col-md-2 me-3">
|
||||
<button class="btn btn-secondary col-12" (click)="clear()">Clear</button>
|
||||
</div>
|
||||
<div class="col-md-2 me-3">
|
||||
<button class="btn btn-primary col-12" (click)="apply()">Apply</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
<app-metadata-filter [filterSettings]="filterSettings" [filterOpen]="filterOpen" (applyFilter)="applyMetadataFilter($event)"></app-metadata-filter>
|
||||
|
||||
<ng-container [ngTemplateOutlet]="paginationTemplate" [ngTemplateOutletContext]="{ id: 'top' }"></ng-container>
|
||||
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="col-auto" *ngFor="let item of items; trackBy:trackByIdentity; index as i">
|
||||
<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>
|
||||
|
||||
@ -368,10 +29,9 @@
|
||||
</div>
|
||||
|
||||
<ng-container [ngTemplateOutlet]="paginationTemplate" [ngTemplateOutletContext]="{ id: 'bottom' }"></ng-container>
|
||||
</div>
|
||||
|
||||
<ng-template #paginationTemplate let-id="id">
|
||||
<div class="d-flex justify-content-center" *ngIf="pagination && items.length > 0">
|
||||
<div class="d-flex justify-content-center mb-0" *ngIf="pagination && items.length > 0">
|
||||
<ngb-pagination
|
||||
*ngIf="pagination.totalPages > 1"
|
||||
[maxSize]="8"
|
||||
|
@ -1,50 +1,16 @@
|
||||
import { Component, ContentChild, EventEmitter, Input, OnDestroy, OnInit, Output, TemplateRef } from '@angular/core';
|
||||
import { FormControl, FormGroup } from '@angular/forms';
|
||||
import { forkJoin, Observable, of, ReplaySubject, Subject } from 'rxjs';
|
||||
import { map, takeUntil } from 'rxjs/operators';
|
||||
import { UtilityService } from 'src/app/shared/_services/utility.service';
|
||||
import { TypeaheadSettings } from 'src/app/typeahead/typeahead-settings';
|
||||
import { CollectionTag } from 'src/app/_models/collection-tag';
|
||||
import { Genre } from 'src/app/_models/genre';
|
||||
import { Subject } from 'rxjs';
|
||||
import { FilterSettings } from 'src/app/metadata-filter/filter-settings';
|
||||
import { Library } from 'src/app/_models/library';
|
||||
import { MangaFormat } from 'src/app/_models/manga-format';
|
||||
import { AgeRating } from 'src/app/_models/metadata/age-rating';
|
||||
import { AgeRatingDto } from 'src/app/_models/metadata/age-rating-dto';
|
||||
import { Language } from 'src/app/_models/metadata/language';
|
||||
import { PublicationStatusDto } from 'src/app/_models/metadata/publication-status-dto';
|
||||
import { Pagination } from 'src/app/_models/pagination';
|
||||
import { Person, PersonRole } from 'src/app/_models/person';
|
||||
import { FilterEvent, FilterItem, mangaFormatFilters, SeriesFilter, SortField } from 'src/app/_models/series-filter';
|
||||
import { Tag } from 'src/app/_models/tag';
|
||||
import { FilterEvent, FilterItem, SeriesFilter, SortField } from 'src/app/_models/series-filter';
|
||||
import { ActionItem } from 'src/app/_services/action-factory.service';
|
||||
import { CollectionTagService } from 'src/app/_services/collection-tag.service';
|
||||
import { LibraryService } from 'src/app/_services/library.service';
|
||||
import { MetadataService } from 'src/app/_services/metadata.service';
|
||||
import { SeriesService } from 'src/app/_services/series.service';
|
||||
|
||||
const FILTER_PAG_REGEX = /[^0-9]/g;
|
||||
|
||||
const ANIMATION_SPEED = 300;
|
||||
|
||||
export class FilterSettings {
|
||||
libraryDisabled = false;
|
||||
formatDisabled = false;
|
||||
collectionDisabled = false;
|
||||
genresDisabled = false;
|
||||
peopleDisabled = false;
|
||||
readProgressDisabled = false;
|
||||
ratingDisabled = false;
|
||||
sortDisabled = false;
|
||||
ageRatingDisabled = false;
|
||||
tagsDisabled = false;
|
||||
languageDisabled = false;
|
||||
publicationStatusDisabled = false;
|
||||
presets: SeriesFilter | undefined;
|
||||
/**
|
||||
* Should the filter section be open by default
|
||||
*/
|
||||
openByDefault = false;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-card-detail-layout',
|
||||
@ -73,87 +39,21 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
|
||||
|
||||
@ContentChild('cardItem') itemTemplate!: TemplateRef<any>;
|
||||
|
||||
formatSettings: TypeaheadSettings<FilterItem<MangaFormat>> = new TypeaheadSettings();
|
||||
librarySettings: TypeaheadSettings<Library> = new TypeaheadSettings();
|
||||
genreSettings: TypeaheadSettings<Genre> = new TypeaheadSettings();
|
||||
collectionSettings: TypeaheadSettings<CollectionTag> = new TypeaheadSettings();
|
||||
ageRatingSettings: TypeaheadSettings<AgeRatingDto> = new TypeaheadSettings();
|
||||
publicationStatusSettings: TypeaheadSettings<PublicationStatusDto> = new TypeaheadSettings();
|
||||
tagsSettings: TypeaheadSettings<Tag> = new TypeaheadSettings();
|
||||
languageSettings: TypeaheadSettings<Language> = new TypeaheadSettings();
|
||||
peopleSettings: {[PersonRole: string]: TypeaheadSettings<Person>} = {};
|
||||
resetTypeaheads: Subject<boolean> = new ReplaySubject(1);
|
||||
|
||||
/**
|
||||
* Controls the visiblity of extended controls that sit below the main header.
|
||||
*/
|
||||
filteringCollapsed: boolean = true;
|
||||
// Filter Code
|
||||
@Input() filterOpen!: EventEmitter<boolean>;
|
||||
|
||||
|
||||
filter!: SeriesFilter;
|
||||
libraries: Array<FilterItem<Library>> = [];
|
||||
|
||||
|
||||
readProgressGroup!: FormGroup;
|
||||
sortGroup!: FormGroup;
|
||||
isAscendingSort: boolean = true;
|
||||
|
||||
updateApplied: number = 0;
|
||||
|
||||
|
||||
private onDestory: Subject<void> = new Subject();
|
||||
|
||||
get PersonRole(): typeof PersonRole {
|
||||
return PersonRole;
|
||||
}
|
||||
|
||||
get SortField(): typeof SortField {
|
||||
return SortField;
|
||||
}
|
||||
|
||||
constructor(private libraryService: LibraryService, private metadataService: MetadataService, private seriesService: SeriesService,
|
||||
private utilityService: UtilityService, private collectionTagService: CollectionTagService) {
|
||||
constructor(private seriesService: SeriesService) {
|
||||
this.filter = this.seriesService.createSeriesFilter();
|
||||
|
||||
this.readProgressGroup = new FormGroup({
|
||||
read: new FormControl(this.filter.readStatus.read, []),
|
||||
notRead: new FormControl(this.filter.readStatus.notRead, []),
|
||||
inProgress: new FormControl(this.filter.readStatus.inProgress, []),
|
||||
});
|
||||
|
||||
this.sortGroup = new FormGroup({
|
||||
sortField: new FormControl(this.filter.sortOptions?.sortField || SortField.SortName, []),
|
||||
});
|
||||
|
||||
this.readProgressGroup.valueChanges.pipe(takeUntil(this.onDestory)).subscribe(changes => {
|
||||
this.filter.readStatus.read = this.readProgressGroup.get('read')?.value;
|
||||
this.filter.readStatus.inProgress = this.readProgressGroup.get('inProgress')?.value;
|
||||
this.filter.readStatus.notRead = this.readProgressGroup.get('notRead')?.value;
|
||||
|
||||
let sum = 0;
|
||||
sum += (this.filter.readStatus.read ? 1 : 0);
|
||||
sum += (this.filter.readStatus.inProgress ? 1 : 0);
|
||||
sum += (this.filter.readStatus.notRead ? 1 : 0);
|
||||
|
||||
if (sum === 1) {
|
||||
if (this.filter.readStatus.read) this.readProgressGroup.get('read')?.disable({ emitEvent: false });
|
||||
if (this.filter.readStatus.notRead) this.readProgressGroup.get('notRead')?.disable({ emitEvent: false });
|
||||
if (this.filter.readStatus.inProgress) this.readProgressGroup.get('inProgress')?.disable({ emitEvent: false });
|
||||
} else {
|
||||
this.readProgressGroup.get('read')?.enable({ emitEvent: false });
|
||||
this.readProgressGroup.get('notRead')?.enable({ emitEvent: false });
|
||||
this.readProgressGroup.get('inProgress')?.enable({ emitEvent: false });
|
||||
}
|
||||
});
|
||||
|
||||
this.sortGroup.valueChanges.pipe(takeUntil(this.onDestory)).subscribe(changes => {
|
||||
if (this.filter.sortOptions == null) {
|
||||
this.filter.sortOptions = {
|
||||
isAscending: this.isAscendingSort,
|
||||
sortField: parseInt(this.sortGroup.get('sortField')?.value, 10)
|
||||
};
|
||||
}
|
||||
this.filter.sortOptions.sortField = parseInt(this.sortGroup.get('sortField')?.value, 10);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
@ -163,14 +63,6 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
|
||||
if (this.filterSettings === undefined) {
|
||||
this.filterSettings = new FilterSettings();
|
||||
}
|
||||
|
||||
if (this.filterSettings.presets) {
|
||||
this.readProgressGroup.get('read')?.patchValue(this.filterSettings.presets?.readStatus.read);
|
||||
this.readProgressGroup.get('notRead')?.patchValue(this.filterSettings.presets?.readStatus.notRead);
|
||||
this.readProgressGroup.get('inProgress')?.patchValue(this.filterSettings.presets?.readStatus.inProgress);
|
||||
}
|
||||
|
||||
this.setupTypeaheads();
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
@ -178,302 +70,6 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
|
||||
this.onDestory.complete();
|
||||
}
|
||||
|
||||
setupTypeaheads() {
|
||||
|
||||
this.setupFormatTypeahead();
|
||||
|
||||
forkJoin([
|
||||
this.setupLibraryTypeahead(),
|
||||
this.setupCollectionTagTypeahead(),
|
||||
this.setupAgeRatingSettings(),
|
||||
this.setupPublicationStatusSettings(),
|
||||
this.setupTagSettings(),
|
||||
this.setupLanguageSettings(),
|
||||
this.setupGenreTypeahead(),
|
||||
this.setupPersonTypeahead(),
|
||||
]).subscribe(results => {
|
||||
this.resetTypeaheads.next(true);
|
||||
if (this.filterSettings.openByDefault) {
|
||||
this.filteringCollapsed = false;
|
||||
}
|
||||
this.apply();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
setupFormatTypeahead() {
|
||||
this.formatSettings.minCharacters = 0;
|
||||
this.formatSettings.multiple = true;
|
||||
this.formatSettings.id = 'format';
|
||||
this.formatSettings.unique = true;
|
||||
this.formatSettings.addIfNonExisting = false;
|
||||
this.formatSettings.fetchFn = (filter: string) => of(mangaFormatFilters).pipe(map(items => this.formatSettings.compareFn(items, filter)));
|
||||
this.formatSettings.compareFn = (options: FilterItem<MangaFormat>[], filter: string) => {
|
||||
return options.filter(m => this.utilityService.filter(m.title, filter));
|
||||
}
|
||||
|
||||
this.formatSettings.singleCompareFn = (a: FilterItem<MangaFormat>, b: FilterItem<MangaFormat>) => {
|
||||
return a.title == b.title;
|
||||
}
|
||||
|
||||
if (this.filterSettings.presets?.formats && this.filterSettings.presets?.formats.length > 0) {
|
||||
this.formatSettings.savedData = mangaFormatFilters.filter(item => this.filterSettings.presets?.formats.includes(item.value));
|
||||
this.filter.formats = this.formatSettings.savedData.map(item => item.value);
|
||||
this.resetTypeaheads.next(true);
|
||||
}
|
||||
}
|
||||
|
||||
setupLibraryTypeahead() {
|
||||
this.librarySettings.minCharacters = 0;
|
||||
this.librarySettings.multiple = true;
|
||||
this.librarySettings.id = 'libraries';
|
||||
this.librarySettings.unique = true;
|
||||
this.librarySettings.addIfNonExisting = false;
|
||||
this.librarySettings.fetchFn = (filter: string) => {
|
||||
return this.libraryService.getLibrariesForMember()
|
||||
.pipe(map(items => this.librarySettings.compareFn(items, filter)));
|
||||
};
|
||||
this.librarySettings.compareFn = (options: Library[], filter: string) => {
|
||||
return options.filter(m => this.utilityService.filter(m.name, filter));
|
||||
}
|
||||
this.librarySettings.singleCompareFn = (a: Library, b: Library) => {
|
||||
return a.name == b.name;
|
||||
}
|
||||
|
||||
if (this.filterSettings.presets?.libraries && this.filterSettings.presets?.libraries.length > 0) {
|
||||
return this.librarySettings.fetchFn('').pipe(map(libraries => {
|
||||
this.librarySettings.savedData = libraries.filter(item => this.filterSettings.presets?.libraries.includes(item.id));
|
||||
this.filter.libraries = this.librarySettings.savedData.map(item => item.id);
|
||||
return of(true);
|
||||
}));
|
||||
}
|
||||
return of(true);
|
||||
}
|
||||
|
||||
setupGenreTypeahead() {
|
||||
this.genreSettings.minCharacters = 0;
|
||||
this.genreSettings.multiple = true;
|
||||
this.genreSettings.id = 'genres';
|
||||
this.genreSettings.unique = true;
|
||||
this.genreSettings.addIfNonExisting = false;
|
||||
this.genreSettings.fetchFn = (filter: string) => {
|
||||
return this.metadataService.getAllGenres(this.filter.libraries)
|
||||
.pipe(map(items => this.genreSettings.compareFn(items, filter)));
|
||||
};
|
||||
this.genreSettings.compareFn = (options: Genre[], filter: string) => {
|
||||
return options.filter(m => this.utilityService.filter(m.title, filter));
|
||||
}
|
||||
this.genreSettings.singleCompareFn = (a: Genre, b: Genre) => {
|
||||
return a.title == b.title;
|
||||
}
|
||||
|
||||
if (this.filterSettings.presets?.genres && this.filterSettings.presets?.genres.length > 0) {
|
||||
return this.genreSettings.fetchFn('').pipe(map(genres => {
|
||||
this.genreSettings.savedData = genres.filter(item => this.filterSettings.presets?.genres.includes(item.id));
|
||||
this.filter.genres = this.genreSettings.savedData.map(item => item.id);
|
||||
return of(true);
|
||||
}));
|
||||
}
|
||||
return of(true);
|
||||
}
|
||||
|
||||
setupAgeRatingSettings() {
|
||||
this.ageRatingSettings.minCharacters = 0;
|
||||
this.ageRatingSettings.multiple = true;
|
||||
this.ageRatingSettings.id = 'age-rating';
|
||||
this.ageRatingSettings.unique = true;
|
||||
this.ageRatingSettings.addIfNonExisting = false;
|
||||
this.ageRatingSettings.fetchFn = (filter: string) => this.metadataService.getAllAgeRatings(this.filter.libraries)
|
||||
.pipe(map(items => this.ageRatingSettings.compareFn(items, filter)));
|
||||
|
||||
this.ageRatingSettings.compareFn = (options: AgeRatingDto[], filter: string) => {
|
||||
return options.filter(m => this.utilityService.filter(m.title, filter));
|
||||
}
|
||||
|
||||
this.ageRatingSettings.singleCompareFn = (a: AgeRatingDto, b: AgeRatingDto) => {
|
||||
return a.title == b.title;
|
||||
}
|
||||
|
||||
if (this.filterSettings.presets?.ageRating && this.filterSettings.presets?.ageRating.length > 0) {
|
||||
return this.ageRatingSettings.fetchFn('').pipe(map(rating => {
|
||||
this.ageRatingSettings.savedData = rating.filter(item => this.filterSettings.presets?.ageRating.includes(item.value));
|
||||
this.filter.ageRating = this.ageRatingSettings.savedData.map(item => item.value);
|
||||
return of(true);
|
||||
}));
|
||||
}
|
||||
return of(true);
|
||||
}
|
||||
|
||||
setupPublicationStatusSettings() {
|
||||
this.publicationStatusSettings.minCharacters = 0;
|
||||
this.publicationStatusSettings.multiple = true;
|
||||
this.publicationStatusSettings.id = 'publication-status';
|
||||
this.publicationStatusSettings.unique = true;
|
||||
this.publicationStatusSettings.addIfNonExisting = false;
|
||||
this.publicationStatusSettings.fetchFn = (filter: string) => this.metadataService.getAllPublicationStatus(this.filter.libraries)
|
||||
.pipe(map(items => this.publicationStatusSettings.compareFn(items, filter)));
|
||||
|
||||
this.publicationStatusSettings.compareFn = (options: PublicationStatusDto[], filter: string) => {
|
||||
return options.filter(m => this.utilityService.filter(m.title, filter));
|
||||
}
|
||||
|
||||
this.publicationStatusSettings.singleCompareFn = (a: PublicationStatusDto, b: PublicationStatusDto) => {
|
||||
return a.title == b.title;
|
||||
}
|
||||
|
||||
if (this.filterSettings.presets?.publicationStatus && this.filterSettings.presets?.publicationStatus.length > 0) {
|
||||
return this.publicationStatusSettings.fetchFn('').pipe(map(statuses => {
|
||||
this.publicationStatusSettings.savedData = statuses.filter(item => this.filterSettings.presets?.publicationStatus.includes(item.value));
|
||||
this.filter.publicationStatus = this.publicationStatusSettings.savedData.map(item => item.value);
|
||||
return of(true);
|
||||
}));
|
||||
}
|
||||
return of(true);
|
||||
}
|
||||
|
||||
setupTagSettings() {
|
||||
this.tagsSettings.minCharacters = 0;
|
||||
this.tagsSettings.multiple = true;
|
||||
this.tagsSettings.id = 'tags';
|
||||
this.tagsSettings.unique = true;
|
||||
this.tagsSettings.addIfNonExisting = false;
|
||||
this.tagsSettings.compareFn = (options: Tag[], filter: string) => {
|
||||
return options.filter(m => this.utilityService.filter(m.title, filter));
|
||||
}
|
||||
this.tagsSettings.fetchFn = (filter: string) => this.metadataService.getAllTags(this.filter.libraries)
|
||||
.pipe(map(items => this.tagsSettings.compareFn(items, filter)));
|
||||
|
||||
this.tagsSettings.singleCompareFn = (a: Tag, b: Tag) => {
|
||||
return a.id == b.id;
|
||||
}
|
||||
|
||||
if (this.filterSettings.presets?.tags && this.filterSettings.presets?.tags.length > 0) {
|
||||
return this.tagsSettings.fetchFn('').pipe(map(tags => {
|
||||
this.tagsSettings.savedData = tags.filter(item => this.filterSettings.presets?.tags.includes(item.id));
|
||||
this.filter.tags = this.tagsSettings.savedData.map(item => item.id);
|
||||
return of(true);
|
||||
}));
|
||||
}
|
||||
return of(true);
|
||||
}
|
||||
|
||||
setupLanguageSettings() {
|
||||
this.languageSettings.minCharacters = 0;
|
||||
this.languageSettings.multiple = true;
|
||||
this.languageSettings.id = 'languages';
|
||||
this.languageSettings.unique = true;
|
||||
this.languageSettings.addIfNonExisting = false;
|
||||
this.languageSettings.compareFn = (options: Language[], filter: string) => {
|
||||
return options.filter(m => this.utilityService.filter(m.title, filter));
|
||||
}
|
||||
this.languageSettings.fetchFn = (filter: string) => this.metadataService.getAllLanguages(this.filter.libraries)
|
||||
.pipe(map(items => this.languageSettings.compareFn(items, filter)));
|
||||
|
||||
this.languageSettings.singleCompareFn = (a: Language, b: Language) => {
|
||||
return a.isoCode == b.isoCode;
|
||||
}
|
||||
|
||||
if (this.filterSettings.presets?.languages && this.filterSettings.presets?.languages.length > 0) {
|
||||
return this.languageSettings.fetchFn('').pipe(map(languages => {
|
||||
this.languageSettings.savedData = languages.filter(item => this.filterSettings.presets?.languages.includes(item.isoCode));
|
||||
this.filter.languages = this.languageSettings.savedData.map(item => item.isoCode);
|
||||
return of(true);
|
||||
}));
|
||||
}
|
||||
return of(true);
|
||||
}
|
||||
|
||||
setupCollectionTagTypeahead() {
|
||||
this.collectionSettings.minCharacters = 0;
|
||||
this.collectionSettings.multiple = true;
|
||||
this.collectionSettings.id = 'collections';
|
||||
this.collectionSettings.unique = true;
|
||||
this.collectionSettings.addIfNonExisting = false;
|
||||
this.collectionSettings.compareFn = (options: CollectionTag[], filter: string) => {
|
||||
return options.filter(m => this.utilityService.filter(m.title, filter));
|
||||
}
|
||||
this.collectionSettings.fetchFn = (filter: string) => this.collectionTagService.allTags()
|
||||
.pipe(map(items => this.collectionSettings.compareFn(items, filter)));
|
||||
|
||||
this.collectionSettings.singleCompareFn = (a: CollectionTag, b: CollectionTag) => {
|
||||
return a.id == b.id;
|
||||
}
|
||||
|
||||
if (this.filterSettings.presets?.collectionTags && this.filterSettings.presets?.collectionTags.length > 0) {
|
||||
return this.collectionSettings.fetchFn('').pipe(map(tags => {
|
||||
this.collectionSettings.savedData = tags.filter(item => this.filterSettings.presets?.collectionTags.includes(item.id));
|
||||
this.filter.collectionTags = this.collectionSettings.savedData.map(item => item.id);
|
||||
return of(true);
|
||||
}));
|
||||
}
|
||||
return of(true);
|
||||
}
|
||||
|
||||
updateFromPreset(id: string, peopleFilterField: Array<any>, presetField: Array<any> | undefined, role: PersonRole) {
|
||||
const personSettings = this.createBlankPersonSettings(id, role)
|
||||
if (presetField && presetField.length > 0) {
|
||||
const fetch = personSettings.fetchFn as ((filter: string) => Observable<Person[]>);
|
||||
return fetch('').pipe(map(people => {
|
||||
personSettings.savedData = people.filter(item => presetField.includes(item.id));
|
||||
peopleFilterField = personSettings.savedData.map(item => item.id);
|
||||
this.resetTypeaheads.next(true);
|
||||
this.peopleSettings[role] = personSettings;
|
||||
this.updatePersonFilters(personSettings.savedData as Person[], role);
|
||||
return true;
|
||||
}));
|
||||
} else {
|
||||
this.peopleSettings[role] = personSettings;
|
||||
return of(true);
|
||||
}
|
||||
}
|
||||
|
||||
setupPersonTypeahead() {
|
||||
this.peopleSettings = {};
|
||||
|
||||
return forkJoin([
|
||||
this.updateFromPreset('writers', this.filter.writers, this.filterSettings.presets?.writers, PersonRole.Writer),
|
||||
this.updateFromPreset('character', this.filter.character, this.filterSettings.presets?.character, PersonRole.Character),
|
||||
this.updateFromPreset('colorist', this.filter.colorist, this.filterSettings.presets?.colorist, PersonRole.Colorist),
|
||||
this.updateFromPreset('cover-artist', this.filter.coverArtist, this.filterSettings.presets?.coverArtist, PersonRole.CoverArtist),
|
||||
this.updateFromPreset('editor', this.filter.editor, this.filterSettings.presets?.editor, PersonRole.Editor),
|
||||
this.updateFromPreset('inker', this.filter.inker, this.filterSettings.presets?.inker, PersonRole.Inker),
|
||||
this.updateFromPreset('letterer', this.filter.letterer, this.filterSettings.presets?.letterer, PersonRole.Letterer),
|
||||
this.updateFromPreset('penciller', this.filter.penciller, this.filterSettings.presets?.penciller, PersonRole.Penciller),
|
||||
this.updateFromPreset('publisher', this.filter.publisher, this.filterSettings.presets?.publisher, PersonRole.Publisher),
|
||||
this.updateFromPreset('translators', this.filter.translators, this.filterSettings.presets?.translators, PersonRole.Translator)
|
||||
]).pipe(map(results => {
|
||||
this.resetTypeaheads.next(true);
|
||||
return of(true);
|
||||
}));
|
||||
}
|
||||
|
||||
fetchPeople(role: PersonRole, filter: string) {
|
||||
return this.metadataService.getAllPeople(this.filter.libraries).pipe(map(people => {
|
||||
return people.filter(p => p.role == role && this.utilityService.filter(p.name, filter));
|
||||
}));
|
||||
}
|
||||
|
||||
createBlankPersonSettings(id: string, role: PersonRole) {
|
||||
var personSettings = new TypeaheadSettings<Person>();
|
||||
personSettings.minCharacters = 0;
|
||||
personSettings.multiple = true;
|
||||
personSettings.unique = true;
|
||||
personSettings.addIfNonExisting = false;
|
||||
personSettings.id = id;
|
||||
personSettings.compareFn = (options: Person[], filter: string) => {
|
||||
return options.filter(m => this.utilityService.filter(m.name, filter));
|
||||
}
|
||||
|
||||
personSettings.singleCompareFn = (a: Person, b: Person) => {
|
||||
return a.name == b.name && a.role == b.role;
|
||||
}
|
||||
personSettings.fetchFn = (filter: string) => {
|
||||
return this.fetchPeople(role, filter).pipe(map(items => personSettings.compareFn(items, filter)));
|
||||
};
|
||||
return personSettings;
|
||||
}
|
||||
|
||||
|
||||
onPageChange(page: number) {
|
||||
this.pageChange.emit(this.pagination);
|
||||
}
|
||||
@ -493,117 +89,8 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
updateFormatFilters(formats: MangaFormat[]) {
|
||||
this.filter.formats = formats.map(item => item) || [];
|
||||
}
|
||||
|
||||
updateLibraryFilters(libraries: Library[]) {
|
||||
this.filter.libraries = libraries.map(item => item.id) || [];
|
||||
}
|
||||
|
||||
updateGenreFilters(genres: Genre[]) {
|
||||
this.filter.genres = genres.map(item => item.id) || [];
|
||||
}
|
||||
|
||||
updateTagFilters(tags: Tag[]) {
|
||||
this.filter.tags = tags.map(item => item.id) || [];
|
||||
}
|
||||
|
||||
updatePersonFilters(persons: Person[], role: PersonRole) {
|
||||
switch (role) {
|
||||
case PersonRole.CoverArtist:
|
||||
this.filter.coverArtist = persons.map(p => p.id);
|
||||
break;
|
||||
case PersonRole.Character:
|
||||
this.filter.character = persons.map(p => p.id);
|
||||
break;
|
||||
case PersonRole.Colorist:
|
||||
this.filter.colorist = persons.map(p => p.id);
|
||||
break;
|
||||
case PersonRole.Editor:
|
||||
this.filter.editor = persons.map(p => p.id);
|
||||
break;
|
||||
case PersonRole.Inker:
|
||||
this.filter.inker = persons.map(p => p.id);
|
||||
break;
|
||||
case PersonRole.Letterer:
|
||||
this.filter.letterer = persons.map(p => p.id);
|
||||
break;
|
||||
case PersonRole.Penciller:
|
||||
this.filter.penciller = persons.map(p => p.id);
|
||||
break;
|
||||
case PersonRole.Publisher:
|
||||
this.filter.publisher = persons.map(p => p.id);
|
||||
break;
|
||||
case PersonRole.Writer:
|
||||
this.filter.writers = persons.map(p => p.id);
|
||||
break;
|
||||
case PersonRole.Translator:
|
||||
this.filter.translators = persons.map(p => p.id);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
updateCollectionFilters(tags: CollectionTag[]) {
|
||||
this.filter.collectionTags = tags.map(item => item.id) || [];
|
||||
}
|
||||
|
||||
updateRating(rating: any) {
|
||||
this.filter.rating = rating;
|
||||
}
|
||||
|
||||
updateAgeRating(ratingDtos: AgeRatingDto[]) {
|
||||
this.filter.ageRating = ratingDtos.map(item => item.value) || [];
|
||||
}
|
||||
|
||||
updatePublicationStatus(dtos: PublicationStatusDto[]) {
|
||||
this.filter.publicationStatus = dtos.map(item => item.value) || [];
|
||||
}
|
||||
|
||||
updateLanguageRating(languages: Language[]) {
|
||||
this.filter.languages = languages.map(item => item.isoCode) || [];
|
||||
}
|
||||
|
||||
updateReadStatus(status: string) {
|
||||
if (status === 'read') {
|
||||
this.filter.readStatus.read = !this.filter.readStatus.read;
|
||||
} else if (status === 'inProgress') {
|
||||
this.filter.readStatus.inProgress = !this.filter.readStatus.inProgress;
|
||||
} else if (status === 'notRead') {
|
||||
this.filter.readStatus.notRead = !this.filter.readStatus.notRead;
|
||||
}
|
||||
}
|
||||
|
||||
updateSortOrder() {
|
||||
this.isAscendingSort = !this.isAscendingSort;
|
||||
if (this.filter.sortOptions === null) {
|
||||
this.filter.sortOptions = {
|
||||
isAscending: this.isAscendingSort,
|
||||
sortField: SortField.SortName
|
||||
}
|
||||
}
|
||||
|
||||
this.filter.sortOptions.isAscending = this.isAscendingSort;
|
||||
}
|
||||
|
||||
getPersonsSettings(role: PersonRole) {
|
||||
return this.peopleSettings[role];
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.filter = this.seriesService.createSeriesFilter();
|
||||
this.readProgressGroup.get('read')?.setValue(true);
|
||||
this.readProgressGroup.get('notRead')?.setValue(true);
|
||||
this.readProgressGroup.get('inProgress')?.setValue(true);
|
||||
this.sortGroup.get('sortField')?.setValue(SortField.SortName);
|
||||
this.isAscendingSort = true;
|
||||
// Apply any presets which will trigger the apply
|
||||
this.setupTypeaheads();
|
||||
}
|
||||
|
||||
apply() {
|
||||
this.applyFilter.emit({filter: this.filter, isFirst: this.updateApplied === 0});
|
||||
applyMetadataFilter(event: FilterEvent) {
|
||||
this.applyFilter.emit(event);
|
||||
this.updateApplied++;
|
||||
}
|
||||
|
||||
|
@ -19,7 +19,6 @@ $image-width: 160px;
|
||||
}
|
||||
|
||||
.card {
|
||||
margin: 5px;
|
||||
max-width: $image-width;
|
||||
cursor: pointer;
|
||||
padding-left: 0px;
|
||||
|
@ -22,6 +22,7 @@ import { PipeModule } from '../pipe/pipe.module';
|
||||
import { ChapterMetadataDetailComponent } from './chapter-metadata-detail/chapter-metadata-detail.component';
|
||||
import { FileInfoComponent } from './file-info/file-info.component';
|
||||
import { BookmarkComponent } from './bookmark/bookmark.component';
|
||||
import { MetadataFilterModule } from '../metadata-filter/metadata-filter.module';
|
||||
|
||||
|
||||
|
||||
@ -49,14 +50,19 @@ import { BookmarkComponent } from './bookmark/bookmark.component';
|
||||
ReactiveFormsModule,
|
||||
FormsModule, // EditCollectionsModal
|
||||
|
||||
PipeModule,
|
||||
SharedModule,
|
||||
TypeaheadModule,
|
||||
TypeaheadModule, // edit series modal
|
||||
|
||||
MetadataFilterModule,
|
||||
|
||||
NgbNavModule,
|
||||
NgbTooltipModule, // Card item
|
||||
NgbCollapseModule,
|
||||
NgbRatingModule,
|
||||
|
||||
|
||||
|
||||
NgbNavModule, //Series Detail
|
||||
NgbPaginationModule, // CardDetailLayoutComponent
|
||||
NgbDropdownModule,
|
||||
|
@ -1,16 +1,13 @@
|
||||
.carousel-container {
|
||||
overflow: hidden;
|
||||
padding-top: 15px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
display: flex;
|
||||
float: left;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.carousel-container {
|
||||
.section-title {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 400;
|
||||
margin-left: 10px;
|
||||
color: var(--carousel-header-text-color);
|
||||
@ -26,3 +23,11 @@
|
||||
::ng-deep .swiper-slide {
|
||||
width: auto !important;
|
||||
}
|
||||
|
||||
::ng-deep .swiper-wrapper {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
::ng-deep .last-carousel {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
@ -1,6 +1,13 @@
|
||||
<app-card-detail-layout header="Collections"
|
||||
<app-side-nav-companion-bar [hasFilter]="false" (filterOpen)="filterOpen.emit($event)">
|
||||
<h1 title>
|
||||
<app-card-actionables [actions]="collectionTagActions"></app-card-actionables>
|
||||
Collections
|
||||
</h1>
|
||||
</app-side-nav-companion-bar>
|
||||
<app-card-detail-layout
|
||||
[isLoading]="isLoading"
|
||||
[items]="collections"
|
||||
[filterOpen]="filterOpen"
|
||||
>
|
||||
<ng-template #cardItem let-item let-position="idx">
|
||||
<app-card-item [title]="item.title" [entity]="item" [actions]="collectionTagActions" [imageUrl]="item.coverImage" (clicked)="loadCollection(item)"></app-card-item>
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { Component, EventEmitter, OnInit } from '@angular/core';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import { Router } from '@angular/router';
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
@ -20,6 +20,8 @@ export class AllCollectionsComponent implements OnInit {
|
||||
collections: CollectionTag[] = [];
|
||||
collectionTagActions: ActionItem<CollectionTag>[] = [];
|
||||
|
||||
filterOpen: EventEmitter<boolean> = new EventEmitter();
|
||||
|
||||
constructor(private collectionService: CollectionTagService, private router: Router,
|
||||
private actionFactoryService: ActionFactoryService, private modalService: NgbModal,
|
||||
private titleService: Title, private imageService: ImageService) {
|
||||
@ -61,5 +63,4 @@ export class AllCollectionsComponent implements OnInit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -6,8 +6,8 @@ import { ToastrService } from 'ngx-toastr';
|
||||
import { Subject } from 'rxjs';
|
||||
import { debounceTime, take, takeUntil, takeWhile } from 'rxjs/operators';
|
||||
import { BulkSelectionService } from 'src/app/cards/bulk-selection.service';
|
||||
import { FilterSettings } from 'src/app/cards/card-detail-layout/card-detail-layout.component';
|
||||
import { EditCollectionTagsComponent } from 'src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component';
|
||||
import { FilterSettings } from 'src/app/metadata-filter/filter-settings';
|
||||
import { KEY_CODES, UtilityService } from 'src/app/shared/_services/utility.service';
|
||||
import { CollectionTag } from 'src/app/_models/collection-tag';
|
||||
import { SeriesAddedToCollectionEvent } from 'src/app/_models/events/series-added-to-collection-event';
|
||||
|
@ -5,6 +5,7 @@ import { SharedModule } from '../shared/shared.module';
|
||||
import { CollectionsRoutingModule } from './collections-routing.module';
|
||||
import { CardsModule } from '../cards/cards.module';
|
||||
import { AllCollectionsComponent } from './all-collections/all-collections.component';
|
||||
import { SidenavModule } from '../sidenav/sidenav.module';
|
||||
|
||||
|
||||
|
||||
@ -18,6 +19,7 @@ import { AllCollectionsComponent } from './all-collections/all-collections.compo
|
||||
SharedModule,
|
||||
CardsModule,
|
||||
CollectionsRoutingModule,
|
||||
SidenavModule
|
||||
],
|
||||
exports: [
|
||||
AllCollectionsComponent
|
||||
|
@ -1,21 +1,3 @@
|
||||
<div class="container-fluid">
|
||||
<nav role="navigation">
|
||||
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav nav-pills justify-content-center mt-3" role="tab">
|
||||
<li *ngFor="let tab of tabs" [ngbNavItem]="tab" class="nav-item">
|
||||
<a ngbNavLink routerLink="." [fragment]="tab.fragment">{{ tab.title | titlecase }}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<ng-container *ngIf="tab.fragment === ''">
|
||||
<app-library></app-library>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="tab.fragment === 'lists'">
|
||||
<app-reading-lists></app-reading-lists>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="tab.fragment === 'collections'">
|
||||
<app-all-collections></app-all-collections>
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div [ngbNavOutlet]="nav" class="mt-3"></div>
|
||||
</div>
|
||||
<app-side-nav-companion-bar>
|
||||
</app-side-nav-companion-bar>
|
||||
<app-library></app-library>
|
@ -1,10 +1,19 @@
|
||||
<app-side-nav-companion-bar [hasFilter]="true" (filterOpen)="filterOpen.emit($event)">
|
||||
<h1 title>
|
||||
<app-card-actionables [actions]="actions"></app-card-actionables>
|
||||
{{libraryName}}
|
||||
</h1>
|
||||
<div main>
|
||||
<!-- TODO: Implement Tabs here for Recommended and Library view -->
|
||||
</div>
|
||||
</app-side-nav-companion-bar>
|
||||
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
|
||||
<app-card-detail-layout [header]="libraryName"
|
||||
<app-card-detail-layout
|
||||
[isLoading]="loadingSeries"
|
||||
[items]="series"
|
||||
[actions]="actions"
|
||||
[pagination]="pagination"
|
||||
[filterSettings]="filterSettings"
|
||||
[filterOpen]="filterOpen"
|
||||
(applyFilter)="updateFilter($event)"
|
||||
(pageChange)="onPageChange($event)"
|
||||
>
|
||||
|
@ -1,10 +1,10 @@
|
||||
import { Component, HostListener, OnDestroy, OnInit } from '@angular/core';
|
||||
import { Component, EventEmitter, HostListener, OnDestroy, OnInit } from '@angular/core';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { Subject } from 'rxjs';
|
||||
import { debounceTime, take, takeUntil, takeWhile } from 'rxjs/operators';
|
||||
import { BulkSelectionService } from '../cards/bulk-selection.service';
|
||||
import { FilterSettings } from '../cards/card-detail-layout/card-detail-layout.component';
|
||||
import { FilterSettings } from '../metadata-filter/filter-settings';
|
||||
import { KEY_CODES, UtilityService } from '../shared/_services/utility.service';
|
||||
import { SeriesAddedEvent } from '../_models/events/series-added-event';
|
||||
import { Library } from '../_models/library';
|
||||
@ -16,6 +16,7 @@ import { ActionService } from '../_services/action.service';
|
||||
import { LibraryService } from '../_services/library.service';
|
||||
import { EVENTS, MessageHubService } from '../_services/message-hub.service';
|
||||
import { SeriesService } from '../_services/series.service';
|
||||
import { NavService } from '../_services/nav.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-library-detail',
|
||||
@ -33,6 +34,8 @@ export class LibraryDetailComponent implements OnInit, OnDestroy {
|
||||
filter: SeriesFilter | undefined = undefined;
|
||||
onDestroy: Subject<void> = new Subject<void>();
|
||||
filterSettings: FilterSettings = new FilterSettings();
|
||||
filterOpen: EventEmitter<boolean> = new EventEmitter();
|
||||
|
||||
|
||||
bulkActionCallback = (action: Action, data: any) => {
|
||||
const selectedSeriesIndexies = this.bulkSelectionService.getSelectedCardsForSource('series');
|
||||
@ -74,7 +77,7 @@ export class LibraryDetailComponent implements OnInit, OnDestroy {
|
||||
constructor(private route: ActivatedRoute, private router: Router, private seriesService: SeriesService,
|
||||
private libraryService: LibraryService, private titleService: Title, private actionFactoryService: ActionFactoryService,
|
||||
private actionService: ActionService, public bulkSelectionService: BulkSelectionService, private hubService: MessageHubService,
|
||||
private utilityService: UtilityService) {
|
||||
private utilityService: UtilityService, public navService: NavService) {
|
||||
const routeId = this.route.snapshot.paramMap.get('id');
|
||||
if (routeId === null) {
|
||||
this.router.navigateByUrl('/libraries');
|
||||
|
@ -14,7 +14,7 @@
|
||||
<!-- TODO: Refactor this so we can use series actions here -->
|
||||
<app-carousel-reel [items]="recentlyUpdatedSeries" title="Recently Updated Series" (sectionClick)="handleSectionClick($event)">
|
||||
<ng-template #carouselItem let-item let-position="idx">
|
||||
<app-card-item [entity]="item" [title]="item.seriesName" [imageUrl]="imageService.getSeriesCoverImage(item.seriesId)"
|
||||
<app-card-item [entity]="item" [title]="item.seriesName" [imageUrl]="imageService.getSeriesCoverImage(item.seriesId)"
|
||||
[supressArchiveWarning]="true" (clicked)="handleRecentlyAddedChapterClick(item)" [count]="item.count"></app-card-item>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
@ -27,13 +27,7 @@
|
||||
|
||||
<!-- <app-carousel-reel [items]="recentlyAddedChapters" title="Recently Added" (sectionClick)="handleSectionClick($event)">
|
||||
<ng-template #carouselItem let-item let-position="idx">
|
||||
<app-card-item [entity]="item" [title]="item.title" [subtitle]="item.seriesName" [imageUrl]="imageService.getRecentlyAddedItem(item)"
|
||||
<app-card-item [entity]="item" [title]="item.title" [subtitle]="item.seriesName" [imageUrl]="imageService.getRecentlyAddedItem(item)"
|
||||
[supressArchiveWarning]="true" (clicked)="handleRecentlyAddedChapterClick(item)"></app-card-item>
|
||||
</ng-template>
|
||||
</app-carousel-reel> -->
|
||||
|
||||
<app-carousel-reel [items]="libraries" title="Libraries" (sectionClick)="handleSectionClick($event)">
|
||||
<ng-template #carouselItem let-item let-position="idx">
|
||||
<app-library-card [data]="item"></app-library-card>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
|
@ -141,11 +141,7 @@ export class LibraryComponent implements OnInit, OnDestroy {
|
||||
} else if (sectionTitle.toLowerCase() === 'recently updated series') {
|
||||
this.router.navigate(['recently-added']);
|
||||
} else if (sectionTitle.toLowerCase() === 'on deck') {
|
||||
const params: any = {};
|
||||
params['readStatus'] = 'true,false,false';
|
||||
params['page'] = 1;
|
||||
this.router.navigate(['all-series'], {queryParams: params});
|
||||
//this.router.navigate(['on-deck']);
|
||||
this.router.navigate(['on-deck']);
|
||||
} else if (sectionTitle.toLowerCase() === 'libraries') {
|
||||
this.router.navigate(['all-series']);
|
||||
}
|
||||
|
@ -1,4 +1,3 @@
|
||||
import { LibraryType } from "src/app/_models/library";
|
||||
import { MangaFormat } from "src/app/_models/manga-format";
|
||||
|
||||
export interface ChapterInfo {
|
||||
@ -9,7 +8,6 @@ export interface ChapterInfo {
|
||||
seriesFormat: MangaFormat;
|
||||
seriesId: number;
|
||||
libraryId: number;
|
||||
libraryType: LibraryType;
|
||||
fileName: string;
|
||||
isSpecial: boolean;
|
||||
volumeId: number;
|
||||
|
@ -38,7 +38,7 @@
|
||||
</canvas>
|
||||
</div>
|
||||
<div class="image-container" [ngClass]="{'d-none': renderWithCanvas, 'center-double': ShouldRenderDoublePage, 'fit-to-width-double-offset' : this.generalSettingsForm.get('fittingOption')?.value === FITTING_OPTION.WIDTH && ShouldRenderDoublePage, 'fit-to-height-double-offset': this.generalSettingsForm.get('fittingOption')?.value === FITTING_OPTION.HEIGHT && ShouldRenderDoublePage, 'original-double-offset' : this.generalSettingsForm.get('fittingOption')?.value === FITTING_OPTION.ORIGINAL && ShouldRenderDoublePage, 'reverse': ShouldRenderReverseDouble}">
|
||||
<img [src]="canvasImage.src" id="image-1"
|
||||
<img [src]="readerService.getPageUrl(this.chapterId, this.pageNum)" id="image-1"
|
||||
class="{{getFittingOptionClass()}} {{readerMode === ReaderMode.LeftRight || readerMode === ReaderMode.UpDown ? '' : 'd-none'}} {{showClickOverlay ? 'blur' : ''}}">
|
||||
|
||||
<ng-container *ngIf="ShouldRenderDoublePage && (this.pageNum + 1 <= maxPages - 1 && this.pageNum > 0)">
|
||||
@ -128,19 +128,27 @@
|
||||
</div>
|
||||
<div class="bottom-menu" *ngIf="settingsOpen && generalSettingsForm">
|
||||
<form [formGroup]="generalSettingsForm">
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<label for="page-splitting" class="form-label">Image Splitting</label>
|
||||
<div class="split fa fa-image">
|
||||
<div class="{{splitIconClass}}"></div>
|
||||
</div>
|
||||
<select class="form-control" id="page-splitting" formControlName="pageSplitOption">
|
||||
<option *ngFor="let opt of pageSplitOptions" [value]="opt.value">{{opt.text}}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="mb-3">
|
||||
<select class="form-control" id="page-splitting" formControlName="pageSplitOption">
|
||||
<option *ngFor="let opt of pageSplitOptions" [value]="opt.value">{{opt.text}}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<label for="page-fitting" class="form-label">Image Scaling</label> <i class="fa {{getFittingIcon()}}" aria-hidden="true"></i>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<select class="form-control" id="page-fitting" formControlName="fittingOption">
|
||||
<option value="full-height">Height</option>
|
||||
<option value="full-width">Width</option>
|
||||
@ -149,14 +157,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<label for="layout-mode" class="form-label">Layout Mode</label>
|
||||
<select class="form-control" id="page-fitting" formControlName="layoutMode">
|
||||
<option [value]="opt.value" *ngFor="let opt of layoutModes">{{opt.text}}</option>
|
||||
</select>
|
||||
<div class="row mt-2 mb-2">
|
||||
<div class="col-6">
|
||||
<label for="autoCloseMenu" class="form-check-label">Auto Close Menu</label>
|
||||
</div>
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<div class="col-6">
|
||||
<div class="mb-3">
|
||||
<label id="auto-close-label" class="form-label"></label>
|
||||
<div class="mb-3">
|
||||
@ -168,6 +173,17 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-2 mb-2">
|
||||
<div class="col-6">
|
||||
<label for="layout-mode" class="form-label">Layout Mode</label>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<select class="form-control" id="page-fitting" formControlName="layoutMode">
|
||||
<option [value]="opt.value" *ngFor="let opt of layoutModes">{{opt.text}}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -47,9 +47,9 @@ img {
|
||||
}
|
||||
}
|
||||
|
||||
// canvas {
|
||||
// //position: absolute; // JOE: Not sure why we have this, but it breaks the renderer
|
||||
// }
|
||||
canvas {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.reader {
|
||||
background-color: var(--manga-reader-bg-color);
|
||||
|
@ -122,10 +122,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
* Used soley for LayoutMode.Double rendering. Will always hold the next image in buffer.
|
||||
*/
|
||||
canvasImage2 = new Image();
|
||||
/**
|
||||
* Dictates if we use render with canvas or with image. This is only for Splitting.
|
||||
*/
|
||||
renderWithCanvas: boolean = false;
|
||||
renderWithCanvas: boolean = false; // Dictates if we use render with canvas or with image
|
||||
|
||||
/**
|
||||
* A circular array of size PREFETCH_PAGES + 2. Maintains prefetched Images around the current page to load from to avoid loading animation.
|
||||
@ -329,6 +326,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
private libraryService: LibraryService, public utilityService: UtilityService,
|
||||
private renderer: Renderer2, @Inject(DOCUMENT) private document: Document, private modalService: NgbModal) {
|
||||
this.navService.hideNavBar();
|
||||
this.navService.hideSideNav();
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
@ -341,6 +339,8 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
|
||||
this.libraryId = parseInt(libraryId, 10);
|
||||
this.seriesId = parseInt(seriesId, 10);
|
||||
this.chapterId = parseInt(chapterId, 10);
|
||||
@ -428,6 +428,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
ngOnDestroy() {
|
||||
this.readerService.resetOverrideStyles();
|
||||
this.navService.showNavBar();
|
||||
this.navService.showSideNav();
|
||||
this.onDestroy.next();
|
||||
this.onDestroy.complete();
|
||||
this.goToPageEvent.complete();
|
||||
@ -533,8 +534,11 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
newOptions.ceil = this.maxPages - 1; // We -1 so that the slider UI shows us hitting the end, since visually we +1 everything.
|
||||
this.pageOptions = newOptions;
|
||||
|
||||
this.libraryType = results.chapterInfo.libraryType;
|
||||
this.updateTitle(results.chapterInfo, this.libraryType);
|
||||
// TODO: Move this into ChapterInfo
|
||||
this.libraryService.getLibraryType(results.chapterInfo.libraryId).pipe(take(1)).subscribe(type => {
|
||||
this.libraryType = type;
|
||||
this.updateTitle(results.chapterInfo, type);
|
||||
});
|
||||
|
||||
this.inSetup = false;
|
||||
|
||||
@ -645,20 +649,20 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
|
||||
getFittingOptionClass() {
|
||||
const formControl = this.generalSettingsForm.get('fittingOption');
|
||||
let val = FITTING_OPTION.HEIGHT as string;
|
||||
let val = FITTING_OPTION.HEIGHT;
|
||||
if (formControl === undefined) {
|
||||
val = FITTING_OPTION.HEIGHT as string;
|
||||
val = FITTING_OPTION.HEIGHT;
|
||||
}
|
||||
val = formControl?.value;
|
||||
|
||||
if (this.layoutMode !== LayoutMode.Single) {
|
||||
val = val + (this.isCoverImage() ? 'cover' : '') + 'double';
|
||||
} else if (this.isCoverImage() && this.shouldRenderAsFitSplit()) {
|
||||
// JOE: If we are Fit to Screen, we should use fitting as width just for cover images
|
||||
// Rewriting to fit to width for this cover image
|
||||
val = FITTING_OPTION.WIDTH;
|
||||
|
||||
if (this.isCoverImage() && this.layoutMode !== LayoutMode.Single) {
|
||||
return val + ' cover double';
|
||||
}
|
||||
|
||||
if (!this.isCoverImage() && this.layoutMode !== LayoutMode.Single) {
|
||||
return val + ' double';
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
@ -971,10 +975,6 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.renderWithCanvas = true;
|
||||
} else {
|
||||
this.renderWithCanvas = false;
|
||||
|
||||
// if (this.isCoverImage() && this.layoutMode === LayoutMode.Single && this.getFit() !== FITTING_OPTION.WIDTH) {
|
||||
|
||||
// }
|
||||
}
|
||||
|
||||
// Reset scroll on non HEIGHT Fits
|
||||
|
21
UI/Web/src/app/metadata-filter/filter-settings.ts
Normal file
21
UI/Web/src/app/metadata-filter/filter-settings.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { SeriesFilter } from "../_models/series-filter";
|
||||
|
||||
export class FilterSettings {
|
||||
libraryDisabled = false;
|
||||
formatDisabled = false;
|
||||
collectionDisabled = false;
|
||||
genresDisabled = false;
|
||||
peopleDisabled = false;
|
||||
readProgressDisabled = false;
|
||||
ratingDisabled = false;
|
||||
sortDisabled = false;
|
||||
ageRatingDisabled = false;
|
||||
tagsDisabled = false;
|
||||
languageDisabled = false;
|
||||
publicationStatusDisabled = false;
|
||||
presets: SeriesFilter | undefined;
|
||||
/**
|
||||
* Should the filter section be open by default
|
||||
*/
|
||||
openByDefault = false;
|
||||
}
|
338
UI/Web/src/app/metadata-filter/metadata-filter.component.html
Normal file
338
UI/Web/src/app/metadata-filter/metadata-filter.component.html
Normal file
@ -0,0 +1,338 @@
|
||||
<div class="phone-hidden">
|
||||
<div #collapse="ngbCollapse" [(ngbCollapse)]="filteringCollapsed">
|
||||
<ng-container [ngTemplateOutlet]="filterSection"></ng-container>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="not-phone-hidden">
|
||||
<app-drawer #commentDrawer="drawer" [isOpen]="!filteringCollapsed" [style.--drawer-width]="'300px'" [style.--drawer-background-color]="'#010409'" (drawerClosed)="filteringCollapsed = !filteringCollapsed">
|
||||
<div header>
|
||||
<h2 style="margin-top: 0.5rem">Book Settings
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="commentDrawer.close()">
|
||||
|
||||
</button>
|
||||
</h2>
|
||||
|
||||
</div>
|
||||
<div body class="drawer-body">
|
||||
<ng-container [ngTemplateOutlet]="filterSection"></ng-container>
|
||||
</div>
|
||||
</app-drawer>
|
||||
</div>
|
||||
|
||||
<ng-template #filterSection>
|
||||
<ng-template #globalFilterTooltip>This is library agnostic</ng-template>
|
||||
<div class="filter-section mx-auto pb-3">
|
||||
<div class="row justify-content-center g-0">
|
||||
<div class="col-md-2 me-3" *ngIf="!filterSettings.formatDisabled">
|
||||
<div class="mb-3">
|
||||
<label for="format" class="form-label">Format</label> <i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="globalFilterTooltip" role="button" tabindex="0"></i>
|
||||
<span class="visually-hidden" id="filter-global-format-help"><ng-container [ngTemplateOutlet]="globalFilterTooltip"></ng-container></span>
|
||||
<app-typeahead (selectedData)="updateFormatFilters($event)" [settings]="formatSettings" [reset]="resetTypeaheads">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.title}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.title}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 me-3"*ngIf="!filterSettings.libraryDisabled">
|
||||
<div class="mb-3">
|
||||
<label for="libraries" class="form-label">Libraries</label>
|
||||
<app-typeahead (selectedData)="updateLibraryFilters($event)" [settings]="librarySettings" [reset]="resetTypeaheads">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 me-3" *ngIf="!filterSettings.collectionDisabled">
|
||||
<div class="mb-3">
|
||||
<label for="collections" class="form-label">Collections</label> <i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="globalFilterTooltip" role="button" tabindex="0"></i>
|
||||
<span class="visually-hidden" id="filter-global-collections-help"><ng-container [ngTemplateOutlet]="globalFilterTooltip"></ng-container></span>
|
||||
<app-typeahead (selectedData)="updateCollectionFilters($event)" [settings]="collectionSettings" [reset]="resetTypeaheads">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.title}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.title}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 me-3" *ngIf="!filterSettings.genresDisabled">
|
||||
<div class="mb-3">
|
||||
<label for="genres" class="form-label">Genres</label>
|
||||
<app-typeahead (selectedData)="updateGenreFilters($event)" [settings]="genreSettings" [reset]="resetTypeaheads">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.title}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.title}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 me-3" *ngIf="!filterSettings.tagsDisabled">
|
||||
<div class="mb-3">
|
||||
<label for="tags" class="form-label">Tags</label>
|
||||
<app-typeahead (selectedData)="updateTagFilters($event)" [settings]="tagsSettings" [reset]="resetTypeaheads">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.title}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.title}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row justify-content-center g-0">
|
||||
<!-- The People row -->
|
||||
<div class="col-md-2 me-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.CoverArtist)">
|
||||
<div class="mb-3">
|
||||
<label for="cover-artist" class="form-label">Cover Artists</label>
|
||||
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.CoverArtist)" [settings]="getPersonsSettings(PersonRole.CoverArtist)" [reset]="resetTypeaheads">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 me-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Writer)">
|
||||
<div class="mb-3">
|
||||
<label for="writers" class="form-label">Writers</label>
|
||||
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Writer)" [settings]="getPersonsSettings(PersonRole.Writer)" [reset]="resetTypeaheads">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 me-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Publisher)">
|
||||
<div class="mb-3">
|
||||
<label for="publisher" class="form-label">Publisher</label>
|
||||
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Publisher)" [settings]="getPersonsSettings(PersonRole.Publisher)" [reset]="resetTypeaheads">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 me-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Penciller)">
|
||||
<div class="mb-3">
|
||||
<label for="penciller" class="form-label">Penciller</label>
|
||||
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Penciller)" [settings]="getPersonsSettings(PersonRole.Penciller)" [reset]="resetTypeaheads">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 me-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Letterer)">
|
||||
<div class="mb-3">
|
||||
<label for="letterer" class="form-label">Letterer</label>
|
||||
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Letterer)" [settings]="getPersonsSettings(PersonRole.Letterer)" [reset]="resetTypeaheads">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 me-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Inker)">
|
||||
<div class="mb-3">
|
||||
<label for="inker" class="form-label">Inker</label>
|
||||
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Inker)" [settings]="getPersonsSettings(PersonRole.Inker)" [reset]="resetTypeaheads">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 me-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Editor)">
|
||||
<div class="mb-3">
|
||||
<label for="editor" class="form-label">Editor</label>
|
||||
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Editor)" [settings]="getPersonsSettings(PersonRole.Editor)" [reset]="resetTypeaheads">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 me-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Colorist)">
|
||||
<div class="mb-3">
|
||||
<label for="colorist" class="form-label">Colorist</label>
|
||||
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Colorist)" [settings]="getPersonsSettings(PersonRole.Colorist)" [reset]="resetTypeaheads">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 me-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Character)">
|
||||
<div class="mb-3">
|
||||
<label for="character" class="form-label">Character</label>
|
||||
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Character)" [settings]="getPersonsSettings(PersonRole.Character)" [reset]="resetTypeaheads">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 me-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Translator)">
|
||||
<div class="mb-3">
|
||||
<label for="translators" class="form-label">Translators</label>
|
||||
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Translator)" [settings]="getPersonsSettings(PersonRole.Translator)" [reset]="resetTypeaheads">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row justify-content-center g-0">
|
||||
<div class="col-md-2 me-3" *ngIf="!filterSettings.readProgressDisabled">
|
||||
<label class="form-label">Read Progress</label>
|
||||
<form [formGroup]="readProgressGroup">
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="notread" formControlName="notRead">
|
||||
<label class="form-check-label" for="notread">Unread</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="inprogress" formControlName="inProgress">
|
||||
<label class="form-check-label" for="inprogress">In Progress</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="read" formControlName="read">
|
||||
<label class="form-check-label" for="read">Read</label>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 me-3" *ngIf="!filterSettings.ratingDisabled">
|
||||
<label for="ratings" class="form-label">Rating</label>
|
||||
<form class="form-inline">
|
||||
<ngb-rating class="rating-star" [(rate)]="filter.rating" (rateChange)="updateRating($event)" [resettable]="true">
|
||||
<ng-template let-fill="fill" let-index="index">
|
||||
<span class="star" [class.filled]="(index >= (filter.rating - 1)) && filter.rating > 0" [ngbTooltip]="(index + 1) + ' and up'">★</span>
|
||||
</ng-template>
|
||||
</ngb-rating>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 me-3" *ngIf="!filterSettings.ageRatingDisabled">
|
||||
<label for="age-rating" class="form-label">Age Rating</label>
|
||||
<app-typeahead (selectedData)="updateAgeRating($event)" [settings]="ageRatingSettings" [reset]="resetTypeaheads">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.title}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.title}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 me-3" *ngIf="!filterSettings.languageDisabled">
|
||||
<label for="languages" class="form-label">Language</label>
|
||||
<app-typeahead (selectedData)="updateLanguageRating($event)" [settings]="languageSettings" [reset]="resetTypeaheads">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.title}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.title}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 me-3" *ngIf="!filterSettings.publicationStatusDisabled">
|
||||
<label for="publication-status" class="form-label">Publication Status</label>
|
||||
<app-typeahead (selectedData)="updatePublicationStatus($event)" [settings]="publicationStatusSettings" [reset]="resetTypeaheads">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.title}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.title}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
<div class="col-md-2 me-3"></div>
|
||||
</div>
|
||||
<div class="row justify-content-center g-0">
|
||||
<div class="col-md-2 me-3" *ngIf="!filterSettings.sortDisabled">
|
||||
<form [formGroup]="sortGroup">
|
||||
<div class="mb-3">
|
||||
<label for="sort-options" class="form-label">Sort By</label>
|
||||
<button class="btn btn-sm btn-secondary-outline" (click)="updateSortOrder()" style="height: 25px; padding-bottom: 0px;">
|
||||
<i class="fa fa-arrow-up" title="Ascending" *ngIf="isAscendingSort; else descSort"></i>
|
||||
<ng-template #descSort>
|
||||
<i class="fa fa-arrow-down" title="Descending"></i>
|
||||
</ng-template>
|
||||
</button>
|
||||
<select id="sort-options" class="form-select" formControlName="sortField" style="height: 38px;">
|
||||
<option [value]="SortField.SortName">Sort Name</option>
|
||||
<option [value]="SortField.Created">Created</option>
|
||||
<option [value]="SortField.LastModified">Last Modified</option>
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="col-md-2 me-3" *ngIf="filterSettings.sortDisabled"></div>
|
||||
<div class="col-md-2 me-3"></div>
|
||||
<div class="col-md-2 me-3"></div>
|
||||
<div class="col-md-2 me-3">
|
||||
<button class="btn btn-secondary col-12" (click)="clear()">Clear</button>
|
||||
</div>
|
||||
<div class="col-md-2 me-3">
|
||||
<button class="btn btn-primary col-12" (click)="apply()">Apply</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
555
UI/Web/src/app/metadata-filter/metadata-filter.component.ts
Normal file
555
UI/Web/src/app/metadata-filter/metadata-filter.component.ts
Normal file
@ -0,0 +1,555 @@
|
||||
import { Component, ContentChild, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core';
|
||||
import { FormControl, FormGroup } from '@angular/forms';
|
||||
import { NgbCollapse } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { forkJoin, map, Observable, of, ReplaySubject, Subject, takeUntil } from 'rxjs';
|
||||
import { UtilityService } from '../shared/_services/utility.service';
|
||||
import { TypeaheadSettings } from '../typeahead/typeahead-settings';
|
||||
import { CollectionTag } from '../_models/collection-tag';
|
||||
import { Genre } from '../_models/genre';
|
||||
import { Library } from '../_models/library';
|
||||
import { MangaFormat } from '../_models/manga-format';
|
||||
import { AgeRatingDto } from '../_models/metadata/age-rating-dto';
|
||||
import { Language } from '../_models/metadata/language';
|
||||
import { PublicationStatusDto } from '../_models/metadata/publication-status-dto';
|
||||
import { Person, PersonRole } from '../_models/person';
|
||||
import { FilterEvent, FilterItem, mangaFormatFilters, SeriesFilter, SortField } from '../_models/series-filter';
|
||||
import { Tag } from '../_models/tag';
|
||||
import { CollectionTagService } from '../_services/collection-tag.service';
|
||||
import { LibraryService } from '../_services/library.service';
|
||||
import { MetadataService } from '../_services/metadata.service';
|
||||
import { SeriesService } from '../_services/series.service';
|
||||
import { FilterSettings } from './filter-settings';
|
||||
|
||||
@Component({
|
||||
selector: 'app-metadata-filter',
|
||||
templateUrl: './metadata-filter.component.html',
|
||||
styleUrls: ['./metadata-filter.component.scss']
|
||||
})
|
||||
export class MetadataFilterComponent implements OnInit, OnDestroy {
|
||||
|
||||
/**
|
||||
* This toggles the opening/collapsing of the metadata filter code
|
||||
*/
|
||||
@Input() filterOpen: EventEmitter<boolean> = new EventEmitter();
|
||||
|
||||
/**
|
||||
* Should filtering be shown on the page
|
||||
*/
|
||||
@Input() filteringDisabled: boolean = false;
|
||||
|
||||
@Input() filterSettings!: FilterSettings;
|
||||
|
||||
@Output() applyFilter: EventEmitter<FilterEvent> = new EventEmitter();
|
||||
|
||||
@ContentChild('[ngbCollapse]') collapse!: NgbCollapse;
|
||||
|
||||
|
||||
formatSettings: TypeaheadSettings<FilterItem<MangaFormat>> = new TypeaheadSettings();
|
||||
librarySettings: TypeaheadSettings<Library> = new TypeaheadSettings();
|
||||
genreSettings: TypeaheadSettings<Genre> = new TypeaheadSettings();
|
||||
collectionSettings: TypeaheadSettings<CollectionTag> = new TypeaheadSettings();
|
||||
ageRatingSettings: TypeaheadSettings<AgeRatingDto> = new TypeaheadSettings();
|
||||
publicationStatusSettings: TypeaheadSettings<PublicationStatusDto> = new TypeaheadSettings();
|
||||
tagsSettings: TypeaheadSettings<Tag> = new TypeaheadSettings();
|
||||
languageSettings: TypeaheadSettings<Language> = new TypeaheadSettings();
|
||||
peopleSettings: {[PersonRole: string]: TypeaheadSettings<Person>} = {};
|
||||
resetTypeaheads: Subject<boolean> = new ReplaySubject(1);
|
||||
|
||||
/**
|
||||
* Controls the visiblity of extended controls that sit below the main header.
|
||||
*/
|
||||
filteringCollapsed: boolean = true;
|
||||
|
||||
filter!: SeriesFilter;
|
||||
libraries: Array<FilterItem<Library>> = [];
|
||||
|
||||
|
||||
readProgressGroup!: FormGroup;
|
||||
sortGroup!: FormGroup;
|
||||
isAscendingSort: boolean = true;
|
||||
|
||||
updateApplied: number = 0;
|
||||
|
||||
|
||||
private onDestory: Subject<void> = new Subject();
|
||||
|
||||
get PersonRole(): typeof PersonRole {
|
||||
return PersonRole;
|
||||
}
|
||||
|
||||
get SortField(): typeof SortField {
|
||||
return SortField;
|
||||
}
|
||||
|
||||
constructor(private libraryService: LibraryService, private metadataService: MetadataService, private seriesService: SeriesService,
|
||||
private utilityService: UtilityService, private collectionTagService: CollectionTagService) {
|
||||
this.filter = this.seriesService.createSeriesFilter();
|
||||
this.readProgressGroup = new FormGroup({
|
||||
read: new FormControl(this.filter.readStatus.read, []),
|
||||
notRead: new FormControl(this.filter.readStatus.notRead, []),
|
||||
inProgress: new FormControl(this.filter.readStatus.inProgress, []),
|
||||
});
|
||||
|
||||
this.sortGroup = new FormGroup({
|
||||
sortField: new FormControl(this.filter.sortOptions?.sortField || SortField.SortName, []),
|
||||
});
|
||||
|
||||
this.readProgressGroup.valueChanges.pipe(takeUntil(this.onDestory)).subscribe(changes => {
|
||||
this.filter.readStatus.read = this.readProgressGroup.get('read')?.value;
|
||||
this.filter.readStatus.inProgress = this.readProgressGroup.get('inProgress')?.value;
|
||||
this.filter.readStatus.notRead = this.readProgressGroup.get('notRead')?.value;
|
||||
|
||||
let sum = 0;
|
||||
sum += (this.filter.readStatus.read ? 1 : 0);
|
||||
sum += (this.filter.readStatus.inProgress ? 1 : 0);
|
||||
sum += (this.filter.readStatus.notRead ? 1 : 0);
|
||||
|
||||
if (sum === 1) {
|
||||
if (this.filter.readStatus.read) this.readProgressGroup.get('read')?.disable({ emitEvent: false });
|
||||
if (this.filter.readStatus.notRead) this.readProgressGroup.get('notRead')?.disable({ emitEvent: false });
|
||||
if (this.filter.readStatus.inProgress) this.readProgressGroup.get('inProgress')?.disable({ emitEvent: false });
|
||||
} else {
|
||||
this.readProgressGroup.get('read')?.enable({ emitEvent: false });
|
||||
this.readProgressGroup.get('notRead')?.enable({ emitEvent: false });
|
||||
this.readProgressGroup.get('inProgress')?.enable({ emitEvent: false });
|
||||
}
|
||||
});
|
||||
|
||||
this.sortGroup.valueChanges.pipe(takeUntil(this.onDestory)).subscribe(changes => {
|
||||
if (this.filter.sortOptions == null) {
|
||||
this.filter.sortOptions = {
|
||||
isAscending: this.isAscendingSort,
|
||||
sortField: parseInt(this.sortGroup.get('sortField')?.value, 10)
|
||||
};
|
||||
}
|
||||
this.filter.sortOptions.sortField = parseInt(this.sortGroup.get('sortField')?.value, 10);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.filterSettings === undefined) {
|
||||
this.filterSettings = new FilterSettings();
|
||||
}
|
||||
|
||||
if (this.filterOpen) {
|
||||
this.filterOpen.pipe(takeUntil(this.onDestory)).subscribe(openState => {
|
||||
this.filteringCollapsed = !openState;
|
||||
});
|
||||
}
|
||||
|
||||
this.setupTypeaheads();
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.onDestory.next();
|
||||
this.onDestory.complete();
|
||||
}
|
||||
|
||||
getPersonsSettings(role: PersonRole) {
|
||||
return this.peopleSettings[role];
|
||||
}
|
||||
|
||||
setupTypeaheads() {
|
||||
|
||||
this.setupFormatTypeahead();
|
||||
|
||||
forkJoin([
|
||||
this.setupLibraryTypeahead(),
|
||||
this.setupCollectionTagTypeahead(),
|
||||
this.setupAgeRatingSettings(),
|
||||
this.setupPublicationStatusSettings(),
|
||||
this.setupTagSettings(),
|
||||
this.setupLanguageSettings(),
|
||||
this.setupGenreTypeahead(),
|
||||
this.setupPersonTypeahead(),
|
||||
]).subscribe(results => {
|
||||
this.resetTypeaheads.next(true);
|
||||
if (this.filterSettings.openByDefault) {
|
||||
this.filteringCollapsed = false;
|
||||
}
|
||||
this.apply();
|
||||
});
|
||||
}
|
||||
|
||||
setupFormatTypeahead() {
|
||||
this.formatSettings.minCharacters = 0;
|
||||
this.formatSettings.multiple = true;
|
||||
this.formatSettings.id = 'format';
|
||||
this.formatSettings.unique = true;
|
||||
this.formatSettings.addIfNonExisting = false;
|
||||
this.formatSettings.fetchFn = (filter: string) => of(mangaFormatFilters).pipe(map(items => this.formatSettings.compareFn(items, filter)));
|
||||
this.formatSettings.compareFn = (options: FilterItem<MangaFormat>[], filter: string) => {
|
||||
return options.filter(m => this.utilityService.filter(m.title, filter));
|
||||
}
|
||||
|
||||
this.formatSettings.singleCompareFn = (a: FilterItem<MangaFormat>, b: FilterItem<MangaFormat>) => {
|
||||
return a.title == b.title;
|
||||
}
|
||||
|
||||
if (this.filterSettings.presets?.formats && this.filterSettings.presets?.formats.length > 0) {
|
||||
this.formatSettings.savedData = mangaFormatFilters.filter(item => this.filterSettings.presets?.formats.includes(item.value));
|
||||
this.filter.formats = this.formatSettings.savedData.map(item => item.value);
|
||||
this.resetTypeaheads.next(true);
|
||||
}
|
||||
}
|
||||
|
||||
setupLibraryTypeahead() {
|
||||
this.librarySettings.minCharacters = 0;
|
||||
this.librarySettings.multiple = true;
|
||||
this.librarySettings.id = 'libraries';
|
||||
this.librarySettings.unique = true;
|
||||
this.librarySettings.addIfNonExisting = false;
|
||||
this.librarySettings.fetchFn = (filter: string) => {
|
||||
return this.libraryService.getLibrariesForMember()
|
||||
.pipe(map(items => this.librarySettings.compareFn(items, filter)));
|
||||
};
|
||||
this.librarySettings.compareFn = (options: Library[], filter: string) => {
|
||||
return options.filter(m => this.utilityService.filter(m.name, filter));
|
||||
}
|
||||
this.librarySettings.singleCompareFn = (a: Library, b: Library) => {
|
||||
return a.name == b.name;
|
||||
}
|
||||
|
||||
if (this.filterSettings.presets?.libraries && this.filterSettings.presets?.libraries.length > 0) {
|
||||
return this.librarySettings.fetchFn('').pipe(map(libraries => {
|
||||
this.librarySettings.savedData = libraries.filter(item => this.filterSettings.presets?.libraries.includes(item.id));
|
||||
this.filter.libraries = this.librarySettings.savedData.map(item => item.id);
|
||||
return of(true);
|
||||
}));
|
||||
}
|
||||
return of(true);
|
||||
}
|
||||
|
||||
setupGenreTypeahead() {
|
||||
this.genreSettings.minCharacters = 0;
|
||||
this.genreSettings.multiple = true;
|
||||
this.genreSettings.id = 'genres';
|
||||
this.genreSettings.unique = true;
|
||||
this.genreSettings.addIfNonExisting = false;
|
||||
this.genreSettings.fetchFn = (filter: string) => {
|
||||
return this.metadataService.getAllGenres(this.filter.libraries)
|
||||
.pipe(map(items => this.genreSettings.compareFn(items, filter)));
|
||||
};
|
||||
this.genreSettings.compareFn = (options: Genre[], filter: string) => {
|
||||
return options.filter(m => this.utilityService.filter(m.title, filter));
|
||||
}
|
||||
this.genreSettings.singleCompareFn = (a: Genre, b: Genre) => {
|
||||
return a.title == b.title;
|
||||
}
|
||||
|
||||
if (this.filterSettings.presets?.genres && this.filterSettings.presets?.genres.length > 0) {
|
||||
return this.genreSettings.fetchFn('').pipe(map(genres => {
|
||||
this.genreSettings.savedData = genres.filter(item => this.filterSettings.presets?.genres.includes(item.id));
|
||||
this.filter.genres = this.genreSettings.savedData.map(item => item.id);
|
||||
return of(true);
|
||||
}));
|
||||
}
|
||||
return of(true);
|
||||
}
|
||||
|
||||
setupAgeRatingSettings() {
|
||||
this.ageRatingSettings.minCharacters = 0;
|
||||
this.ageRatingSettings.multiple = true;
|
||||
this.ageRatingSettings.id = 'age-rating';
|
||||
this.ageRatingSettings.unique = true;
|
||||
this.ageRatingSettings.addIfNonExisting = false;
|
||||
this.ageRatingSettings.fetchFn = (filter: string) => this.metadataService.getAllAgeRatings(this.filter.libraries)
|
||||
.pipe(map(items => this.ageRatingSettings.compareFn(items, filter)));
|
||||
|
||||
this.ageRatingSettings.compareFn = (options: AgeRatingDto[], filter: string) => {
|
||||
return options.filter(m => this.utilityService.filter(m.title, filter));
|
||||
}
|
||||
|
||||
this.ageRatingSettings.singleCompareFn = (a: AgeRatingDto, b: AgeRatingDto) => {
|
||||
return a.title == b.title;
|
||||
}
|
||||
|
||||
if (this.filterSettings.presets?.ageRating && this.filterSettings.presets?.ageRating.length > 0) {
|
||||
return this.ageRatingSettings.fetchFn('').pipe(map(rating => {
|
||||
this.ageRatingSettings.savedData = rating.filter(item => this.filterSettings.presets?.ageRating.includes(item.value));
|
||||
this.filter.ageRating = this.ageRatingSettings.savedData.map(item => item.value);
|
||||
return of(true);
|
||||
}));
|
||||
}
|
||||
return of(true);
|
||||
}
|
||||
|
||||
setupPublicationStatusSettings() {
|
||||
this.publicationStatusSettings.minCharacters = 0;
|
||||
this.publicationStatusSettings.multiple = true;
|
||||
this.publicationStatusSettings.id = 'publication-status';
|
||||
this.publicationStatusSettings.unique = true;
|
||||
this.publicationStatusSettings.addIfNonExisting = false;
|
||||
this.publicationStatusSettings.fetchFn = (filter: string) => this.metadataService.getAllPublicationStatus(this.filter.libraries)
|
||||
.pipe(map(items => this.publicationStatusSettings.compareFn(items, filter)));
|
||||
|
||||
this.publicationStatusSettings.compareFn = (options: PublicationStatusDto[], filter: string) => {
|
||||
return options.filter(m => this.utilityService.filter(m.title, filter));
|
||||
}
|
||||
|
||||
this.publicationStatusSettings.singleCompareFn = (a: PublicationStatusDto, b: PublicationStatusDto) => {
|
||||
return a.title == b.title;
|
||||
}
|
||||
|
||||
if (this.filterSettings.presets?.publicationStatus && this.filterSettings.presets?.publicationStatus.length > 0) {
|
||||
return this.publicationStatusSettings.fetchFn('').pipe(map(statuses => {
|
||||
this.publicationStatusSettings.savedData = statuses.filter(item => this.filterSettings.presets?.publicationStatus.includes(item.value));
|
||||
this.filter.publicationStatus = this.publicationStatusSettings.savedData.map(item => item.value);
|
||||
return of(true);
|
||||
}));
|
||||
}
|
||||
return of(true);
|
||||
}
|
||||
|
||||
setupTagSettings() {
|
||||
this.tagsSettings.minCharacters = 0;
|
||||
this.tagsSettings.multiple = true;
|
||||
this.tagsSettings.id = 'tags';
|
||||
this.tagsSettings.unique = true;
|
||||
this.tagsSettings.addIfNonExisting = false;
|
||||
this.tagsSettings.compareFn = (options: Tag[], filter: string) => {
|
||||
return options.filter(m => this.utilityService.filter(m.title, filter));
|
||||
}
|
||||
this.tagsSettings.fetchFn = (filter: string) => this.metadataService.getAllTags(this.filter.libraries)
|
||||
.pipe(map(items => this.tagsSettings.compareFn(items, filter)));
|
||||
|
||||
this.tagsSettings.singleCompareFn = (a: Tag, b: Tag) => {
|
||||
return a.id == b.id;
|
||||
}
|
||||
|
||||
if (this.filterSettings.presets?.tags && this.filterSettings.presets?.tags.length > 0) {
|
||||
return this.tagsSettings.fetchFn('').pipe(map(tags => {
|
||||
this.tagsSettings.savedData = tags.filter(item => this.filterSettings.presets?.tags.includes(item.id));
|
||||
this.filter.tags = this.tagsSettings.savedData.map(item => item.id);
|
||||
return of(true);
|
||||
}));
|
||||
}
|
||||
return of(true);
|
||||
}
|
||||
|
||||
setupLanguageSettings() {
|
||||
this.languageSettings.minCharacters = 0;
|
||||
this.languageSettings.multiple = true;
|
||||
this.languageSettings.id = 'languages';
|
||||
this.languageSettings.unique = true;
|
||||
this.languageSettings.addIfNonExisting = false;
|
||||
this.languageSettings.compareFn = (options: Language[], filter: string) => {
|
||||
return options.filter(m => this.utilityService.filter(m.title, filter));
|
||||
}
|
||||
this.languageSettings.fetchFn = (filter: string) => this.metadataService.getAllLanguages(this.filter.libraries)
|
||||
.pipe(map(items => this.languageSettings.compareFn(items, filter)));
|
||||
|
||||
this.languageSettings.singleCompareFn = (a: Language, b: Language) => {
|
||||
return a.isoCode == b.isoCode;
|
||||
}
|
||||
|
||||
if (this.filterSettings.presets?.languages && this.filterSettings.presets?.languages.length > 0) {
|
||||
return this.languageSettings.fetchFn('').pipe(map(languages => {
|
||||
this.languageSettings.savedData = languages.filter(item => this.filterSettings.presets?.languages.includes(item.isoCode));
|
||||
this.filter.languages = this.languageSettings.savedData.map(item => item.isoCode);
|
||||
return of(true);
|
||||
}));
|
||||
}
|
||||
return of(true);
|
||||
}
|
||||
|
||||
setupCollectionTagTypeahead() {
|
||||
this.collectionSettings.minCharacters = 0;
|
||||
this.collectionSettings.multiple = true;
|
||||
this.collectionSettings.id = 'collections';
|
||||
this.collectionSettings.unique = true;
|
||||
this.collectionSettings.addIfNonExisting = false;
|
||||
this.collectionSettings.compareFn = (options: CollectionTag[], filter: string) => {
|
||||
return options.filter(m => this.utilityService.filter(m.title, filter));
|
||||
}
|
||||
this.collectionSettings.fetchFn = (filter: string) => this.collectionTagService.allTags()
|
||||
.pipe(map(items => this.collectionSettings.compareFn(items, filter)));
|
||||
|
||||
this.collectionSettings.singleCompareFn = (a: CollectionTag, b: CollectionTag) => {
|
||||
return a.id == b.id;
|
||||
}
|
||||
|
||||
if (this.filterSettings.presets?.collectionTags && this.filterSettings.presets?.collectionTags.length > 0) {
|
||||
return this.collectionSettings.fetchFn('').pipe(map(tags => {
|
||||
this.collectionSettings.savedData = tags.filter(item => this.filterSettings.presets?.collectionTags.includes(item.id));
|
||||
this.filter.collectionTags = this.collectionSettings.savedData.map(item => item.id);
|
||||
return of(true);
|
||||
}));
|
||||
}
|
||||
return of(true);
|
||||
}
|
||||
|
||||
updateFromPreset(id: string, peopleFilterField: Array<any>, presetField: Array<any> | undefined, role: PersonRole) {
|
||||
const personSettings = this.createBlankPersonSettings(id, role)
|
||||
if (presetField && presetField.length > 0) {
|
||||
const fetch = personSettings.fetchFn as ((filter: string) => Observable<Person[]>);
|
||||
return fetch('').pipe(map(people => {
|
||||
personSettings.savedData = people.filter(item => presetField.includes(item.id));
|
||||
peopleFilterField = personSettings.savedData.map(item => item.id);
|
||||
this.resetTypeaheads.next(true);
|
||||
this.peopleSettings[role] = personSettings;
|
||||
this.updatePersonFilters(personSettings.savedData as Person[], role);
|
||||
return true;
|
||||
}));
|
||||
} else {
|
||||
this.peopleSettings[role] = personSettings;
|
||||
return of(true);
|
||||
}
|
||||
}
|
||||
|
||||
setupPersonTypeahead() {
|
||||
this.peopleSettings = {};
|
||||
|
||||
return forkJoin([
|
||||
this.updateFromPreset('writers', this.filter.writers, this.filterSettings.presets?.writers, PersonRole.Writer),
|
||||
this.updateFromPreset('character', this.filter.character, this.filterSettings.presets?.character, PersonRole.Character),
|
||||
this.updateFromPreset('colorist', this.filter.colorist, this.filterSettings.presets?.colorist, PersonRole.Colorist),
|
||||
this.updateFromPreset('cover-artist', this.filter.coverArtist, this.filterSettings.presets?.coverArtist, PersonRole.CoverArtist),
|
||||
this.updateFromPreset('editor', this.filter.editor, this.filterSettings.presets?.editor, PersonRole.Editor),
|
||||
this.updateFromPreset('inker', this.filter.inker, this.filterSettings.presets?.inker, PersonRole.Inker),
|
||||
this.updateFromPreset('letterer', this.filter.letterer, this.filterSettings.presets?.letterer, PersonRole.Letterer),
|
||||
this.updateFromPreset('penciller', this.filter.penciller, this.filterSettings.presets?.penciller, PersonRole.Penciller),
|
||||
this.updateFromPreset('publisher', this.filter.publisher, this.filterSettings.presets?.publisher, PersonRole.Publisher),
|
||||
this.updateFromPreset('translators', this.filter.translators, this.filterSettings.presets?.translators, PersonRole.Translator)
|
||||
]).pipe(map(results => {
|
||||
this.resetTypeaheads.next(true);
|
||||
return of(true);
|
||||
}));
|
||||
}
|
||||
|
||||
fetchPeople(role: PersonRole, filter: string) {
|
||||
return this.metadataService.getAllPeople(this.filter.libraries).pipe(map(people => {
|
||||
return people.filter(p => p.role == role && this.utilityService.filter(p.name, filter));
|
||||
}));
|
||||
}
|
||||
|
||||
createBlankPersonSettings(id: string, role: PersonRole) {
|
||||
var personSettings = new TypeaheadSettings<Person>();
|
||||
personSettings.minCharacters = 0;
|
||||
personSettings.multiple = true;
|
||||
personSettings.unique = true;
|
||||
personSettings.addIfNonExisting = false;
|
||||
personSettings.id = id;
|
||||
personSettings.compareFn = (options: Person[], filter: string) => {
|
||||
return options.filter(m => this.utilityService.filter(m.name, filter));
|
||||
}
|
||||
|
||||
personSettings.singleCompareFn = (a: Person, b: Person) => {
|
||||
return a.name == b.name && a.role == b.role;
|
||||
}
|
||||
personSettings.fetchFn = (filter: string) => {
|
||||
return this.fetchPeople(role, filter).pipe(map(items => personSettings.compareFn(items, filter)));
|
||||
};
|
||||
return personSettings;
|
||||
}
|
||||
|
||||
updateFormatFilters(formats: MangaFormat[]) {
|
||||
this.filter.formats = formats.map(item => item) || [];
|
||||
}
|
||||
|
||||
updateLibraryFilters(libraries: Library[]) {
|
||||
this.filter.libraries = libraries.map(item => item.id) || [];
|
||||
}
|
||||
|
||||
updateGenreFilters(genres: Genre[]) {
|
||||
this.filter.genres = genres.map(item => item.id) || [];
|
||||
}
|
||||
|
||||
updateTagFilters(tags: Tag[]) {
|
||||
this.filter.tags = tags.map(item => item.id) || [];
|
||||
}
|
||||
|
||||
updatePersonFilters(persons: Person[], role: PersonRole) {
|
||||
switch (role) {
|
||||
case PersonRole.CoverArtist:
|
||||
this.filter.coverArtist = persons.map(p => p.id);
|
||||
break;
|
||||
case PersonRole.Character:
|
||||
this.filter.character = persons.map(p => p.id);
|
||||
break;
|
||||
case PersonRole.Colorist:
|
||||
this.filter.colorist = persons.map(p => p.id);
|
||||
break;
|
||||
case PersonRole.Editor:
|
||||
this.filter.editor = persons.map(p => p.id);
|
||||
break;
|
||||
case PersonRole.Inker:
|
||||
this.filter.inker = persons.map(p => p.id);
|
||||
break;
|
||||
case PersonRole.Letterer:
|
||||
this.filter.letterer = persons.map(p => p.id);
|
||||
break;
|
||||
case PersonRole.Penciller:
|
||||
this.filter.penciller = persons.map(p => p.id);
|
||||
break;
|
||||
case PersonRole.Publisher:
|
||||
this.filter.publisher = persons.map(p => p.id);
|
||||
break;
|
||||
case PersonRole.Writer:
|
||||
this.filter.writers = persons.map(p => p.id);
|
||||
break;
|
||||
case PersonRole.Translator:
|
||||
this.filter.translators = persons.map(p => p.id);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
updateCollectionFilters(tags: CollectionTag[]) {
|
||||
this.filter.collectionTags = tags.map(item => item.id) || [];
|
||||
}
|
||||
|
||||
updateRating(rating: any) {
|
||||
this.filter.rating = rating;
|
||||
}
|
||||
|
||||
updateAgeRating(ratingDtos: AgeRatingDto[]) {
|
||||
this.filter.ageRating = ratingDtos.map(item => item.value) || [];
|
||||
}
|
||||
|
||||
updatePublicationStatus(dtos: PublicationStatusDto[]) {
|
||||
this.filter.publicationStatus = dtos.map(item => item.value) || [];
|
||||
}
|
||||
|
||||
updateLanguageRating(languages: Language[]) {
|
||||
this.filter.languages = languages.map(item => item.isoCode) || [];
|
||||
}
|
||||
|
||||
updateReadStatus(status: string) {
|
||||
if (status === 'read') {
|
||||
this.filter.readStatus.read = !this.filter.readStatus.read;
|
||||
} else if (status === 'inProgress') {
|
||||
this.filter.readStatus.inProgress = !this.filter.readStatus.inProgress;
|
||||
} else if (status === 'notRead') {
|
||||
this.filter.readStatus.notRead = !this.filter.readStatus.notRead;
|
||||
}
|
||||
}
|
||||
|
||||
updateSortOrder() {
|
||||
this.isAscendingSort = !this.isAscendingSort;
|
||||
if (this.filter.sortOptions === null) {
|
||||
this.filter.sortOptions = {
|
||||
isAscending: this.isAscendingSort,
|
||||
sortField: SortField.SortName
|
||||
}
|
||||
}
|
||||
|
||||
this.filter.sortOptions.isAscending = this.isAscendingSort;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.filter = this.seriesService.createSeriesFilter();
|
||||
this.readProgressGroup.get('read')?.setValue(true);
|
||||
this.readProgressGroup.get('notRead')?.setValue(true);
|
||||
this.readProgressGroup.get('inProgress')?.setValue(true);
|
||||
this.sortGroup.get('sortField')?.setValue(SortField.SortName);
|
||||
this.isAscendingSort = true;
|
||||
// Apply any presets which will trigger the apply
|
||||
this.setupTypeaheads();
|
||||
}
|
||||
|
||||
apply() {
|
||||
this.applyFilter.emit({filter: this.filter, isFirst: this.updateApplied === 0});
|
||||
this.updateApplied++;
|
||||
}
|
||||
|
||||
}
|
27
UI/Web/src/app/metadata-filter/metadata-filter.module.ts
Normal file
27
UI/Web/src/app/metadata-filter/metadata-filter.module.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MetadataFilterComponent } from './metadata-filter.component';
|
||||
import { NgbCollapseModule, NgbRatingModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { SharedModule } from '../shared/shared.module';
|
||||
import { TypeaheadModule } from '../typeahead/typeahead.module';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
MetadataFilterComponent
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
NgbTooltipModule,
|
||||
NgbRatingModule,
|
||||
NgbCollapseModule,
|
||||
SharedModule,
|
||||
TypeaheadModule
|
||||
],
|
||||
exports: [
|
||||
MetadataFilterComponent
|
||||
]
|
||||
})
|
||||
export class MetadataFilterModule { }
|
@ -1,6 +1,7 @@
|
||||
<nav class="navbar navbar-expand-md navbar-dark fixed-top" *ngIf="navService?.navbarVisible$ | async">
|
||||
<div class="container-fluid">
|
||||
<a class="visually-hidden-focusable focus-visible" href="javascript:void(0);" (click)="moveFocus()">Skip to main content</a>
|
||||
<a class="side-nav-toggle" (click)="hideSideNav()"><i class="fas fa-bars"></i></a>
|
||||
<a class="navbar-brand dark-exempt" routerLink="/library" routerLinkActive="active"><img class="logo" src="../../assets/images/logo.png" alt="kavita icon" aria-hidden="true"/><span class="d-none d-md-inline"> Kavita</span></a>
|
||||
<ul class="navbar-nav col me-auto">
|
||||
|
||||
@ -117,7 +118,7 @@
|
||||
<div class="nav-item">
|
||||
<app-nav-events-toggle [user]="user"></app-nav-events-toggle>
|
||||
</div>
|
||||
<div class="nav-item pe-2 not-xs-only">
|
||||
<div class="nav-item not-xs-only">
|
||||
<a routerLink="/admin/dashboard" *ngIf="user.roles.includes('Admin')" class="dark-exempt btn btn-icon" title="Server Settings">
|
||||
<i class="fa fa-cogs nav" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">Server Settings</span>
|
||||
@ -127,11 +128,13 @@
|
||||
|
||||
<div ngbDropdown class="nav-item dropdown" display="dynamic" placement="bottom-right" *ngIf="(accountService.currentUser$ | async) as user" dropdown>
|
||||
<button class="btn btn-outline-secondary primary-text" ngbDropdownToggle>
|
||||
{{user.username | sentenceCase}}
|
||||
<i class="fa-solid fa-user-circle align-self-center phone-hidden d-xs-inline-block d-sm-inline-block d-md-none"></i><span class="d-none d-xs-none d-sm-none d-md-inline-block">{{user.username | sentenceCase}}</span>
|
||||
</button>
|
||||
<div ngbDropdownMenu>
|
||||
<a class="xs-only" ngbDropdownItem routerLink="/admin/dashboard" *ngIf="user.roles.includes('Admin')">Server Settings</a>
|
||||
<a ngbDropdownItem routerLink="/preferences/">Settings</a>
|
||||
<a ngbDropdownItem href="https://wiki.kavitareader.com" rel="noreferrer" target="_blank">Help</a>
|
||||
<a ngbDropdownItem routerLink="/announcements/" *ngIf="accountService.hasAdminRole(user)">Annoucements</a>
|
||||
<a ngbDropdownItem (click)="logout()">Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -5,6 +5,14 @@
|
||||
|
||||
.navbar {
|
||||
background-color: var(--navbar-bg-color);
|
||||
|
||||
.side-nav-toggle {
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
i {
|
||||
color: var(--navbar-fa-icon-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* small devices (phones, 650px and down) */
|
||||
@ -38,6 +46,7 @@
|
||||
.navbar-brand {
|
||||
font-family: var(--brand-font-family);
|
||||
font-weight: bold;
|
||||
margin: 0 1rem;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
|
@ -79,6 +79,7 @@ export class NavHeaderComponent implements OnInit, OnDestroy {
|
||||
logout() {
|
||||
this.accountService.logout();
|
||||
this.navService.hideNavBar();
|
||||
this.navService.hideSideNav();
|
||||
this.router.navigateByUrl('/login');
|
||||
}
|
||||
|
||||
@ -193,5 +194,7 @@ export class NavHeaderComponent implements OnInit, OnDestroy {
|
||||
return searchFocused;
|
||||
}
|
||||
|
||||
|
||||
hideSideNav() {
|
||||
this.navService.toggleSideNav();
|
||||
}
|
||||
}
|
||||
|
19
UI/Web/src/app/on-deck/on-deck.component.html
Normal file
19
UI/Web/src/app/on-deck/on-deck.component.html
Normal file
@ -0,0 +1,19 @@
|
||||
<app-side-nav-companion-bar [hasFilter]="true" (filterOpen)="filterOpen.emit($event)">
|
||||
<h1 title>
|
||||
On Deck
|
||||
</h1>
|
||||
</app-side-nav-companion-bar>
|
||||
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
|
||||
<app-card-detail-layout
|
||||
[isLoading]="isLoading"
|
||||
[items]="series"
|
||||
[pagination]="pagination"
|
||||
[filterSettings]="filterSettings"
|
||||
[filterOpen]="filterOpen"
|
||||
(pageChange)="onPageChange($event)"
|
||||
(applyFilter)="updateFilter($event)"
|
||||
>
|
||||
<ng-template #cardItem let-item let-position="idx">
|
||||
<app-series-card [data]="item" [libraryId]="item.libraryId" (reload)="loadPage()" (selection)="bulkSelectionService.handleCardSelection('series', position, series.length, $event)" [selected]="bulkSelectionService.isCardSelected('series', position)" [allowSelection]="true"></app-series-card>
|
||||
</ng-template>
|
||||
</app-card-detail-layout>
|
0
UI/Web/src/app/on-deck/on-deck.component.scss
Normal file
0
UI/Web/src/app/on-deck/on-deck.component.scss
Normal file
133
UI/Web/src/app/on-deck/on-deck.component.ts
Normal file
133
UI/Web/src/app/on-deck/on-deck.component.ts
Normal file
@ -0,0 +1,133 @@
|
||||
import { Component, EventEmitter, HostListener, OnInit } from '@angular/core';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import { Router, ActivatedRoute } from '@angular/router';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { BulkSelectionService } from '../cards/bulk-selection.service';
|
||||
import { FilterSettings } from '../metadata-filter/filter-settings';
|
||||
import { KEY_CODES } from '../shared/_services/utility.service';
|
||||
import { Pagination } from '../_models/pagination';
|
||||
import { Series } from '../_models/series';
|
||||
import { FilterEvent, SeriesFilter} from '../_models/series-filter';
|
||||
import { Action } from '../_services/action-factory.service';
|
||||
import { ActionService } from '../_services/action.service';
|
||||
import { SeriesService } from '../_services/series.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-on-deck',
|
||||
templateUrl: './on-deck.component.html',
|
||||
styleUrls: ['./on-deck.component.scss']
|
||||
})
|
||||
export class OnDeckComponent implements OnInit {
|
||||
|
||||
isLoading: boolean = true;
|
||||
series: Series[] = [];
|
||||
pagination!: Pagination;
|
||||
libraryId!: number;
|
||||
filter: SeriesFilter | undefined = undefined;
|
||||
filterSettings: FilterSettings = new FilterSettings();
|
||||
filterOpen: EventEmitter<boolean> = new EventEmitter();
|
||||
|
||||
constructor(private router: Router, private route: ActivatedRoute, private seriesService: SeriesService, private titleService: Title,
|
||||
private actionService: ActionService, public bulkSelectionService: BulkSelectionService) {
|
||||
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
|
||||
this.titleService.setTitle('Kavita - On Deck');
|
||||
if (this.pagination === undefined || this.pagination === null) {
|
||||
this.pagination = {currentPage: 0, itemsPerPage: 30, totalItems: 0, totalPages: 1};
|
||||
}
|
||||
this.filterSettings.readProgressDisabled = true;
|
||||
this.filterSettings.sortDisabled = true;
|
||||
this.loadPage();
|
||||
}
|
||||
|
||||
@HostListener('document:keydown.shift', ['$event'])
|
||||
handleKeypress(event: KeyboardEvent) {
|
||||
if (event.key === KEY_CODES.SHIFT) {
|
||||
this.bulkSelectionService.isShiftDown = true;
|
||||
}
|
||||
}
|
||||
|
||||
@HostListener('document:keyup.shift', ['$event'])
|
||||
handleKeyUp(event: KeyboardEvent) {
|
||||
if (event.key === KEY_CODES.SHIFT) {
|
||||
this.bulkSelectionService.isShiftDown = false;
|
||||
}
|
||||
}
|
||||
|
||||
ngOnInit() {}
|
||||
|
||||
seriesClicked(series: Series) {
|
||||
this.router.navigate(['library', this.libraryId, 'series', series.id]);
|
||||
}
|
||||
|
||||
onPageChange(pagination: Pagination) {
|
||||
window.history.replaceState(window.location.href, '', window.location.href.split('?')[0] + '?page=' + this.pagination.currentPage);
|
||||
this.loadPage();
|
||||
}
|
||||
|
||||
updateFilter(event: FilterEvent) {
|
||||
this.filter = event.filter;
|
||||
const page = this.getPage();
|
||||
if (page === undefined || page === null || !event.isFirst) {
|
||||
this.pagination.currentPage = 1;
|
||||
this.onPageChange(this.pagination);
|
||||
} else {
|
||||
this.loadPage();
|
||||
}
|
||||
}
|
||||
|
||||
loadPage() {
|
||||
const page = this.getPage();
|
||||
if (page != null) {
|
||||
this.pagination.currentPage = parseInt(page, 10);
|
||||
}
|
||||
this.isLoading = true;
|
||||
this.seriesService.getOnDeck(this.libraryId, this.pagination?.currentPage, this.pagination?.itemsPerPage, this.filter).pipe(take(1)).subscribe(series => {
|
||||
this.series = series.result;
|
||||
this.pagination = series.pagination;
|
||||
this.isLoading = false;
|
||||
window.scrollTo(0, 0);
|
||||
});
|
||||
}
|
||||
|
||||
getPage() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
return urlParams.get('page');
|
||||
}
|
||||
|
||||
bulkActionCallback = (action: Action, data: any) => {
|
||||
const selectedSeriesIndexies = this.bulkSelectionService.getSelectedCardsForSource('series');
|
||||
const selectedSeries = this.series.filter((series, index: number) => selectedSeriesIndexies.includes(index + ''));
|
||||
|
||||
switch (action) {
|
||||
case Action.AddToReadingList:
|
||||
this.actionService.addMultipleSeriesToReadingList(selectedSeries, () => {
|
||||
this.bulkSelectionService.deselectAll();
|
||||
});
|
||||
break;
|
||||
case Action.AddToCollection:
|
||||
this.actionService.addMultipleSeriesToCollectionTag(selectedSeries, () => {
|
||||
this.bulkSelectionService.deselectAll();
|
||||
});
|
||||
break;
|
||||
case Action.MarkAsRead:
|
||||
this.actionService.markMultipleSeriesAsRead(selectedSeries, () => {
|
||||
this.loadPage();
|
||||
this.bulkSelectionService.deselectAll();
|
||||
});
|
||||
break;
|
||||
case Action.MarkAsUnread:
|
||||
this.actionService.markMultipleSeriesAsUnread(selectedSeries, () => {
|
||||
this.loadPage();
|
||||
this.bulkSelectionService.deselectAll();
|
||||
});
|
||||
break;
|
||||
case Action.Delete:
|
||||
this.actionService.deleteMultipleSeries(selectedSeries, () => {
|
||||
this.loadPage();
|
||||
this.bulkSelectionService.deselectAll();
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -4,6 +4,7 @@ import { FilterPipe } from './filter.pipe';
|
||||
import { PublicationStatusPipe } from './publication-status.pipe';
|
||||
import { SentenceCasePipe } from './sentence-case.pipe';
|
||||
import { PersonRolePipe } from './person-role.pipe';
|
||||
import { SafeHtmlPipe } from './safe-html.pipe';
|
||||
|
||||
|
||||
|
||||
@ -12,7 +13,8 @@ import { PersonRolePipe } from './person-role.pipe';
|
||||
FilterPipe,
|
||||
PersonRolePipe,
|
||||
PublicationStatusPipe,
|
||||
SentenceCasePipe
|
||||
SentenceCasePipe,
|
||||
SafeHtmlPipe
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
@ -21,7 +23,8 @@ import { PersonRolePipe } from './person-role.pipe';
|
||||
FilterPipe,
|
||||
PersonRolePipe,
|
||||
PublicationStatusPipe,
|
||||
SentenceCasePipe
|
||||
SentenceCasePipe,
|
||||
SafeHtmlPipe
|
||||
]
|
||||
})
|
||||
export class PipeModule { }
|
||||
|
@ -1,16 +1,14 @@
|
||||
<app-side-nav-companion-bar [showGoBack]="true">
|
||||
<h1 title>
|
||||
<span *ngIf="actions.length > 0">
|
||||
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="readingList.title"></app-card-actionables>
|
||||
</span>
|
||||
{{readingList.title}} <span *ngIf="readingList?.promoted">(<i class="fa fa-angle-double-up" aria-hidden="true"></i>)</span>
|
||||
<span class="badge bg-primary rounded-pill" attr.aria-label="{{items.length}} total items">{{items.length}}</span>
|
||||
</h1>
|
||||
</app-side-nav-companion-bar>
|
||||
<div class="container-fluid mt-2" *ngIf="readingList">
|
||||
<div class="mb-3">
|
||||
<!-- Title row-->
|
||||
<div class="row g-0">
|
||||
|
||||
<h2 style="display: inline-block">
|
||||
<span *ngIf="actions.length > 0">
|
||||
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="readingList.title"></app-card-actionables>
|
||||
</span>
|
||||
{{readingList.title}} <span *ngIf="readingList?.promoted">(<i class="fa fa-angle-double-up" aria-hidden="true"></i>)</span>
|
||||
<span class="badge bg-primary rounded-pill" attr.aria-label="{{items.length}} total items">{{items.length}}</span>
|
||||
</h2>
|
||||
</div>
|
||||
<!-- Action row-->
|
||||
<div class="row g-0">
|
||||
<div class="col-auto me-2">
|
||||
|
@ -11,6 +11,7 @@ import { ReadingListsComponent } from './reading-lists/reading-lists.component';
|
||||
import { EditReadingListModalComponent } from './_modals/edit-reading-list-modal/edit-reading-list-modal.component';
|
||||
import { PipeModule } from '../pipe/pipe.module';
|
||||
import { SharedModule } from '../shared/shared.module';
|
||||
import { SidenavModule } from '../sidenav/sidenav.module';
|
||||
|
||||
|
||||
|
||||
@ -29,7 +30,8 @@ import { SharedModule } from '../shared/shared.module';
|
||||
DragDropModule,
|
||||
CardsModule,
|
||||
PipeModule,
|
||||
SharedModule
|
||||
SharedModule,
|
||||
SidenavModule
|
||||
],
|
||||
exports: [
|
||||
AddToListModalComponent,
|
||||
|
@ -2,6 +2,7 @@ import { NgModule } from "@angular/core";
|
||||
import { Routes, RouterModule } from "@angular/router";
|
||||
import { AuthGuard } from "../_guards/auth.guard";
|
||||
import { ReadingListDetailComponent } from "./reading-list-detail/reading-list-detail.component";
|
||||
import { ReadingListsComponent } from "./reading-lists/reading-lists.component";
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
@ -9,10 +10,11 @@ const routes: Routes = [
|
||||
runGuardsAndResolvers: 'always',
|
||||
canActivate: [AuthGuard],
|
||||
children: [
|
||||
{path: '', component: ReadingListDetailComponent, pathMatch: 'full'},
|
||||
{path: '', component: ReadingListsComponent, pathMatch: 'full'},
|
||||
{path: ':id', component: ReadingListDetailComponent, pathMatch: 'full'},
|
||||
]
|
||||
}
|
||||
},
|
||||
{path: '**', component: ReadingListsComponent, pathMatch: 'full', canActivate: [AuthGuard]},
|
||||
];
|
||||
|
||||
|
||||
|
@ -1,4 +1,11 @@
|
||||
<app-card-detail-layout header="Reading Lists"
|
||||
<app-side-nav-companion-bar [showGoBack]="true" pageHeader="Home">
|
||||
<h1 title>
|
||||
<app-card-actionables [actions]="actions"></app-card-actionables>
|
||||
Reading Lists
|
||||
</h1>
|
||||
</app-side-nav-companion-bar>
|
||||
|
||||
<app-card-detail-layout
|
||||
[isLoading]="loadingLists"
|
||||
[items]="lists"
|
||||
[actions]="actions"
|
||||
|
@ -1,9 +1,15 @@
|
||||
<app-side-nav-companion-bar [hasFilter]="true" (filterOpen)="filterOpen.emit($event)">
|
||||
<h1 title>
|
||||
Recently Added
|
||||
</h1>
|
||||
</app-side-nav-companion-bar>
|
||||
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
|
||||
<app-card-detail-layout header="Recently Added"
|
||||
<app-card-detail-layout
|
||||
[isLoading]="isLoading"
|
||||
[items]="series"
|
||||
[pagination]="pagination"
|
||||
[filterSettings]="filterSettings"
|
||||
[filterOpen]="filterOpen"
|
||||
(applyFilter)="applyFilter($event)"
|
||||
(pageChange)="onPageChange($event)"
|
||||
>
|
||||
|
@ -1,10 +1,10 @@
|
||||
import { Component, HostListener, OnDestroy, OnInit } from '@angular/core';
|
||||
import { Component, EventEmitter, HostListener, OnDestroy, OnInit } from '@angular/core';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { Subject } from 'rxjs';
|
||||
import { debounceTime, take, takeUntil, takeWhile } from 'rxjs/operators';
|
||||
import { debounceTime, take, takeUntil } from 'rxjs/operators';
|
||||
import { BulkSelectionService } from '../cards/bulk-selection.service';
|
||||
import { FilterSettings } from '../cards/card-detail-layout/card-detail-layout.component';
|
||||
import { FilterSettings } from '../metadata-filter/filter-settings';
|
||||
import { KEY_CODES } from '../shared/_services/utility.service';
|
||||
import { SeriesAddedEvent } from '../_models/events/series-added-event';
|
||||
import { Pagination } from '../_models/pagination';
|
||||
@ -33,6 +33,7 @@ export class RecentlyAddedComponent implements OnInit, OnDestroy {
|
||||
|
||||
filter: SeriesFilter | undefined = undefined;
|
||||
filterSettings: FilterSettings = new FilterSettings();
|
||||
filterOpen: EventEmitter<boolean> = new EventEmitter();
|
||||
|
||||
onDestroy: Subject<void> = new Subject();
|
||||
|
||||
|
@ -1,14 +1,20 @@
|
||||
<app-side-nav-companion-bar *ngIf="series !== undefined">
|
||||
<ng-container title>
|
||||
<h2 style="margin-bottom: 0px">
|
||||
<app-card-actionables [disabled]="actionInProgress" (actionHandler)="performAction($event)" [actions]="seriesActions" [labelBy]="series.name" iconClass="fa-ellipsis-v"></app-card-actionables>
|
||||
{{series?.name}}
|
||||
</h2>
|
||||
</ng-container>
|
||||
<ng-container subtitle *ngIf="series?.localizedName !== series?.name">
|
||||
<h6 style="margin-left:40px;" title="Localized Name">{{series?.localizedName}}</h6>
|
||||
</ng-container>
|
||||
</app-side-nav-companion-bar>
|
||||
<div class="container-fluid" *ngIf="series !== undefined" style="padding-top: 10px">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-2 col-xs-4 col-sm-6">
|
||||
<div class="col-md-2 col-xs-4 col-sm-6">
|
||||
<app-image class="poster" maxWidth="300px" [imageUrl]="seriesImage"></app-image>
|
||||
</div>
|
||||
<div class="col-md-10 col-xs-8 col-sm-6">
|
||||
<div class="row g-0">
|
||||
<h2>
|
||||
{{series?.name}}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="row g-0">
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-primary" (click)="read()">
|
||||
@ -36,11 +42,6 @@
|
||||
</ng-template>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-auto ms-2">
|
||||
<div class="card-actions">
|
||||
<app-card-actionables [disabled]="actionInProgress" (actionHandler)="performAction($event)" [actions]="seriesActions" [labelBy]="series.name" iconClass="fa-ellipsis-h" btnClass="btn-secondary"></app-card-actionables>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto ms-2">
|
||||
<ngb-rating class="rating-star" [(rate)]="series!.userRating" (rateChange)="updateRating($event)" (click)="promptToReview()"></ngb-rating>
|
||||
<button *ngIf="series?.userRating || series.userRating" class="btn btn-sm btn-icon" (click)="openReviewModal(true)" placement="top" ngbTooltip="Edit Review" attr.aria-label="Edit Review"><i class="fa fa-pen" aria-hidden="true"></i></button>
|
||||
@ -123,4 +124,4 @@
|
||||
<span class="invisible">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -32,6 +32,7 @@ import { EVENTS, MessageHubService } from '../_services/message-hub.service';
|
||||
import { ReaderService } from '../_services/reader.service';
|
||||
import { ReadingListService } from '../_services/reading-list.service';
|
||||
import { SeriesService } from '../_services/series.service';
|
||||
import { NavService } from '../_services/nav.service';
|
||||
|
||||
|
||||
enum TabID {
|
||||
@ -74,6 +75,8 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
|
||||
hasSpecials = false;
|
||||
specials: Array<Chapter> = [];
|
||||
activeTabId = TabID.Storyline;
|
||||
hasNonSpecialVolumeChapters = false;
|
||||
hasNonSpecialNonVolumeChapters = false;
|
||||
|
||||
userReview: string = '';
|
||||
libraryType: LibraryType = LibraryType.Manga;
|
||||
@ -172,7 +175,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
|
||||
private confirmService: ConfirmService, private titleService: Title,
|
||||
private downloadService: DownloadService, private actionService: ActionService,
|
||||
public imageSerivce: ImageService, private messageHub: MessageHubService,
|
||||
private readingListService: ReadingListService
|
||||
private readingListService: ReadingListService, public navService: NavService
|
||||
) {
|
||||
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
|
||||
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
|
||||
@ -378,7 +381,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
|
||||
* This assumes loadPage() has already primed all the calculations and state variables. Do not call directly.
|
||||
*/
|
||||
updateSelectedTab() {
|
||||
|
||||
|
||||
// Book libraries only have Volumes or Specials enabled
|
||||
if (this.libraryType === LibraryType.Book) {
|
||||
if (this.volumes.length === 0) {
|
||||
@ -394,7 +397,6 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
|
||||
} else {
|
||||
this.activeTabId = TabID.Storyline;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
createHTML() {
|
||||
@ -451,7 +453,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
read() {
|
||||
if (this.currentlyReadingChapter !== undefined) {
|
||||
if (this.currentlyReadingChapter !== undefined) {
|
||||
this.openChapter(this.currentlyReadingChapter);
|
||||
return;
|
||||
}
|
||||
@ -496,12 +498,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
|
||||
// If user has progress on the volume, load them where they left off
|
||||
if (volume.pagesRead < volume.pages && volume.pagesRead > 0) {
|
||||
// Find the continue point chapter and load it
|
||||
const unreadChapters = volume.chapters.filter(item => item.pagesRead < item.pages);
|
||||
if (unreadChapters.length > 0) {
|
||||
this.openChapter(unreadChapters[0]);
|
||||
return;
|
||||
}
|
||||
this.openChapter(volume.chapters[0]);
|
||||
this.readerService.getCurrentChapter(this.seriesId).subscribe(chapter => this.openChapter(chapter));
|
||||
return;
|
||||
}
|
||||
|
||||
@ -514,7 +511,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
openViewInfo(data: Volume | Chapter) {
|
||||
const modalRef = this.modalService.open(CardDetailsModalComponent, { size: 'lg' });
|
||||
const modalRef = this.modalService.open(CardDetailsModalComponent, { size: 'lg' }); // , scrollable: true (these don't work well on mobile)
|
||||
modalRef.componentInstance.data = data;
|
||||
modalRef.componentInstance.parentName = this.series?.name;
|
||||
modalRef.componentInstance.seriesId = this.series?.id;
|
||||
|
@ -207,18 +207,6 @@ export class UtilityService {
|
||||
filter.translators = [...filter.translators, ...translators.split(',').map(item => parseInt(item, 10))];
|
||||
anyChanged = true;
|
||||
}
|
||||
|
||||
/// Read status is encoded as true,true,true
|
||||
const readStatus = snapshot.queryParamMap.get('readStatus');
|
||||
if (readStatus !== undefined && readStatus !== null) {
|
||||
const values = readStatus.split(',').map(i => i === "true");
|
||||
if (values.length === 3) {
|
||||
filter.readStatus.inProgress = values[0];
|
||||
filter.readStatus.notRead = values[1];
|
||||
filter.readStatus.read = values[2];
|
||||
anyChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return [filter, anyChanged];
|
||||
@ -254,6 +242,16 @@ export class UtilityService {
|
||||
}
|
||||
}
|
||||
|
||||
getLibraryTypeIcon(format: LibraryType) {
|
||||
switch (format) {
|
||||
case LibraryType.Book:
|
||||
return 'fa-book';
|
||||
case LibraryType.Comic:
|
||||
case LibraryType.Manga:
|
||||
return 'fa-book-open';
|
||||
}
|
||||
}
|
||||
|
||||
isVolume(d: any) {
|
||||
return d != null && d.hasOwnProperty('chapters');
|
||||
}
|
||||
|
@ -3,7 +3,6 @@ import { CommonModule } from '@angular/common';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
import { NgbCollapseModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ConfirmDialogComponent } from './confirm-dialog/confirm-dialog.component';
|
||||
import { SafeHtmlPipe } from './safe-html.pipe';
|
||||
import { ReadMoreComponent } from './read-more/read-more.component';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { DrawerComponent } from './drawer/drawer.component';
|
||||
@ -17,11 +16,11 @@ import { NgCircleProgressModule } from 'ng-circle-progress';
|
||||
import { PersonBadgeComponent } from './person-badge/person-badge.component';
|
||||
import { BadgeExpanderComponent } from './badge-expander/badge-expander.component';
|
||||
import { ImageComponent } from './image/image.component';
|
||||
import { PipeModule } from '../pipe/pipe.module';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
ConfirmDialogComponent,
|
||||
SafeHtmlPipe,
|
||||
ReadMoreComponent,
|
||||
DrawerComponent,
|
||||
TagBadgeComponent,
|
||||
@ -32,7 +31,7 @@ import { ImageComponent } from './image/image.component';
|
||||
CircularLoaderComponent,
|
||||
PersonBadgeComponent,
|
||||
BadgeExpanderComponent,
|
||||
ImageComponent
|
||||
ImageComponent,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
@ -40,10 +39,10 @@ import { ImageComponent } from './image/image.component';
|
||||
ReactiveFormsModule,
|
||||
NgbCollapseModule,
|
||||
NgbTooltipModule, // RegisterMemberComponent
|
||||
PipeModule,
|
||||
NgCircleProgressModule.forRoot(),
|
||||
],
|
||||
exports: [
|
||||
SafeHtmlPipe, // Used globally
|
||||
ReadMoreComponent, // Used globably
|
||||
DrawerComponent, // Can be replaced with boostrap offscreen canvas (v5)
|
||||
ShowIfScrollbarDirective, // Used book reader only?
|
||||
|
@ -0,0 +1,21 @@
|
||||
<div class="mt-0">
|
||||
<div class="row g-0">
|
||||
<div class="col mr-auto">
|
||||
<ng-content select="[title]"></ng-content>
|
||||
<ng-content select="[subtitle]"></ng-content>
|
||||
</div>
|
||||
<div class="col mr-auto">
|
||||
<ng-content select="[main]"></ng-content>
|
||||
</div>
|
||||
<div class="col" *ngIf="hasFilter">
|
||||
<div class="row justify-content-end">
|
||||
<div class="col-auto align-self-end">
|
||||
<button *ngIf="hasFilter" class="btn btn-secondary btn-small" (click)="toggleFilter()" [attr.aria-expanded]="filterOpen" placement="left" ngbTooltip="{{filterOpen ? 'Open' : 'Close'}} Filtering and Sorting" attr.aria-label="{{filterOpen ? 'Open' : 'Close'}} Filtering and Sorting">
|
||||
<i class="fa fa-filter" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">Sort / Filter</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -0,0 +1,47 @@
|
||||
import { Component, ContentChild, EventEmitter, Input, OnInit, Output, TemplateRef } from '@angular/core';
|
||||
|
||||
/**
|
||||
* This should go on all pages which have the side nav present and is not Settings related.
|
||||
* Content inside [main] selector should not have any padding top or bottom, they are included in this component.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-side-nav-companion-bar',
|
||||
templateUrl: './side-nav-companion-bar.component.html',
|
||||
styleUrls: ['./side-nav-companion-bar.component.scss']
|
||||
})
|
||||
export class SideNavCompanionBarComponent implements OnInit {
|
||||
|
||||
/**
|
||||
* Show a dedicated button to go back one history event.
|
||||
*/
|
||||
@Input() showGoBack: boolean = false;
|
||||
/**
|
||||
* If the page should show a filter
|
||||
*/
|
||||
@Input() hasFilter: boolean = false;
|
||||
|
||||
/**
|
||||
* Should be passed through from Filter component.
|
||||
*/
|
||||
//@Input() filterDisabled: EventEmitter<boolean> = new EventEmitter();
|
||||
|
||||
@Output() filterOpen: EventEmitter<boolean> = new EventEmitter();
|
||||
|
||||
isFilterOpen = false;
|
||||
|
||||
constructor() { }
|
||||
|
||||
ngOnInit(): void {
|
||||
}
|
||||
|
||||
goBack() {
|
||||
|
||||
}
|
||||
|
||||
toggleFilter() {
|
||||
//collapse.toggle()
|
||||
this.isFilterOpen = !this.isFilterOpen;
|
||||
this.filterOpen.emit(this.isFilterOpen);
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
<ng-container *ngIf="link === undefined || link.length === 0; else useLink">
|
||||
<div class="side-nav-item" [ngClass]="{'closed': !(navService?.sideNavCollapsed$ | async), 'active': highlighted}">
|
||||
<ng-container [ngTemplateOutlet]="inner"></ng-container>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-template #useLink>
|
||||
<a class="side-nav-item" href="javascript:void(0);" [ngClass]="{'closed': !(navService?.sideNavCollapsed$ | async), 'active': highlighted}" [routerLink]="link">
|
||||
<ng-container [ngTemplateOutlet]="inner"></ng-container>
|
||||
</a>
|
||||
</ng-template>
|
||||
|
||||
|
||||
<ng-template #inner>
|
||||
<div class="active-highlight"></div>
|
||||
<span class="phone-hidden" title="{{title}}">
|
||||
<div>
|
||||
<i class="fa {{icon}}" aria-hidden="true"></i>
|
||||
</div>
|
||||
</span>
|
||||
<span class="side-nav-text">
|
||||
<div>
|
||||
{{title}}
|
||||
</div>
|
||||
</span>
|
||||
<span class="card-actions">
|
||||
<ng-content select="[actions]"></ng-content>
|
||||
</span>
|
||||
</ng-template>
|
@ -0,0 +1,206 @@
|
||||
.side-nav-item {
|
||||
position: relative;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0 10px;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-height: 40px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
|
||||
.side-nav-text {
|
||||
padding-left: 10px;
|
||||
opacity: 1;
|
||||
min-width: 100px;
|
||||
|
||||
div {
|
||||
min-width: 102px;
|
||||
width: 100%
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
span {
|
||||
&:last-child {
|
||||
flex-grow: 1;
|
||||
justify-content: end;
|
||||
}
|
||||
|
||||
div {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
justify-content: inherit;
|
||||
min-width: 30px;
|
||||
width: 100%;
|
||||
padding-left: 5px;
|
||||
|
||||
i {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.closed {
|
||||
.side-nav-text {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--side-nav-item-active-color);
|
||||
background-color: unset;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--side-nav-hover-color);
|
||||
background-color: var(--side-nav-hover-bg-color);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--side-nav-item-active-color);
|
||||
background-color: var(--side-nav-active-bg-color);
|
||||
|
||||
.active-highlight {
|
||||
background-color: var(--side-nav-item-active-color);
|
||||
width: 5px;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.side-nav-text, i {
|
||||
color: var(--side-nav-item-active-text-color);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--side-nav-hover-bg-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: var(--side-nav-color);
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.side-nav-item {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 15px 10px;
|
||||
width: 100%;
|
||||
height: 70px;
|
||||
min-height: 40px;
|
||||
overflow: hidden;
|
||||
font-size: 1rem;
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
.side-nav-text {
|
||||
padding-left: 10px;
|
||||
opacity: 1;
|
||||
min-width: 100px;
|
||||
width: 100%;
|
||||
|
||||
div {
|
||||
min-width: 102px;
|
||||
width: 100%
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
&.closed {
|
||||
.side-nav-text {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
opacity: 0;
|
||||
font-size: inherit
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
&:last-child {
|
||||
flex-grow: 1;
|
||||
justify-content: end;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// @media (max-width: 576px) {
|
||||
// .side-nav-item {
|
||||
// align-items: center;
|
||||
// display: flex;
|
||||
// justify-content: space-between;
|
||||
// padding: 15px 10px;
|
||||
// width: 100%;
|
||||
// height: 70px;
|
||||
// min-height: 40px;
|
||||
// overflow: hidden;
|
||||
// font-size: 1rem;
|
||||
|
||||
// cursor: pointer;
|
||||
|
||||
// .side-nav-text {
|
||||
// padding-left: 10px;
|
||||
// opacity: 1;
|
||||
// min-width: 100px;
|
||||
// width: 100%;
|
||||
|
||||
// div {
|
||||
// min-width: 102px;
|
||||
// width: 100%
|
||||
// }
|
||||
// }
|
||||
|
||||
// .card-actions {
|
||||
// opacity: 1;
|
||||
// }
|
||||
|
||||
// div {
|
||||
// align-items: center;
|
||||
// display: flex;
|
||||
// height: 100%;
|
||||
// justify-content: inherit;
|
||||
// min-width: 30px;
|
||||
// width: 100%;
|
||||
// padding-left: 5px;
|
||||
|
||||
// i {
|
||||
// font-size: 2rem;
|
||||
// width: 50px;
|
||||
// }
|
||||
// }
|
||||
|
||||
// &.closed {
|
||||
// .side-nav-text {
|
||||
// opacity: 0;
|
||||
// }
|
||||
|
||||
// .card-actions {
|
||||
// opacity: 0;
|
||||
// font-size: inherit
|
||||
// }
|
||||
// }
|
||||
|
||||
// span {
|
||||
// &:last-child {
|
||||
// flex-grow: 1;
|
||||
// justify-content: end;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
@ -0,0 +1,72 @@
|
||||
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { filter, map, Subject, takeUntil } from 'rxjs';
|
||||
import { NavService } from '../../_services/nav.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-side-nav-item',
|
||||
templateUrl: './side-nav-item.component.html',
|
||||
styleUrls: ['./side-nav-item.component.scss']
|
||||
})
|
||||
export class SideNavItemComponent implements OnInit, OnDestroy {
|
||||
/**
|
||||
* Icon to display next to item. ie) 'fa-home'
|
||||
*/
|
||||
@Input() icon: string = '';
|
||||
/**
|
||||
* Text for the item
|
||||
*/
|
||||
@Input() title: string = '';
|
||||
|
||||
/**
|
||||
* If a link should be generated when clicked. By default (undefined), no link will be generated
|
||||
*/
|
||||
@Input() link: string | undefined;
|
||||
|
||||
@Input() comparisonMethod: 'startsWith' | 'equals' = 'equals';
|
||||
|
||||
|
||||
highlighted = false;
|
||||
private onDestroy: Subject<void> = new Subject();
|
||||
|
||||
constructor(public navService: NavService, private router: Router) {
|
||||
router.events
|
||||
.pipe(filter(event => event instanceof NavigationEnd),
|
||||
takeUntil(this.onDestroy),
|
||||
map(evt => evt as NavigationEnd))
|
||||
.subscribe((evt: NavigationEnd) => {
|
||||
this.updateHightlight(evt.url.split('?')[0]);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
setTimeout(() => {
|
||||
this.updateHightlight(this.router.url.split('?')[0]);
|
||||
}, 100);
|
||||
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.onDestroy.next();
|
||||
this.onDestroy.complete();
|
||||
}
|
||||
|
||||
updateHightlight(page: string) {
|
||||
if (this.link === undefined) {
|
||||
this.highlighted = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.comparisonMethod === 'equals' && page === this.link) {
|
||||
this.highlighted = true;
|
||||
return;
|
||||
}
|
||||
if (this.comparisonMethod === 'startsWith' && page.startsWith(this.link)) {
|
||||
this.highlighted = true;
|
||||
return;
|
||||
}
|
||||
|
||||
this.highlighted = false;
|
||||
}
|
||||
|
||||
}
|
31
UI/Web/src/app/sidenav/side-nav/side-nav.component.html
Normal file
31
UI/Web/src/app/sidenav/side-nav/side-nav.component.html
Normal file
@ -0,0 +1,31 @@
|
||||
<ng-container>
|
||||
<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/">
|
||||
<ng-container actions>
|
||||
<!-- Todo: This will be customize dashboard/side nav controls-->
|
||||
<a href="/preferences/" title="User Settings"><span class="visually-hidden">User Settings</span></a>
|
||||
</ng-container>
|
||||
</app-side-nav-item>
|
||||
|
||||
<div class="mt-3">
|
||||
<app-side-nav-item icon="fa-home" title="Home" link="/library"></app-side-nav-item>
|
||||
<app-side-nav-item icon="fa-list" title="Collections" link="/collections"></app-side-nav-item>
|
||||
<app-side-nav-item icon="fa-list-ol" title="Reading Lists" link="/lists"></app-side-nav-item>
|
||||
<app-side-nav-item icon="fa-regular fa-rectangle-list" title="All Series" link="/all-series"></app-side-nav-item>
|
||||
<div class="mb-2 mt-3 ms-2 me-2" *ngIf="libraries.length > 10">
|
||||
<label for="filter" class="form-label visually-hidden">Filter</label>
|
||||
<div class="input-group">
|
||||
<input id="filter" autocomplete="off" class="form-control" [(ngModel)]="filterQuery" type="text" aria-describedby="reset-input">
|
||||
<button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="filterQuery = '';">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
<app-side-nav-item *ngFor="let library of libraries | filter: filterLibrary" [link]="'/library/' + library.id"
|
||||
[icon]="utilityService.getLibraryTypeIcon(library.type)" [title]="library.name" [comparisonMethod]="'startsWith'">
|
||||
<ng-container actions>
|
||||
<app-card-actionables [actions]="actions" [labelBy]="library.name" iconClass="fa-ellipsis-v" (actionHandler)="performAction($event, library)"></app-card-actionables>
|
||||
</ng-container>
|
||||
</app-side-nav-item>
|
||||
</div>
|
||||
</div>
|
||||
<div class="side-nav-overlay" [ngClass]="{'closed' : !(navService?.sideNavCollapsed$ | async)}"></div>
|
||||
</ng-container>
|
63
UI/Web/src/app/sidenav/side-nav/side-nav.component.scss
Normal file
63
UI/Web/src/app/sidenav/side-nav/side-nav.component.scss
Normal file
@ -0,0 +1,63 @@
|
||||
.side-nav {
|
||||
padding: 10px 0;
|
||||
width: 190px;
|
||||
background-color: var(--side-nav-bg-color);
|
||||
height: calc(100vh - 85px);
|
||||
position: fixed;
|
||||
margin: 0;
|
||||
left: 10px;
|
||||
top: 73px;
|
||||
border-radius: var(--side-nav-border-radius);
|
||||
transition: width var(--side-nav-openclose-transition), background-color var(--side-nav-bg-color-transition), border-color var(--side-nav-border-transition);
|
||||
overflow: auto;
|
||||
border: var(--side-nav-border);
|
||||
|
||||
&.hidden {
|
||||
display: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&.closed {
|
||||
width: 50px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
background-color: var(--side-nav-closed-bg-color);
|
||||
border: var(--side-nav-border-closed);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.side-nav {
|
||||
padding: 10px 0;
|
||||
width: 90vw;
|
||||
background-color: var(--side-nav-mobile-bg-color);
|
||||
height: calc(100vh - 56px);
|
||||
position: fixed;
|
||||
margin: 0;
|
||||
left: 0;
|
||||
top: 56px;
|
||||
transition: width var(--side-nav-openclose-transition);
|
||||
z-index: 999;
|
||||
overflow: auto;
|
||||
border: var(--side-nav-mobile-border);
|
||||
|
||||
&.closed {
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.side-nav-overlay {
|
||||
background-color: var(--side-nav-overlay-color);
|
||||
width: 100vw;
|
||||
height: calc(100vh - 56px);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
z-index: 998;
|
||||
|
||||
&.closed {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
70
UI/Web/src/app/sidenav/side-nav/side-nav.component.ts
Normal file
70
UI/Web/src/app/sidenav/side-nav/side-nav.component.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { UtilityService } from '../../shared/_services/utility.service';
|
||||
import { Library } from '../../_models/library';
|
||||
import { User } from '../../_models/user';
|
||||
import { AccountService } from '../../_services/account.service';
|
||||
import { Action, ActionFactoryService, ActionItem } from '../../_services/action-factory.service';
|
||||
import { ActionService } from '../../_services/action.service';
|
||||
import { LibraryService } from '../../_services/library.service';
|
||||
import { NavService } from '../../_services/nav.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-side-nav',
|
||||
templateUrl: './side-nav.component.html',
|
||||
styleUrls: ['./side-nav.component.scss']
|
||||
})
|
||||
export class SideNavComponent implements OnInit {
|
||||
|
||||
user: User | undefined;
|
||||
libraries: Library[] = [];
|
||||
isAdmin = false;
|
||||
actions: ActionItem<Library>[] = [];
|
||||
|
||||
filterQuery: string = '';
|
||||
filterLibrary = (library: Library) => {
|
||||
return library.name.toLowerCase().indexOf((this.filterQuery || '').toLowerCase()) >= 0;
|
||||
}
|
||||
|
||||
|
||||
constructor(public accountService: AccountService, private libraryService: LibraryService,
|
||||
public utilityService: UtilityService, private router: Router,
|
||||
private actionFactoryService: ActionFactoryService, private actionService: ActionService, public navService: NavService) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
|
||||
this.user = user;
|
||||
if (this.user) {
|
||||
this.isAdmin = this.accountService.hasAdminRole(this.user);
|
||||
}
|
||||
this.libraryService.getLibrariesForMember().pipe(take(1)).subscribe((libraries: Library[]) => {
|
||||
this.libraries = libraries;
|
||||
});
|
||||
this.actions = this.actionFactoryService.getLibraryActions(this.handleAction.bind(this));
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
|
||||
handleAction(action: Action, library: Library) {
|
||||
switch (action) {
|
||||
case(Action.ScanLibrary):
|
||||
this.actionService.scanLibrary(library);
|
||||
break;
|
||||
case(Action.RefreshMetadata):
|
||||
this.actionService.refreshMetadata(library);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
performAction(action: ActionItem<Library>, library: Library) {
|
||||
if (typeof action.callback === 'function') {
|
||||
action.callback(action.action, library);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
34
UI/Web/src/app/sidenav/sidenav.module.ts
Normal file
34
UI/Web/src/app/sidenav/sidenav.module.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { SideNavCompanionBarComponent } from './side-nav-companion-bar/side-nav-companion-bar.component';
|
||||
import { SideNavItemComponent } from './side-nav-item/side-nav-item.component';
|
||||
import { SideNavComponent } from './side-nav/side-nav.component';
|
||||
import { PipeModule } from '../pipe/pipe.module';
|
||||
import { CardsModule } from '../cards/cards.module';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { RouterModule } from '@angular/router';
|
||||
|
||||
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
SideNavCompanionBarComponent,
|
||||
SideNavItemComponent,
|
||||
SideNavComponent
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
PipeModule,
|
||||
CardsModule,
|
||||
FormsModule,
|
||||
NgbTooltipModule,
|
||||
],
|
||||
exports: [
|
||||
SideNavCompanionBarComponent,
|
||||
SideNavItemComponent,
|
||||
SideNavComponent
|
||||
]
|
||||
})
|
||||
export class SidenavModule { }
|
@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common';
|
||||
import { TypeaheadComponent } from './typeahead.component';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { SharedModule } from '../shared/shared.module';
|
||||
import { PipeModule } from '../pipe/pipe.module';
|
||||
|
||||
|
||||
|
||||
@ -14,7 +15,8 @@ import { SharedModule } from '../shared/shared.module';
|
||||
CommonModule,
|
||||
SharedModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule
|
||||
ReactiveFormsModule,
|
||||
PipeModule
|
||||
],
|
||||
exports: [
|
||||
TypeaheadComponent
|
||||
|
@ -41,6 +41,7 @@ export class UserLoginComponent implements OnInit {
|
||||
|
||||
ngOnInit(): void {
|
||||
this.navService.showNavBar();
|
||||
this.navService.hideSideNav();
|
||||
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
|
||||
if (user) {
|
||||
this.router.navigateByUrl('/library');
|
||||
@ -83,6 +84,7 @@ export class UserLoginComponent implements OnInit {
|
||||
this.accountService.login(this.model).subscribe(() => {
|
||||
this.loginForm.reset();
|
||||
this.navService.showNavBar();
|
||||
this.navService.showSideNav();
|
||||
|
||||
// Check if user came here from another url, else send to library route
|
||||
const pageResume = localStorage.getItem('kavita--auth-intersection-url');
|
||||
|
@ -4,7 +4,7 @@
|
||||
<div class="input-group">
|
||||
<input #apiKey type="text" readonly class="form-control" id="api-key--{{title}}" aria-describedby="button-addon4" [value]="key" (click)="selectAll()">
|
||||
<div id="button-addon4">
|
||||
<button class="btn btn-outline-secondary" type="button" (click)="copy()" title="Copy"><span class="visually-hidden">Copy</span><i class="fa fa-copy" aria-hidden="true"></i></button>
|
||||
<button class="btn btn-outline-secondary" type="button" (click)="copy()"><span class="visually-hidden">Copy</span><i class="fa fa-copy" aria-hidden="true"></i></button>
|
||||
<button class="btn btn-danger" type="button" [ngbTooltip]="tipContent" (click)="refresh()" *ngIf="showRefresh"><span class="visually-hidden">Regenerate</span><i class="fa fa-sync-alt" aria-hidden="true"></i></button>
|
||||
</div>
|
||||
<ng-template #tipContent>
|
||||
|
@ -1,52 +1,24 @@
|
||||
<div class="container">
|
||||
<h2>User Dashboard</h2>
|
||||
<app-side-nav-companion-bar>
|
||||
<h1 title>
|
||||
User Dashboard
|
||||
</h1>
|
||||
</app-side-nav-companion-bar>
|
||||
<div class="container-fluid">
|
||||
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav nav-tabs">
|
||||
<li *ngFor="let tab of tabs" [ngbNavItem]="tab">
|
||||
<a ngbNavLink routerLink="." [fragment]="tab.fragment">{{ tab.title | sentenceCase }}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<ng-container *ngIf="tab.fragment === ''">
|
||||
<p>
|
||||
These are global settings that are bound to your account.
|
||||
These are global settings that are bound to your account.
|
||||
</p>
|
||||
|
||||
|
||||
<ngb-accordion [closeOthers]="true" activeIds="reading-panel" #acc="ngbAccordion">
|
||||
<!-- <ngb-panel id="site-panel" title="Site">
|
||||
<ng-template ngbPanelHeader>
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button" ngbPanelToggle type="button" [attr.aria-expanded]="acc.isExpanded('site-panel')" aria-controls="collapseOne">
|
||||
Site
|
||||
</button>
|
||||
</h2>
|
||||
</ng-template>
|
||||
<ng-template ngbPanelContent>
|
||||
<form [formGroup]="settingsForm" *ngIf="user !== undefined">
|
||||
<div class="mb-3">
|
||||
<label id="site-dark-mode-label" aria-describedby="site-heading" class="form-label">Dark Mode</label>
|
||||
<div class="mb-3">
|
||||
<div class="custom-control custom-radio custom-control-inline">
|
||||
<input type="radio" id="site-dark-mode" formControlName="siteDarkMode" class=" form-check-input" [value]="true" aria-labelledby="site-dark-mode-label">
|
||||
<label class="form-check-label" for="site-dark-mode">True</label>
|
||||
</div>
|
||||
<div class="custom-control custom-radio custom-control-inline">
|
||||
<input type="radio" id="site-not-dark-mode" formControlName="siteDarkMode" class="form-check-input" [value]="false" aria-labelledby="site-dark-mode-label">
|
||||
<label class="form-check-label" for="site-not-dark-mode">False</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="">
|
||||
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
|
||||
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetForm()" aria-describedby="site-dark-mode-label">Reset</button>
|
||||
<button type="submit" class="flex-fill btn btn-primary" (click)="save()" aria-describedby="site-dark-mode-label" [disabled]="!settingsForm.touched && !settingsForm.dirty">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</ng-template>
|
||||
</ngb-panel> -->
|
||||
<ngb-panel id="reading-panel" title="Reading">
|
||||
<ng-template ngbPanelHeader>
|
||||
<!-- <div class="d-flex align-items-center justify-content-between">
|
||||
<button ngbPanelToggle class="btn container-fluid text-start ps-0 accordion-header">Reading</button>
|
||||
<span class="pull-right"><i class="fa fa-angle-{{acc.isExpanded('reading-panel') ? 'down' : 'up'}}" aria-hidden="true"></i></span>
|
||||
<span class="pull-right"><i class="fa fa-angle-{{acc.isExpanded('reading-panel') ? 'down' : 'up'}}" aria-hidden="true"></i></span>
|
||||
</div> -->
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button" ngbPanelToggle type="button" [attr.aria-expanded]="acc.isExpanded('reading-panel')" aria-controls="collapseOne">
|
||||
@ -57,7 +29,7 @@
|
||||
<ng-template ngbPanelContent>
|
||||
<form [formGroup]="settingsForm" *ngIf="user !== undefined">
|
||||
<h3 id="manga-header">Image Reader</h3>
|
||||
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="col-md-6 col-sm-12 pe-2 mb-2">
|
||||
<label for="settings-reading-direction" class="form-label">Reading Direction</label> <i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="readingDirectionTooltip" role="button" tabindex="0"></i>
|
||||
@ -67,7 +39,7 @@
|
||||
<option *ngFor="let opt of readingDirections" [value]="opt.value">{{opt.text | titlecase}}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="col-md-6 col-sm-12 pe-2 mb-2">
|
||||
<label for="settings-scaling-option" class="form-label">Scaling Options</label> <i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="taskBackupTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #taskBackupTooltip>How to scale the image to your screen.</ng-template>
|
||||
@ -77,7 +49,7 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="col-md-6 col-sm-12 pe-2 mb-2">
|
||||
<label for="settings-pagesplit-option" class="form-label">Page Splitting</label> <i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="pageSplitOptionTooltip" role="button" tabindex="0"></i>
|
||||
@ -114,8 +86,8 @@
|
||||
[(colorPicker)]="user.preferences.backgroundColor"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="col-md-6 col-sm-12 pe-2 mb-2">
|
||||
<div class="mb-3">
|
||||
@ -152,7 +124,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="col-md-6 col-sm-12 pe-2 mb-3">
|
||||
<label id="taptopaginate-label" class="form-label"></label>
|
||||
<div class="mb-3">
|
||||
@ -175,8 +147,8 @@
|
||||
<option *ngFor="let opt of readingDirections" [value]="opt.value">{{opt.text | titlecase}}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="col-md-6 col-sm-12 pe-2 mb-3">
|
||||
<label for="settings-fontfamily-option" class="form-label">Font Family</label> <i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="fontFamilyOptionTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #fontFamilyOptionTooltip>Font familty to load up. Default will load the book's default font</ng-template>
|
||||
@ -187,7 +159,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="col-md-4 col-sm-12 pe-2 mb-3">
|
||||
@ -200,7 +172,7 @@
|
||||
<span class="visually-hidden" id="settings-booklineheight-option-help">How much spacing between the lines of the book</span>
|
||||
<div class="custom-slider"><ngx-slider [options]="bookReaderLineSpacingOptions" formControlName="bookReaderLineSpacing" aria-describedby="settings-booklineheight-option-help"></ngx-slider></div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="col-md-4 col-sm-12 pe-2 mb-3">
|
||||
<label class="form-label">Margin</label> <i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="bookReaderMarginOptionTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #bookReaderMarginOptionTooltip>How much spacing on each side of the screen. This will override to 0 on mobile devices regardless of this setting.</ng-template>
|
||||
@ -209,8 +181,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
|
||||
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetForm()" aria-describedby="reading-panel">Reset</button>
|
||||
<button type="submit" class="flex-fill btn btn-primary" (click)="save()" aria-describedby="reading-panel" [disabled]="!settingsForm.touched && !settingsForm.dirty">Save</button>
|
||||
@ -273,6 +245,5 @@
|
||||
</ng-template>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div [ngbNavOutlet]="nav" class="mt-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -7,10 +7,10 @@ import { ReactiveFormsModule } from '@angular/forms';
|
||||
import { NgxSliderModule } from '@angular-slider/ngx-slider';
|
||||
import { UserSettingsRoutingModule } from './user-settings-routing.module';
|
||||
import { ApiKeyComponent } from './api-key/api-key.component';
|
||||
import { SharedModule } from '../shared/shared.module';
|
||||
import { ThemeManagerComponent } from './theme-manager/theme-manager.component';
|
||||
import { SiteThemeProviderPipe } from './_pipes/site-theme-provider.pipe';
|
||||
import { PipeModule } from '../pipe/pipe.module';
|
||||
import { SiteThemeProviderPipe } from './_pipes/site-theme-provider.pipe';
|
||||
import { ThemeManagerComponent } from './theme-manager/theme-manager.component';
|
||||
import { SidenavModule } from '../sidenav/sidenav.module';
|
||||
import { ColorPickerModule } from 'ngx-color-picker';
|
||||
|
||||
|
||||
@ -32,11 +32,11 @@ import { ColorPickerModule } from 'ngx-color-picker';
|
||||
NgxSliderModule,
|
||||
UserSettingsRoutingModule,
|
||||
PipeModule,
|
||||
SidenavModule,
|
||||
ColorPickerModule, // User prefernces background color
|
||||
],
|
||||
],
|
||||
exports: [
|
||||
SiteThemeProviderPipe,
|
||||
ApiKeyComponent
|
||||
SiteThemeProviderPipe
|
||||
]
|
||||
})
|
||||
export class UserSettingsModule { }
|
||||
|
@ -35,6 +35,9 @@
|
||||
@import './theme/components/radios';
|
||||
@import './theme/components/selects';
|
||||
@import './theme/components/progress';
|
||||
@import './theme/components/sidenav';
|
||||
@import './theme/components/carousel';
|
||||
@import './theme/components/progress';
|
||||
|
||||
|
||||
@import './theme/utilities/utilities';
|
||||
@ -63,7 +66,7 @@ label, select, .clickable {
|
||||
|
||||
html, body { height: 100%; color-scheme: var(--color-scheme); }
|
||||
body {
|
||||
margin: 0;
|
||||
margin: 0;
|
||||
font-family: var(--body-font-family);
|
||||
color: var(--body-text-color);
|
||||
}
|
||||
|
@ -2,14 +2,14 @@
|
||||
a:not(.dark-exempt) {
|
||||
color: var(--primary-color);
|
||||
> i {
|
||||
color: var(--primary-color) !important;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
a.btn {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
@ -18,7 +18,6 @@ a:not(.dark-exempt) {
|
||||
a.read-more-link {
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
color: var(--body-text-color);
|
||||
cursor: pointer;
|
||||
color: var(--body-text-color) !important;
|
||||
|
||||
|
3
UI/Web/src/theme/components/_carousel.scss
Normal file
3
UI/Web/src/theme/components/_carousel.scss
Normal file
@ -0,0 +1,3 @@
|
||||
.swiper-slide {
|
||||
margin: 0 5px;
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user