feat/AddTheming #6

Merged
ChiaraBm merged 3 commits from feat/AddTheming into v2.1 2026-01-19 09:56:19 +00:00
32 changed files with 1283 additions and 18 deletions

View File

@@ -10,6 +10,54 @@
<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.frontendConfig = {
STYLE_TAG_ID: 'theme-variables',
configuration: {},
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;
},
reloadConfiguration: function (){
try {
const xhr = new XMLHttpRequest();
xhr.open('GET', '/api/frontend/config', false);
xhr.send(null);
if (xhr.status === 200) {
this.configuration = JSON.parse(xhr.responseText);
}
} catch (error) {
console.error('Failed to load initial theme:', error);
}
},
getConfiguration: function (){
return this.configuration;
},
reload: function () {
this.reloadConfiguration();
document.title = this.configuration.name;
this.applyTheme(this.configuration.themeCss);
}
};
window.frontendConfig.reload();
</script>
</head>
<body class="bg-background text-foreground">
@@ -52,6 +100,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>

View File

@@ -0,0 +1,7 @@
namespace Moonlight.Api.Configuration;
public class FrontendOptions
{
public bool Enabled { get; set; } = true;
public int CacheMinutes { get; set; } = 3;
}

View File

@@ -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;

View 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; }
}

View 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
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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")

View 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();
}
}

View 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);
}
}

View 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);
}

View 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);
}

View File

@@ -0,0 +1,3 @@
namespace Moonlight.Api.Models;
public record FrontendConfiguration(string Name, string? ThemeCss);

View File

@@ -31,7 +31,7 @@
</ItemGroup>
<ItemGroup>
<Folder Include="Database\Migrations\"/>
<Folder Include="Migrations\" />
</ItemGroup>
<ItemGroup>

View 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;
}
}

View File

@@ -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");
}
}

View File

@@ -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"),
]),
]);
}
}

View 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);
}

View File

@@ -0,0 +1,9 @@
using System.Text.Json.Serialization;
namespace Moonlight.Frontend.Models;
public class FrontendConfiguration
{
[JsonPropertyName("name")]
public string Name { get; set; }
}

View File

@@ -24,8 +24,8 @@
<PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="10.0.1"/>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.1"/>
<PackageReference Include="Riok.Mapperly" Version="4.3.1-next.0"/>
<PackageReference Include="ShadcnBlazor" Version="1.0.8" />
<PackageReference Include="ShadcnBlazor.Extras" Version="1.0.8" />
<PackageReference Include="ShadcnBlazor" Version="1.0.9" />
<PackageReference Include="ShadcnBlazor.Extras" Version="1.0.9" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,24 @@
using Microsoft.JSInterop;
using Moonlight.Frontend.Models;
namespace Moonlight.Frontend.Services;
public class FrontendService
{
private readonly IJSRuntime JsRuntime;
public FrontendService(IJSRuntime jsRuntime)
{
JsRuntime = jsRuntime;
}
public async Task<FrontendConfiguration> GetConfigurationAsync()
{
return await JsRuntime.InvokeAsync<FrontendConfiguration>("frontendConfig.getConfiguration");
}
public async Task ReloadAsync()
{
await JsRuntime.InvokeVoidAsync("frontendConfig.reload");
}
}

View File

@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Moonlight.Frontend.Implementations;
using Moonlight.Frontend.Interfaces;
using Moonlight.Frontend.Services;
using Moonlight.Frontend.UI;
using ShadcnBlazor;
using ShadcnBlazor.Extras;
@@ -22,5 +23,7 @@ public partial class Startup
builder.Services.AddShadcnBlazorExtras();
builder.Services.AddSingleton<ISidebarProvider, SidebarProvider>();
builder.Services.AddScoped<FrontendService>();
}
}

View File

@@ -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)

View File

@@ -0,0 +1,138 @@
@page "/admin/system/themes/create"
@using Microsoft.AspNetCore.Authorization
@using Moonlight.Shared
@using LucideBlazor
@using Moonlight.Frontend.Services
@using Moonlight.Shared.Http.Requests.Themes
@using ShadcnBlazor.Buttons
@using ShadcnBlazor.Labels
@using ShadcnBlazor.Cards
@using ShadcnBlazor.Extras.Editors
@using ShadcnBlazor.Extras.FormHandlers
@using ShadcnBlazor.Extras.Toasts
@using ShadcnBlazor.Inputs
@using ShadcnBlazor.Switches
@attribute [Authorize(Policy = Permissions.Themes.Create)]
@inject HttpClient HttpClient
@inject NavigationManager Navigation
@inject ToastService ToastService
@inject FrontendService FrontendService
<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>
<Switch @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.Css" 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}"
);
await FrontendService.ReloadAsync();
Navigation.NavigateTo("/admin/system?tab=themes");
}
}

View 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();
}
);
}
}

View File

@@ -0,0 +1,151 @@
@page "/admin/system/themes/{Id:int}"
@using Microsoft.AspNetCore.Authorization
@using Moonlight.Shared
@using LucideBlazor
@using Moonlight.Frontend.Mappers
@using Moonlight.Frontend.Services
@using Moonlight.Shared.Http.Requests.Themes
@using Moonlight.Shared.Http.Responses.Themes
@using ShadcnBlazor.Buttons
@using ShadcnBlazor.Labels
@using ShadcnBlazor.Cards
@using ShadcnBlazor.Extras.Common
@using ShadcnBlazor.Extras.Editors
@using ShadcnBlazor.Extras.FormHandlers
@using ShadcnBlazor.Extras.Toasts
@using ShadcnBlazor.Inputs
@using ShadcnBlazor.Switches
@attribute [Authorize(Policy = Permissions.Themes.Edit)]
@inject HttpClient HttpClient
@inject NavigationManager Navigation
@inject ToastService ToastService
@inject FrontendService FrontendService
<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>
<Switch @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.Css" 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}"
);
await FrontendService.ReloadAsync();
Navigation.NavigateTo("/admin/system?tab=themes");
}
}

View File

@@ -1,11 +1,14 @@
@using Microsoft.AspNetCore.Authorization
@using System.Text.Json
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using Moonlight.Frontend.Interfaces
@using Moonlight.Frontend.Models
@using Moonlight.Frontend.Services
@using ShadcnBlazor.Sidebars
@inject NavigationManager Navigation
@inject IAuthorizationService AuthorizationService
@inject FrontendService FrontendService
@inject IEnumerable<ISidebarProvider> Providers
@implements IDisposable
@@ -21,7 +24,7 @@
<SidebarMenuButton>
<a href="/" class="flex flex-row items-center">
<img alt="Logo" src="/_content/Moonlight.Frontend/logo.svg" class="size-6"/>
<span class="ms-2.5 text-lg font-semibold">Moonlight</span>
<span class="ms-2.5 text-lg font-semibold">@FrontendConfiguration?.Name</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
@@ -74,6 +77,7 @@
[CascadingParameter] public Task<AuthenticationState> AuthState { get; set; }
private readonly List<SidebarItem> Items = new();
private FrontendConfiguration? FrontendConfiguration;
protected override async Task OnInitializedAsync()
{
@@ -98,6 +102,10 @@
}
Navigation.LocationChanged += OnLocationChanged;
FrontendConfiguration = await FrontendService.GetConfigurationAsync();
Console.WriteLine(JsonSerializer.Serialize(FrontendConfiguration));
}
private async void OnLocationChanged(object? sender, LocationChangedEventArgs e)

View 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; }
}

View 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; }
}

View File

@@ -0,0 +1,3 @@
namespace Moonlight.Shared.Http.Responses.Frontend;
public record FrontendConfigDto(string Name, string? ThemeCss);

View 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);

View File

@@ -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
{
}

View File

@@ -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";