feat/AddTheming #6
@@ -10,6 +10,45 @@
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=fallback" />
|
||||
<link rel="stylesheet" href="style.min.css" />
|
||||
<script type="importmap"></script>
|
||||
|
||||
<script>
|
||||
window.themeLoader = {
|
||||
STYLE_TAG_ID: 'theme-variables',
|
||||
|
||||
applyTheme: function(cssContent) {
|
||||
// Find or create the style tag
|
||||
let styleTag = document.getElementById(this.STYLE_TAG_ID);
|
||||
|
||||
if (!styleTag) {
|
||||
styleTag = document.createElement('style');
|
||||
styleTag.id = this.STYLE_TAG_ID;
|
||||
document.head.appendChild(styleTag);
|
||||
}
|
||||
|
||||
// Update the style tag content
|
||||
styleTag.textContent = cssContent;
|
||||
},
|
||||
|
||||
loadInitialThemeSync: function() {
|
||||
try {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', '/api/frontend/config', false);
|
||||
xhr.send(null);
|
||||
|
||||
if (xhr.status === 200) {
|
||||
const config = JSON.parse(xhr.responseText);
|
||||
|
||||
this.applyTheme(config.themeCss);
|
||||
document.title = config.name;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load initial theme:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.themeLoader.loadInitialThemeSync();
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body class="bg-background text-foreground">
|
||||
@@ -52,6 +91,8 @@
|
||||
</div>
|
||||
|
||||
<script src="/_content/ShadcnBlazor/interop.js" defer></script>
|
||||
<script src="/_content/ShadcnBlazor.Extras/interop.js" defer></script>
|
||||
<script src="/_content/ShadcnBlazor.Extras/codemirror-bundle.js" defer></script>
|
||||
<script src="_framework/blazor.webassembly#[.{fingerprint}].js"></script>
|
||||
</body>
|
||||
|
||||
|
||||
7
Moonlight.Api/Configuration/FrontendOptions.cs
Normal file
7
Moonlight.Api/Configuration/FrontendOptions.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Moonlight.Api.Configuration;
|
||||
|
||||
public class FrontendOptions
|
||||
{
|
||||
public bool Enabled { get; set; } = true;
|
||||
public int CacheMinutes { get; set; } = 3;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ public class DataContext : DbContext
|
||||
public DbSet<Role> Roles { get; set; }
|
||||
public DbSet<RoleMember> RoleMembers { get; set; }
|
||||
public DbSet<ApiKey> ApiKeys { get; set; }
|
||||
public DbSet<Theme> Themes { get; set; }
|
||||
|
||||
private readonly IOptions<DatabaseOptions> Options;
|
||||
|
||||
|
||||
21
Moonlight.Api/Database/Entities/Theme.cs
Normal file
21
Moonlight.Api/Database/Entities/Theme.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Moonlight.Api.Database.Entities;
|
||||
|
||||
public class Theme
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
[MaxLength(30)]
|
||||
public required string Name { get; set; }
|
||||
|
||||
[MaxLength(30)]
|
||||
public required string Version { get; set; }
|
||||
|
||||
[MaxLength(30)]
|
||||
public required string Author { get; set; }
|
||||
public bool IsEnabled { get; set; }
|
||||
|
||||
[MaxLength(20_000)]
|
||||
public required string CssContent { get; set; }
|
||||
}
|
||||
251
Moonlight.Api/Database/Migrations/20260118005634_AddedThemes.Designer.cs
generated
Normal file
251
Moonlight.Api/Database/Migrations/20260118005634_AddedThemes.Designer.cs
generated
Normal file
@@ -0,0 +1,251 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Moonlight.Api.Database;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Moonlight.Api.Database.Migrations
|
||||
{
|
||||
[DbContext(typeof(DataContext))]
|
||||
[Migration("20260118005634_AddedThemes")]
|
||||
partial class AddedThemes
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("core")
|
||||
.HasAnnotation("ProductVersion", "10.0.1")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Moonlight.Api.Database.Entities.ApiKey", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.PrimitiveCollection<string[]>("Permissions")
|
||||
.IsRequired()
|
||||
.HasColumnType("text[]");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("ApiKeys", "core");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Moonlight.Api.Database.Entities.Role", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.PrimitiveCollection<string[]>("Permissions")
|
||||
.IsRequired()
|
||||
.HasColumnType("text[]");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Roles", "core");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Moonlight.Api.Database.Entities.RoleMember", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("RoleId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("RoleMembers", "core");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Moonlight.Api.Database.Entities.SettingsOption", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("SettingsOptions", "core");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Moonlight.Api.Database.Entities.Theme", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Author")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<string>("CssContent")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20000)
|
||||
.HasColumnType("character varying(20000)");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<string>("Version")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Themes", "core");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Moonlight.Api.Database.Entities.User", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(254)
|
||||
.HasColumnType("character varying(254)");
|
||||
|
||||
b.Property<DateTimeOffset>("InvalidateTimestamp")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Users", "core");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Moonlight.Api.Database.Entities.RoleMember", b =>
|
||||
{
|
||||
b.HasOne("Moonlight.Api.Database.Entities.Role", "Role")
|
||||
.WithMany("Members")
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Moonlight.Api.Database.Entities.User", "User")
|
||||
.WithMany("RoleMemberships")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Role");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Moonlight.Api.Database.Entities.Role", b =>
|
||||
{
|
||||
b.Navigation("Members");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Moonlight.Api.Database.Entities.User", b =>
|
||||
{
|
||||
b.Navigation("RoleMemberships");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Moonlight.Api.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddedThemes : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Themes",
|
||||
schema: "core",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Name = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
|
||||
Version = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
|
||||
Author = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
|
||||
IsEnabled = table.Column<bool>(type: "boolean", nullable: false),
|
||||
CssContent = table.Column<string>(type: "character varying(20000)", maxLength: 20000, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Themes", x => x.Id);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Themes",
|
||||
schema: "core");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -146,6 +146,42 @@ namespace Moonlight.Api.Database.Migrations
|
||||
b.ToTable("SettingsOptions", "core");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Moonlight.Api.Database.Entities.Theme", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Author")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<string>("CssContent")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20000)
|
||||
.HasColumnType("character varying(20000)");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<string>("Version")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Themes", "core");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Moonlight.Api.Database.Entities.User", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
|
||||
145
Moonlight.Api/Http/Controllers/Admin/ThemesController.cs
Normal file
145
Moonlight.Api/Http/Controllers/Admin/ThemesController.cs
Normal file
@@ -0,0 +1,145 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Moonlight.Api.Database;
|
||||
using Moonlight.Api.Database.Entities;
|
||||
using Moonlight.Api.Mappers;
|
||||
using Moonlight.Api.Services;
|
||||
using Moonlight.Shared;
|
||||
using Moonlight.Shared.Http.Requests;
|
||||
using Moonlight.Shared.Http.Requests.Themes;
|
||||
using Moonlight.Shared.Http.Responses;
|
||||
using Moonlight.Shared.Http.Responses.Themes;
|
||||
|
||||
namespace Moonlight.Api.Http.Controllers.Admin;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/admin/themes")]
|
||||
public class ThemesController : Controller
|
||||
{
|
||||
private readonly DatabaseRepository<Theme> ThemeRepository;
|
||||
private readonly FrontendService FrontendService;
|
||||
|
||||
public ThemesController(DatabaseRepository<Theme> themeRepository, FrontendService frontendService)
|
||||
{
|
||||
ThemeRepository = themeRepository;
|
||||
FrontendService = frontendService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[Authorize(Policy = Permissions.Themes.View)]
|
||||
public async Task<ActionResult<PagedData<ThemeDto>>> GetAsync(
|
||||
[FromQuery] int startIndex,
|
||||
[FromQuery] int length,
|
||||
[FromQuery] FilterOptions? filterOptions
|
||||
)
|
||||
{
|
||||
// Validation
|
||||
if (startIndex < 0)
|
||||
return Problem("Invalid start index specified", statusCode: 400);
|
||||
|
||||
if (length is < 1 or > 100)
|
||||
return Problem("Invalid length specified");
|
||||
|
||||
// Query building
|
||||
|
||||
var query = ThemeRepository
|
||||
.Query();
|
||||
|
||||
// Filters
|
||||
if (filterOptions != null)
|
||||
{
|
||||
foreach (var filterOption in filterOptions.Filters)
|
||||
{
|
||||
query = filterOption.Key switch
|
||||
{
|
||||
nameof(Theme.Name) =>
|
||||
query.Where(user => EF.Functions.ILike(user.Name, $"%{filterOption.Value}%")),
|
||||
|
||||
nameof(Theme.Version) =>
|
||||
query.Where(user => EF.Functions.ILike(user.Version, $"%{filterOption.Value}%")),
|
||||
|
||||
nameof(Theme.Author) =>
|
||||
query.Where(user => EF.Functions.ILike(user.Author, $"%{filterOption.Value}%")),
|
||||
|
||||
_ => query
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Pagination
|
||||
var data = await query
|
||||
.OrderBy(x => x.Id)
|
||||
.ProjectToDto()
|
||||
.Skip(startIndex)
|
||||
.Take(length)
|
||||
.ToArrayAsync();
|
||||
|
||||
var total = await query.CountAsync();
|
||||
|
||||
return new PagedData<ThemeDto>(data, total);
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}")]
|
||||
[Authorize(Policy = Permissions.Themes.View)]
|
||||
public async Task<ActionResult<ThemeDto>> GetAsync([FromRoute] int id)
|
||||
{
|
||||
var item = await ThemeRepository
|
||||
.Query()
|
||||
.FirstOrDefaultAsync(x => x.Id == id);
|
||||
|
||||
if (item == null)
|
||||
return Problem("No theme with this id", statusCode: 404);
|
||||
|
||||
return ThemeMapper.ToDto(item);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Policy = Permissions.Themes.Create)]
|
||||
public async Task<ActionResult<ThemeDto>> CreateAsync([FromBody] CreateThemeDto request)
|
||||
{
|
||||
var theme = ThemeMapper.ToEntity(request);
|
||||
|
||||
var finalTheme = await ThemeRepository.AddAsync(theme);
|
||||
await FrontendService.ResetCacheAsync();
|
||||
|
||||
return ThemeMapper.ToDto(finalTheme);
|
||||
}
|
||||
|
||||
[HttpPatch("{id:int}")]
|
||||
[Authorize(Policy = Permissions.Themes.Edit)]
|
||||
public async Task<ActionResult<ThemeDto>> UpdateAsync([FromRoute] int id, [FromBody] UpdateThemeDto request)
|
||||
{
|
||||
var theme = await ThemeRepository
|
||||
.Query()
|
||||
.FirstOrDefaultAsync(x => x.Id == id);
|
||||
|
||||
if (theme == null)
|
||||
return Problem("No theme with this id found", statusCode: 404);
|
||||
|
||||
ThemeMapper.Merge(theme, request);
|
||||
await ThemeRepository.UpdateAsync(theme);
|
||||
|
||||
await FrontendService.ResetCacheAsync();
|
||||
|
||||
return ThemeMapper.ToDto(theme);
|
||||
}
|
||||
|
||||
[HttpDelete("{id:int}")]
|
||||
[Authorize(Policy = Permissions.Themes.Delete)]
|
||||
public async Task<ActionResult> DeleteAsync([FromRoute] int id)
|
||||
{
|
||||
var theme = await ThemeRepository
|
||||
.Query()
|
||||
.FirstOrDefaultAsync(x => x.Id == id);
|
||||
|
||||
if (theme == null)
|
||||
return Problem("No theme with this id found", statusCode: 404);
|
||||
|
||||
await ThemeRepository.RemoveAsync(theme);
|
||||
|
||||
await FrontendService.ResetCacheAsync();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
25
Moonlight.Api/Http/Controllers/FrontendController.cs
Normal file
25
Moonlight.Api/Http/Controllers/FrontendController.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Moonlight.Api.Mappers;
|
||||
using Moonlight.Api.Services;
|
||||
using Moonlight.Shared.Http.Responses.Frontend;
|
||||
|
||||
namespace Moonlight.Api.Http.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/frontend")]
|
||||
public class FrontendController : Controller
|
||||
{
|
||||
private readonly FrontendService FrontendService;
|
||||
|
||||
public FrontendController(FrontendService frontendService)
|
||||
{
|
||||
FrontendService = frontendService;
|
||||
}
|
||||
|
||||
[HttpGet("config")]
|
||||
public async Task<ActionResult<FrontendConfigDto>> GetConfigAsync()
|
||||
{
|
||||
var configuration = await FrontendService.GetConfigurationAsync();
|
||||
return FrontendConfigMapper.ToDto(configuration);
|
||||
}
|
||||
}
|
||||
14
Moonlight.Api/Mappers/FrontendConfigMapper.cs
Normal file
14
Moonlight.Api/Mappers/FrontendConfigMapper.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Moonlight.Api.Models;
|
||||
using Moonlight.Shared.Http.Responses.Frontend;
|
||||
using Riok.Mapperly.Abstractions;
|
||||
|
||||
namespace Moonlight.Api.Mappers;
|
||||
|
||||
[Mapper]
|
||||
[SuppressMessage("Mapper", "RMG020:No members are mapped in an object mapping")]
|
||||
[SuppressMessage("Mapper", "RMG012:No members are mapped in an object mapping")]
|
||||
public static partial class FrontendConfigMapper
|
||||
{
|
||||
public static partial FrontendConfigDto ToDto(FrontendConfiguration configuration);
|
||||
}
|
||||
21
Moonlight.Api/Mappers/ThemeMapper.cs
Normal file
21
Moonlight.Api/Mappers/ThemeMapper.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Moonlight.Api.Database.Entities;
|
||||
using Moonlight.Shared.Http.Requests.Themes;
|
||||
using Moonlight.Shared.Http.Responses.Themes;
|
||||
using Riok.Mapperly.Abstractions;
|
||||
|
||||
namespace Moonlight.Api.Mappers;
|
||||
|
||||
[Mapper]
|
||||
[SuppressMessage("Mapper", "RMG020:No members are mapped in an object mapping")]
|
||||
[SuppressMessage("Mapper", "RMG012:No members are mapped in an object mapping")]
|
||||
public static partial class ThemeMapper
|
||||
{
|
||||
public static partial IQueryable<ThemeDto> ProjectToDto(this IQueryable<Theme> themes);
|
||||
|
||||
public static partial ThemeDto ToDto(Theme theme);
|
||||
|
||||
public static partial void Merge([MappingTarget] Theme theme, UpdateThemeDto request);
|
||||
|
||||
public static partial Theme ToEntity(CreateThemeDto request);
|
||||
}
|
||||
3
Moonlight.Api/Models/FrontendConfiguration.cs
Normal file
3
Moonlight.Api/Models/FrontendConfiguration.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace Moonlight.Api.Models;
|
||||
|
||||
public record FrontendConfiguration(string Name, string? ThemeCss);
|
||||
@@ -31,7 +31,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Database\Migrations\"/>
|
||||
<Folder Include="Migrations\" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
50
Moonlight.Api/Services/FrontendService.cs
Normal file
50
Moonlight.Api/Services/FrontendService.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moonlight.Api.Configuration;
|
||||
using Moonlight.Api.Database;
|
||||
using Moonlight.Api.Database.Entities;
|
||||
using Moonlight.Api.Models;
|
||||
|
||||
namespace Moonlight.Api.Services;
|
||||
|
||||
public class FrontendService
|
||||
{
|
||||
private readonly IMemoryCache Cache;
|
||||
private readonly DatabaseRepository<Theme> ThemeRepository;
|
||||
private readonly IOptions<FrontendOptions> Options;
|
||||
|
||||
private const string CacheKey = $"Moonlight.{nameof(FrontendService)}.{nameof(GetConfigurationAsync)}";
|
||||
|
||||
public FrontendService(IMemoryCache cache, DatabaseRepository<Theme> themeRepository, IOptions<FrontendOptions> options)
|
||||
{
|
||||
Cache = cache;
|
||||
ThemeRepository = themeRepository;
|
||||
Options = options;
|
||||
}
|
||||
|
||||
public async Task<FrontendConfiguration> GetConfigurationAsync()
|
||||
{
|
||||
if (Cache.TryGetValue(CacheKey, out FrontendConfiguration? value))
|
||||
{
|
||||
if (value != null)
|
||||
return value;
|
||||
}
|
||||
|
||||
var theme = await ThemeRepository
|
||||
.Query()
|
||||
.FirstOrDefaultAsync(x => x.IsEnabled);
|
||||
|
||||
var config = new FrontendConfiguration("Moonlight", theme?.CssContent);
|
||||
|
||||
Cache.Set(CacheKey, config, TimeSpan.FromMinutes(Options.Value.CacheMinutes));
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
public Task ResetCacheAsync()
|
||||
{
|
||||
Cache.Remove(CacheKey);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,14 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Console;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moonlight.Api.Configuration;
|
||||
using Moonlight.Shared.Http;
|
||||
using Moonlight.Api.Helpers;
|
||||
using Moonlight.Api.Implementations;
|
||||
using Moonlight.Api.Interfaces;
|
||||
using Moonlight.Api.Services;
|
||||
using SessionOptions = Microsoft.AspNetCore.Builder.SessionOptions;
|
||||
|
||||
namespace Moonlight.Api.Startup;
|
||||
|
||||
@@ -32,6 +35,9 @@ public partial class Startup
|
||||
|
||||
builder.Services.AddMemoryCache();
|
||||
builder.Services.AddOptions<SessionOptions>().BindConfiguration("Moonlight:Session");
|
||||
|
||||
builder.Services.AddOptions<FrontendOptions>().BindConfiguration("Moonlight:Frontend");
|
||||
builder.Services.AddScoped<FrontendService>();
|
||||
}
|
||||
|
||||
private static void UseBase(WebApplication application)
|
||||
@@ -43,6 +49,9 @@ public partial class Startup
|
||||
{
|
||||
application.MapControllers();
|
||||
|
||||
var options = application.Services.GetRequiredService<IOptions<FrontendOptions>>();
|
||||
|
||||
if(options.Value.Enabled)
|
||||
application.MapFallbackToFile("index.html");
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,12 @@ public sealed class PermissionProvider : IPermissionProvider
|
||||
new Permission(Permissions.ApiKeys.Edit, "Edit", "Edit API key details"),
|
||||
new Permission(Permissions.ApiKeys.Delete, "Delete", "Delete API keys"),
|
||||
]),
|
||||
new PermissionCategory("Themes", typeof(PaintRollerIcon), [
|
||||
new Permission(Permissions.Themes.Create, "Create", "Create new theme"),
|
||||
new Permission(Permissions.Themes.View, "View", "View all themes"),
|
||||
new Permission(Permissions.Themes.Edit, "Edit", "Edit themes"),
|
||||
new Permission(Permissions.Themes.Delete, "Delete", "Delete themes"),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
14
Moonlight.Frontend/Mappers/ThemeMapper.cs
Normal file
14
Moonlight.Frontend/Mappers/ThemeMapper.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Moonlight.Shared.Http.Requests.Themes;
|
||||
using Moonlight.Shared.Http.Responses.Themes;
|
||||
using Riok.Mapperly.Abstractions;
|
||||
|
||||
namespace Moonlight.Frontend.Mappers;
|
||||
|
||||
[Mapper]
|
||||
[SuppressMessage("Mapper", "RMG020:No members are mapped in an object mapping")]
|
||||
[SuppressMessage("Mapper", "RMG012:No members are mapped in an object mapping")]
|
||||
public partial class ThemeMapper
|
||||
{
|
||||
public static partial UpdateThemeDto ToUpdate(ThemeDto theme);
|
||||
}
|
||||
@@ -13,11 +13,15 @@
|
||||
@inject NavigationManager Navigation
|
||||
@inject IAuthorizationService AuthorizationService
|
||||
|
||||
<Tabs DefaultValue="@(Tab ?? "customization")" OnValueChanged="OnTabChanged">
|
||||
<Tabs DefaultValue="@(Tab ?? "settings")" OnValueChanged="OnTabChanged">
|
||||
<TabsList ClassName="inline-flex w-full lg:w-fit justify-start overflow-x-auto overflow-y-hidden">
|
||||
<TabsTrigger Value="customization">
|
||||
<TabsTrigger Value="settings">
|
||||
<CogIcon />
|
||||
Settings
|
||||
</TabsTrigger>
|
||||
<TabsTrigger Value="themes" Disabled="@(!ThemesAccess.Succeeded)">
|
||||
<PaintRollerIcon/>
|
||||
Customization
|
||||
Themes
|
||||
</TabsTrigger>
|
||||
<TabsTrigger Value="apiKeys" Disabled="@(!ApiKeyAccess.Succeeded)">
|
||||
<KeyIcon/>
|
||||
@@ -28,7 +32,7 @@
|
||||
Diagnose
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent Value="customization">
|
||||
<TabsContent Value="settings">
|
||||
<Card ClassName="mt-5">
|
||||
<CardContent>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-5">
|
||||
@@ -55,6 +59,12 @@
|
||||
<ApiKeys />
|
||||
</TabsContent>
|
||||
}
|
||||
@if (ThemesAccess.Succeeded)
|
||||
{
|
||||
<TabsContent Value="themes">
|
||||
<Moonlight.Frontend.UI.Admin.Views.Sys.Themes.Index />
|
||||
</TabsContent>
|
||||
}
|
||||
</Tabs>
|
||||
|
||||
@code
|
||||
@@ -66,12 +76,14 @@
|
||||
[CascadingParameter] public Task<AuthenticationState> AuthState { get; set; }
|
||||
|
||||
private AuthorizationResult ApiKeyAccess;
|
||||
private AuthorizationResult ThemesAccess;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthState;
|
||||
|
||||
ApiKeyAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.ApiKeys.View);
|
||||
ThemesAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.Themes.View);
|
||||
}
|
||||
|
||||
private void OnTabChanged(string name)
|
||||
|
||||
134
Moonlight.Frontend/UI/Admin/Views/Sys/Themes/Create.razor
Normal file
134
Moonlight.Frontend/UI/Admin/Views/Sys/Themes/Create.razor
Normal file
@@ -0,0 +1,134 @@
|
||||
@page "/admin/system/themes/create"
|
||||
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using Moonlight.Shared
|
||||
@using LucideBlazor
|
||||
@using Moonlight.Shared.Http.Requests.Themes
|
||||
@using ShadcnBlazor.Buttons
|
||||
@using ShadcnBlazor.Labels
|
||||
@using ShadcnBlazor.Cards
|
||||
@using ShadcnBlazor.Checkboxes
|
||||
@using ShadcnBlazor.Extras.Editors
|
||||
@using ShadcnBlazor.Extras.FormHandlers
|
||||
@using ShadcnBlazor.Extras.Toasts
|
||||
@using ShadcnBlazor.Inputs
|
||||
|
||||
@attribute [Authorize(Policy = Permissions.Themes.Create)]
|
||||
|
||||
@inject HttpClient HttpClient
|
||||
@inject NavigationManager Navigation
|
||||
@inject ToastService ToastService
|
||||
|
||||
<div class="flex flex-row justify-between">
|
||||
<div class="flex flex-col">
|
||||
<h1 class="text-xl font-semibold">Create theme</h1>
|
||||
<div class="text-muted-foreground">
|
||||
Create a new theme
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row gap-x-1.5">
|
||||
<Button Variant="ButtonVariant.Secondary">
|
||||
<Slot>
|
||||
<a href="/admin/system?tab=themes" @attributes="context">
|
||||
<ChevronLeftIcon/>
|
||||
Back
|
||||
</a>
|
||||
</Slot>
|
||||
</Button>
|
||||
<Button @onclick="SubmitAsync">
|
||||
<CheckIcon/>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<Card>
|
||||
<CardContent>
|
||||
<FormHandler @ref="Form" OnValidSubmit="OnSubmitAsync" Model="Request">
|
||||
<div class="flex flex-col gap-6">
|
||||
|
||||
<FormValidationSummary />
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
<div class="col-span-1 grid gap-2">
|
||||
<Label for="themeName">Name</Label>
|
||||
<InputField
|
||||
@bind-Value="Request.Name"
|
||||
id="themeName"
|
||||
placeholder="My cool theme"/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-1 grid gap-2">
|
||||
<Label for="themeVersion">Version</Label>
|
||||
<InputField
|
||||
@bind-Value="Request.Version"
|
||||
id="themeVersion"
|
||||
Type="text"
|
||||
placeholder="1.0.0"/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-1 grid gap-2">
|
||||
<Label for="themeAuthor">Author</Label>
|
||||
<InputField
|
||||
@bind-Value="Request.Author"
|
||||
id="themeAuthor"
|
||||
Type="text"
|
||||
placeholder="Your name"/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-1 grid gap-2">
|
||||
<Label for="themeAuthor">Is Enabled</Label>
|
||||
<Checkbox @bind-Value="Request.IsEnabled" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.cm-editor {
|
||||
max-height: 400px;
|
||||
min-height: 400px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="themeAuthor">CSS Content</Label>
|
||||
<Editor @ref="Editor" Language="EditorLanguage.None" InitialValue="@Request.CssContent"/>
|
||||
</div>
|
||||
</div>
|
||||
</FormHandler>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@code
|
||||
{
|
||||
private CreateThemeDto Request = new()
|
||||
{
|
||||
CssContent = "/* Define your css here */"
|
||||
};
|
||||
|
||||
private FormHandler Form;
|
||||
private Editor Editor;
|
||||
|
||||
private async Task SubmitAsync()
|
||||
{
|
||||
Request.CssContent = await Editor.GetValueAsync();
|
||||
await Form.SubmitAsync();
|
||||
}
|
||||
|
||||
private async Task OnSubmitAsync()
|
||||
{
|
||||
await HttpClient.PostAsJsonAsync(
|
||||
"/api/admin/themes",
|
||||
Request,
|
||||
Constants.SerializerOptions
|
||||
);
|
||||
|
||||
await ToastService.SuccessAsync(
|
||||
"Theme creation",
|
||||
$"Successfully created theme {Request.Name}"
|
||||
);
|
||||
|
||||
Navigation.NavigateTo("/admin/system?tab=themes");
|
||||
}
|
||||
}
|
||||
148
Moonlight.Frontend/UI/Admin/Views/Sys/Themes/Index.razor
Normal file
148
Moonlight.Frontend/UI/Admin/Views/Sys/Themes/Index.razor
Normal file
@@ -0,0 +1,148 @@
|
||||
@using LucideBlazor
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@using Moonlight.Shared
|
||||
@using Moonlight.Shared.Http.Requests
|
||||
@using Moonlight.Shared.Http.Responses
|
||||
@using Moonlight.Shared.Http.Responses.Themes
|
||||
@using ShadcnBlazor.DataGrids
|
||||
@using ShadcnBlazor.Dropdowns
|
||||
@using ShadcnBlazor.Extras.AlertDialogs
|
||||
@using ShadcnBlazor.Tabels
|
||||
@using ShadcnBlazor.Buttons
|
||||
@using ShadcnBlazor.Extras.Toasts
|
||||
|
||||
@inject ToastService ToastService
|
||||
@inject NavigationManager Navigation
|
||||
@inject AlertDialogService AlertDialogService
|
||||
@inject IAuthorizationService AuthorizationService
|
||||
@inject HttpClient HttpClient
|
||||
|
||||
<div class="flex flex-row justify-between mt-5">
|
||||
<div class="flex flex-col">
|
||||
<h1 class="text-xl font-semibold">Themes</h1>
|
||||
<div class="text-muted-foreground">
|
||||
Manage themes for your instance
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row gap-x-1.5">
|
||||
<Button @onclick="CreateAsync" disabled="@(!CreateAccess.Succeeded)">
|
||||
<PlusIcon/>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<DataGrid @ref="Grid" TGridItem="ThemeDto" Loader="LoadAsync" PageSize="10" ClassName="bg-card">
|
||||
<PropertyColumn Field="k => k.Id"/>
|
||||
<TemplateColumn Identifier="@nameof(ThemeDto.Name)" IsFilterable="true" Title="Name">
|
||||
<CellTemplate>
|
||||
<TableCell>
|
||||
<a class="text-primary flex flex-row items-center" href="#" @onclick="() => EditAsync(context)" @onclick:preventDefault>
|
||||
@context.Name
|
||||
|
||||
@if (context.IsEnabled)
|
||||
{
|
||||
<span class="ms-2">
|
||||
<CheckIcon ClassName="size-4 text-green-400" />
|
||||
</span>
|
||||
}
|
||||
</a>
|
||||
</TableCell>
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
<PropertyColumn IsFilterable="true"
|
||||
Identifier="@nameof(ThemeDto.Version)" Field="k => k.Version"/>
|
||||
<PropertyColumn IsFilterable="true"
|
||||
Identifier="@nameof(ThemeDto.Author)" Field="k => k.Author"/>
|
||||
<TemplateColumn>
|
||||
<CellTemplate>
|
||||
<TableCell>
|
||||
<div class="flex flex-row items-center justify-end me-3">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Slot Context="dropdownSlot">
|
||||
<Button Size="ButtonSize.IconSm" Variant="ButtonVariant.Ghost"
|
||||
@attributes="dropdownSlot">
|
||||
<EllipsisIcon/>
|
||||
</Button>
|
||||
</Slot>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent SideOffset="2">
|
||||
<DropdownMenuItem OnClick="() => EditAsync(context)" Disabled="@(!EditAccess.Succeeded)">
|
||||
Edit
|
||||
<DropdownMenuShortcut>
|
||||
<PenIcon/>
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem OnClick="() => DeleteAsync(context)"
|
||||
Variant="DropdownMenuItemVariant.Destructive"
|
||||
Disabled="@(!DeleteAccess.Succeeded)">
|
||||
Delete
|
||||
<DropdownMenuShortcut>
|
||||
<TrashIcon/>
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</TableCell>
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
</DataGrid>
|
||||
</div>
|
||||
|
||||
@code
|
||||
{
|
||||
[CascadingParameter] public Task<AuthenticationState> AuthState { get; set; }
|
||||
|
||||
private DataGrid<ThemeDto> Grid;
|
||||
|
||||
private AuthorizationResult EditAccess;
|
||||
private AuthorizationResult DeleteAccess;
|
||||
private AuthorizationResult CreateAccess;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthState;
|
||||
|
||||
EditAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.Themes.Edit);
|
||||
DeleteAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.Themes.Delete);
|
||||
CreateAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.Themes.Create);
|
||||
}
|
||||
|
||||
private async Task<DataGridResponse<ThemeDto>> LoadAsync(DataGridRequest<ThemeDto> request)
|
||||
{
|
||||
var query = $"?startIndex={request.StartIndex}&length={request.Length}";
|
||||
var filterOptions = request.Filters.Count > 0 ? new FilterOptions(request.Filters) : null;
|
||||
|
||||
var response = await HttpClient.GetFromJsonAsync<PagedData<ThemeDto>>(
|
||||
$"api/admin/themes{query}&filterOptions={filterOptions}",
|
||||
Constants.SerializerOptions
|
||||
);
|
||||
|
||||
return new DataGridResponse<ThemeDto>(response!.Data, response.TotalLength);
|
||||
}
|
||||
|
||||
private void CreateAsync() => Navigation.NavigateTo("/admin/system/themes/create");
|
||||
|
||||
private void EditAsync(ThemeDto theme) => Navigation.NavigateTo($"/admin/system/themes/{theme.Id}");
|
||||
|
||||
private async Task DeleteAsync(ThemeDto theme)
|
||||
{
|
||||
await AlertDialogService.ConfirmDangerAsync(
|
||||
$"Deletion of theme {theme.Name}",
|
||||
"Do you really want to delete this theme? This action cannot be undone",
|
||||
async () =>
|
||||
{
|
||||
var response = await HttpClient.DeleteAsync($"api/admin/themes/{theme.Id}");
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await ToastService.SuccessAsync("Theme deletion", $"Successfully deleted theme {theme.Name}");
|
||||
|
||||
await Grid.RefreshAsync();
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
147
Moonlight.Frontend/UI/Admin/Views/Sys/Themes/Update.razor
Normal file
147
Moonlight.Frontend/UI/Admin/Views/Sys/Themes/Update.razor
Normal file
@@ -0,0 +1,147 @@
|
||||
@page "/admin/system/themes/{Id:int}"
|
||||
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using Moonlight.Shared
|
||||
@using LucideBlazor
|
||||
@using Moonlight.Frontend.Mappers
|
||||
@using Moonlight.Shared.Http.Requests.Themes
|
||||
@using Moonlight.Shared.Http.Responses.Themes
|
||||
@using ShadcnBlazor.Buttons
|
||||
@using ShadcnBlazor.Labels
|
||||
@using ShadcnBlazor.Cards
|
||||
@using ShadcnBlazor.Checkboxes
|
||||
@using ShadcnBlazor.Extras.Common
|
||||
@using ShadcnBlazor.Extras.Editors
|
||||
@using ShadcnBlazor.Extras.FormHandlers
|
||||
@using ShadcnBlazor.Extras.Toasts
|
||||
@using ShadcnBlazor.Inputs
|
||||
|
||||
@attribute [Authorize(Policy = Permissions.Themes.Edit)]
|
||||
|
||||
@inject HttpClient HttpClient
|
||||
@inject NavigationManager Navigation
|
||||
@inject ToastService ToastService
|
||||
|
||||
<div class="flex flex-row justify-between">
|
||||
<div class="flex flex-col">
|
||||
<h1 class="text-xl font-semibold">Update theme</h1>
|
||||
<div class="text-muted-foreground">
|
||||
Update the theme
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row gap-x-1.5">
|
||||
<Button Variant="ButtonVariant.Secondary">
|
||||
<Slot>
|
||||
<a href="/admin/system?tab=themes" @attributes="context">
|
||||
<ChevronLeftIcon/>
|
||||
Back
|
||||
</a>
|
||||
</Slot>
|
||||
</Button>
|
||||
<Button @onclick="SubmitAsync">
|
||||
<CheckIcon/>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<Card>
|
||||
<CardContent>
|
||||
<LazyLoader Load="LoadAsync">
|
||||
<FormHandler @ref="Form" OnValidSubmit="OnSubmitAsync" Model="Request">
|
||||
<div class="flex flex-col gap-6">
|
||||
|
||||
<FormValidationSummary/>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
<div class="col-span-1 grid gap-2">
|
||||
<Label for="themeName">Name</Label>
|
||||
<InputField
|
||||
@bind-Value="Request.Name"
|
||||
id="themeName"
|
||||
placeholder="My cool theme"/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-1 grid gap-2">
|
||||
<Label for="themeVersion">Version</Label>
|
||||
<InputField
|
||||
@bind-Value="Request.Version"
|
||||
id="themeVersion"
|
||||
Type="text"
|
||||
placeholder="1.0.0"/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-1 grid gap-2">
|
||||
<Label for="themeAuthor">Author</Label>
|
||||
<InputField
|
||||
@bind-Value="Request.Author"
|
||||
id="themeAuthor"
|
||||
Type="text"
|
||||
placeholder="Your name"/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-1 grid gap-2">
|
||||
<Label for="themeAuthor">Is Enabled</Label>
|
||||
<Checkbox @bind-Value="Request.IsEnabled" DefaultValue="Request.IsEnabled"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.cm-editor {
|
||||
max-height: 400px;
|
||||
min-height: 400px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="themeAuthor">CSS Content</Label>
|
||||
<Editor @ref="Editor" Language="EditorLanguage.None" InitialValue="@Request.CssContent"/>
|
||||
</div>
|
||||
</div>
|
||||
</FormHandler>
|
||||
</LazyLoader>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@code
|
||||
{
|
||||
[Parameter] public int Id { get; set; }
|
||||
|
||||
private UpdateThemeDto Request;
|
||||
private ThemeDto Theme;
|
||||
|
||||
private FormHandler Form;
|
||||
private Editor Editor;
|
||||
|
||||
private async Task LoadAsync(LazyLoader _)
|
||||
{
|
||||
var theme = await HttpClient.GetFromJsonAsync<ThemeDto>($"api/admin/themes/{Id}");
|
||||
|
||||
Theme = theme!;
|
||||
Request = ThemeMapper.ToUpdate(Theme);
|
||||
}
|
||||
|
||||
private async Task SubmitAsync()
|
||||
{
|
||||
Request.CssContent = await Editor.GetValueAsync();
|
||||
await Form.SubmitAsync();
|
||||
}
|
||||
|
||||
private async Task OnSubmitAsync()
|
||||
{
|
||||
await HttpClient.PatchAsJsonAsync(
|
||||
$"/api/admin/themes/{Theme.Id}",
|
||||
Request,
|
||||
Constants.SerializerOptions
|
||||
);
|
||||
|
||||
await ToastService.SuccessAsync(
|
||||
"Theme update",
|
||||
$"Successfully updated theme {Request.Name}"
|
||||
);
|
||||
|
||||
Navigation.NavigateTo("/admin/system?tab=themes");
|
||||
}
|
||||
}
|
||||
23
Moonlight.Shared/Http/Requests/Themes/CreateThemeDto.cs
Normal file
23
Moonlight.Shared/Http/Requests/Themes/CreateThemeDto.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Moonlight.Shared.Http.Requests.Themes;
|
||||
|
||||
public class CreateThemeDto
|
||||
{
|
||||
[Required]
|
||||
[MaxLength(30)]
|
||||
public string Name { get; set; }
|
||||
|
||||
[Required]
|
||||
[MaxLength(30)]
|
||||
public string Version { get; set; }
|
||||
|
||||
[Required]
|
||||
[MaxLength(30)]
|
||||
public string Author { get; set; }
|
||||
public bool IsEnabled { get; set; }
|
||||
|
||||
[Required]
|
||||
[MaxLength(20_000)]
|
||||
public string CssContent { get; set; }
|
||||
}
|
||||
23
Moonlight.Shared/Http/Requests/Themes/UpdateThemeDto.cs
Normal file
23
Moonlight.Shared/Http/Requests/Themes/UpdateThemeDto.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Moonlight.Shared.Http.Requests.Themes;
|
||||
|
||||
public class UpdateThemeDto
|
||||
{
|
||||
[Required]
|
||||
[MaxLength(30)]
|
||||
public string Name { get; set; }
|
||||
|
||||
[Required]
|
||||
[MaxLength(30)]
|
||||
public string Version { get; set; }
|
||||
|
||||
[Required]
|
||||
[MaxLength(30)]
|
||||
public string Author { get; set; }
|
||||
public bool IsEnabled { get; set; }
|
||||
|
||||
[Required]
|
||||
[MaxLength(20_000)]
|
||||
public string CssContent { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Moonlight.Shared.Http.Responses.Frontend;
|
||||
|
||||
public record FrontendConfigDto(string Name, string? ThemeCss);
|
||||
3
Moonlight.Shared/Http/Responses/Themes/ThemeDto.cs
Normal file
3
Moonlight.Shared/Http/Responses/Themes/ThemeDto.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace Moonlight.Shared.Http.Responses.Themes;
|
||||
|
||||
public record ThemeDto(int Id, string Name, string Author, string Version, string CssContent, bool IsEnabled);
|
||||
@@ -1,32 +1,48 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Moonlight.Shared.Http.Requests.ApiKeys;
|
||||
using Moonlight.Shared.Http.Requests.Roles;
|
||||
using Moonlight.Shared.Http.Requests.Themes;
|
||||
using Moonlight.Shared.Http.Requests.Users;
|
||||
using Moonlight.Shared.Http.Responses;
|
||||
using Moonlight.Shared.Http.Responses.Admin;
|
||||
using Moonlight.Shared.Http.Responses.ApiKeys;
|
||||
using Moonlight.Shared.Http.Responses.Auth;
|
||||
using Moonlight.Shared.Http.Responses.Themes;
|
||||
using Moonlight.Shared.Http.Responses.Users;
|
||||
|
||||
namespace Moonlight.Shared.Http;
|
||||
|
||||
// Users
|
||||
[JsonSerializable(typeof(CreateUserDto))]
|
||||
[JsonSerializable(typeof(UpdateUserDto))]
|
||||
[JsonSerializable(typeof(PagedData<UserDto>))]
|
||||
[JsonSerializable(typeof(UserDto))]
|
||||
|
||||
// Auth
|
||||
[JsonSerializable(typeof(ClaimDto[]))]
|
||||
[JsonSerializable(typeof(SchemeDto[]))]
|
||||
|
||||
// System
|
||||
[JsonSerializable(typeof(DiagnoseResultDto[]))]
|
||||
[JsonSerializable(typeof(UserDto))]
|
||||
[JsonSerializable(typeof(SystemInfoDto))]
|
||||
[JsonSerializable(typeof(PagedData<UserDto>))]
|
||||
[JsonSerializable(typeof(PagedData<RoleDto>))]
|
||||
[JsonSerializable(typeof(RoleDto))]
|
||||
|
||||
// Roles
|
||||
[JsonSerializable(typeof(CreateRoleDto))]
|
||||
[JsonSerializable(typeof(UpdateRoleDto))]
|
||||
[JsonSerializable(typeof(PagedData<RoleDto>))]
|
||||
[JsonSerializable(typeof(RoleDto))]
|
||||
|
||||
// API Keys
|
||||
[JsonSerializable(typeof(CreateApiKeyDto))]
|
||||
[JsonSerializable(typeof(UpdateApiKeyDto))]
|
||||
[JsonSerializable(typeof(UpdateApiKeyDto))]
|
||||
[JsonSerializable(typeof(PagedData<ApiKeyDto>))]
|
||||
[JsonSerializable(typeof(ApiKeyDto))]
|
||||
|
||||
// Themes
|
||||
[JsonSerializable(typeof(CreateThemeDto))]
|
||||
[JsonSerializable(typeof(UpdateThemeDto))]
|
||||
[JsonSerializable(typeof(PagedData<ThemeDto>))]
|
||||
[JsonSerializable(typeof(ThemeDto))]
|
||||
public partial class SerializationContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
@@ -37,6 +37,16 @@ public static class Permissions
|
||||
public const string Members = $"{Prefix}{Section}.{nameof(Members)}";
|
||||
}
|
||||
|
||||
public static class Themes
|
||||
{
|
||||
private const string Section = "Themes";
|
||||
|
||||
public const string View = $"{Prefix}{Section}.{nameof(View)}";
|
||||
public const string Edit = $"{Prefix}{Section}.{nameof(Edit)}";
|
||||
public const string Create = $"{Prefix}{Section}.{nameof(Create)}";
|
||||
public const string Delete = $"{Prefix}{Section}.{nameof(Delete)}";
|
||||
}
|
||||
|
||||
public static class System
|
||||
{
|
||||
private const string Section = "System";
|
||||
|
||||
Reference in New Issue
Block a user