Add multilingual support with language selector

Introduced a `LanguageControls` component for switching between
languages (`de-DE`, `en-US`, `fr-FR`) and integrated a new
`LocalizationService` to manage language preferences. Updated
`LocalizationController` to handle language persistence via
cookies and support multiple cultures. Enhanced the footer
layout to include the language selector alongside theme
controls.

Added `flag-icons` library for displaying country flags and
configured request localization in `Program.cs`. Styled the
language selector dropdown and improved sender page visuals
with updated CSS. Defaulted language handling to `de-DE` for
fallback scenarios. Cleaned up redundant code in
`LocalizationController`.
This commit is contained in:
2026-09-28 09:19:46 +02:00
parent 5aaa37b8ec
commit 0d2e43b2d6
10 changed files with 218 additions and 10 deletions

View File

@@ -0,0 +1,84 @@
@using EnvelopeGenerator.Server.Client.Services
@rendermode InteractiveWebAssembly
@inject LocalizationService LocalizationService
@inject NavigationManager Navigation
<div class="sf-lang-combo">
<button type="button"
class="sf-lang-trigger"
@onclick="ToggleMenu"
aria-label="Language"
aria-expanded="@_isOpen">
<span class="fi @_selected.FlagClass"></span>
</button>
@if (_isOpen)
{
<button type="button"
class="sf-lang-backdrop"
aria-label="Close language menu"
@onclick="CloseMenu">
</button>
<div class="sf-lang-menu" role="listbox" aria-label="Languages">
@foreach (var option in _options)
{
<button type="button"
class="sf-lang-option @(_selected.Code == option.Code ? "sf-lang-option--active" : string.Empty)"
role="option"
aria-selected="@(_selected.Code == option.Code)"
@onmousedown:preventDefault="true"
@onclick="() => SelectLanguageAsync(option)">
<span class="fi @option.FlagClass"></span>
<span>@option.ShortName</span>
</button>
}
</div>
}
</div>
@code {
private readonly IReadOnlyList<LanguageOption> _options =
[
new("de-DE", "fi-de", "DE"),
new("en-US", "fi-us", "EN"),
new("fr-FR", "fi-fr", "FR")
];
private LanguageOption _selected = new("de-DE", "fi-de", "DE");
private bool _isOpen;
protected override async Task OnInitializedAsync()
{
var currentLanguage = await LocalizationService.GetLanguageAsync();
_selected = _options.FirstOrDefault(x => x.Code.Equals(currentLanguage, StringComparison.OrdinalIgnoreCase))
?? _options[0];
}
private void ToggleMenu()
{
_isOpen = !_isOpen;
}
private void CloseMenu()
{
_isOpen = false;
}
private async Task SelectLanguageAsync(LanguageOption option)
{
if (_selected.Code.Equals(option.Code, StringComparison.OrdinalIgnoreCase))
{
_isOpen = false;
return;
}
_selected = option;
_isOpen = false;
await LocalizationService.SetLanguageAsync(option.Code);
Navigation.NavigateTo(Navigation.Uri, forceLoad: true);
}
private sealed record LanguageOption(string Code, string FlagClass, string ShortName);
}

View File

@@ -21,7 +21,10 @@
<a href="docs/privacy-policy.de-DE.html" target="_blank" rel="noopener">Datenschutz</a>
</div>
<ThemeControls />
<div class="receiver-footer__right">
<LanguageControls />
<ThemeControls />
</div>
</footer>
</div>

View File

@@ -33,6 +33,7 @@ builder.Services.AddScoped<SignatureService>();
builder.Services.AddScoped<SignatureCacheService>();
builder.Services.AddSingleton<AppVersionService>();
builder.Services.AddScoped<EnvelopeService>();
builder.Services.AddScoped<LocalizationService>();
// UI Preferences & Theme
builder.Services.AddScoped<UserPreferencesService>();

View File

@@ -0,0 +1,30 @@
using System.Net;
namespace EnvelopeGenerator.Server.Client.Services;
public class LocalizationService(IHttpClientFactory httpClientFactory)
{
private HttpClient CreateDefaultClient() => httpClientFactory.CreateClient("EnvelopeGenerator.Server");
public async Task<string> GetLanguageAsync(CancellationToken cancel = default)
{
using var http = CreateDefaultClient();
var response = await http.GetAsync("/api/Localization/lang", cancel);
if (response.StatusCode == HttpStatusCode.NotFound)
return "de-DE";
if (!response.IsSuccessStatusCode)
return "de-DE";
var language = await response.Content.ReadAsStringAsync(cancel);
return string.IsNullOrWhiteSpace(language) ? "de-DE" : language.Trim('"');
}
public async Task SetLanguageAsync(string language, CancellationToken cancel = default)
{
using var http = CreateDefaultClient();
var response = await http.PostAsync($"/api/Localization/lang?language={Uri.EscapeDataString(language)}", null, cancel);
response.EnsureSuccessStatusCode();
}
}

View File

@@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<link rel="stylesheet" href="css/bootstrap/bootstrap.min.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flag-icons@7.2.3/css/flag-icons.min.css" />
<link rel="stylesheet" href="css/app.css" />
<link rel="stylesheet" href="css/envelope-viewer.css" />
<link rel="stylesheet" href="EnvelopeGenerator.Server.styles.css" />

View File

@@ -527,6 +527,7 @@
</div>
}
</div>
</div>
@* ── PDF Vorschau Popup ────────────────────────────────────────────────── *@

View File

@@ -4,7 +4,6 @@ using Microsoft.AspNetCore.Localization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Localization;
using EnvelopeGenerator.Application.Resources;
namespace EnvelopeGenerator.Server.Controllers;
@@ -20,7 +19,6 @@ public class LocalizationController : ControllerBase
private readonly ILogger<LocalizationController> _logger;
private readonly IStringLocalizer<Resource> _mLocalizer;
private readonly IStringLocalizer<Resource> _localizer;
private readonly IMemoryCache _cache;
/// <summary>
@@ -32,14 +30,12 @@ public class LocalizationController : ControllerBase
/// <param name="_modelLocalizer">Lokalisierungsdienst für Modelle.</param>
public LocalizationController(
ILogger<LocalizationController> logger,
IStringLocalizer<Resource> localizer,
IMemoryCache memoryCache,
IStringLocalizer<Resource> _modelLocalizer)
IStringLocalizer<Resource> modelLocalizer)
{
_logger = logger;
_localizer = localizer;
_cache = memoryCache;
_mLocalizer = _modelLocalizer;
_mLocalizer = modelLocalizer;
}
/// <summary>

View File

@@ -337,6 +337,7 @@ try
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.UserPreferencesService>();
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.ThemeService>();
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.CustomImagesService>();
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.LocalizationService>();
// Business Services (Server specific)
builder.Services.AddScoped<DocumentService>();
@@ -410,9 +411,14 @@ try
// Set CORS policy
app.UseCors("AllowSpecificOriginsPolicy");
// Localization (fixed to German for now; persisted via cookie)
// Localization (default German; can be switched via cookie)
var defaultCulture = new CultureInfo("de-DE");
IList<CultureInfo> list = [defaultCulture];
IList<CultureInfo> list =
[
defaultCulture,
new CultureInfo("en-US"),
new CultureInfo("fr-FR")
];
var requestLocalizationOptions = new RequestLocalizationOptions
{
DefaultRequestCulture = new RequestCulture(defaultCulture),
@@ -420,6 +426,7 @@ try
SupportedUICultures = list,
RequestCultureProviders = [new CookieRequestCultureProvider()]
};
requestLocalizationOptions.ApplyCurrentCultureToResponseHeaders = true;
app.UseRequestLocalization(requestLocalizationOptions);
app.UseHttpsRedirection();

View File

@@ -404,6 +404,89 @@ article {
opacity: 0.4;
}
.receiver-footer__right {
display: inline-flex;
align-items: center;
gap: 12px;
}
.sf-lang-combo {
position: relative;
display: inline-flex;
align-items: center;
}
.sf-lang-trigger {
width: 28px;
height: 20px;
border: 1px solid rgba(255, 255, 255, 0.28);
border-radius: 5px;
background: rgba(255, 255, 255, 0.08);
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.sf-lang-trigger:hover {
background: rgba(255, 255, 255, 0.16);
}
.sf-lang-menu {
position: absolute;
right: 0;
bottom: 28px;
display: flex;
flex-direction: column;
gap: 4px;
min-width: 64px;
padding: 6px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.18);
background: rgba(29, 38, 55, 0.98);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.3);
z-index: 110;
}
.sf-lang-backdrop {
position: fixed;
inset: 0;
border: 0;
background: transparent;
padding: 0;
margin: 0;
cursor: default;
z-index: 105;
}
.sf-lang-option {
display: inline-flex;
align-items: center;
gap: 6px;
border: 0;
border-radius: 6px;
background: transparent;
color: rgba(255, 255, 255, 0.88);
font-size: 0.68rem;
font-weight: 600;
padding: 4px 6px;
cursor: pointer;
}
.sf-lang-option:hover,
.sf-lang-option--active {
background: rgba(255, 255, 255, 0.14);
}
.sf-lang-trigger .fi,
.sf-lang-option .fi {
width: 16px;
height: 12px;
border-radius: 2px;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.25) inset;
}
/* ── Footer: Theme Slider ────────────────────────────────────────────────── */
/* 🌙 blazing-dark ──◇──·──◇── purple ☀️ */
/* Left group: copyright | datenschutz → Right group: slider */
@@ -613,4 +696,4 @@ article {
[data-sf-theme="blazing-dark"] .login-card .text-muted {
color: rgba(255, 255, 255, 0.50) !important;
}
}

View File

@@ -238,6 +238,7 @@
overflow: auto;
}
.sender-grid-container {
background: rgba(255, 255, 255, 0.98);
backdrop-filter: blur(20px);
@@ -690,6 +691,7 @@
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%);
}