From 709fa17e32b6c284863141a4db5cd0de1bfb91eb Mon Sep 17 00:00:00 2001 From: Mark Davis Date: Wed, 15 Jul 2026 12:04:31 -0700 Subject: [PATCH 1/2] Add localized navigation menu to TenTreesTheme Copies Oqtane's Menu/MenuItems control chain into the theme and localizes page names via IStringLocalizer keyed by Page.Name, with fallback to the raw database name when no resource exists. Includes neutral and ts-ZA (Xitsonga) resources reusing reviewed Home tile translations; "Kaya" (Home) is new and needs translation review. Co-Authored-By: Claude Fable 5 --- .../LocalizedMenu.resx | 48 +++++++++ .../LocalizedMenu.ts-ZA.resx | 48 +++++++++ .../Themes/TenTreesTheme/LocalizedMenu.razor | 15 +++ .../Themes/TenTreesTheme/LocalizedMenuBase.cs | 20 ++++ .../LocalizedMenuHorizontal.razor | 16 +++ .../LocalizedMenuItemsHorizontal.razor | 99 +++++++++++++++++++ .../LocalizedMenuItemsVertical.razor | 83 ++++++++++++++++ .../TenTreesTheme/LocalizedMenuVertical.razor | 16 +++ Client/Themes/TenTreesTheme/Theme.razor | 2 +- 9 files changed, 346 insertions(+), 1 deletion(-) create mode 100644 Client/Resources/OpenEug.TenTrees.Theme.TenTreesTheme/LocalizedMenu.resx create mode 100644 Client/Resources/OpenEug.TenTrees.Theme.TenTreesTheme/LocalizedMenu.ts-ZA.resx create mode 100644 Client/Themes/TenTreesTheme/LocalizedMenu.razor create mode 100644 Client/Themes/TenTreesTheme/LocalizedMenuBase.cs create mode 100644 Client/Themes/TenTreesTheme/LocalizedMenuHorizontal.razor create mode 100644 Client/Themes/TenTreesTheme/LocalizedMenuItemsHorizontal.razor create mode 100644 Client/Themes/TenTreesTheme/LocalizedMenuItemsVertical.razor create mode 100644 Client/Themes/TenTreesTheme/LocalizedMenuVertical.razor diff --git a/Client/Resources/OpenEug.TenTrees.Theme.TenTreesTheme/LocalizedMenu.resx b/Client/Resources/OpenEug.TenTrees.Theme.TenTreesTheme/LocalizedMenu.resx new file mode 100644 index 0000000..5ccaf6a --- /dev/null +++ b/Client/Resources/OpenEug.TenTrees.Theme.TenTreesTheme/LocalizedMenu.resx @@ -0,0 +1,48 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Home + + + Enrollment + + + Classes + + + Training + + + Grower + + + Assessment + + + Admin + + + Village + + + Cohort + + + Mentor + + + TreeType + + diff --git a/Client/Resources/OpenEug.TenTrees.Theme.TenTreesTheme/LocalizedMenu.ts-ZA.resx b/Client/Resources/OpenEug.TenTrees.Theme.TenTreesTheme/LocalizedMenu.ts-ZA.resx new file mode 100644 index 0000000..2746db3 --- /dev/null +++ b/Client/Resources/OpenEug.TenTrees.Theme.TenTreesTheme/LocalizedMenu.ts-ZA.resx @@ -0,0 +1,48 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Kaya + + + Nkandziyiso + + + Tiklasi + + + Ku Leha + + + Mulimi + + + Xikambelo + + + Vulawuri + + + Rixaka + + + Cohort + + + Muleriseri + + + Muxaka wa Murhi + + diff --git a/Client/Themes/TenTreesTheme/LocalizedMenu.razor b/Client/Themes/TenTreesTheme/LocalizedMenu.razor new file mode 100644 index 0000000..a1f8274 --- /dev/null +++ b/Client/Themes/TenTreesTheme/LocalizedMenu.razor @@ -0,0 +1,15 @@ +@namespace OpenEug.TenTrees.Theme.TenTreesTheme + +@if (Orientation == "Vertical") +{ + +} +else +{ + +} + +@code { + [Parameter] + public string Orientation { get; set; } = "Horizontal"; +} diff --git a/Client/Themes/TenTreesTheme/LocalizedMenuBase.cs b/Client/Themes/TenTreesTheme/LocalizedMenuBase.cs new file mode 100644 index 0000000..37feffd --- /dev/null +++ b/Client/Themes/TenTreesTheme/LocalizedMenuBase.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Localization; +using Oqtane.Models; +using Oqtane.Themes.Controls; + +namespace OpenEug.TenTrees.Theme.TenTreesTheme +{ + public abstract class LocalizedMenuBase : MenuItemsBase + { + [Inject] + protected IStringLocalizer Localizer { get; set; } + + // IStringLocalizer returns the key itself when no resource exists, + // so unmapped pages fall back to their raw database name. + protected string LocalizeName(Page page) + { + return Localizer[page.Name]; + } + } +} diff --git a/Client/Themes/TenTreesTheme/LocalizedMenuHorizontal.razor b/Client/Themes/TenTreesTheme/LocalizedMenuHorizontal.razor new file mode 100644 index 0000000..2b4d748 --- /dev/null +++ b/Client/Themes/TenTreesTheme/LocalizedMenuHorizontal.razor @@ -0,0 +1,16 @@ +@namespace OpenEug.TenTrees.Theme.TenTreesTheme +@inherits MenuBase + +@if (MenuPages.Any()) +{ + + + +
+
+ +
+
+} diff --git a/Client/Themes/TenTreesTheme/LocalizedMenuItemsHorizontal.razor b/Client/Themes/TenTreesTheme/LocalizedMenuItemsHorizontal.razor new file mode 100644 index 0000000..cbb0baf --- /dev/null +++ b/Client/Themes/TenTreesTheme/LocalizedMenuItemsHorizontal.razor @@ -0,0 +1,99 @@ +@namespace OpenEug.TenTrees.Theme.TenTreesTheme +@inherits LocalizedMenuBase + +@if (ParentPage != null) +{ +
+ @foreach (var childPage in GetChildPages()) + { + var _attributes = new Dictionary(); + _attributes.Add("href", GetUrl(childPage)); + var _target = GetTarget(childPage); + if (!string.IsNullOrEmpty(_target)) + { + _attributes.Add("target", _target); + } + if (childPage.PageId == PageState.Page.PageId) + { + + + + @LocalizeName(childPage) (current) + + + } + else + { + + + + @LocalizeName(childPage) + + + } + } +
+} +else +{ +
    + @foreach (var childPage in GetChildPages()) + { + var _attributes = new Dictionary(); + _attributes.Add("href", GetUrl(childPage)); + var _target = GetTarget(childPage); + if (!string.IsNullOrEmpty(_target)) + { + _attributes.Add("target", _target); + } + if (!Pages.Any(e => e.ParentId == childPage.PageId)) + { + if (childPage.PageId == PageState.Page.PageId) + { +
  • + + + + @LocalizeName(childPage) (current) + + +
  • + } + else + { +
  • + + + + @LocalizeName(childPage) + + +
  • + } + } + else + { + if (childPage.PageId == PageState.Page.PageId) + { +
  • + + + @LocalizeName(childPage) (current) + + +
  • + } + else + { +
  • + + + @LocalizeName(childPage) + + +
  • + } + } + } +
+} diff --git a/Client/Themes/TenTreesTheme/LocalizedMenuItemsVertical.razor b/Client/Themes/TenTreesTheme/LocalizedMenuItemsVertical.razor new file mode 100644 index 0000000..45281d5 --- /dev/null +++ b/Client/Themes/TenTreesTheme/LocalizedMenuItemsVertical.razor @@ -0,0 +1,83 @@ +@namespace OpenEug.TenTrees.Theme.TenTreesTheme +@inherits LocalizedMenuBase + +@if (ParentPage != null) +{ + foreach (var childPage in GetChildPages()) + { + var _attributes = new Dictionary(); + _attributes.Add("href", GetUrl(childPage)); + var _target = GetTarget(childPage); + if (!string.IsNullOrEmpty(_target)) + { + _attributes.Add("target", _target); + } + if (childPage.PageId == PageState.Page.PageId) + { +
  • + + + + @LocalizeName(childPage) (current) + + +
  • + } + else + { +
  • + + + + @LocalizeName(childPage) + + +
  • + } + if (Pages.Any(e => e.ParentId == childPage.PageId)) + { + + } + } +} +else +{ +
      + @foreach (var childPage in GetChildPages()) + { + var _attributes = new Dictionary(); + _attributes.Add("href", GetUrl(childPage)); + var _target = GetTarget(childPage); + if (!string.IsNullOrEmpty(_target)) + { + _attributes.Add("target", _target); + } + if (childPage.PageId == PageState.Page.PageId) + { +
    • + + + + @LocalizeName(childPage) (current) + + +
    • + } + else + { +
    • + + + + @LocalizeName(childPage) + + +
    • + } + if (Pages.Any(e => e.ParentId == childPage.PageId)) + { + + } + } +
    +} diff --git a/Client/Themes/TenTreesTheme/LocalizedMenuVertical.razor b/Client/Themes/TenTreesTheme/LocalizedMenuVertical.razor new file mode 100644 index 0000000..2ae0236 --- /dev/null +++ b/Client/Themes/TenTreesTheme/LocalizedMenuVertical.razor @@ -0,0 +1,16 @@ +@namespace OpenEug.TenTrees.Theme.TenTreesTheme +@inherits MenuBase + +@if (MenuPages.Any()) +{ + + + +
    +
    + +
    +
    +} diff --git a/Client/Themes/TenTreesTheme/Theme.razor b/Client/Themes/TenTreesTheme/Theme.razor index 00541f1..1f018cf 100644 --- a/Client/Themes/TenTreesTheme/Theme.razor +++ b/Client/Themes/TenTreesTheme/Theme.razor @@ -4,7 +4,7 @@
    - +
    From a96b75b2a8a52729c9f39c4841c0d995748986be Mon Sep 17 00:00:00 2001 From: Mark Davis Date: Wed, 15 Jul 2026 13:02:06 -0700 Subject: [PATCH 2/2] Add hand-off notes for continuing localized menu testing Co-Authored-By: Claude Fable 5 --- HANDOFF.md | 100 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 HANDOFF.md diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..1f1a89c --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,100 @@ +# Hand-off: Localized Navigation Menu (feature/localized-menu) + +Session hand-off for continuing work on another machine. Written 2026-07-15. +Delete this file before merging the branch. + +## Goal + +Localize the top navigation menu (page names come from the `Page` table in the +database, so Oqtane's built-in menu shows raw English names regardless of +culture). Approach: a copy of Oqtane's menu control chain that looks up each +`Page.Name` as a resx key via `IStringLocalizer`, falling back to the raw name +when no key exists. This mirrors the framework's own precedent +(`Oqtane.Client\Modules\Admin\Dashboard\Index.razor` localizes admin page names +against `SharedResources` using the name as key). + +Stretch goal (deferred): upstream to oqtane/oqtane.framework — inject +`IStringLocalizer` into `MenuItemsBase` and render +`SharedLocalizer[childPage.Name]`. Zero behavior change when no resource +exists. Process: issue first, fork, branch from `dev`, PR to `dev` per +CONTRIBUTING.md. + +## What's on this branch (commit 709fa17) + +All new files in `Client\Themes\TenTreesTheme\`, namespace +`OpenEug.TenTrees.Theme.TenTreesTheme`: + +| File | Role | +|---|---| +| `LocalizedMenuBase.cs` | Inherits Oqtane's `MenuItemsBase`; injects `IStringLocalizer`; `LocalizeName(page)` returns `Localizer[page.Name]` (missing key ⇒ raw name) | +| `LocalizedMenu.razor` | Orientation dispatcher (`Horizontal`/`Vertical`), compile-time switch | +| `LocalizedMenuHorizontal.razor` / `LocalizedMenuVertical.razor` | Verbatim copies of stock `MenuHorizontal/Vertical` (inherit Oqtane `MenuBase`), pointing at the Localized items components | +| `LocalizedMenuItemsHorizontal.razor` / `LocalizedMenuItemsVertical.razor` | Verbatim copies of stock items components; only change: `@childPage.Name` → `@LocalizeName(childPage)` | + +Resources (resolve via full-namespace folder because the Client project's root +namespace doesn't prefix the theme namespace): + +- `Client\Resources\OpenEug.TenTrees.Theme.TenTreesTheme\LocalizedMenu.resx` +- `Client\Resources\OpenEug.TenTrees.Theme.TenTreesTheme\LocalizedMenu.ts-ZA.resx` + +Keys: `Home, Enrollment, Classes, Training, Grower, Assessment, Admin, +Village, Cohort, Mentor, TreeType`. ts-ZA values reuse the reviewed Home-tile +strings (Nkandziyiso, Tiklasi, Ku Leha, Mulimi, Xikambelo, Vulawuri, Rixaka, +Muleriseri, Muxaka wa Murhi). **"Kaya" (Home) is a new, unreviewed translation +— flag for translation review.** + +Wire-up: `Client\Themes\TenTreesTheme\Theme.razor` line 7 — +`` (was ``). + +## Test status + +Done: +- `dotnet build` clean (0 errors). +- Menu structure/fallback verified — but only under the **default Oqtane + theme** (the dev site wasn't set to TenTreesTheme at the time), so the + localized control itself has NOT yet rendered successfully end-to-end. + +Not done (the actual test plan): +1. Site set to TenTreesTheme, English: menu identical to before (Admin + dropdown, active-page `(current)` marker, mobile toggler). +2. Switch to Xitsonga via the theme's LanguagePicker (full reload): navbar + shows Kaya / Nkandziyiso / Tiklasi / Mulimi / Xikambelo / Vulawuri etc. +3. Fallback: any page without a matching key shows its raw DB name. +4. Keyboard nav + aria attributes survived the copy. + +## Known issues / gotchas + +1. **Key = exact `Page.Name`, case-sensitive.** The Surf7 dev DB has a page + literally named `training` (lowercase) — it falls back to raw "training" + because the key is `Training`. Fix by renaming the page or adding a + lowercase key. Check the target DB's names first: + `SELECT Name, Path FROM [Page] WHERE IsNavigation = 1`. +2. **White screen on Surf7 when TenTreesTheme is selected — NOT caused by this + branch.** Server prerender dies with `NullReferenceException` at + `OqtaneLocalizationExtensions.Create` ← `LocalizableComponent.OnParametersSet` + ← `Oqtane.Modules.Controls.Label.OnParametersSet` — i.e. some component + passes a `ResourceType` string that `Type.GetType()` can't resolve. No + TenTrees menu frames in the stack; none of the new files use `Label`. + Suspected stale type registration in that machine's ancient `Oqtane-TenTrees` + LocalDB. Response is HTTP 200 with a zero-length body; the real stack trace + is in the Oqtane `Log` table (`SELECT TOP 5 * FROM [Log] ORDER BY LogId DESC`). + If the theme renders fine on this PC's DB, that diagnosis is confirmed. +3. **Connection strings are per-machine.** `appsettings.json` keeps named + variants (`DefaultConnectionSurf7`, `DefaultConnectionFold`); the active + `DefaultConnection` swap is intentionally NOT committed. Set it locally. +4. This dev DB had no `Classes` or `Admin` navigation page (production does, + per the Discord screenshot) — those resx keys are dormant until tested + against a site that has them. + +## Reference notes (saves re-research) + +- Framework reference clone: `.oqtane-ref\` (git-ignored). Menu chain: + `.oqtane-ref\Oqtane.Client\Themes\Controls\Theme\Menu*.razor|cs`. +- `MenuBase.MenuPages` already applies `IsNavigation` + view-permission + filtering; `GetUrl`/`GetTarget` handle external URLs and non-clickable pages. +- Culture switching (theme `LanguagePicker.razor`) persists `User.CultureCode` + for logged-in users / sets the `.AspNetCore.Culture` cookie for anonymous, + then full-reloads — so the menu re-renders in the new culture with no cache + concerns. +- `Page` model has no localization support at all (verified `Page.cs` incl. + `Clone()`); menu text is always `Page.Name`, browser tab is `Page.Title`.