Custom Theme Support (#1077)
* Started the migration to bootstrap 5. Introduced a breakpoint system that bootstrap reflects for our screens. * sr only migrated * mr/ml -> me/ms * pl/pr -> ps/pe * btn-block * removed input-group-append * Added form-label to all labels * Added some style overrides for inputs * Replaced form-group with mb-3 * Ignore journal files * Update media to d-flex/flex-grow-1 * Fixed reading list detail page * For develop builds, don't inline critical styles * Fixed some downstream security issues * Fixed a layout issue in series detail * Fixed issue with btn-light not having background color. Updated layout for series detail metadata * Cleaned up nav search * Laid out the organization for custom theme components. Update _inputs.scss with variable overrides and depending on theme, it will just work. * Lots of theming work * Added inputs to the theme page * Login and input placeholder changes - Fixed login screen centering issue on all devices - Changed the format of the login screen - Change the input placeholder color * Added checkbox styles * Refactored tagbadges and removed some ngdeep selectors * Added nav bar component and refactored some styles into event widget * Cleaned nav events again and made dedicated popover body * Finished pagination component * Fixed up some styles with buttons * refactored dropdown component * Update accordion component * Refactored breadcrumbs and rating star. Fixed a missing style for cards * Fixed some styling issues on person badge, added modal component, and some global styles * Finished moving everything within dark to component files * Fixed up filter buttons, move card styles into a component theme, fixed slider style * Refactored library card and grouped typeahead * Updated normal typeahead component and reduced amount of ngdeep selector * Refactored grid breakpoints to be available by css variable, but it's hardcoded into the app * Ensure breakpoints are defined per theme * Fixed up some styling overrides and customization for nav links and alt button * Removed some deep styles, moved css out of splash container and brough back labels for login page * Finished css variable refactor * Refactored all the theme variable definitions into files for each theme. * Added back bootstrap overrides * Added a note about bootstrap theme colors being not-possible to swap out at runtime * Cleaned up some dead code * Implemented the ability to set a custom theme on the site. Cleaned up misc code throughout. * Additional changes - Fixed nav where "kavita" was not hiding correctly on small viewports - Fixed search bar to make the behavior more consistent - Fixed accordion buttons - Changed accordion buttons to be more responsive - Added radio button colors - Fixed radios on theme test page - Changed login and reset password card layouts to be more consistent. - Added primary color shade for when darker shading is needed. * Built a basic site, allow the user to apply different themes, refactored nav service code out. * Implemented the ability update a user's theme * Added unit tests for Scan and Get Content in SiteThemeService. * Fixed a bug in the login code and Pref code which wasn't joining on SiteTheme table. Wrote Unit tests and the UI component to manage current theme. * Implemented scan so that it manages custom themes with unit tests * Component updates - Repositioning style ordering - Adding indicator override - Adding select styles * SignlaR integration, some fixes when creating custom entities, one single migration. Just login functionality left. * More ui updated - Added .no-hover to prevent hover on elements where not needed - Changed all selects I could find to appropriate class - Changed up nav tabs to work more like bootstrap tabs than pills - Added padding to top of some containers to make styles consistent - Added ability to change navbar fontawesome icon colors - removed some unecessary inline styling - Changed radio button to appropriate class - Toned down primate color, a bit too bright for dark theme. - Added ability to change button fontawesome icon color * nav-tab fix for series-detail * Added themes folder to gitignore * Adding card overlay * Fixing up light theme * Everything is done. Only bug is that color-scheme isn't being set properly from css variable. * Checkboxes have pointer by default. Confirm/Confirm email use default (dark) theme by default * Fixed an error where color-scheme wasn't reflecting correctly on themes on first load * Fixed user preferences not available on login * Changing dual radios to switches and color tweaks * disabled primary APCA fix * button APCA fixes * Fixed some timing issues with first load and image service * Fixed swiper issues from upgrade * Changed themes to be scss files again and adjusted Seed code * Migrated carousel to css variables. Fixed a broken animation for search. * Cleaned up some backend smells * Fixed white border outline on nav tabs, added some variables for header * Nav bar has been css variable-ified * Added some basic eink stuff to make the app useable Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
This commit is contained in:
parent
c776ca3b72
commit
568ea9fd3a
168 changed files with 4710 additions and 1666 deletions
|
@ -310,4 +310,8 @@
|
|||
<Reference Include="System.Drawing.Common" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="config\themes" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
@ -106,7 +106,10 @@ namespace API.Controllers
|
|||
{
|
||||
UserName = registerDto.Username,
|
||||
Email = registerDto.Email,
|
||||
UserPreferences = new AppUserPreferences(),
|
||||
UserPreferences = new AppUserPreferences
|
||||
{
|
||||
Theme = await _unitOfWork.SiteThemeRepository.GetDefaultTheme()
|
||||
},
|
||||
ApiKey = HashUtil.ApiKey()
|
||||
};
|
||||
|
||||
|
@ -179,22 +182,23 @@ namespace API.Controllers
|
|||
|
||||
// Update LastActive on account
|
||||
user.LastActive = DateTime.Now;
|
||||
user.UserPreferences ??= new AppUserPreferences();
|
||||
user.UserPreferences ??= new AppUserPreferences
|
||||
{
|
||||
Theme = await _unitOfWork.SiteThemeRepository.GetDefaultTheme()
|
||||
};
|
||||
|
||||
_unitOfWork.UserRepository.Update(user);
|
||||
await _unitOfWork.CommitAsync();
|
||||
|
||||
_logger.LogInformation("{UserName} logged in at {Time}", user.UserName, user.LastActive);
|
||||
|
||||
return new UserDto
|
||||
{
|
||||
Username = user.UserName,
|
||||
Email = user.Email,
|
||||
Token = await _tokenService.CreateToken(user),
|
||||
RefreshToken = await _tokenService.CreateRefreshToken(user),
|
||||
ApiKey = user.ApiKey,
|
||||
Preferences = _mapper.Map<UserPreferencesDto>(user.UserPreferences)
|
||||
};
|
||||
var dto = _mapper.Map<UserDto>(user);
|
||||
dto.Token = await _tokenService.CreateToken(user);
|
||||
dto.RefreshToken = await _tokenService.CreateRefreshToken(user);
|
||||
var pref = await _unitOfWork.UserRepository.GetPreferencesAsync(user.UserName);
|
||||
pref.Theme ??= await _unitOfWork.SiteThemeRepository.GetDefaultTheme();
|
||||
dto.Preferences = _mapper.Map<UserPreferencesDto>(pref);
|
||||
return dto;
|
||||
}
|
||||
|
||||
[HttpPost("refresh-token")]
|
||||
|
@ -358,7 +362,10 @@ namespace API.Controllers
|
|||
UserName = dto.Email,
|
||||
Email = dto.Email,
|
||||
ApiKey = HashUtil.ApiKey(),
|
||||
UserPreferences = new AppUserPreferences()
|
||||
UserPreferences = new AppUserPreferences
|
||||
{
|
||||
Theme = await _unitOfWork.SiteThemeRepository.GetDefaultTheme()
|
||||
}
|
||||
};
|
||||
|
||||
try
|
||||
|
|
64
API/Controllers/ThemeController.cs
Normal file
64
API/Controllers/ThemeController.cs
Normal file
|
@ -0,0 +1,64 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.DTOs.Theme;
|
||||
using API.Services;
|
||||
using API.Services.Tasks;
|
||||
using Kavita.Common;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace API.Controllers;
|
||||
|
||||
public class ThemeController : BaseApiController
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ISiteThemeService _siteThemeService;
|
||||
private readonly ITaskScheduler _taskScheduler;
|
||||
|
||||
public ThemeController(IUnitOfWork unitOfWork, ISiteThemeService siteThemeService, ITaskScheduler taskScheduler)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_siteThemeService = siteThemeService;
|
||||
_taskScheduler = taskScheduler;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<IEnumerable<SiteThemeDto>>> GetThemes()
|
||||
{
|
||||
return Ok(await _unitOfWork.SiteThemeRepository.GetThemeDtos());
|
||||
}
|
||||
|
||||
[Authorize("RequireAdminRole")]
|
||||
[HttpPost("scan")]
|
||||
public ActionResult Scan()
|
||||
{
|
||||
_taskScheduler.ScanSiteThemes();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[Authorize("RequireAdminRole")]
|
||||
[HttpPost("update-default")]
|
||||
public async Task<ActionResult> UpdateDefault(UpdateDefaultSiteThemeDto dto)
|
||||
{
|
||||
await _siteThemeService.UpdateDefault(dto.ThemeId);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns css content to the UI. UI is expected to escape the content
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[HttpGet("download-content")]
|
||||
public async Task<ActionResult<string>> GetThemeContent(int themeId)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Ok(await _siteThemeService.GetContent(themeId));
|
||||
}
|
||||
catch (KavitaException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -78,7 +78,8 @@ namespace API.Controllers
|
|||
existingPreferences.BookReaderDarkMode = preferencesDto.BookReaderDarkMode;
|
||||
existingPreferences.BookReaderFontSize = preferencesDto.BookReaderFontSize;
|
||||
existingPreferences.BookReaderTapToPaginate = preferencesDto.BookReaderTapToPaginate;
|
||||
existingPreferences.SiteDarkMode = preferencesDto.SiteDarkMode;
|
||||
existingPreferences.BookReaderReadingDirection = preferencesDto.BookReaderReadingDirection;
|
||||
existingPreferences.Theme = await _unitOfWork.SiteThemeRepository.GetThemeById(preferencesDto.Theme.Id);
|
||||
|
||||
_unitOfWork.UserRepository.Update(existingPreferences);
|
||||
|
||||
|
|
30
API/DTOs/Theme/SiteThemeDto.cs
Normal file
30
API/DTOs/Theme/SiteThemeDto.cs
Normal file
|
@ -0,0 +1,30 @@
|
|||
using System;
|
||||
using API.Entities.Enums.Theme;
|
||||
using API.Services;
|
||||
|
||||
namespace API.DTOs.Theme;
|
||||
|
||||
public class SiteThemeDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
/// <summary>
|
||||
/// Name of the Theme
|
||||
/// </summary>
|
||||
public string Name { get; set; }
|
||||
/// <summary>
|
||||
/// File path to the content. Stored under <see cref="DirectoryService.SiteThemeDirectory"/>.
|
||||
/// Must be a .css file
|
||||
/// </summary>
|
||||
public string FileName { get; set; }
|
||||
/// <summary>
|
||||
/// Only one theme can have this. Will auto-set this as default for new user accounts
|
||||
/// </summary>
|
||||
public bool IsDefault { get; set; }
|
||||
/// <summary>
|
||||
/// Where did the theme come from
|
||||
/// </summary>
|
||||
public ThemeProvider Provider { get; set; }
|
||||
public DateTime Created { get; set; }
|
||||
public DateTime LastModified { get; set; }
|
||||
public string Selector => "bg-" + Name.ToLower();
|
||||
}
|
6
API/DTOs/Theme/UpdateDefaultSiteThemeDto.cs
Normal file
6
API/DTOs/Theme/UpdateDefaultSiteThemeDto.cs
Normal file
|
@ -0,0 +1,6 @@
|
|||
namespace API.DTOs.Theme;
|
||||
|
||||
public class UpdateDefaultSiteThemeDto
|
||||
{
|
||||
public int ThemeId { get; set; }
|
||||
}
|
|
@ -5,8 +5,8 @@ namespace API.DTOs
|
|||
{
|
||||
public string Username { get; init; }
|
||||
public string Email { get; init; }
|
||||
public string Token { get; init; }
|
||||
public string RefreshToken { get; init; }
|
||||
public string Token { get; set; }
|
||||
public string RefreshToken { get; set; }
|
||||
public string ApiKey { get; init; }
|
||||
public UserPreferencesDto Preferences { get; set; }
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
using API.Entities.Enums;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
|
||||
namespace API.DTOs
|
||||
{
|
||||
|
@ -16,6 +17,6 @@ namespace API.DTOs
|
|||
public string BookReaderFontFamily { get; set; }
|
||||
public bool BookReaderTapToPaginate { get; set; }
|
||||
public ReadingDirection BookReaderReadingDirection { get; set; }
|
||||
public bool SiteDarkMode { get; set; }
|
||||
public SiteTheme Theme { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -40,6 +40,7 @@ namespace API.Data
|
|||
public DbSet<Person> Person { get; set; }
|
||||
public DbSet<Genre> Genre { get; set; }
|
||||
public DbSet<Tag> Tag { get; set; }
|
||||
public DbSet<SiteTheme> SiteTheme { get; set; }
|
||||
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
|
|
1391
API/Data/Migrations/20220215163317_SiteTheme.Designer.cs
generated
Normal file
1391
API/Data/Migrations/20220215163317_SiteTheme.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
79
API/Data/Migrations/20220215163317_SiteTheme.cs
Normal file
79
API/Data/Migrations/20220215163317_SiteTheme.cs
Normal file
|
@ -0,0 +1,79 @@
|
|||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace API.Data.Migrations
|
||||
{
|
||||
public partial class SiteTheme : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SiteDarkMode",
|
||||
table: "AppUserPreferences");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "ThemeId",
|
||||
table: "AppUserPreferences",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SiteTheme",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Name = table.Column<string>(type: "TEXT", nullable: true),
|
||||
NormalizedName = table.Column<string>(type: "TEXT", nullable: true),
|
||||
FileName = table.Column<string>(type: "TEXT", nullable: true),
|
||||
IsDefault = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
Provider = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Created = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
LastModified = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SiteTheme", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AppUserPreferences_ThemeId",
|
||||
table: "AppUserPreferences",
|
||||
column: "ThemeId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_AppUserPreferences_SiteTheme_ThemeId",
|
||||
table: "AppUserPreferences",
|
||||
column: "ThemeId",
|
||||
principalTable: "SiteTheme",
|
||||
principalColumn: "Id");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_AppUserPreferences_SiteTheme_ThemeId",
|
||||
table: "AppUserPreferences");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "SiteTheme");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_AppUserPreferences_ThemeId",
|
||||
table: "AppUserPreferences");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ThemeId",
|
||||
table: "AppUserPreferences");
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "SiteDarkMode",
|
||||
table: "AppUserPreferences",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -15,7 +15,7 @@ namespace API.Data.Migrations
|
|||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "6.0.0");
|
||||
modelBuilder.HasAnnotation("ProductVersion", "6.0.1");
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppRole", b =>
|
||||
{
|
||||
|
@ -198,7 +198,7 @@ namespace API.Data.Migrations
|
|||
b.Property<int>("ScalingOption")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SiteDarkMode")
|
||||
b.Property<int?>("ThemeId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
@ -206,6 +206,8 @@ namespace API.Data.Migrations
|
|||
b.HasIndex("AppUserId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("ThemeId");
|
||||
|
||||
b.ToTable("AppUserPreferences");
|
||||
});
|
||||
|
||||
|
@ -687,6 +689,38 @@ namespace API.Data.Migrations
|
|||
b.ToTable("ServerSetting");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.SiteTheme", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("Created")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsDefault")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("LastModified")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Provider")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("SiteTheme");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Tag", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
|
@ -967,7 +1001,13 @@ namespace API.Data.Migrations
|
|||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("API.Entities.SiteTheme", "Theme")
|
||||
.WithMany()
|
||||
.HasForeignKey("ThemeId");
|
||||
|
||||
b.Navigation("AppUser");
|
||||
|
||||
b.Navigation("Theme");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserProgress", b =>
|
||||
|
|
107
API/Data/Repositories/SiteThemeRepository.cs
Normal file
107
API/Data/Repositories/SiteThemeRepository.cs
Normal file
|
@ -0,0 +1,107 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.DTOs.Theme;
|
||||
using API.Entities;
|
||||
using AutoMapper;
|
||||
using AutoMapper.QueryableExtensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace API.Data.Repositories;
|
||||
|
||||
public interface ISiteThemeRepository
|
||||
{
|
||||
void Add(SiteTheme theme);
|
||||
void Remove(SiteTheme theme);
|
||||
void Update(SiteTheme siteTheme);
|
||||
Task<IEnumerable<SiteThemeDto>> GetThemeDtos();
|
||||
Task<SiteThemeDto> GetThemeDto(int themeId);
|
||||
Task<SiteThemeDto> GetThemeDtoByName(string themeName);
|
||||
Task<SiteTheme> GetDefaultTheme();
|
||||
Task<IEnumerable<SiteTheme>> GetThemes();
|
||||
|
||||
Task<SiteTheme> GetThemeById(int themeId);
|
||||
}
|
||||
|
||||
public class SiteThemeRepository : ISiteThemeRepository
|
||||
{
|
||||
private readonly DataContext _context;
|
||||
private readonly IMapper _mapper;
|
||||
|
||||
public SiteThemeRepository(DataContext context, IMapper mapper)
|
||||
{
|
||||
_context = context;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
public void Add(SiteTheme theme)
|
||||
{
|
||||
_context.Add(theme);
|
||||
}
|
||||
|
||||
public void Remove(SiteTheme theme)
|
||||
{
|
||||
_context.Remove(theme);
|
||||
}
|
||||
|
||||
public void Update(SiteTheme siteTheme)
|
||||
{
|
||||
_context.Entry(siteTheme).State = EntityState.Modified;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<SiteThemeDto>> GetThemeDtos()
|
||||
{
|
||||
return await _context.SiteTheme
|
||||
.ProjectTo<SiteThemeDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<SiteThemeDto> GetThemeDtoByName(string themeName)
|
||||
{
|
||||
return await _context.SiteTheme
|
||||
.Where(t => t.Name.Equals(themeName))
|
||||
.ProjectTo<SiteThemeDto>(_mapper.ConfigurationProvider)
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns default theme, if the default theme is not available, returns the dark theme
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public async Task<SiteTheme> GetDefaultTheme()
|
||||
{
|
||||
var result = await _context.SiteTheme
|
||||
.Where(t => t.IsDefault)
|
||||
.SingleOrDefaultAsync();
|
||||
|
||||
if (result == null)
|
||||
{
|
||||
return await _context.SiteTheme
|
||||
.Where(t => t.NormalizedName == "dark")
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<SiteTheme>> GetThemes()
|
||||
{
|
||||
return await _context.SiteTheme
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<SiteTheme> GetThemeById(int themeId)
|
||||
{
|
||||
return await _context.SiteTheme
|
||||
.Where(t => t.Id == themeId)
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<SiteThemeDto> GetThemeDto(int themeId)
|
||||
{
|
||||
return await _context.SiteTheme
|
||||
.Where(t => t.Id == themeId)
|
||||
.ProjectTo<SiteThemeDto>(_mapper.ConfigurationProvider)
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
}
|
|
@ -55,6 +55,7 @@ public interface IUserRepository
|
|||
Task<AppUser> GetUserByEmailAsync(string email);
|
||||
Task<IEnumerable<AppUser>> GetAllUsers();
|
||||
|
||||
Task<IEnumerable<AppUserPreferences>> GetAllPreferencesByThemeAsync(int themeId);
|
||||
}
|
||||
|
||||
public class UserRepository : IUserRepository
|
||||
|
@ -227,6 +228,15 @@ public class UserRepository : IUserRepository
|
|||
return await _context.AppUser.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<AppUserPreferences>> GetAllPreferencesByThemeAsync(int themeId)
|
||||
{
|
||||
return await _context.AppUserPreferences
|
||||
.Include(p => p.Theme)
|
||||
.Where(p => p.Theme.Id == themeId)
|
||||
.AsSplitQuery()
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<AppUser>> GetAdminUsersAsync()
|
||||
{
|
||||
return await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole);
|
||||
|
@ -244,7 +254,8 @@ public class UserRepository : IUserRepository
|
|||
|
||||
public async Task<AppUserRating> GetUserRatingAsync(int seriesId, int userId)
|
||||
{
|
||||
return await _context.AppUserRating.Where(r => r.SeriesId == seriesId && r.AppUserId == userId)
|
||||
return await _context.AppUserRating
|
||||
.Where(r => r.SeriesId == seriesId && r.AppUserId == userId)
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
|
||||
|
@ -252,6 +263,8 @@ public class UserRepository : IUserRepository
|
|||
{
|
||||
return await _context.AppUserPreferences
|
||||
.Include(p => p.AppUser)
|
||||
.Include(p => p.Theme)
|
||||
.AsSplitQuery()
|
||||
.SingleOrDefaultAsync(p => p.AppUser.UserName == username);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
|
@ -6,6 +7,7 @@ using System.Threading.Tasks;
|
|||
using API.Constants;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Enums.Theme;
|
||||
using API.Services;
|
||||
using Kavita.Common;
|
||||
using Kavita.Common.EnvironmentInfo;
|
||||
|
@ -21,6 +23,34 @@ namespace API.Data
|
|||
/// </summary>
|
||||
public static IList<ServerSetting> DefaultSettings;
|
||||
|
||||
public static readonly IList<SiteTheme> DefaultThemes = new List<SiteTheme>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Name = "Dark",
|
||||
NormalizedName = Parser.Parser.Normalize("Dark"),
|
||||
Provider = ThemeProvider.System,
|
||||
FileName = "dark.scss",
|
||||
IsDefault = true,
|
||||
},
|
||||
new()
|
||||
{
|
||||
Name = "Light",
|
||||
NormalizedName = Parser.Parser.Normalize("Light"),
|
||||
Provider = ThemeProvider.System,
|
||||
FileName = "light.scss",
|
||||
IsDefault = false,
|
||||
},
|
||||
new()
|
||||
{
|
||||
Name = "E-Ink",
|
||||
NormalizedName = Parser.Parser.Normalize("E-Ink"),
|
||||
Provider = ThemeProvider.System,
|
||||
FileName = "eink.scss",
|
||||
IsDefault = false,
|
||||
},
|
||||
};
|
||||
|
||||
public static async Task SeedRoles(RoleManager<AppRole> roleManager)
|
||||
{
|
||||
var roles = typeof(PolicyConstants)
|
||||
|
@ -41,6 +71,22 @@ namespace API.Data
|
|||
}
|
||||
}
|
||||
|
||||
public static async Task SeedThemes(DataContext context)
|
||||
{
|
||||
await context.Database.EnsureCreatedAsync();
|
||||
|
||||
foreach (var theme in DefaultThemes)
|
||||
{
|
||||
var existing = context.SiteTheme.FirstOrDefault(s => s.Name.Equals(theme.Name));
|
||||
if (existing == null)
|
||||
{
|
||||
await context.SiteTheme.AddAsync(theme);
|
||||
}
|
||||
}
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public static async Task SeedSettings(DataContext context, IDirectoryService directoryService)
|
||||
{
|
||||
await context.Database.EnsureCreatedAsync();
|
||||
|
|
|
@ -21,6 +21,7 @@ public interface IUnitOfWork
|
|||
IPersonRepository PersonRepository { get; }
|
||||
IGenreRepository GenreRepository { get; }
|
||||
ITagRepository TagRepository { get; }
|
||||
ISiteThemeRepository SiteThemeRepository { get; }
|
||||
bool Commit();
|
||||
Task<bool> CommitAsync();
|
||||
bool HasChanges();
|
||||
|
@ -56,6 +57,7 @@ public class UnitOfWork : IUnitOfWork
|
|||
public IPersonRepository PersonRepository => new PersonRepository(_context, _mapper);
|
||||
public IGenreRepository GenreRepository => new GenreRepository(_context, _mapper);
|
||||
public ITagRepository TagRepository => new TagRepository(_context, _mapper);
|
||||
public ISiteThemeRepository SiteThemeRepository => new SiteThemeRepository(_context, _mapper);
|
||||
|
||||
/// <summary>
|
||||
/// Commits changes to the DB. Completes the open transaction.
|
||||
|
|
|
@ -58,11 +58,11 @@ namespace API.Entities
|
|||
/// Book Reader Option: What direction should the next/prev page buttons go
|
||||
/// </summary>
|
||||
public ReadingDirection BookReaderReadingDirection { get; set; } = ReadingDirection.LeftToRight;
|
||||
|
||||
/// <summary>
|
||||
/// UI Site Global Setting: Whether the UI should render in Dark mode or not.
|
||||
/// UI Site Global Setting: The UI theme the user should use.
|
||||
/// </summary>
|
||||
public bool SiteDarkMode { get; set; } = true;
|
||||
/// <remarks>Should default to Dark</remarks>
|
||||
public SiteTheme Theme { get; set; }
|
||||
|
||||
|
||||
|
||||
|
|
17
API/Entities/Enums/Theme/ThemeProvider.cs
Normal file
17
API/Entities/Enums/Theme/ThemeProvider.cs
Normal file
|
@ -0,0 +1,17 @@
|
|||
using System.ComponentModel;
|
||||
|
||||
namespace API.Entities.Enums.Theme;
|
||||
|
||||
public enum ThemeProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Theme is provided by System
|
||||
/// </summary>
|
||||
[Description("System")]
|
||||
System = 1,
|
||||
/// <summary>
|
||||
/// Theme is provided by the User (ie it's custom)
|
||||
/// </summary>
|
||||
[Description("User")]
|
||||
User = 2
|
||||
}
|
37
API/Entities/SiteTheme.cs
Normal file
37
API/Entities/SiteTheme.cs
Normal file
|
@ -0,0 +1,37 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using API.Entities.Enums.Theme;
|
||||
using API.Entities.Interfaces;
|
||||
using API.Services;
|
||||
|
||||
namespace API.Entities;
|
||||
/// <summary>
|
||||
/// Represents a set of css overrides the user can upload to Kavita and will load into webui
|
||||
/// </summary>
|
||||
public class SiteTheme : IEntityDate
|
||||
{
|
||||
public int Id { get; set; }
|
||||
/// <summary>
|
||||
/// Name of the Theme
|
||||
/// </summary>
|
||||
public string Name { get; set; }
|
||||
/// <summary>
|
||||
/// Normalized name for lookups
|
||||
/// </summary>
|
||||
public string NormalizedName { get; set; }
|
||||
/// <summary>
|
||||
/// File path to the content. Stored under <see cref="DirectoryService.SiteThemeDirectory"/>.
|
||||
/// Must be a .css file
|
||||
/// </summary>
|
||||
public string FileName { get; set; }
|
||||
/// <summary>
|
||||
/// Only one theme can have this. Will auto-set this as default for new user accounts
|
||||
/// </summary>
|
||||
public bool IsDefault { get; set; }
|
||||
/// <summary>
|
||||
/// Where did the theme come from
|
||||
/// </summary>
|
||||
public ThemeProvider Provider { get; set; }
|
||||
public DateTime Created { get; set; }
|
||||
public DateTime LastModified { get; set; }
|
||||
}
|
|
@ -39,6 +39,7 @@ namespace API.Extensions
|
|||
services.AddScoped<IAccountService, AccountService>();
|
||||
services.AddScoped<IEmailService, EmailService>();
|
||||
services.AddScoped<IBookmarkService, BookmarkService>();
|
||||
services.AddScoped<ISiteThemeService, SiteThemeService>();
|
||||
|
||||
services.AddScoped<IFileSystem, FileSystem>();
|
||||
services.AddScoped<IFileService, FileService>();
|
||||
|
|
|
@ -7,6 +7,7 @@ using API.DTOs.Reader;
|
|||
using API.DTOs.ReadingLists;
|
||||
using API.DTOs.Search;
|
||||
using API.DTOs.Settings;
|
||||
using API.DTOs.Theme;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Metadata;
|
||||
|
@ -119,10 +120,14 @@ namespace API.Helpers
|
|||
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Editor)));
|
||||
|
||||
|
||||
CreateMap<AppUser, UserDto>();
|
||||
CreateMap<SiteTheme, SiteThemeDto>();
|
||||
CreateMap<AppUserPreferences, UserPreferencesDto>()
|
||||
.ForMember(dest => dest.Theme,
|
||||
opt =>
|
||||
opt.MapFrom(src => src.Theme));
|
||||
|
||||
|
||||
CreateMap<AppUserPreferences, UserPreferencesDto>();
|
||||
|
||||
CreateMap<AppUserBookmark, BookmarkDto>();
|
||||
|
||||
CreateMap<ReadingList, ReadingListDto>();
|
||||
|
@ -146,6 +151,7 @@ namespace API.Helpers
|
|||
CreateMap<RegisterDto, AppUser>();
|
||||
|
||||
|
||||
|
||||
CreateMap<IEnumerable<ServerSetting>, ServerSettingDto>()
|
||||
.ConvertUsing<ServerSettingConverter>();
|
||||
}
|
||||
|
|
|
@ -77,6 +77,7 @@ namespace API
|
|||
|
||||
await Seed.SeedRoles(roleManager);
|
||||
await Seed.SeedSettings(context, directoryService);
|
||||
await Seed.SeedThemes(context);
|
||||
await Seed.SeedUserApiKeys(context);
|
||||
|
||||
|
||||
|
|
|
@ -19,6 +19,7 @@ namespace API.Services
|
|||
string LogDirectory { get; }
|
||||
string TempDirectory { get; }
|
||||
string ConfigDirectory { get; }
|
||||
string SiteThemeDirectory { get; }
|
||||
/// <summary>
|
||||
/// Original BookmarkDirectory. Only used for resetting directory. Use <see cref="ServerSettingKey.BackupDirectory"/> for actual path.
|
||||
/// </summary>
|
||||
|
@ -64,6 +65,7 @@ namespace API.Services
|
|||
public string TempDirectory { get; }
|
||||
public string ConfigDirectory { get; }
|
||||
public string BookmarkDirectory { get; }
|
||||
public string SiteThemeDirectory { get; }
|
||||
private readonly ILogger<DirectoryService> _logger;
|
||||
|
||||
private static readonly Regex ExcludeDirectories = new Regex(
|
||||
|
@ -81,6 +83,7 @@ namespace API.Services
|
|||
TempDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "temp");
|
||||
ConfigDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config");
|
||||
BookmarkDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "bookmarks");
|
||||
SiteThemeDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "themes");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
@ -22,6 +22,7 @@ public interface ITaskScheduler
|
|||
void ScanSeries(int libraryId, int seriesId, bool forceUpdate = false);
|
||||
void CancelStatsTasks();
|
||||
Task RunStatCollection();
|
||||
void ScanSiteThemes();
|
||||
}
|
||||
public class TaskScheduler : ITaskScheduler
|
||||
{
|
||||
|
@ -35,6 +36,7 @@ public class TaskScheduler : ITaskScheduler
|
|||
|
||||
private readonly IStatsService _statsService;
|
||||
private readonly IVersionUpdaterService _versionUpdaterService;
|
||||
private readonly ISiteThemeService _siteThemeService;
|
||||
|
||||
public static BackgroundJobServer Client => new BackgroundJobServer();
|
||||
private static readonly Random Rnd = new Random();
|
||||
|
@ -42,7 +44,8 @@ public class TaskScheduler : ITaskScheduler
|
|||
|
||||
public TaskScheduler(ICacheService cacheService, ILogger<TaskScheduler> logger, IScannerService scannerService,
|
||||
IUnitOfWork unitOfWork, IMetadataService metadataService, IBackupService backupService,
|
||||
ICleanupService cleanupService, IStatsService statsService, IVersionUpdaterService versionUpdaterService)
|
||||
ICleanupService cleanupService, IStatsService statsService, IVersionUpdaterService versionUpdaterService,
|
||||
ISiteThemeService siteThemeService)
|
||||
{
|
||||
_cacheService = cacheService;
|
||||
_logger = logger;
|
||||
|
@ -53,6 +56,7 @@ public class TaskScheduler : ITaskScheduler
|
|||
_cleanupService = cleanupService;
|
||||
_statsService = statsService;
|
||||
_versionUpdaterService = versionUpdaterService;
|
||||
_siteThemeService = siteThemeService;
|
||||
}
|
||||
|
||||
public async Task ScheduleTasks()
|
||||
|
@ -124,6 +128,12 @@ public class TaskScheduler : ITaskScheduler
|
|||
BackgroundJob.Enqueue(() => _statsService.Send());
|
||||
}
|
||||
|
||||
public void ScanSiteThemes()
|
||||
{
|
||||
_logger.LogInformation("Starting Site Theme scan");
|
||||
BackgroundJob.Enqueue(() => _siteThemeService.Scan());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UpdateTasks
|
||||
|
|
163
API/Services/Tasks/SiteThemeService.cs
Normal file
163
API/Services/Tasks/SiteThemeService.cs
Normal file
|
@ -0,0 +1,163 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums.Theme;
|
||||
using API.SignalR;
|
||||
using Kavita.Common;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace API.Services.Tasks;
|
||||
|
||||
public interface ISiteThemeService
|
||||
{
|
||||
Task<string> GetContent(int themeId);
|
||||
Task Scan();
|
||||
Task UpdateDefault(int themeId);
|
||||
}
|
||||
|
||||
public class SiteThemeService : ISiteThemeService
|
||||
{
|
||||
private readonly IDirectoryService _directoryService;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IHubContext<MessageHub> _messageHub;
|
||||
|
||||
public SiteThemeService(IDirectoryService directoryService, IUnitOfWork unitOfWork, IHubContext<MessageHub> messageHub)
|
||||
{
|
||||
_directoryService = directoryService;
|
||||
_unitOfWork = unitOfWork;
|
||||
_messageHub = messageHub;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Given a themeId, return the content inside that file
|
||||
/// </summary>
|
||||
/// <param name="themeId"></param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="KavitaException"></exception>
|
||||
public async Task<string> GetContent(int themeId)
|
||||
{
|
||||
var theme = await _unitOfWork.SiteThemeRepository.GetThemeDto(themeId);
|
||||
if (theme == null) throw new KavitaException("Theme file missing or invalid");
|
||||
var themeFile = _directoryService.FileSystem.Path.Join(_directoryService.SiteThemeDirectory, theme.FileName);
|
||||
if (string.IsNullOrEmpty(themeFile) || !_directoryService.FileSystem.File.Exists(themeFile))
|
||||
throw new KavitaException("Theme file missing or invalid");
|
||||
|
||||
return await _directoryService.FileSystem.File.ReadAllTextAsync(themeFile);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans the site theme directory for custom css files and updates what the system has on store
|
||||
/// </summary>
|
||||
public async Task Scan()
|
||||
{
|
||||
_directoryService.ExistOrCreate(_directoryService.SiteThemeDirectory);
|
||||
var reservedNames = Seed.DefaultThemes.Select(t => t.NormalizedName).ToList();
|
||||
var themeFiles = _directoryService.GetFilesWithExtension(Parser.Parser.NormalizePath(_directoryService.SiteThemeDirectory), @"\.css")
|
||||
.Where(name => !reservedNames.Contains(Parser.Parser.Normalize(name))).ToList();
|
||||
|
||||
var allThemes = (await _unitOfWork.SiteThemeRepository.GetThemes()).ToList();
|
||||
var totalThemesToIterate = themeFiles.Count;
|
||||
var themeIteratedCount = 0;
|
||||
|
||||
// First remove any files from allThemes that are User Defined and not on disk
|
||||
var userThemes = allThemes.Where(t => t.Provider == ThemeProvider.User).ToList();
|
||||
foreach (var userTheme in userThemes)
|
||||
{
|
||||
var filepath = Parser.Parser.NormalizePath(
|
||||
_directoryService.FileSystem.Path.Join(_directoryService.SiteThemeDirectory, userTheme.FileName));
|
||||
if (!_directoryService.FileSystem.File.Exists(filepath))
|
||||
{
|
||||
// I need to do the removal different. I need to update all userpreferences to use DefaultTheme
|
||||
allThemes.Remove(userTheme);
|
||||
await RemoveTheme(userTheme);
|
||||
|
||||
await _messageHub.Clients.All.SendAsync(SignalREvents.SiteThemeProgress,
|
||||
MessageFactory.SiteThemeProgressEvent(1, totalThemesToIterate, userTheme.FileName, 0F));
|
||||
}
|
||||
}
|
||||
|
||||
// Add new custom themes
|
||||
var allThemeNames = allThemes.Select(t => t.NormalizedName).ToList();
|
||||
foreach (var themeFile in themeFiles)
|
||||
{
|
||||
var themeName =
|
||||
Parser.Parser.Normalize(_directoryService.FileSystem.Path.GetFileNameWithoutExtension(themeFile));
|
||||
if (allThemeNames.Contains(themeName))
|
||||
{
|
||||
themeIteratedCount += 1;
|
||||
continue;
|
||||
}
|
||||
_unitOfWork.SiteThemeRepository.Add(new SiteTheme()
|
||||
{
|
||||
Name = _directoryService.FileSystem.Path.GetFileNameWithoutExtension(themeFile),
|
||||
NormalizedName = themeName,
|
||||
FileName = _directoryService.FileSystem.Path.GetFileName(themeFile),
|
||||
Provider = ThemeProvider.User,
|
||||
IsDefault = false,
|
||||
});
|
||||
await _messageHub.Clients.All.SendAsync(SignalREvents.SiteThemeProgress,
|
||||
MessageFactory.SiteThemeProgressEvent(themeIteratedCount, totalThemesToIterate, themeName, themeIteratedCount / (totalThemesToIterate * 1.0f)));
|
||||
themeIteratedCount += 1;
|
||||
}
|
||||
|
||||
|
||||
if (_unitOfWork.HasChanges())
|
||||
{
|
||||
await _unitOfWork.CommitAsync();
|
||||
}
|
||||
|
||||
await _messageHub.Clients.All.SendAsync(SignalREvents.SiteThemeProgress,
|
||||
MessageFactory.SiteThemeProgressEvent(totalThemesToIterate, totalThemesToIterate, "", 1F));
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the theme and any references to it from Pref and sets them to the default at the time.
|
||||
/// This commits to DB.
|
||||
/// </summary>
|
||||
/// <param name="theme"></param>
|
||||
private async Task RemoveTheme(SiteTheme theme)
|
||||
{
|
||||
var prefs = await _unitOfWork.UserRepository.GetAllPreferencesByThemeAsync(theme.Id);
|
||||
var defaultTheme = await _unitOfWork.SiteThemeRepository.GetDefaultTheme();
|
||||
foreach (var pref in prefs)
|
||||
{
|
||||
pref.Theme = defaultTheme;
|
||||
_unitOfWork.UserRepository.Update(pref);
|
||||
}
|
||||
_unitOfWork.SiteThemeRepository.Remove(theme);
|
||||
await _unitOfWork.CommitAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the themeId to the default theme, all others are marked as non-default
|
||||
/// </summary>
|
||||
/// <param name="themeId"></param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="KavitaException">If theme does not exist</exception>
|
||||
public async Task UpdateDefault(int themeId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var theme = await _unitOfWork.SiteThemeRepository.GetThemeDto(themeId);
|
||||
if (theme == null) throw new KavitaException("Theme file missing or invalid");
|
||||
|
||||
foreach (var siteTheme in await _unitOfWork.SiteThemeRepository.GetThemes())
|
||||
{
|
||||
siteTheme.IsDefault = (siteTheme.Id == themeId);
|
||||
_unitOfWork.SiteThemeRepository.Update(siteTheme);
|
||||
}
|
||||
|
||||
if (!_unitOfWork.HasChanges()) return;
|
||||
await _unitOfWork.CommitAsync();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
await _unitOfWork.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
using System;
|
||||
using System.Threading;
|
||||
using API.DTOs.Update;
|
||||
|
||||
namespace API.SignalR
|
||||
|
@ -160,5 +161,20 @@ namespace API.SignalR
|
|||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static SignalRMessage SiteThemeProgressEvent(int themeIteratedCount, int totalThemesToIterate, string themeName, float progress)
|
||||
{
|
||||
return new SignalRMessage()
|
||||
{
|
||||
Name = SignalREvents.SiteThemeProgress,
|
||||
Body = new
|
||||
{
|
||||
TotalUpdates = totalThemesToIterate,
|
||||
CurrentCount = themeIteratedCount,
|
||||
ThemeName = themeName,
|
||||
Progress = progress
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -54,5 +54,10 @@
|
|||
/// A cover was updated
|
||||
/// </summary>
|
||||
public const string CoverUpdate = "CoverUpdate";
|
||||
/// <summary>
|
||||
/// A custom site theme was removed or added
|
||||
/// </summary>
|
||||
public const string SiteThemeProgress = "SiteThemeProgress";
|
||||
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue