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:
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -527,6 +527,7 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@* ── PDF Vorschau Popup ────────────────────────────────────────────────── *@
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user