Custom Theme Support (#1077)

* Started the migration to bootstrap 5. Introduced a breakpoint system that bootstrap reflects for our screens.

* sr only migrated

* mr/ml -> me/ms

* pl/pr -> ps/pe

* btn-block

* removed input-group-append

* Added form-label to all labels

* Added some style overrides for inputs

* Replaced form-group with mb-3

* Ignore journal files

* Update media to d-flex/flex-grow-1

* Fixed reading list detail page

* For develop builds, don't inline critical styles

* Fixed some downstream security issues

* Fixed a layout issue in series detail

* Fixed issue with btn-light not having background color. Updated layout for series detail metadata

* Cleaned up nav search

* Laid out the organization for custom theme components. Update _inputs.scss with variable overrides and depending on theme, it will just work.

* Lots of theming work

* Added inputs to the theme page

* Login and input placeholder changes

- Fixed login screen centering issue on all devices
- Changed the format of the login screen
- Change the input placeholder color

* Added checkbox styles

* Refactored tagbadges and removed some ngdeep selectors

* Added nav bar component and refactored some styles into event widget

* Cleaned nav events again and made dedicated popover body

* Finished pagination component

* Fixed up some styles with buttons

* refactored dropdown component

* Update accordion component

* Refactored breadcrumbs and rating star. Fixed a missing style for cards

* Fixed some styling issues on person badge, added modal component, and some global styles

* Finished moving everything within dark to component files

* Fixed up filter buttons, move card styles into a component theme, fixed slider style

* Refactored library card and grouped typeahead

* Updated normal typeahead component and reduced amount of ngdeep selector

* Refactored grid breakpoints to be available by css variable, but it's hardcoded into the app

* Ensure breakpoints are defined per theme

* Fixed up some styling overrides and customization for nav links and alt button

* Removed some deep styles, moved css out of splash container and brough back labels for login page

* Finished css variable refactor

* Refactored all the theme variable definitions into files for each theme.

* Added back bootstrap overrides

* Added a note about bootstrap theme colors being not-possible to swap out at runtime

* Cleaned up some dead code

* Implemented the ability to set a custom theme on the site. Cleaned up misc code throughout.

* Additional changes

- Fixed nav where "kavita" was not hiding correctly on small viewports
- Fixed search bar to make the behavior more consistent
- Fixed accordion buttons
- Changed accordion buttons to be more responsive
- Added radio button colors
- Fixed radios on theme test page
- Changed login and reset password card layouts to be more consistent.
- Added primary color shade for when darker shading is needed.

* Built a basic site, allow the user to apply different themes, refactored nav service code out.

* Implemented the ability update a user's theme

* Added unit tests for Scan and Get Content in SiteThemeService.

* Fixed a bug in the login code and Pref code which wasn't joining on SiteTheme table. Wrote Unit tests and the UI component to manage current theme.

* Implemented scan so that it manages custom themes with unit tests

* Component updates

- Repositioning style ordering
- Adding indicator override
- Adding select styles

* SignlaR integration, some fixes when creating custom entities, one single migration. Just login functionality left.

* More ui updated

- Added .no-hover to prevent hover on elements where not needed
- Changed all selects I could find to appropriate class
- Changed up nav tabs to work more like bootstrap tabs than pills
- Added padding to top of some containers to make styles consistent
- Added ability to change navbar fontawesome icon colors
- removed some unecessary inline styling
- Changed radio button to appropriate class
- Toned down primate color, a bit too bright for dark theme.
- Added ability to change button fontawesome icon color

* nav-tab fix for series-detail

* Added themes folder to gitignore

* Adding card overlay

* Fixing up light theme

* Everything is done. Only bug is that color-scheme isn't being set properly from css variable.

* Checkboxes have pointer by default. Confirm/Confirm email use default (dark) theme by default

* Fixed an error where color-scheme wasn't reflecting correctly on themes on first load

* Fixed user preferences not available on login

* Changing dual radios to switches and color tweaks

* disabled primary APCA fix

* button APCA fixes

* Fixed some timing issues with first load and image service

* Fixed swiper issues from upgrade

* Changed themes to be scss files again and adjusted Seed code

* Migrated carousel to css variables. Fixed a broken animation for search.

* Cleaned up some backend smells

* Fixed white border outline on nav tabs, added some variables for header

* Nav bar has been css variable-ified

* Added some basic eink stuff to make the app useable

Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
This commit is contained in:
Joseph Milazzo 2022-02-16 07:12:38 -08:00 committed by GitHub
parent c776ca3b72
commit 568ea9fd3a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
168 changed files with 4710 additions and 1666 deletions

2
.gitignore vendored
View File

@ -510,11 +510,13 @@ UI/Web/dist/
/API/config/backups/
/API/config/cache/
/API/config/temp/
/API/config/themes/
/API/config/stats/
/API/config/bookmarks/
/API/config/kavita.db
/API/config/kavita.db-shm
/API/config/kavita.db-wal
/API/config/kavita.db-journal
/API/config/Hangfire.db
/API/config/Hangfire-log.db
API/config/covers/

View File

@ -0,0 +1,264 @@
using System.Collections.Generic;
using System.Data.Common;
using System.IO.Abstractions.TestingHelpers;
using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Enums.Theme;
using API.Helpers;
using API.Services;
using API.Services.Tasks;
using API.SignalR;
using AutoMapper;
using Kavita.Common;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
namespace API.Tests.Services;
public class SiteThemeServiceTests
{
private readonly ILogger<SiteThemeService> _logger = Substitute.For<ILogger<SiteThemeService>>();
private readonly IHubContext<MessageHub> _messageHub = Substitute.For<IHubContext<MessageHub>>();
private readonly DbConnection _connection;
private readonly DataContext _context;
private readonly IUnitOfWork _unitOfWork;
private const string CacheDirectory = "C:/kavita/config/cache/";
private const string CoverImageDirectory = "C:/kavita/config/covers/";
private const string BackupDirectory = "C:/kavita/config/backups/";
private const string BookmarkDirectory = "C:/kavita/config/bookmarks/";
private const string SiteThemeDirectory = "C:/kavita/config/themes/";
public SiteThemeServiceTests()
{
var contextOptions = new DbContextOptionsBuilder()
.UseSqlite(CreateInMemoryDatabase())
.Options;
_connection = RelationalOptionsExtension.Extract(contextOptions).Connection;
_context = new DataContext(contextOptions);
Task.Run(SeedDb).GetAwaiter().GetResult();
var config = new MapperConfiguration(cfg => cfg.AddProfile<AutoMapperProfiles>());
var mapper = config.CreateMapper();
_unitOfWork = new UnitOfWork(_context, mapper, null);
}
#region Setup
private static DbConnection CreateInMemoryDatabase()
{
var connection = new SqliteConnection("Filename=:memory:");
connection.Open();
return connection;
}
private async Task<bool> SeedDb()
{
await _context.Database.MigrateAsync();
var filesystem = CreateFileSystem();
await Seed.SeedSettings(_context, new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem));
var setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.CacheDirectory).SingleAsync();
setting.Value = CacheDirectory;
setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BackupDirectory).SingleAsync();
setting.Value = BackupDirectory;
setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BookmarkDirectory).SingleAsync();
setting.Value = BookmarkDirectory;
_context.ServerSetting.Update(setting);
_context.AppUser.Add(new AppUser()
{
UserName = "Joe",
UserPreferences = new AppUserPreferences
{
Theme = Seed.DefaultThemes[1]
}
});
_context.Library.Add(new Library()
{
Name = "Manga",
Folders = new List<FolderPath>()
{
new FolderPath()
{
Path = "C:/data/"
}
}
});
return await _context.SaveChangesAsync() > 0;
}
private static MockFileSystem CreateFileSystem()
{
var fileSystem = new MockFileSystem();
fileSystem.Directory.SetCurrentDirectory("C:/kavita/");
fileSystem.AddDirectory("C:/kavita/config/");
fileSystem.AddDirectory(CacheDirectory);
fileSystem.AddDirectory(CoverImageDirectory);
fileSystem.AddDirectory(BackupDirectory);
fileSystem.AddDirectory(BookmarkDirectory);
fileSystem.AddDirectory(SiteThemeDirectory);
fileSystem.AddDirectory("C:/data/");
return fileSystem;
}
private async Task ResetDb()
{
_context.SiteTheme.RemoveRange(_context.SiteTheme);
await _context.SaveChangesAsync();
}
#endregion
[Fact]
public async Task Scan_ShouldFindCustomFile()
{
await ResetDb();
var filesystem = CreateFileSystem();
filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData(""));
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
var siteThemeService = new SiteThemeService(ds, _unitOfWork, _messageHub);
await siteThemeService.Scan();
Assert.NotNull(await _unitOfWork.SiteThemeRepository.GetThemeDtoByName("custom"));
}
[Fact]
public async Task Scan_ShouldOnlyInsertOnceOnSecondScan()
{
await ResetDb();
var filesystem = CreateFileSystem();
filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData(""));
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
var siteThemeService = new SiteThemeService(ds, _unitOfWork, _messageHub);
await siteThemeService.Scan();
Assert.NotNull(await _unitOfWork.SiteThemeRepository.GetThemeDtoByName("custom"));
await siteThemeService.Scan();
var customThemes = (await _unitOfWork.SiteThemeRepository.GetThemeDtos()).Where(t =>
API.Parser.Parser.Normalize(t.Name).Equals(API.Parser.Parser.Normalize("custom")));
Assert.Single(customThemes);
}
[Fact]
public async Task Scan_ShouldDeleteWhenFileDoesntExistOnSecondScan()
{
await ResetDb();
var filesystem = CreateFileSystem();
filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData(""));
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
var siteThemeService = new SiteThemeService(ds, _unitOfWork, _messageHub);
await siteThemeService.Scan();
Assert.NotNull(await _unitOfWork.SiteThemeRepository.GetThemeDtoByName("custom"));
filesystem.RemoveFile($"{SiteThemeDirectory}custom.css");
await siteThemeService.Scan();
var customThemes = (await _unitOfWork.SiteThemeRepository.GetThemeDtos()).Where(t =>
API.Parser.Parser.Normalize(t.Name).Equals(API.Parser.Parser.Normalize("custom")));
Assert.Empty(customThemes);
}
[Fact]
public async Task GetContent_ShouldReturnContent()
{
await ResetDb();
var filesystem = CreateFileSystem();
filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("123"));
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
var siteThemeService = new SiteThemeService(ds, _unitOfWork, _messageHub);
_context.SiteTheme.Add(new SiteTheme()
{
Name = "Custom",
NormalizedName = API.Parser.Parser.Normalize("Custom"),
Provider = ThemeProvider.User,
FileName = "custom.css",
IsDefault = false
});
await _context.SaveChangesAsync();
var content = await siteThemeService.GetContent((await _unitOfWork.SiteThemeRepository.GetThemeDtoByName("Custom")).Id);
Assert.NotNull(content);
Assert.NotEmpty(content);
Assert.Equal("123", content);
}
[Fact]
public async Task UpdateDefault_ShouldHaveOneDefault()
{
await ResetDb();
var filesystem = CreateFileSystem();
filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("123"));
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
var siteThemeService = new SiteThemeService(ds, _unitOfWork, _messageHub);
_context.SiteTheme.Add(new SiteTheme()
{
Name = "Custom",
NormalizedName = API.Parser.Parser.Normalize("Custom"),
Provider = ThemeProvider.User,
FileName = "custom.css",
IsDefault = false
});
await _context.SaveChangesAsync();
var customTheme = (await _unitOfWork.SiteThemeRepository.GetThemeDtoByName("Custom"));
await siteThemeService.UpdateDefault(customTheme.Id);
Assert.Equal(customTheme.Id, (await _unitOfWork.SiteThemeRepository.GetDefaultTheme()).Id);
}
[Fact]
public async Task UpdateDefault_ShouldThrowOnInvalidId()
{
await ResetDb();
var filesystem = CreateFileSystem();
filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("123"));
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
var siteThemeService = new SiteThemeService(ds, _unitOfWork, _messageHub);
_context.SiteTheme.Add(new SiteTheme()
{
Name = "Custom",
NormalizedName = API.Parser.Parser.Normalize("Custom"),
Provider = ThemeProvider.User,
FileName = "custom.css",
IsDefault = false
});
await _context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<KavitaException>(async () => await siteThemeService.UpdateDefault(10));
Assert.Equal("Theme file missing or invalid", ex.Message);
}
}

View File

@ -310,4 +310,8 @@
<Reference Include="System.Drawing.Common" />
</ItemGroup>
<ItemGroup>
<Folder Include="config\themes" />
</ItemGroup>
</Project>

View File

@ -106,7 +106,10 @@ namespace API.Controllers
{
UserName = registerDto.Username,
Email = registerDto.Email,
UserPreferences = new AppUserPreferences(),
UserPreferences = new AppUserPreferences
{
Theme = await _unitOfWork.SiteThemeRepository.GetDefaultTheme()
},
ApiKey = HashUtil.ApiKey()
};
@ -179,22 +182,23 @@ namespace API.Controllers
// Update LastActive on account
user.LastActive = DateTime.Now;
user.UserPreferences ??= new AppUserPreferences();
user.UserPreferences ??= new AppUserPreferences
{
Theme = await _unitOfWork.SiteThemeRepository.GetDefaultTheme()
};
_unitOfWork.UserRepository.Update(user);
await _unitOfWork.CommitAsync();
_logger.LogInformation("{UserName} logged in at {Time}", user.UserName, user.LastActive);
return new UserDto
{
Username = user.UserName,
Email = user.Email,
Token = await _tokenService.CreateToken(user),
RefreshToken = await _tokenService.CreateRefreshToken(user),
ApiKey = user.ApiKey,
Preferences = _mapper.Map<UserPreferencesDto>(user.UserPreferences)
};
var dto = _mapper.Map<UserDto>(user);
dto.Token = await _tokenService.CreateToken(user);
dto.RefreshToken = await _tokenService.CreateRefreshToken(user);
var pref = await _unitOfWork.UserRepository.GetPreferencesAsync(user.UserName);
pref.Theme ??= await _unitOfWork.SiteThemeRepository.GetDefaultTheme();
dto.Preferences = _mapper.Map<UserPreferencesDto>(pref);
return dto;
}
[HttpPost("refresh-token")]
@ -358,7 +362,10 @@ namespace API.Controllers
UserName = dto.Email,
Email = dto.Email,
ApiKey = HashUtil.ApiKey(),
UserPreferences = new AppUserPreferences()
UserPreferences = new AppUserPreferences
{
Theme = await _unitOfWork.SiteThemeRepository.GetDefaultTheme()
}
};
try

View File

@ -0,0 +1,64 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using API.Data;
using API.DTOs.Theme;
using API.Services;
using API.Services.Tasks;
using Kavita.Common;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace API.Controllers;
public class ThemeController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
private readonly ISiteThemeService _siteThemeService;
private readonly ITaskScheduler _taskScheduler;
public ThemeController(IUnitOfWork unitOfWork, ISiteThemeService siteThemeService, ITaskScheduler taskScheduler)
{
_unitOfWork = unitOfWork;
_siteThemeService = siteThemeService;
_taskScheduler = taskScheduler;
}
[HttpGet]
public async Task<ActionResult<IEnumerable<SiteThemeDto>>> GetThemes()
{
return Ok(await _unitOfWork.SiteThemeRepository.GetThemeDtos());
}
[Authorize("RequireAdminRole")]
[HttpPost("scan")]
public ActionResult Scan()
{
_taskScheduler.ScanSiteThemes();
return Ok();
}
[Authorize("RequireAdminRole")]
[HttpPost("update-default")]
public async Task<ActionResult> UpdateDefault(UpdateDefaultSiteThemeDto dto)
{
await _siteThemeService.UpdateDefault(dto.ThemeId);
return Ok();
}
/// <summary>
/// Returns css content to the UI. UI is expected to escape the content
/// </summary>
/// <returns></returns>
[HttpGet("download-content")]
public async Task<ActionResult<string>> GetThemeContent(int themeId)
{
try
{
return Ok(await _siteThemeService.GetContent(themeId));
}
catch (KavitaException ex)
{
return BadRequest(ex.Message);
}
}
}

View File

@ -78,7 +78,8 @@ namespace API.Controllers
existingPreferences.BookReaderDarkMode = preferencesDto.BookReaderDarkMode;
existingPreferences.BookReaderFontSize = preferencesDto.BookReaderFontSize;
existingPreferences.BookReaderTapToPaginate = preferencesDto.BookReaderTapToPaginate;
existingPreferences.SiteDarkMode = preferencesDto.SiteDarkMode;
existingPreferences.BookReaderReadingDirection = preferencesDto.BookReaderReadingDirection;
existingPreferences.Theme = await _unitOfWork.SiteThemeRepository.GetThemeById(preferencesDto.Theme.Id);
_unitOfWork.UserRepository.Update(existingPreferences);

View File

@ -0,0 +1,30 @@
using System;
using API.Entities.Enums.Theme;
using API.Services;
namespace API.DTOs.Theme;
public class SiteThemeDto
{
public int Id { get; set; }
/// <summary>
/// Name of the Theme
/// </summary>
public string Name { get; set; }
/// <summary>
/// File path to the content. Stored under <see cref="DirectoryService.SiteThemeDirectory"/>.
/// Must be a .css file
/// </summary>
public string FileName { get; set; }
/// <summary>
/// Only one theme can have this. Will auto-set this as default for new user accounts
/// </summary>
public bool IsDefault { get; set; }
/// <summary>
/// Where did the theme come from
/// </summary>
public ThemeProvider Provider { get; set; }
public DateTime Created { get; set; }
public DateTime LastModified { get; set; }
public string Selector => "bg-" + Name.ToLower();
}

View File

@ -0,0 +1,6 @@
namespace API.DTOs.Theme;
public class UpdateDefaultSiteThemeDto
{
public int ThemeId { get; set; }
}

View File

@ -5,8 +5,8 @@ namespace API.DTOs
{
public string Username { get; init; }
public string Email { get; init; }
public string Token { get; init; }
public string RefreshToken { get; init; }
public string Token { get; set; }
public string RefreshToken { get; set; }
public string ApiKey { get; init; }
public UserPreferencesDto Preferences { get; set; }
}

View File

@ -1,4 +1,5 @@
using API.Entities.Enums;
using API.Entities;
using API.Entities.Enums;
namespace API.DTOs
{
@ -16,6 +17,6 @@ namespace API.DTOs
public string BookReaderFontFamily { get; set; }
public bool BookReaderTapToPaginate { get; set; }
public ReadingDirection BookReaderReadingDirection { get; set; }
public bool SiteDarkMode { get; set; }
public SiteTheme Theme { get; set; }
}
}

View File

@ -40,6 +40,7 @@ namespace API.Data
public DbSet<Person> Person { get; set; }
public DbSet<Genre> Genre { get; set; }
public DbSet<Tag> Tag { get; set; }
public DbSet<SiteTheme> SiteTheme { get; set; }
protected override void OnModelCreating(ModelBuilder builder)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,79 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
public partial class SiteTheme : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "SiteDarkMode",
table: "AppUserPreferences");
migrationBuilder.AddColumn<int>(
name: "ThemeId",
table: "AppUserPreferences",
type: "INTEGER",
nullable: true);
migrationBuilder.CreateTable(
name: "SiteTheme",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(type: "TEXT", nullable: true),
NormalizedName = table.Column<string>(type: "TEXT", nullable: true),
FileName = table.Column<string>(type: "TEXT", nullable: true),
IsDefault = table.Column<bool>(type: "INTEGER", nullable: false),
Provider = table.Column<int>(type: "INTEGER", nullable: false),
Created = table.Column<DateTime>(type: "TEXT", nullable: false),
LastModified = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SiteTheme", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_AppUserPreferences_ThemeId",
table: "AppUserPreferences",
column: "ThemeId");
migrationBuilder.AddForeignKey(
name: "FK_AppUserPreferences_SiteTheme_ThemeId",
table: "AppUserPreferences",
column: "ThemeId",
principalTable: "SiteTheme",
principalColumn: "Id");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_AppUserPreferences_SiteTheme_ThemeId",
table: "AppUserPreferences");
migrationBuilder.DropTable(
name: "SiteTheme");
migrationBuilder.DropIndex(
name: "IX_AppUserPreferences_ThemeId",
table: "AppUserPreferences");
migrationBuilder.DropColumn(
name: "ThemeId",
table: "AppUserPreferences");
migrationBuilder.AddColumn<bool>(
name: "SiteDarkMode",
table: "AppUserPreferences",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
}
}

View File

@ -15,7 +15,7 @@ namespace API.Data.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "6.0.0");
modelBuilder.HasAnnotation("ProductVersion", "6.0.1");
modelBuilder.Entity("API.Entities.AppRole", b =>
{
@ -198,7 +198,7 @@ namespace API.Data.Migrations
b.Property<int>("ScalingOption")
.HasColumnType("INTEGER");
b.Property<bool>("SiteDarkMode")
b.Property<int?>("ThemeId")
.HasColumnType("INTEGER");
b.HasKey("Id");
@ -206,6 +206,8 @@ namespace API.Data.Migrations
b.HasIndex("AppUserId")
.IsUnique();
b.HasIndex("ThemeId");
b.ToTable("AppUserPreferences");
});
@ -687,6 +689,38 @@ namespace API.Data.Migrations
b.ToTable("ServerSetting");
});
modelBuilder.Entity("API.Entities.SiteTheme", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<string>("FileName")
.HasColumnType("TEXT");
b.Property<bool>("IsDefault")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasColumnType("TEXT");
b.Property<int>("Provider")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("SiteTheme");
});
modelBuilder.Entity("API.Entities.Tag", b =>
{
b.Property<int>("Id")
@ -967,7 +1001,13 @@ namespace API.Data.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.SiteTheme", "Theme")
.WithMany()
.HasForeignKey("ThemeId");
b.Navigation("AppUser");
b.Navigation("Theme");
});
modelBuilder.Entity("API.Entities.AppUserProgress", b =>

View File

@ -0,0 +1,107 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.DTOs.Theme;
using API.Entities;
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Microsoft.EntityFrameworkCore;
namespace API.Data.Repositories;
public interface ISiteThemeRepository
{
void Add(SiteTheme theme);
void Remove(SiteTheme theme);
void Update(SiteTheme siteTheme);
Task<IEnumerable<SiteThemeDto>> GetThemeDtos();
Task<SiteThemeDto> GetThemeDto(int themeId);
Task<SiteThemeDto> GetThemeDtoByName(string themeName);
Task<SiteTheme> GetDefaultTheme();
Task<IEnumerable<SiteTheme>> GetThemes();
Task<SiteTheme> GetThemeById(int themeId);
}
public class SiteThemeRepository : ISiteThemeRepository
{
private readonly DataContext _context;
private readonly IMapper _mapper;
public SiteThemeRepository(DataContext context, IMapper mapper)
{
_context = context;
_mapper = mapper;
}
public void Add(SiteTheme theme)
{
_context.Add(theme);
}
public void Remove(SiteTheme theme)
{
_context.Remove(theme);
}
public void Update(SiteTheme siteTheme)
{
_context.Entry(siteTheme).State = EntityState.Modified;
}
public async Task<IEnumerable<SiteThemeDto>> GetThemeDtos()
{
return await _context.SiteTheme
.ProjectTo<SiteThemeDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
public async Task<SiteThemeDto> GetThemeDtoByName(string themeName)
{
return await _context.SiteTheme
.Where(t => t.Name.Equals(themeName))
.ProjectTo<SiteThemeDto>(_mapper.ConfigurationProvider)
.SingleOrDefaultAsync();
}
/// <summary>
/// Returns default theme, if the default theme is not available, returns the dark theme
/// </summary>
/// <returns></returns>
public async Task<SiteTheme> GetDefaultTheme()
{
var result = await _context.SiteTheme
.Where(t => t.IsDefault)
.SingleOrDefaultAsync();
if (result == null)
{
return await _context.SiteTheme
.Where(t => t.NormalizedName == "dark")
.SingleOrDefaultAsync();
}
return result;
}
public async Task<IEnumerable<SiteTheme>> GetThemes()
{
return await _context.SiteTheme
.ToListAsync();
}
public async Task<SiteTheme> GetThemeById(int themeId)
{
return await _context.SiteTheme
.Where(t => t.Id == themeId)
.SingleOrDefaultAsync();
}
public async Task<SiteThemeDto> GetThemeDto(int themeId)
{
return await _context.SiteTheme
.Where(t => t.Id == themeId)
.ProjectTo<SiteThemeDto>(_mapper.ConfigurationProvider)
.SingleOrDefaultAsync();
}
}

View File

@ -55,6 +55,7 @@ public interface IUserRepository
Task<AppUser> GetUserByEmailAsync(string email);
Task<IEnumerable<AppUser>> GetAllUsers();
Task<IEnumerable<AppUserPreferences>> GetAllPreferencesByThemeAsync(int themeId);
}
public class UserRepository : IUserRepository
@ -227,6 +228,15 @@ public class UserRepository : IUserRepository
return await _context.AppUser.ToListAsync();
}
public async Task<IEnumerable<AppUserPreferences>> GetAllPreferencesByThemeAsync(int themeId)
{
return await _context.AppUserPreferences
.Include(p => p.Theme)
.Where(p => p.Theme.Id == themeId)
.AsSplitQuery()
.ToListAsync();
}
public async Task<IEnumerable<AppUser>> GetAdminUsersAsync()
{
return await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole);
@ -244,7 +254,8 @@ public class UserRepository : IUserRepository
public async Task<AppUserRating> GetUserRatingAsync(int seriesId, int userId)
{
return await _context.AppUserRating.Where(r => r.SeriesId == seriesId && r.AppUserId == userId)
return await _context.AppUserRating
.Where(r => r.SeriesId == seriesId && r.AppUserId == userId)
.SingleOrDefaultAsync();
}
@ -252,6 +263,8 @@ public class UserRepository : IUserRepository
{
return await _context.AppUserPreferences
.Include(p => p.AppUser)
.Include(p => p.Theme)
.AsSplitQuery()
.SingleOrDefaultAsync(p => p.AppUser.UserName == username);
}

View File

@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Linq;
using System.Reflection;
@ -6,6 +7,7 @@ using System.Threading.Tasks;
using API.Constants;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Enums.Theme;
using API.Services;
using Kavita.Common;
using Kavita.Common.EnvironmentInfo;
@ -21,6 +23,34 @@ namespace API.Data
/// </summary>
public static IList<ServerSetting> DefaultSettings;
public static readonly IList<SiteTheme> DefaultThemes = new List<SiteTheme>
{
new()
{
Name = "Dark",
NormalizedName = Parser.Parser.Normalize("Dark"),
Provider = ThemeProvider.System,
FileName = "dark.scss",
IsDefault = true,
},
new()
{
Name = "Light",
NormalizedName = Parser.Parser.Normalize("Light"),
Provider = ThemeProvider.System,
FileName = "light.scss",
IsDefault = false,
},
new()
{
Name = "E-Ink",
NormalizedName = Parser.Parser.Normalize("E-Ink"),
Provider = ThemeProvider.System,
FileName = "eink.scss",
IsDefault = false,
},
};
public static async Task SeedRoles(RoleManager<AppRole> roleManager)
{
var roles = typeof(PolicyConstants)
@ -41,6 +71,22 @@ namespace API.Data
}
}
public static async Task SeedThemes(DataContext context)
{
await context.Database.EnsureCreatedAsync();
foreach (var theme in DefaultThemes)
{
var existing = context.SiteTheme.FirstOrDefault(s => s.Name.Equals(theme.Name));
if (existing == null)
{
await context.SiteTheme.AddAsync(theme);
}
}
await context.SaveChangesAsync();
}
public static async Task SeedSettings(DataContext context, IDirectoryService directoryService)
{
await context.Database.EnsureCreatedAsync();

View File

@ -21,6 +21,7 @@ public interface IUnitOfWork
IPersonRepository PersonRepository { get; }
IGenreRepository GenreRepository { get; }
ITagRepository TagRepository { get; }
ISiteThemeRepository SiteThemeRepository { get; }
bool Commit();
Task<bool> CommitAsync();
bool HasChanges();
@ -56,6 +57,7 @@ public class UnitOfWork : IUnitOfWork
public IPersonRepository PersonRepository => new PersonRepository(_context, _mapper);
public IGenreRepository GenreRepository => new GenreRepository(_context, _mapper);
public ITagRepository TagRepository => new TagRepository(_context, _mapper);
public ISiteThemeRepository SiteThemeRepository => new SiteThemeRepository(_context, _mapper);
/// <summary>
/// Commits changes to the DB. Completes the open transaction.

View File

@ -58,11 +58,11 @@ namespace API.Entities
/// Book Reader Option: What direction should the next/prev page buttons go
/// </summary>
public ReadingDirection BookReaderReadingDirection { get; set; } = ReadingDirection.LeftToRight;
/// <summary>
/// UI Site Global Setting: Whether the UI should render in Dark mode or not.
/// UI Site Global Setting: The UI theme the user should use.
/// </summary>
public bool SiteDarkMode { get; set; } = true;
/// <remarks>Should default to Dark</remarks>
public SiteTheme Theme { get; set; }

View File

@ -0,0 +1,17 @@
using System.ComponentModel;
namespace API.Entities.Enums.Theme;
public enum ThemeProvider
{
/// <summary>
/// Theme is provided by System
/// </summary>
[Description("System")]
System = 1,
/// <summary>
/// Theme is provided by the User (ie it's custom)
/// </summary>
[Description("User")]
User = 2
}

37
API/Entities/SiteTheme.cs Normal file
View File

@ -0,0 +1,37 @@
using System;
using System.Collections.Generic;
using API.Entities.Enums.Theme;
using API.Entities.Interfaces;
using API.Services;
namespace API.Entities;
/// <summary>
/// Represents a set of css overrides the user can upload to Kavita and will load into webui
/// </summary>
public class SiteTheme : IEntityDate
{
public int Id { get; set; }
/// <summary>
/// Name of the Theme
/// </summary>
public string Name { get; set; }
/// <summary>
/// Normalized name for lookups
/// </summary>
public string NormalizedName { get; set; }
/// <summary>
/// File path to the content. Stored under <see cref="DirectoryService.SiteThemeDirectory"/>.
/// Must be a .css file
/// </summary>
public string FileName { get; set; }
/// <summary>
/// Only one theme can have this. Will auto-set this as default for new user accounts
/// </summary>
public bool IsDefault { get; set; }
/// <summary>
/// Where did the theme come from
/// </summary>
public ThemeProvider Provider { get; set; }
public DateTime Created { get; set; }
public DateTime LastModified { get; set; }
}

View File

@ -39,6 +39,7 @@ namespace API.Extensions
services.AddScoped<IAccountService, AccountService>();
services.AddScoped<IEmailService, EmailService>();
services.AddScoped<IBookmarkService, BookmarkService>();
services.AddScoped<ISiteThemeService, SiteThemeService>();
services.AddScoped<IFileSystem, FileSystem>();
services.AddScoped<IFileService, FileService>();

View File

@ -7,6 +7,7 @@ using API.DTOs.Reader;
using API.DTOs.ReadingLists;
using API.DTOs.Search;
using API.DTOs.Settings;
using API.DTOs.Theme;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Metadata;
@ -119,10 +120,14 @@ namespace API.Helpers
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Editor)));
CreateMap<AppUser, UserDto>();
CreateMap<SiteTheme, SiteThemeDto>();
CreateMap<AppUserPreferences, UserPreferencesDto>()
.ForMember(dest => dest.Theme,
opt =>
opt.MapFrom(src => src.Theme));
CreateMap<AppUserPreferences, UserPreferencesDto>();
CreateMap<AppUserBookmark, BookmarkDto>();
CreateMap<ReadingList, ReadingListDto>();
@ -146,6 +151,7 @@ namespace API.Helpers
CreateMap<RegisterDto, AppUser>();
CreateMap<IEnumerable<ServerSetting>, ServerSettingDto>()
.ConvertUsing<ServerSettingConverter>();
}

View File

@ -77,6 +77,7 @@ namespace API
await Seed.SeedRoles(roleManager);
await Seed.SeedSettings(context, directoryService);
await Seed.SeedThemes(context);
await Seed.SeedUserApiKeys(context);

View File

@ -19,6 +19,7 @@ namespace API.Services
string LogDirectory { get; }
string TempDirectory { get; }
string ConfigDirectory { get; }
string SiteThemeDirectory { get; }
/// <summary>
/// Original BookmarkDirectory. Only used for resetting directory. Use <see cref="ServerSettingKey.BackupDirectory"/> for actual path.
/// </summary>
@ -64,6 +65,7 @@ namespace API.Services
public string TempDirectory { get; }
public string ConfigDirectory { get; }
public string BookmarkDirectory { get; }
public string SiteThemeDirectory { get; }
private readonly ILogger<DirectoryService> _logger;
private static readonly Regex ExcludeDirectories = new Regex(
@ -81,6 +83,7 @@ namespace API.Services
TempDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "temp");
ConfigDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config");
BookmarkDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "bookmarks");
SiteThemeDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "themes");
}
/// <summary>

View File

@ -22,6 +22,7 @@ public interface ITaskScheduler
void ScanSeries(int libraryId, int seriesId, bool forceUpdate = false);
void CancelStatsTasks();
Task RunStatCollection();
void ScanSiteThemes();
}
public class TaskScheduler : ITaskScheduler
{
@ -35,6 +36,7 @@ public class TaskScheduler : ITaskScheduler
private readonly IStatsService _statsService;
private readonly IVersionUpdaterService _versionUpdaterService;
private readonly ISiteThemeService _siteThemeService;
public static BackgroundJobServer Client => new BackgroundJobServer();
private static readonly Random Rnd = new Random();
@ -42,7 +44,8 @@ public class TaskScheduler : ITaskScheduler
public TaskScheduler(ICacheService cacheService, ILogger<TaskScheduler> logger, IScannerService scannerService,
IUnitOfWork unitOfWork, IMetadataService metadataService, IBackupService backupService,
ICleanupService cleanupService, IStatsService statsService, IVersionUpdaterService versionUpdaterService)
ICleanupService cleanupService, IStatsService statsService, IVersionUpdaterService versionUpdaterService,
ISiteThemeService siteThemeService)
{
_cacheService = cacheService;
_logger = logger;
@ -53,6 +56,7 @@ public class TaskScheduler : ITaskScheduler
_cleanupService = cleanupService;
_statsService = statsService;
_versionUpdaterService = versionUpdaterService;
_siteThemeService = siteThemeService;
}
public async Task ScheduleTasks()
@ -124,6 +128,12 @@ public class TaskScheduler : ITaskScheduler
BackgroundJob.Enqueue(() => _statsService.Send());
}
public void ScanSiteThemes()
{
_logger.LogInformation("Starting Site Theme scan");
BackgroundJob.Enqueue(() => _siteThemeService.Scan());
}
#endregion
#region UpdateTasks

View File

@ -0,0 +1,163 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.Entities;
using API.Entities.Enums.Theme;
using API.SignalR;
using Kavita.Common;
using Microsoft.AspNetCore.SignalR;
namespace API.Services.Tasks;
public interface ISiteThemeService
{
Task<string> GetContent(int themeId);
Task Scan();
Task UpdateDefault(int themeId);
}
public class SiteThemeService : ISiteThemeService
{
private readonly IDirectoryService _directoryService;
private readonly IUnitOfWork _unitOfWork;
private readonly IHubContext<MessageHub> _messageHub;
public SiteThemeService(IDirectoryService directoryService, IUnitOfWork unitOfWork, IHubContext<MessageHub> messageHub)
{
_directoryService = directoryService;
_unitOfWork = unitOfWork;
_messageHub = messageHub;
}
/// <summary>
/// Given a themeId, return the content inside that file
/// </summary>
/// <param name="themeId"></param>
/// <returns></returns>
/// <exception cref="KavitaException"></exception>
public async Task<string> GetContent(int themeId)
{
var theme = await _unitOfWork.SiteThemeRepository.GetThemeDto(themeId);
if (theme == null) throw new KavitaException("Theme file missing or invalid");
var themeFile = _directoryService.FileSystem.Path.Join(_directoryService.SiteThemeDirectory, theme.FileName);
if (string.IsNullOrEmpty(themeFile) || !_directoryService.FileSystem.File.Exists(themeFile))
throw new KavitaException("Theme file missing or invalid");
return await _directoryService.FileSystem.File.ReadAllTextAsync(themeFile);
}
/// <summary>
/// Scans the site theme directory for custom css files and updates what the system has on store
/// </summary>
public async Task Scan()
{
_directoryService.ExistOrCreate(_directoryService.SiteThemeDirectory);
var reservedNames = Seed.DefaultThemes.Select(t => t.NormalizedName).ToList();
var themeFiles = _directoryService.GetFilesWithExtension(Parser.Parser.NormalizePath(_directoryService.SiteThemeDirectory), @"\.css")
.Where(name => !reservedNames.Contains(Parser.Parser.Normalize(name))).ToList();
var allThemes = (await _unitOfWork.SiteThemeRepository.GetThemes()).ToList();
var totalThemesToIterate = themeFiles.Count;
var themeIteratedCount = 0;
// First remove any files from allThemes that are User Defined and not on disk
var userThemes = allThemes.Where(t => t.Provider == ThemeProvider.User).ToList();
foreach (var userTheme in userThemes)
{
var filepath = Parser.Parser.NormalizePath(
_directoryService.FileSystem.Path.Join(_directoryService.SiteThemeDirectory, userTheme.FileName));
if (!_directoryService.FileSystem.File.Exists(filepath))
{
// I need to do the removal different. I need to update all userpreferences to use DefaultTheme
allThemes.Remove(userTheme);
await RemoveTheme(userTheme);
await _messageHub.Clients.All.SendAsync(SignalREvents.SiteThemeProgress,
MessageFactory.SiteThemeProgressEvent(1, totalThemesToIterate, userTheme.FileName, 0F));
}
}
// Add new custom themes
var allThemeNames = allThemes.Select(t => t.NormalizedName).ToList();
foreach (var themeFile in themeFiles)
{
var themeName =
Parser.Parser.Normalize(_directoryService.FileSystem.Path.GetFileNameWithoutExtension(themeFile));
if (allThemeNames.Contains(themeName))
{
themeIteratedCount += 1;
continue;
}
_unitOfWork.SiteThemeRepository.Add(new SiteTheme()
{
Name = _directoryService.FileSystem.Path.GetFileNameWithoutExtension(themeFile),
NormalizedName = themeName,
FileName = _directoryService.FileSystem.Path.GetFileName(themeFile),
Provider = ThemeProvider.User,
IsDefault = false,
});
await _messageHub.Clients.All.SendAsync(SignalREvents.SiteThemeProgress,
MessageFactory.SiteThemeProgressEvent(themeIteratedCount, totalThemesToIterate, themeName, themeIteratedCount / (totalThemesToIterate * 1.0f)));
themeIteratedCount += 1;
}
if (_unitOfWork.HasChanges())
{
await _unitOfWork.CommitAsync();
}
await _messageHub.Clients.All.SendAsync(SignalREvents.SiteThemeProgress,
MessageFactory.SiteThemeProgressEvent(totalThemesToIterate, totalThemesToIterate, "", 1F));
}
/// <summary>
/// Removes the theme and any references to it from Pref and sets them to the default at the time.
/// This commits to DB.
/// </summary>
/// <param name="theme"></param>
private async Task RemoveTheme(SiteTheme theme)
{
var prefs = await _unitOfWork.UserRepository.GetAllPreferencesByThemeAsync(theme.Id);
var defaultTheme = await _unitOfWork.SiteThemeRepository.GetDefaultTheme();
foreach (var pref in prefs)
{
pref.Theme = defaultTheme;
_unitOfWork.UserRepository.Update(pref);
}
_unitOfWork.SiteThemeRepository.Remove(theme);
await _unitOfWork.CommitAsync();
}
/// <summary>
/// Updates the themeId to the default theme, all others are marked as non-default
/// </summary>
/// <param name="themeId"></param>
/// <returns></returns>
/// <exception cref="KavitaException">If theme does not exist</exception>
public async Task UpdateDefault(int themeId)
{
try
{
var theme = await _unitOfWork.SiteThemeRepository.GetThemeDto(themeId);
if (theme == null) throw new KavitaException("Theme file missing or invalid");
foreach (var siteTheme in await _unitOfWork.SiteThemeRepository.GetThemes())
{
siteTheme.IsDefault = (siteTheme.Id == themeId);
_unitOfWork.SiteThemeRepository.Update(siteTheme);
}
if (!_unitOfWork.HasChanges()) return;
await _unitOfWork.CommitAsync();
}
catch (Exception)
{
await _unitOfWork.RollbackAsync();
throw;
}
}
}

View File

@ -1,4 +1,5 @@
using System;
using System.Threading;
using API.DTOs.Update;
namespace API.SignalR
@ -160,5 +161,20 @@ namespace API.SignalR
}
};
}
public static SignalRMessage SiteThemeProgressEvent(int themeIteratedCount, int totalThemesToIterate, string themeName, float progress)
{
return new SignalRMessage()
{
Name = SignalREvents.SiteThemeProgress,
Body = new
{
TotalUpdates = totalThemesToIterate,
CurrentCount = themeIteratedCount,
ThemeName = themeName,
Progress = progress
}
};
}
}
}

View File

@ -54,5 +54,10 @@
/// A cover was updated
/// </summary>
public const string CoverUpdate = "CoverUpdate";
/// <summary>
/// A custom site theme was removed or added
/// </summary>
public const string SiteThemeProgress = "SiteThemeProgress";
}
}

View File

@ -4,4 +4,5 @@
3. Run Kavita executable.
4. Open localhost:5000 and setup your account and libraries in the UI.
If updating, copy everything but the config/ directory over. Restart Kavita.
How to Update
1. Copy everything but the config/ directory over. Restart Kavita.

View File

@ -49,7 +49,7 @@
"vendorChunk": true,
"extractLicenses": false,
"buildOptimizer": false,
"optimization": true,
"optimization": false,
"namedChunks": true
},
"configurations": {

View File

@ -2405,18 +2405,11 @@
}
},
"@ng-bootstrap/ng-bootstrap": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/@ng-bootstrap/ng-bootstrap/-/ng-bootstrap-11.0.0.tgz",
"integrity": "sha512-qDnB0+jbpQ4wjXpM4NPRAtwmgTDUCjGavoeRDZHOvFfYvx/MBf1RTjZEqTJ1Yqq1pKP4BWpzxCgVTunfnpmsjA==",
"version": "12.0.0-beta.4",
"resolved": "https://registry.npmjs.org/@ng-bootstrap/ng-bootstrap/-/ng-bootstrap-12.0.0-beta.4.tgz",
"integrity": "sha512-iOXZT4FLouAGJDRw4ruogyR+lg648nywNWKUxW7l+mtMC9i4kdpfo4beQ/nqb4Uq2zMDs9zj4MbKVI391+kMnA==",
"requires": {
"tslib": "^2.3.0"
},
"dependencies": {
"tslib": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
"integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
}
}
},
"@ngtools/webpack": {
@ -2581,6 +2574,11 @@
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.21.tgz",
"integrity": "sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g=="
},
"@popperjs/core": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.2.tgz",
"integrity": "sha512-92FRmppjjqz29VMJ2dn+xdyXZBrMlE42AV6Kq6BwjWV7CNUW1hs2FtxSNLQE+gJhaZ6AAmYuO9y8dshhcBl7vA=="
},
"@schematics/angular": {
"version": "13.2.3",
"resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-13.2.3.tgz",
@ -3841,9 +3839,9 @@
"dev": true
},
"bootstrap": {
"version": "4.6.1",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-4.6.1.tgz",
"integrity": "sha512-0dj+VgI9Ecom+rvvpNZ4MUZJz8dcX7WCX+eTID9+/8HgOkv3dsRzi8BGeZJCQU6flWQVYxwTQnEZFrmJSEO7og=="
"version": "5.1.3",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.1.3.tgz",
"integrity": "sha512-fcQztozJ8jToQWXxVuEyXWW+dSo8AiXWKwiSSrKWsRB/Qt+Ewwza+JWoLKiTuQLaEPhdNAJ7+Dosc9DOIqNy7Q=="
},
"bowser": {
"version": "2.11.0",
@ -11848,9 +11846,9 @@
"dev": true
},
"swiper": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/swiper/-/swiper-8.0.3.tgz",
"integrity": "sha512-mpw7v/Lkh48LQUxtJuFD+3Lls8LViNi3j1fbk45fNo9DXZxXK/e7NMixxS27OxvC5wx+5H3bet1O2pdjk7akBA==",
"version": "8.0.6",
"resolved": "https://registry.npmjs.org/swiper/-/swiper-8.0.6.tgz",
"integrity": "sha512-Ssyu1+FeNATF/G8e84QG+ZUNtUOAZ5vngdgxzczh0oWZPhGUVgkdv+BoePUuaCXLAFXnwVpNjgLIcGnxMdmWPA==",
"requires": {
"dom7": "^4.0.4",
"ssr-window": "^4.0.2"

View File

@ -28,11 +28,12 @@
"@angular/router": "~13.2.2",
"@fortawesome/fontawesome-free": "^6.0.0",
"@microsoft/signalr": "^6.0.2",
"@ng-bootstrap/ng-bootstrap": "^11.0.0",
"@ng-bootstrap/ng-bootstrap": "^12.0.0-beta.4",
"@ngx-lite/nav-drawer": "^0.4.7",
"@ngx-lite/util": "0.0.1",
"@popperjs/core": "^2.11.2",
"@types/file-saver": "^2.0.5",
"bootstrap": "^4.6.1",
"bootstrap": "^5.1.2",
"bowser": "^2.11.0",
"file-saver": "^2.0.5",
"lazysizes": "^5.3.2",
@ -41,7 +42,7 @@
"ngx-file-drop": "^13.0.0",
"ngx-toastr": "^14.2.1",
"rxjs": "~7.5.4",
"swiper": "^8.0.3",
"swiper": "^8.0.6",
"tslib": "^2.3.1",
"webpack-bundle-analyzer": "^4.5.0",
"zone.js": "~0.11.4"

View File

@ -2,29 +2,29 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">
{{series.name}} Review</h4>
<button type="button" class="close" aria-label="Close" (click)="close()">
<span aria-hidden="true">&times;</span>
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
</button>
</div>
<div class="modal-body">
<form [formGroup]="reviewGroup">
<div class="form-group">
<label for="rating">Rating</label>
<div class="row g-0">
<label for="rating" class="form-label">Rating</label>
<div>
<ngb-rating style="margin-top: 2px; font-size: 1.5rem;" formControlName="rating"></ngb-rating>
<button class="btn btn-information ml-2" (click)="clearRating()"><i aria-hidden="true" class="fa fa-ban"></i><span class="phone-hidden">&nbsp;Clear</span></button>
<button class="btn btn-icon ms-2" (click)="clearRating()"><i aria-hidden="true" class="fa fa-ban"></i><span class="phone-hidden">&nbsp;Clear</span></button>
</div>
</div>
<div class="form-group">
<label for="review">Review</label>
<div class="row g-0">
<label for="review" class="form-label">Review</label>
<textarea id="review" class="form-control" formControlName="review" rows="3"></textarea>
</div>
</form>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-secondary" (click)="close()">Close</button>
<button class="btn btn-secondary" (click)="close()">Close</button>
<button type="submit" class="btn btn-primary" (click)="save()">Save</button>
</div>
</div>

View File

@ -0,0 +1,7 @@
export interface SiteThemeProgressEvent {
totalUpdates: number;
currentCount: number;
themeName: string;
progress: number;
eventTime: string;
}

View File

@ -3,6 +3,7 @@ import { PageSplitOption } from './page-split-option';
import { READER_MODE } from './reader-mode';
import { ReadingDirection } from './reading-direction';
import { ScalingOption } from './scaling-option';
import { SiteTheme } from './site-theme';
export interface Preferences {
// Manga Reader
@ -22,7 +23,7 @@ export interface Preferences {
bookReaderReadingDirection: ReadingDirection;
// Global
siteDarkMode: boolean;
theme: SiteTheme;
}
export const readingDirections = [{text: 'Left to Right', value: ReadingDirection.LeftToRight}, {text: 'Right to Left', value: ReadingDirection.RightToLeft}];

View File

@ -0,0 +1,22 @@
/**
* Where does the theme come from
*/
export enum ThemeProvider {
System = 1,
User = 2
}
/**
* Theme for the whole instance
*/
export interface SiteTheme {
id: number;
name: string;
filePath: string;
isDefault: boolean;
provider: ThemeProvider;
/**
* The actual class the root is defined against. It is generated at the backend.
*/
selector: string;
}

View File

@ -7,6 +7,7 @@ import { Preferences } from '../_models/preferences/preferences';
import { User } from '../_models/user';
import { Router } from '@angular/router';
import { MessageHubService } from './message-hub.service';
import { ThemeService } from '../theme.service';
@Injectable({
providedIn: 'root'
@ -33,7 +34,7 @@ export class AccountService implements OnDestroy {
private readonly onDestroy = new Subject<void>();
constructor(private httpClient: HttpClient, private router: Router,
private messageHub: MessageHubService) {}
private messageHub: MessageHubService, private themeService: ThemeService) {}
ngOnDestroy(): void {
this.onDestroy.next();
@ -61,6 +62,7 @@ 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));
}
@ -77,6 +79,11 @@ export class AccountService implements OnDestroy {
localStorage.setItem(this.userKey, JSON.stringify(user));
localStorage.setItem(this.lastLoginKey, user.username);
if (user.preferences) {
this.themeService.setTheme(user.preferences.theme.name);
} else {
this.themeService.setTheme(this.themeService.defaultTheme);
}
}
this.currentUserSource.next(user);

View File

@ -1,7 +1,9 @@
import { Injectable, OnDestroy } from '@angular/core';
import { DOCUMENT } from '@angular/common';
import { Inject, Injectable, OnDestroy } from '@angular/core';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { environment } from 'src/environments/environment';
import { ThemeService } from '../theme.service';
import { RecentlyAddedItem } from '../_models/recently-added-item';
import { AccountService } from './account.service';
import { NavService } from './nav.service';
@ -19,9 +21,9 @@ export class ImageService implements OnDestroy {
private onDestroy: Subject<void> = new Subject();
constructor(private navSerivce: NavService, private accountService: AccountService) {
this.navSerivce.darkMode$.subscribe(res => {
if (res) {
constructor(private accountService: AccountService, private themeService: ThemeService) {
this.themeService.currentTheme$.pipe(takeUntil(this.onDestroy)).subscribe(theme => {
if (this.themeService.isDarkTheme()) {
this.placeholderImage = 'assets/images/image-placeholder.dark-min.png';
this.errorImage = 'assets/images/error-placeholder2.dark-min.png';
} else {

View File

@ -1,15 +1,13 @@
import { EventEmitter, Injectable } from '@angular/core';
import { Router } from '@angular/router';
import { HubConnection, HubConnectionBuilder } from '@microsoft/signalr';
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { BehaviorSubject, ReplaySubject } from 'rxjs';
import { environment } from 'src/environments/environment';
import { UpdateNotificationModalComponent } from '../shared/update-notification/update-notification-modal.component';
import { RefreshMetadataEvent } from '../_models/events/refresh-metadata-event';
import { ProgressEvent } from '../_models/events/scan-library-progress-event';
import { ScanSeriesEvent } from '../_models/events/scan-series-event';
import { SeriesAddedEvent } from '../_models/events/series-added-event';
import { SiteThemeProgressEvent } from '../_models/events/site-theme-progress-event';
import { User } from '../_models/user';
export enum EVENTS {
@ -25,6 +23,10 @@ export enum EVENTS {
BackupDatabaseProgress = 'BackupDatabaseProgress',
CleanupProgress = 'CleanupProgress',
DownloadProgress = 'DownloadProgress',
/**
* A custom user site theme is added or removed during a scan
*/
SiteThemeProgress = 'SiteThemeProgress',
/**
* A cover is updated
*/
@ -122,6 +124,13 @@ export class MessageHubService {
});
});
this.hubConnection.on(EVENTS.SiteThemeProgress, resp => {
this.messagesSource.next({
event: EVENTS.SiteThemeProgress,
payload: resp.body as SiteThemeProgressEvent
});
});
this.hubConnection.on(EVENTS.SeriesAddedToCollection, resp => {
this.messagesSource.next({
event: EVENTS.SeriesAddedToCollection,

View File

@ -1,4 +1,4 @@
import { Injectable, Renderer2, RendererFactory2 } from '@angular/core';
import { Injectable } from '@angular/core';
import { ReplaySubject } from 'rxjs';
@Injectable({
@ -9,14 +9,7 @@ export class NavService {
private navbarVisibleSource = new ReplaySubject<boolean>(1);
navbarVisible$ = this.navbarVisibleSource.asObservable();
private darkMode: boolean = true;
private darkModeSource = new ReplaySubject<boolean>(1);
darkMode$ = this.darkModeSource.asObservable();
private renderer: Renderer2;
constructor(rendererFactory: RendererFactory2) {
this.renderer = rendererFactory.createRenderer(null, null);
constructor() {
this.showNavBar();
}
@ -27,26 +20,4 @@ export class NavService {
hideNavBar() {
this.navbarVisibleSource.next(false);
}
toggleDarkMode() {
this.darkMode = !this.darkMode;
this.updateColorScheme();
this.darkModeSource.next(this.darkMode);
}
setDarkMode(mode: boolean) {
this.darkMode = mode;
this.updateColorScheme();
this.darkModeSource.next(this.darkMode);
}
private updateColorScheme() {
if (this.darkMode) {
this.renderer.setStyle(document.querySelector('html'), 'color-scheme', 'dark');
} else {
this.renderer.setStyle(document.querySelector('html'), 'color-scheme', 'light');
}
}
}

View File

@ -1,30 +1,25 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Choose a Directory</h4>
<button type="button" class="close" aria-label="Close" (click)="close()">
<span aria-hidden="true">&times;</span>
</button>
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="filter">Filter</label>
<div class="mb-3">
<label for="filter" class="form-label">Filter</label>
<div class="input-group">
<input id="filter" autocomplete="off" class="form-control" [(ngModel)]="filterQuery" type="text" aria-describedby="reset-input">
<div class="input-group-append">
<button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="filterQuery = '';">Clear</button>
</div>
<button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="filterQuery = '';">Clear</button>
</div>
</div>
<nav aria-label="directory breadcrumb">
<ol class="breadcrumb" *ngIf="routeStack.peek() !== undefined; else noBreadcrumb">
<li class="breadcrumb-item {{route === routeStack.peek() ? 'active' : ''}}" *ngFor="let route of routeStack.items; let index = index">
<li class="breadcrumb-item {{route === routeStack.peek() ? 'active' : ''}}" *ngFor="let route of routeStack.items; let index = index">
<ng-container *ngIf="route === routeStack.peek(); else nonActive">
{{route}}
</ng-container>
<ng-template #nonActive>
<a href="javascript:void(0);" (click)="navigateTo(index)">{{route}}</a>
</ng-template>
</li>
</li>
</ol>
<ng-template #noBreadcrumb>
<div class="breadcrumb">Select a folder to view breadcrumb. Don't see your directory, try checking / first.</div>
@ -33,17 +28,17 @@
<ul class="list-group">
<div class="list-group-item list-group-item-action">
<button (click)="goBack()" class="btn btn-secondary" [disabled]="routeStack.peek() === undefined">
<i class="fa fa-arrow-left mr-2" aria-hidden="true"></i>
<i class="fa fa-arrow-left me-2" aria-hidden="true"></i>
Back
</button>
<button type="button" class="btn btn-primary float-right" [disabled]="routeStack.peek() === undefined" (click)="shareFolder('', $event)">Share</button>
<button type="button" class="btn btn-primary float-end" [disabled]="routeStack.peek() === undefined" (click)="shareFolder('', $event)">Share</button>
</div>
</ul>
<ul class="list-group scrollable">
<button *ngFor="let folder of folders | filter: filterFolder" class="list-group-item list-group-item-action" (click)="selectNode(folder)">
<span>{{getStem(folder)}}</span>
<button type="button" class="btn btn-primary float-right" (click)="shareFolder(folder, $event)">Share</button>
<button type="button" class="btn btn-primary float-end" (click)="shareFolder(folder, $event)">Share</button>
</button>
<div class="list-group-item text-center" *ngIf="folders.length === 0">
There are no folders here
@ -51,6 +46,6 @@
</ul>
</div>
<div class="modal-footer">
<a class="btn btn-info" *ngIf="helpUrl.length > 0" href="{{helpUrl}}" target="_blank">Help</a>
<a class="btn btn-icon" *ngIf="helpUrl.length > 0" href="{{helpUrl}}" target="_blank">Help</a>
<button type="button" class="btn btn-secondary" (click)="close()">Cancel</button>
</div>
</div>

View File

@ -1,8 +1,8 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Library Access</h4>
<button type="button" class="close" aria-label="Close" (click)="close()">
<span aria-hidden="true">&times;</span>
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
</button>
</div>
<div class="modal-body">

View File

@ -2,35 +2,35 @@
<form [formGroup]="libraryForm">
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{this.library !== undefined ? 'Edit' : 'New'}} Library</h4>
<button type="button" class="close" aria-label="Close" (click)="close()">
<span aria-hidden="true">&times;</span>
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
</button>
</div>
<div class="modal-body">
<div class="alert alert-info" *ngIf="errorMessage !== ''">
<strong>Error: </strong> {{errorMessage}}
</div>
<div class="form-group">
<label for="library-name">Name</label>
<div class="mb-3">
<label for="library-name" class="form-label">Name</label>
<input id="library-name" class="form-control" formControlName="name" type="text">
</div>
<div class="form-group">
<label for="library-type">Type</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="typeTooltip" role="button" tabindex="0"></i>
<div class="mb-3">
<label for="library-type" class="form-label">Type</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="typeTooltip" role="button" tabindex="0"></i>
<ng-template #typeTooltip>Library type determines how filenames are parsed and if the UI shows Chapters (Manga) vs Issues (Comics). Book work the same way as Manga but fall back to embedded data.</ng-template>
<span class="sr-only" id="library-type-help">Library type determines how filenames are parsed and if the UI shows Chapters (Manga) vs Issues (Comics). Book work the same way as Manga but fall back to embedded data.</span>
<select class="form-control" id="library-type" formControlName="type" [attr.disabled]="this.library" aria-describedby="library-type-help">
<span class="visually-hidden" id="library-type-help">Library type determines how filenames are parsed and if the UI shows Chapters (Manga) vs Issues (Comics). Book work the same way as Manga but fall back to embedded data.</span>
<select class="form-select" id="library-type" formControlName="type" [attr.disabled]="this.library" aria-describedby="library-type-help">
<option [value]="i" *ngFor="let opt of libraryTypes; let i = index">{{opt}}</option>
</select>
</div>
<h4>Folders <button type="button" class="btn float-right btn-sm" (click)="openDirectoryPicker()"><i class="fa fa-plus" aria-hidden="true"></i></button></h4>
<h4>Folders <button type="button" class="btn float-end btn-sm" (click)="openDirectoryPicker()"><i class="fa fa-plus" aria-hidden="true"></i></button></h4>
<ul class="list-group" style="width: 100%">
<li class="list-group-item" *ngFor="let folder of selectedFolders; let i = index">
{{folder}}
<button class="btn float-right btn-sm" (click)="removeFolder(folder)"><i class="fa fa-times-circle" aria-hidden="true"></i></button>
<button class="btn float-end btn-sm" (click)="removeFolder(folder)"><i class="fa fa-times-circle" aria-hidden="true"></i></button>
</li>
</ul>
</div>

View File

@ -1,16 +1,16 @@
<form [formGroup]="resetPasswordForm">
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Reset {{member.username | sentenceCase}}'s Password</h4>
<button type="button" class="close" aria-label="Close" (click)="close()">
<span aria-hidden="true">&times;</span>
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
</button>
</div>
<div class="modal-body">
<div class="alert alert-info" *ngIf="errorMessage !== ''">
<strong>Error: </strong> {{errorMessage}}
</div>
<div class="form-group">
<label for="password">New Password</label>
<div class="mb-3">
<label for="password" class="form-label">New Password</label>
<input id="password" class="form-control" minlength="4" formControlName="password" type="password">
</div>
</div>

View File

@ -3,13 +3,13 @@
<div class="card w-100 mb-2" style="width: 18rem;">
<div class="card-body">
<h4 class="card-title">{{update.updateTitle}}&nbsp;
<span class="badge badge-secondary" *ngIf="update.updateVersion === installedVersion">Installed</span>
<span class="badge badge-secondary" *ngIf="update.updateVersion > installedVersion">Available</span>
<span class="badge bg-secondary" *ngIf="update.updateVersion === installedVersion">Installed</span>
<span class="badge bg-secondary" *ngIf="update.updateVersion > installedVersion">Available</span>
</h4>
<h6 class="card-subtitle mb-2 text-muted">Published: {{update.publishDate | date: 'short'}}</h6>
<pre class="card-text update-body" [innerHtml]="update.updateBody | safeHtml"></pre>
<a *ngIf="!update.isDocker" href="{{update.updateUrl}}" class="btn btn-{{indx === 0 ? 'primary' : 'secondary'}} float-right" target="_blank">Download</a>
<a *ngIf="!update.isDocker" href="{{update.updateUrl}}" class="btn btn-{{indx === 0 ? 'primary' : 'secondary'}} float-end" target="_blank">Download</a>
</div>
</div>
</ng-container>

View File

@ -1,7 +1,7 @@
<div class="container">
<h2>Admin Dashboard</h2>
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-tabs nav-pills">
<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>

View File

@ -0,0 +1,3 @@
.container {
padding-top: 10px;
}

View File

@ -1,16 +1,16 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Edit {{member.username | sentenceCase}}</h4>
<button type="button" class="close" aria-label="Close" (click)="close()">
<span aria-hidden="true">&times;</span>
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
</button>
</div>
<div class="modal-body">
<form [formGroup]="userForm">
<div class="row no-gutters">
<div class="col-md-6 col-sm-12 pr-2">
<div class="form-group">
<label for="username">Username</label>
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input id="username" class="form-control" formControlName="username" type="text">
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="userForm.dirty || userForm.touched">
<div *ngIf="userForm.get('username')?.errors?.required">
@ -20,8 +20,8 @@
</div>
</div>
<div class="col-md-6 col-sm-12">
<div class="form-group" style="width:100%">
<label for="email">Email</label>
<div class="mb-3" style="width:100%">
<label for="email" class="form-label">Email</label>
<input class="form-control" type="email" id="email" formControlName="email" [disabled]="true">
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="userForm.dirty || userForm.touched">
<div *ngIf="userForm.get('email')?.errors?.required">
@ -35,7 +35,7 @@
</div>
</div>
<div class="row no-gutters">
<div class="row g-0">
<div class="col-md-6">
<app-role-selector (selected)="updateRoleSelection($event)" [allowAdmin]="true" [member]="member"></app-role-selector>
</div>

View File

@ -1,7 +1,7 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Invite User</h4>
<button type="button" class="close" aria-label="Close" (click)="close()">
<span aria-hidden="true">&times;</span>
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
</button>
</div>
<div class="modal-body">
@ -16,9 +16,9 @@
<form [formGroup]="inviteForm">
<div class="row no-gutters">
<div class="form-group" style="width:100%">
<label for="email">Email</label>
<div class="row g-0">
<div class="mb-3" style="width:100%">
<label for="email" class="form-label">Email</label>
<input class="form-control" type="email" id="email" formControlName="email" required>
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="inviteForm.dirty || inviteForm.touched">
<div *ngIf="email?.errors?.required">
@ -33,7 +33,7 @@
<a class="email-link" href="{{emailLink}}" target="_blank">{{emailLink}}</a>
</ng-container>
<div class="row no-gutters">
<div class="row g-0">
<div class="col-md-6">
<app-role-selector (selected)="updateRoleSelection($event)" [allowAdmin]="true"></app-role-selector>
</div>

View File

@ -1,19 +1,19 @@
<div class="container-fluid">
<div class="row mb-2">
<div class="col-8"><h3>Libraries</h3></div>
<div class="col-4"><button class="btn btn-primary float-right" (click)="addLibrary()"><i class="fa fa-plus" aria-hidden="true"></i><span class="phone-hidden">&nbsp;Add Library</span></button></div>
<div class="col-4"><button class="btn btn-primary float-end" (click)="addLibrary()"><i class="fa fa-plus" aria-hidden="true"></i><span class="phone-hidden">&nbsp;Add Library</span></button></div>
</div>
<ul class="list-group" *ngIf="!createLibraryToggle; else createLibrary">
<li *ngFor="let library of libraries; let idx = index; trackby: trackbyLibrary" class="list-group-item">
<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>&nbsp;
<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="sr-only">Scan for {{library.name}} in progress</span>
<span class="visually-hidden">Scan for {{library.name}} in progress</span>
</div>
<div class="float-right">
<button class="btn btn-secondary mr-2 btn-sm" (click)="scanLibrary(library)" placement="top" ngbTooltip="Scan Library" attr.aria-label="Scan Library"><i class="fa fa-sync-alt" title="Scan"></i></button>
<button class="btn btn-danger mr-2 btn-sm" [disabled]="deletionInProgress" (click)="deleteLibrary(library)"><i class="fa fa-trash" placement="top" ngbTooltip="Delete Library" attr.aria-label="Delete {{library.name | sentenceCase}}"></i></button>
<div class="float-end">
<button class="btn btn-secondary me-2 btn-sm" (click)="scanLibrary(library)" placement="top" ngbTooltip="Scan Library" attr.aria-label="Scan Library"><i class="fa fa-sync-alt" title="Scan"></i></button>
<button class="btn btn-danger me-2 btn-sm" [disabled]="deletionInProgress" (click)="deleteLibrary(library)"><i class="fa fa-trash" placement="top" ngbTooltip="Delete Library" attr.aria-label="Delete {{library.name | sentenceCase}}"></i></button>
<button class="btn btn-primary btn-sm" (click)="editLibrary(library)"><i class="fa fa-pen" placement="top" ngbTooltip="Edit" attr.aria-label="Edit {{library.name | sentenceCase}}"></i></button>
</div>
</h4>

View File

@ -1,65 +1,63 @@
<div class="container-fluid">
<form [formGroup]="settingsForm" *ngIf="serverSettings !== undefined">
<p class="text-warning pt-2">Port and Logging Level require a manual restart of Kavita to take effect.</p>
<div class="form-group">
<label for="settings-cachedir">Cache Directory</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="cacheDirectoryTooltip" role="button" tabindex="0"></i>
<div class="mb-3">
<label for="settings-cachedir" class="form-label">Cache Directory</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="cacheDirectoryTooltip" role="button" tabindex="0"></i>
<ng-template #cacheDirectoryTooltip>Where the server place temporary files when reading. This will be cleaned up on a regular basis.</ng-template>
<span class="sr-only" id="settings-cachedir-help">Where the server place temporary files when reading. This will be cleaned up on a regular basis.</span>
<span class="visually-hidden" id="settings-cachedir-help">Where the server place temporary files when reading. This will be cleaned up on a regular basis.</span>
<input readonly id="settings-cachedir" aria-describedby="settings-cachedir-help" class="form-control" formControlName="cacheDirectory" type="text">
</div>
<div class="form-group">
<label for="settings-bookmarksdir">Bookmarks Directory</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="bookmarksDirectoryTooltip" role="button" tabindex="0"></i>
<div class="mb-3">
<label for="settings-bookmarksdir" class="form-label">Bookmarks Directory</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="bookmarksDirectoryTooltip" role="button" tabindex="0"></i>
<ng-template #bookmarksDirectoryTooltip>Location where bookmarks will be stored. Bookmarks are source files and can be large. Choose a location with adequate storage. Directory is managed, other files within directory will be deleted.</ng-template>
<span class="sr-only" id="settings-bookmarksdir-help"><ng-container [ngTemplateOutlet]="bookmarksDirectoryTooltip"></ng-container></span>
<span class="visually-hidden" id="settings-bookmarksdir-help"><ng-container [ngTemplateOutlet]="bookmarksDirectoryTooltip"></ng-container></span>
<div class="input-group">
<input readonly id="settings-bookmarksdir" aria-describedby="settings-bookmarksdir-help" class="form-control" formControlName="bookmarksDirectory" type="text" aria-describedby="change-bookmarks-dir">
<div class="input-group-append">
<button id="change-bookmarks-dir" class="btn btn-primary" (click)="openDirectoryChooser(settingsForm.get('bookmarksDirectory')?.value, 'bookmarksDirectory')">
Change
</button>
</div>
<button id="change-bookmarks-dir" class="btn btn-primary" (click)="openDirectoryChooser(settingsForm.get('bookmarksDirectory')?.value, 'bookmarksDirectory')">
Change
</button>
</div>
</div>
<!-- <div class="form-group">
<!-- <div class="mb-3">
<label for="settings-baseurl">Base Url</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="baseUrlTooltip" role="button" tabindex="0"></i>
<ng-template #baseUrlTooltip>Use this if you want to host Kavita on a base url ie) yourdomain.com/kavita</ng-template>
<span class="sr-only" id="settings-baseurl-help">Use this if you want to host Kavita on a base url ie) yourdomain.com/kavita</span>
<span class="visually-hidden" id="settings-baseurl-help">Use this if you want to host Kavita on a base url ie) yourdomain.com/kavita</span>
<input id="settings-baseurl" aria-describedby="settings-baseurl-help" class="form-control" formControlName="baseUrl" type="text">
</div> -->
<div class="row no-gutters">
<div class="form-group col-md-6 col-sm-12 pr-2">
<label for="settings-port">Port</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="portTooltip" role="button" tabindex="0"></i>
<div class="row g-0 mb-2">
<div class="form-group col-md-6 col-sm-12 pe-2">
<label for="settings-port" class="form-label">Port</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="portTooltip" role="button" tabindex="0"></i>
<ng-template #portTooltip>Port the server listens on. This is fixed if you are running on Docker. Requires restart to take effect.</ng-template>
<span class="sr-only" id="settings-port-help">Port the server listens on. This is fixed if you are running on Docker. Requires restart to take effect.</span>
<span class="visually-hidden" id="settings-port-help">Port the server listens on. This is fixed if you are running on Docker. Requires restart to take effect.</span>
<input id="settings-port" aria-describedby="settings-port-help" class="form-control" formControlName="port" type="number" step="1" min="1" onkeypress="return event.charCode >= 48 && event.charCode <= 57">
</div>
<div class="form-group col-md-6 col-sm-12">
<label for="logging-level-port">Logging Level</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="loggingLevelTooltip" role="button" tabindex="0"></i>
<label for="logging-level-port" class="form-label">Logging Level</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="loggingLevelTooltip" role="button" tabindex="0"></i>
<ng-template #loggingLevelTooltip>Use debug to help identify issues. Debug can eat up a lot of disk space. Requires restart to take effect.</ng-template>
<span class="sr-only" id="logging-level-port-help">Port the server listens on. Requires restart to take effect.</span>
<select id="logging-level-port" aria-describedby="logging-level-port-help" class="form-control" aria-describedby="settings-tasks-scan-help" formControlName="loggingLevel">
<span class="visually-hidden" id="logging-level-port-help">Port the server listens on. Requires restart to take effect.</span>
<select id="logging-level-port" aria-describedby="logging-level-port-help" class="form-select" aria-describedby="settings-tasks-scan-help" formControlName="loggingLevel">
<option *ngFor="let level of logLevels" [value]="level">{{level | titlecase}}</option>
</select>
</div>
</div>
<div class="form-group">
<label for="stat-collection" aria-describedby="collection-info">Allow Anonymous Usage Collection</label>
<div class="mb-3">
<label for="stat-collection" class="form-label" aria-describedby="collection-info">Allow Anonymous Usage Collection</label>
<p class="accent" id="collection-info">Send anonymous usage and error information to Kavita's servers. This includes information on your browser, error reporting as well as OS and runtime version. We will use this information to prioritize features, bug fixes, and preformance tuning. Requires restart to take effect.</p>
<div class="form-check">
<div class="form-check form-switch">
<input id="stat-collection" type="checkbox" aria-label="Stat Collection" class="form-check-input" formControlName="allowStatCollection">
<label for="stat-collection" class="form-check-label">Send Data</label>
</div>
</div>
<div class="form-group">
<label for="opds" aria-describedby="opds-info">OPDS</label>
<div class="mb-3">
<label for="opds" aria-describedby="opds-info" class="form-label">OPDS</label>
<p class="accent" id="opds-info">OPDS support will allow all users to use OPDS to read and download content from the server. If OPDS is enabled, a user will not need download permissions to download media while using it.</p>
<div class="form-check">
<div class="form-check form-switch">
<input id="opds" type="checkbox" aria-label="OPDS Support" class="form-check-input" formControlName="enableOpds">
<label for="opds" class="form-check-label">Enable OPDS</label>
</div>
@ -70,47 +68,45 @@
email service. Set the url of the email service and use the Test button to ensure it works. At any time you can reset to ours. There is no way to disable emails althought confirmation links will always
be saved to logs.
</p>
<div class="form-group">
<label for="settings-emailservice">Email Service Url</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="emailServiceTooltip" role="button" tabindex="0"></i>
<div class="mb-3">
<label for="settings-emailservice" class="form-label">Email Service Url</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="emailServiceTooltip" role="button" tabindex="0"></i>
<ng-template #emailServiceTooltip>Use fully qualified url of the email service. Do not include ending slash.</ng-template>
<span class="sr-only" id="settings-emailservice-help"><ng-container [ngTemplateOutlet]="emailServiceTooltip"></ng-container></span>
<span class="visually-hidden" id="settings-emailservice-help"><ng-container [ngTemplateOutlet]="emailServiceTooltip"></ng-container></span>
<div class="input-group">
<input id="settings-emailservice" aria-describedby="settings-emailservice-help" class="form-control" formControlName="emailServiceUrl" type="text" aria-describedby="change-bookmarks-dir">
<div class="input-group-append">
<button class="btn btn-secondary" (click)="resetEmailServiceUrl()">
Reset
</button>
<button class="btn btn-secondary" (click)="testEmailServiceUrl()">
Test
</button>
</div>
<button class="btn btn-outline-secondary" (click)="resetEmailServiceUrl()">
Reset
</button>
<button class="btn btn-outline-secondary" (click)="testEmailServiceUrl()">
Test
</button>
</div>
</div>
<h4>Reoccuring Tasks</h4>
<div class="form-group">
<label for="settings-tasks-scan">Library Scan</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="taskScanTooltip" role="button" tabindex="0"></i>
<div class="mb-3">
<label for="settings-tasks-scan" class="form-label">Library Scan</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="taskScanTooltip" role="button" tabindex="0"></i>
<ng-template #taskScanTooltip>How often Kavita will scan and refresh metatdata around manga files.</ng-template>
<span class="sr-only" id="settings-tasks-scan-help">How often Kavita will scan and refresh metatdata around manga files.</span>
<select class="form-control" aria-describedby="settings-tasks-scan-help" formControlName="taskScan" id="settings-tasks-scan">
<span class="visually-hidden" id="settings-tasks-scan-help">How often Kavita will scan and refresh metatdata around manga files.</span>
<select class="form-select" aria-describedby="settings-tasks-scan-help" formControlName="taskScan" id="settings-tasks-scan">
<option *ngFor="let freq of taskFrequencies" [value]="freq">{{freq | titlecase}}</option>
</select>
</div>
<div class="form-group">
<label for="settings-tasks-backup">Library Database Backup</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="taskBackupTooltip" role="button" tabindex="0"></i>
<div class="mb-3">
<label for="settings-tasks-backup" class="form-label">Library Database Backup</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="taskBackupTooltip" role="button" tabindex="0"></i>
<ng-template #taskBackupTooltip>How often Kavita will backup the database.</ng-template>
<span class="sr-only" id="settings-tasks-backup-help">How often Kavita will backup the database.</span>
<select class="form-control" aria-describedby="settings-tasks-backup-help" formControlName="taskBackup" id="settings-tasks-backup">
<span class="visually-hidden" id="settings-tasks-backup-help">How often Kavita will backup the database.</span>
<select class="form-select" aria-describedby="settings-tasks-backup-help" formControlName="taskBackup" id="settings-tasks-backup">
<option *ngFor="let freq of taskFrequencies" [value]="freq">{{freq | titlecase}}</option>
</select>
</div>
<div class="float-right">
<button type="button" class="btn btn-secondary mr-2" (click)="resetToDefaults()">Reset to Default</button>
<button type="button" class="btn btn-secondary mr-2" (click)="resetForm()">Reset</button>
<button type="submit" class="btn btn-primary" (click)="saveSettings()" [disabled]="!settingsForm.touched && !settingsForm.dirty">Save</button>
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end">
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetToDefaults()">Reset to Default</button>
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetForm()">Reset</button>
<button type="submit" class="flex-fill btn btn-primary" (click)="saveSettings()" [disabled]="!settingsForm.touched && !settingsForm.dirty">Save</button>
</div>
</form>
</div>

View File

@ -1,11 +1,11 @@
<div class="container-fluid">
<div class="float-right">
<div class="float-end">
<div class="d-inline-block" ngbDropdown #myDrop="ngbDropdown">
<button class="btn btn-outline-primary mr-2" id="dropdownManual" ngbDropdownToggle>
<button class="btn btn-outline-primary me-2" id="dropdownManual" ngbDropdownToggle>
<ng-container *ngIf="backupDBInProgress || clearCacheInProgress || isCheckingForUpdate || downloadLogsInProgress">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span class="sr-only">Loading...</span>
<span class="visually-hidden">Loading...</span>
</ng-container>
Actions
</button>
@ -28,7 +28,7 @@
<h3>About System</h3>
<hr/>
<div class="form-group" *ngIf="serverInfo">
<div class="mb-3" *ngIf="serverInfo">
<dl>
<dt>Version</dt>
<dd>{{serverInfo.kavitaVersion}}</dd>

View File

@ -4,28 +4,28 @@
<ng-container>
<div class="row mb-2">
<div class="col-8"><h3>Pending Invites</h3></div>
<div class="col-4"><button class="btn btn-primary float-right" (click)="inviteUser()"><i class="fa fa-plus" aria-hidden="true"></i><span class="phone-hidden">&nbsp;Invite</span></button></div>
<div class="col-4"><button class="btn btn-primary float-end" (click)="inviteUser()"><i class="fa fa-plus" aria-hidden="true"></i><span class="phone-hidden">&nbsp;Invite</span></button></div>
</div>
<ul class="list-group">
<li class="list-group-item" *ngFor="let invite of pendingInvites; let idx = index;">
<li class="list-group-item no-hover" *ngFor="let invite of pendingInvites; let idx = index;">
<div>
<h4>
<span id="member-name--{{idx}}">{{invite.username | titlecase}} </span>
<div class="float-right">
<button class="btn btn-danger mr-2" (click)="deleteUser(invite)">Cancel</button>
<button class="btn btn-secondary mr-2" (click)="resendEmail(invite)">Resend</button>
<div class="float-end">
<button class="btn btn-danger me-2" (click)="deleteUser(invite)">Cancel</button>
<button class="btn btn-secondary me-2" (click)="resendEmail(invite)">Resend</button>
</div>
</h4>
<div>Invited: {{invite.created | date: 'short'}}</div>
</div>
</li>
<li *ngIf="loadingMembers" class="list-group-item">
<li *ngIf="loadingMembers" class="list-group-item no-hover">
<div class="spinner-border text-secondary" role="status">
<span class="invisible">Loading...</span>
</div>
</li>
<li class="list-group-item" *ngIf="pendingInvites.length === 0 && !loadingMembers">
<li class="list-group-item no-hover" *ngIf="pendingInvites.length === 0 && !loadingMembers">
There are no invited Users
</li>
</ul>
@ -35,18 +35,18 @@
<h3 class="mt-3">Active Users</h3>
<ul class="list-group">
<li *ngFor="let member of members; let idx = index;" class="list-group-item">
<li *ngFor="let member of members; let idx = index;" class="list-group-item no-hover">
<div>
<h4>
<i class="presence fa fa-circle" title="Active" aria-hidden="true" *ngIf="false && (messageHub.onlineUsers$ | async)?.includes(member.username)"></i>
<span id="member-name--{{idx}}">{{member.username | titlecase}} </span>
<span *ngIf="member.username === loggedInUsername">
<i class="fas fa-star" aria-hidden="true"></i>
<span class="sr-only">(You)</span>
<span class="visually-hidden">(You)</span>
</span>
<div class="float-right" *ngIf="canEditMember(member)">
<button class="btn btn-danger mr-2" (click)="deleteUser(member)" placement="top" ngbTooltip="Delete User" attr.aria-label="Delete User {{member.username | titlecase}}"><i class="fa fa-trash" aria-hidden="true"></i></button>
<button class="btn btn-secondary mr-2" (click)="updatePassword(member)" placement="top" ngbTooltip="Change Password" attr.aria-label="Change Password for {{member.username | titlecase}}"><i class="fa fa-key" aria-hidden="true"></i></button>
<div class="float-end" *ngIf="canEditMember(member)">
<button class="btn btn-danger me-2" (click)="deleteUser(member)" placement="top" ngbTooltip="Delete User" attr.aria-label="Delete User {{member.username | titlecase}}"><i class="fa fa-trash" aria-hidden="true"></i></button>
<button class="btn btn-secondary me-2" (click)="updatePassword(member)" placement="top" ngbTooltip="Change Password" attr.aria-label="Change Password for {{member.username | titlecase}}"><i class="fa fa-key" aria-hidden="true"></i></button>
<button class="btn btn-primary" (click)="openEditUser(member)" placement="top" ngbTooltip="Edit" attr.aria-label="Edit {{member.username | titlecase}}"><i class="fa fa-pen" aria-hidden="true"></i></button>
</div>
</h4>
@ -57,10 +57,10 @@
</ng-template>
</div>
<div *ngIf="!hasAdminRole(member)">Sharing: {{formatLibraries(member)}}</div>
<div>
<div class="row g-0">
Roles: <span *ngIf="getRoles(member).length === 0; else showRoles">None</span>
<ng-template #showRoles>
<app-tag-badge *ngFor="let role of getRoles(member)">{{role}}</app-tag-badge>
<app-tag-badge *ngFor="let role of getRoles(member)" class="col-auto">{{role}}</app-tag-badge>
</ng-template>
</div>
</div>

View File

@ -11,6 +11,7 @@ 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';
// TODO: Once we modularize the components, use this and measure performance impact: https://angular.io/guide/lazy-loading-ngmodules#preloading-modules
@ -71,6 +72,7 @@ const routes: Routes = [
},
{path: 'login', component: UserLoginComponent}, // TODO: move this to registration module
{path: 'no-connection', component: NotConnectedComponent},
{path: 'theme', component: ThemeTestComponent},
{path: '**', component: UserLoginComponent, pathMatch: 'full'}
];

View File

@ -1,5 +1,5 @@
<app-nav-header></app-nav-header>
<div [ngStyle]="(navService?.navbarVisible$ | async) ? {'padding-top': 'calc(56px + 5px)', 'height': '100%'} : {}">
<div [ngStyle]="(navService?.navbarVisible$ | async) ? {'padding-top': 'calc(57px)', 'height': '100%'} : {}">
<a id="content"></a>
<router-outlet></router-outlet>
</div>

View File

@ -1,4 +1,4 @@
import { Component, OnInit } from '@angular/core';
import { Component, HostListener, Inject, OnInit } from '@angular/core';
import { NavigationStart, Router } from '@angular/router';
import { take } from 'rxjs/operators';
import { AccountService } from './_services/account.service';
@ -7,6 +7,8 @@ import { MessageHubService } from './_services/message-hub.service';
import { NavService } from './_services/nav.service';
import { filter } from 'rxjs/operators';
import { NgbModal, NgbRatingConfig } from '@ng-bootstrap/ng-bootstrap';
import { DOCUMENT } from '@angular/common';
import { ThemeService } from './theme.service';
@Component({
selector: 'app-root',
@ -17,7 +19,8 @@ export class AppComponent implements OnInit {
constructor(private accountService: AccountService, public navService: NavService,
private messageHub: MessageHubService, private libraryService: LibraryService,
private router: Router, private ngbModal: NgbModal, private ratingConfig: NgbRatingConfig) {
router: Router, private ngbModal: NgbModal, ratingConfig: NgbRatingConfig,
@Inject(DOCUMENT) private document: Document) {
// Setup default rating config
ratingConfig.max = 5;
@ -33,22 +36,34 @@ export class AppComponent implements OnInit {
});
}
ngOnInit(): void {
this.setCurrentUser();
@HostListener('resize')
onResize() {
this.setDocHeight();
}
@HostListener('orientationchange')
onOrientationChange() {
this.setDocHeight();
}
ngOnInit(): void {
this.setCurrentUser();
this.setDocHeight();
}
setCurrentUser() {
const user = this.accountService.getUserFromLocalStorage();
this.accountService.setCurrentUser(user);
if (user) {
this.navService.setDarkMode(user.preferences.siteDarkMode);
this.messageHub.createHubConnection(user, this.accountService.hasAdminRole(user));
this.libraryService.getLibraryNames().pipe(take(1)).subscribe(() => {/* No Operation */});
} else {
this.navService.setDarkMode(true);
}
}
}
setDocHeight() {
// Sets a CSS variable for the actual device viewport height. Needed for mobile dev.
this.document.documentElement.style.setProperty('--vh', `${window.innerHeight/100}px`);
}
}

View File

@ -37,6 +37,7 @@ import { AllSeriesComponent } from './all-series/all-series.component';
import { PublicationStatusPipe } from './publication-status.pipe';
import { RegistrationModule } from './registration/registration.module';
import { GroupedTypeaheadComponent } from './grouped-typeahead/grouped-typeahead.component';
import { ThemeTestComponent } from './theme-test/theme-test.component';
@NgModule({
@ -58,6 +59,7 @@ import { GroupedTypeaheadComponent } from './grouped-typeahead/grouped-typeahead
SeriesMetadataDetailComponent,
AllSeriesComponent,
GroupedTypeaheadComponent,
ThemeTestComponent,
],
imports: [
HttpClientModule,

View File

@ -1,12 +1,12 @@
<div class="container-flex {{darkMode ? 'dark-mode' : ''}} reader-container" tabindex="0" #reader>
<div class="fixed-top" #stickyTop>
<a class="sr-only sr-only-focusable focus-visible" href="javascript:void(0);" (click)="moveFocus()">Skip to main content</a>
<a class="visually-hidden-focusable focus-visible" href="javascript:void(0);" (click)="moveFocus()">Skip to main content</a>
<ng-container [ngTemplateOutlet]="actionBar"></ng-container>
<app-drawer #commentDrawer="drawer" [isOpen]="drawerOpen" [style.--drawer-width]="'300px'" [options]="{topOffset: topOffset}" [style.--drawer-background-color]="drawerBackgroundColor" (drawerClosed)="closeDrawer()">
<div header>
<h2 style="margin-top: 0.5rem">Book Settings
<button type="button" class="close" aria-label="Close" (click)="commentDrawer.close()">
<span aria-hidden="true">&times;</span>
<button type="button" class="btn-close" aria-label="Close" (click)="commentDrawer.close()">
</button>
</h2>
@ -16,8 +16,8 @@
<div class="controls">
<form [formGroup]="settingsForm">
<div class="form-group">
<label for="library-type">Font Family</label>
<div class="mb-3">
<label for="library-type" class="form-label">Font Family</label>
<select class="form-control" id="library-type" formControlName="bookReaderFontFamily">
<option [value]="opt" *ngFor="let opt of fontFamilies; let i = index">{{opt | titlecase}}</option>
</select>
@ -25,42 +25,42 @@
</form>
</div>
<div class="controls">
<label id="fontsize">Font Size</label>
<label id="fontsize" class="form-label">Font Size</label>
<button (click)="updateFontSize(-10)" class="btn btn-icon" title="Decrease" aria-labelledby="fontsize"><i class="fa fa-minus" aria-hidden="true"></i></button>
<span>{{pageStyles['font-size']}}</span>
<button (click)="updateFontSize(10)" class="btn btn-icon" title="Increase" aria-labelledby="fontsize"><i class="fa fa-plus" aria-hidden="true"></i></button>
</div>
<div class="controls">
<label id="linespacing">Line Spacing</label>
<label id="linespacing" class="form-label">Line Spacing</label>
<button (click)="updateLineSpacing(-10)" class="btn btn-icon" title="Decrease" aria-labelledby="linespacing"><i class="fa fa-minus" aria-hidden="true"></i></button>
<span>{{pageStyles['line-height']}}</span>
<button (click)="updateLineSpacing(10)" class="btn btn-icon" title="Increase" aria-labelledby="linespacing"><i class="fa fa-plus" aria-hidden="true"></i></button>
</div>
<div class="controls">
<label id="margin">Margin</label>
<label id="margin" class="form-label">Margin</label>
<button (click)="updateMargin(-5)" class="btn btn-icon" title="Remove Margin" aria-labelledby="margin"><i class="fa fa-minus" aria-hidden="true"></i></button>
<span>{{pageStyles['margin-right']}}</span>
<button (click)="updateMargin(5)" class="btn btn-icon" title="Add Margin" aria-labelledby="margin"><i class="fa fa-plus" aria-hidden="true"></i></button>
</div>
<div class="controls">
<label id="readingdirection">Reading Direction</label>
<label id="readingdirection" class="form-label">Reading Direction</label>
<button (click)="toggleReadingDirection()" class="btn btn-icon" aria-labelledby="readingdirection" title="{{readingDirection === 0 ? 'Left to Right' : 'Right to Left'}}"><i class="fa {{readingDirection === 0 ? 'fa-arrow-right' : 'fa-arrow-left'}} " aria-hidden="true"></i><span class="phone-hidden">&nbsp;{{readingDirection === 0 ? 'Left to Right' : 'Right to Left'}}</span></button>
</div>
<div class="controls">
<label id="darkmode">Dark Mode</label>
<label id="darkmode" class="form-label">Dark Mode</label>
<button (click)="toggleDarkMode(false)" class="btn btn-icon" aria-labelledby="darkmode" title="Off"><i class="fa fa-sun" aria-hidden="true"></i></button>
<button (click)="toggleDarkMode(true)" class="btn btn-icon" aria-labelledby="darkmode" title="On"><i class="fa fa-moon" aria-hidden="true"></i></button>
</div>
<div class="controls">
<label id="tap-pagination">Tap Pagination&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="top" [ngbTooltip]="tapPaginationTooltip" role="button" tabindex="0" aria-describedby="tap-pagination-help"></i></label>
<label id="tap-pagination" class="form-label">Tap Pagination&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="top" [ngbTooltip]="tapPaginationTooltip" role="button" tabindex="0" aria-describedby="tap-pagination-help"></i></label>
<ng-template #tapPaginationTooltip>The ability to click the sides of the page to page left and right</ng-template>
<span class="sr-only" id="tap-pagination-help">The ability to click the sides of the page to page left and right</span>
<span class="visually-hidden" id="tap-pagination-help">The ability to click the sides of the page to page left and right</span>
<button (click)="toggleClickToPaginate()" class="btn btn-icon" aria-labelledby="tap-pagination"><i class="fa fa-arrows-alt-h {{clickToPaginate ? 'icon-primary-color' : ''}}" aria-hidden="true"></i><span *ngIf="darkMode">&nbsp;{{clickToPaginate ? 'On' : 'Off'}}</span></button>
</div>
<div class="controls">
<label id="fullscreen">Fullscreen&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="top" [ngbTooltip]="fullscreenTooltip" role="button" tabindex="0" aria-describedby="fullscreen-help"></i></label>
<label id="fullscreen" class="form-label">Fullscreen&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="top" [ngbTooltip]="fullscreenTooltip" role="button" tabindex="0" aria-describedby="fullscreen-help"></i></label>
<ng-template #fullscreenTooltip>Put reader in fullscreen mode</ng-template>
<span class="sr-only" id="fullscreen-help">
<span class="visually-hidden" id="fullscreen-help">
<ng-container [ngTemplateOutlet]="fullscreenTooltip"></ng-container>
</span>
<button (click)="toggleFullscreen()" class="btn btn-icon" aria-labelledby="fullscreen">
@ -68,11 +68,11 @@
<span *ngIf="darkMode">&nbsp;{{isFullscreen ? 'Exit' : 'Enter'}}</span>
</button>
</div>
<div class="row no-gutters justify-content-between">
<div class="row g-0 justify-content-between">
<button (click)="resetSettings()" class="btn btn-primary col">Reset to Defaults</button>
</div>
</div>
<div class="row no-gutters">
<div class="row g-0">
<button class="btn btn-small btn-icon col-1" [disabled]="prevChapterDisabled" (click)="loadPrevChapter()" title="Prev Chapter/Volume"><i class="fa fa-fast-backward" aria-hidden="true"></i></button>
<div class="col-1 page-stub">{{pageNum}}</div>
<div class="col-8" style="margin-top: 15px;padding-right:10px">
@ -125,7 +125,7 @@
</div>
<ng-template #actionBar>
<div class="reading-bar row no-gutters justify-content-between">
<div class="reading-bar row g-0 justify-content-between">
<button class="btn btn-outline-secondary btn-icon col-2 col-xs-1" (click)="prevPage()"
[disabled]="IsPrevDisabled"
title="{{readingDirection === ReadingDirection.LeftToRight ? 'Previous' : 'Next'}} Page">
@ -137,12 +137,12 @@
<div class="book-title col-2 phone-hidden">
<ng-container *ngIf="isLoading; else showTitle">
<div class="spinner-border spinner-border-sm text-primary" style="border-radius: 50%;" role="status">
<span class="sr-only">Loading book...</span>
<span class="visually-hidden">Loading book...</span>
</div>
</ng-container>
<ng-template #showTitle>
{{bookTitle}}
<span *ngIf="incognitoMode" (click)="turnOffIncognito()" role="button" aria-label="Incognito mode is on. Toggle to turn off.">(<i class="fa fa-glasses" aria-hidden="true"></i><span class="sr-only">Incognito Mode</span>)</span>
<span *ngIf="incognitoMode" (click)="turnOffIncognito()" role="button" aria-label="Incognito mode is on. Toggle to turn off.">(<i class="fa fa-glasses" aria-hidden="true"></i><span class="visually-hidden">Incognito Mode</span>)</span>
</ng-template>
</div>
<button class="btn btn-secondary col-2 col-xs-1" (click)="closeReader()"><i class="fa fa-times-circle" aria-hidden="true"></i><span class="phone-hidden">&nbsp;Close</span></button>

View File

@ -24,6 +24,7 @@ import { ScrollService } from 'src/app/scroll.service';
import { MangaFormat } from 'src/app/_models/manga-format';
import { LibraryService } from 'src/app/_services/library.service';
import { LibraryType } from 'src/app/_models/library';
import { ThemeService } from 'src/app/theme.service';
interface PageStyle {
@ -260,7 +261,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
private renderer: Renderer2, private navService: NavService, private toastr: ToastrService,
private domSanitizer: DomSanitizer, private bookService: BookService, private memberService: MemberService,
private scrollService: ScrollService, private utilityService: UtilityService, private libraryService: LibraryService,
@Inject(DOCUMENT) private document: Document) {
@Inject(DOCUMENT) private document: Document, private themeService: ThemeService) {
this.navService.hideNavBar();
this.darkModeStyleElem = this.renderer.createElement('style');
@ -382,7 +383,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
const bodyNode = this.document.querySelector('body');
if (bodyNode !== undefined && bodyNode !== null && this.originalBodyColor !== undefined) {
bodyNode.style.background = this.originalBodyColor;
if (this.user.preferences.siteDarkMode) {
if (this.themeService.isDarkTheme()) {
bodyNode.classList.add('bg-dark');
}
}
@ -968,7 +969,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
setOverrideStyles() {
const bodyNode = this.document.querySelector('body');
if (bodyNode !== undefined && bodyNode !== null) {
if (this.user.preferences.siteDarkMode) {
if (this.themeService.isDarkTheme()) {
bodyNode.classList.remove('bg-dark');
}

View File

@ -1,7 +1,7 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{title}} Bookmarks</h4>
<button type="button" class="close" aria-label="Close" (click)="close()">
<span aria-hidden="true">&times;</span>
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
</button>
</div>
<div class="modal-body">
@ -10,7 +10,7 @@
</p>
<ng-template #noBookmarks>No bookmarks yet</ng-template>
<div class="row no-gutters">
<div class="row g-0">
<div *ngFor="let bookmark of bookmarks; let idx = index">
<app-bookmark [bookmark]="bookmark" (bookmarkRemoved)="removeBookmark(bookmark, idx)" class="col-auto"></app-bookmark>
</div>

View File

@ -1,19 +1,17 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Add to Collection</h4>
<button type="button" class="close" aria-label="Close" (click)="close()">
<span aria-hidden="true">&times;</span>
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
</button>
</div>
<form style="width: 100%" [formGroup]="listForm">
<div class="modal-body">
<div class="form-group" *ngIf="lists.length >= 5">
<label for="filter">Filter</label>
<div class="mb-3" *ngIf="lists.length >= 5">
<label for="filter" class="form-label">Filter</label>
<div class="input-group">
<input id="filter" autocomplete="off" class="form-control" formControlName="filterQuery" type="text" aria-describedby="reset-input">
<div class="input-group-append">
<button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="listForm.get('filterQuery')?.setValue('');">Clear</button>
</div>
<button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="listForm.get('filterQuery')?.setValue('');">Clear</button>
</div>
</div>
<ul class="list-group">
@ -23,7 +21,7 @@
<li class="list-group-item" *ngIf="lists.length === 0 && !loading">No collections created yet</li>
<li class="list-group-item" *ngIf="loading">
<div class="spinner-border text-secondary" role="status">
<span class="sr-only">Loading...</span>
<span class="visually-hidden">Loading...</span>
</div>
</li>
</ul>
@ -32,7 +30,7 @@
<div style="width: 100%;">
<div class="form-row">
<div class="col-9 col-lg-10">
<label class="sr-only" for="add-rlist">Collection</label>
<label class="visually-hidden" class="form-label" for="add-rlist">Collection</label>
<input width="100%" #title ngbAutofocus type="text" class="form-control mb-2" id="add-rlist" formControlName="title">
</div>
<div class="col-2">

View File

@ -5,8 +5,8 @@
<ng-template #comicHeader><h4 class="modal-title" id="modal-basic-title">
{{parentName}} - {{data.number != 0 ? (isChapter ? 'Issue #' : 'Volume ') + data.number : 'Special'}} Details</h4>
</ng-template>
<button type="button" class="close" aria-label="Close" (click)="close()">
<span aria-hidden="true">&times;</span>
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
</button>
</div>
<div class="modal-body scrollable-modal" *ngIf="utilityService.isChapter(data)">
@ -18,15 +18,15 @@
<h4 *ngIf="utilityService.isVolume(data)">Information</h4>
<ng-container *ngIf="utilityService.isVolume(data) || utilityService.isChapter(data)">
<div class="row no-gutters">
<div class="row g-0">
<div class="col">
Id: {{data.id}}
</div>
<div class="col" *ngIf="series !== undefined">
Format: <span class="badge badge-secondary">{{utilityService.mangaFormat(series.format) | sentenceCase}}</span>
Format: <span class="badge bg-secondary">{{utilityService.mangaFormat(series.format) | sentenceCase}}</span>
</div>
</div>
<div class="row no-gutters">
<div class="row g-0">
<div class="col" *ngIf="data.hasOwnProperty('created')">
Added: {{(data.created | date: 'short') || '-'}}
</div>
@ -38,18 +38,18 @@
<h4 *ngIf="!utilityService.isChapter(data)">{{utilityService.formatChapterName(libraryType) + 's'}}</h4>
<ul class="list-unstyled">
<li class="media my-4" *ngFor="let chapter of chapters">
<li class="d-flex my-4" *ngFor="let chapter of chapters">
<a (click)="readChapter(chapter)" href="javascript:void(0);" title="Read {{libraryType !== LibraryType.Comic ? 'Chapter ' : 'Issue #'}} {{chapter.number}}">
<app-image class="mr-2" width="74px" [imageUrl]="chapter.coverImage"></app-image>
<app-image class="me-2" width="74px" [imageUrl]="chapter.coverImage"></app-image>
</a>
<div class="media-body">
<div class="flex-grow-1">
<h5 class="mt-0 mb-1">
<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>&nbsp;
{{utilityService.formatChapterName(libraryType, true, false) }} {{formatChapterNumber(chapter)}}
</span>
<span class="badge badge-primary badge-pill">
<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>
@ -58,9 +58,9 @@
<ng-template #specialHeader>File(s)</ng-template>
</h5>
<ul class="list-group">
<li *ngFor="let file of chapter.files" class="list-group-item">
<li *ngFor="let file of chapter.files" class="list-group-item no-hover">
<span>{{file.filePath}}</span>
<div class="row no-gutters">
<div class="row g-0">
<div class="col">
Pages: {{file.pages}}
</div>
@ -76,7 +76,7 @@
</div>
<div class="modal-footer">
<button type="button" class="btn btn-info" [disabled]="!isAdmin" (click)="updateCover()">Update Cover</button>
<button type="button" class="btn btn-secondary" [disabled]="!isAdmin" (click)="updateCover()">Update Cover</button>
<button type="submit" class="btn btn-primary" (click)="close()">Close</button>
</div>
</div>

View File

@ -1,8 +1,8 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Edit {{tag?.title}} Collection</h4>
<button type="button" class="close" aria-label="Close" (click)="close()">
<span aria-hidden="true">&times;</span>
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
</button>
</div>
<div class="modal-body">
@ -16,8 +16,8 @@
<a ngbNavLink>{{tabs[0]}}</a>
<ng-template ngbNavContent>
<form [formGroup]="collectionTagForm">
<div class="form-group">
<label for="summary">Summary</label>
<div class="mb-3">
<label for="summary" class="form-label">Summary</label>
<textarea id="summary" class="form-control" formControlName="summary" rows="3"></textarea>
</div>
</form>
@ -67,6 +67,6 @@
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="close()">Cancel</button>
<button type="button" class="btn btn-info" (click)="togglePromotion()">{{tag?.promoted ? 'Demote' : 'Promote'}}</button>
<button type="button" class="btn btn-secondary alt" (click)="togglePromotion()">{{tag?.promoted ? 'Demote' : 'Promote'}}</button>
<button type="button" class="btn btn-primary" (click)="save()">Save</button>
</div>

View File

@ -2,8 +2,8 @@
<div class="modal-header">
<h4 class="modal-title">
{{this.series.name}} Details</h4>
<button type="button" class="close" aria-label="Close" (click)="close()">
<span aria-hidden="true">&times;</span>
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
</button>
</div>
<div class="modal-body scrollable-modal {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? '' : 'd-flex'}}">
@ -13,52 +13,52 @@
<a ngbNavLink>{{tabs[0]}}</a>
<ng-template ngbNavContent>
<form [formGroup]="editSeriesForm">
<div class="row no-gutters">
<div class="form-group" style="width: 100%">
<label for="name">Name</label>
<div class="row g-0">
<div class="mb-3" style="width: 100%">
<label for="name" class="form-label">Name</label>
<input id="name" class="form-control" formControlName="name" type="text">
</div>
</div>
<div class="row no-gutters">
<div class="form-group" style="width: 100%">
<label for="sort-name">Sort Name</label>
<div class="row g-0">
<div class="mb-3" style="width: 100%">
<label for="sort-name" class="form-label">Sort Name</label>
<input id="sort-name" class="form-control" formControlName="sortName" type="text">
</div>
</div>
<div class="row no-gutters">
<div class="form-group" style="width: 100%">
<label for="localized-name">Localized Name</label>
<div class="row g-0">
<div class="mb-3" style="width: 100%">
<label for="localized-name" class="form-label">Localized Name</label>
<input id="localized-name" class="form-control" formControlName="localizedName" type="text">
</div>
</div>
<div class="row no-gutters" *ngIf="metadata">
<div class="row g-0" *ngIf="metadata">
<div class="col-md-6">
<div class="form-group">
<label for="author">Author</label>
<div class="mb-3">
<label for="author" class="form-label">Author</label>
<input id="author" class="form-control" placeholder="Not Implemented" readonly="true" formControlName="author" type="text">
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="artist">Artist</label>
<div class="mb-3">
<label for="artist" class="form-label">Artist</label>
<input id="artist" class="form-control" placeholder="Not Implemented" readonly="true" formControlName="artist" type="text">
</div>
</div>
</div>
<div class="row no-gutters" *ngIf="metadata">
<div class="row g-0" *ngIf="metadata">
<div class="col-md-6">
<div class="form-group">
<label for="genres">Genres</label>
<div class="mb-3">
<label for="genres" class="form-label">Genres</label>
<input id="genres" class="form-control" placeholder="Not Implemented" readonly="true" formControlName="genres" type="text">
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="collections">Collections</label>
<div class="mb-3">
<label for="collections" class="form-label">Collections</label>
<app-typeahead (selectedData)="updateCollections($event)" [settings]="settings">
<ng-template #badgeItem let-item let-position="idx">
{{item.title}}
@ -71,9 +71,9 @@
</div>
</div>
<div class="row no-gutters">
<div class="form-group" style="width: 100%">
<label for="summary">Summary</label>
<div class="row g-0">
<div class="mb-3" style="width: 100%">
<label for="summary" class="form-label">Summary</label>
<textarea id="summary" class="form-control" formControlName="summary" rows="4"></textarea>
</div>
</div>
@ -94,7 +94,7 @@
<a ngbNavLink>{{tabs[2]}}</a>
<ng-template ngbNavContent>
<h4>Information</h4>
<div class="row no-gutters mb-2">
<div class="row g-0 mb-2">
<div class="col-md-6" *ngIf="libraryName">Library: {{libraryName | sentenceCase}}</div>
<div class="col-md-6">Format: <app-tag-badge>{{utilityService.mangaFormat(series.format)}}</app-tag-badge></div>
</div>
@ -103,12 +103,12 @@
<span class="invisible">Loading...</span>
</div>
<ul class="list-unstyled" *ngIf="!isLoadingVolumes">
<li class="media my-4" *ngFor="let volume of seriesVolumes">
<app-image class="mr-3" style="width: 74px;" width="74px" [imageUrl]="imageService.getVolumeCoverImage(volume.id)"></app-image>
<div class="media-body">
<li class="d-flex my-4" *ngFor="let volume of seriesVolumes">
<app-image class="me-3" style="width: 74px;" width="74px" [imageUrl]="imageService.getVolumeCoverImage(volume.id)"></app-image>
<div class="flex-grow-1">
<h5 class="mt-0 mb-1">Volume {{volume.name}}</h5>
<div>
<div class="row no-gutters">
<div class="row g-0">
<div class="col">
Added: {{volume.created | date: 'short'}}
</div>
@ -116,7 +116,7 @@
Last Modified: {{volume.lastModified | date: 'short'}}
</div>
</div>
<div class="row no-gutters">
<div class="row g-0">
<div class="col">
<button type="button" class="btn btn-outline-primary" (click)="collapse.toggle()" [attr.aria-expanded]="!volumeCollapsed[volume.name]">
View Files
@ -131,7 +131,7 @@
<ul class="list-group mt-2">
<li *ngFor="let file of volume.volumeFiles.sort()" class="list-group-item">
<span>{{file.filePath}}</span>
<div class="row no-gutters">
<div class="row g-0">
<div class="col">
Chapter: {{file.chapter}}
</div>
@ -153,7 +153,7 @@
</li>
</ul>
<div [ngbNavOutlet]="nav" class="tab-content {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'mt-3' : 'ml-4 flex-fill'}}"></div>
<div [ngbNavOutlet]="nav" class="tab-content {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'mt-3' : 'ms-4 flex-fill'}}"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="close()">Close</button>

View File

@ -6,12 +6,12 @@
<span class="card-title" tabindex="0">
Page {{bookmark.page + 1}}
</span>
<span class="card-actions float-right" *ngIf="series != undefined">
<span class="card-actions float-end" *ngIf="series != undefined">
<button attr.aria-labelledby="series--{{series.name}}" class="btn btn-danger btn-sm" (click)="removeBookmark()"
[disabled]="isClearing" placement="top" ngbTooltip="Remove Bookmark" attr.aria-label="Remove Bookmark">
<ng-container *ngIf="isClearing; else notClearing">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span class="sr-only">Loading...</span>
<span class="visually-hidden">Loading...</span>
</ng-container>
<ng-template #notClearing>
<i class="fa fa-trash-alt" aria-hidden="true"></i>

View File

@ -2,7 +2,7 @@
<div class="d-flex justify-content-around align-items-center">
<span class="highlight"><i class="fa fa-check" aria-hidden="true"></i>&nbsp;{{bulkSelectionService.totalSelections()}} selected</span>
<app-card-actionables [actions]="actions" labelBy="bulk-actions-header" iconClass="fa-ellipsis-h" (actionHandler)="performAction($event)"></app-card-actionables>
<span id="bulk-actions-header" class="sr-only">Bulk Actions</span>
<span id="bulk-actions-header" class="visually-hidden">Bulk Actions</span>
<button class="btn btn-icon" (click)="bulkSelectionService.deselectAll()"><i class="fa fa-times" aria-hidden="true"></i>&nbsp;Deselect All</button>
</div>
</div>

View File

@ -1,15 +1,9 @@
@use "../../../theme/colors";
.bulk-select {
background-color: colors.$dark-form-background-no-opacity;
border-bottom: 2px solid colors.$primary-color;
color: white;
}
.btn-icon {
color: white;
background-color: var(--navbar-bg-color);
border-bottom: 2px solid var(--primary-color);
color: var(--navbar-text-color);
}
.highlight {
color: colors.$primary-color !important;
color: var(--primary-color) !important;
}

View File

@ -1,18 +1,20 @@
<div class="container-fluid" style="padding-top: 10px">
<div class="row no-gutters pb-2">
<div class="col mr-auto">
<div class="row 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>&nbsp;
</span>{{header}}&nbsp;
<span class="badge badge-primary badge-pill" attr.aria-label="{{pagination.totalItems}} total items" *ngIf="pagination != undefined">{{pagination.totalItems}}</span>
<span class="badge bg-primary rounded-pill" attr.aria-label="{{pagination.totalItems}} total items" *ngIf="pagination != undefined">{{pagination.totalItems}}</span>
</h2>
</div>
<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="sr-only">Sort / Filter</span>
</button>
<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">
@ -25,8 +27,8 @@
<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="close" aria-label="Close" (click)="commentDrawer.close()">
<span aria-hidden="true">&times;</span>
<button type="button" class="btn-close" aria-label="Close" (click)="commentDrawer.close()">
</button>
</h2>
@ -40,11 +42,11 @@
<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 no-gutters">
<div class="col-md-2 mr-3" *ngIf="!filterSettings.formatDisabled">
<div class="form-group">
<label for="format">Format</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="globalFilterTooltip" role="button" tabindex="0"></i>
<span class="sr-only" id="filter-global-format-help"><ng-container [ngTemplateOutlet]="globalFilterTooltip"></ng-container></span>
<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>&nbsp;<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}}
@ -56,9 +58,9 @@
</div>
</div>
<div class="col-md-2 mr-3"*ngIf="!filterSettings.libraryDisabled">
<div class="form-group">
<label for="libraries">Libraries</label>
<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}}
@ -70,10 +72,10 @@
</div>
</div>
<div class="col-md-2 mr-3" *ngIf="!filterSettings.collectionDisabled">
<div class="form-group">
<label for="collections">Collections</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="globalFilterTooltip" role="button" tabindex="0"></i>
<span class="sr-only" id="filter-global-collections-help"><ng-container [ngTemplateOutlet]="globalFilterTooltip"></ng-container></span>
<div class="col-md-2 me-3" *ngIf="!filterSettings.collectionDisabled">
<div class="mb-3">
<label for="collections" class="form-label">Collections</label>&nbsp;<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}}
@ -85,9 +87,9 @@
</div>
</div>
<div class="col-md-2 mr-3" *ngIf="!filterSettings.genresDisabled">
<div class="form-group">
<label for="genres">Genres</label>
<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}}
@ -99,9 +101,9 @@
</div>
</div>
<div class="col-md-2 mr-3" *ngIf="!filterSettings.tagsDisabled">
<div class="form-group">
<label for="tags">Tags</label>
<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}}
@ -113,11 +115,11 @@
</div>
</div>
</div>
<div class="row justify-content-center no-gutters">
<div class="row justify-content-center g-0">
<!-- The People row -->
<div class="col-md-2 mr-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.CoverArtist)">
<div class="form-group">
<label for="cover-artist">Cover Artists</label>
<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}}
@ -129,9 +131,9 @@
</div>
</div>
<div class="col-md-2 mr-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Writer)">
<div class="form-group">
<label for="writers">Writers</label>
<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}}
@ -143,9 +145,9 @@
</div>
</div>
<div class="col-md-2 mr-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Publisher)">
<div class="form-group">
<label for="publisher">Publisher</label>
<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}}
@ -157,9 +159,9 @@
</div>
</div>
<div class="col-md-2 mr-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Penciller)">
<div class="form-group">
<label for="penciller">Penciller</label>
<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}}
@ -171,9 +173,9 @@
</div>
</div>
<div class="col-md-2 mr-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Letterer)">
<div class="form-group">
<label for="letterer">Letterer</label>
<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}}
@ -185,9 +187,9 @@
</div>
</div>
<div class="col-md-2 mr-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Inker)">
<div class="form-group">
<label for="inker">Inker</label>
<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}}
@ -199,9 +201,9 @@
</div>
</div>
<div class="col-md-2 mr-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Editor)">
<div class="form-group">
<label for="editor">Editor</label>
<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}}
@ -213,9 +215,9 @@
</div>
</div>
<div class="col-md-2 mr-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Colorist)">
<div class="form-group">
<label for="colorist">Colorist</label>
<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}}
@ -227,9 +229,9 @@
</div>
</div>
<div class="col-md-2 mr-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Character)">
<div class="form-group">
<label for="character">Character</label>
<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}}
@ -241,9 +243,9 @@
</div>
</div>
<div class="col-md-2 mr-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Translator)">
<div class="form-group">
<label for="translators">Translators</label>
<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}}
@ -255,9 +257,9 @@
</div>
</div>
</div>
<div class="row justify-content-center no-gutters">
<div class="col-md-2 mr-3" *ngIf="!filterSettings.readProgressDisabled">
<label>Read Progress</label>
<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">
@ -274,8 +276,8 @@
</form>
</div>
<div class="col-md-2 mr-3" *ngIf="!filterSettings.ratingDisabled">
<label for="ratings">Rating</label>
<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">
@ -285,8 +287,8 @@
</form>
</div>
<div class="col-md-2 mr-3" *ngIf="!filterSettings.ageRatingDisabled">
<label for="age-rating">Age Rating</label>
<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}}
@ -297,8 +299,8 @@
</app-typeahead>
</div>
<div class="col-md-2 mr-3" *ngIf="!filterSettings.languageDisabled">
<label for="languages">Language</label>
<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}}
@ -309,8 +311,8 @@
</app-typeahead>
</div>
<div class="col-md-2 mr-3" *ngIf="!filterSettings.publicationStatusDisabled">
<label for="publication-status">Publication Status</label>
<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}}
@ -320,13 +322,13 @@
</ng-template>
</app-typeahead>
</div>
<div class="col-md-2 mr-3"></div>
<div class="col-md-2 me-3"></div>
</div>
<div class="row justify-content-center no-gutters">
<div class="col-md-2 mr-3" *ngIf="!filterSettings.sortDisabled">
<div class="row justify-content-center g-0">
<div class="col-md-2 me-3" *ngIf="!filterSettings.sortDisabled">
<form [formGroup]="sortGroup">
<div class="form-group">
<label for="sort-options">Sort By</label>
<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>
@ -341,14 +343,14 @@
</div>
</form>
</div>
<div class="col-md-2 mr-3" *ngIf="filterSettings.sortDisabled"></div>
<div class="col-md-2 mr-3"></div>
<div class="col-md-2 mr-3"></div>
<div class="col-md-2 mr-3 mt-4">
<button class="btn btn-secondary btn-block" (click)="clear()">Clear</button>
<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 mr-3 mt-4">
<button class="btn btn-primary btn-block" (click)="apply()">Apply</button>
<div class="col-md-2 me-3">
<button class="btn btn-primary col-12" (click)="apply()">Apply</button>
</div>
</div>
</div>
@ -357,7 +359,7 @@
<ng-container [ngTemplateOutlet]="paginationTemplate" [ngTemplateOutletContext]="{ id: 'top' }"></ng-container>
<div class="row no-gutters">
<div class="row g-0">
<div class="col-auto" *ngFor="let item of items; trackBy:trackByIdentity; index as i">
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
</div>
@ -388,7 +390,7 @@
<label
id="paginationInputLabel-{{id}}"
for="paginationInput-{{id}}"
class="col-form-label mr-2 ml-1"
class="col-form-label me-2 ms-1 form-label"
>Page</label>
<input #i
type="text"

View File

@ -1,9 +0,0 @@
@use '../../../theme/colors';
.star {
font-size: 1.5rem;
color: colors.$rating-empty;
}
.filled {
color: colors.$rating-filled;
}

View File

@ -12,7 +12,7 @@
<span class="download" *ngIf="download$ | async as download">
<app-circular-loader [currentValue]="download.progress"></app-circular-loader>
<span class="sr-only" role="status">
<span class="visually-hidden" role="status">
{{download.progress}}% downloaded
</span>
</span>
@ -27,8 +27,9 @@
</div>
<div class="count" *ngIf="count > 1">
<span class="badge badge-primary">{{count}}</span>
<span class="badge bg-primary">{{count}}</span>
</div>
<div class="card-overlay"></div>
</div>
<div class="card-body" *ngIf="title.length > 0 || actions.length > 0">
@ -36,12 +37,12 @@
<span class="card-title" placement="top" id="{{title}}_{{entity?.id}}" [ngbTooltip]="tooltipTitle" (click)="handleClick()" tabindex="0">
<span *ngIf="isPromoted()">
<i class="fa fa-angle-double-up" aria-hidden="true"></i>
<span class="sr-only">(promoted)</span>
<span class="visually-hidden">(promoted)</span>
</span>
<i class="fa {{utilityService.mangaFormatIcon(format)}}" aria-hidden="true" *ngIf="format != MangaFormat.UNKNOWN" title="{{utilityService.mangaFormat(format)}}"></i><span class="sr-only">{{utilityService.mangaFormat(format)}}</span>
<i class="fa {{utilityService.mangaFormatIcon(format)}}" aria-hidden="true" *ngIf="format != MangaFormat.UNKNOWN" title="{{utilityService.mangaFormat(format)}}"></i><span class="visually-hidden">{{utilityService.mangaFormat(format)}}</span>
&nbsp;{{title}}
</span>
<span class="card-actions float-right">
<span class="card-actions float-end">
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="title"></app-card-actionables>
</span>
</div>

View File

@ -1,4 +1,4 @@
@use '../../../theme/colors';
$triangle-size: 30px;
$image-height: 230px;
@ -7,7 +7,7 @@ $image-width: 160px;
.error-banner {
width: 160px;
height: 18px;
background-color: colors.$error-color;
background-color: var(--toast-error-bg-color);
font-size: 12px;
color: white;
text-transform: uppercase;
@ -25,6 +25,11 @@ $image-width: 160px;
padding-left: 0px;
padding-right: 0px;
box-sizing: border-box;
position: relative;
background-color: var(--card-bg-color);
color: var(--card-text-color);
border-color: var(--card-border-color);
}
.card-title {
@ -39,7 +44,7 @@ $image-width: 160px;
}
.selected-highlight {
outline: 2px solid colors.$primary-color;
outline: 2px solid var(--primary-color);
}
@ -52,7 +57,7 @@ $image-width: 160px;
height: 5px;
.progress {
color: colors.$primary-color;
color: var(--card-progress-bar-color);
background-color: transparent;
}
}
@ -73,7 +78,7 @@ $image-width: 160px;
height: 0;
border-style: solid;
border-width: 0 $triangle-size $triangle-size 0;
border-color: transparent colors.$primary-color transparent transparent;
border-color: transparent var(--primary-color) transparent transparent;
}
@ -106,10 +111,12 @@ $image-width: 160px;
.bulk-mode {
visibility: visible;
z-index: 110;
}
.overlay-item {
visibility: visible;
z-index: 100;
}
}
@ -142,3 +149,17 @@ $image-width: 160px;
text-decoration: none;
margin-top: 0px;
}
.card-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 230px;
z-index: 10;
transition: opacity 0.2s;
}
.card-overlay:hover {
opacity: 0;
}

View File

@ -4,13 +4,13 @@
<!-- Arc Information -->
<div class="row no-gutters">
<div class="row g-0">
<div class="col">
Id: {{chapter.id}}
</div>
</div>
<div class="row no-gutters">
<div class="row g-0">
<div class="col">
Title: {{chapter.titleName || '-'}}
</div>
@ -19,7 +19,7 @@
</div>
</div>
<div class="row no-gutters">
<div class="row g-0">
<div class="col" *ngIf="chapter.hasOwnProperty('created')">
Added: {{(chapter.created | date: 'short') || '-'}}
</div>
@ -30,11 +30,11 @@
</div>
<ul class="list-unstyled" >
<li class="media my-4">
<li class="d-flex my-4">
<a (click)="readChapter(chapter)" href="javascript:void(0);" title="Read {{libraryType !== LibraryType.Comic ? 'Chapter ' : 'Issue #'}} {{chapter.number}}">
<app-image class="mr-3" width="74px" [imageUrl]="chapter.coverImage"></app-image>
<app-image class="me-3" width="74px" [imageUrl]="chapter.coverImage"></app-image>
</a>
<div class="media-body">
<div class="flex-grow-1">
<h5 class="mt-0 mb-1">
<span *ngIf="chapter.number !== '0'; else specialHeader">
<!-- TODO: Add back in
@ -42,7 +42,7 @@
<app-card-actionables (actionHandler)="performAction($event, chapter)" [actions]="chapterActions" [labelBy]="utilityService.formatChapterName(libraryType, true, true) + formatChapterNumber(chapter)"></app-card-actionables>&nbsp;
{{utilityService.formatChapterName(libraryType, true, false) }} {{formatChapterNumber(chapter)}}
</span> -->
<span class="badge badge-primary badge-pill">
<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>
@ -56,7 +56,7 @@
<ng-container>
<div class="row no-gutters mt-1" *ngIf="chapter.writers && chapter.writers.length > 0">
<div class="row g-0 mt-1" *ngIf="chapter.writers && chapter.writers.length > 0">
<div class="col-md-4">
<h5>Writers</h5>
</div>
@ -65,7 +65,7 @@
</div>
</div>
<div class="row no-gutters mt-1" *ngIf="chapter.coverArtist && chapter.coverArtist.length > 0">
<div class="row g-0 mt-1" *ngIf="chapter.coverArtist && chapter.coverArtist.length > 0">
<div class="col-md-4">
<h5>Artists</h5>
</div>
@ -74,7 +74,7 @@
</div>
</div>
<div class="row no-gutters mt-1" *ngIf="chapter.publisher && chapter.publisher.length > 0">
<div class="row g-0 mt-1" *ngIf="chapter.publisher && chapter.publisher.length > 0">
<div class="col-md-4">
<h5>Publishers</h5>
</div>
@ -98,7 +98,7 @@
Arc Information
<div class="row no-gutters">
<div class="row g-0">
<div class="col">
Id: {{chapter.id}}
</div>
@ -107,7 +107,7 @@
</div>
</div>
<div class="row no-gutters">
<div class="row g-0">
<div class="col" *ngIf="chapter.hasOwnProperty('created')">
Added: {{(chapter.created | date: 'short') || '-'}}
</div>

View File

@ -3,13 +3,13 @@
<ngx-file-drop (onFileDrop)="dropped($event)"
(onFileOver)="fileOver($event)" (onFileLeave)="fileLeave($event)" accept=".png,.jpg,.jpeg" [directory]="false" dropZoneClassName="file-upload" contentClassName="file-upload-zone" [directory]="false">
<ng-template ngx-file-drop-content-tmp let-openFileSelector="openFileSelector">
<div class="row no-gutters mt-3 pb-3" *ngIf="mode === 'all'">
<div class="row g-0 mt-3 pb-3" *ngIf="mode === 'all'">
<div class="mx-auto">
<div class="row no-gutters mb-3">
<div class="row g-0 mb-3">
<i class="fa fa-file-upload mx-auto" style="font-size: 24px;" aria-hidden="true"></i>
</div>
<div class="row no-gutters">
<div class="row g-0">
<div class="mx-auto">
<a class="col" style="padding-right:0px" href="javascript:void(0)" (click)="mode = 'url'; setupEnterHandler()"><span class="phone-hidden">Enter a </span>Url</a>
<span class="col" style="padding-right:0px"></span>
@ -23,17 +23,16 @@
<ng-container *ngIf="mode === 'url'">
<div class="row no-gutters mt-3 pb-3 ml-md-2 mr-md-2">
<div class="input-group col-md-10 mr-md-2" style="width: 100%">
<div class="row g-0 mt-3 pb-3 ms-md-2 me-md-2">
<div class="input-group col-md-10 me-md-2" style="width: 100%">
<!-- TOOD: Bootstrap migration: This should be replaced with just the label-->
<div class="input-group-prepend">
<label class="input-group-text" for="load-image">Url</label>
<label class="input-group-text form-label" for="load-image">Url</label>
</div>
<input type="text" autocomplete="off" class="form-control" formControlName="coverImageUrl" placeholder="https://" id="load-image" class="form-control">
<div class="input-group-append">
<button class="btn btn-outline-secondary" type="button" id="load-image-addon" (click)="loadImage(); mode='all';" [disabled]="form.get('coverImageUrl')?.value.length === 0">
Load
</button>
</div>
<button class="btn btn-outline-secondary" type="button" id="load-image-addon" (click)="loadImage(); mode='all';" [disabled]="form.get('coverImageUrl')?.value.length === 0">
Load
</button>
</div>
<button class="col btn btn-secondary" href="javascript:void(0)" (click)="mode = 'all'" aria-label="Back">
<i class="fas fa-share" aria-hidden="true" style="transform: rotateY(180deg)"></i>&nbsp;
@ -51,7 +50,7 @@
</ng-template>
</form>
<div class="row no-gutters chooser" style="padding-top: 10px">
<div class="row g-0 chooser" style="padding-top: 10px">
<div class="image-card col-auto {{selectedIndex === idx ? 'selected' : ''}}" *ngFor="let url of imageUrls; let idx = index;" tabindex="0" attr.aria-label="Image {{idx + 1}}" (click)="selectImage(idx)">
<app-image class="card-img-top" height="230px" width="158px" [imageUrl]="url"></app-image>
</div>

View File

@ -1,4 +1,3 @@
@use '../../../theme/colors';
$image-height: 230px;
$image-width: 160px;
@ -14,7 +13,7 @@ $image-width: 160px;
}
.selected {
outline: 5px solid colors.$primary-color;
outline: 5px solid var(--primary-color);
outline-width: medium;
outline-offset: -1px;
}
@ -22,7 +21,7 @@ $image-width: 160px;
ngx-file-drop ::ng-deep > div {
// styling for the outer drop box
width: 100%;
border: 2px solid colors.$primary-color;
border: 2px solid var(--primary-color);
border-radius: 5px;
height: 100px;
margin: auto;

View File

@ -1,6 +1,6 @@
<li class="list-group-item">
<span>{{file.filePath}}</span>
<div class="row no-gutters">
<div class="row g-0">
<div class="col">
Pages: {{file.pages}}
</div>

View File

@ -1,4 +1,3 @@
$primary-color: #cc7b19;
.card {
margin: 10px;
@ -28,7 +27,6 @@ $primary-color: #cc7b19;
.overlay {
height: 160px;
&:hover {
background-color: rgba(0, 0, 0, 0.4);
visibility: visible;
.overlay-item {

View File

@ -1,9 +1,9 @@
<div class="carousel-container" *ngIf="items.length > 0">
<div>
<h2 style="display: inline-block;"><a href="javascript:void(0)" (click)="sectionClicked($event)" class="section-title">{{title}}</a></h2>
<div class="float-right">
<button class="btn btn-icon" [disabled]="isBeginning" (click)="prevPage()"><i class="fa fa-angle-left" aria-hidden="true"></i><span class="sr-only">Previous Items</span></button>
<button class="btn btn-icon" [disabled]="isEnd" (click)="nextPage()"><i class="fa fa-angle-right" aria-hidden="true"></i><span class="sr-only">Next Items</span></button>
<div class="float-end">
<button class="btn btn-icon" [disabled]="isBeginning" (click)="prevPage()"><i class="fa fa-angle-left" aria-hidden="true"></i><span class="visually-hidden">Previous Items</span></button>
<button class="btn btn-icon" [disabled]="isEnd" (click)="nextPage()"><i class="fa fa-angle-right" aria-hidden="true"></i><span class="visually-hidden">Next Items</span></button>
</div>
</div>
<div>

View File

@ -8,41 +8,20 @@
flex-direction: column;
}
.section-title {
font-size: 1.6rem;
font-weight: 400;
margin-left: 10px;
color: black;
:hover {
text-decoration: underline;
}
:active {
text-decoration: underline;
}
:focus {
text-decoration: underline;
}
}
::ng-deep .bg-light {
.carousel-container {
.section-title {
color: black !important;
}
}
// These are needed due to nested css within another component
::ng-deep .bg-dark {
.section-title {
color: #efefef !important;
font-size: 1.6rem;
font-weight: 400;
margin-left: 10px;
color: var(--carousel-header-text-color);
text-decoration: var(--carousel-header-text-decoration);
&:hover, &:focus, &:active {
text-decoration: var(--carousel-hover-header-text-decoration);
}
}
}
::ng-deep .swiper-slide {
width: auto !important;
}
::ng-deep .swiper-slide {
width: auto !important;

View File

@ -1,7 +1,6 @@
import { Component, ContentChild, EventEmitter, Input, OnInit, Output, TemplateRef, ViewChild } from '@angular/core';
import { SwiperComponent } from 'swiper/angular';
//import Swiper from 'swiper';
//import { SwiperEvents, Swiper } from 'swiper/types';
import { Swiper, SwiperEvents } from 'swiper/types';
@Component({
selector: 'app-carousel-reel',
@ -15,18 +14,17 @@ export class CarouselReelComponent implements OnInit {
@Input() title = '';
@Output() sectionClick = new EventEmitter<string>();
@ViewChild('swiper', { static: false }) swiper?: SwiperComponent;
swiper: Swiper | undefined;
//swiper!: Swiper;
trackByIdentity: (index: number, item: any) => string;
get isEnd() {
return this.swiper?.swiperRef.isEnd;
return this.swiper?.isEnd;
}
get isBeginning() {
return this.swiper?.swiperRef.isBeginning;
return this.swiper?.isBeginning;
}
constructor() {
@ -36,14 +34,16 @@ export class CarouselReelComponent implements OnInit {
ngOnInit(): void {}
nextPage() {
if (this.isEnd) return;
if (this.swiper) {
this.swiper.swiperRef.setProgress(this.swiper.swiperRef.progress + 0.25, 600);
this.swiper.setProgress(this.swiper.progress + 0.25, 600);
}
}
prevPage() {
if (this.isBeginning) return;
if (this.swiper) {
this.swiper.swiperRef.setProgress(this.swiper.swiperRef.progress - 0.25, 600);
this.swiper.setProgress(this.swiper.progress - 0.25, 600);
}
}
@ -51,14 +51,7 @@ export class CarouselReelComponent implements OnInit {
this.sectionClick.emit(this.title);
}
// onSwiper(eventParams: Parameters<SwiperEvents['init']>) {
// console.log('swiper: ', eventParams);
// [this.swiper] = eventParams;
// }
// onSwiper(params: Swiper) {
// // const [swiper] = params;
// // console.log(swiper);
// // return params;
// }
onSwiper(eventParams: Parameters<SwiperEvents['init']>) {
[this.swiper] = eventParams;
}
}

View File

@ -4,14 +4,14 @@
<app-image class="poster" maxWidth="481px" [imageUrl]="tagImage"></app-image>
</div>
<div class="col-md-10 col-xs-8 col-sm-6">
<div class="row no-gutters">
<div class="row g-0">
<h2>
{{collectionTag.title}}
</h2>
</div>
<div class="row no-gutters mt-2 mb-2">
<div class="ml-2" *ngIf="isAdmin">
<div class="row g-0 mt-2 mb-2">
<div class="ms-2" *ngIf="isAdmin">
<button class="btn btn-secondary" (click)="openEditCollectionTagModal(collectionTag)" title="Edit Series information">
<span>
<i class="fa fa-pen" aria-hidden="true"></i>
@ -19,7 +19,7 @@
</button>
</div>
</div>
<div class="row no-gutters">
<div class="row g-0">
<app-read-more [text]="collectionTag.summary" [maxLength]="250"></app-read-more>
</div>
</div>

View File

@ -1,15 +1,15 @@
<form [formGroup]="typeaheadForm" class="grouped-typeahead">
<div class="typeahead-input" [ngClass]="{'focused': hasFocus == true}" (click)="onInputFocus($event)">
<div>
<div class="search">
<input #input [id]="id" type="text" autocomplete="off" formControlName="typeahead" [placeholder]="placeholder"
aria-haspopup="listbox" aria-owns="dropdown" aria-expanded="hasFocus && (grouppedData.persons.length || grouppedData.collections.length || grouppedData.series.length || grouppedData.persons.length || grouppedData.tags.length || grouppedData.genres.length)"
aria-autocomplete="list" (focusout)="close($event)" (focus)="open($event)"
>
<div class="spinner-border spinner-border-sm" role="status" *ngIf="isLoading">
<span class="sr-only">Loading...</span>
<span class="visually-hidden">Loading...</span>
</div>
<button type="button" class="close" aria-label="Close" (click)="resetField()">
<span aria-hidden="true">&times;</span>
<button type="button" class="btn-close float-end" aria-label="Close" (click)="resetField()">
</button>
</div>
</div>

View File

@ -1,20 +1,24 @@
@use "../../theme/colors";
form {
max-height: 38px;
}
input {
width: 15px;
opacity: 1;
position: relative;
left: 4px;
border: none;
}
// input {
// width: 15px;
// opacity: 1;
// position: relative;
// left: 4px;
// border: none;
// }
.search-result img {
width: 100% !important;
}
.btn-close {
margin-top: 8px;
font-size: 0.8rem;
}
.typeahead-input {
border: 1px solid transparent;
@ -27,32 +31,35 @@ input {
box-sizing: border-box;
box-shadow: none;
cursor: text;
background-color: #fff;
background-color: var(--input-bg-color);
color: var(--body-text-color);
min-height: 38px;
transition-property: all;
transition-duration: 0.3s;
display: block;
.close {
cursor: pointer;
position: absolute;
top: 7px;
right: 10px;
.search {
display: flex;
}
@media only screen and (max-width:650px) {
.close {
top: 50%;
transform: translate(0, -60%);
}
}
// .close {
// cursor: pointer;
// position: absolute;
// top: 7px;
// right: 10px;
// }
// @media only screen and (max-width:650px) {
// .close {
// top: 50%;
// transform: translate(0, -60%);
// }
// }
input {
outline: 0 !important;
border-radius: .28571429rem;
display: inline-block !important;
padding: 0px !important;
min-height: 0px !important;
max-width: 100% !important;
@ -60,32 +67,35 @@ input {
text-indent: 0 !important;
line-height: inherit !important;
box-shadow: none !important;
width: 300px;
width: 200px;
transition-property: all;
transition-duration: 0.3s;
display: block;
opacity: 1;
position: relative;
left: 4px;
border: none;
&:focus-visible {
width: calc(100vw - 400px);
}
&:empty {
padding-top: 6px !important;
}
}
input:focus-visible {
width: calc(100vw - 400px);
&.focused {
width: 100%;
border-color: var(--input-focused-border-color);
}
input:empty {
padding-top: 6px !important;
}
}
.typeahead-input.focused {
width: 100%;
border-color: #ccc;
}
/* small devices (phones, 650px and down) */
@media only screen and (max-width:650px) {
.typeahead-input {
width: 120px;
}
input {
width: 100%
}
@ -95,21 +105,20 @@ input {
}
}
::ng-deep .bg-dark .typeahead-input {
color: #efefef;
background-color: colors.$dark-bg-color;
}
// Causes bleedover
::ng-deep .bg-dark .dropdown .list-group-item.hover {
background-color: colors.$dark-hover-color;
}
.section-header {
color: var(--body-text-color);
&:hover {
background-color: var(--list-group-item-bg-color) !important;
cursor: default;
}
}
.dropdown {
width: 100vw;
height: calc(100vh - 57px); //header offset
background: rgba(0,0,0,0.5);
background: var(--dropdown-overlay-color);
position: fixed;
justify-content: center;
left: 0;
@ -155,39 +164,6 @@ ul ul {
border-radius: 0px !important;
}
.list-group-item {
cursor: pointer;
}
::ng-deep .bg-dark {
& .section-header {
background: colors.$dark-item-accent-bg;
cursor: default;
}
& .section-header:hover {
background-color: colors.$dark-item-accent-bg !important;
}
}
::ng-deep .bg-light {
& .section-header {
background: colors.$white-item-accent-bg;
cursor: default;
}
& .section-header:hover, .list-group-item.section-header:hover {
background: colors.$white-item-accent-bg !important;
}
& .list-group-item:hover {
background-color: colors.$primary-color !important;
}
}
.spinner-border {
position: absolute;

View File

@ -22,7 +22,7 @@
<button class="btn btn-icon mx-auto">
<i class="fa fa-angle-double-up animate" aria-hidden="true"></i>
</button>
<span class="sr-only">Scroll up to move to next chapter</span>
<span class="visually-hidden">Scroll up to move to next chapter</span>
</div>
</div>
<ng-container *ngFor="let item of webtoonImages | async; let index = index;">
@ -40,7 +40,7 @@
<button class="btn btn-icon mx-auto">
<i class="fa fa-angle-double-down animate" aria-hidden="true"></i>
</button>
<span class="sr-only">Scroll down to move to next chapter</span>
<span class="visually-hidden">Scroll down to move to next chapter</span>
</div>
<div style="height: 200px"></div>
</div>

View File

@ -3,17 +3,17 @@
<div style="display: flex; margin-top: 5px;">
<button class="btn btn-icon" style="height: 100%" title="Back" (click)="closeReader()">
<i class="fa fa-arrow-left" aria-hidden="true"></i>
<span class="sr-only">Back</span>
<span class="visually-hidden">Back</span>
</button>
<div>
<div style="font-weight: bold;">{{title}} <span class="clickable" *ngIf="incognitoMode" (click)="turnOffIncognito()" role="button" aria-label="Incognito mode is on. Toggle to turn off.">(<i class="fa fa-glasses" aria-hidden="true"></i><span class="sr-only">Incognito Mode:</span>)</span></div>
<div style="font-weight: bold;">{{title}} <span class="clickable" *ngIf="incognitoMode" (click)="turnOffIncognito()" role="button" aria-label="Incognito mode is on. Toggle to turn off.">(<i class="fa fa-glasses" aria-hidden="true"></i><span class="visually-hidden">Incognito Mode:</span>)</span></div>
<div class="subtitle">
{{subtitle}}
</div>
</div>
<div style="margin-left: auto; padding-right: 3%;">
<button class="btn btn-icon btn-small" role="checkbox" [attr.aria-checked]="pageBookmarked" title="{{pageBookmarked ? 'Unbookmark Page' : 'Bookmark Page'}}" (click)="bookmarkPage()"><i class="{{pageBookmarked ? 'fa' : 'far'}} fa-bookmark" aria-hidden="true"></i><span class="sr-only">{{pageBookmarked ? 'Unbookmark Page' : 'Bookmark Page'}}</span></button>
<button class="btn btn-icon btn-small" role="checkbox" [attr.aria-checked]="pageBookmarked" title="{{pageBookmarked ? 'Unbookmark Page' : 'Bookmark Page'}}" (click)="bookmarkPage()"><i class="{{pageBookmarked ? 'fa' : 'far'}} fa-bookmark" aria-hidden="true"></i><span class="visually-hidden">{{pageBookmarked ? 'Unbookmark Page' : 'Bookmark Page'}}</span></button>
</div>
</div>
</div>
@ -56,9 +56,9 @@
</div>
<div class="fixed-bottom overlay" *ngIf="menuOpen" [@slideFromBottom]="menuOpen">
<div class="form-group" *ngIf="pageOptions != undefined && pageOptions.ceil != undefined">
<span class="sr-only" id="slider-info"></span>
<div class="row no-gutters">
<div class="mb-3" *ngIf="pageOptions != undefined && pageOptions.ceil != undefined">
<span class="visually-hidden" id="slider-info"></span>
<div class="row g-0">
<button class="btn btn-small btn-icon col-1" [disabled]="prevChapterDisabled" (click)="loadPrevChapter();resetMenuCloseTimer();" title="Prev Chapter/Volume"><i class="fa fa-fast-backward" aria-hidden="true"></i></button>
<button class="btn btn-small btn-icon col-1" [disabled]="prevPageDisabled || pageNum === 0" (click)="goToPage(0);resetMenuCloseTimer();" title="First Page"><i class="fa fa-step-backward" aria-hidden="true"></i></button>
<div class="col custom-slider" *ngIf="pageOptions.ceil > 0; else noSlider">
@ -75,29 +75,29 @@
</div>
<div class="row pt-4 ml-2 mr-2">
<div class="row pt-4 ms-2 me-2">
<div class="col">
<button class="btn btn-icon" (click)="setReadingDirection();resetMenuCloseTimer();" [disabled]="readerMode === READER_MODE.WEBTOON || readerMode === READER_MODE.MANGA_UD" aria-describedby="reading-direction" title="Reading Direction: {{readingDirection === ReadingDirection.LeftToRight ? 'Left to Right' : 'Right to Left'}}">
<i class="fa fa-angle-double-{{readingDirection === ReadingDirection.LeftToRight ? 'right' : 'left'}}" aria-hidden="true"></i>
<span id="reading-direction" class="sr-only">{{readingDirection === ReadingDirection.LeftToRight ? 'Left to Right' : 'Right to Left'}}</span>
<span id="reading-direction" class="visually-hidden">{{readingDirection === ReadingDirection.LeftToRight ? 'Left to Right' : 'Right to Left'}}</span>
</button>
</div>
<div class="col">
<button class="btn btn-icon" title="Reading Mode" (click)="toggleReaderMode();resetMenuCloseTimer();">
<i class="fa {{readerModeIcon}}" aria-hidden="true"></i>
<span class="sr-only">Reading Mode</span>
<span class="visually-hidden">Reading Mode</span>
</button>
</div>
<div class="col">
<button class="btn btn-icon" title="{{this.isFullscreen ? 'Collapse' : 'Fullscreen'}}" (click)="toggleFullscreen();resetMenuCloseTimer();">
<i class="fa {{this.isFullscreen ? 'fa-compress-alt' : 'fa-expand-alt'}}" aria-hidden="true"></i>
<span class="sr-only">{{this.isFullscreen ? 'Collapse' : 'Fullscreen'}}</span>
<span class="visually-hidden">{{this.isFullscreen ? 'Collapse' : 'Fullscreen'}}</span>
</button>
</div>
<div class="col">
<button class="btn btn-icon" title="Settings" (click)="settingsOpen = !settingsOpen;resetMenuCloseTimer();">
<i class="fa fa-sliders-h" aria-hidden="true"></i>
<span class="sr-only">Settings</span>
<span class="visually-hidden">Settings</span>
</button>
</div>
</div>
@ -105,13 +105,13 @@
<form [formGroup]="generalSettingsForm">
<div class="row">
<div class="col-6">
<label for="page-splitting">Image Splitting</label>&nbsp;
<label for="page-splitting" class="form-label">Image Splitting</label>&nbsp;
<div class="split fa fa-image">
<div class="{{splitIconClass}}"></div>
</div>
</div>
<div class="col-6">
<div class="form-group">
<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>
@ -121,7 +121,7 @@
<div class="row">
<div class="col-6">
<label for="page-fitting">Image Scaling</label>&nbsp;<i class="fa {{getFittingIcon()}}" aria-hidden="true"></i>
<label for="page-fitting" class="form-label">Image Scaling</label>&nbsp;<i class="fa {{getFittingIcon()}}" aria-hidden="true"></i>
</div>
<div class="col-6">
<select class="form-control" id="page-fitting" formControlName="fittingOption">

View File

@ -1,5 +1,3 @@
@use '../../theme/colors';
$center-width: 50%;
$side-width: 25%;
@ -16,16 +14,16 @@ $pointer-offset: 5px;
}
}
.btn-icon {
color: white;
}
// .btn-icon {
// color: white;
// }
canvas {
position: absolute;
}
.reader {
background-color: black;
background-color: var(--manga-reader-bg-color);
overflow: auto;
img {
@ -58,9 +56,9 @@ canvas {
.overlay {
background-color: rgba(0,0,0,0.5);
backdrop-filter: blur(10px); // BUG: This doesn't work on Firefox
color: white;
background-color: var(--manga-reader-overlay-bg-color);
backdrop-filter: var(--manga-reader-overlay-filter); // BUG: This doesn't work on Firefox
color: var(--manga-reader-overlay-text-color);
}
// Fitting Options
@ -175,7 +173,7 @@ canvas {
height: 2px;
}
.custom-slider .ngx-slider .ngx-slider-selection {
background: colors.$primary-color;
background: var(--primary-color);
}
.custom-slider .ngx-slider .ngx-slider-pointer {
@ -183,7 +181,7 @@ canvas {
height: 16px;
top: auto; /* to remove the default positioning */
bottom: 0;
background-color: colors.$primary-color; // #333;
background-color: var(--primary-color); // #333;
border-top-left-radius: 3px;
border-top-right-radius: 3px;
}
@ -214,7 +212,7 @@ canvas {
}
.custom-slider .ngx-slider .ngx-slider-tick.ngx-slider-selected {
background: colors.$primary-color;
background: var(--primary-color);
}
}
@ -235,12 +233,12 @@ canvas {
}
.highlight {
background-color: rgba(65, 225, 100, 0.5) !important;
background-color: var(--manga-reader-next-highlight-bg-color) !important;
animation: fadein .5s both;
backdrop-filter: blur(10px);
}
.highlight-2 {
background-color: rgba(65, 105, 225, 0.5) !important;
background-color: var(--manga-reader-prev-highlight-bg-color) !important;
animation: fadein .5s both;
backdrop-filter: blur(10px);
}
@ -255,6 +253,6 @@ canvas {
border: 0px;
}
50% {
border: 5px solid colors.$primary-color;
border: 5px solid var(--primary-color);
}
}

View File

@ -2,7 +2,7 @@
<button type="button" class="btn btn-icon {{(progressEventsSource.getValue().length > 0 || updateAvailable) ? 'colored' : ''}}"
[ngbPopover]="popContent" title="Activity" placement="bottom" [popoverClass]="'nav-events'">
<i aria-hidden="true" class="fa fa-wave-square"></i>
<i aria-hidden="true" class="fa fa-wave-square nav"></i>
</button>
<ng-template #popContent>
@ -11,7 +11,7 @@
<div class="spinner-border text-primary small-spinner"
role="status" title="Started at {{event.timestamp | date: 'short'}}"
attr.aria-valuetext="{{prettyPrintProgress(event.progress)}}%" [attr.aria-valuenow]="prettyPrintProgress(event.progress)">
<span class="sr-only">Scan for {{event.libraryName}} in progress</span>
<span class="visually-hidden">Scan for {{event.libraryName}} in progress</span>
</div>
{{prettyPrintProgress(event.progress)}}%
{{prettyPrintEvent(event.eventType, event)}} {{event.libraryName}}

View File

@ -1,4 +1,28 @@
@use "../../theme/colors";
// NOTE: I'm leaving this not fully customized because I'm planning to rewrite the whole design in v0.5.2/3
// These are customizations for events nav
.dark-menu {
background-color: var(--navbar-bg-color);
border-color: rgba(1, 4, 9, 0.5);
}
.dark-menu-item {
color: var(--body-text-color);
background-color: rgb(1, 4, 9);
border-color: rgba(1, 4, 9, 0.5);
}
// Popovers need to be their own component
::ng-deep .bs-popover-bottom > .popover-arrow::after, .bs-popover-bottom > .popover-arrow::before {
border-bottom-color: transparent;
}
.nav-events {
background-color: var(--navbar-bg-color);
}
// .nav-events {
// background-color: white;
// }
.btn:focus, .btn:hover {
@ -10,9 +34,7 @@
height: 1rem;
}
.nav-events {
background-color: white;
}
.nav-events .popover-body {
padding: 0px;
@ -23,7 +45,7 @@
}
.colored {
background-color: colors.$primary-color;
background-color: var(--primary-color);
border-radius: 60px;
}
@ -31,7 +53,7 @@
cursor: pointer;
i.fa {
color: colors.$primary-color !important;
color: var(--primary-color) !important;
}
color: colors.$primary-color;
color: var(--primary-color);
}

View File

@ -19,8 +19,9 @@ interface ProcessedEvent {
type ProgressType = EVENTS.ScanLibraryProgress | EVENTS.RefreshMetadataProgress | EVENTS.BackupDatabaseProgress | EVENTS.CleanupProgress;
const acceptedEvents = [EVENTS.ScanLibraryProgress, EVENTS.RefreshMetadataProgress, EVENTS.BackupDatabaseProgress, EVENTS.CleanupProgress, EVENTS.DownloadProgress];
const acceptedEvents = [EVENTS.ScanLibraryProgress, EVENTS.RefreshMetadataProgress, EVENTS.BackupDatabaseProgress, EVENTS.CleanupProgress, EVENTS.DownloadProgress, EVENTS.SiteThemeProgress];
// TODO: Rename this to events widget
@Component({
selector: 'app-nav-events-toggle',
templateUrl: './nav-events-toggle.component.html',

View File

@ -1,112 +1,106 @@
<nav class="navbar navbar-expand-md navbar-dark fixed-top" *ngIf="navService?.navbarVisible$ | async">
<div class="container-fluid">
<a class="sr-only sr-only-focusable focus-visible" href="javascript:void(0);" (click)="moveFocus()">Skip to main content</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="phone-hidden"> Kavita</span></a>
<ul class="navbar-nav col mr-auto">
<a class="visually-hidden-focusable focus-visible" href="javascript:void(0);" (click)="moveFocus()">Skip to main content</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">
<div class="nav-item" *ngIf="(accountService.currentUser$ | async) as user">
<div>
<fieldset class="form-inline">
<div class="form-group" style="margin-bottom: 0px;">
<label for="nav-search" class="sr-only">Search series</label>
<div class="ng-autocomplete">
<app-grouped-typeahead
#search
id="nav-search"
[minQueryLength]="2"
initialValue=""
placeholder="Search…"
[grouppedData]="searchResults"
(inputChanged)="onChangeSearch($event)"
(clearField)="clearSearch()"
(focusChanged)="focusUpdate($event)"
>
<label for="nav-search" class="form-label visually-hidden">Search series</label>
<div class="ng-autocomplete">
<app-grouped-typeahead
#search
id="nav-search"
[minQueryLength]="2"
initialValue=""
placeholder="Search…"
[grouppedData]="searchResults"
(inputChanged)="onChangeSearch($event)"
(clearField)="clearSearch()"
(focusChanged)="focusUpdate($event)"
>
<ng-template #libraryTemplate let-item>
<div style="display: flex;padding: 5px;" (click)="clickLibraryResult(item)">
<div class="ml-1">
<span>{{item.name}}</span>
</div>
</div>
</ng-template>
<ng-template #seriesTemplate let-item>
<div style="display: flex;padding: 5px;" (click)="clickSeriesSearchResult(item)">
<div style="width: 24px" class="mr-1">
<app-image class="mr-3 search-result" width="24px" [imageUrl]="imageService.getSeriesCoverImage(item.seriesId)"></app-image>
</div>
<div class="ml-1">
<app-series-format [format]="item.format"></app-series-format>
<span *ngIf="item.name.toLowerCase().trim().indexOf(searchTerm) >= 0; else localizedName">{{item.name}}</span>
<ng-template #localizedName>
<span [innerHTML]="item.localizedName"></span>
</ng-template>
<span class="form-text" style="font-size: 0.8rem;">in {{item.libraryName}}</span>
</div>
</div>
</ng-template>
<ng-template #collectionTemplate let-item>
<div style="display: flex;padding: 5px;" (click)="clickCollectionSearchResult(item)">
<div style="width: 24px" class="mr-1">
<app-image class="mr-3 search-result" width="24px" [imageUrl]="imageService.getCollectionCoverImage(item.id)"></app-image>
</div>
<div class="ml-1">
<span>{{item.title}}</span>
<span *ngIf="item.promoted">
&nbsp;<i class="fa fa-angle-double-up" aria-hidden="true" title="Promoted"></i>
<span class="sr-only">(promoted)</span>
</span>
</div>
</div>
</ng-template>
<ng-template #readingListTemplate let-item>
<div style="display: flex;padding: 5px;" (click)="clickReadingListSearchResult(item)">
<div class="ml-1">
<span>{{item.title}}</span>
<span *ngIf="item.promoted">
&nbsp;<i class="fa fa-angle-double-up" aria-hidden="true" title="Promoted"></i>
<span class="sr-only">(promoted)</span>
</span>
</div>
</div>
</ng-template>
<ng-template #tagTemplate let-item>
<div style="display: flex;padding: 5px;" (click)="goTo('tags', item.id)">
<div class="ml-1">
<span>{{item.title}}</span>
</div>
</div>
</ng-template>
<ng-template #personTemplate let-item>
<div style="display: flex;padding: 5px;" class="clickable" (click)="goToPerson(item.role, item.id)">
<div class="ml-1">
<div [innerHTML]="item.name"></div>
<div>{{item.role | personRole}}</div>
</div>
</div>
</ng-template>
<ng-template #genreTemplate let-item>
<div style="display: flex;padding: 5px;" class="clickable" (click)="goTo('genres', item.id)">
<div class="ml-1">
<div [innerHTML]="item.title"></div>
</div>
</div>
</ng-template>
<ng-template #noResultsTemplate let-notFound>
No results found
</ng-template>
</app-grouped-typeahead>
</div>
<ng-template #libraryTemplate let-item>
<div style="display: flex;padding: 5px;" (click)="clickLibraryResult(item)">
<div class="ms-1">
<span>{{item.name}}</span>
</div>
</fieldset>
</div>
</ng-template>
<ng-template #seriesTemplate let-item>
<div style="display: flex;padding: 5px;" (click)="clickSeriesSearchResult(item)">
<div style="width: 24px" class="me-1">
<app-image class="me-3 search-result" width="24px" [imageUrl]="imageService.getSeriesCoverImage(item.seriesId)"></app-image>
</div>
<div class="ms-1">
<app-series-format [format]="item.format"></app-series-format>
<span *ngIf="item.name.toLowerCase().trim().indexOf(searchTerm) >= 0; else localizedName">{{item.name}}</span>
<ng-template #localizedName>
<span [innerHTML]="item.localizedName"></span>
</ng-template>
<div class="form-text" style="font-size: 0.8rem;">in {{item.libraryName}}</div>
</div>
</div>
</ng-template>
<ng-template #collectionTemplate let-item>
<div style="display: flex;padding: 5px;" (click)="clickCollectionSearchResult(item)">
<div style="width: 24px" class="me-1">
<app-image class="me-3 search-result" width="24px" [imageUrl]="imageService.getCollectionCoverImage(item.id)"></app-image>
</div>
<div class="ms-1">
<span>{{item.title}}</span>
<span *ngIf="item.promoted">
&nbsp;<i class="fa fa-angle-double-up" aria-hidden="true" title="Promoted"></i>
<span class="visually-hidden">(promoted)</span>
</span>
</div>
</div>
</ng-template>
<ng-template #readingListTemplate let-item>
<div style="display: flex;padding: 5px;" (click)="clickReadingListSearchResult(item)">
<div class="ms-1">
<span>{{item.title}}</span>
<span *ngIf="item.promoted">
&nbsp;<i class="fa fa-angle-double-up" aria-hidden="true" title="Promoted"></i>
<span class="visually-hidden">(promoted)</span>
</span>
</div>
</div>
</ng-template>
<ng-template #tagTemplate let-item>
<div style="display: flex;padding: 5px;" (click)="goTo('tags', item.id)">
<div class="ms-1">
<span>{{item.title}}</span>
</div>
</div>
</ng-template>
<ng-template #personTemplate let-item>
<div style="display: flex;padding: 5px;" class="clickable" (click)="goToPerson(item.role, item.id)">
<div class="ms-1">
<div [innerHTML]="item.name"></div>
<div>{{item.role | personRole}}</div>
</div>
</div>
</ng-template>
<ng-template #genreTemplate let-item>
<div style="display: flex;padding: 5px;" class="clickable" (click)="goTo('genres', item.id)">
<div class="ms-1">
<div [innerHTML]="item.title"></div>
</div>
</div>
</ng-template>
<ng-template #noResultsTemplate let-notFound>
No results found
</ng-template>
</app-grouped-typeahead>
</div>
</div>
</ul>
@ -114,8 +108,8 @@
<ng-container *ngIf="!searchFocused">
<div class="back-to-top">
<button class="btn btn-icon scroll-to-top" (click)="scrollToTop()" *ngIf="backToTopNeeded">
<i class="fa fa-angle-double-up" style="color: white" aria-hidden="true"></i>
<span class="sr-only">Scroll to Top</span>
<i class="fa fa-angle-double-up nav" aria-hidden="true"></i>
<span class="visually-hidden">Scroll to Top</span>
</button>
</div>
@ -123,10 +117,10 @@
<div class="nav-item">
<app-nav-events-toggle [user]="user"></app-nav-events-toggle>
</div>
<div class="nav-item pr-2 not-xs-only">
<a routerLink="/admin/dashboard" *ngIf="user.roles.includes('Admin')" class="dark-exempt btn btn-icon">
<i class="fa fa-cogs" aria-hidden="true" style="color: white"></i>
<span class="sr-only">Server Settings</span>
<div class="nav-item pe-2 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>
</a>
</div>

View File

@ -1,24 +1,21 @@
@import '~bootstrap/scss/mixins/_breakpoints.scss';
$primary-color: white;
$bg-color: rgb(22, 27, 34);
.btn:focus, .btn:hover {
box-shadow: 0 0 0 0.1rem rgba(255, 255, 255, 1);
box-shadow: 0 0 0 0.1rem var(--navbar-btn-hover-outline-color);
}
.navbar {
background-color: $bg-color;
background-color: var(--navbar-bg-color);
}
/* small devices (phones, 650px and down) */
@media only screen and (max-width:650px) { //370
@media only screen and (max-width:650px) {
.navbar-nav {
width: 0;
}
}
// On Really small screens, hide the server settings wheel and show it in nav
// TODO: Look into doing this with bootstrap 5 (and moving to _utilities.scss)
.xs-only {
display: none;
}
@ -39,7 +36,7 @@ $bg-color: rgb(22, 27, 34);
}
.navbar-brand {
font-family: "Spartan", sans-serif;
font-family: var(--brand-font-family);
font-weight: bold;
.logo {
@ -54,7 +51,7 @@ $bg-color: rgb(22, 27, 34);
.focus-visible:focus {
visibility: visible;
color: white;
color: var(--nav-header-text-color);
}
.ng-autocomplete {
@ -62,7 +59,7 @@ $bg-color: rgb(22, 27, 34);
}
.primary-text {
color: $primary-color;
color: var(--nav-header-text-color);
border: none;
}
@ -75,27 +72,12 @@ $bg-color: rgb(22, 27, 34);
width: 100%;
}
@media (min-width: 576px) {
@media (min-width: var(--grid-breakpoints-sm)) {
.form-inline .form-group {
width: 100%;
}
}
@include media-breakpoint-down(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px)) {
.ng-autocomplete {
width: 100%; // 232px
}
}
.scroll-to-top:hover {
animation: MoveUpDown 1s linear infinite;
}
@keyframes MoveUpDown {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}

View File

@ -50,15 +50,15 @@ export class NavHeaderComponent implements OnInit, OnDestroy {
private scrollService: ScrollService) { }
ngOnInit(): void {
this.navService.darkMode$.pipe(takeUntil(this.onDestroy)).subscribe(res => {
if (res) {
this.document.body.classList.remove('bg-light');
this.document.body.classList.add('bg-dark');
} else {
this.document.body.classList.remove('bg-dark');
this.document.body.classList.add('bg-light');
}
});
// this.navService.darkMode$.pipe(takeUntil(this.onDestroy)).subscribe(res => {
// if (res) {
// this.document.body.classList.remove('bg-light');
// this.document.body.classList.add('bg-dark');
// } else {
// this.document.body.classList.remove('bg-dark');
// this.document.body.classList.add('bg-light');
// }
// });
}
@HostListener("window:scroll", [])

View File

@ -1,19 +1,17 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Add to Reading List</h4>
<button type="button" class="close" aria-label="Close" (click)="close()">
<span aria-hidden="true">&times;</span>
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
</button>
</div>
<form style="width: 100%" [formGroup]="listForm">
<div class="modal-body">
<div class="form-group" *ngIf="lists.length >= 5">
<label for="filter">Filter</label>
<div class="mb-3" *ngIf="lists.length >= 5">
<label for="filter" class="form-label">Filter</label>
<div class="input-group">
<input id="filter" autocomplete="off" class="form-control" formControlName="filterQuery" type="text" aria-describedby="reset-input">
<div class="input-group-append">
<button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="listForm.get('filterQuery')?.setValue('');">Clear</button>
</div>
<button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="listForm.get('filterQuery')?.setValue('');">Clear</button>
</div>
</div>
<ul class="list-group">
@ -23,7 +21,7 @@
<li class="list-group-item" *ngIf="lists.length === 0 && !loading">No lists created yet</li>
<li class="list-group-item" *ngIf="loading">
<div class="spinner-border text-secondary" role="status">
<span class="sr-only">Loading...</span>
<span class="visually-hidden">Loading...</span>
</div>
</li>
</ul>
@ -32,7 +30,7 @@
<div style="width: 100%;">
<div class="form-row">
<div class="col-9 col-lg-10">
<label class="sr-only" for="add-rlist">Reading List</label>
<label class="form-label visually-hidden" for="add-rlist">Reading List</label>
<input width="100%" #title ngbAutofocus type="text" class="form-control mb-2" id="add-rlist" formControlName="title">
</div>
<div class="col-2">

View File

@ -1,9 +1,4 @@
@use '../../../../theme/colors';
.clickable {
cursor: pointer;
}
.clickable:hover, .clickable:focus {
background-color: colors.$primary-color;
background-color: var(--primary-color);
}

View File

@ -1,8 +1,8 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Edit {{readingList.title}} Reading List</h4>
<button type="button" class="close" aria-label="Close" (click)="close()">
<span aria-hidden="true">&times;</span>
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
</button>
</div>
<div class="modal-body">
@ -11,20 +11,20 @@
Promotion means that the list can be seen server-wide, not just for admin users. All series that are within this list will still have user-access restrictions placed on them.
</p>
<form [formGroup]="reviewGroup">
<div class="form-group">
<label for="title">Name</label>
<div class="mb-3">
<label for="title" class="form-label">Name</label>
<input id="title" class="form-control" formControlName="title" type="text">
</div>
<div class="form-group">
<label for="summary">Summary</label>
<div class="mb-3">
<label for="summary" class="form-label">Summary</label>
<textarea id="summary" class="form-control" formControlName="summary" rows="3"></textarea>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="close()">Close</button>
<button type="button" class="btn btn-info" (click)="togglePromotion()">{{readingList.promoted ? 'Demote' : 'Promote'}}</button>
<button type="button" class="btn btn-secondary alt" (click)="togglePromotion()">{{readingList.promoted ? 'Demote' : 'Promote'}}</button>
<button type="submit" class="btn btn-primary" [disabled]="reviewGroup.get('title')?.value.trim().length === 0" (click)="save()">Save</button>
</div>

View File

@ -1,23 +1,23 @@
<div cdkDropList class="{{items.length > 0 ? 'example-list list-group-flush' : ''}}" (cdkDropListDropped)="drop($event)">
<div class="example-box" *ngFor="let item of items; index as i" cdkDrag [cdkDragData]="item" cdkDragBoundary=".example-list">
<div class="mr-3 align-middle">
<div class="me-3 align-middle">
<i class="fa fa-grip-vertical drag-handle" aria-hidden="true" cdkDragHandle></i>
</div>
<ng-container style="display: inline-block" [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
<div class="align-middle" style="padding-top: 40px">
<label for="reorder-{{i}}" class="sr-only">Reorder</label>
<label for="reorder-{{i}}" class="form-label visually-hidden">Reorder</label>
<input *ngIf="accessibilityMode" id="reorder-{{i}}" type="number" min="0" [max]="items.length - 1" [value]="i" style="width: 40px" (focusout)="updateIndex(i, item)" (keydown.enter)="updateIndex(i, item)" aria-describedby="instructions">
</div>
<button class="btn btn-icon pull-right" (click)="removeItem(item, i)">
<i class="fa fa-times" aria-hidden="true"></i>
<span class="sr-only" attr.aria-labelledby="item.id--{{i}}">Remove item</span>
<span class="visually-hidden" attr.aria-labelledby="item.id--{{i}}">Remove item</span>
</button>
</div>
</div>
<p class="sr-only" id="instructions">
<p class="visually-hidden" id="instructions">
</p>

View File

@ -1,19 +1,19 @@
<div class="container-fluid mt-2" *ngIf="readingList">
<div class="mb-3">
<!-- Title row-->
<div class="row no-gutters">
<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>&nbsp;
</span>
{{readingList.title}}&nbsp;<span *ngIf="readingList?.promoted">(<i class="fa fa-angle-double-up" aria-hidden="true"></i>)</span>&nbsp;
<span class="badge badge-primary badge-pill" attr.aria-label="{{items.length}} total items">{{items.length}}</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 no-gutters">
<div class="mr-2">
<div class="row g-0">
<div class="col-auto me-2">
<button class="btn btn-primary" title="Read" (click)="read()">
<span>
<i class="fa fa-book-open" aria-hidden="true"></i>
@ -21,7 +21,7 @@
</span>
</button>
</div>
<div>
<div class="col-auto">
<button class="btn btn-secondary" (click)="removeRead()" [disabled]="readingList?.promoted && !this.isAdmin">
<span>
<i class="fa fa-check"></i>
@ -29,7 +29,7 @@
<span class="read-btn--text">&nbsp;Remove Read</span>
</button>
</div>
<div class="ml-2 mt-2" *ngIf="!(readingList?.promoted && !this.isAdmin)">
<div class="col-auto ms-2 mt-2" *ngIf="!(readingList?.promoted && !this.isAdmin)">
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="accessibilit-mode" [value]="accessibilityMode" (change)="accessibilityMode = !accessibilityMode">
<label class="form-check-label" for="accessibilit-mode">Order Numbers</label>
@ -37,7 +37,7 @@
</div>
</div>
<!-- Summary row-->
<div class="row no-gutters mt-2">
<div class="row g-0 mt-2">
<app-read-more [text]="readingList.summary" [maxLength]="250"></app-read-more>
</div>
</div>
@ -48,18 +48,18 @@
<app-dragable-ordered-list [items]="items" (orderUpdated)="orderUpdated($event)" (itemRemove)="itemRemoved($event)" [accessibilityMode]="accessibilityMode">
<ng-template #draggableItem let-item let-position="idx">
<div class="media" style="width: 100%;">
<app-image width="74px" class="img-top mr-3" [imageUrl]="imageService.getChapterCoverImage(item.chapterId)"></app-image>
<div class="media-body">
<div class="d-flex" style="width: 100%;">
<app-image width="74px" class="img-top me-3" [imageUrl]="imageService.getChapterCoverImage(item.chapterId)"></app-image>
<div class="flex-grow-1">
<h5 class="mt-0 mb-1" id="item.id--{{position}}">{{formatTitle(item)}}&nbsp;
<span class="badge badge-primary badge-pill">
<span class="badge bg-primary rounded-pill">
<span *ngIf="item.pagesRead > 0 && item.pagesRead < item.pagesTotal">{{item.pagesRead}} / {{item.pagesTotal}}</span>
<span *ngIf="item.pagesRead === 0">UNREAD</span>
<span *ngIf="item.pagesRead === item.pagesTotal">READ</span>
</span>
</h5>
<i class="fa {{utilityService.mangaFormatIcon(item.seriesFormat)}}" aria-hidden="true" *ngIf="item.seriesFormat != MangaFormat.UNKNOWN" title="{{utilityService.mangaFormat(item.seriesFormat)}}"></i>
<span class="sr-only">{{utilityService.mangaFormat(item.seriesFormat)}}</span>&nbsp;
<span class="visually-hidden">{{utilityService.mangaFormat(item.seriesFormat)}}</span>&nbsp;
<a href="/library/{{item.libraryId}}/series/{{item.seriesId}}">{{item.seriesName}}</a>
<span *ngIf="item.promoted">

View File

@ -1,7 +1,7 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Account Migration</h4>
<button type="button" class="close" aria-label="Close" (click)="close()">
<span aria-hidden="true">&times;</span>
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
</button>
</div>
<div class="modal-body">
@ -12,8 +12,8 @@
<p class="text-danger" *ngIf="error.length > 0">{{error}}</p>
<form [formGroup]="registerForm">
<div class="form-group">
<label for="username">Username</label>
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input id="username" class="form-control" formControlName="username" type="text">
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="registerForm.dirty || registerForm.touched">
<div *ngIf="registerForm.get('username')?.errors?.required">
@ -22,8 +22,8 @@
</div>
</div>
<div class="form-group" style="width:100%">
<label for="email">Email</label>
<div class="mb-3" style="width:100%">
<label for="email" class="form-label">Email</label>
<input class="form-control" type="email" id="email" formControlName="email" required>
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="registerForm.dirty || registerForm.touched">
<div *ngIf="registerForm.get('email')?.errors?.required">
@ -35,8 +35,8 @@
</div>
</div>
<div class="form-group">
<label for="password">Password</label>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input id="password" class="form-control" maxlength="32" minlength="6" formControlName="password" type="password" aria-describedby="password-help">
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="registerForm.dirty || registerForm.touched">
<div *ngIf="registerForm.get('password')?.errors?.required">

View File

@ -10,8 +10,8 @@
</ul>
</div>
<form [formGroup]="registerForm" (ngSubmit)="submit()">
<div class="form-group">
<label for="username">Username</label>
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input id="username" class="form-control" formControlName="username" type="text">
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="registerForm.dirty || registerForm.touched">
<div *ngIf="registerForm.get('username')?.errors?.required">
@ -20,8 +20,8 @@
</div>
</div>
<div class="form-group" style="width:100%">
<label for="email">Email</label>
<div class="mb-3" style="width:100%">
<label for="email" class="form-label">Email</label>
<input class="form-control" type="email" id="email" formControlName="email" required>
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="registerForm.dirty || registerForm.touched">
<div *ngIf="registerForm.get('email')?.errors?.required">
@ -33,12 +33,12 @@
</div>
</div>
<div class="form-group">
<label for="password">Password</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="passwordTooltip" role="button" tabindex="0"></i>
<div class="mb-3">
<label for="password" class="form-label">Password</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="passwordTooltip" role="button" tabindex="0"></i>
<ng-template #passwordTooltip>
Password must be between 6 and 32 characters in length
</ng-template>
<span class="sr-only" id="password-help"><ng-container [ngTemplateOutlet]="passwordTooltip"></ng-container></span>
<span class="visually-hidden" id="password-help"><ng-container [ngTemplateOutlet]="passwordTooltip"></ng-container></span>
<input id="password" class="form-control" maxlength="32" minlength="6" formControlName="password" type="password" aria-describedby="password-help">
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="registerForm.dirty || registerForm.touched">
<div *ngIf="registerForm.get('password')?.errors?.required">
@ -50,7 +50,7 @@
</div>
</div>
<div class="float-right">
<div class="float-end">
<button class="btn btn-secondary alt" type="submit">Register</button>
</div>
</form>

View File

@ -2,6 +2,7 @@ import { Component, Input, OnInit } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { ToastrService } from 'ngx-toastr';
import { ThemeService } from 'src/app/theme.service';
import { AccountService } from 'src/app/_services/account.service';
@Component({
@ -10,8 +11,6 @@ import { AccountService } from 'src/app/_services/account.service';
styleUrls: ['./confirm-email.component.scss']
})
export class ConfirmEmailComponent implements OnInit {
/**
* Email token used for validating
*/
@ -29,8 +28,9 @@ export class ConfirmEmailComponent implements OnInit {
errors: Array<string> = [];
constructor(private route: ActivatedRoute, private router: Router, private accountService: AccountService, private toastr: ToastrService) {
constructor(private route: ActivatedRoute, private router: Router, private accountService: AccountService,
private toastr: ToastrService, private themeService: ThemeService) {
this.themeService.setTheme(this.themeService.defaultTheme);
const token = this.route.snapshot.queryParamMap.get('token');
const email = this.route.snapshot.queryParamMap.get('email');
if (token == undefined || token === '' || token === null) {

Some files were not shown because too many files have changed in this diff Show More