mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-05-24 00:52:23 -04:00
Theme Viewer + Theme Updater (#2952)
This commit is contained in:
parent
24302d4fcc
commit
38e7c1c131
@ -9,6 +9,7 @@ using API.Services;
|
||||
using API.Services.Tasks;
|
||||
using API.SignalR;
|
||||
using Kavita.Common;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
@ -44,13 +45,14 @@ public abstract class SiteThemeServiceTest : AbstractDbTest
|
||||
var filesystem = CreateFileSystem();
|
||||
filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("123"));
|
||||
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
|
||||
var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub);
|
||||
var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub, Substitute.For<IFileService>(),
|
||||
Substitute.For<ILogger<ThemeService>>(), Substitute.For<IMemoryCache>());
|
||||
|
||||
_context.SiteTheme.Add(new SiteTheme()
|
||||
{
|
||||
Name = "Custom",
|
||||
NormalizedName = "Custom".ToNormalized(),
|
||||
Provider = ThemeProvider.User,
|
||||
Provider = ThemeProvider.Custom,
|
||||
FileName = "custom.css",
|
||||
IsDefault = false
|
||||
});
|
||||
@ -61,63 +63,6 @@ public abstract class SiteThemeServiceTest : AbstractDbTest
|
||||
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Scan_ShouldFindCustomFile()
|
||||
{
|
||||
await ResetDb();
|
||||
_testOutputHelper.WriteLine($"[Scan_ShouldOnlyInsertOnceOnSecondScan] All Themes: {(await _unitOfWork.SiteThemeRepository.GetThemes()).Count(t => t.IsDefault)}");
|
||||
var filesystem = CreateFileSystem();
|
||||
filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData(""));
|
||||
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
|
||||
var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub);
|
||||
await siteThemeService.Scan();
|
||||
|
||||
Assert.NotNull(await _unitOfWork.SiteThemeRepository.GetThemeDtoByName("custom"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Scan_ShouldOnlyInsertOnceOnSecondScan()
|
||||
{
|
||||
await ResetDb();
|
||||
_testOutputHelper.WriteLine(
|
||||
$"[Scan_ShouldOnlyInsertOnceOnSecondScan] All Themes: {(await _unitOfWork.SiteThemeRepository.GetThemes()).Count(t => t.IsDefault)}");
|
||||
var filesystem = CreateFileSystem();
|
||||
filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData(""));
|
||||
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
|
||||
var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub);
|
||||
await siteThemeService.Scan();
|
||||
|
||||
Assert.NotNull(await _unitOfWork.SiteThemeRepository.GetThemeDtoByName("custom"));
|
||||
|
||||
await siteThemeService.Scan();
|
||||
|
||||
var customThemes = (await _unitOfWork.SiteThemeRepository.GetThemeDtos()).Where(t =>
|
||||
t.Name.ToNormalized().Equals("custom".ToNormalized()));
|
||||
|
||||
Assert.Single(customThemes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Scan_ShouldDeleteWhenFileDoesntExistOnSecondScan()
|
||||
{
|
||||
await ResetDb();
|
||||
_testOutputHelper.WriteLine($"[Scan_ShouldDeleteWhenFileDoesntExistOnSecondScan] All Themes: {(await _unitOfWork.SiteThemeRepository.GetThemes()).Count(t => t.IsDefault)}");
|
||||
var filesystem = CreateFileSystem();
|
||||
filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData(""));
|
||||
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
|
||||
var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub);
|
||||
await siteThemeService.Scan();
|
||||
|
||||
Assert.NotNull(await _unitOfWork.SiteThemeRepository.GetThemeDtoByName("custom"));
|
||||
|
||||
filesystem.RemoveFile($"{SiteThemeDirectory}custom.css");
|
||||
await siteThemeService.Scan();
|
||||
|
||||
var themes = (await _unitOfWork.SiteThemeRepository.GetThemeDtos());
|
||||
|
||||
Assert.Equal(0, themes.Count(t =>
|
||||
t.Name.ToNormalized().Equals("custom".ToNormalized())));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetContent_ShouldReturnContent()
|
||||
@ -127,13 +72,14 @@ public abstract class SiteThemeServiceTest : AbstractDbTest
|
||||
var filesystem = CreateFileSystem();
|
||||
filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("123"));
|
||||
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
|
||||
var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub);
|
||||
var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub, Substitute.For<IFileService>(),
|
||||
Substitute.For<ILogger<ThemeService>>(), Substitute.For<IMemoryCache>());
|
||||
|
||||
_context.SiteTheme.Add(new SiteTheme()
|
||||
{
|
||||
Name = "Custom",
|
||||
NormalizedName = "Custom".ToNormalized(),
|
||||
Provider = ThemeProvider.User,
|
||||
Provider = ThemeProvider.Custom,
|
||||
FileName = "custom.css",
|
||||
IsDefault = false
|
||||
});
|
||||
@ -153,13 +99,14 @@ public abstract class SiteThemeServiceTest : AbstractDbTest
|
||||
var filesystem = CreateFileSystem();
|
||||
filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("123"));
|
||||
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
|
||||
var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub);
|
||||
var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub, Substitute.For<IFileService>(),
|
||||
Substitute.For<ILogger<ThemeService>>(), Substitute.For<IMemoryCache>());
|
||||
|
||||
_context.SiteTheme.Add(new SiteTheme()
|
||||
{
|
||||
Name = "Custom",
|
||||
NormalizedName = "Custom".ToNormalized(),
|
||||
Provider = ThemeProvider.User,
|
||||
Provider = ThemeProvider.Custom,
|
||||
FileName = "custom.css",
|
||||
IsDefault = false
|
||||
});
|
||||
|
@ -1,13 +1,21 @@
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Constants;
|
||||
using API.Data;
|
||||
using API.DTOs.Theme;
|
||||
using API.Entities;
|
||||
using API.Extensions;
|
||||
using API.Services;
|
||||
using API.Services.Tasks;
|
||||
using AutoMapper;
|
||||
using Kavita.Common;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace API.Controllers;
|
||||
|
||||
@ -17,16 +25,19 @@ public class ThemeController : BaseApiController
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IThemeService _themeService;
|
||||
private readonly ITaskScheduler _taskScheduler;
|
||||
private readonly ILocalizationService _localizationService;
|
||||
private readonly IDirectoryService _directoryService;
|
||||
private readonly IMapper _mapper;
|
||||
|
||||
public ThemeController(IUnitOfWork unitOfWork, IThemeService themeService, ITaskScheduler taskScheduler,
|
||||
ILocalizationService localizationService)
|
||||
|
||||
public ThemeController(IUnitOfWork unitOfWork, IThemeService themeService,
|
||||
ILocalizationService localizationService, IDirectoryService directoryService, IMapper mapper)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_themeService = themeService;
|
||||
_taskScheduler = taskScheduler;
|
||||
_localizationService = localizationService;
|
||||
_directoryService = directoryService;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
[ResponseCache(CacheProfileName = "10Minute")]
|
||||
@ -37,13 +48,6 @@ public class ThemeController : BaseApiController
|
||||
return Ok(await _unitOfWork.SiteThemeRepository.GetThemeDtos());
|
||||
}
|
||||
|
||||
[Authorize("RequireAdminRole")]
|
||||
[HttpPost("scan")]
|
||||
public ActionResult Scan()
|
||||
{
|
||||
_taskScheduler.ScanSiteThemes();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[Authorize("RequireAdminRole")]
|
||||
[HttpPost("update-default")]
|
||||
@ -78,4 +82,68 @@ public class ThemeController : BaseApiController
|
||||
return BadRequest(await _localizationService.Get("en", ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Browse themes that can be used on this server
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour)]
|
||||
[HttpGet("browse")]
|
||||
public async Task<ActionResult<IEnumerable<DownloadableSiteThemeDto>>> BrowseThemes()
|
||||
{
|
||||
var themes = await _themeService.GetDownloadableThemes();
|
||||
return Ok(themes.Where(t => !t.AlreadyDownloaded));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to delete a theme. If already in use by users, will not allow
|
||||
/// </summary>
|
||||
/// <param name="themeId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpDelete]
|
||||
public async Task<ActionResult<IEnumerable<DownloadableSiteThemeDto>>> DeleteTheme(int themeId)
|
||||
{
|
||||
|
||||
await _themeService.DeleteTheme(themeId);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downloads a SiteTheme from upstream
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("download-theme")]
|
||||
public async Task<ActionResult<SiteThemeDto>> DownloadTheme(DownloadableSiteThemeDto dto)
|
||||
{
|
||||
return Ok(_mapper.Map<SiteThemeDto>(await _themeService.DownloadRepoTheme(dto)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uploads a new theme file
|
||||
/// </summary>
|
||||
/// <param name="formFile"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("upload-theme")]
|
||||
public async Task<ActionResult<SiteThemeDto>> DownloadTheme(IFormFile formFile)
|
||||
{
|
||||
if (!formFile.FileName.EndsWith(".css")) return BadRequest("Invalid file");
|
||||
if (formFile.FileName.Contains("..")) return BadRequest("Invalid file");
|
||||
var tempFile = await UploadToTemp(formFile);
|
||||
|
||||
// Set summary as "Uploaded by User.GetUsername() on DATE"
|
||||
var theme = await _themeService.CreateThemeFromFile(tempFile, User.GetUsername());
|
||||
return Ok(_mapper.Map<SiteThemeDto>(theme));
|
||||
}
|
||||
|
||||
private async Task<string> UploadToTemp(IFormFile file)
|
||||
{
|
||||
var outputFile = Path.Join(_directoryService.TempDirectory, file.FileName);
|
||||
await using var stream = System.IO.File.Create(outputFile);
|
||||
await file.CopyToAsync(stream);
|
||||
stream.Close();
|
||||
return outputFile;
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -391,4 +391,6 @@ public class UploadController : BaseApiController
|
||||
return BadRequest(await _localizationService.Translate(User.GetUserId(), "reset-chapter-lock"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
@ -122,9 +122,10 @@ public class UsersController : BaseApiController
|
||||
existingPreferences.PdfScrollMode = preferencesDto.PdfScrollMode;
|
||||
existingPreferences.PdfSpreadMode = preferencesDto.PdfSpreadMode;
|
||||
|
||||
if (existingPreferences.Theme.Id != preferencesDto.Theme?.Id)
|
||||
if (preferencesDto.Theme != null && existingPreferences.Theme.Id != preferencesDto.Theme?.Id)
|
||||
{
|
||||
existingPreferences.Theme = preferencesDto.Theme ?? await _unitOfWork.SiteThemeRepository.GetDefaultTheme();
|
||||
var theme = await _unitOfWork.SiteThemeRepository.GetTheme(preferencesDto.Theme!.Id);
|
||||
existingPreferences.Theme = theme ?? await _unitOfWork.SiteThemeRepository.GetDefaultTheme();
|
||||
}
|
||||
|
||||
|
||||
|
52
API/DTOs/Theme/DownloadableSiteThemeDto.cs
Normal file
52
API/DTOs/Theme/DownloadableSiteThemeDto.cs
Normal file
@ -0,0 +1,52 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace API.DTOs.Theme;
|
||||
|
||||
|
||||
public class DownloadableSiteThemeDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Theme Name
|
||||
/// </summary>
|
||||
public string Name { get; set; }
|
||||
/// <summary>
|
||||
/// Url to download css file
|
||||
/// </summary>
|
||||
public string CssUrl { get; set; }
|
||||
public string CssFile { get; set; }
|
||||
/// <summary>
|
||||
/// Url to preview image
|
||||
/// </summary>
|
||||
public IList<string> PreviewUrls { get; set; }
|
||||
/// <summary>
|
||||
/// If Already downloaded
|
||||
/// </summary>
|
||||
public bool AlreadyDownloaded { get; set; }
|
||||
/// <summary>
|
||||
/// Sha of the file
|
||||
/// </summary>
|
||||
public string Sha { get; set; }
|
||||
/// <summary>
|
||||
/// Path of the Folder the files reside in
|
||||
/// </summary>
|
||||
public string Path { get; set; }
|
||||
/// <summary>
|
||||
/// Author of the theme
|
||||
/// </summary>
|
||||
/// <remarks>Derived from Readme</remarks>
|
||||
public string Author { get; set; }
|
||||
/// <summary>
|
||||
/// Last version tested against
|
||||
/// </summary>
|
||||
/// <remarks>Derived from Readme</remarks>
|
||||
public string LastCompatibleVersion { get; set; }
|
||||
/// <summary>
|
||||
/// If version compatible with version
|
||||
/// </summary>
|
||||
public bool IsCompatible { get; set; }
|
||||
/// <summary>
|
||||
/// Small blurb about the Theme
|
||||
/// </summary>
|
||||
public string Description { get; set; }
|
||||
}
|
@ -1,3 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using API.Entities.Enums.Theme;
|
||||
using API.Services;
|
||||
|
||||
@ -30,5 +31,21 @@ public class SiteThemeDto
|
||||
/// Where did the theme come from
|
||||
/// </summary>
|
||||
public ThemeProvider Provider { get; set; }
|
||||
|
||||
public IList<string> PreviewUrls { get; set; }
|
||||
/// <summary>
|
||||
/// Information about the theme
|
||||
/// </summary>
|
||||
public string Description { get; set; }
|
||||
/// <summary>
|
||||
/// Author of the Theme (only applies to non-system provided themes)
|
||||
/// </summary>
|
||||
public string Author { get; set; }
|
||||
/// <summary>
|
||||
/// Last compatible version. System provided will always be most current
|
||||
/// </summary>
|
||||
public string CompatibleVersion { get; set; }
|
||||
|
||||
|
||||
public string Selector => "bg-" + Name.ToLower();
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using API.DTOs.Theme;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Enums.UserPreferences;
|
||||
@ -104,7 +105,7 @@ public class UserPreferencesDto
|
||||
/// </summary>
|
||||
/// <remarks>Should default to Dark</remarks>
|
||||
[Required]
|
||||
public SiteTheme? Theme { get; set; }
|
||||
public SiteThemeDto? Theme { get; set; }
|
||||
|
||||
[Required] public string BookReaderThemeName { get; set; } = null!;
|
||||
[Required]
|
||||
|
3043
API/Data/Migrations/20240510134030_SiteThemeFields.Designer.cs
generated
Normal file
3043
API/Data/Migrations/20240510134030_SiteThemeFields.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
78
API/Data/Migrations/20240510134030_SiteThemeFields.cs
Normal file
78
API/Data/Migrations/20240510134030_SiteThemeFields.cs
Normal file
@ -0,0 +1,78 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace API.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class SiteThemeFields : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Author",
|
||||
table: "SiteTheme",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "CompatibleVersion",
|
||||
table: "SiteTheme",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Description",
|
||||
table: "SiteTheme",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "GitHubPath",
|
||||
table: "SiteTheme",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "PreviewUrls",
|
||||
table: "SiteTheme",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ShaHash",
|
||||
table: "SiteTheme",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Author",
|
||||
table: "SiteTheme");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CompatibleVersion",
|
||||
table: "SiteTheme");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Description",
|
||||
table: "SiteTheme");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GitHubPath",
|
||||
table: "SiteTheme");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PreviewUrls",
|
||||
table: "SiteTheme");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ShaHash",
|
||||
table: "SiteTheme");
|
||||
}
|
||||
}
|
||||
}
|
@ -1871,15 +1871,27 @@ namespace API.Data.Migrations
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Author")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CompatibleVersion")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("Created")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("GitHubPath")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsDefault")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@ -1895,9 +1907,15 @@ namespace API.Data.Migrations
|
||||
b.Property<string>("NormalizedName")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PreviewUrls")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Provider")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ShaHash")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("SiteTheme");
|
||||
|
@ -19,6 +19,8 @@ public interface ISiteThemeRepository
|
||||
Task<SiteThemeDto?> GetThemeDtoByName(string themeName);
|
||||
Task<SiteTheme> GetDefaultTheme();
|
||||
Task<IEnumerable<SiteTheme>> GetThemes();
|
||||
Task<SiteTheme?> GetTheme(int themeId);
|
||||
Task<bool> IsThemeInUse(int themeId);
|
||||
}
|
||||
|
||||
public class SiteThemeRepository : ISiteThemeRepository
|
||||
@ -88,6 +90,19 @@ public class SiteThemeRepository : ISiteThemeRepository
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<SiteTheme> GetTheme(int themeId)
|
||||
{
|
||||
return await _context.SiteTheme
|
||||
.Where(t => t.Id == themeId)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<bool> IsThemeInUse(int themeId)
|
||||
{
|
||||
return await _context.AppUserPreferences
|
||||
.AnyAsync(p => p.Theme.Id == themeId);
|
||||
}
|
||||
|
||||
public async Task<SiteThemeDto?> GetThemeDto(int themeId)
|
||||
{
|
||||
return await _context.SiteTheme
|
||||
|
@ -25,8 +25,8 @@ public static class Seed
|
||||
/// </summary>
|
||||
public static ImmutableArray<ServerSetting> DefaultSettings;
|
||||
|
||||
public static readonly ImmutableArray<SiteTheme> DefaultThemes = ImmutableArray.Create(
|
||||
new List<SiteTheme>
|
||||
public static readonly ImmutableArray<SiteTheme> DefaultThemes = [
|
||||
..new List<SiteTheme>
|
||||
{
|
||||
new()
|
||||
{
|
||||
@ -36,7 +36,8 @@ public static class Seed
|
||||
FileName = "dark.scss",
|
||||
IsDefault = true,
|
||||
}
|
||||
}.ToArray());
|
||||
}.ToArray()
|
||||
];
|
||||
|
||||
public static readonly ImmutableArray<AppUserDashboardStream> DefaultStreams = ImmutableArray.Create(
|
||||
new List<AppUserDashboardStream>
|
||||
|
@ -10,8 +10,8 @@ public enum ThemeProvider
|
||||
[Description("System")]
|
||||
System = 1,
|
||||
/// <summary>
|
||||
/// Theme is provided by the User (ie it's custom)
|
||||
/// Theme is provided by the User (ie it's custom) or Downloaded via Themes Repo
|
||||
/// </summary>
|
||||
[Description("User")]
|
||||
User = 2
|
||||
[Description("Custom")]
|
||||
Custom = 2,
|
||||
}
|
||||
|
@ -37,4 +37,30 @@ public class SiteTheme : IEntityDate, ITheme
|
||||
public DateTime LastModified { get; set; }
|
||||
public DateTime CreatedUtc { get; set; }
|
||||
public DateTime LastModifiedUtc { get; set; }
|
||||
|
||||
#region ThemeBrowser
|
||||
|
||||
/// <summary>
|
||||
/// The Url on the repo to download the file from
|
||||
/// </summary>
|
||||
public string? GitHubPath { get; set; }
|
||||
/// <summary>
|
||||
/// Hash of the Css File
|
||||
/// </summary>
|
||||
public string? ShaHash { get; set; }
|
||||
/// <summary>
|
||||
/// Pipe (|) separated urls of the images. Empty string if
|
||||
/// </summary>
|
||||
public string PreviewUrls { get; set; }
|
||||
// /// <summary>
|
||||
// /// A description about the theme
|
||||
// /// </summary>
|
||||
public string Description { get; set; }
|
||||
// /// <summary>
|
||||
// /// Author of the Theme
|
||||
// /// </summary>
|
||||
public string Author { get; set; }
|
||||
public string CompatibleVersion { get; set; }
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using API.Data.Migrations;
|
||||
using API.DTOs;
|
||||
@ -241,7 +242,10 @@ public class AutoMapperProfiles : Profile
|
||||
IncludeUnknowns = src.AgeRestrictionIncludeUnknowns
|
||||
}));
|
||||
|
||||
CreateMap<SiteTheme, SiteThemeDto>();
|
||||
CreateMap<SiteTheme, SiteThemeDto>()
|
||||
.ForMember(dest => dest.PreviewUrls,
|
||||
opt =>
|
||||
opt.MapFrom(src => (src.PreviewUrls ?? string.Empty).Split('|', StringSplitOptions.TrimEntries)));
|
||||
CreateMap<AppUserPreferences, UserPreferencesDto>()
|
||||
.ForMember(dest => dest.Theme,
|
||||
opt =>
|
||||
|
@ -1,5 +1,10 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.IO.Abstractions;
|
||||
using System.Runtime.Intrinsics.Arm;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Unicode;
|
||||
using API.Extensions;
|
||||
|
||||
namespace API.Services;
|
||||
@ -9,6 +14,7 @@ public interface IFileService
|
||||
IFileSystem GetFileSystem();
|
||||
bool HasFileBeenModifiedSince(string filePath, DateTime time);
|
||||
bool Exists(string filePath);
|
||||
bool ValidateSha(string filepath, string sha);
|
||||
}
|
||||
|
||||
public class FileService : IFileService
|
||||
@ -43,4 +49,28 @@ public class FileService : IFileService
|
||||
{
|
||||
return _fileSystem.File.Exists(filePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates the Sha256 hash matches
|
||||
/// </summary>
|
||||
/// <param name="filepath"></param>
|
||||
/// <param name="sha"></param>
|
||||
/// <returns></returns>
|
||||
public bool ValidateSha(string filepath, string sha)
|
||||
{
|
||||
if (!Exists(filepath)) return false;
|
||||
if (string.IsNullOrEmpty(sha)) throw new ArgumentException("Sha cannot be null");
|
||||
|
||||
using var fs = _fileSystem.File.OpenRead(filepath);
|
||||
fs.Position = 0;
|
||||
|
||||
using var reader = new StreamReader(fs, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
|
||||
var content = reader.ReadToEnd();
|
||||
|
||||
// Compute SHA hash
|
||||
var checksum = SHA256.HashData(Encoding.UTF8.GetBytes(content));
|
||||
|
||||
return BitConverter.ToString(checksum).Replace("-", string.Empty).Equals(sha);
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -32,7 +32,6 @@ public interface ITaskScheduler
|
||||
void AnalyzeFilesForLibrary(int libraryId, bool forceUpdate = false);
|
||||
void CancelStatsTasks();
|
||||
Task RunStatCollection();
|
||||
void ScanSiteThemes();
|
||||
void CovertAllCoversToEncoding();
|
||||
Task CleanupDbEntries();
|
||||
Task CheckForUpdate();
|
||||
@ -64,6 +63,7 @@ public class TaskScheduler : ITaskScheduler
|
||||
public const string DefaultQueue = "default";
|
||||
public const string RemoveFromWantToReadTaskId = "remove-from-want-to-read";
|
||||
public const string UpdateYearlyStatsTaskId = "update-yearly-stats";
|
||||
public const string SyncThemesTaskId = "sync-themes";
|
||||
public const string CheckForUpdateId = "check-updates";
|
||||
public const string CleanupDbTaskId = "cleanup-db";
|
||||
public const string CleanupTaskId = "cleanup";
|
||||
@ -161,6 +161,9 @@ public class TaskScheduler : ITaskScheduler
|
||||
RecurringJob.AddOrUpdate(UpdateYearlyStatsTaskId, () => _statisticService.UpdateServerStatistics(),
|
||||
Cron.Monthly, RecurringJobOptions);
|
||||
|
||||
RecurringJob.AddOrUpdate(SyncThemesTaskId, () => _themeService.SyncThemes(),
|
||||
Cron.Weekly, RecurringJobOptions);
|
||||
|
||||
await ScheduleKavitaPlusTasks();
|
||||
}
|
||||
|
||||
@ -200,7 +203,7 @@ public class TaskScheduler : ITaskScheduler
|
||||
|
||||
public async Task ScheduleStatsTasks()
|
||||
{
|
||||
var allowStatCollection = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).AllowStatCollection;
|
||||
var allowStatCollection = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).AllowStatCollection;
|
||||
if (!allowStatCollection)
|
||||
{
|
||||
_logger.LogDebug("User has opted out of stat collection, not registering tasks");
|
||||
@ -241,18 +244,6 @@ public class TaskScheduler : ITaskScheduler
|
||||
BackgroundJob.Schedule(() => _statsService.Send(), DateTimeOffset.Now.AddDays(1));
|
||||
}
|
||||
|
||||
public void ScanSiteThemes()
|
||||
{
|
||||
if (HasAlreadyEnqueuedTask("ThemeService", "Scan", Array.Empty<object>(), ScanQueue))
|
||||
{
|
||||
_logger.LogInformation("A Theme Scan is already running");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Enqueueing Site Theme scan");
|
||||
BackgroundJob.Enqueue(() => _themeService.Scan());
|
||||
}
|
||||
|
||||
public void CovertAllCoversToEncoding()
|
||||
{
|
||||
var defaultParams = Array.Empty<object>();
|
||||
|
@ -1,35 +1,105 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.DTOs.Theme;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums.Theme;
|
||||
using API.Extensions;
|
||||
using API.SignalR;
|
||||
using Flurl.Http;
|
||||
using HtmlAgilityPack;
|
||||
using Kavita.Common;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Kavita.Common.EnvironmentInfo;
|
||||
using MarkdownDeep;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace API.Services.Tasks;
|
||||
#nullable enable
|
||||
|
||||
internal class GitHubContent
|
||||
{
|
||||
[JsonProperty("name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
[JsonProperty("path")]
|
||||
public string Path { get; set; }
|
||||
|
||||
[JsonProperty("type")]
|
||||
public string Type { get; set; }
|
||||
|
||||
[JsonProperty("download_url")]
|
||||
public string DownloadUrl { get; set; }
|
||||
|
||||
[JsonProperty("sha")]
|
||||
public string Sha { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The readme of the Theme repo
|
||||
/// </summary>
|
||||
internal class ThemeMetadata
|
||||
{
|
||||
public string Author { get; set; }
|
||||
public string AuthorUrl { get; set; }
|
||||
public string Description { get; set; }
|
||||
public Version LastCompatible { get; set; }
|
||||
}
|
||||
|
||||
|
||||
public interface IThemeService
|
||||
{
|
||||
Task<string> GetContent(int themeId);
|
||||
Task Scan();
|
||||
Task UpdateDefault(int themeId);
|
||||
/// <summary>
|
||||
/// Browse theme repo for themes to download
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
Task<List<DownloadableSiteThemeDto>> GetDownloadableThemes();
|
||||
|
||||
Task<SiteTheme> DownloadRepoTheme(DownloadableSiteThemeDto dto);
|
||||
Task DeleteTheme(int siteThemeId);
|
||||
Task<SiteTheme> CreateThemeFromFile(string tempFile, string username);
|
||||
Task SyncThemes();
|
||||
}
|
||||
|
||||
|
||||
|
||||
public class ThemeService : IThemeService
|
||||
{
|
||||
private readonly IDirectoryService _directoryService;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IEventHub _eventHub;
|
||||
private readonly IFileService _fileService;
|
||||
private readonly ILogger<ThemeService> _logger;
|
||||
private readonly Markdown _markdown = new();
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly MemoryCacheEntryOptions _cacheOptions;
|
||||
|
||||
public ThemeService(IDirectoryService directoryService, IUnitOfWork unitOfWork, IEventHub eventHub)
|
||||
private const string GithubBaseUrl = "https://api.github.com";
|
||||
|
||||
/// <summary>
|
||||
/// Used for refreshing metadata around themes
|
||||
/// </summary>
|
||||
private const string GithubReadme = "https://raw.githubusercontent.com/Kareadita/Themes/main/README.md";
|
||||
|
||||
public ThemeService(IDirectoryService directoryService, IUnitOfWork unitOfWork,
|
||||
IEventHub eventHub, IFileService fileService, ILogger<ThemeService> logger, IMemoryCache cache)
|
||||
{
|
||||
_directoryService = directoryService;
|
||||
_unitOfWork = unitOfWork;
|
||||
_eventHub = eventHub;
|
||||
_fileService = fileService;
|
||||
_logger = logger;
|
||||
_cache = cache;
|
||||
|
||||
_cacheOptions = new MemoryCacheEntryOptions()
|
||||
.SetSize(1)
|
||||
.SetAbsoluteExpiration(TimeSpan.FromMinutes(30));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -39,8 +109,7 @@ public class ThemeService : IThemeService
|
||||
/// <returns></returns>
|
||||
public async Task<string> GetContent(int themeId)
|
||||
{
|
||||
var theme = await _unitOfWork.SiteThemeRepository.GetThemeDto(themeId);
|
||||
if (theme == null) throw new KavitaException("theme-doesnt-exist");
|
||||
var theme = await _unitOfWork.SiteThemeRepository.GetThemeDto(themeId) ?? throw new KavitaException("theme-doesnt-exist");
|
||||
var themeFile = _directoryService.FileSystem.Path.Join(_directoryService.SiteThemeDirectory, theme.FileName);
|
||||
if (string.IsNullOrEmpty(themeFile) || !_directoryService.FileSystem.File.Exists(themeFile))
|
||||
throw new KavitaException("theme-doesnt-exist");
|
||||
@ -48,78 +117,350 @@ public class ThemeService : IThemeService
|
||||
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()
|
||||
public async Task<List<DownloadableSiteThemeDto>> GetDownloadableThemes()
|
||||
{
|
||||
_directoryService.ExistOrCreate(_directoryService.SiteThemeDirectory);
|
||||
var reservedNames = Seed.DefaultThemes.Select(t => t.NormalizedName).ToList();
|
||||
var themeFiles = _directoryService
|
||||
.GetFilesWithExtension(Scanner.Parser.Parser.NormalizePath(_directoryService.SiteThemeDirectory), @"\.css")
|
||||
.Where(name => !reservedNames.Contains(name.ToNormalized()) && !name.Contains(" "))
|
||||
.ToList();
|
||||
|
||||
var allThemes = (await _unitOfWork.SiteThemeRepository.GetThemes()).ToList();
|
||||
|
||||
// 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)
|
||||
const string cacheKey = "browse";
|
||||
var existingThemes = (await _unitOfWork.SiteThemeRepository.GetThemeDtos()).ToDictionary(k => k.Name);
|
||||
if (_cache.TryGetValue(cacheKey, out List<DownloadableSiteThemeDto>? themes) && themes != null)
|
||||
{
|
||||
var filepath = Scanner.Parser.Parser.NormalizePath(
|
||||
_directoryService.FileSystem.Path.Join(_directoryService.SiteThemeDirectory, userTheme.FileName));
|
||||
if (_directoryService.FileSystem.File.Exists(filepath)) continue;
|
||||
|
||||
// I need to do the removal different. I need to update all user preferences to use DefaultTheme
|
||||
allThemes.Remove(userTheme);
|
||||
await RemoveTheme(userTheme);
|
||||
}
|
||||
|
||||
// Add new custom themes
|
||||
var allThemeNames = allThemes.Select(t => t.NormalizedName).ToList();
|
||||
foreach (var themeFile in themeFiles)
|
||||
{
|
||||
var themeName =
|
||||
_directoryService.FileSystem.Path.GetFileNameWithoutExtension(themeFile).ToNormalized();
|
||||
if (allThemeNames.Contains(themeName)) continue;
|
||||
|
||||
_unitOfWork.SiteThemeRepository.Add(new SiteTheme()
|
||||
foreach (var t in themes)
|
||||
{
|
||||
Name = _directoryService.FileSystem.Path.GetFileNameWithoutExtension(themeFile),
|
||||
NormalizedName = themeName,
|
||||
FileName = _directoryService.FileSystem.Path.GetFileName(themeFile),
|
||||
Provider = ThemeProvider.User,
|
||||
IsDefault = false,
|
||||
});
|
||||
|
||||
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
|
||||
MessageFactory.SiteThemeProgressEvent(_directoryService.FileSystem.Path.GetFileName(themeFile), themeName,
|
||||
ProgressEventType.Updated));
|
||||
t.AlreadyDownloaded = existingThemes.ContainsKey(t.Name);
|
||||
}
|
||||
return themes;
|
||||
}
|
||||
|
||||
// Fetch contents of the Native Themes directory
|
||||
var themesContents = await GetDirectoryContent("Native%20Themes");
|
||||
|
||||
if (_unitOfWork.HasChanges())
|
||||
{
|
||||
await _unitOfWork.CommitAsync();
|
||||
}
|
||||
// Filter out directories
|
||||
var themeDirectories = themesContents.Where(c => c.Type == "dir").ToList();
|
||||
|
||||
// if there are no default themes, reselect Dark as default
|
||||
var postSaveThemes = (await _unitOfWork.SiteThemeRepository.GetThemes()).ToList();
|
||||
if (!postSaveThemes.Exists(t => t.IsDefault))
|
||||
// Get the Readme and augment the theme data
|
||||
var themeMetadata = await GetReadme();
|
||||
|
||||
var themeDtos = new List<DownloadableSiteThemeDto>();
|
||||
foreach (var themeDir in themeDirectories)
|
||||
{
|
||||
var defaultThemeName = Seed.DefaultThemes.Single(t => t.IsDefault).NormalizedName;
|
||||
var theme = postSaveThemes.SingleOrDefault(t => t.NormalizedName == defaultThemeName);
|
||||
if (theme != null)
|
||||
var themeName = themeDir.Name.Trim();
|
||||
|
||||
// Fetch contents of the theme directory
|
||||
var themeContents = await GetDirectoryContent(themeDir.Path);
|
||||
|
||||
// Find css and preview files
|
||||
var cssFile = themeContents.FirstOrDefault(c => c.Name.EndsWith(".css"));
|
||||
var previewUrls = GetPreviewUrls(themeContents);
|
||||
|
||||
if (cssFile == null) continue;
|
||||
|
||||
var cssUrl = cssFile.DownloadUrl;
|
||||
|
||||
|
||||
var dto = new DownloadableSiteThemeDto()
|
||||
{
|
||||
theme.IsDefault = true;
|
||||
_unitOfWork.SiteThemeRepository.Update(theme);
|
||||
await _unitOfWork.CommitAsync();
|
||||
Name = themeName,
|
||||
CssUrl = cssUrl,
|
||||
CssFile = cssFile.Name,
|
||||
PreviewUrls = previewUrls,
|
||||
Sha = cssFile.Sha,
|
||||
Path = themeDir.Path,
|
||||
};
|
||||
|
||||
if (themeMetadata.TryGetValue(themeName, out var metadata))
|
||||
{
|
||||
dto.Author = metadata.Author;
|
||||
dto.LastCompatibleVersion = metadata.LastCompatible.ToString();
|
||||
dto.IsCompatible = BuildInfo.Version <= metadata.LastCompatible;
|
||||
dto.AlreadyDownloaded = existingThemes.ContainsKey(themeName);
|
||||
dto.Description = metadata.Description;
|
||||
}
|
||||
|
||||
themeDtos.Add(dto);
|
||||
}
|
||||
|
||||
_cache.Set(themeDtos, themes, _cacheOptions);
|
||||
|
||||
return themeDtos;
|
||||
}
|
||||
|
||||
private static IList<string> GetPreviewUrls(IEnumerable<GitHubContent> themeContents)
|
||||
{
|
||||
return themeContents.Where(c => c.Name.ToLower().EndsWith(".jpg") || c.Name.ToLower().EndsWith(".png") )
|
||||
.Select(p => p.DownloadUrl)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static async Task<IList<GitHubContent>> GetDirectoryContent(string path)
|
||||
{
|
||||
return await $"{GithubBaseUrl}/repos/Kareadita/Themes/contents/{path}"
|
||||
.WithHeader("Accept", "application/vnd.github+json")
|
||||
.WithHeader("User-Agent", "Kavita")
|
||||
.GetJsonAsync<List<GitHubContent>>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a map of all Native Themes names mapped to their metadata
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
private async Task<IDictionary<string, ThemeMetadata>> GetReadme()
|
||||
{
|
||||
var tempDownloadFile = await GithubReadme.DownloadFileAsync(_directoryService.TempDirectory);
|
||||
|
||||
// Read file into Markdown
|
||||
var htmlContent = _markdown.Transform(await _directoryService.FileSystem.File.ReadAllTextAsync(tempDownloadFile));
|
||||
var htmlDoc = new HtmlDocument();
|
||||
htmlDoc.LoadHtml(htmlContent);
|
||||
|
||||
// Find the table of Native Themes
|
||||
var tableContent = htmlDoc.DocumentNode
|
||||
.SelectSingleNode("//h2[contains(text(),'Native Themes')]/following-sibling::p").InnerText;
|
||||
|
||||
// Initialize dictionary to store theme metadata
|
||||
var themes = new Dictionary<string, ThemeMetadata>();
|
||||
|
||||
|
||||
// Split the table content by rows
|
||||
var rows = tableContent.Split("\r\n").Select(row => row.Trim()).Where(row => !string.IsNullOrWhiteSpace(row)).ToList();
|
||||
|
||||
// Parse each row in the Native Themes table
|
||||
foreach (var row in rows.Skip(2))
|
||||
{
|
||||
|
||||
var cells = row.Split('|').Skip(1).Select(cell => cell.Trim()).ToList();
|
||||
|
||||
// Extract information from each cell
|
||||
var themeName = cells[0];
|
||||
var authorName = cells[1];
|
||||
var description = cells[2];
|
||||
var compatibility = Version.Parse(cells[3]);
|
||||
|
||||
// Create ThemeMetadata object
|
||||
var themeMetadata = new ThemeMetadata
|
||||
{
|
||||
Author = authorName,
|
||||
Description = description,
|
||||
LastCompatible = compatibility
|
||||
};
|
||||
|
||||
// Add theme metadata to dictionary
|
||||
themes.Add(themeName, themeMetadata);
|
||||
}
|
||||
|
||||
return themes;
|
||||
}
|
||||
|
||||
|
||||
private async Task<string> DownloadSiteTheme(DownloadableSiteThemeDto dto)
|
||||
{
|
||||
if (string.IsNullOrEmpty(dto.Sha))
|
||||
{
|
||||
throw new ArgumentException("SHA cannot be null or empty for already downloaded themes.");
|
||||
}
|
||||
|
||||
_directoryService.ExistOrCreate(_directoryService.SiteThemeDirectory);
|
||||
var existingTempFile = _directoryService.FileSystem.Path.Join(_directoryService.SiteThemeDirectory,
|
||||
_directoryService.FileSystem.FileInfo.New(dto.CssUrl).Name);
|
||||
_directoryService.DeleteFiles([existingTempFile]);
|
||||
|
||||
var tempDownloadFile = await dto.CssUrl.DownloadFileAsync(_directoryService.TempDirectory);
|
||||
|
||||
// Validate the hash on the downloaded file
|
||||
// if (!_fileService.ValidateSha(tempDownloadFile, dto.Sha))
|
||||
// {
|
||||
// throw new KavitaException("Cannot download theme, hash does not match");
|
||||
// }
|
||||
|
||||
_directoryService.CopyFileToDirectory(tempDownloadFile, _directoryService.SiteThemeDirectory);
|
||||
var finalLocation = _directoryService.FileSystem.Path.Join(_directoryService.SiteThemeDirectory, dto.CssFile);
|
||||
|
||||
return finalLocation;
|
||||
}
|
||||
|
||||
|
||||
public async Task<SiteTheme> DownloadRepoTheme(DownloadableSiteThemeDto dto)
|
||||
{
|
||||
|
||||
// Validate we don't have a collision with existing or existing doesn't already exist
|
||||
var existingThemes = _directoryService.ScanFiles(_directoryService.SiteThemeDirectory, string.Empty);
|
||||
if (existingThemes.Any(f => Path.GetFileName(f) == dto.CssFile))
|
||||
{
|
||||
throw new KavitaException("Cannot download file, file already on disk");
|
||||
}
|
||||
|
||||
var finalLocation = await DownloadSiteTheme(dto);
|
||||
|
||||
// Create a new entry and note that this is downloaded
|
||||
var theme = new SiteTheme()
|
||||
{
|
||||
Name = dto.Name,
|
||||
NormalizedName = dto.Name.ToNormalized(),
|
||||
FileName = _directoryService.FileSystem.Path.GetFileName(finalLocation),
|
||||
Provider = ThemeProvider.Custom,
|
||||
IsDefault = false,
|
||||
GitHubPath = dto.Path,
|
||||
Description = dto.Description,
|
||||
PreviewUrls = string.Join('|', dto.PreviewUrls),
|
||||
Author = dto.Author,
|
||||
ShaHash = dto.Sha,
|
||||
CompatibleVersion = dto.LastCompatibleVersion,
|
||||
};
|
||||
_unitOfWork.SiteThemeRepository.Add(theme);
|
||||
|
||||
await _unitOfWork.CommitAsync();
|
||||
|
||||
// Inform about the new theme
|
||||
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
|
||||
MessageFactory.SiteThemeProgressEvent("", "", ProgressEventType.Ended));
|
||||
MessageFactory.SiteThemeProgressEvent(_directoryService.FileSystem.Path.GetFileName(theme.FileName), theme.Name,
|
||||
ProgressEventType.Ended));
|
||||
return theme;
|
||||
}
|
||||
|
||||
public async Task SyncThemes()
|
||||
{
|
||||
var themes = await _unitOfWork.SiteThemeRepository.GetThemes();
|
||||
var themeMetadata = await GetReadme();
|
||||
foreach (var theme in themes)
|
||||
{
|
||||
await SyncTheme(theme, themeMetadata);
|
||||
}
|
||||
_logger.LogInformation("Sync Themes complete");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// If the Theme is from the Theme repo, see if there is a new version that is compatible
|
||||
/// </summary>
|
||||
/// <param name="theme"></param>
|
||||
/// <param name="themeMetadata">The Readme information</param>
|
||||
private async Task SyncTheme(SiteTheme? theme, IDictionary<string, ThemeMetadata> themeMetadata)
|
||||
{
|
||||
// Given a theme, first validate that it is applicable
|
||||
if (theme == null || theme.Provider == ThemeProvider.System || string.IsNullOrEmpty(theme.GitHubPath))
|
||||
{
|
||||
_logger.LogInformation("Cannot Sync {ThemeName} as it is not valid", theme?.Name);
|
||||
return;
|
||||
}
|
||||
|
||||
if (new Version(theme.CompatibleVersion) > BuildInfo.Version)
|
||||
{
|
||||
_logger.LogDebug("{ThemeName} theme supports a more up-to-date version ({Version}) of Kavita. Please update", theme.Name, theme.CompatibleVersion);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
var themeContents = await GetDirectoryContent(theme.GitHubPath);
|
||||
var cssFile = themeContents.FirstOrDefault(c => c.Name.EndsWith(".css"));
|
||||
|
||||
if (cssFile == null) return;
|
||||
|
||||
// Update any metadata
|
||||
if (themeMetadata.TryGetValue(theme.Name, out var metadata))
|
||||
{
|
||||
theme.Description = metadata.Description;
|
||||
theme.Author = metadata.Author;
|
||||
theme.CompatibleVersion = metadata.LastCompatible.ToString();
|
||||
theme.PreviewUrls = string.Join('|', GetPreviewUrls(themeContents));
|
||||
}
|
||||
|
||||
var hasUpdated = cssFile.Sha != theme.ShaHash;
|
||||
if (hasUpdated)
|
||||
{
|
||||
_logger.LogDebug("Theme {ThemeName} is out of date, updating", theme.Name);
|
||||
var tempLocation = _directoryService.FileSystem.Path.Join(_directoryService.TempDirectory, theme.FileName);
|
||||
|
||||
_directoryService.DeleteFiles([tempLocation]);
|
||||
|
||||
var location = await cssFile.DownloadUrl.DownloadFileAsync(_directoryService.TempDirectory);
|
||||
if (_directoryService.FileSystem.File.Exists(location))
|
||||
{
|
||||
_directoryService.CopyFileToDirectory(location, _directoryService.SiteThemeDirectory);
|
||||
_logger.LogInformation("Updated Theme on disk for {ThemeName}", theme.Name);
|
||||
}
|
||||
}
|
||||
|
||||
await _unitOfWork.CommitAsync();
|
||||
|
||||
|
||||
if (hasUpdated)
|
||||
{
|
||||
await _eventHub.SendMessageAsync(MessageFactory.SiteThemeUpdated,
|
||||
MessageFactory.SiteThemeUpdatedEvent(theme.Name));
|
||||
}
|
||||
|
||||
// Send an update to refresh metadata around the themes
|
||||
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
|
||||
MessageFactory.SiteThemeProgressEvent(_directoryService.FileSystem.Path.GetFileName(theme.FileName), theme.Name,
|
||||
ProgressEventType.Ended));
|
||||
|
||||
_logger.LogInformation("Theme Sync complete");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a SiteTheme. The CSS file will be moved to temp/ to allow user to recover data
|
||||
/// </summary>
|
||||
/// <param name="siteThemeId"></param>
|
||||
public async Task DeleteTheme(int siteThemeId)
|
||||
{
|
||||
// Validate no one else is using this theme
|
||||
var inUse = await _unitOfWork.SiteThemeRepository.IsThemeInUse(siteThemeId);
|
||||
if (inUse)
|
||||
{
|
||||
throw new KavitaException("errors.delete-theme-in-use");
|
||||
}
|
||||
|
||||
var siteTheme = await _unitOfWork.SiteThemeRepository.GetTheme(siteThemeId);
|
||||
if (siteTheme == null) return;
|
||||
|
||||
await RemoveTheme(siteTheme);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This assumes a file is already in temp directory and will be used for
|
||||
/// </summary>
|
||||
/// <param name="tempFile"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<SiteTheme> CreateThemeFromFile(string tempFile, string username)
|
||||
{
|
||||
if (!_directoryService.FileSystem.File.Exists(tempFile))
|
||||
{
|
||||
_logger.LogInformation("Unable to create theme from manual upload as file not in temp");
|
||||
throw new KavitaException("errors.theme-manual-upload");
|
||||
}
|
||||
|
||||
|
||||
var filename = _directoryService.FileSystem.FileInfo.New(tempFile).Name;
|
||||
var themeName = Path.GetFileNameWithoutExtension(filename);
|
||||
|
||||
if (await _unitOfWork.SiteThemeRepository.GetThemeDtoByName(themeName) != null)
|
||||
{
|
||||
throw new KavitaException("errors.theme-already-in-use");
|
||||
}
|
||||
|
||||
_directoryService.CopyFileToDirectory(tempFile, _directoryService.SiteThemeDirectory);
|
||||
var finalLocation = _directoryService.FileSystem.Path.Join(_directoryService.SiteThemeDirectory, filename);
|
||||
|
||||
|
||||
// Create a new entry and note that this is downloaded
|
||||
var theme = new SiteTheme()
|
||||
{
|
||||
Name = Path.GetFileNameWithoutExtension(filename),
|
||||
NormalizedName = themeName.ToNormalized(),
|
||||
FileName = _directoryService.FileSystem.Path.GetFileName(finalLocation),
|
||||
Provider = ThemeProvider.Custom,
|
||||
IsDefault = false,
|
||||
Description = $"Manually uploaded via UI by {username}",
|
||||
PreviewUrls = string.Empty,
|
||||
Author = username,
|
||||
};
|
||||
_unitOfWork.SiteThemeRepository.Add(theme);
|
||||
|
||||
await _unitOfWork.CommitAsync();
|
||||
|
||||
// Inform about the new theme
|
||||
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
|
||||
MessageFactory.SiteThemeProgressEvent(_directoryService.FileSystem.Path.GetFileName(theme.FileName), theme.Name,
|
||||
ProgressEventType.Ended));
|
||||
return theme;
|
||||
|
||||
}
|
||||
|
||||
|
||||
@ -130,6 +471,7 @@ public class ThemeService : IThemeService
|
||||
/// <param name="theme"></param>
|
||||
private async Task RemoveTheme(SiteTheme theme)
|
||||
{
|
||||
_logger.LogInformation("Removing {ThemeName}. File can be found in temp/ until nightly cleanup", theme.Name);
|
||||
var prefs = await _unitOfWork.UserRepository.GetAllPreferencesByThemeAsync(theme.Id);
|
||||
var defaultTheme = await _unitOfWork.SiteThemeRepository.GetDefaultTheme();
|
||||
foreach (var pref in prefs)
|
||||
@ -137,6 +479,20 @@ public class ThemeService : IThemeService
|
||||
pref.Theme = defaultTheme;
|
||||
_unitOfWork.UserRepository.Update(pref);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Copy the theme file to temp for nightly removal (to give user time to reclaim if made a mistake)
|
||||
var existingLocation =
|
||||
_directoryService.FileSystem.Path.Join(_directoryService.SiteThemeDirectory, theme.FileName);
|
||||
var newLocation =
|
||||
_directoryService.FileSystem.Path.Join(_directoryService.TempDirectory, theme.FileName);
|
||||
_directoryService.CopyFileToDirectory(existingLocation, newLocation);
|
||||
_directoryService.DeleteFiles([existingLocation]);
|
||||
}
|
||||
catch (Exception) { /* Swallow */ }
|
||||
|
||||
|
||||
_unitOfWork.SiteThemeRepository.Remove(theme);
|
||||
await _unitOfWork.CommitAsync();
|
||||
}
|
||||
|
@ -130,6 +130,10 @@ public static class MessageFactory
|
||||
/// Order, Visibility, etc has changed on the Sidenav. UI will refresh the layout
|
||||
/// </summary>
|
||||
public const string SideNavUpdate = "SideNavUpdate";
|
||||
/// <summary>
|
||||
/// A Theme was updated and UI should refresh to get the latest version
|
||||
/// </summary>
|
||||
public const string SiteThemeUpdated = "SiteThemeUpdated";
|
||||
|
||||
public static SignalRMessage DashboardUpdateEvent(int userId)
|
||||
{
|
||||
@ -485,7 +489,7 @@ public static class MessageFactory
|
||||
return new SignalRMessage()
|
||||
{
|
||||
Name = SiteThemeProgress,
|
||||
Title = "Scanning Site Theme",
|
||||
Title = "Processing Site Theme", // TODO: Localize SignalRMessage titles
|
||||
SubTitle = subtitle,
|
||||
EventType = eventType,
|
||||
Progress = ProgressType.Indeterminate,
|
||||
@ -496,6 +500,25 @@ public static class MessageFactory
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends an event to the UI informing of a SiteTheme update and UI needs to refresh the content
|
||||
/// </summary>
|
||||
/// <param name="themeName"></param>
|
||||
/// <returns></returns>
|
||||
public static SignalRMessage SiteThemeUpdatedEvent(string themeName)
|
||||
{
|
||||
return new SignalRMessage()
|
||||
{
|
||||
Name = SiteThemeUpdated,
|
||||
Title = "SiteTheme Update",
|
||||
Progress = ProgressType.None,
|
||||
Body = new
|
||||
{
|
||||
ThemeName = themeName,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static SignalRMessage BookThemeProgressEvent(string subtitle, string themeName, string eventType)
|
||||
{
|
||||
return new SignalRMessage()
|
||||
|
@ -398,7 +398,10 @@ public class Startup
|
||||
endpoints.MapControllers();
|
||||
endpoints.MapHub<MessageHub>("hubs/messages");
|
||||
endpoints.MapHub<LogHub>("hubs/logs");
|
||||
endpoints.MapHangfireDashboard();
|
||||
if (env.IsDevelopment())
|
||||
{
|
||||
endpoints.MapHangfireDashboard();
|
||||
}
|
||||
endpoints.MapFallbackToController("Index", "Fallback");
|
||||
});
|
||||
|
||||
|
36
UI/Web/package-lock.json
generated
36
UI/Web/package-lock.json
generated
@ -504,6 +504,7 @@
|
||||
"version": "17.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-17.3.4.tgz",
|
||||
"integrity": "sha512-TVWjpZSI/GIXTYsmVgEKYjBckcW8Aj62DcxLNehRFR+c7UB95OY3ZFjU8U4jL0XvWPgTkkVWQVq+P6N4KCBsyw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/core": "7.23.9",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14",
|
||||
@ -531,6 +532,7 @@
|
||||
"version": "7.23.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.9.tgz",
|
||||
"integrity": "sha512-5q0175NOjddqpvvzU+kDiSOAk4PfdO6FvwCWoQ6RO7rTzEe8vlo+4HVfcnAREhD4npMs0e9uZypjTwzZPCf/cw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.2.0",
|
||||
"@babel/code-frame": "^7.23.5",
|
||||
@ -559,12 +561,14 @@
|
||||
"node_modules/@angular/compiler-cli/node_modules/@babel/core/node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@angular/compiler-cli/node_modules/@babel/core/node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
@ -745,6 +749,7 @@
|
||||
"version": "7.24.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.0.tgz",
|
||||
"integrity": "sha512-fQfkg0Gjkza3nf0c7/w6Xf34BW4YvzNfACRLmmb7XRLa6XHdR+K9AlJlxneFfWYf6uhOzuzZVTjF/8KfndZANw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.2.0",
|
||||
"@babel/code-frame": "^7.23.5",
|
||||
@ -773,12 +778,14 @@
|
||||
"node_modules/@babel/core/node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@babel/core/node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
@ -5622,6 +5629,7 @@
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
||||
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"normalize-path": "^3.0.0",
|
||||
"picomatch": "^2.0.4"
|
||||
@ -5634,6 +5642,7 @@
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
},
|
||||
@ -5905,6 +5914,7 @@
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
@ -6216,6 +6226,7 @@
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"anymatch": "~3.1.2",
|
||||
"braces": "~3.0.2",
|
||||
@ -6507,7 +6518,8 @@
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
|
||||
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="
|
||||
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.6.0",
|
||||
@ -7409,6 +7421,7 @@
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
|
||||
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"iconv-lite": "^0.6.2"
|
||||
@ -7418,6 +7431,7 @@
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
@ -8526,6 +8540,7 @@
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -9207,6 +9222,7 @@
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"binary-extensions": "^2.0.0"
|
||||
},
|
||||
@ -11047,6 +11063,7 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@ -12436,6 +12453,7 @@
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"picomatch": "^2.2.1"
|
||||
},
|
||||
@ -12447,6 +12465,7 @@
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
},
|
||||
@ -12457,7 +12476,8 @@
|
||||
"node_modules/reflect-metadata": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
||||
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="
|
||||
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/regenerate": {
|
||||
"version": "1.4.2",
|
||||
@ -12925,7 +12945,7 @@
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"devOptional": true
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/sass": {
|
||||
"version": "1.71.1",
|
||||
@ -13044,6 +13064,7 @@
|
||||
"version": "7.6.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
|
||||
"integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"lru-cache": "^6.0.0"
|
||||
},
|
||||
@ -13058,6 +13079,7 @@
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
||||
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"yallist": "^4.0.0"
|
||||
},
|
||||
@ -13068,7 +13090,8 @@
|
||||
"node_modules/semver/node_modules/yallist": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
|
||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "0.18.0",
|
||||
@ -14199,6 +14222,7 @@
|
||||
"version": "5.4.5",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
|
||||
"integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
@ -0,0 +1,3 @@
|
||||
export interface SiteThemeUpdatedEvent {
|
||||
themeName: string;
|
||||
}
|
@ -3,9 +3,9 @@
|
||||
*/
|
||||
export enum ThemeProvider {
|
||||
System = 1,
|
||||
User = 2
|
||||
Custom = 2,
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Theme for the whole instance
|
||||
*/
|
||||
@ -20,4 +20,8 @@
|
||||
* The actual class the root is defined against. It is generated at the backend.
|
||||
*/
|
||||
selector: string;
|
||||
}
|
||||
description: string;
|
||||
previewUrls: Array<string>;
|
||||
author: string;
|
||||
|
||||
}
|
||||
|
10
UI/Web/src/app/_models/theme/downloadable-site-theme.ts
Normal file
10
UI/Web/src/app/_models/theme/downloadable-site-theme.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export interface DownloadableSiteTheme {
|
||||
name: string;
|
||||
cssUrl: string;
|
||||
previewUrls: Array<string>;
|
||||
author: string;
|
||||
isCompatible: boolean;
|
||||
lastCompatibleVersion: string;
|
||||
alreadyDownloaded: boolean;
|
||||
description: string;
|
||||
}
|
@ -16,8 +16,8 @@ export class SiteThemeProviderPipe implements PipeTransform {
|
||||
switch(provider) {
|
||||
case ThemeProvider.System:
|
||||
return this.translocoService.translate('site-theme-provider-pipe.system');
|
||||
case ThemeProvider.User:
|
||||
return this.translocoService.translate('site-theme-provider-pipe.user');
|
||||
case ThemeProvider.Custom:
|
||||
return this.translocoService.translate('site-theme-provider-pipe.custom');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
|
@ -9,6 +9,7 @@ import { UserUpdateEvent } from '../_models/events/user-update-event';
|
||||
import { User } from '../_models/user';
|
||||
import {DashboardUpdateEvent} from "../_models/events/dashboard-update-event";
|
||||
import {SideNavUpdateEvent} from "../_models/events/sidenav-update-event";
|
||||
import {SiteThemeUpdatedEvent} from "../_models/events/site-theme-updated-event";
|
||||
|
||||
export enum EVENTS {
|
||||
UpdateAvailable = 'UpdateAvailable',
|
||||
@ -98,7 +99,11 @@ export enum EVENTS {
|
||||
/**
|
||||
* User's sidenav needs to be re-rendered
|
||||
*/
|
||||
SideNavUpdate = 'SideNavUpdate'
|
||||
SideNavUpdate = 'SideNavUpdate',
|
||||
/**
|
||||
* A Theme was updated and UI should refresh to get the latest version
|
||||
*/
|
||||
SiteThemeUpdated= 'SiteThemeUpdated'
|
||||
}
|
||||
|
||||
export interface Message<T> {
|
||||
@ -194,6 +199,13 @@ export class MessageHubService {
|
||||
});
|
||||
});
|
||||
|
||||
this.hubConnection.on(EVENTS.SiteThemeUpdated, resp => {
|
||||
this.messagesSource.next({
|
||||
event: EVENTS.SiteThemeUpdated,
|
||||
payload: resp.body as SiteThemeUpdatedEvent
|
||||
});
|
||||
});
|
||||
|
||||
this.hubConnection.on(EVENTS.DashboardUpdate, resp => {
|
||||
this.messagesSource.next({
|
||||
event: EVENTS.DashboardUpdate,
|
||||
|
@ -1,25 +1,20 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import {
|
||||
DestroyRef,
|
||||
inject,
|
||||
Inject,
|
||||
Injectable,
|
||||
Renderer2,
|
||||
RendererFactory2,
|
||||
SecurityContext
|
||||
} from '@angular/core';
|
||||
import { DomSanitizer } from '@angular/platform-browser';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { map, ReplaySubject, take } from 'rxjs';
|
||||
import { environment } from 'src/environments/environment';
|
||||
import { ConfirmService } from '../shared/confirm.service';
|
||||
import { NotificationProgressEvent } from '../_models/events/notification-progress-event';
|
||||
import { SiteTheme, ThemeProvider } from '../_models/preferences/site-theme';
|
||||
import { TextResonse } from '../_types/text-response';
|
||||
import { EVENTS, MessageHubService } from './message-hub.service';
|
||||
import {DOCUMENT} from '@angular/common';
|
||||
import {HttpClient} from '@angular/common/http';
|
||||
import {DestroyRef, inject, Inject, Injectable, Renderer2, RendererFactory2, SecurityContext} from '@angular/core';
|
||||
import {DomSanitizer} from '@angular/platform-browser';
|
||||
import {ToastrService} from 'ngx-toastr';
|
||||
import {map, ReplaySubject, take} from 'rxjs';
|
||||
import {environment} from 'src/environments/environment';
|
||||
import {ConfirmService} from '../shared/confirm.service';
|
||||
import {NotificationProgressEvent} from '../_models/events/notification-progress-event';
|
||||
import {SiteTheme, ThemeProvider} from '../_models/preferences/site-theme';
|
||||
import {TextResonse} from '../_types/text-response';
|
||||
import {EVENTS, MessageHubService} from './message-hub.service';
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {translate} from "@ngneat/transloco";
|
||||
import {DownloadableSiteTheme} from "../_models/theme/downloadable-site-theme";
|
||||
import {NgxFileDropEntry} from "ngx-file-drop";
|
||||
import {SiteThemeUpdatedEvent} from "../_models/events/site-theme-updated-event";
|
||||
|
||||
|
||||
@Injectable({
|
||||
@ -52,18 +47,45 @@ export class ThemeService {
|
||||
|
||||
messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(message => {
|
||||
|
||||
if (message.event !== EVENTS.NotificationProgress) return;
|
||||
const notificationEvent = (message.payload as NotificationProgressEvent);
|
||||
if (notificationEvent.name !== EVENTS.SiteThemeProgress) return;
|
||||
if (message.event === EVENTS.NotificationProgress) {
|
||||
const notificationEvent = (message.payload as NotificationProgressEvent);
|
||||
if (notificationEvent.name !== EVENTS.SiteThemeProgress) return;
|
||||
|
||||
if (notificationEvent.eventType === 'ended') {
|
||||
if (notificationEvent.name === EVENTS.SiteThemeProgress) this.getThemes().subscribe(() => {
|
||||
if (notificationEvent.eventType === 'ended') {
|
||||
if (notificationEvent.name === EVENTS.SiteThemeProgress) this.getThemes().subscribe();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.event === EVENTS.SiteThemeUpdated) {
|
||||
const evt = (message.payload as SiteThemeUpdatedEvent);
|
||||
this.currentTheme$.pipe(take(1)).subscribe(currentTheme => {
|
||||
if (currentTheme && currentTheme.name !== EVENTS.SiteThemeProgress) return;
|
||||
console.log('Active theme has been updated, refreshing theme');
|
||||
this.setTheme(currentTheme.name);
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
getDownloadableThemes() {
|
||||
return this.httpClient.get<Array<DownloadableSiteTheme>>(this.baseUrl + 'theme/browse');
|
||||
}
|
||||
|
||||
downloadTheme(theme: DownloadableSiteTheme) {
|
||||
return this.httpClient.post<SiteTheme>(this.baseUrl + 'theme/download-theme', theme);
|
||||
}
|
||||
|
||||
uploadTheme(themeFile: File, fileEntry: NgxFileDropEntry) {
|
||||
const formData = new FormData()
|
||||
formData.append('formFile', themeFile, fileEntry.relativePath);
|
||||
|
||||
return this.httpClient.post<SiteTheme>(this.baseUrl + 'theme/upload-theme', formData);
|
||||
}
|
||||
|
||||
getColorScheme() {
|
||||
return getComputedStyle(this.document.body).getPropertyValue('--color-scheme').trim();
|
||||
}
|
||||
@ -113,6 +135,12 @@ export class ThemeService {
|
||||
this.unsetThemes();
|
||||
}
|
||||
|
||||
deleteTheme(themeId: number) {
|
||||
return this.httpClient.delete(this.baseUrl + 'theme?themeId=' + themeId).pipe(map(() => {
|
||||
this.getThemes().subscribe(() => {});
|
||||
}));
|
||||
}
|
||||
|
||||
setDefault(themeId: number) {
|
||||
return this.httpClient.post(this.baseUrl + 'theme/update-default', {themeId: themeId}).pipe(map(() => {
|
||||
// Refresh the cache when a default state is changed
|
||||
@ -148,7 +176,7 @@ export class ThemeService {
|
||||
this.unsetThemes();
|
||||
this.renderer.addClass(this.document.querySelector('body'), theme.selector);
|
||||
|
||||
if (theme.provider === ThemeProvider.User && !this.hasThemeInHead(theme.name)) {
|
||||
if (theme.provider !== ThemeProvider.System && !this.hasThemeInHead(theme.name)) {
|
||||
// We need to load the styles into the browser
|
||||
this.fetchThemeContent(theme.id).subscribe(async (content) => {
|
||||
if (content === null) {
|
||||
|
@ -13,7 +13,6 @@ import { SideNavComponent } from './sidenav/_components/side-nav/side-nav.compon
|
||||
import {NavHeaderComponent} from "./nav/_components/nav-header/nav-header.component";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {ServerService} from "./_services/server.service";
|
||||
import {ImportCblModalComponent} from "./reading-list/_modals/import-cbl-modal/import-cbl-modal.component";
|
||||
import {OutOfDateModalComponent} from "./announcements/_components/out-of-date-modal/out-of-date-modal.component";
|
||||
|
||||
@Component({
|
||||
|
@ -16,27 +16,19 @@
|
||||
</div>
|
||||
<div>by {{stack.author}} • {{t('series-count', {num: stack.seriesCount | number})}} • <span><i class="fa-solid fa-layer-group me-1" aria-hidden="true"></i>{{t('restack-count', {num: stack.restackCount | number})}}</span></div>
|
||||
</li>
|
||||
} @empty {
|
||||
@if (isLoading) {
|
||||
<app-loading [loading]="isLoading"></app-loading>
|
||||
} @else {
|
||||
<p>{{t('nothing-found')}}</p>
|
||||
}
|
||||
}
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<!-- <div class="col-auto">-->
|
||||
<!-- <a class="btn btn-icon" href="https://wiki.kavitareader.com/guides/features/readinglists#creating-a-reading-list-via-cbl" target="_blank" rel="noopener noreferrer">Help</a>-->
|
||||
<!-- </div>-->
|
||||
<!-- <div class="col-auto">-->
|
||||
<!-- <button type="button" class="btn btn-secondary" (click)="close()">{{t('close')}}</button>-->
|
||||
<!-- </div>-->
|
||||
<!-- <div class="col-auto">-->
|
||||
<!-- <button type="button" class="btn btn-primary" (click)="prevStep()" [disabled]="!canMoveToPrevStep()">{{t('prev')}}</button>-->
|
||||
<!-- </div>-->
|
||||
<!-- <div class="col-auto">-->
|
||||
<!-- <button type="button" class="btn btn-primary" (click)="nextStep()" [disabled]="!canMoveToNextStep()">{{t(NextButtonLabel)}}</button>-->
|
||||
<!-- </div>-->
|
||||
<div class="col-auto">
|
||||
<button type="button" class="btn btn-secondary" (click)="ngbModal.dismiss()">{{t('close')}}</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary" (click)="ngbModal.dismiss()">{{t('close')}}</button>
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
|
@ -10,6 +10,7 @@ import {ScrobbleProvider} 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";
|
||||
|
||||
@Component({
|
||||
selector: 'app-import-mal-collection-modal',
|
||||
@ -18,7 +19,8 @@ import {DecimalPipe} from "@angular/common";
|
||||
TranslocoDirective,
|
||||
ReactiveFormsModule,
|
||||
Select2Module,
|
||||
DecimalPipe
|
||||
DecimalPipe,
|
||||
LoadingComponent
|
||||
],
|
||||
templateUrl: './import-mal-collection-modal.component.html',
|
||||
styleUrl: './import-mal-collection-modal.component.scss',
|
||||
|
@ -1,32 +1,141 @@
|
||||
<ng-container *transloco="let t; read:'theme-manager'">
|
||||
<div class="container-fluid">
|
||||
<div class="row mb-2">
|
||||
<div class="col-8"><h3>{{t('title')}}</h3></div>
|
||||
<div class="col-4" *ngIf="isAdmin">
|
||||
<button class="btn btn-primary float-end" (click)="scan()">
|
||||
<i class="fa fa-refresh" aria-hidden="true"></i> {{t('scan')}}
|
||||
</button>
|
||||
</div>
|
||||
<h3>{{t('title')}}</h3>
|
||||
</div>
|
||||
|
||||
<p *ngIf="isAdmin">
|
||||
{{t('looking-for-theme')}}<a href="https://github.com/Kareadita/Themes" target="_blank" rel="noopener noreferrer">{{t('looking-for-theme-continued')}}</a>
|
||||
</p>
|
||||
<p>{{t('description')}}</p>
|
||||
|
||||
<div class="row g-0">
|
||||
<h4>{{t('site-themes')}}</h4>
|
||||
<ng-container *ngFor="let theme of (themeService.themes$ | async)">
|
||||
<div class="card col-auto me-3 mb-3" style="width: 18rem;">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">{{theme.name | sentenceCase}}</h5>
|
||||
<h6 class="card-subtitle mb-2 text-muted">{{theme.provider | siteThemeProvider}}</h6>
|
||||
<button class="btn btn-secondary me-2" [disabled]="theme.isDefault" *ngIf="isAdmin" (click)="updateDefault(theme)">{{t('set-default')}}</button>
|
||||
<button class="btn btn-primary" (click)="applyTheme(theme)" [disabled]="currentTheme?.id === theme.id">{{currentTheme?.id === theme.id ? t('applied') : t('apply')}}</button>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 theme-container">
|
||||
<div class="col-md-3">
|
||||
<div class="pe-2">
|
||||
<ul style="height: 100%" class="list-group list-group-flush">
|
||||
|
||||
@for (theme of themeService.themes$ | async; track theme.name) {
|
||||
<ng-container [ngTemplateOutlet]="themeOption" [ngTemplateOutletContext]="{ $implicit: theme}"></ng-container>
|
||||
}
|
||||
|
||||
@for (theme of downloadableThemes; track theme.name) {
|
||||
<ng-container [ngTemplateOutlet]="themeOption" [ngTemplateOutletContext]="{ $implicit: theme}"></ng-container>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<div class="col-md-9">
|
||||
@if (selectedTheme === undefined) {
|
||||
|
||||
<div class="row pb-4">
|
||||
<div class="mx-auto">
|
||||
<div class="d-flex justify-content-center">
|
||||
<div class="d-flex justify-content-evenly">
|
||||
@if (hasAdmin$ | async) {
|
||||
{{t('preview-default-admin')}}
|
||||
} @else {
|
||||
{{t('preview-default')}}
|
||||
}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@if (files && files.length > 0) {
|
||||
<app-loading [loading]="isUploadingTheme"></app-loading>
|
||||
} @else if (hasAdmin$ | async) {
|
||||
<ngx-file-drop (onFileDrop)="dropped($event)" [accept]="acceptableExtensions" [directory]="false"
|
||||
dropZoneClassName="file-upload" contentClassName="file-upload-zone">
|
||||
|
||||
<ng-template ngx-file-drop-content-tmp let-openFileSelector="openFileSelector">
|
||||
<div class="row g-0 mt-3 pb-3">
|
||||
<div class="mx-auto">
|
||||
<div class="row g-0 mb-3">
|
||||
<i class="fa fa-file-upload mx-auto" style="font-size: 24px; width: 20px;" aria-hidden="true"></i>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-center">
|
||||
<div class="d-flex justify-content-evenly">
|
||||
<span class="pe-0" href="javascript:void(0)">{{t('drag-n-drop')}}</span>
|
||||
<span class="ps-1 pe-1">•</span>
|
||||
<a class="pe-0" href="javascript:void(0)" (click)="openFileSelector()">{{t('upload')}}<span class="phone-hidden"> {{t('upload-continued')}}</span></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</ng-template>
|
||||
|
||||
</ngx-file-drop>
|
||||
}
|
||||
|
||||
}
|
||||
@else {
|
||||
<h4>
|
||||
{{selectedTheme.name | sentenceCase}}
|
||||
<div class="float-end">
|
||||
@if (selectedTheme.isSiteTheme) {
|
||||
@if (selectedTheme.name !== 'Dark') {
|
||||
<button class="btn btn-danger me-1" (click)="deleteTheme(selectedTheme.site!)">{{t('delete')}}</button>
|
||||
}
|
||||
@if (hasAdmin$ | async) {
|
||||
<button class="btn btn-secondary me-1" [disabled]="selectedTheme.site?.isDefault" (click)="updateDefault(selectedTheme.site!)">{{t('set-default')}}</button>
|
||||
}
|
||||
<button class="btn btn-primary me-1" [disabled]="currentTheme && selectedTheme.name === currentTheme.name" (click)="applyTheme(selectedTheme.site!)">{{t('apply')}}</button>
|
||||
} @else {
|
||||
<button class="btn btn-primary" [disabled]="selectedTheme.downloadable?.alreadyDownloaded" (click)="downloadTheme(selectedTheme.downloadable!)">{{t('download')}}</button>
|
||||
}
|
||||
</div>
|
||||
</h4>
|
||||
@if(!selectedTheme.isSiteTheme) {
|
||||
<p>{{selectedTheme.downloadable!.description | defaultValue}}</p>
|
||||
|
||||
<app-carousel-reel [items]="selectedTheme.downloadable!.previewUrls" title="Preview">
|
||||
<ng-template #carouselItem let-item>
|
||||
<a [href]="item | safeUrl" target="_blank" rel="noopener noreferrer">
|
||||
<app-image [imageUrl]="item" height="100px" width="160px"></app-image>
|
||||
</a>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
} @else {
|
||||
<p>{{selectedTheme.site!.description | defaultValue}}</p>
|
||||
|
||||
<app-carousel-reel [items]="selectedTheme.site!.previewUrls" title="Preview">
|
||||
<ng-template #carouselItem let-item>
|
||||
<a [href]="item | safeUrl" target="_blank" rel="noopener noreferrer">
|
||||
<app-image [imageUrl]="item" height="100px" width="160px"></app-image>
|
||||
</a>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-template #themeOption let-item>
|
||||
@if (item !== undefined) {
|
||||
|
||||
<li class="list-group-item d-flex justify-content-between align-items-start {{selectedTheme && selectedTheme.name === item.name ? 'active' : ''}}" (click)="selectTheme(item)">
|
||||
<div class="ms-2 me-auto">
|
||||
<div class="fw-bold">{{item.name | sentenceCase}}</div>
|
||||
|
||||
@if (item.hasOwnProperty('provider')) {
|
||||
{{item.provider | siteThemeProvider}}
|
||||
} @else if (item.hasOwnProperty('lastCompatibleVersion')) {
|
||||
{{ThemeProvider.Custom | siteThemeProvider}} • v{{item.lastCompatibleVersion}}
|
||||
}
|
||||
@if (currentTheme && item.name === currentTheme.name) {
|
||||
• {{t('active-theme')}}
|
||||
}
|
||||
</div>
|
||||
@if (item.hasOwnProperty('isDefault') && item.isDefault) {
|
||||
<i class="fa-solid fa-star" [attr.aria-label]="t('default-theme')"></i>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
|
||||
</ng-container>
|
||||
|
@ -0,0 +1,27 @@
|
||||
//.theme-container {
|
||||
// max-height: calc(100 * var(--vh));
|
||||
// overflow-y: auto;
|
||||
//}
|
||||
|
||||
.chooser {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, 158px);
|
||||
grid-gap: 0.5rem;
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
ngx-file-drop ::ng-deep > div {
|
||||
// styling for the outer drop box
|
||||
width: 100%;
|
||||
border: 2px solid var(--primary-color);
|
||||
border-radius: 5px;
|
||||
height: 100px;
|
||||
margin: auto;
|
||||
|
||||
> div {
|
||||
// styling for the inner box (template)
|
||||
width: 100%;
|
||||
display: inline-block;
|
||||
|
||||
}
|
||||
}
|
@ -6,17 +6,36 @@ import {
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { distinctUntilChanged, take } from 'rxjs';
|
||||
import {distinctUntilChanged, map, take} from 'rxjs';
|
||||
import { ThemeService } from 'src/app/_services/theme.service';
|
||||
import { SiteTheme } from 'src/app/_models/preferences/site-theme';
|
||||
import {SiteTheme, ThemeProvider} from 'src/app/_models/preferences/site-theme';
|
||||
import { User } from 'src/app/_models/user';
|
||||
import { AccountService } from 'src/app/_services/account.service';
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import { SiteThemeProviderPipe } from '../../_pipes/site-theme-provider.pipe';
|
||||
import { SentenceCasePipe } from '../../_pipes/sentence-case.pipe';
|
||||
import { NgIf, NgFor, AsyncPipe } from '@angular/common';
|
||||
import {TranslocoDirective, TranslocoService} from "@ngneat/transloco";
|
||||
import {tap} from "rxjs/operators";
|
||||
import {NgIf, NgFor, AsyncPipe, NgTemplateOutlet} from '@angular/common';
|
||||
import {translate, TranslocoDirective} from "@ngneat/transloco";
|
||||
import {shareReplay} from "rxjs/operators";
|
||||
import {CarouselReelComponent} from "../../carousel/_components/carousel-reel/carousel-reel.component";
|
||||
import {SeriesCardComponent} from "../../cards/series-card/series-card.component";
|
||||
import {ImageComponent} from "../../shared/image/image.component";
|
||||
import {DownloadableSiteTheme} from "../../_models/theme/downloadable-site-theme";
|
||||
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
|
||||
import {SafeUrlPipe} from "../../_pipes/safe-url.pipe";
|
||||
import {ScrobbleProvider} from "../../_services/scrobbling.service";
|
||||
import {ConfirmService} from "../../shared/confirm.service";
|
||||
import {FileSystemFileEntry, NgxFileDropEntry, NgxFileDropModule} from "ngx-file-drop";
|
||||
import {ReactiveFormsModule} from "@angular/forms";
|
||||
import {Select2Module} from "ng-select2-component";
|
||||
import {LoadingComponent} from "../../shared/loading/loading.component";
|
||||
|
||||
interface ThemeContainer {
|
||||
downloadable?: DownloadableSiteTheme;
|
||||
site?: SiteTheme;
|
||||
isSiteTheme: boolean;
|
||||
name: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-theme-manager',
|
||||
@ -24,51 +43,125 @@ import {tap} from "rxjs/operators";
|
||||
styleUrls: ['./theme-manager.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [NgIf, NgFor, AsyncPipe, SentenceCasePipe, SiteThemeProviderPipe, TranslocoDirective]
|
||||
imports: [NgIf, NgFor, AsyncPipe, SentenceCasePipe, SiteThemeProviderPipe, TranslocoDirective, CarouselReelComponent, SeriesCardComponent, ImageComponent, DefaultValuePipe, NgTemplateOutlet, SafeUrlPipe, NgxFileDropModule, ReactiveFormsModule, Select2Module, LoadingComponent]
|
||||
})
|
||||
export class ThemeManagerComponent {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
protected readonly themeService = inject(ThemeService);
|
||||
private readonly accountService = inject(AccountService);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly confirmService = inject(ConfirmService);
|
||||
|
||||
protected readonly ThemeProvider = ThemeProvider;
|
||||
protected readonly ScrobbleProvider = ScrobbleProvider;
|
||||
|
||||
currentTheme: SiteTheme | undefined;
|
||||
isAdmin: boolean = false;
|
||||
user: User | undefined;
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly translocService = inject(TranslocoService);
|
||||
selectedTheme: ThemeContainer | undefined;
|
||||
downloadableThemes: Array<DownloadableSiteTheme> = [];
|
||||
hasAdmin$ = this.accountService.currentUser$.pipe(
|
||||
takeUntilDestroyed(this.destroyRef), shareReplay({refCount: true, bufferSize: 1}),
|
||||
map(c => c && this.accountService.hasAdminRole(c))
|
||||
);
|
||||
|
||||
files: NgxFileDropEntry[] = [];
|
||||
acceptableExtensions = ['.css'].join(',');
|
||||
isUploadingTheme: boolean = false;
|
||||
|
||||
|
||||
constructor(public themeService: ThemeService, private accountService: AccountService,
|
||||
private toastr: ToastrService, private readonly cdRef: ChangeDetectorRef) {
|
||||
|
||||
themeService.currentTheme$.pipe(takeUntilDestroyed(this.destroyRef), distinctUntilChanged()).subscribe(theme => {
|
||||
constructor() {
|
||||
|
||||
this.loadDownloadableThemes();
|
||||
|
||||
this.themeService.currentTheme$.pipe(takeUntilDestroyed(this.destroyRef), distinctUntilChanged()).subscribe(theme => {
|
||||
this.currentTheme = theme;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
|
||||
accountService.currentUser$.pipe(take(1)).subscribe(user => {
|
||||
if (user) {
|
||||
this.user = user;
|
||||
this.isAdmin = accountService.hasAdminRole(user);
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
}
|
||||
|
||||
loadDownloadableThemes() {
|
||||
this.themeService.getDownloadableThemes().subscribe(d => {
|
||||
this.downloadableThemes = d;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
applyTheme(theme: SiteTheme) {
|
||||
if (!this.user) return;
|
||||
async deleteTheme(theme: SiteTheme) {
|
||||
if (!await this.confirmService.confirm(translate('toasts.confirm-delete-theme'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pref = Object.assign({}, this.user.preferences);
|
||||
pref.theme = theme;
|
||||
this.accountService.updatePreferences(pref).subscribe();
|
||||
this.themeService.deleteTheme(theme.id).subscribe(_ => {
|
||||
this.removeDownloadedTheme(theme);
|
||||
this.loadDownloadableThemes();
|
||||
});
|
||||
}
|
||||
|
||||
removeDownloadedTheme(theme: SiteTheme) {
|
||||
this.selectedTheme = undefined;
|
||||
this.downloadableThemes = this.downloadableThemes.filter(d => d.name !== theme.name);
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
applyTheme(theme: SiteTheme) {
|
||||
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
|
||||
if (!user) return;
|
||||
const pref = Object.assign({}, user.preferences);
|
||||
pref.theme = theme;
|
||||
this.accountService.updatePreferences(pref).subscribe();
|
||||
// Updating theme emits the new theme to load on the themes$
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
updateDefault(theme: SiteTheme) {
|
||||
this.themeService.setDefault(theme.id).subscribe(() => {
|
||||
this.toastr.success(this.translocService.translate('theme-manager.updated-toastr', {name: theme.name}));
|
||||
this.toastr.success(translate('theme-manager.updated-toastr', {name: theme.name}));
|
||||
});
|
||||
}
|
||||
|
||||
scan() {
|
||||
this.themeService.scan().subscribe(() => {
|
||||
this.toastr.info(this.translocService.translate('theme-manager.scan-queued'));
|
||||
selectTheme(theme: SiteTheme | DownloadableSiteTheme) {
|
||||
if (theme.hasOwnProperty('provider')) {
|
||||
this.selectedTheme = {
|
||||
isSiteTheme: true,
|
||||
site: theme as SiteTheme,
|
||||
name: theme.name
|
||||
};
|
||||
} else {
|
||||
this.selectedTheme = {
|
||||
isSiteTheme: false,
|
||||
downloadable: theme as DownloadableSiteTheme,
|
||||
name: theme.name
|
||||
};
|
||||
}
|
||||
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
downloadTheme(theme: DownloadableSiteTheme) {
|
||||
this.themeService.downloadTheme(theme).subscribe(theme => {
|
||||
this.removeDownloadedTheme(theme);
|
||||
});
|
||||
}
|
||||
|
||||
public dropped(files: NgxFileDropEntry[]) {
|
||||
this.files = files;
|
||||
for (const droppedFile of files) {
|
||||
// Is it a file?
|
||||
if (droppedFile.fileEntry.isFile) {
|
||||
const fileEntry = droppedFile.fileEntry as FileSystemFileEntry;
|
||||
fileEntry.file((file: File) => {
|
||||
this.themeService.uploadTheme(file, droppedFile).subscribe(t => {
|
||||
this.isUploadingTheme = false;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
this.isUploadingTheme = true;
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
}
|
||||
|
@ -182,15 +182,22 @@
|
||||
|
||||
"theme-manager": {
|
||||
"title": "Theme Manager",
|
||||
"looking-for-theme": "Looking for a light or e-ink theme? We have some custom themes you can use on our ",
|
||||
"looking-for-theme-continued": "theme github.",
|
||||
"scan": "Scan",
|
||||
"description": "Kavita comes in my colors, find a color scheme that meets your needs or build one yourself and share it. Themes may be applied for your account or applied to all accounts.",
|
||||
"site-themes": "Site Themes",
|
||||
"set-default": "Set Default",
|
||||
"default-theme": "Default theme",
|
||||
"download": "{{changelog.download}}",
|
||||
"apply": "{{common.apply}}",
|
||||
"applied": "Applied",
|
||||
"active-theme": "Active",
|
||||
"updated-toastr": "Site default has been updated to {{name}}",
|
||||
"scan-queued": "A site theme scan has been queued"
|
||||
"scan-queued": "A site theme scan has been queued",
|
||||
"delete": "{{common.delete}}",
|
||||
"drag-n-drop": "{{cover-image-chooser.drag-n-drop}}",
|
||||
"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"
|
||||
},
|
||||
|
||||
"theme": {
|
||||
@ -212,7 +219,7 @@
|
||||
|
||||
"site-theme-provider-pipe": {
|
||||
"system": "System",
|
||||
"user": "User"
|
||||
"custom": "{{device-platform-pipe.custom}}"
|
||||
},
|
||||
|
||||
"manage-devices": {
|
||||
@ -1583,14 +1590,17 @@
|
||||
"promote-tooltip": "Promotion means that the collection can be seen server-wide, not just for you. All series within this collection will still have user-access restrictions placed on them."
|
||||
},
|
||||
|
||||
|
||||
"browse-themes-modal": {
|
||||
"title": "Browse Themes"
|
||||
},
|
||||
|
||||
"import-mal-collection-modal": {
|
||||
"close": "{{common.close}}",
|
||||
"title": "MAL Interest Stack Import",
|
||||
"description": "Import your MAL Interest Stacks and create Collections within Kavita",
|
||||
"series-count": "{{common.series-count}}",
|
||||
"restack-count": "{{num}} Restacks"
|
||||
"restack-count": "{{num}} Restacks",
|
||||
"nothing-found": ""
|
||||
},
|
||||
|
||||
"edit-chapter-progress": {
|
||||
@ -1946,7 +1956,10 @@
|
||||
"rejected-cover-upload": "The image could not be fetched due to server refusing request. Please download and upload from file instead.",
|
||||
"invalid-confirmation-url": "Invalid confirmation url",
|
||||
"invalid-confirmation-email": "Invalid confirmation email",
|
||||
"invalid-password-reset-url": "Invalid reset password url"
|
||||
"invalid-password-reset-url": "Invalid reset password url",
|
||||
"delete-theme-in-use": "Theme is currently in use by at least one user, cannot delete",
|
||||
"theme-manual-upload": "There was an issue creating Theme from manual upload",
|
||||
"theme-already-in-use": "Theme already exists by that name"
|
||||
},
|
||||
|
||||
"metadata-builder": {
|
||||
@ -2185,7 +2198,8 @@
|
||||
"confirm-delete-collections": "Are you sure you want to delete multiple collections?",
|
||||
"collections-deleted": "Collections deleted",
|
||||
"pdf-book-mode-screen-size": "Screen too small for Book mode",
|
||||
"stack-imported": "Stack Imported"
|
||||
"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"
|
||||
|
||||
},
|
||||
|
||||
|
302
openapi.json
302
openapi.json
@ -7,7 +7,7 @@
|
||||
"name": "GPL-3.0",
|
||||
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
|
||||
},
|
||||
"version": "0.8.1.0"
|
||||
"version": "0.8.1.3"
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
@ -11968,16 +11968,52 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/Theme/scan": {
|
||||
"post": {
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Theme"
|
||||
],
|
||||
"summary": "Attempts to delete a theme. If already in use by users, will not allow",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "themeId",
|
||||
"in": "query",
|
||||
"description": "",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/DownloadableSiteThemeDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/DownloadableSiteThemeDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/DownloadableSiteThemeDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -12053,6 +12089,145 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/Theme/browse": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Theme"
|
||||
],
|
||||
"summary": "Browse themes that can be used on this server",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/DownloadableSiteThemeDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/DownloadableSiteThemeDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/DownloadableSiteThemeDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/Theme/download-theme": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Theme"
|
||||
],
|
||||
"summary": "Downloads a SiteTheme from upstream",
|
||||
"requestBody": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DownloadableSiteThemeDto"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DownloadableSiteThemeDto"
|
||||
}
|
||||
},
|
||||
"application/*+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DownloadableSiteThemeDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SiteThemeDto"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SiteThemeDto"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SiteThemeDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/Theme/upload-theme": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Theme"
|
||||
],
|
||||
"summary": "Uploads a new theme file",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"multipart/form-data": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"formFile": {
|
||||
"type": "string",
|
||||
"format": "binary"
|
||||
}
|
||||
}
|
||||
},
|
||||
"encoding": {
|
||||
"formFile": {
|
||||
"style": "form"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SiteThemeDto"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SiteThemeDto"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SiteThemeDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/Upload/upload-by-url": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@ -15777,6 +15952,67 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"DownloadableSiteThemeDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Theme Name",
|
||||
"nullable": true
|
||||
},
|
||||
"cssUrl": {
|
||||
"type": "string",
|
||||
"description": "Url to download css file",
|
||||
"nullable": true
|
||||
},
|
||||
"cssFile": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"previewUrls": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Url to preview image",
|
||||
"nullable": true
|
||||
},
|
||||
"alreadyDownloaded": {
|
||||
"type": "boolean",
|
||||
"description": "If Already downloaded"
|
||||
},
|
||||
"sha": {
|
||||
"type": "string",
|
||||
"description": "Sha of the file",
|
||||
"nullable": true
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Path of the Folder the files reside in",
|
||||
"nullable": true
|
||||
},
|
||||
"author": {
|
||||
"type": "string",
|
||||
"description": "Author of the theme",
|
||||
"nullable": true
|
||||
},
|
||||
"lastCompatibleVersion": {
|
||||
"type": "string",
|
||||
"description": "Last version tested against",
|
||||
"nullable": true
|
||||
},
|
||||
"isCompatible": {
|
||||
"type": "boolean",
|
||||
"description": "If version compatible with version"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Small blurb about the Theme",
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"EmailTestResultDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@ -20117,6 +20353,33 @@
|
||||
"lastModifiedUtc": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"gitHubPath": {
|
||||
"type": "string",
|
||||
"description": "The Url on the repo to download the file from",
|
||||
"nullable": true
|
||||
},
|
||||
"shaHash": {
|
||||
"type": "string",
|
||||
"description": "Hash of the Css File",
|
||||
"nullable": true
|
||||
},
|
||||
"previewUrls": {
|
||||
"type": "string",
|
||||
"description": "Pipe (|) separated urls of the images. Empty string if",
|
||||
"nullable": true
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"author": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"compatibleVersion": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
@ -20157,6 +20420,28 @@
|
||||
"description": "Where did the theme come from",
|
||||
"format": "int32"
|
||||
},
|
||||
"previewUrls": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"nullable": true
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Information about the theme",
|
||||
"nullable": true
|
||||
},
|
||||
"author": {
|
||||
"type": "string",
|
||||
"description": "Author of the Theme (only applies to non-system provided themes)",
|
||||
"nullable": true
|
||||
},
|
||||
"compatibleVersion": {
|
||||
"type": "string",
|
||||
"description": "Last compatible version. System provided will always be most current",
|
||||
"nullable": true
|
||||
},
|
||||
"selector": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
@ -20239,7 +20524,8 @@
|
||||
5,
|
||||
6,
|
||||
7,
|
||||
8
|
||||
8,
|
||||
9
|
||||
],
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
@ -21303,7 +21589,7 @@
|
||||
"format": "int32"
|
||||
},
|
||||
"theme": {
|
||||
"$ref": "#/components/schemas/SiteTheme"
|
||||
"$ref": "#/components/schemas/SiteThemeDto"
|
||||
},
|
||||
"bookReaderThemeName": {
|
||||
"minLength": 1,
|
||||
|
Loading…
x
Reference in New Issue
Block a user