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>
|
<a href="docs/privacy-policy.de-DE.html" target="_blank" rel="noopener">Datenschutz</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ThemeControls />
|
<div class="receiver-footer__right">
|
||||||
|
<LanguageControls />
|
||||||
|
<ThemeControls />
|
||||||
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ builder.Services.AddScoped<SignatureService>();
|
|||||||
builder.Services.AddScoped<SignatureCacheService>();
|
builder.Services.AddScoped<SignatureCacheService>();
|
||||||
builder.Services.AddSingleton<AppVersionService>();
|
builder.Services.AddSingleton<AppVersionService>();
|
||||||
builder.Services.AddScoped<EnvelopeService>();
|
builder.Services.AddScoped<EnvelopeService>();
|
||||||
|
builder.Services.AddScoped<LocalizationService>();
|
||||||
|
|
||||||
// UI Preferences & Theme
|
// UI Preferences & Theme
|
||||||
builder.Services.AddScoped<UserPreferencesService>();
|
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" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<base href="/" />
|
<base href="/" />
|
||||||
<link rel="stylesheet" href="css/bootstrap/bootstrap.min.css" />
|
<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/app.css" />
|
||||||
<link rel="stylesheet" href="css/envelope-viewer.css" />
|
<link rel="stylesheet" href="css/envelope-viewer.css" />
|
||||||
<link rel="stylesheet" href="EnvelopeGenerator.Server.styles.css" />
|
<link rel="stylesheet" href="EnvelopeGenerator.Server.styles.css" />
|
||||||
|
|||||||
@@ -527,6 +527,7 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@* ── PDF Vorschau Popup ────────────────────────────────────────────────── *@
|
@* ── PDF Vorschau Popup ────────────────────────────────────────────────── *@
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ using Microsoft.AspNetCore.Localization;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
using Microsoft.Extensions.Localization;
|
using Microsoft.Extensions.Localization;
|
||||||
using EnvelopeGenerator.Application.Resources;
|
|
||||||
|
|
||||||
namespace EnvelopeGenerator.Server.Controllers;
|
namespace EnvelopeGenerator.Server.Controllers;
|
||||||
|
|
||||||
@@ -20,7 +19,6 @@ public class LocalizationController : ControllerBase
|
|||||||
|
|
||||||
private readonly ILogger<LocalizationController> _logger;
|
private readonly ILogger<LocalizationController> _logger;
|
||||||
private readonly IStringLocalizer<Resource> _mLocalizer;
|
private readonly IStringLocalizer<Resource> _mLocalizer;
|
||||||
private readonly IStringLocalizer<Resource> _localizer;
|
|
||||||
private readonly IMemoryCache _cache;
|
private readonly IMemoryCache _cache;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -32,14 +30,12 @@ public class LocalizationController : ControllerBase
|
|||||||
/// <param name="_modelLocalizer">Lokalisierungsdienst für Modelle.</param>
|
/// <param name="_modelLocalizer">Lokalisierungsdienst für Modelle.</param>
|
||||||
public LocalizationController(
|
public LocalizationController(
|
||||||
ILogger<LocalizationController> logger,
|
ILogger<LocalizationController> logger,
|
||||||
IStringLocalizer<Resource> localizer,
|
|
||||||
IMemoryCache memoryCache,
|
IMemoryCache memoryCache,
|
||||||
IStringLocalizer<Resource> _modelLocalizer)
|
IStringLocalizer<Resource> modelLocalizer)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_localizer = localizer;
|
|
||||||
_cache = memoryCache;
|
_cache = memoryCache;
|
||||||
_mLocalizer = _modelLocalizer;
|
_mLocalizer = modelLocalizer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -337,6 +337,7 @@ try
|
|||||||
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.UserPreferencesService>();
|
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.UserPreferencesService>();
|
||||||
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.ThemeService>();
|
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.ThemeService>();
|
||||||
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.CustomImagesService>();
|
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.CustomImagesService>();
|
||||||
|
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.LocalizationService>();
|
||||||
|
|
||||||
// Business Services (Server specific)
|
// Business Services (Server specific)
|
||||||
builder.Services.AddScoped<DocumentService>();
|
builder.Services.AddScoped<DocumentService>();
|
||||||
@@ -410,9 +411,14 @@ try
|
|||||||
// Set CORS policy
|
// Set CORS policy
|
||||||
app.UseCors("AllowSpecificOriginsPolicy");
|
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");
|
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
|
var requestLocalizationOptions = new RequestLocalizationOptions
|
||||||
{
|
{
|
||||||
DefaultRequestCulture = new RequestCulture(defaultCulture),
|
DefaultRequestCulture = new RequestCulture(defaultCulture),
|
||||||
@@ -420,6 +426,7 @@ try
|
|||||||
SupportedUICultures = list,
|
SupportedUICultures = list,
|
||||||
RequestCultureProviders = [new CookieRequestCultureProvider()]
|
RequestCultureProviders = [new CookieRequestCultureProvider()]
|
||||||
};
|
};
|
||||||
|
requestLocalizationOptions.ApplyCurrentCultureToResponseHeaders = true;
|
||||||
app.UseRequestLocalization(requestLocalizationOptions);
|
app.UseRequestLocalization(requestLocalizationOptions);
|
||||||
|
|
||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
|
|||||||
@@ -404,6 +404,89 @@ article {
|
|||||||
opacity: 0.4;
|
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 ────────────────────────────────────────────────── */
|
/* ── Footer: Theme Slider ────────────────────────────────────────────────── */
|
||||||
/* 🌙 blazing-dark ──◇──·──◇── purple ☀️ */
|
/* 🌙 blazing-dark ──◇──·──◇── purple ☀️ */
|
||||||
/* Left group: copyright | datenschutz → Right group: slider */
|
/* Left group: copyright | datenschutz → Right group: slider */
|
||||||
|
|||||||
@@ -238,6 +238,7 @@
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.sender-grid-container {
|
.sender-grid-container {
|
||||||
background: rgba(255, 255, 255, 0.98);
|
background: rgba(255, 255, 255, 0.98);
|
||||||
backdrop-filter: blur(20px);
|
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);
|
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 {
|
[data-sf-theme="blazing-dark"] .sender-grid-container::before {
|
||||||
background: linear-gradient(90deg, #6366f1 0%, #4338ca 100%);
|
background: linear-gradient(90deg, #6366f1 0%, #4338ca 100%);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user