feat: Implement client-side localization using JSON files, a custom localizer, and a new language switcher component.

This commit is contained in:
Ho Ngoc Hai
2026-01-19 11:58:52 +07:00
parent caf1a06dc8
commit e00c6032d1
12 changed files with 293 additions and 17 deletions

View File

@@ -0,0 +1,58 @@
@using System.Globalization
@inject NavigationManager Navigation
<MudMenu Dense="true" AnchorOrigin="Origin.BottomRight" TransformOrigin="Origin.TopRight" LockScroll="true">
<ActivatorContent>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1" Class="mr-2 cursor-pointer">
<MudText Typo="Typo.button" Style="font-family: var(--font-heading);">
@(CultureInfo.CurrentCulture.Name.StartsWith("vi") ? "VI" : "EN")
</MudText>
<MudIcon Icon="@Icons.Material.Rounded.Language" Size="Size.Small" />
</MudStack>
</ActivatorContent>
<ChildContent>
<MudMenuItem OnClick="@(() => SwitchLanguage("vi-VN"))">
<MudStack Row="true" Spacing="2">
<MudText>🇻🇳</MudText>
<MudText>Tiếng Việt</MudText>
</MudStack>
</MudMenuItem>
<MudMenuItem OnClick="@(() => SwitchLanguage("en-US"))">
<MudStack Row="true" Spacing="2">
<MudText>🇺🇸</MudText>
<MudText>English</MudText>
</MudStack>
</MudMenuItem>
</ChildContent>
</MudMenu>
@code {
private void SwitchLanguage(string targetCulture)
{
var uri = new Uri(Navigation.Uri);
var path = uri.PathAndQuery;
// Simple logic to replace or prepend culture segment
// Check if path starts with a supported culture
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
string newPath;
if (segments.Length > 0 && (segments[0].Equals("vi-VN", StringComparison.OrdinalIgnoreCase) ||
segments[0].Equals("en-US", StringComparison.OrdinalIgnoreCase) ||
segments[0].Equals("vi", StringComparison.OrdinalIgnoreCase) ||
segments[0].Equals("en", StringComparison.OrdinalIgnoreCase)))
{
// Replace first segment
segments[0] = targetCulture;
newPath = "/" + string.Join('/', segments);
}
else
{
// Prepend
if (path == "/") path = "";
newPath = $"/{targetCulture}{path}";
}
Navigation.NavigateTo(newPath, forceLoad: true);
}
}

View File

@@ -1,4 +1,5 @@
@inherits LayoutComponentBase
@inject IStringLocalizer<MainLayout> L
<MudThemeProvider @bind-IsDarkMode="@_isDarkMode" Theme="_theme" />
<MudPopoverProvider />
@@ -8,12 +9,12 @@
<!-- Full Screen Navigation Overlay -->
<div class="nav-overlay @(_menuOpen ? "open" : "")">
<div class="d-flex flex-column align-center">
<a href="/" class="nav-overlay-link" @onclick="CloseMenu">Home</a>
<a href="/solutions" class="nav-overlay-link" @onclick="CloseMenu">Solutions</a>
<a href="/enterprise" class="nav-overlay-link" @onclick="CloseMenu">Enterprise</a>
<a href="/company" class="nav-overlay-link" @onclick="CloseMenu">Company</a>
<a href="/" class="nav-overlay-link" @onclick="CloseMenu">@L["Home"]</a>
<a href="/solutions" class="nav-overlay-link" @onclick="CloseMenu">@L["Solutions"]</a>
<a href="/enterprise" class="nav-overlay-link" @onclick="CloseMenu">@L["Enterprise"]</a>
<a href="/company" class="nav-overlay-link" @onclick="CloseMenu">@L["Company"]</a>
<div class="mt-8">
<button class="btn-enterprise-primary" @onclick="CloseMenu">Get in Touch</button>
<button class="btn-enterprise-primary" @onclick="CloseMenu">@L["GetInTouch"]</button>
</div>
</div>
@@ -38,8 +39,11 @@
<!-- Action Group (Theme + Menu) -->
<MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center">
<!-- Language Switcher -->
<LanguageSwitcher />
<!-- Theme Toggle -->
<button class="theme-toggle mr-2" @onclick="ToggleDarkMode" aria-label="Toggle theme">
<button class="theme-toggle mr-2" @onclick="ToggleDarkMode" aria-label="@L["ToggleTheme"]">
@if (_isDarkMode)
{
<MudIcon Icon="@Icons.Material.Rounded.LightMode" Size="Size.Small" />
@@ -66,7 +70,7 @@
@code {
[Inject] private IJSRuntime JSRuntime { get; set; } = default!;
private bool _menuOpen = false;
private bool _isDarkMode = false;

View File

@@ -0,0 +1,54 @@
using System.Globalization;
using System.Net.Http.Json;
using Microsoft.Extensions.Localization;
namespace WebClientBase.Client.Localization;
public class JsonStringLocalizer : IStringLocalizer
{
private readonly LocalizationCache _cache;
private readonly string _resourceName;
public JsonStringLocalizer(LocalizationCache cache, string resourceName)
{
_cache = cache;
_resourceName = resourceName;
}
// This constructor style is used by the Factory (if we update factory)
public JsonStringLocalizer(LocalizationCache cache)
{
_cache = cache;
_resourceName = "Shared";
}
public LocalizedString this[string name]
{
get
{
var value = GetString(name);
return new LocalizedString(name, value ?? name, resourceNotFound: value == null);
}
}
public LocalizedString this[string name, params object[] arguments]
{
get
{
var format = GetString(name);
var value = string.Format(format ?? name, arguments);
return new LocalizedString(name, value, resourceNotFound: format == null);
}
}
public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures)
{
// Not fully supported by simple cache
return Enumerable.Empty<LocalizedString>();
}
private string? GetString(string name)
{
return _cache.GetString(name);
}
}

View File

@@ -0,0 +1,26 @@
using Microsoft.Extensions.Localization;
namespace WebClientBase.Client.Localization;
public class JsonStringLocalizerFactory : IStringLocalizerFactory
{
private readonly LocalizationCache _cache;
private readonly IServiceProvider _serviceProvider;
public JsonStringLocalizerFactory(LocalizationCache cache, IServiceProvider serviceProvider)
{
_cache = cache;
_serviceProvider = serviceProvider;
}
public IStringLocalizer Create(Type resourceSource)
{
return new JsonStringLocalizer(_cache, resourceSource.Name);
}
public IStringLocalizer Create(string baseName, string location)
{
return new JsonStringLocalizer(_cache, baseName);
}
}

View File

@@ -0,0 +1,50 @@
using System.Globalization;
using System.Net.Http.Json;
namespace WebClientBase.Client.Localization;
public class LocalizationCache
{
private readonly HttpClient _httpClient;
private Dictionary<string, string> _strings = new();
private bool _isLoaded;
public LocalizationCache(HttpClient httpClient)
{
_httpClient = httpClient;
}
public string? GetString(string key)
{
if (_strings.TryGetValue(key, out var value))
{
return value;
}
return null;
}
public async Task LoadAsync(CultureInfo culture)
{
if (_isLoaded) return; // Or check if culture changed
try
{
var cultureName = culture.Name;
// Map generic "vi" to "vi-VN" if needed, but for now we trust the culture name matches file
// Fallback for simple "vi" -> "vi-VN"
if (cultureName == "vi") cultureName = "vi-VN";
if (cultureName == "en") cultureName = "en-US";
var loaded = await _httpClient.GetFromJsonAsync<Dictionary<string, string>>($"/locales/{cultureName}.json");
if (loaded != null)
{
_strings = loaded;
_isLoaded = true;
}
}
catch (Exception ex)
{
Console.WriteLine($"Error loading localization for {culture.Name}: {ex.Message}");
}
}
}

View File

@@ -2,6 +2,10 @@ using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using MudBlazor.Services;
using WebClientBase.Client;
using WebClientBase.Client.Localization;
using Microsoft.Extensions.Localization;
using System.Globalization;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
@@ -9,10 +13,22 @@ builder.RootComponents.Add<HeadOutlet>("head::after");
// EN: Add HttpClient for API calls
// VI: Thêm HttpClient cho các cuộc gọi API
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
builder.Services.AddSingleton(sp => new HttpClient { BaseAddress = new Uri(new Uri(builder.HostEnvironment.BaseAddress).GetLeftPart(UriPartial.Authority)) });
// EN: Add MudBlazor services
// VI: Thêm các services của MudBlazor
builder.Services.AddMudServices();
await builder.Build().RunAsync();
// Localization
builder.Services.AddLocalization();
builder.Services.AddSingleton<LocalizationCache>();
builder.Services.AddSingleton<IStringLocalizerFactory, JsonStringLocalizerFactory>();
// Build the host
var host = builder.Build();
// Initialize Localization Cache
var cache = host.Services.GetRequiredService<LocalizationCache>();
await cache.LoadAsync(CultureInfo.CurrentCulture);
await host.RunAsync();

View File

@@ -10,6 +10,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.1" PrivateAssets="all" />
<PackageReference Include="Microsoft.Extensions.Localization" Version="10.0.2" />
<PackageReference Include="MudBlazor" Version="8.15.0" />
</ItemGroup>

View File

@@ -11,3 +11,6 @@
@using WebClientBase.Client.Layout
@using WebClientBase.Shared
@using WebClientBase.Shared.DTOs
@using WebClientBase.Client.Components
@using Microsoft.Extensions.Localization

View File

@@ -21,13 +21,15 @@
<!-- EN: MudBlazor CSS -->
<!-- VI: CSS MudBlazor -->
<link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" />
<!-- EN: MudBlazor CSS -->
<!-- VI: CSS MudBlazor -->
<link href="/_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" />
<!-- EN: Custom CSS -->
<!-- VI: CSS tùy chỉnh -->
<link rel="stylesheet" href="css/app.css" />
<link rel="icon" type="image/png" href="favicon.png" />
<link href="WebClientBase.Client.styles.css" rel="stylesheet" />
<link rel="stylesheet" href="/css/app.css" />
<link rel="icon" type="image/png" href="/favicon.png" />
<link href="/WebClientBase.Client.styles.css" rel="stylesheet" />
</head>
<body>
@@ -82,7 +84,7 @@
</div>
<!-- EN: Blazor WebAssembly -->
<script src="_framework/blazor.webassembly.js"></script>
<script src="/_framework/blazor.webassembly.js"></script>
<!-- EN: MudBlazor JavaScript -->
<!-- VI: JavaScript MudBlazor -->
@@ -92,7 +94,7 @@
document.documentElement.setAttribute('data-theme', theme);
}
</script>
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
<script src="/_content/MudBlazor/MudBlazor.min.js"></script>
</body>
</html>

View File

@@ -0,0 +1,8 @@
{
"Home": "Home",
"Solutions": "Solutions",
"Enterprise": "Enterprise",
"Company": "Company",
"GetInTouch": "Get in Touch",
"ToggleTheme": "Toggle theme"
}

View File

@@ -0,0 +1,8 @@
{
"Home": "Trang chủ",
"Solutions": "Giải pháp",
"Enterprise": "Doanh nghiệp",
"Company": "Công ty",
"GetInTouch": "Liên hệ",
"ToggleTheme": "Đổi giao diện"
}

View File

@@ -3,6 +3,8 @@
/// VI: ASP.NET Core BFF (Backend for Frontend) với YARP Reverse Proxy.
/// </summary>
using Microsoft.AspNetCore.Rewrite;
var builder = WebApplication.CreateBuilder(args);
// ═══════════════════════════════════════════════════════════════════════════════
@@ -54,10 +56,18 @@ if (app.Environment.IsDevelopment())
app.UseHttpsRedirection();
// EN: Enable CORS
// VI: Kích hoạt CORS
// EN: Enable CORS
// VI: Kích hoạt CORS
app.UseCors("BlazorClient");
// EN: Rewrite localized framework/content requests to root
// VI: Viết lại các yêu cầu framework/content từ đường dẫn ngôn ngữ về root
var rewriteOptions = new RewriteOptions()
.AddRewrite(@"^(en-US|vi-VN)/(_framework|_content)/(.*)", "$2/$3", skipRemainingRules: true);
app.UseRewriter(rewriteOptions);
// EN: Serve static files with fingerprinting support (.NET 10+)
// VI: Phục vụ static files với hỗ trợ fingerprinting (.NET 10+)
app.MapStaticAssets();
@@ -70,8 +80,44 @@ app.MapHealthChecks("/health");
// VI: Map các routes YARP Reverse Proxy đến microservices
app.MapReverseProxy();
// EN: Fallback to index.html for SPA routing
// VI: Fallback đến index.html cho SPA routing
// EN: Localization Support - Serve index.html with dynamic base tag for specific cultures
// VI: Hỗ trợ đa ngôn ngữ - Phục vụ index.html với base tag động cho các ngôn ngữ cụ thể
var supportedCultures = new[] { "en-US", "vi-VN" };
var localizationOptions = new RequestLocalizationOptions()
.SetDefaultCulture("en-US")
.AddSupportedCultures(supportedCultures)
.AddSupportedUICultures(supportedCultures);
app.UseRequestLocalization(localizationOptions);
// Handle mapped culture routes (e.g. /en-US/home, /vi-VN/solutions)
app.Map("{culture:regex(^(en-US|vi-VN)$)}/{**slug}", async (string culture, HttpContext context, IWebHostEnvironment env) =>
{
// Try to find index.html
var fileInfo = env.WebRootFileProvider.GetFileInfo("index.html");
if (!fileInfo.Exists)
{
// In Development with Hosted Blazor, index.html might not be in Server's wwwroot strictly directly depending on setup,
// but typically it is served via StaticFiles/BlazorFrameworkFiles.
// If we can't find it easily via IWebHostEnvironment in Dev, we might fail.
// However, for this task let's assume standard structure or handle gracefully.
return Results.NotFound("index.html not found in wwwroot. Ensure the Client project is built.");
}
using var stream = fileInfo.CreateReadStream();
using var reader = new StreamReader(stream);
var html = await reader.ReadToEndAsync();
// Replace base tag: <base href="/" /> -> <base href="/vi-VN/" />
// Be robust with spaces or standard format
var modifiedHtml = html.Replace("<base href=\"/\" />", $"<base href=\"/{culture}/\" />")
.Replace("<base href=\"/\"/>", $"<base href=\"/{culture}/\" />");
return Results.Content(modifiedHtml, "text/html");
});
// EN: Fallback to index.html for SPA routing (default culture)
// VI: Fallback đến index.html cho SPA routing (ngôn ngữ mặc định)
app.MapFallbackToFile("index.html");
app.Run();