Compare commits
13 Commits
c2d8b6531c
...
b536b31a4b
| Author | SHA1 | Date | |
|---|---|---|---|
| b536b31a4b | |||
| a80efee075 | |||
| 86d6daafbd | |||
| aefa1a8883 | |||
| b3e7f77165 | |||
| bac9bfd036 | |||
| 6effb8a0d6 | |||
| a9ff0d88f2 | |||
| 0fdfe991ea | |||
| 7fc2c14f9c | |||
| 5098deedd1 | |||
| 2e634361c5 | |||
| 60ebebc0d8 |
@@ -0,0 +1,39 @@
|
||||
namespace EnvelopeGenerator.Application.Common.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a user's UI preferences stored in the distributed cache (dbo.TBDD_CACHE).
|
||||
/// </summary>
|
||||
public sealed record UserPreferencesDto
|
||||
{
|
||||
/// <summary>
|
||||
/// The active DevExpress theme name.
|
||||
/// Allowed values: "blazing-berry", "fluent", "purple"
|
||||
/// </summary>
|
||||
public string ThemeName { get; set; } = "blazing-berry";
|
||||
|
||||
/// <summary>
|
||||
/// Whether dark mode is active.
|
||||
/// Default: false (light mode).
|
||||
/// </summary>
|
||||
public bool IsDarkMode { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Persisted DxGrid layout states, keyed by a stable grid identifier.
|
||||
/// Example keys: "sender.active-envelopes", "sender.completed-envelopes"
|
||||
/// Values are opaque JSON strings produced by DxGrid.SaveLayoutAsync().
|
||||
/// </summary>
|
||||
public Dictionary<string, string> GridLayouts { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// The authenticated user's display name ("{GivenName} {Surname}").
|
||||
/// Populated by the server on GET responses from JWT claims.
|
||||
/// NOT persisted in cache — injected at read time by UserPreferencesController.
|
||||
/// Ignored on PUT requests.
|
||||
/// </summary>
|
||||
public string? DisplayName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public static UserPreferencesDto Default { get; } = new UserPreferencesDto();
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
|
||||
namespace EnvelopeGenerator.Application.Common.Interfaces.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Manages per-user UI preferences (theme, dark mode, grid layouts).
|
||||
/// Preferences are stored in the distributed cache (dbo.TBDD_CACHE) as JSON.
|
||||
/// Cache key schema: "signflow.user-preferences:{username}"
|
||||
/// </summary>
|
||||
public interface IUserPreferencesService
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieves preferences for the given user.
|
||||
/// Always returns a valid object — never throws, never returns null.
|
||||
/// Returns defaults if no entry exists in cache.
|
||||
/// </summary>
|
||||
/// <param name="id"></param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task<UserPreferencesDto> GetOrCreateAsync(string id, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Persists the given preferences for the user in the distributed cache.
|
||||
/// Failures are non-fatal and silently ignored.
|
||||
/// </summary>
|
||||
/// <param name="id"></param>
|
||||
/// <param name="preferences">The preferences to persist.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task SaveAsync(string id, UserPreferencesDto preferences, CancellationToken ct = default);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
#if NETFRAMEWORK
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
#endif
|
||||
|
||||
namespace EnvelopeGenerator.Domain.Constants
|
||||
@@ -15,7 +16,11 @@ namespace EnvelopeGenerator.Domain.Constants
|
||||
public static class Receiver
|
||||
{
|
||||
public const string TFA = "EGReceiverTFA";
|
||||
|
||||
[Obsolete("Use Receiver.Authorized")]
|
||||
public const string Full = "EGReceiver";
|
||||
|
||||
public static readonly IReadOnlyList<string> Authorized = new[] { "receiver", "EGReceiver" };
|
||||
}
|
||||
|
||||
public const string Sender = "EGSender";
|
||||
|
||||
@@ -14,6 +14,7 @@ using EnvelopeGenerator.Infrastructure.Repositories;
|
||||
using EnvelopeGenerator.Infrastructure.Executor;
|
||||
using EnvelopeGenerator.Application.Common.Interfaces.Repositories;
|
||||
using EnvelopeGenerator.Application.Common.Interfaces.SQLExecutor;
|
||||
using EnvelopeGenerator.Application.Common.Interfaces.Services;
|
||||
using DigitalData.EmailProfilerDispatcher.Abstraction.Entities;
|
||||
#elif NETFRAMEWORK
|
||||
using System;
|
||||
@@ -55,6 +56,9 @@ namespace EnvelopeGenerator.Infrastructure
|
||||
services.TryAddScoped<IEnvelopeTypeRepository, EnvelopeTypeRepository>();
|
||||
services.TryAddScoped<IReceiverRepository, ReceiverRepository>();
|
||||
services.TryAddScoped<IEnvelopeReceiverReadOnlyRepository, EnvelopeReceiverReadOnlyRepository>();
|
||||
|
||||
// User Preferences — distributed cache (dbo.TBDD_CACHE)
|
||||
services.TryAddScoped<IUserPreferencesService, UserPreferencesService>();
|
||||
#endif
|
||||
|
||||
services.AddDbRepository(opt =>
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
<PackageReference Include="DigitalData.Core.Infrastructure" Version="2.6.1" />
|
||||
<PackageReference Include="HtmlSanitizer" Version="9.0.892" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.6" />
|
||||
<PackageReference Include="Microsoft.Identity.Client" Version="4.82.1" />
|
||||
<PackageReference Include="QuestPDF" Version="2025.7.1" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
|
||||
|
||||
46
EnvelopeGenerator.Infrastructure/UserPreferencesService.cs
Normal file
46
EnvelopeGenerator.Infrastructure/UserPreferencesService.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
#if NET
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
using EnvelopeGenerator.Application.Common.Interfaces.Services;
|
||||
|
||||
namespace EnvelopeGenerator.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Implements user preferences storage using the distributed cache (dbo.TBDD_CACHE).
|
||||
/// No DB schema changes required — uses the existing SQL Server distributed cache table.
|
||||
/// Cache key schema: "signflow.user-preferences:{username}"
|
||||
/// Expiration: indefinite (no TTL) — preferences persist until explicitly overwritten.
|
||||
/// </summary>
|
||||
public sealed class UserPreferencesService(IDistributedCache cache) : IUserPreferencesService
|
||||
{
|
||||
private const string KeyPrefix = "signflow.user-preferences:";
|
||||
|
||||
private static string BuildKey(string id) => $"{KeyPrefix}{id}";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UserPreferencesDto> GetOrCreateAsync(string id, CancellationToken ct = default)
|
||||
{
|
||||
var json = await cache.GetStringAsync(BuildKey(id), ct);
|
||||
if(json is not null && JsonSerializer.Deserialize<UserPreferencesDto>(json) is UserPreferencesDto pref)
|
||||
{
|
||||
return pref;
|
||||
}
|
||||
else
|
||||
{
|
||||
// init new
|
||||
await SaveAsync(id, UserPreferencesDto.Default, ct);
|
||||
return UserPreferencesDto.Default;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SaveAsync(string id, UserPreferencesDto preferences, CancellationToken ct = default)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(preferences);
|
||||
|
||||
// No expiration — preferences are stored indefinitely.
|
||||
await cache.SetStringAsync(BuildKey(id), json, new DistributedCacheEntryOptions(), ct);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,28 +1,43 @@
|
||||
@using EnvelopeGenerator.Server.Client.Services;
|
||||
@using EnvelopeGenerator.Server.Client.Services
|
||||
@inherits LayoutComponentBase
|
||||
@implements IDisposable
|
||||
|
||||
<div class="page">
|
||||
<main>
|
||||
@inject ThemeService ThemeService
|
||||
|
||||
<HeadContent>
|
||||
<link href="@ThemeService.ThemeCssHref" rel="stylesheet" />
|
||||
</HeadContent>
|
||||
|
||||
<div class="page" data-bs-theme="@ThemeService.BootstrapTheme">
|
||||
<main>
|
||||
<article class="content">
|
||||
@Body
|
||||
</article>
|
||||
</main>
|
||||
<footer class="receiver-footer">
|
||||
<span>© SignFlow 2023-2024 <a href="https://digitaldata.works" target="_blank" rel="noopener">Digital Data GmbH</a></span>
|
||||
<span class="receiver-footer__sep">|</span>
|
||||
<a href="docs/privacy-policy.de-DE.html" target="_blank" rel="noopener">Datenschutz</a>
|
||||
<div class="receiver-footer__left">
|
||||
<span>© SignFlow 2023-2024 <a href="https://digitaldata.works" target="_blank" rel="noopener">Digital Data GmbH</a></span>
|
||||
<span class="receiver-footer__sep">|</span>
|
||||
<a href="docs/privacy-policy.de-DE.html" target="_blank" rel="noopener">Datenschutz</a>
|
||||
</div>
|
||||
|
||||
<ThemeControls />
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Inject] IHttpClientFactory HttpClientFactory { get; set; } = default!;
|
||||
|
||||
List<string> RequiredFonts = new() {
|
||||
"opensans.ttf"
|
||||
};
|
||||
|
||||
protected async override Task OnInitializedAsync() {
|
||||
await FontLoader.LoadFonts(HttpClientFactory, RequiredFonts);
|
||||
private Action? _themeChangeHandler;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await FontLoader.LoadFonts(HttpClientFactory, ["opensans.ttf"]);
|
||||
_themeChangeHandler = () => InvokeAsync(StateHasChanged);
|
||||
ThemeService.OnChange += _themeChangeHandler;
|
||||
await base.OnInitializedAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
=> ThemeService.OnChange -= _themeChangeHandler;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
@using EnvelopeGenerator.Server.Client.Services
|
||||
@rendermode InteractiveWebAssembly
|
||||
@implements IDisposable
|
||||
|
||||
@inject ThemeService ThemeService
|
||||
|
||||
<div class="sf-theme-slider">
|
||||
|
||||
@* Moon — left end (blazing-dark) *@
|
||||
<svg class="sf-theme-icon @(ThemeService.ThemeName == "blazing-dark" ? "sf-theme-icon--active" : "")"
|
||||
xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 24 24"
|
||||
title="Blazing Dark">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||
</svg>
|
||||
|
||||
@* Track wrapper: range input + left/mid/right ticks *@
|
||||
<div class="sf-theme-track-wrapper">
|
||||
@* Left tick *@
|
||||
<div class="sf-theme-tick sf-theme-tick--left"></div>
|
||||
@* Mid tick (marks the middle position) *@
|
||||
<div class="sf-theme-tick sf-theme-tick--mid"></div>
|
||||
@* Right tick *@
|
||||
<div class="sf-theme-tick sf-theme-tick--right"></div>
|
||||
|
||||
<input type="range"
|
||||
class="sf-theme-range"
|
||||
min="0" max="2" step="1"
|
||||
value="@SliderValue"
|
||||
@oninput="OnSliderChanged"
|
||||
title="@ThemeService.AvailableThemes[SliderValue].Label" />
|
||||
</div>
|
||||
|
||||
@* Sun — right end (purple) *@
|
||||
<svg class="sf-theme-icon @(ThemeService.ThemeName == "purple" ? "sf-theme-icon--active" : (ThemeService.ThemeName == "blazing-berry" ? "sf-theme-icon--mid" : ""))"
|
||||
xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 24 24"
|
||||
stroke="currentColor" stroke-width="1.5"
|
||||
title="Blazing Berry / Purple">
|
||||
<circle cx="12" cy="12" r="4" fill="currentColor" stroke="none"/>
|
||||
<line x1="12" y1="2" x2="12" y2="5"/>
|
||||
<line x1="12" y1="19" x2="12" y2="22"/>
|
||||
<line x1="4.22" y1="4.22" x2="6.34" y2="6.34"/>
|
||||
<line x1="17.66" y1="17.66" x2="19.78" y2="19.78"/>
|
||||
<line x1="2" y1="12" x2="5" y2="12"/>
|
||||
<line x1="19" y1="12" x2="22" y2="12"/>
|
||||
<line x1="4.22" y1="19.78" x2="6.34" y2="17.66"/>
|
||||
<line x1="17.66" y1="6.34" x2="19.78" y2="4.22"/>
|
||||
</svg>
|
||||
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private Action? _themeChangeHandler;
|
||||
|
||||
/// <summary>
|
||||
/// Maps the active theme to a 0-based slider step.
|
||||
/// 0 = blazing-dark (🌙 left)
|
||||
/// 1 = blazing-berry (middle)
|
||||
/// 2 = purple (☀️ right)
|
||||
/// </summary>
|
||||
private int SliderValue => ThemeService.ThemeName switch
|
||||
{
|
||||
"blazing-dark" => 0,
|
||||
"purple" => 2,
|
||||
_ => 1
|
||||
};
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_themeChangeHandler = () => InvokeAsync(StateHasChanged);
|
||||
ThemeService.OnChange += _themeChangeHandler;
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
await ThemeService.InitializeAsync();
|
||||
}
|
||||
|
||||
private async Task OnSliderChanged(ChangeEventArgs e)
|
||||
{
|
||||
if (!int.TryParse(e.Value?.ToString(), out var step)) return;
|
||||
|
||||
var themeName = step switch
|
||||
{
|
||||
0 => "blazing-dark",
|
||||
2 => "purple",
|
||||
_ => "blazing-berry"
|
||||
};
|
||||
|
||||
await ThemeService.SetThemeAsync(themeName);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
=> ThemeService.OnChange -= _themeChangeHandler;
|
||||
}
|
||||
@@ -5,19 +5,15 @@
|
||||
@using EnvelopeGenerator.Server.Client.Models
|
||||
@using DevExpress.Blazor
|
||||
@using EnvelopeGenerator.Server.Client.Services
|
||||
@inject EnvelopeGenerator.Server.Client.Services.EnvelopeService EnvelopeService
|
||||
@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService
|
||||
@inject NavigationManager Navigation
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject AppVersionService AppVersion
|
||||
@using EnvelopeGenerator.Application.Common.Dto
|
||||
@inject EnvelopeGenerator.Server.Client.Services.EnvelopeService EnvelopeService
|
||||
@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService
|
||||
@inject EnvelopeGenerator.Server.Client.Services.ThemeService ThemeService
|
||||
@inject EnvelopeGenerator.Server.Client.Services.UserPreferencesService UserPreferencesService
|
||||
@inject NavigationManager Navigation
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject AppVersionService AppVersion
|
||||
|
||||
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
|
||||
<link href="@AppVersion.GetVersionedUrl("css/envelope-viewer.css")" rel="stylesheet" />
|
||||
<link href="@AppVersion.GetVersionedUrl("css/sender-page.css")" rel="stylesheet" />
|
||||
|
||||
@@ -136,7 +132,9 @@
|
||||
SelectionMode="GridSelectionMode.Single"
|
||||
SelectedDataItem="@_selectedEnvelope"
|
||||
SelectedDataItemChanged="@OnSelectedEnvelopeChanged"
|
||||
CustomizeElement="OnCustomizeElement">
|
||||
CustomizeElement="OnCustomizeElement"
|
||||
LayoutAutoLoading="OnActiveGridLayoutAutoLoading"
|
||||
LayoutAutoSaving="OnActiveGridLayoutAutoSaving">
|
||||
<Columns>
|
||||
<DxGridDataColumn FieldName="Id" Caption="ID"
|
||||
SortIndex="0"
|
||||
@@ -234,7 +232,9 @@
|
||||
SelectionMode="GridSelectionMode.Single"
|
||||
SelectedDataItem="@_selectedEnvelope"
|
||||
SelectedDataItemChanged="@OnSelectedEnvelopeChanged"
|
||||
CustomizeElement="OnCustomizeElement">
|
||||
CustomizeElement="OnCustomizeElement"
|
||||
LayoutAutoLoading="OnCompletedGridLayoutAutoLoading"
|
||||
LayoutAutoSaving="OnCompletedGridLayoutAutoSaving">
|
||||
<Columns>
|
||||
<DxGridDataColumn FieldName="Id" Caption="ID"
|
||||
SortIndex="0"
|
||||
@@ -341,6 +341,10 @@
|
||||
private DxGrid? _gridActive;
|
||||
private DxGrid? _gridCompleted;
|
||||
|
||||
// Stable keys used to store/restore each grid's layout in UserPreferences.GridLayouts
|
||||
private const string GridKeyActive = "sender.active-envelopes";
|
||||
private const string GridKeyCompleted = "sender.completed-envelopes";
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var hasAccess = await AuthService.CheckSenderAccessAsync();
|
||||
@@ -350,6 +354,12 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Load theme + DisplayName from server after authentication.
|
||||
// Non-fatal: if this fails the page still loads with default theme.
|
||||
// ThemeService.ScheduleSaveAsync() already logs errors internally.
|
||||
try { await ThemeService.LoadFromServerAsync(); }
|
||||
catch { /* swallow — default theme remains active */ }
|
||||
|
||||
await LoadEnvelopesAsync();
|
||||
}
|
||||
|
||||
@@ -367,13 +377,10 @@
|
||||
var envelopes = _allEnvelopes.ToList();
|
||||
_activeEnvelopes = envelopes.Where(e => ((EnvelopeStatus)e.Status).IsActive()).ToList();
|
||||
_completedEnvelopes = envelopes.Where(e => ((EnvelopeStatus)e.Status).IsCompleted()).ToList();
|
||||
|
||||
await JSRuntime.InvokeVoidAsync("console.log", $"Loaded {_activeEnvelopes.Count()} active and {_completedEnvelopes.Count()} completed envelopes");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_errorMessage = ex.Message;
|
||||
await JSRuntime.InvokeVoidAsync("console.error", "Fehler beim Laden der Umschläge:", ex.ToString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -396,20 +403,19 @@
|
||||
{
|
||||
if (_selectedEnvelope == null) return;
|
||||
// TODO: Navigate to envelope editor
|
||||
JSRuntime.InvokeVoidAsync("console.log", $"Edit envelope {_selectedEnvelope.Id} clicked - not yet implemented");
|
||||
}
|
||||
|
||||
void DeleteEnvelope()
|
||||
{
|
||||
if (_selectedEnvelope == null) return;
|
||||
// TODO: Show delete confirmation dialog
|
||||
JSRuntime.InvokeVoidAsync("console.log", $"Delete envelope {_selectedEnvelope.Id} clicked - not yet implemented");
|
||||
}
|
||||
|
||||
async Task LogoutAsync()
|
||||
{
|
||||
_isLoggingOut = true;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
UserPreferencesService.InvalidateCache();
|
||||
await AuthService.LogoutSenderAsync();
|
||||
Navigation.NavigateTo("/sender/login", forceLoad: true);
|
||||
}
|
||||
@@ -446,4 +452,45 @@
|
||||
{
|
||||
_selectedEnvelope = envelope as EnvelopeDto;
|
||||
}
|
||||
|
||||
// ── Grid Layout Persistence ───────────────────────────────────────────────
|
||||
// LayoutAutoLoading fires when the grid initializes — restore saved layout.
|
||||
// LayoutAutoSaving fires when the user changes column order/width/sort — persist it.
|
||||
// UserPreferencesService.GetAsync() uses the 1h in-memory cache → 0 HTTP on restore.
|
||||
// UserPreferencesService.SaveGridLayoutAsync() merges the single key → 1 PUT.
|
||||
|
||||
async Task OnActiveGridLayoutAutoLoading(GridPersistentLayoutEventArgs e)
|
||||
{
|
||||
// Use cached preferences only — avoid HTTP during grid initialization.
|
||||
// If cache is empty (LoadFromServerAsync failed), skip layout restore silently.
|
||||
var prefs = UserPreferencesService.GetCached();
|
||||
if (prefs is not null && prefs.GridLayouts.TryGetValue(GridKeyActive, out var json))
|
||||
e.Layout = JsonSerializer.Deserialize<GridPersistentLayout>(json);
|
||||
}
|
||||
|
||||
async Task OnActiveGridLayoutAutoSaving(GridPersistentLayoutEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = JsonSerializer.Serialize(e.Layout);
|
||||
await UserPreferencesService.SaveGridLayoutAsync(GridKeyActive, json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("console.warn", $"[Sender] Grid layout save failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
async Task OnCompletedGridLayoutAutoLoading(GridPersistentLayoutEventArgs e)
|
||||
{
|
||||
var prefs = UserPreferencesService.GetCached();
|
||||
if (prefs is not null && prefs.GridLayouts.TryGetValue(GridKeyCompleted, out var json))
|
||||
e.Layout = JsonSerializer.Deserialize<GridPersistentLayout>(json);
|
||||
}
|
||||
|
||||
async Task OnCompletedGridLayoutAutoSaving(GridPersistentLayoutEventArgs e)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(e.Layout);
|
||||
await UserPreferencesService.SaveGridLayoutAsync(GridKeyCompleted, json);
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,6 @@
|
||||
@inject NavigationManager Navigation
|
||||
@rendermode InteractiveWebAssembly
|
||||
|
||||
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
|
||||
|
||||
<div class="home-page-wrapper">
|
||||
|
||||
<div class="home-hero-header">
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
@inject AuthService AuthService
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
|
||||
|
||||
<div class="login-page-wrapper d-flex align-items-center justify-content-center min-vh-100">
|
||||
<div class="login-card card shadow border-0" style="max-width: 440px; width: 100%;">
|
||||
|
||||
@@ -15,14 +13,14 @@
|
||||
<path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h5 class="mb-0 fw-semibold">Dokument öffnen</h5>
|
||||
<h5 class="mb-0 fw-semibold">Dokument öffnen</h5>
|
||||
<p class="mb-0 mt-1 opacity-75" style="font-size: 0.85rem;">Sicherer Zugang mit Zugangscode</p>
|
||||
</div>
|
||||
|
||||
<div class="card-body p-4">
|
||||
|
||||
<p class="text-muted mb-4" style="font-size: 0.875rem; line-height: 1.5;">
|
||||
Bitte geben Sie den Zugangscode ein, den Sie per E-Mail erhalten haben, um das Dokument sicher zu öffnen.
|
||||
Bitte geben Sie den Zugangscode ein, den Sie per E-Mail erhalten haben, um das Dokument sicher zu öffnen.
|
||||
</p>
|
||||
|
||||
@if (LoginResult == EnvelopeLoginResult.NotFound)
|
||||
@@ -33,7 +31,7 @@
|
||||
</svg>
|
||||
<div>
|
||||
<strong>Dokument nicht gefunden.</strong><br />
|
||||
<span style="font-size:0.85rem;">Der angegebene Zugangscode konnte keinem Dokument zugeordnet werden. Bitte prüfen Sie den Link in Ihrer E-Mail.</span>
|
||||
<span style="font-size:0.85rem;">Der angegebene Zugangscode konnte keinem Dokument zugeordnet werden. Bitte prüfen Sie den Link in Ihrer E-Mail.</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -44,7 +42,7 @@
|
||||
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z" />
|
||||
</svg>
|
||||
<div>
|
||||
<strong>Ungültiger Zugangscode.</strong><br />
|
||||
<strong>Ungültiger Zugangscode.</strong><br />
|
||||
<span style="font-size:0.85rem;">Der eingegebene Code ist falsch. Bitte versuchen Sie es erneut.</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -58,7 +56,7 @@
|
||||
</svg>
|
||||
<div>
|
||||
<strong>Serverfehler.</strong><br />
|
||||
<span style="font-size:0.85rem;">Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.</span>
|
||||
<span style="font-size:0.85rem;">Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -115,14 +113,14 @@
|
||||
@if (IsLoading)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
|
||||
<span>Überprüfen …</span>
|
||||
<span>Überprüfen …</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="me-2" viewBox="0 0 16 16">
|
||||
<path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z" />
|
||||
</svg>
|
||||
<span>Dokument öffnen</span>
|
||||
<span>Dokument öffnen</span>
|
||||
}
|
||||
</button>
|
||||
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
@inject AuthService AuthService
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
|
||||
|
||||
<div class="login-page-wrapper d-flex align-items-center justify-content-center min-vh-100">
|
||||
<div class="login-card card shadow border-0" style="max-width: 440px; width: 100%;">
|
||||
|
||||
@@ -32,7 +30,7 @@
|
||||
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/>
|
||||
</svg>
|
||||
<div>
|
||||
<strong>Ungültige Anmeldedaten.</strong><br />
|
||||
<strong>Ungültige Anmeldedaten.</strong><br />
|
||||
<span style="font-size:0.85rem;">Benutzername oder Passwort ist falsch. Bitte versuchen Sie es erneut.</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -44,7 +42,7 @@
|
||||
</svg>
|
||||
<div>
|
||||
<strong>Serverfehler.</strong><br />
|
||||
<span style="font-size:0.85rem;">Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.</span>
|
||||
<span style="font-size:0.85rem;">Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -120,7 +118,7 @@
|
||||
disabled="@(IsLoading || string.IsNullOrWhiteSpace(Username) || string.IsNullOrWhiteSpace(Password))">
|
||||
@if (IsLoading) {
|
||||
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
|
||||
<span>Anmelden …</span>
|
||||
<span>Anmelden …</span>
|
||||
} else {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="me-2" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M10 3.5a.5.5 0 0 0-.5-.5h-8a.5.5 0 0 0-.5.5v9a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5v-2a.5.5 0 0 1 1 0v2A1.5 1.5 0 0 1 9.5 14h-8A1.5 1.5 0 0 1 0 12.5v-9A1.5 1.5 0 0 1 1.5 2h8A1.5 1.5 0 0 1 11 3.5v2a.5.5 0 0 1-1 0v-2z"/>
|
||||
|
||||
@@ -34,6 +34,10 @@ builder.Services.AddScoped<SignatureCacheService>();
|
||||
builder.Services.AddSingleton<AppVersionService>();
|
||||
builder.Services.AddScoped<EnvelopeService>();
|
||||
|
||||
// UI Preferences & Theme
|
||||
builder.Services.AddScoped<UserPreferencesService>();
|
||||
builder.Services.AddScoped<ThemeService>();
|
||||
|
||||
// DevExpress WASM
|
||||
builder.Services.AddDevExpressWebAssemblyBlazorPdfViewer();
|
||||
builder.Services.AddDevExpressWebAssemblyBlazorReportViewer();
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
using Microsoft.JSInterop;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
|
||||
namespace EnvelopeGenerator.Server.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Manages the active DevExpress theme for the WASM client.
|
||||
///
|
||||
/// Theme selection is the sole UI control — no separate dark/light toggle.
|
||||
/// Themes ordered dark → light: blazing-dark | blazing-berry | purple
|
||||
///
|
||||
/// Initialization:
|
||||
/// 1. InitializeAsync() — reads localStorage["signflow.theme"], falls back to
|
||||
/// system prefers-color-scheme (dark → blazing-dark, light → blazing-berry).
|
||||
/// 2. LoadFromServerAsync() — called after login, syncs theme from server cache.
|
||||
///
|
||||
/// Persistence:
|
||||
/// - SetThemeAsync() → debounced 500ms PUT /api/UserPreferences.
|
||||
/// - ScheduleSaveAsync() uses GetCached() — no extra GET before each save.
|
||||
///
|
||||
/// Consumers subscribe to OnChange and call StateHasChanged (e.g. MainLayout).
|
||||
/// </summary>
|
||||
public class ThemeService : IAsyncDisposable
|
||||
{
|
||||
// ── Allowed themes (ordered dark → light for range slider) ───────────────
|
||||
// DevExpress.Blazor.Themes 25.2.3: blazing-berry, blazing-dark, purple
|
||||
public static readonly IReadOnlyList<ThemeOption> AvailableThemes =
|
||||
[
|
||||
new("Blazing Dark", "blazing-dark"), // index 0 — 🌙
|
||||
new("Blazing Berry", "blazing-berry"), // index 1 — middle
|
||||
new("Purple", "purple"), // index 2 — ☀️
|
||||
];
|
||||
|
||||
private const string LocalStorageThemeKey = "signflow.theme";
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────────
|
||||
private string _themeName = "blazing-berry";
|
||||
private bool _initialized = false;
|
||||
|
||||
// ── Dependencies ──────────────────────────────────────────────────────────
|
||||
private readonly IJSRuntime _js;
|
||||
private readonly UserPreferencesService _preferencesService;
|
||||
private readonly ILogger<ThemeService> _logger;
|
||||
|
||||
// ── Debounce ──────────────────────────────────────────────────────────────
|
||||
private CancellationTokenSource? _saveCts;
|
||||
|
||||
// ── Change & error notification ───────────────────────────────────────────
|
||||
/// <summary>Fired whenever theme changes. Subscribers call StateHasChanged.</summary>
|
||||
public event Action? OnChange;
|
||||
|
||||
/// <summary>
|
||||
/// Set when a background save fails. UI layer can surface this as a warning.
|
||||
/// Cleared on the next successful save or theme change.
|
||||
/// </summary>
|
||||
public Exception? SaveError { get; private set; }
|
||||
|
||||
public ThemeService(IJSRuntime js, UserPreferencesService preferencesService, ILogger<ThemeService> logger)
|
||||
{
|
||||
_js = js;
|
||||
_preferencesService = preferencesService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// ── Public state ──────────────────────────────────────────────────────────
|
||||
|
||||
public string ThemeName => _themeName;
|
||||
|
||||
/// <summary>True when blazing-dark is active — used for Bootstrap data-bs-theme.</summary>
|
||||
public bool IsDarkMode => _themeName == "blazing-dark";
|
||||
|
||||
public string? DisplayName { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// CSS href for the active DevExpress theme.
|
||||
/// Example: "_content/DevExpress.Blazor.Themes/blazing-dark.bs5.min.css"
|
||||
/// </summary>
|
||||
public string ThemeCssHref =>
|
||||
$"_content/DevExpress.Blazor.Themes/{_themeName}.bs5.min.css";
|
||||
|
||||
/// <summary>Bootstrap 5 data-bs-theme value. Only "dark" for blazing-dark.</summary>
|
||||
public string BootstrapTheme => _themeName == "blazing-dark" ? "dark" : "light";
|
||||
|
||||
// ── Initialization ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Called once in OnAfterRenderAsync(firstRender: true).
|
||||
/// Reads theme from localStorage; falls back to system dark-mode preference.
|
||||
/// Safe to call multiple times — subsequent calls are no-ops.
|
||||
/// </summary>
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
if (_initialized) return;
|
||||
_initialized = true;
|
||||
|
||||
var stored = await _js.InvokeAsync<string?>("localStorage.getItem", LocalStorageThemeKey);
|
||||
|
||||
if (stored is not null && AvailableThemes.Any(t => t.Value == stored))
|
||||
{
|
||||
_themeName = stored;
|
||||
}
|
||||
else
|
||||
{
|
||||
// No stored theme — use system preference to pick dark or light
|
||||
var prefersDark = await _js.InvokeAsync<bool>(
|
||||
"eval",
|
||||
"(function(){ return window.matchMedia('(prefers-color-scheme: dark)').matches; })()");
|
||||
|
||||
_themeName = prefersDark ? "blazing-dark" : "blazing-berry";
|
||||
}
|
||||
|
||||
await ApplyThemeToDocumentAsync();
|
||||
OnChange?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called after successful sender login.
|
||||
/// Syncs theme from the server cache; on first login propagates local theme to server.
|
||||
/// </summary>
|
||||
public async Task LoadFromServerAsync(CancellationToken ct = default)
|
||||
{
|
||||
var prefs = await _preferencesService.GetAsync(ct);
|
||||
|
||||
var isFirstLogin = prefs.GridLayouts.Count == 0
|
||||
&& prefs.ThemeName == "blazing-berry"
|
||||
&& !prefs.IsDarkMode;
|
||||
|
||||
if (isFirstLogin && _themeName != "blazing-berry")
|
||||
{
|
||||
// Propagate locally-detected theme to server on first login
|
||||
prefs.ThemeName = _themeName;
|
||||
prefs.IsDarkMode = IsDarkMode;
|
||||
prefs = await _preferencesService.SaveAsync(prefs, ct);
|
||||
}
|
||||
|
||||
ApplyPreferences(prefs);
|
||||
|
||||
// Persist resolved theme to localStorage
|
||||
await _js.InvokeVoidAsync("localStorage.setItem", LocalStorageThemeKey, _themeName);
|
||||
|
||||
DisplayName = prefs.DisplayName;
|
||||
await ApplyThemeToDocumentAsync();
|
||||
OnChange?.Invoke();
|
||||
}
|
||||
|
||||
// ── Mutation ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Sets the active theme, applies it immediately, and schedules a server save.
|
||||
/// State change is applied regardless of whether the save succeeds.
|
||||
/// </summary>
|
||||
public async Task SetThemeAsync(string themeName)
|
||||
{
|
||||
if (themeName == _themeName) return;
|
||||
if (!AvailableThemes.Any(t => t.Value == themeName))
|
||||
throw new ArgumentException($"Theme '{themeName}' is not allowed.", nameof(themeName));
|
||||
|
||||
_themeName = themeName;
|
||||
SaveError = null;
|
||||
|
||||
await _js.InvokeVoidAsync("localStorage.setItem", LocalStorageThemeKey, _themeName);
|
||||
await ApplyThemeToDocumentAsync();
|
||||
OnChange?.Invoke();
|
||||
await ScheduleSaveAsync();
|
||||
}
|
||||
|
||||
// ── Private helpers ───────────────────────────────────────────────────────
|
||||
|
||||
private void ApplyPreferences(UserPreferencesDto prefs)
|
||||
{
|
||||
_themeName = AvailableThemes.Any(t => t.Value == prefs.ThemeName)
|
||||
? prefs.ThemeName
|
||||
: "blazing-berry";
|
||||
|
||||
// Legacy: if IsDarkMode flag was saved but ThemeName wasn't updated
|
||||
if (prefs.IsDarkMode && _themeName == "blazing-berry")
|
||||
_themeName = "blazing-dark";
|
||||
}
|
||||
|
||||
private async Task ApplyThemeToDocumentAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await _js.InvokeVoidAsync("signflowTheme.apply", ThemeCssHref, BootstrapTheme, _themeName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// JS unavailable during server prerender — <HeadContent> link is the SSR fallback.
|
||||
_logger.LogDebug(ex, "[ThemeService] ApplyThemeToDocumentAsync skipped (JS unavailable).");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ScheduleSaveAsync()
|
||||
{
|
||||
_saveCts?.Cancel();
|
||||
_saveCts = new CancellationTokenSource();
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(500, _saveCts.Token);
|
||||
|
||||
var prefs = _preferencesService.GetCached() ?? new UserPreferencesDto();
|
||||
prefs.ThemeName = _themeName;
|
||||
prefs.IsDarkMode = IsDarkMode;
|
||||
|
||||
await _preferencesService.SaveAsync(prefs, _saveCts.Token);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
// Newer save scheduled — intentionally cancelled.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Background save of user preferences failed.");
|
||||
SaveError = ex;
|
||||
OnChange?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
_saveCts?.Cancel();
|
||||
_saveCts?.Dispose();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Describes a selectable DevExpress theme.</summary>
|
||||
/// <param name="Label">User-facing display name.</param>
|
||||
/// <param name="Value">CSS theme identifier.</param>
|
||||
public sealed record ThemeOption(string Label, string Value);
|
||||
@@ -0,0 +1,136 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace EnvelopeGenerator.Server.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves and persists user UI preferences via the server API.
|
||||
/// The server stores preferences in the distributed cache (dbo.TBDD_CACHE).
|
||||
///
|
||||
/// Client-side memory cache:
|
||||
/// Preferences are cached in-memory for <see cref="CacheDuration"/> after the first fetch.
|
||||
/// All reads (GetAsync, GetCachedAsync) are served from cache after the first load.
|
||||
/// Writes (SaveAsync) update both the cache and the server atomically.
|
||||
/// Cache is invalidated explicitly via InvalidateCache().
|
||||
/// </summary>
|
||||
public class UserPreferencesService(
|
||||
IHttpClientFactory clientFactory,
|
||||
ILogger<UserPreferencesService> logger)
|
||||
{
|
||||
private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
|
||||
private const string Endpoint = "/api/UserPreferences";
|
||||
|
||||
// ── Client-side memory cache ──────────────────────────────────────────────
|
||||
private static readonly TimeSpan CacheDuration = TimeSpan.FromHours(1);
|
||||
private UserPreferencesDto? _cached;
|
||||
private DateTimeOffset _cachedAt = DateTimeOffset.MinValue;
|
||||
|
||||
private bool IsCacheValid => _cached is not null
|
||||
&& DateTimeOffset.UtcNow - _cachedAt < CacheDuration;
|
||||
|
||||
/// <summary>
|
||||
/// Returns preferences from in-memory cache if valid; otherwise fetches from the server.
|
||||
/// On first call after login this issues one HTTP GET — all subsequent calls within
|
||||
/// <see cref="CacheDuration"/> are served from memory with zero network overhead.
|
||||
/// </summary>
|
||||
public async Task<UserPreferencesDto> GetAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (IsCacheValid)
|
||||
return _cached!;
|
||||
|
||||
try
|
||||
{
|
||||
var client = clientFactory.CreateClient("EnvelopeGenerator.Server");
|
||||
var response = await client.GetAsync(Endpoint, ct);
|
||||
|
||||
// Not authenticated yet (login page) or no preferences saved — return defaults.
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound ||
|
||||
response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
|
||||
{
|
||||
SetCache(new UserPreferencesDto());
|
||||
return _cached!;
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var dto = await response.Content.ReadFromJsonAsync<UserPreferencesDto>(_jsonOptions, ct)
|
||||
?? new UserPreferencesDto();
|
||||
|
||||
SetCache(dto);
|
||||
return _cached!;
|
||||
}
|
||||
catch (Exception ex) when (ex is not TaskCanceledException)
|
||||
{
|
||||
logger.LogError(ex, "Failed to load user preferences from server.");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the cached preferences without hitting the network.
|
||||
/// Returns null if the cache has not been populated yet (before first GetAsync call).
|
||||
/// </summary>
|
||||
public UserPreferencesDto? GetCached() => IsCacheValid ? _cached : null;
|
||||
|
||||
/// <summary>
|
||||
/// Persists the given preferences on the server and updates the in-memory cache.
|
||||
/// </summary>
|
||||
public async Task<UserPreferencesDto> SaveAsync(UserPreferencesDto preferences, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var client = clientFactory.CreateClient("EnvelopeGenerator.Server");
|
||||
var response = await client.PutAsJsonAsync(Endpoint, preferences, _jsonOptions, ct);
|
||||
|
||||
// Not authenticated (login page) — skip save, keep local state only.
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
|
||||
return preferences;
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var saved = await response.Content.ReadFromJsonAsync<UserPreferencesDto>(_jsonOptions, ct)
|
||||
?? preferences;
|
||||
|
||||
SetCache(saved);
|
||||
return saved;
|
||||
}
|
||||
catch (Exception ex) when (ex is not TaskCanceledException)
|
||||
{
|
||||
logger.LogError(ex, "Failed to save user preferences to server.");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates a single grid layout key without a network read.
|
||||
/// Uses the in-memory cache for the current state, merges the new layout, then saves.
|
||||
/// If the cache is empty (cold start), fetches from server first.
|
||||
/// </summary>
|
||||
/// <param name="gridKey">Stable grid identifier, e.g. "sender.active-envelopes".</param>
|
||||
/// <param name="layoutJson">Opaque layout JSON produced by DxGrid.SaveLayoutAsync().</param>
|
||||
public async Task SaveGridLayoutAsync(string gridKey, string layoutJson, CancellationToken ct = default)
|
||||
{
|
||||
// Use cached copy — avoids a GET before every grid layout save.
|
||||
var prefs = GetCached() ?? await GetAsync(ct);
|
||||
prefs.GridLayouts[gridKey] = layoutJson;
|
||||
await SaveAsync(prefs, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invalidates the in-memory cache, forcing the next GetAsync to fetch from the server.
|
||||
/// Call this after logout or when a forced refresh is needed.
|
||||
/// </summary>
|
||||
public void InvalidateCache()
|
||||
{
|
||||
_cached = null;
|
||||
_cachedAt = DateTimeOffset.MinValue;
|
||||
}
|
||||
|
||||
private void SetCache(UserPreferencesDto dto)
|
||||
{
|
||||
_cached = dto;
|
||||
_cachedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
<script src="_content/DevExpress.Blazor.Resources/js/preload-script.js"></script>
|
||||
<script src="js/typed.umd.js"></script>
|
||||
<script src="js/receiver-signature.js?v=9"></script>
|
||||
<script src="js/signflow-theme.js"></script>
|
||||
<script src="_framework/blazor.web.js"></script>
|
||||
</body>
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using EnvelopeGenerator.Domain.Constants;
|
||||
using EnvelopeGenerator.Server.Extensions;
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
using EnvelopeGenerator.Application.Common.Interfaces.Services;
|
||||
|
||||
namespace EnvelopeGenerator.Server.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Manages per-user UI preferences (theme, dark mode, grid layouts).
|
||||
/// Preferences are stored in the distributed cache (dbo.TBDD_CACHE).
|
||||
/// Accessible by both Sender and Receiver roles.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize(Policy = AuthPolicy.SenderOrReceiver)]
|
||||
public class UserPreferencesController(IUserPreferencesService preferencesService) : ControllerBase
|
||||
{
|
||||
private static readonly HashSet<string> AllowedThemes =
|
||||
["blazing-berry", "blazing-dark", "purple"];
|
||||
|
||||
/// <summary>
|
||||
/// Returns the current user's UI preferences.
|
||||
/// Always returns 200 with defaults if no preferences have been saved yet.
|
||||
/// DisplayName is populated from JWT claims for senders only.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<UserPreferencesDto>> Get(CancellationToken ct)
|
||||
{
|
||||
bool isReceiver = User.IsReceiver();
|
||||
|
||||
var userId = isReceiver ? User.ReceiverMail() : User.GetUsername();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
return Unauthorized();
|
||||
|
||||
var prefs = await preferencesService.GetOrCreateAsync(userId, ct);
|
||||
|
||||
// Populate DisplayName for sender only (receiver has no given/surname claims)
|
||||
if (!isReceiver)
|
||||
prefs.DisplayName = $"{User.GetPrename()} {User.GetName()}".Trim();
|
||||
|
||||
return Ok(prefs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves (full replace) the current user's UI preferences.
|
||||
/// </summary>
|
||||
[HttpPut]
|
||||
public async Task<ActionResult<UserPreferencesDto>> Put(
|
||||
[FromBody] UserPreferencesDto dto,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!AllowedThemes.Contains(dto.ThemeName))
|
||||
return BadRequest($"Invalid theme '{dto.ThemeName}'. Allowed: {string.Join(", ", AllowedThemes)}.");
|
||||
|
||||
var userId = User.IsReceiver() ? User.ReceiverMail() : User.GetUsername();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
return Unauthorized();
|
||||
|
||||
await preferencesService.SaveAsync(userId, dto, ct);
|
||||
return Ok(dto);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
using DigitalData.Auth.Claims;
|
||||
using DigitalData.UserManager.Domain.Entities;
|
||||
using EnvelopeGenerator.Domain.Constants;
|
||||
using Microsoft.IdentityModel.JsonWebTokens;
|
||||
using System.Security.Claims;
|
||||
|
||||
@@ -93,4 +95,14 @@ public static class ReceiverClaimExtensions
|
||||
else
|
||||
throw new InvalidOperationException($"Claim '{EnvelopeClaimNames.ReceiverId}' is not a valid integer.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="user"></param>
|
||||
/// <returns></returns>
|
||||
public static bool IsReceiver(this ClaimsPrincipal user)
|
||||
{
|
||||
return Role.Receiver.Authorized.Any(user.IsInRole);
|
||||
}
|
||||
}
|
||||
@@ -271,7 +271,12 @@ try
|
||||
.AddAuthenticationSchemes(AuthScheme.Receiver)
|
||||
.RequireAuthenticatedUser()
|
||||
.RequireRole(Role.Receiver.Full, "receiver"))
|
||||
.AddPolicy(AuthPolicy.ReceiverTFA, policy => policy.RequireRole(Role.Receiver.TFA));
|
||||
.AddPolicy(AuthPolicy.ReceiverTFA, policy => policy.RequireRole(Role.Receiver.TFA))
|
||||
// Combined policy: accepts both Sender and Receiver tokens.
|
||||
// Used by endpoints accessible to both roles (e.g. UserPreferences).
|
||||
.AddPolicy(AuthPolicy.SenderOrReceiver, policy => policy
|
||||
.AddAuthenticationSchemes(AuthScheme.Sender, AuthScheme.Receiver)
|
||||
.RequireAuthenticatedUser());
|
||||
|
||||
// User Manager
|
||||
#pragma warning disable CS0618
|
||||
@@ -318,6 +323,16 @@ try
|
||||
.AddEnvelopeGeneratorServices(config);
|
||||
#pragma warning restore CS0618
|
||||
|
||||
// User Preferences (theme, dark mode, grid layouts — stored in TBDD_CACHE)
|
||||
// Registration handled by Infrastructure DependencyInjection (AddEnvelopeGeneratorInfrastructureServices)
|
||||
|
||||
// Client-side services that MainLayout depends on must also be registered here
|
||||
// so that Blazor Auto prerender (SSR pass) can resolve them on the server.
|
||||
// ThemeService.InitializeAsync() uses IJSRuntime but is only called in OnAfterRenderAsync
|
||||
// (never during prerender), so registering it server-side is safe.
|
||||
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.UserPreferencesService>();
|
||||
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.ThemeService>();
|
||||
|
||||
// Business Services (Server specific)
|
||||
builder.Services.AddScoped<DocumentService>();
|
||||
builder.Services.AddScoped<AuthService>();
|
||||
|
||||
@@ -342,7 +342,7 @@ article {
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
justify-content: space-between; /* ← left group | right group */
|
||||
gap: 10px;
|
||||
padding: 0 20px;
|
||||
background: linear-gradient(135deg, #2c3e50 0%, #3498db 100%);
|
||||
@@ -363,6 +363,224 @@ article {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.receiver-footer__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.receiver-footer__sep {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* ── Footer: Theme Slider ────────────────────────────────────────────────── */
|
||||
/* 🌙 blazing-dark ──◇──·──◇── purple ☀️ */
|
||||
/* Left group: copyright | datenschutz → Right group: slider */
|
||||
|
||||
.sf-theme-slider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Moon / Sun SVG icons */
|
||||
.sf-theme-icon {
|
||||
display: block;
|
||||
opacity: 0.55; /* ↑ was 0.42 — more visible as base */
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.18s;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.sf-theme-icon--active {
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.sf-theme-icon--mid {
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
/* ── Wrapper holds the range input + tick marks ───────────────────────────── */
|
||||
.sf-theme-track-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 80px;
|
||||
height: 16px; /* enough height so ticks + thumb don't clip */
|
||||
}
|
||||
|
||||
/* Actual range input — fills the wrapper */
|
||||
.sf-theme-range {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: rgba(255, 255, 255, 0.28);
|
||||
border-radius: 1px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
/* no extra padding so ticks align correctly */
|
||||
}
|
||||
|
||||
.sf-theme-range:hover {
|
||||
background: rgba(255, 255, 255, 0.42);
|
||||
}
|
||||
|
||||
/* Thumb — white circle */
|
||||
.sf-theme-range::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.90);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.28);
|
||||
transition: transform 0.12s, box-shadow 0.12s;
|
||||
}
|
||||
|
||||
.sf-theme-range::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.25);
|
||||
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.38);
|
||||
}
|
||||
|
||||
/* Firefox */
|
||||
.sf-theme-range::-moz-range-thumb {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.90);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.sf-theme-range::-moz-range-track {
|
||||
height: 2px;
|
||||
background: rgba(255, 255, 255, 0.28);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
/* ── End ticks (left & right): thin vertical bars ────────────────────────── */
|
||||
.sf-theme-tick {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
width: 2px;
|
||||
height: 7px;
|
||||
border-radius: 1px;
|
||||
background: rgba(255, 255, 255, 0.45);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sf-theme-tick--left { left: 0; }
|
||||
.sf-theme-tick--right { right: 0; }
|
||||
|
||||
/* Mid tick — thinner, subtler */
|
||||
.sf-theme-tick--mid {
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 1px;
|
||||
height: 5px;
|
||||
background: rgba(255, 255, 255, 0.50); /* ↑ was 0.22 */
|
||||
}
|
||||
|
||||
/* Sun icon: always visible regardless of active state */
|
||||
.sf-theme-icon--sun {
|
||||
opacity: 0.72; /* minimum visibility */
|
||||
}
|
||||
|
||||
/* ── Theme overrides: Home page & Login ──────────────────────────────────── */
|
||||
/* selector: [data-sf-theme="<name>"] on <html>, set by signflow-theme.js */
|
||||
|
||||
/* ── Purple ── */
|
||||
[data-sf-theme="purple"] .home-page-wrapper {
|
||||
background: linear-gradient(160deg, #1a0533 0%, #3b0764 45%, #6b21a8 100%);
|
||||
}
|
||||
|
||||
[data-sf-theme="purple"] .home-btn-primary {
|
||||
background: linear-gradient(135deg, #6b21a8 0%, #9333ea 100%);
|
||||
}
|
||||
|
||||
[data-sf-theme="purple"] .home-feature-badge {
|
||||
background: rgba(147, 51, 234, 0.10);
|
||||
color: #c084fc;
|
||||
border-color: rgba(147, 51, 234, 0.25);
|
||||
}
|
||||
|
||||
/* Login card header: set via a CSS rule so inline gradient can be overridden */
|
||||
.login-card .card-header {
|
||||
background: linear-gradient(135deg, #2c3e50 0%, #3498db 100%) !important;
|
||||
}
|
||||
|
||||
[data-sf-theme="purple"] .login-card .card-header {
|
||||
background: linear-gradient(135deg, #1a0533 0%, #6b21a8 100%) !important;
|
||||
}
|
||||
|
||||
[data-sf-theme="blazing-dark"] .home-page-wrapper {
|
||||
background: linear-gradient(160deg, #0a0f1e 0%, #111827 50%, #1e1b4b 100%);
|
||||
}
|
||||
|
||||
[data-sf-theme="blazing-dark"] .home-btn-primary {
|
||||
background: linear-gradient(135deg, #4338ca 0%, #6366f1 100%);
|
||||
}
|
||||
|
||||
[data-sf-theme="blazing-dark"] .home-feature-badge {
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
color: #a5b4fc;
|
||||
border-color: rgba(99, 102, 241, 0.28);
|
||||
}
|
||||
|
||||
[data-sf-theme="blazing-dark"] .home-card {
|
||||
background: rgba(15, 20, 40, 0.92) !important;
|
||||
color: rgba(255, 255, 255, 0.85) !important;
|
||||
}
|
||||
|
||||
[data-sf-theme="blazing-dark"] .home-card .text-muted {
|
||||
color: rgba(255, 255, 255, 0.55) !important;
|
||||
}
|
||||
|
||||
[data-sf-theme="blazing-dark"] .login-card {
|
||||
background: rgba(15, 20, 40, 0.95) !important;
|
||||
color: rgba(255, 255, 255, 0.85) !important;
|
||||
}
|
||||
|
||||
[data-sf-theme="blazing-dark"] .login-card .card-header {
|
||||
background: linear-gradient(135deg, #0a0f1e 0%, #1e1b4b 100%) !important;
|
||||
}
|
||||
|
||||
[data-sf-theme="blazing-dark"] .login-card .card-body {
|
||||
background: rgba(15, 20, 40, 0.95);
|
||||
}
|
||||
|
||||
[data-sf-theme="blazing-dark"] .login-card .card-footer {
|
||||
background: rgba(15, 20, 40, 0.95) !important;
|
||||
color: rgba(255, 255, 255, 0.45) !important;
|
||||
}
|
||||
|
||||
[data-sf-theme="blazing-dark"] .login-card .form-label {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
[data-sf-theme="blazing-dark"] .login-card .form-control,
|
||||
[data-sf-theme="blazing-dark"] .login-card .input-group-text {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
[data-sf-theme="blazing-dark"] .login-card .btn-primary {
|
||||
background: linear-gradient(135deg, #4338ca 0%, #6366f1 100%) !important;
|
||||
border-color: transparent !important;
|
||||
}
|
||||
|
||||
[data-sf-theme="blazing-dark"] .login-card .text-muted {
|
||||
color: rgba(255, 255, 255, 0.50) !important;
|
||||
}
|
||||
@@ -1103,3 +1103,111 @@ body.resizing {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Theme overrides: Envelope Viewer / Editor / Receiver ───────────────── */
|
||||
/* selector: [data-sf-theme="<name>"] on <html>, set by signflow-theme.js */
|
||||
|
||||
/* ── Purple ─────────────────────────────────────────────────────────────── */
|
||||
[data-sf-theme="purple"] .envelope-viewer-layout {
|
||||
background: linear-gradient(135deg, #1a0533 0%, #3b0764 50%, #6b21a8 100%);
|
||||
}
|
||||
|
||||
[data-sf-theme="purple"] .envelope-action-bar {
|
||||
border-bottom-color: rgba(147, 51, 234, 0.4);
|
||||
}
|
||||
|
||||
[data-sf-theme="purple"] .envelope-logo svg {
|
||||
color: #9333ea;
|
||||
filter: drop-shadow(0 2px 4px rgba(147, 51, 234, 0.35));
|
||||
}
|
||||
|
||||
[data-sf-theme="purple"] .pdf-toolbar__btn {
|
||||
border-color: rgba(147, 51, 234, 0.25);
|
||||
}
|
||||
|
||||
[data-sf-theme="purple"] .pdf-toolbar__btn:hover {
|
||||
border-color: rgba(147, 51, 234, 0.5);
|
||||
box-shadow: 0 2px 8px rgba(147, 51, 234, 0.2);
|
||||
}
|
||||
|
||||
[data-sf-theme="purple"] .pdf-text-layer ::selection,
|
||||
[data-sf-theme="purple"] .pdf-text-layer ::-moz-selection {
|
||||
background: rgba(147, 51, 234, 0.3);
|
||||
}
|
||||
|
||||
/* ── Blazing Dark ───────────────────────────────────────────────────────── */
|
||||
[data-sf-theme="blazing-dark"] .envelope-viewer-layout {
|
||||
background: linear-gradient(135deg, #0a0f1e 0%, #111827 55%, #1e1b4b 100%);
|
||||
}
|
||||
|
||||
[data-sf-theme="blazing-dark"] .envelope-action-bar {
|
||||
background: rgba(20, 20, 35, 0.92);
|
||||
border-bottom-color: rgba(99, 102, 241, 0.35);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
[data-sf-theme="blazing-dark"] .envelope-title {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
[data-sf-theme="blazing-dark"] .envelope-key {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
[data-sf-theme="blazing-dark"] .envelope-logo svg {
|
||||
color: #818cf8;
|
||||
filter: drop-shadow(0 2px 4px rgba(129, 140, 248, 0.3));
|
||||
}
|
||||
|
||||
[data-sf-theme="blazing-dark"] .pdf-toolbar__btn {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
}
|
||||
|
||||
[data-sf-theme="blazing-dark"] .pdf-toolbar__btn:hover {
|
||||
background: rgba(129, 140, 248, 0.15);
|
||||
border-color: rgba(129, 140, 248, 0.4);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
[data-sf-theme="blazing-dark"] .pdf-toolbar__btn--signature-change-active {
|
||||
background: rgba(99, 102, 241, 0.25);
|
||||
border-color: rgba(99, 102, 241, 0.5);
|
||||
color: #a5b4fc;
|
||||
}
|
||||
|
||||
/* Error container */
|
||||
[data-sf-theme="blazing-dark"] .error-container .alert-danger {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
/* Receiver info header */
|
||||
[data-sf-theme="blazing-dark"] .receiver-info-header__gradient {
|
||||
background: linear-gradient(135deg, #0a0f1e 0%, #1e1b4b 100%);
|
||||
}
|
||||
|
||||
[data-sf-theme="blazing-dark"] .receiver-info-message {
|
||||
color: rgba(255, 255, 255, 0.70);
|
||||
border-bottom-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
[data-sf-theme="blazing-dark"] .receiver-info-private-message {
|
||||
background: rgba(245, 158, 11, 0.08);
|
||||
border-top-color: rgba(245, 158, 11, 0.18);
|
||||
color: #fde68a;
|
||||
}
|
||||
|
||||
/* Signature action bar */
|
||||
[data-sf-theme="blazing-dark"] .receiver-action-bar {
|
||||
background: rgba(20, 20, 35, 0.92);
|
||||
border-bottom-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
/* PDF text selection */
|
||||
[data-sf-theme="blazing-dark"] .pdf-text-layer ::selection,
|
||||
[data-sf-theme="blazing-dark"] .pdf-text-layer ::-moz-selection {
|
||||
background: rgba(99, 102, 241, 0.35);
|
||||
}
|
||||
|
||||
@@ -295,3 +295,146 @@
|
||||
font-size: 0.813rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Theme overrides ─────────────────────────────────────────────────────── */
|
||||
/* Selector: [data-sf-theme="<name>"] set on <html> by signflow-theme.js */
|
||||
|
||||
/* ── Purple theme ──────────────────────────────────────────────────────────
|
||||
Deeper violet, less blue — visually distinct from Blazing Berry */
|
||||
[data-sf-theme="purple"] .sender-dashboard-layout {
|
||||
background: linear-gradient(135deg, #1a0533 0%, #3b0764 50%, #6b21a8 100%);
|
||||
}
|
||||
|
||||
[data-sf-theme="purple"] .sender-grid-container::before {
|
||||
background: linear-gradient(90deg, #9333ea 0%, #6b21a8 100%);
|
||||
}
|
||||
|
||||
[data-sf-theme="purple"] .sender-action-bar {
|
||||
border-bottom-color: rgba(147, 51, 234, 0.4);
|
||||
}
|
||||
|
||||
[data-sf-theme="purple"] .sender-logo svg {
|
||||
color: #9333ea;
|
||||
filter: drop-shadow(0 2px 4px rgba(147, 51, 234, 0.35));
|
||||
}
|
||||
|
||||
[data-sf-theme="purple"] .sender-btn--primary {
|
||||
background: linear-gradient(135deg, #9333ea 0%, #6b21a8 100%);
|
||||
}
|
||||
|
||||
[data-sf-theme="purple"] .sender-btn--primary:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #7e22ce 0%, #581c87 100%);
|
||||
box-shadow: 0 4px 16px rgba(147, 51, 234, 0.35);
|
||||
}
|
||||
|
||||
[data-sf-theme="purple"] .sender-btn:hover:not(:disabled) {
|
||||
border-color: rgba(147, 51, 234, 0.45);
|
||||
box-shadow: 0 4px 12px rgba(147, 51, 234, 0.22);
|
||||
}
|
||||
|
||||
[data-sf-theme="purple"] .sender-tabs {
|
||||
border-bottom-color: rgba(147, 51, 234, 0.12);
|
||||
}
|
||||
|
||||
[data-sf-theme="purple"] .sender-tab--active {
|
||||
color: #9333ea;
|
||||
border-bottom-color: #9333ea;
|
||||
}
|
||||
|
||||
/* ── Blazing Dark theme ────────────────────────────────────────────────────
|
||||
Dark navy background, frosted glass surfaces turn semi-dark */
|
||||
[data-sf-theme="blazing-dark"] .sender-dashboard-layout {
|
||||
background: linear-gradient(135deg, #0a0f1e 0%, #111827 55%, #1e1b4b 100%);
|
||||
}
|
||||
|
||||
[data-sf-theme="blazing-dark"] .sender-action-bar {
|
||||
background: rgba(20, 20, 35, 0.92);
|
||||
border-bottom-color: rgba(99, 102, 241, 0.35);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
[data-sf-theme="blazing-dark"] .sender-title {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
[data-sf-theme="blazing-dark"] .sender-logo svg {
|
||||
color: #818cf8;
|
||||
filter: drop-shadow(0 2px 4px rgba(129, 140, 248, 0.3));
|
||||
}
|
||||
|
||||
[data-sf-theme="blazing-dark"] .sender-btn {
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
[data-sf-theme="blazing-dark"] .sender-btn:hover:not(:disabled) {
|
||||
background: rgba(129, 140, 248, 0.15);
|
||||
border-color: rgba(129, 140, 248, 0.4);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 12px rgba(129, 140, 248, 0.2);
|
||||
}
|
||||
|
||||
[data-sf-theme="blazing-dark"] .sender-btn:disabled {
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
[data-sf-theme="blazing-dark"] .sender-btn--primary {
|
||||
background: linear-gradient(135deg, #6366f1 0%, #4338ca 100%);
|
||||
color: white;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
[data-sf-theme="blazing-dark"] .sender-btn--primary:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #818cf8 0%, #6366f1 100%);
|
||||
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.4);
|
||||
}
|
||||
|
||||
[data-sf-theme="blazing-dark"] .sender-btn--danger {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-color: rgba(239, 68, 68, 0.28);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
[data-sf-theme="blazing-dark"] .sender-btn--danger:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||
color: white;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
[data-sf-theme="blazing-dark"] .sender-grid-container {
|
||||
background: rgba(15, 20, 40, 0.95);
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
[data-sf-theme="blazing-dark"] .sender-grid-container::before {
|
||||
background: linear-gradient(90deg, #6366f1 0%, #4338ca 100%);
|
||||
}
|
||||
|
||||
[data-sf-theme="blazing-dark"] .sender-tabs {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-bottom-color: rgba(99, 102, 241, 0.12);
|
||||
}
|
||||
|
||||
[data-sf-theme="blazing-dark"] .sender-tab {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
[data-sf-theme="blazing-dark"] .sender-tab--active {
|
||||
color: #818cf8;
|
||||
border-bottom-color: #818cf8;
|
||||
}
|
||||
|
||||
[data-sf-theme="blazing-dark"] .sender-tab:hover:not(.sender-tab--active) {
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
/* Error container in dark mode */
|
||||
[data-sf-theme="blazing-dark"] .error-container .alert-danger {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* signFLOW Theme Helper
|
||||
* Called from ThemeService (Blazor WASM) to apply theme changes.
|
||||
*/
|
||||
window.signflowTheme = {
|
||||
|
||||
apply: function (href, bsTheme, themeName) {
|
||||
console.debug('[signflowTheme] apply', themeName, bsTheme);
|
||||
|
||||
// ── Bootstrap dark mode ─────────────────────────────────────────
|
||||
document.documentElement.setAttribute('data-bs-theme', bsTheme);
|
||||
|
||||
// ── Theme name attribute for CSS overrides ──────────────────────
|
||||
// Applied to <html> so any nested selector can use it:
|
||||
// [data-sf-theme="blazing-dark"] .sender-dashboard-layout { ... }
|
||||
document.documentElement.setAttribute('data-sf-theme', themeName);
|
||||
|
||||
// ── DevExpress theme CSS link ────────────────────────────────────
|
||||
document.querySelectorAll('link[href*="DevExpress.Blazor.Themes"]').forEach(function(el) {
|
||||
if (el.id !== 'dx-theme-link') el.parentNode.removeChild(el);
|
||||
});
|
||||
|
||||
let link = document.getElementById('dx-theme-link');
|
||||
if (!link) {
|
||||
link = document.createElement('link');
|
||||
link.id = 'dx-theme-link';
|
||||
link.rel = 'stylesheet';
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
const currentRelative = link.getAttribute('href') || '';
|
||||
if (currentRelative !== href) {
|
||||
link.setAttribute('href', href);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@ async function createAnnotations(document, envelopeId, receiverId) {
|
||||
const id = generateId(envelopeId, receiverId, element.id, 'signature');
|
||||
|
||||
const boundingBox = new PSPDFKit.Geometry.Rect(annotParams.signature);
|
||||
|
||||
|
||||
const annotation = new PSPDFKit.Annotations.WidgetAnnotation({
|
||||
id: id,
|
||||
pageIndex: page,
|
||||
@@ -273,7 +273,7 @@ async function createAnnotationFrameBlob(receiverName, receiverSignature, timest
|
||||
ctx.moveTo(30 * scale, 50 * scale)
|
||||
ctx.lineTo(signatureLength, 50 * scale)
|
||||
|
||||
ctx.strokeStyle = 'darkgray'
|
||||
ctx.strokeStyle = 'darkblue'
|
||||
ctx.stroke()
|
||||
|
||||
ctx.fillStyle = 'black'
|
||||
|
||||
Reference in New Issue
Block a user