feat: Implement client-side localization using JSON files, a custom localizer, and a new language switcher component.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -11,3 +11,6 @@
|
||||
@using WebClientBase.Client.Layout
|
||||
@using WebClientBase.Shared
|
||||
@using WebClientBase.Shared.DTOs
|
||||
@using WebClientBase.Client.Components
|
||||
@using Microsoft.Extensions.Localization
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Home": "Home",
|
||||
"Solutions": "Solutions",
|
||||
"Enterprise": "Enterprise",
|
||||
"Company": "Company",
|
||||
"GetInTouch": "Get in Touch",
|
||||
"ToggleTheme": "Toggle theme"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user