Lots of Bugfixes (#2960)

Co-authored-by: Samuel Martins <s@smartins.ch>
Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
This commit is contained in:
Joe Milazzo 2024-05-22 06:58:23 -05:00 committed by GitHub
parent 97ffdd0975
commit b50fa0fd1e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
45 changed files with 563 additions and 282 deletions

View File

@ -21,7 +21,7 @@ jobs:
- name: Install Swashbuckle CLI
shell: powershell
run: dotnet tool install -g --version 6.5.0 Swashbuckle.AspNetCore.Cli
run: dotnet tool install -g Swashbuckle.AspNetCore.Cli
- name: Install dependencies
run: dotnet restore

View File

@ -6,7 +6,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.5" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
<PackageReference Include="NSubstitute" Version="5.1.0" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="21.0.2" />

View File

@ -74,7 +74,7 @@ public class WordCountAnalysisTests : AbstractDbTest
var cacheService = new CacheHelper(new FileService());
var service = new WordCountAnalyzerService(Substitute.For<ILogger<WordCountAnalyzerService>>(), _unitOfWork,
Substitute.For<IEventHub>(), cacheService, _readerService);
Substitute.For<IEventHub>(), cacheService, _readerService, Substitute.For<IMediaErrorService>());
await service.ScanSeries(1, 1);
@ -126,7 +126,7 @@ public class WordCountAnalysisTests : AbstractDbTest
var cacheService = new CacheHelper(new FileService());
var service = new WordCountAnalyzerService(Substitute.For<ILogger<WordCountAnalyzerService>>(), _unitOfWork,
Substitute.For<IEventHub>(), cacheService, _readerService);
Substitute.For<IEventHub>(), cacheService, _readerService, Substitute.For<IMediaErrorService>());
await service.ScanSeries(1, 1);
var chapter2 = new ChapterBuilder("2")

View File

@ -12,9 +12,9 @@
<LangVersion>latestmajor</LangVersion>
</PropertyGroup>
<Target Name="PostBuild" AfterTargets="Build" Condition=" '$(Configuration)' == 'Debug' ">
<Exec Command="swagger tofile --output ../openapi.json bin/$(Configuration)/$(TargetFramework)/$(AssemblyName).dll v1" />
</Target>
<!-- <Target Name="PostBuild" AfterTargets="Build" Condition=" '$(Configuration)' == 'Debug' ">-->
<!-- <Exec Command="swagger tofile &#45;&#45;output ../openapi.json bin/$(Configuration)/$(TargetFramework)/$(AssemblyName).dll v1" />-->
<!-- </Target>-->
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<DebugSymbols>false</DebugSymbols>
@ -95,13 +95,13 @@
<PackageReference Include="Serilog.Sinks.SignalR.Core" Version="0.1.2" />
<PackageReference Include="SharpCompress" Version="0.37.2" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.4" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.24.0.89429">
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.25.0.90414">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.1" />
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="8.0.2" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.5.1" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.5.2" />
<PackageReference Include="System.IO.Abstractions" Version="21.0.2" />
<PackageReference Include="System.Drawing.Common" Version="8.0.4" />
<PackageReference Include="VersOne.Epub" Version="3.3.1" />

View File

@ -0,0 +1,48 @@
using System;
using System.Threading.Tasks;
using API.Entities;
using Kavita.Common.EnvironmentInfo;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace API.Data.ManualMigrations;
/// <summary>
/// v0.8.2 switches Default Kavita installs to WAL
/// </summary>
public static class ManualMigrateSwitchToWal
{
public static async Task Migrate(DataContext context, ILogger<Program> logger)
{
if (await context.ManualMigrationHistory.AnyAsync(m => m.Name == "ManualMigrateSwitchToWal"))
{
return;
}
logger.LogCritical("Running ManualMigrateSwitchToWal migration - Please be patient, this may take some time. This is not an error");
try
{
var connection = context.Database.GetDbConnection();
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = "PRAGMA journal_mode=WAL;";
await command.ExecuteNonQueryAsync();
}
catch (Exception ex)
{
logger.LogError(ex, "Error setting WAL");
/* Swallow */
}
await context.ManualMigrationHistory.AddAsync(new ManualMigrationHistory()
{
Name = "ManualMigrateSwitchToWal",
ProductVersion = BuildInfo.Version.ToString(),
RanAt = DateTime.UtcNow
});
await context.SaveChangesAsync();
logger.LogCritical("Running ManualMigrateSwitchToWal migration - Completed. This is not an error");
}
}

View File

@ -0,0 +1,49 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using API.Entities;
using Kavita.Common.EnvironmentInfo;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace API.Data.ManualMigrations;
/// <summary>
/// v0.8.2 introduced Theme repo viewer, this adds Description to existing SiteTheme defaults
/// </summary>
public static class ManualMigrateThemeDescription
{
public static async Task Migrate(DataContext context, ILogger<Program> logger)
{
if (await context.ManualMigrationHistory.AnyAsync(m => m.Name == "ManualMigrateThemeDescription"))
{
return;
}
logger.LogCritical("Running ManualMigrateThemeDescription migration - Please be patient, this may take some time. This is not an error");
var theme = await context.SiteTheme.FirstOrDefaultAsync(t => t.Name == "Dark");
if (theme != null)
{
theme.Description = Seed.DefaultThemes.First().Description;
}
if (context.ChangeTracker.HasChanges())
{
await context.SaveChangesAsync();
}
await context.ManualMigrationHistory.AddAsync(new ManualMigrationHistory()
{
Name = "ManualMigrateThemeDescription",
ProductVersion = BuildInfo.Version.ToString(),
RanAt = DateTime.UtcNow
});
await context.SaveChangesAsync();
logger.LogCritical("Running ManualMigrateThemeDescription migration - Completed. This is not an error");
}
}

View File

@ -1748,12 +1748,12 @@ public class SeriesRepository : ISeriesRepository
{
// This is due to v0.5.6 introducing bugs where we could have multiple series get duplicated and no way to delete them
// This here will delete the 2nd one as the first is the one to likely be used.
var sId = _context.Series
var sId = await _context.Series
.Where(s => s.Format == parsedSeries.Format && s.NormalizedName == parsedSeries.NormalizedName &&
s.LibraryId == libraryId)
.Select(s => s.Id)
.OrderBy(s => s)
.Last();
.LastAsync();
if (sId > 0)
{
ids.Add(sId);

View File

@ -35,6 +35,7 @@ public static class Seed
Provider = ThemeProvider.System,
FileName = "dark.scss",
IsDefault = true,
Description = "Default theme shipped with Kavita"
}
}.ToArray()
];

View File

@ -99,10 +99,13 @@ public class Program
// Apply all migrations on startup
logger.LogInformation("Running Migrations");
// v0.7.14
try
{
// v0.7.14
await MigrateWantToReadExport.Migrate(context, directoryService, logger);
// v0.8.2
await ManualMigrateSwitchToWal.Migrate(context, logger);
}
catch (Exception ex)
{

View File

@ -73,7 +73,8 @@ public class BookService : IBookService
{
PackageReaderOptions = new PackageReaderOptions
{
IgnoreMissingToc = true
IgnoreMissingToc = true,
SkipInvalidManifestItems = true
}
};

View File

@ -1,8 +1,10 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using API.Data;
using API.DTOs.Reader;
@ -51,6 +53,8 @@ public class CacheService : ICacheService
private readonly IReadingItemService _readingItemService;
private readonly IBookmarkService _bookmarkService;
private static readonly ConcurrentDictionary<int, SemaphoreSlim> ExtractLocks = new();
public CacheService(ILogger<CacheService> logger, IUnitOfWork unitOfWork,
IDirectoryService directoryService, IReadingItemService readingItemService,
IBookmarkService bookmarkService)
@ -166,9 +170,17 @@ public class CacheService : ICacheService
var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId);
var extractPath = GetCachePath(chapterId);
SemaphoreSlim extractLock = ExtractLocks.GetOrAdd(chapterId, id => new SemaphoreSlim(1,1));
await extractLock.WaitAsync();
try {
if(_directoryService.Exists(extractPath)) return chapter;
var files = chapter?.Files.ToList();
ExtractChapterFiles(extractPath, files, extractPdfToImages);
} finally {
extractLock.Release();
}
return chapter;
}
@ -190,6 +202,14 @@ public class CacheService : ICacheService
var extractDi = _directoryService.FileSystem.DirectoryInfo.New(extractPath);
if (files.Count > 0 && files[0].Format == MangaFormat.Image)
{
// Check if all the files are Images. If so, do a directory copy, else do the normal copy
if (files.All(f => f.Format == MangaFormat.Image))
{
_directoryService.ExistOrCreate(extractPath);
_directoryService.CopyFilesToDirectory(files.Select(f => f.FilePath), extractPath);
}
else
{
foreach (var file in files)
{
@ -202,6 +222,8 @@ public class CacheService : ICacheService
_directoryService.Flatten(extractDi.FullName);
}
}
foreach (var file in files)
{
if (fileCount > 1)

View File

@ -439,18 +439,13 @@ public class ImageService : IImageService
rows = 1;
cols = 2;
}
else if (coverImages.Count == 3)
{
rows = 2;
cols = 2;
}
else
{
// Default to 2x2 layout for more than 3 images
rows = 2;
cols = 2;
}
var image = Image.Black(dims.Width, dims.Height);
var thumbnailWidth = image.Width / cols;

View File

@ -60,7 +60,6 @@ public interface IScrobblingService
public class ScrobblingService : IScrobblingService
{
private readonly IUnitOfWork _unitOfWork;
private readonly ITokenService _tokenService;
private readonly IEventHub _eventHub;
private readonly ILogger<ScrobblingService> _logger;
private readonly ILicenseService _licenseService;
@ -99,12 +98,10 @@ public class ScrobblingService : IScrobblingService
private const string AccessTokenErrorMessage = "Access Token needs to be rotated to continue scrobbling";
public ScrobblingService(IUnitOfWork unitOfWork, ITokenService tokenService,
IEventHub eventHub, ILogger<ScrobblingService> logger, ILicenseService licenseService,
ILocalizationService localizationService)
public ScrobblingService(IUnitOfWork unitOfWork, IEventHub eventHub, ILogger<ScrobblingService> logger,
ILicenseService licenseService, ILocalizationService localizationService)
{
_unitOfWork = unitOfWork;
_tokenService = tokenService;
_eventHub = eventHub;
_logger = logger;
_licenseService = licenseService;

View File

@ -142,10 +142,15 @@ public class SmartCollectionSyncService : ISmartCollectionSyncService
// For everything that's not there, link it up for this user.
_logger.LogInformation("Starting Sync on {CollectionName} with {SeriesCount} Series", info.Title, info.TotalItems);
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.SmartCollectionProgressEvent(info.Title, string.Empty, 0, info.TotalItems, ProgressEventType.Started));
var missingCount = 0;
var missingSeries = new StringBuilder();
var counter = -1;
foreach (var seriesInfo in info.Series.OrderBy(s => s.SeriesName))
{
counter++;
try
{
// Normalize series name and localized name
@ -164,7 +169,12 @@ public class SmartCollectionSyncService : ISmartCollectionSyncService
s.NormalizedLocalizedName == normalizedSeriesName)
&& formats.Contains(s.Format));
if (existingSeries != null) continue;
if (existingSeries != null)
{
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.SmartCollectionProgressEvent(info.Title, seriesInfo.SeriesName, counter, info.TotalItems, ProgressEventType.Updated));
continue;
}
// Series not found in the collection, try to find it in the server
var newSeries = await _unitOfWork.SeriesRepository.GetSeriesByAnyName(seriesInfo.SeriesName,
@ -196,6 +206,8 @@ public class SmartCollectionSyncService : ISmartCollectionSyncService
missingSeries.Append("<br/>");
}
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.SmartCollectionProgressEvent(info.Title, seriesInfo.SeriesName, counter, info.TotalItems, ProgressEventType.Updated));
}
// At this point, all series in the info have been checked and added if necessary
@ -213,6 +225,9 @@ public class SmartCollectionSyncService : ISmartCollectionSyncService
await _unitOfWork.CollectionTagRepository.UpdateCollectionAgeRating(collection);
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.SmartCollectionProgressEvent(info.Title, string.Empty, info.TotalItems, info.TotalItems, ProgressEventType.Ended));
await _eventHub.SendMessageAsync(MessageFactory.CollectionUpdated,
MessageFactory.CollectionUpdatedEvent(collection.Id), false);

View File

@ -33,17 +33,19 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
private readonly IEventHub _eventHub;
private readonly ICacheHelper _cacheHelper;
private readonly IReaderService _readerService;
private readonly IMediaErrorService _mediaErrorService;
private const int AverageCharactersPerWord = 5;
public WordCountAnalyzerService(ILogger<WordCountAnalyzerService> logger, IUnitOfWork unitOfWork, IEventHub eventHub,
ICacheHelper cacheHelper, IReaderService readerService)
ICacheHelper cacheHelper, IReaderService readerService, IMediaErrorService mediaErrorService)
{
_logger = logger;
_unitOfWork = unitOfWork;
_eventHub = eventHub;
_cacheHelper = cacheHelper;
_readerService = readerService;
_mediaErrorService = mediaErrorService;
}
@ -188,7 +190,7 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.WordCountAnalyzerProgressEvent(series.LibraryId, progress,
ProgressEventType.Updated, useFileName ? filePath : series.Name));
sum += await GetWordCountFromHtml(bookPage);
sum += await GetWordCountFromHtml(bookPage, filePath);
pageCounter++;
}
@ -245,7 +247,9 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
}
private static async Task<int> GetWordCountFromHtml(EpubLocalTextContentFileRef bookFile)
private async Task<int> GetWordCountFromHtml(EpubLocalTextContentFileRef bookFile, string filePath)
{
try
{
var doc = new HtmlDocument();
doc.LoadHtml(await bookFile.ReadContentAsync());
@ -253,5 +257,13 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
var textNodes = doc.DocumentNode.SelectNodes("//body//text()[not(parent::script)]");
return textNodes?.Sum(node => node.InnerText.Count(char.IsLetter)) / AverageCharactersPerWord ?? 0;
}
catch (EpubContentException ex)
{
_logger.LogError(ex, "Error when counting words in epub {EpubPath}", filePath);
await _mediaErrorService.ReportMediaIssueAsync(filePath, MediaErrorProducer.BookService,
$"Invalid Epub Metadata, {bookFile.FilePath} does not exist", ex.Message);
return 0;
}
}
}

View File

@ -178,7 +178,7 @@ public class ThemeService : IThemeService
themeDtos.Add(dto);
}
_cache.Set(themeDtos, themes, _cacheOptions);
_cache.Set(cacheKey, themeDtos, _cacheOptions);
return themeDtos;
}

View File

@ -134,6 +134,10 @@ public static class MessageFactory
/// A Theme was updated and UI should refresh to get the latest version
/// </summary>
public const string SiteThemeUpdated = "SiteThemeUpdated";
/// <summary>
/// A Progress event when a smart collection is synchronizing
/// </summary>
public const string SmartCollectionSync = "SmartCollectionSync";
public static SignalRMessage DashboardUpdateEvent(int userId)
{
@ -425,6 +429,31 @@ public static class MessageFactory
};
}
/// <summary>
/// Represents a file being scanned by Kavita for processing and grouping
/// </summary>
/// <remarks>Does not have a progress as it's unknown how many files there are. Instead sends -1 to represent indeterminate</remarks>
/// <param name="folderPath"></param>
/// <param name="libraryName"></param>
/// <param name="eventType"></param>
/// <returns></returns>
public static SignalRMessage SmartCollectionProgressEvent(string collectionName, string seriesName, int currentItems, int totalItems, string eventType)
{
return new SignalRMessage()
{
Name = SmartCollectionSync,
Title = $"Synchronizing {collectionName}",
SubTitle = seriesName,
EventType = eventType,
Progress = ProgressType.Determinate,
Body = new
{
Progress = float.Min((currentItems / (totalItems * 1.0f)), 100f),
EventTime = DateTime.Now
}
};
}
/// <summary>
/// This informs the UI with details about what is being processed by the Scanner
/// </summary>

View File

@ -266,6 +266,9 @@ public class Startup
// v0.8.1
await MigrateLowestSeriesFolderPath.Migrate(dataContext, unitOfWork, logger);
// v0.8.2
await ManualMigrateThemeDescription.Migrate(dataContext, logger);
// Update the version in the DB after all migrations are run
var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion);
installVersion.Value = BuildInfo.Version.ToString();

View File

@ -14,7 +14,7 @@ Setup guides, FAQ, the more information we have on the [wiki](https://wiki.kavit
- [Git](https://git-scm.com/downloads)
- [NodeJS](https://nodejs.org/en/download/) (Node 18.13.X or higher)
- .NET 8.0+
- dotnet tool install -g --version 6.5.0 Swashbuckle.AspNetCore.Cli
- dotnet tool install -g Swashbuckle.AspNetCore.Cli
### Getting started ###

View File

@ -14,7 +14,7 @@
<PackageReference Include="Flurl.Http" Version="3.2.4" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.24.0.89429">
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.25.0.90414">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

View File

@ -1,4 +1,4 @@
$scrollbarHeight: 34px;
$scrollbarHeight: 35px;
img {
user-select: none;
@ -9,29 +9,31 @@ img {
align-items: center;
&.full-width {
height: calc(var(--vh)*100);
height: 100dvh;
display: grid;
}
&.full-height {
height: calc(100vh); // We need to - $scrollbarHeight when there is a horizontal scroll on macos
height: calc(100dvh); // We need to - $scrollbarHeight when there is a horizontal scroll on macos
display: flex;
align-content: center;
overflow-y: hidden;
}
&.original {
height: 100vh;
height: calc(100dvh);
display: grid;
}
.full-height {
width: auto;
margin: auto;
max-height: calc(var(--vh)*100);
overflow: hidden; // This technically will crop and make it just fit
max-height: calc(100dvh);
height: calc(100dvh);
vertical-align: top;
object-fit: cover;
&.wide {
height: 100vh;
height: calc(100dvh);
}
}
@ -46,12 +48,13 @@ img {
width: 100%;
margin: 0 auto;
vertical-align: top;
max-width: fit-content;
object-fit: contain;
width: 100%;
}
.fit-to-screen.full-width {
width: 100%;
max-height: calc(var(--vh)*100);
max-height: calc(100dvh);
}
}

View File

@ -10,7 +10,6 @@ import { Volume } from '../_models/volume';
import { AccountService } from './account.service';
import { DeviceService } from './device.service';
import {SideNavStream} from "../_models/sidenav/sidenav-stream";
import {User} from "../_models/user";
export enum Action {
Submenu = -1,

View File

@ -103,7 +103,11 @@ export enum EVENTS {
/**
* A Theme was updated and UI should refresh to get the latest version
*/
SiteThemeUpdated= 'SiteThemeUpdated'
SiteThemeUpdated = 'SiteThemeUpdated',
/**
* A Progress event when a smart collection is synchronizing
*/
SmartCollectionSync = 'SmartCollectionSync'
}
export interface Message<T> {
@ -199,6 +203,13 @@ export class MessageHubService {
});
});
this.hubConnection.on(EVENTS.SmartCollectionSync, resp => {
this.messagesSource.next({
event: EVENTS.NotificationProgress,
payload: resp.body
});
});
this.hubConnection.on(EVENTS.SiteThemeUpdated, resp => {
this.messagesSource.next({
event: EVENTS.SiteThemeUpdated,

View File

@ -24,11 +24,11 @@
<div class="card-footer bg-transparent text-muted">
<div>
@if (isMyReview) {
<i class="d-md-none fa-solid fa-star me-1" aria-hidden="true" [title]="t('your-review')"></i>
<img class="me-1" [ngSrc]="ScrobbleProvider.Kavita | providerImage" width="20" height="20" alt="">
<i class="d-md-none fa-solid fa-star me-2" aria-hidden="true" [title]="t('your-review')"></i>
<img class="me-2" [ngSrc]="ScrobbleProvider.Kavita | providerImage" width="20" height="20" alt="">
{{review.username}}
} @else {
<img class="me-1" [ngSrc]="review.provider | providerImage" width="20" height="20" alt="">
<img class="me-2" [ngSrc]="review.provider | providerImage" width="20" height="20" alt="">
}
{{(isMyReview ? '' : review.username | defaultValue:'')}}

View File

@ -42,4 +42,10 @@
max-width: 319px;
justify-content: space-between;
margin: 0 auto;
padding: .5rem 0;
& > * {
margin: 0 5px;
display: inline-flex;
}
}

View File

@ -1,6 +1,7 @@
<ng-container *transloco="let t; read:'user-scrobble-history'">
<h5>{{t('title')}}</h5>
<p>{{t('description')}}</p>
<p class="fw-bold">{{t('not-read-warning')}}</p>
<div class="row g-0 mb-2">
<div class="col-md-10">
<form [formGroup]="formGroup">
@ -67,8 +68,12 @@
@switch (item.scrobbleEventType) {
@case (ScrobbleEventType.ChapterRead) {
@if(item.volumeNumber === LooseLeafOrDefaultNumber) {
@if (item.chapterNumber === LooseLeafOrDefaultNumber) {
{{t('special')}}
} @else {
{{t('chapter-num', {num: item.chapterNumber})}}
}
}
@else if (item.chapterNumber === LooseLeafOrDefaultNumber) {
{{t('volume-num', {num: item.volumeNumber})}}
}

View File

@ -3,8 +3,12 @@
<form [formGroup]="settingsForm" *ngIf="serverSettings !== undefined">
<h4 id="email-header">{{t('title')}}</h4>
<p>You must fill out both Host Name and SMTP settings to use email-based functionality within Kavita.</p>
<p>{{t('setting-description')}}</p>
@if (settingsForm.dirty) {
<ngb-alert [type]="'warning'">
{{t('test-warning')}}
</ngb-alert>
}
<div class="mb-3 pe-2 ps-2 ">
<label for="settings-hostname" class="form-label">{{t('host-name-label')}}</label><i class="fa fa-info-circle ms-1" placement="right" [ngbTooltip]="hostNameTooltip" role="button" tabindex="0"></i>
<ng-template #hostNameTooltip>{{t('host-name-tooltip')}}</ng-template>

View File

@ -5,6 +5,7 @@ import {take} from 'rxjs';
import {SettingsService} from '../settings.service';
import {ServerSettings} from '../_models/server-settings';
import {
NgbAlert,
NgbTooltip
} from '@ng-bootstrap/ng-bootstrap';
import {NgIf, NgTemplateOutlet, TitleCasePipe} from '@angular/common';
@ -19,7 +20,7 @@ import {ManageAlertsComponent} from "../manage-alerts/manage-alerts.component";
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [NgIf, ReactiveFormsModule, NgbTooltip, NgTemplateOutlet, TranslocoModule, SafeHtmlPipe,
ManageAlertsComponent, TitleCasePipe]
ManageAlertsComponent, TitleCasePipe, NgbAlert]
})
export class ManageEmailSettingsComponent implements OnInit {

View File

@ -14,7 +14,7 @@
<ng-template #cardItem let-item let-position="idx">
<!-- TODO: figure a way to get a hover effect -->
<div class="card-item-container card clickable" (click)="loadSmartFilter(item)">
<div class="overlay">
<div class="overlay filter">
<div class="card-overlay"></div>
<div class="overlay-information overlay-information--centered">
<div class="position-relative">

View File

@ -1,7 +1,7 @@
<ng-container *transloco="let t; read: 'series-info-cards'">
<div class="row g-0 mt-3">
<ng-container *ngIf="seriesMetadata.releaseYear > 0">
<div class="col-lg-1 col-md-4 col-sm-4 col-4 mb-2">
<div class="col-xl-auto col-lg-auto col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title [label]="t('release-date-title')" [clickable]="false" fontClasses="fa-regular fa-calendar" [title]="t('release-year-tooltip')">
{{seriesMetadata.releaseYear}}
</app-icon-and-title>
@ -11,7 +11,7 @@
<ng-container *ngIf="seriesMetadata">
<ng-container *ngIf="seriesMetadata.ageRating">
<div class="col-lg-1 col-md-4 col-sm-4 col-4 mb-2">
<div class="col-xl-auto col-lg-auto col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title [label]="t('age-rating-title')" [clickable]="true" fontClasses="fas fa-eye" (click)="handleGoTo(FilterField.AgeRating, seriesMetadata.ageRating)" [title]="t('age-rating-title')">
{{this.seriesMetadata.ageRating | ageRating}}
</app-icon-and-title>
@ -20,7 +20,7 @@
</ng-container>
<ng-container *ngIf="seriesMetadata.language !== null && seriesMetadata.language !== ''">
<div class="col-lg-1 col-md-4 col-sm-4 col-4 mb-2">
<div class="col-xl-auto col-lg-auto col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title [label]="t('language-title')" [clickable]="true" fontClasses="fas fa-language" (click)="handleGoTo(FilterField.Languages, seriesMetadata.language)" [title]="t('language-title')">
{{seriesMetadata.language | defaultValue:'en' | languageName | async}}
</app-icon-and-title>
@ -30,7 +30,7 @@
</ng-container>
<ng-container>
<div class="col-lg-1 col-md-4 col-sm-4 col-4 mb-2">
<div class="col-xl-auto col-lg-auto col-md-4 col-sm-4 col-4 mb-2">
<ng-container *ngIf="seriesMetadata.publicationStatus | publicationStatus as pubStatus">
<app-icon-and-title [label]="t('publication-status-title')" [clickable]="true" fontClasses="fa-solid fa-hourglass-{{pubStatus === t('ongoing') ? 'empty' : 'end'}}"
(click)="handleGoTo(FilterField.PublicationStatus, seriesMetadata.publicationStatus)"
@ -43,7 +43,7 @@
</ng-container>
<ng-container *ngIf="accountService.hasValidLicense$ | async">
<div class="col-lg-1 col-md-4 col-sm-4 col-4 mb-2">
<div class="col-xl-auto col-lg-auto col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title [label]="t('scrobbling-title')" [clickable]="libraryAllowsScrobbling"
fontClasses="fa-solid fa-tower-{{(isScrobbling && libraryAllowsScrobbling) ? 'broadcast' : 'observation'}}"
(click)="toggleScrobbling($event)"
@ -62,7 +62,7 @@
<ng-container *ngIf="series">
<ng-container>
<div class="d-none d-md-block col-lg-1 col-md-4 col-sm-4 col-4 mb-2">
<div class="d-none d-md-block col-xl-auto col-lg-auto col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title [label]="t('format-title')" [clickable]="true"
[fontClasses]="series.format | mangaFormatIcon"
(click)="handleGoTo(FilterField.Formats, series.format)" [title]="t('format-title')">
@ -73,7 +73,7 @@
</ng-container>
<ng-container *ngIf="series.latestReadDate && series.latestReadDate !== '' && (series.latestReadDate | date: 'shortDate') !== '1/1/01'">
<div class="d-none d-md-block col-lg-1 col-md-4 col-sm-4 col-4 mb-2">
<div class="d-none d-md-block col-xl-auto col-lg-auto col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title [label]="t('last-read-title')" [clickable]="false" fontClasses="fa-regular fa-clock" [title]="t('last-read-title')">
{{series.latestReadDate | timeAgo}}
</app-icon-and-title>
@ -83,7 +83,7 @@
<ng-container *ngIf="series.format === MangaFormat.EPUB; else showPages">
<ng-container *ngIf="series.wordCount > 0">
<div class="col-lg-1 col-md-4 col-sm-4 col-4 mb-2">
<div class="col-xl-auto col-lg-auto col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title [label]="t('length-title')" [clickable]="false" fontClasses="fa-solid fa-book-open">
{{t('words-count', {num: series.wordCount | compactNumber})}}
</app-icon-and-title>
@ -93,7 +93,7 @@
</ng-container>
<ng-template #showPages>
<div class="d-none d-md-block col-lg-1 col-md-4 col-sm-4 col-4 mb-2">
<div class="d-none d-md-block col-xl-auto col-lg-auto col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title [label]="t('length-title')" [clickable]="false" fontClasses="fa-regular fa-file-lines">
{{t('pages-count', {num: series.pages | compactNumber})}}
</app-icon-and-title>
@ -102,7 +102,7 @@
</ng-template>
<ng-container *ngIf="series.format === MangaFormat.EPUB && series.wordCount > 0 || series.format !== MangaFormat.EPUB">
<div class="col-lg-1 col-md-4 col-sm-4 col-4 mb-2">
<div class="col-xl-auto col-lg-auto col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title [label]="t('read-time-title')" [clickable]="false" fontClasses="fa-regular fa-clock">
<ng-container *ngIf="readingTime.maxHours === 0 || readingTime.minHours === 0; else normalReadTime">{{t('less-than-hour')}}</ng-container>
<ng-template #normalReadTime>
@ -114,7 +114,7 @@
<ng-container *ngIf="hasReadingProgress && showReadingTimeLeft && readingTimeLeft && readingTimeLeft.avgHours !== 0">
<div class="vr d-none d-lg-block m-2"></div>
<div class="col-lg-1 col-md-4 col-sm-4 col-4 mb-2">
<div class="col-xl-auto col-lg-auto col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title label="Time Left" [clickable]="false" fontClasses="fa-solid fa-clock">
~{{readingTimeLeft.avgHours}} {{readingTimeLeft.avgHours > 1 ? t('hours') : t('hour')}}
</app-icon-and-title>

View File

@ -6,11 +6,13 @@ import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap";
import {CollectionTagService} from "../../../_services/collection-tag.service";
import {MalStack} from "../../../_models/collection/mal-stack";
import {UserCollection} from "../../../_models/collection-tag";
import {ScrobbleProvider} from "../../../_services/scrobbling.service";
import {ScrobbleProvider, ScrobblingService} from "../../../_services/scrobbling.service";
import {forkJoin} from "rxjs";
import {ToastrService} from "ngx-toastr";
import {DecimalPipe} from "@angular/common";
import {LoadingComponent} from "../../../shared/loading/loading.component";
import {AccountService} from "../../../_services/account.service";
import {ConfirmService} from "../../../shared/confirm.service";
@Component({
selector: 'app-import-mal-collection-modal',
@ -32,13 +34,25 @@ export class ImportMalCollectionModalComponent {
private readonly collectionService = inject(CollectionTagService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly toastr = inject(ToastrService);
private readonly scrobblingService = inject(ScrobblingService);
private readonly confirmService = inject(ConfirmService);
stacks: Array<MalStack> = [];
isLoading = true;
collectionMap: {[key: string]: UserCollection | MalStack} = {};
constructor() {
this.scrobblingService.getMalToken().subscribe(async token => {
if (token.accessToken === '') {
await this.confirmService.alert(translate('toasts.mal-token-required'));
this.ngbModal.dismiss();
return;
}
this.setup();
});
}
setup() {
forkJoin({
allCollections: this.collectionService.allCollections(true),
malStacks: this.collectionService.getMalStacks()

View File

@ -3,18 +3,25 @@
.image-container {
#image-1 {
&.double {
margin: 0 0 0 auto;
}
}
}
.image-container.full-height {
display: inline-block !important;
.image-container {
&.full-height {
display: flex;
align-content: center;
justify-content: center;
}
.full-height {
margin: unset;
object-fit: contain;
}
}
.full-width {
width: 100%;
margin: 0 auto;
vertical-align: top;
max-width: fit-content;
@ -41,7 +48,7 @@
}
.fit-to-height-double-offset {
height: 100vh;
height: calc(100dvh);
object-fit: scale-down;
top: 50%;
left: 50%;

View File

@ -2,7 +2,7 @@
// Overrides for reverse
.image-container {
height: calc(100vh); // override as on single, we -34px for the potential scrollbar
height: calc(100dvh); // override as on single, we -34px for the potential scrollbar
&.reverse {
overflow: unset;
@ -29,8 +29,16 @@
}
}
.image-container.full-height {
display: inline-block;
.image-container {
display: flex;
align-content: center;
justify-content: center;
.full-height {
margin: unset;
object-fit: contain;
}
}
.full-width {
@ -62,7 +70,7 @@
}
.fit-to-height-double-offset {
height: 100vh;
height: calc(100dvh);
object-fit: scale-down;
top: 50%;
left: 50%;

View File

@ -41,7 +41,7 @@
<app-loading [loading]="isLoading || (!(currentImage$ | async)?.complete && this.readerMode !== ReaderMode.Webtoon)" [absolute]="true"></app-loading>
<div class="reading-area"
ngSwipe (swipeEnd)="onSwipeEnd($event)" (swipeMove)="onSwipeMove($event)"
[ngStyle]="{'background-color': backgroundColor, 'height': readerMode === ReaderMode.Webtoon ? 'inherit' : 'calc(var(--vh)*100)'}" #readingArea>
[ngStyle]="{'background-color': backgroundColor, 'height': readerMode === ReaderMode.Webtoon ? 'inherit' : '100dvh'}" #readingArea>
<ng-container *ngIf="readerMode !== ReaderMode.Webtoon; else webtoon">
<div (dblclick)="bookmarkPage($event)">
@ -56,14 +56,14 @@
<!-- Pagination controls and screen hints-->
<div class="pagination-area">
<div class="{{readerMode === ReaderMode.LeftRight ? 'left' : 'top'}} {{clickOverlayClass('left')}}" (click)="handlePageChange($event, KeyDirection.Left)"
[ngStyle]="{'height': (readerMode === ReaderMode.LeftRight ? ImageHeight: '25%'), 'max-height': MaxHeight}">
[ngStyle]="{'height': (readerMode === ReaderMode.LeftRight ? MaxHeight: '25%'), 'max-height': MaxHeight}">
<div *ngIf="showClickOverlay">
<i class="fa fa-angle-{{readingDirection === ReadingDirection.RightToLeft ? 'double-' : ''}}{{readerMode === ReaderMode.LeftRight ? 'left' : 'up'}}"
[title]="t('prev-page-tooltip')" aria-hidden="true"></i>
</div>
</div>
<div class="{{readerMode === ReaderMode.LeftRight ? 'right' : 'bottom'}} {{clickOverlayClass('right')}}" (click)="handlePageChange($event, KeyDirection.Right)"
[ngStyle]="{'height': (readerMode === ReaderMode.LeftRight ? ImageHeight: '25%'),
[ngStyle]="{'height': (readerMode === ReaderMode.LeftRight ? MaxHeight: '25%'),
'left': 'inherit',
'right': RightPaginationOffset + 'px',
'max-height': MaxHeight}">

View File

@ -16,7 +16,6 @@ $pointer-offset: 5px;
.reading-area {
position: relative;
overflow: auto;
text-align: center;
//height: calc(var(--vh)*100); // this needs to be applied on the DOM because it breaks infinite scroller

View File

@ -432,17 +432,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
// This is for the pagination area
get MaxHeight() {
if (this.FittingOption === FITTING_OPTION.HEIGHT) {
return 'calc(var(--vh) * 100)';
}
const needsScrolling = this.readingArea?.nativeElement?.scrollHeight > this.readingArea?.nativeElement?.clientHeight;
if (this.readingArea?.nativeElement?.clientHeight <= this.mangaReaderService.getPageDimensions(this.pageNum)?.height!) {
if (needsScrolling) {
return Math.min(this.readingArea?.nativeElement?.scrollHeight, this.mangaReaderService.getPageDimensions(this.pageNum)?.height!) + 'px';
}
}
return this.readingArea?.nativeElement?.clientHeight + 'px';
return '100dvh';
}
get RightPaginationOffset() {

View File

@ -150,14 +150,14 @@ export class SingleRendererComponent implements OnInit, ImageRenderer {
if (mode !== FITTING_OPTION.HEIGHT) return '';
const readingArea = this.document.querySelector('.reading-area');
if (!readingArea) return 'calc(100vh)';
if (!readingArea) return 'calc(100dvh)';
// If you ever see fit to height and a bit of scrollbar, it's due to currentImage not being ready on first load
if (this.currentImage?.width - readingArea.scrollWidth > 0) {
// we also need to check if this is FF or Chrome. FF doesn't require the -34px as it doesn't render a scrollbar
return 'calc(100vh - 34px)';
return 'calc(100dvh)';
}
return 'calc(100vh)';
return 'calc(100dvh)';
}),
filter(_ => this.isValid())
);

View File

@ -118,7 +118,7 @@ export class EventsWidgetComponent implements OnInit, OnDestroy {
this.cdRef.markForCheck();
break;
case 'started':
// Sometimes we can receive 2 started on long running scans, so better to just treat as a merge then.
// Sometimes we can receive 2 started on long-running scans, so better to just treat as a merge then.
data = this.mergeOrUpdate(this.progressEventsSource.getValue(), message);
this.progressEventsSource.next(data);
break;

View File

@ -3,12 +3,15 @@
<h2 title>
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [attr.aria-labelledby]="readingList?.title" *ngIf="actions.length > 0"></app-card-actionables>
{{readingList?.title}}
<span *ngIf="readingList?.promoted" class="ms-1">(<i class="fa fa-angle-double-up" aria-hidden="true"></i>)</span>
@if (readingList?.promoted) {
<span class="ms-1">(<i class="fa fa-angle-double-up" aria-hidden="true"></i>)</span>
}
</h2>
<h6 subtitle class="subtitle-with-actionables">{{t('item-count', {num: items.length | number})}}</h6>
<ng-template #extrasDrawer let-offcanvas>
<div style="margin-top: 56px" *ngIf="readingList">
@if (readingList) {
<div style="margin-top: 56px">
<div class="offcanvas-header">
<h4 class="offcanvas-title" id="offcanvas-basic-title">{{t('page-settings-title')}}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="offcanvas.dismiss()"></button>
@ -23,19 +26,24 @@
<span class="read-btn--text">&nbsp;{{t('remove-read')}}</span>
</button>
<div class="col-auto ms-2 mt-2" *ngIf="!(readingList?.promoted && !this.isAdmin)">
@if (!(readingList.promoted && !this.isAdmin)) {
<div class="col-auto ms-2 mt-2">
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="accessibility-mode" [value]="accessibilityMode" (change)="updateAccessibilityMode()">
<input class="form-check-input" type="checkbox" id="accessibility-mode" [disabled]="this.utilityService.getActiveBreakpoint() < Breakpoint.Tablet" [value]="accessibilityMode" (change)="updateAccessibilityMode()">
<label class="form-check-label" for="accessibility-mode">{{t('order-numbers-label')}}</label>
</div>
</div>
}
</div>
</div>
</div>
</div>
}
</ng-template>
</app-side-nav-companion-bar>
<div class="container-fluid mt-2" *ngIf="readingList" >
@if (readingList) {
<div class="container-fluid mt-2">
<div class="row mb-2">
<div class="col-md-2 col-xs-4 col-sm-6 d-none d-sm-block">
@ -135,5 +143,5 @@
</app-draggable-ordered-list>
</div>
</div>
}
</ng-container>

View File

@ -3,7 +3,7 @@ import {ActivatedRoute, Router} from '@angular/router';
import {ToastrService} from 'ngx-toastr';
import {take} from 'rxjs/operators';
import {ConfirmService} from 'src/app/shared/confirm.service';
import {UtilityService} from 'src/app/shared/_services/utility.service';
import {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service';
import {LibraryType} from 'src/app/_models/library/library';
import {MangaFormat} from 'src/app/_models/manga-format';
import {ReadingList, ReadingListItem} from 'src/app/_models/reading-list';
@ -32,7 +32,7 @@ import {AsyncPipe, DatePipe, DecimalPipe, NgClass, NgIf} from '@angular/common';
import {
SideNavCompanionBarComponent
} from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco";
import {translate, TranslocoDirective} from "@ngneat/transloco";
import {CardActionablesComponent} from "../../../_single-module/card-actionables/card-actionables.component";
import {FilterUtilitiesService} from "../../../shared/_services/filter-utilities.service";
import {FilterField} from "../../../_models/metadata/v2/filter-field";
@ -53,6 +53,26 @@ import {Title} from "@angular/platform-browser";
MetadataDetailComponent]
})
export class ReadingListDetailComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private readingListService = inject(ReadingListService);
private actionService = inject(ActionService);
private actionFactoryService = inject(ActionFactoryService);
public utilityService = inject(UtilityService);
public imageService = inject(ImageService);
private accountService = inject(AccountService);
private toastr = inject(ToastrService);
private confirmService = inject(ConfirmService);
private libraryService = inject(LibraryService);
private readerService = inject(ReaderService);
private cdRef = inject(ChangeDetectorRef);
private filterUtilityService = inject(FilterUtilitiesService);
private titleService = inject(Title);
protected readonly MangaFormat = MangaFormat;
protected readonly Breakpoint = Breakpoint;
items: Array<ReadingListItem> = [];
listId!: number;
readingList: ReadingList | undefined;
@ -65,15 +85,8 @@ export class ReadingListDetailComponent implements OnInit {
libraryTypes: {[key: number]: LibraryType} = {};
characters$!: Observable<Person[]>;
private translocoService = inject(TranslocoService);
protected readonly MangaFormat = MangaFormat;
constructor(private route: ActivatedRoute, private router: Router, private readingListService: ReadingListService,
private actionService: ActionService, private actionFactoryService: ActionFactoryService, public utilityService: UtilityService,
public imageService: ImageService, private accountService: AccountService, private toastr: ToastrService,
private confirmService: ConfirmService, private libraryService: LibraryService, private readerService: ReaderService,
private readonly cdRef: ChangeDetectorRef, private filterUtilityService: FilterUtilitiesService, private titleService: Title) {
}
ngOnInit(): void {
const listId = this.route.snapshot.paramMap.get('id');
@ -86,6 +99,9 @@ export class ReadingListDetailComponent implements OnInit {
this.listId = parseInt(listId, 10);
this.characters$ = this.readingListService.getCharacters(this.listId);
this.accessibilityMode = this.utilityService.getActiveBreakpoint() < Breakpoint.Tablet;
this.cdRef.markForCheck();
forkJoin([
this.libraryService.getLibraries(),
this.readingListService.getReadingList(this.listId)
@ -165,10 +181,10 @@ export class ReadingListDetailComponent implements OnInit {
}
async deleteList(readingList: ReadingList) {
if (!await this.confirmService.confirm(this.translocoService.translate('toasts.confirm-delete-reading-list'))) return;
if (!await this.confirmService.confirm(translate('toasts.confirm-delete-reading-list'))) return;
this.readingListService.delete(readingList.id).subscribe(() => {
this.toastr.success(this.translocoService.translate('toasts.reading-list-deleted'));
this.toastr.success(translate('toasts.reading-list-deleted'));
this.router.navigateByUrl('/lists');
});
}
@ -186,7 +202,7 @@ export class ReadingListDetailComponent implements OnInit {
this.items.splice(position, 1);
this.items = [...this.items];
this.cdRef.markForCheck();
this.toastr.success(this.translocoService.translate('toasts.item-removed'));
this.toastr.success(translate('toasts.item-removed'));
});
}
@ -196,7 +212,7 @@ export class ReadingListDetailComponent implements OnInit {
this.cdRef.markForCheck();
this.readingListService.removeRead(this.readingList.id).subscribe((resp) => {
if (resp === 'Nothing to remove') {
this.toastr.info(this.translocoService.translate('toasts.nothing-to-remove'));
this.toastr.info(translate('toasts.nothing-to-remove'));
return;
}
this.getListItems();

View File

@ -8,7 +8,7 @@
<div class="row g-0 theme-container">
<div class="col-md-3">
<div class="col-lg-3 col-md-5 col-sm-7 col-xs-7 scroller">
<div class="pe-2">
<ul style="height: 100%" class="list-group list-group-flush">
@ -23,7 +23,9 @@
</div>
</div>
<div class="col-md-9">
<div class="col-lg-9 col-md-7 col-sm-4 col-xs-4 ps-3">
<div class="card p-3">
@if (selectedTheme === undefined) {
<div class="row pb-4">
@ -35,7 +37,6 @@
} @else {
{{t('preview-default')}}
}
</div>
</div>
</div>
@ -91,20 +92,20 @@
@if(!selectedTheme.isSiteTheme) {
<p>{{selectedTheme.downloadable!.description | defaultValue}}</p>
<app-carousel-reel [items]="selectedTheme.downloadable!.previewUrls" title="Preview">
<app-carousel-reel [items]="selectedTheme.downloadable!.previewUrls" [title]="t('preview-title')">
<ng-template #carouselItem let-item>
<a [href]="item | safeUrl" target="_blank" rel="noopener noreferrer">
<app-image [imageUrl]="item" height="100px" width="160px"></app-image>
<app-image [imageUrl]="item" height="108px" width="260px"></app-image>
</a>
</ng-template>
</app-carousel-reel>
} @else {
<p>{{selectedTheme.site!.description | defaultValue}}</p>
<app-carousel-reel [items]="selectedTheme.site!.previewUrls" title="Preview">
<app-carousel-reel [items]="selectedTheme.site!.previewUrls" [title]="t('preview-title')">
<ng-template #carouselItem let-item>
<a [href]="item | safeUrl" target="_blank" rel="noopener noreferrer">
<app-image [imageUrl]="item" height="100px" width="160px"></app-image>
<app-image [imageUrl]="item" height="108px" width="260px"></app-image>
</a>
</ng-template>
</app-carousel-reel>
@ -113,6 +114,7 @@
</div>
</div>
</div>
</div>
<ng-template #themeOption let-item>
@if (item !== undefined) {
@ -122,12 +124,12 @@
<div class="fw-bold">{{item.name | sentenceCase}}</div>
@if (item.hasOwnProperty('provider')) {
{{item.provider | siteThemeProvider}}
<span class="pill p-1 me-1 provider">{{item.provider | siteThemeProvider}}</span>
} @else if (item.hasOwnProperty('lastCompatibleVersion')) {
{{ThemeProvider.Custom | siteThemeProvider}} • v{{item.lastCompatibleVersion}}
<span class="pill p-1 me-1 provider">{{ThemeProvider.Custom | siteThemeProvider}}</span><span class="pill p-1 me-1 version">v{{item.lastCompatibleVersion}}</span>
}
@if (currentTheme && item.name === currentTheme.name) {
• {{t('active-theme')}}
<span class="pill p-1 active">{{t('active-theme')}}</span>
}
</div>
@if (item.hasOwnProperty('isDefault') && item.isDefault) {

View File

@ -10,6 +10,25 @@
justify-content: space-around;
}
.scroller {
max-height: calc(100dvh - 280px);
overflow-y: auto;
}
.pill {
font-size: .8rem;
background-color: var(--card-bg-color);
border-radius: 0.375rem;
&.active {
background-color : var(--primary-color);
}
}
.list-group-item, .list-group-item.active {
border-top-width: 0;
border-bottom-width: 0;
}
ngx-file-drop ::ng-deep > div {
// styling for the outer drop box
width: 100%;

View File

@ -33,6 +33,7 @@
"user-scrobble-history": {
"title": "Scrobble History",
"description": "Here you will find any scrobble events linked with your account. In order for events to exist, you must have an active scrobble provider configured. All events that have been processed will clear after a month. If there are non-processed events, it is likely these cannot form matches upstream. Please reach out to your admin to get them corrected.",
"not-read-warning": "Upstream providers will always keep the highest number",
"filter-label": "{{common.filter}}",
"created-header": "Created",
"last-modified-header": "Last Modified",
@ -47,7 +48,8 @@
"rating": "Rating {{r}}",
"not-applicable": "Not Applicable",
"processed": "Processed",
"not-processed": "Not Processed"
"not-processed": "Not Processed",
"special": "{{entity-title.special}}"
},
"scrobble-event-type-pipe": {
@ -197,7 +199,8 @@
"upload": "{{cover-image-chooser.upload}}",
"upload-continued": "a css file",
"preview-default": "Select a theme first",
"preview-default-admin": "Select a theme first or upload one manually"
"preview-default-admin": "Select a theme first or upload one manually",
"preview-title": "Preview"
},
"theme": {
@ -1122,6 +1125,8 @@
"manage-email-settings": {
"title": "Email Services (SMTP)",
"description": "In order to use some functions of Kavita like Forgot Password and Send To Device, an email provider must be setup. Other features like Password change are less secure without Email setup.",
"setting-description": "You must fill out both Host Name and SMTP settings to use email-based functionality within Kavita.",
"test-warning": "You must save before using Test button.",
"send-to-warning": "If you want Send to Device to work you must setup your email settings",
"email-url-label": "Email Service URL",
"email-url-tooltip": "Use fully qualified URL of the email service. Do not include ending slash.",
@ -2199,8 +2204,8 @@
"collections-deleted": "Collections deleted",
"pdf-book-mode-screen-size": "Screen too small for Book mode",
"stack-imported": "Stack Imported",
"confirm-delete-theme": "Removing this theme will delete it from the disk. You can grab it from temp directory before removal"
"confirm-delete-theme": "Removing this theme will delete it from the disk. You can grab it from temp directory before removal",
"mal-token-required": "MAL Token is required, set in User Settings"
},
"actionable": {

View File

@ -15,6 +15,7 @@
}
$image-height: 230px;
$image-filter-height: 160px;
$image-width: 160px;
.card-item-container {
@ -62,6 +63,14 @@ $image-width: 160px;
border-top-right-radius: 4px;
z-index: 10;
&.filter {
height: $image-filter-height;
.card-overlay {
height: $image-filter-height;
}
}
&:hover {
visibility: visible;
}

View File

@ -7,7 +7,7 @@
"name": "GPL-3.0",
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
},
"version": "0.8.1.3"
"version": "0.8.1.4"
},
"servers": [
{