diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 8a6b9c2f6..6aaef02d9 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -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 diff --git a/API.Tests/API.Tests.csproj b/API.Tests/API.Tests.csproj index 12cf2b444..c00a93d46 100644 --- a/API.Tests/API.Tests.csproj +++ b/API.Tests/API.Tests.csproj @@ -6,7 +6,7 @@ - + diff --git a/API.Tests/Services/WordCountAnalysisTests.cs b/API.Tests/Services/WordCountAnalysisTests.cs index 4d86ea2d3..d911bbcf0 100644 --- a/API.Tests/Services/WordCountAnalysisTests.cs +++ b/API.Tests/Services/WordCountAnalysisTests.cs @@ -74,7 +74,7 @@ public class WordCountAnalysisTests : AbstractDbTest var cacheService = new CacheHelper(new FileService()); var service = new WordCountAnalyzerService(Substitute.For>(), _unitOfWork, - Substitute.For(), cacheService, _readerService); + Substitute.For(), cacheService, _readerService, Substitute.For()); 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>(), _unitOfWork, - Substitute.For(), cacheService, _readerService); + Substitute.For(), cacheService, _readerService, Substitute.For()); await service.ScanSeries(1, 1); var chapter2 = new ChapterBuilder("2") diff --git a/API/API.csproj b/API/API.csproj index 9767a0ec3..cc3c3aa38 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -12,9 +12,9 @@ latestmajor - - - + + + false @@ -95,13 +95,13 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - + diff --git a/API/Data/ManualMigrations/ManualMigrateSwitchToWal.cs b/API/Data/ManualMigrations/ManualMigrateSwitchToWal.cs new file mode 100644 index 000000000..8e648b025 --- /dev/null +++ b/API/Data/ManualMigrations/ManualMigrateSwitchToWal.cs @@ -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; + +/// +/// v0.8.2 switches Default Kavita installs to WAL +/// +public static class ManualMigrateSwitchToWal +{ + public static async Task Migrate(DataContext context, ILogger 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"); + } + +} diff --git a/API/Data/ManualMigrations/ManualMigrateThemeDescription.cs b/API/Data/ManualMigrations/ManualMigrateThemeDescription.cs new file mode 100644 index 000000000..8ac000f0d --- /dev/null +++ b/API/Data/ManualMigrations/ManualMigrateThemeDescription.cs @@ -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; + +/// +/// v0.8.2 introduced Theme repo viewer, this adds Description to existing SiteTheme defaults +/// +public static class ManualMigrateThemeDescription +{ + public static async Task Migrate(DataContext context, ILogger 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"); + } +} diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index 5c8b91b19..73a52856e 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -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); diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index 955450a37..75715645c 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -35,6 +35,7 @@ public static class Seed Provider = ThemeProvider.System, FileName = "dark.scss", IsDefault = true, + Description = "Default theme shipped with Kavita" } }.ToArray() ]; diff --git a/API/Program.cs b/API/Program.cs index 46d8d6c6d..9668a06da 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -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) { diff --git a/API/Services/BookService.cs b/API/Services/BookService.cs index 8c039e323..a6d978c67 100644 --- a/API/Services/BookService.cs +++ b/API/Services/BookService.cs @@ -73,7 +73,8 @@ public class BookService : IBookService { PackageReaderOptions = new PackageReaderOptions { - IgnoreMissingToc = true + IgnoreMissingToc = true, + SkipInvalidManifestItems = true } }; diff --git a/API/Services/CacheService.cs b/API/Services/CacheService.cs index 27641abd5..183dda6d3 100644 --- a/API/Services/CacheService.cs +++ b/API/Services/CacheService.cs @@ -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 ExtractLocks = new(); + public CacheService(ILogger logger, IUnitOfWork unitOfWork, IDirectoryService directoryService, IReadingItemService readingItemService, IBookmarkService bookmarkService) @@ -166,11 +170,19 @@ public class CacheService : ICacheService var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId); var extractPath = GetCachePath(chapterId); - if (_directoryService.Exists(extractPath)) return chapter; - var files = chapter?.Files.ToList(); - ExtractChapterFiles(extractPath, files, extractPdfToImages); + SemaphoreSlim extractLock = ExtractLocks.GetOrAdd(chapterId, id => new SemaphoreSlim(1,1)); - return chapter; + await extractLock.WaitAsync(); + try { + if(_directoryService.Exists(extractPath)) return chapter; + + var files = chapter?.Files.ToList(); + ExtractChapterFiles(extractPath, files, extractPdfToImages); + } finally { + extractLock.Release(); + } + + return chapter; } /// @@ -191,15 +203,25 @@ public class CacheService : ICacheService if (files.Count > 0 && files[0].Format == MangaFormat.Image) { - foreach (var file in files) + // 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)) { - if (fileCount > 1) - { - extraPath = file.Id + string.Empty; - } - _readingItemService.Extract(file.FilePath, Path.Join(extractPath, extraPath), MangaFormat.Image, files.Count); + _directoryService.ExistOrCreate(extractPath); + _directoryService.CopyFilesToDirectory(files.Select(f => f.FilePath), extractPath); } - _directoryService.Flatten(extractDi.FullName); + else + { + foreach (var file in files) + { + if (fileCount > 1) + { + extraPath = file.Id + string.Empty; + } + _readingItemService.Extract(file.FilePath, Path.Join(extractPath, extraPath), MangaFormat.Image, files.Count); + } + _directoryService.Flatten(extractDi.FullName); + } + } foreach (var file in files) diff --git a/API/Services/ImageService.cs b/API/Services/ImageService.cs index 36ba07ddc..815be6c86 100644 --- a/API/Services/ImageService.cs +++ b/API/Services/ImageService.cs @@ -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; diff --git a/API/Services/Plus/ScrobblingService.cs b/API/Services/Plus/ScrobblingService.cs index 57dca5943..8383f1778 100644 --- a/API/Services/Plus/ScrobblingService.cs +++ b/API/Services/Plus/ScrobblingService.cs @@ -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 _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 logger, ILicenseService licenseService, - ILocalizationService localizationService) + public ScrobblingService(IUnitOfWork unitOfWork, IEventHub eventHub, ILogger logger, + ILicenseService licenseService, ILocalizationService localizationService) { _unitOfWork = unitOfWork; - _tokenService = tokenService; _eventHub = eventHub; _logger = logger; _licenseService = licenseService; diff --git a/API/Services/Plus/SmartCollectionSyncService.cs b/API/Services/Plus/SmartCollectionSyncService.cs index 63cdb95b7..79a9c7a23 100644 --- a/API/Services/Plus/SmartCollectionSyncService.cs +++ b/API/Services/Plus/SmartCollectionSyncService.cs @@ -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("
"); } + 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); diff --git a/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs b/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs index 333c5ef18..b03681613 100644 --- a/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs +++ b/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs @@ -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 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,13 +247,23 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService } - private static async Task GetWordCountFromHtml(EpubLocalTextContentFileRef bookFile) + private async Task GetWordCountFromHtml(EpubLocalTextContentFileRef bookFile, string filePath) { - var doc = new HtmlDocument(); - doc.LoadHtml(await bookFile.ReadContentAsync()); + try + { + var doc = new HtmlDocument(); + doc.LoadHtml(await bookFile.ReadContentAsync()); - var textNodes = doc.DocumentNode.SelectNodes("//body//text()[not(parent::script)]"); - return textNodes?.Sum(node => node.InnerText.Count(char.IsLetter)) / AverageCharactersPerWord ?? 0; + 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; + } } } diff --git a/API/Services/Tasks/SiteThemeService.cs b/API/Services/Tasks/SiteThemeService.cs index f71d97c7e..b2f81363d 100644 --- a/API/Services/Tasks/SiteThemeService.cs +++ b/API/Services/Tasks/SiteThemeService.cs @@ -178,7 +178,7 @@ public class ThemeService : IThemeService themeDtos.Add(dto); } - _cache.Set(themeDtos, themes, _cacheOptions); + _cache.Set(cacheKey, themeDtos, _cacheOptions); return themeDtos; } diff --git a/API/SignalR/MessageFactory.cs b/API/SignalR/MessageFactory.cs index 31a5c949f..ff04e3201 100644 --- a/API/SignalR/MessageFactory.cs +++ b/API/SignalR/MessageFactory.cs @@ -134,6 +134,10 @@ public static class MessageFactory /// A Theme was updated and UI should refresh to get the latest version ///
public const string SiteThemeUpdated = "SiteThemeUpdated"; + /// + /// A Progress event when a smart collection is synchronizing + /// + public const string SmartCollectionSync = "SmartCollectionSync"; public static SignalRMessage DashboardUpdateEvent(int userId) { @@ -425,6 +429,31 @@ public static class MessageFactory }; } + /// + /// Represents a file being scanned by Kavita for processing and grouping + /// + /// Does not have a progress as it's unknown how many files there are. Instead sends -1 to represent indeterminate + /// + /// + /// + /// + 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 + } + }; + } + /// /// This informs the UI with details about what is being processed by the Scanner /// diff --git a/API/Startup.cs b/API/Startup.cs index 8d9e45fa5..a7eb490de 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -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(); diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d3735253f..5ccd27541 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 ### diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj index 52d7d204a..a0b6c3995 100644 --- a/Kavita.Common/Kavita.Common.csproj +++ b/Kavita.Common/Kavita.Common.csproj @@ -14,7 +14,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/UI/Web/src/_manga-reader-common.scss b/UI/Web/src/_manga-reader-common.scss index 1f1af75fa..9b54a5fad 100644 --- a/UI/Web/src/_manga-reader-common.scss +++ b/UI/Web/src/_manga-reader-common.scss @@ -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); } } diff --git a/UI/Web/src/app/_services/action-factory.service.ts b/UI/Web/src/app/_services/action-factory.service.ts index 1e211d7f5..d103cf133 100644 --- a/UI/Web/src/app/_services/action-factory.service.ts +++ b/UI/Web/src/app/_services/action-factory.service.ts @@ -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, diff --git a/UI/Web/src/app/_services/message-hub.service.ts b/UI/Web/src/app/_services/message-hub.service.ts index a7f30fda2..7601ee437 100644 --- a/UI/Web/src/app/_services/message-hub.service.ts +++ b/UI/Web/src/app/_services/message-hub.service.ts @@ -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 { @@ -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, diff --git a/UI/Web/src/app/_single-module/review-card/review-card.component.html b/UI/Web/src/app/_single-module/review-card/review-card.component.html index d2159033a..f9f8c53c3 100644 --- a/UI/Web/src/app/_single-module/review-card/review-card.component.html +++ b/UI/Web/src/app/_single-module/review-card/review-card.component.html @@ -24,11 +24,11 @@