From 8d63c9a9b3a275a86e393e098337046e5854cd35 Mon Sep 17 00:00:00 2001 From: Marcus Kempe Date: Fri, 25 Sep 2026 20:33:52 +0200 Subject: [PATCH 1/2] Rework the event selector for phones and move Eventor keys to Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Eventor API key is a club-wide credential, but it was entered from the selector's import panel on publicProcedure mutations, so any invited user could replace or clear it. validateKey / setKey / clearKey are now adminProcedure and live on a new admin-only Settings → Eventor tab (EventorKeysPanel, one card per environment). keyStatus / getKey become authedProcedure so the import panel and the registration dialog can still tell whether a key exists. With no key, the import panel links admins to the tab and tells members to ask an admin. Selector layout: - user chip + language switcher move from the header to the footer - "Select an event to manage" tagline is desktop-only - search gets its own row on phones; the type setApiKey(e.target.value)} + placeholder={t("apiKeyPlaceholder")} + autoComplete="off" + className="w-full px-4 py-2.5 border border-slate-200 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +
+ + {replacing && ( + + )} +
+ {validate.isError && ( +

{validate.error.message}

+ )} + + ) : ( +
+ + +
+ )} + {clear.isError && ( +

{clear.error.message}

+ )} + + ); +} diff --git a/packages/web/src/i18n/locales/en/event.json b/packages/web/src/i18n/locales/en/event.json index dff2d70..c8235c2 100644 --- a/packages/web/src/i18n/locales/en/event.json +++ b/packages/web/src/i18n/locales/en/event.json @@ -41,6 +41,7 @@ "noMatches": "No events match the current search.", "clearFilters": "Clear filters", "typeFilterAll": "All types", + "filterMine": "My events", "typeUnclassified": "Unclassified", "eventType": "Event type", "eventTypeHelp": "This Oxygen label is editable and is not overwritten by later Eventor syncs.", @@ -67,14 +68,21 @@ "classification6": "International", "production": "Production", "testEventor": "Test-Eventor", - "apiKeyStep": "1. API Key", - "selectImportStep": "2. Select & Import", "apiKeyPrompt": "Enter your club's Eventor API key to browse and import events.", "apiKeyPlaceholder": "API key (e.g. df34af90...)", "validating": "Validating...", "connect": "Connect", "connectedTo": "Connected: {{name}}", "changeKey": "Change key", + "manageKeys": "Manage keys", + "importNoKey": "No Eventor API key is configured for this environment.", + "importNoKeyAdmin": "Add one under Settings → Eventor", + "importNoKeyMember": "Ask an instance admin to add it under Settings → Eventor.", + "eventorKeysHelp": "Club-wide Eventor API keys, shared by every event on this instance. The production key is used for real events; the Test-Eventor key for rehearsals. Keys are validated against Eventor before they are stored.", + "eventorKeyConfigured": "Key configured", + "eventorKeyNotConfigured": "No key configured", + "eventorKeyClear": "Remove key", + "eventorKeyClearing": "Removing...", "searchEvents": "Search events...", "loadingEvents": "Loading events from Eventor...", "noEventsFound": "No events found", @@ -159,7 +167,7 @@ "pushResultsComplete": "Uploaded results for {{count}} runners to Test-Eventor.", "pushStartListFailed": "Push start list failed: {{message}}", "pushResultsFailed": "Push results failed: {{message}}", - "eventorApiKeyNotConfigured": "Eventor API key not configured. Go to the event selector and connect your key to enable sync.", + "eventorApiKeyNotConfigured": "Eventor API key not configured. An instance admin can add it under Settings → Eventor to enable sync.", "runnerDatabase": "Runner Database", "runnerDatabaseGlobal": "Global", "runnerDbStats": "{{runners}} runners, {{clubs}} clubs", diff --git a/packages/web/src/i18n/locales/en/library.json b/packages/web/src/i18n/locales/en/library.json index 1651036..c46be38 100644 --- a/packages/web/src/i18n/locales/en/library.json +++ b/packages/web/src/i18n/locales/en/library.json @@ -4,6 +4,7 @@ "tabMaps": "Base maps", "tabControls": "Controls", "tabUsers": "Users", + "tabEventor": "Eventor", "tabMaintenance": "Maintenance", "mapsHeading": "Base maps", "mapsHelp": "Upload OCAD maps once for the club. When you set up an event, pick a map from this list — Oxygen copies it into the event.", diff --git a/packages/web/src/i18n/locales/en/trends.json b/packages/web/src/i18n/locales/en/trends.json index d1ef25f..6ad7db0 100644 --- a/packages/web/src/i18n/locales/en/trends.json +++ b/packages/web/src/i18n/locales/en/trends.json @@ -36,7 +36,7 @@ "classificationInternational": "International", "loadingEvents": "Looking up events on Eventor…", "noEventsFound": "No events match those filters.", - "noKeyConfigured": "Connect an Eventor API key to enable comparison.", + "noKeyConfigured": "An instance admin must add an Eventor API key under Settings → Eventor to enable comparison.", "addByIdHeading": "Add by Eventor event ID or URL", "addByIdHelp": "Paste an Eventor event ID (e.g. 12345) or a full URL like eventor.orientering.se/Events/Show/12345. Useful when you already know the event you want to compare against.", "addByIdPlaceholder": "Event ID or URL", diff --git a/packages/web/src/i18n/locales/sv/event.json b/packages/web/src/i18n/locales/sv/event.json index e33b748..4f184b6 100644 --- a/packages/web/src/i18n/locales/sv/event.json +++ b/packages/web/src/i18n/locales/sv/event.json @@ -41,6 +41,7 @@ "noMatches": "Inga tävlingar matchar sökningen.", "clearFilters": "Rensa filter", "typeFilterAll": "Alla typer", + "filterMine": "Mina tävlingar", "typeUnclassified": "Oklassificerad", "eventType": "Arrangemangstyp", "eventTypeHelp": "Oxygens etikett kan redigeras och skrivs inte över vid senare Eventor-synkningar.", @@ -67,14 +68,21 @@ "classification6": "Internationell", "production": "Produktion", "testEventor": "Test-Eventor", - "apiKeyStep": "1. API-nyckel", - "selectImportStep": "2. Välj & importera", "apiKeyPrompt": "Ange din klubbs Eventor API-nyckel för att bläddra och importera tävlingar.", "apiKeyPlaceholder": "API-nyckel (t.ex. df34af90...)", "validating": "Validerar...", "connect": "Anslut", "connectedTo": "Ansluten: {{name}}", "changeKey": "Byt nyckel", + "manageKeys": "Hantera nycklar", + "importNoKey": "Ingen Eventor API-nyckel är konfigurerad för den här miljön.", + "importNoKeyAdmin": "Lägg till en under Inställningar → Eventor", + "importNoKeyMember": "Be en instansadministratör lägga till den under Inställningar → Eventor.", + "eventorKeysHelp": "Klubbgemensamma Eventor API-nycklar som delas av alla tävlingar på den här instansen. Produktionsnyckeln används för riktiga tävlingar, Test-Eventor-nyckeln för repetitioner. Nycklar valideras mot Eventor innan de sparas.", + "eventorKeyConfigured": "Nyckel konfigurerad", + "eventorKeyNotConfigured": "Ingen nyckel konfigurerad", + "eventorKeyClear": "Ta bort nyckel", + "eventorKeyClearing": "Tar bort...", "searchEvents": "Sök tävlingar...", "loadingEvents": "Laddar tävlingar från Eventor...", "noEventsFound": "Inga tävlingar hittades", @@ -159,7 +167,7 @@ "pushResultsComplete": "Laddade upp resultat för {{count}} löpare till Test-Eventor.", "pushStartListFailed": "Skicka startlista misslyckades: {{message}}", "pushResultsFailed": "Skicka resultat misslyckades: {{message}}", - "eventorApiKeyNotConfigured": "Eventor API-nyckel ej konfigurerad. Gå till tävlingsväljaren och anslut din nyckel för att aktivera synk.", + "eventorApiKeyNotConfigured": "Eventor API-nyckel ej konfigurerad. En instansadministratör kan lägga till den under Inställningar → Eventor för att aktivera synk.", "runnerDatabase": "Löpardatabas", "runnerDatabaseGlobal": "Global", "runnerDbStats": "{{runners}} löpare, {{clubs}} klubbar", diff --git a/packages/web/src/i18n/locales/sv/library.json b/packages/web/src/i18n/locales/sv/library.json index cd9a591..bc1478c 100644 --- a/packages/web/src/i18n/locales/sv/library.json +++ b/packages/web/src/i18n/locales/sv/library.json @@ -4,6 +4,7 @@ "tabMaps": "Baskartor", "tabControls": "Kontroller", "tabUsers": "Användare", + "tabEventor": "Eventor", "tabMaintenance": "Underhåll", "mapsHeading": "Baskartor", "mapsHelp": "Ladda upp OCAD-kartor en gång för klubben. När du skapar en tävling väljer du en karta i listan — Oxygen kopierar den till tävlingen.", diff --git a/packages/web/src/i18n/locales/sv/trends.json b/packages/web/src/i18n/locales/sv/trends.json index b2c6329..2d0d014 100644 --- a/packages/web/src/i18n/locales/sv/trends.json +++ b/packages/web/src/i18n/locales/sv/trends.json @@ -36,7 +36,7 @@ "classificationInternational": "Internationell", "loadingEvents": "Hämtar tävlingar från Eventor…", "noEventsFound": "Inga tävlingar matchar filtret.", - "noKeyConfigured": "Anslut en Eventor API-nyckel för att aktivera jämförelse.", + "noKeyConfigured": "En instansadministratör måste lägga till en Eventor API-nyckel under Inställningar → Eventor för att aktivera jämförelse.", "addByIdHeading": "Lägg till med Eventor-tävlings-ID eller URL", "addByIdHelp": "Klistra in ett Eventor-tävlings-ID (t.ex. 12345) eller en URL som eventor.orientering.se/Events/Show/12345. Användbart när du redan vet vilken tävling du vill jämföra mot.", "addByIdPlaceholder": "Tävlings-ID eller URL", diff --git a/packages/web/src/lib/__tests__/event-list.test.ts b/packages/web/src/lib/__tests__/event-list.test.ts index c303427..d2b8d3b 100644 --- a/packages/web/src/lib/__tests__/event-list.test.ts +++ b/packages/web/src/lib/__tests__/event-list.test.ts @@ -121,4 +121,33 @@ describe("filterEvents", () => { it("has a label for every curated kind", () => { expect(Object.keys(EVENT_KIND_LABEL_KEYS)).toHaveLength(11); }); + + describe("onlyMine", () => { + const mixed = [ + ev({ id: 1, name: "Mine", nameId: "mine", date: "2026-09-01", ownedByMe: true, kind: "club" }), + ev({ id: 2, name: "Theirs", nameId: "theirs", date: "2026-09-02", ownedByMe: false }), + // Auth off / legacy server: the flag is absent, which must read as + // "not mine" rather than crash or match. + ev({ id: 3, name: "Unknown", nameId: "unknown", date: "2026-09-03" }), + ]; + + it("keeps only events the current user owns", () => { + expect(filterEvents(mixed, { query: "", onlyMine: true }).map((e) => e.nameId)).toEqual([ + "mine", + ]); + }); + + it("is off by default", () => { + expect(filterEvents(mixed, { query: "" })).toHaveLength(3); + expect(filterEvents(mixed, { query: "", onlyMine: false })).toHaveLength(3); + }); + + it("composes with query and kind", () => { + expect( + filterEvents(mixed, { query: "mine", kind: "club", onlyMine: true }).map((e) => e.nameId), + ).toEqual(["mine"]); + expect(filterEvents(mixed, { query: "", kind: "competition", onlyMine: true })).toEqual([]); + expect(filterEvents(mixed, { query: "theirs", onlyMine: true })).toEqual([]); + }); + }); }); diff --git a/packages/web/src/lib/event-list.ts b/packages/web/src/lib/event-list.ts index d2d93e4..e0637c0 100644 --- a/packages/web/src/lib/event-list.ts +++ b/packages/web/src/lib/event-list.ts @@ -60,11 +60,12 @@ export function groupEvents( export function filterEvents( events: EventInfo[], - opts: { query: string; kind?: EventKindFilter }, + opts: { query: string; kind?: EventKindFilter; onlyMine?: boolean }, ): EventInfo[] { const query = opts.query.trim().toLowerCase(); const kind = opts.kind ?? "all"; return events.filter((event) => { + if (opts.onlyMine && event.ownedByMe !== true) return false; if (query) { const haystack = `${event.name} ${event.nameId} ${event.annotation} ${event.kind} ${event.kindCustom}` diff --git a/packages/web/src/pages/CompetitionSelector.tsx b/packages/web/src/pages/CompetitionSelector.tsx index 59d81ee..8739f02 100644 --- a/packages/web/src/pages/CompetitionSelector.tsx +++ b/packages/web/src/pages/CompetitionSelector.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo } from "react"; +import { useState, useMemo } from "react"; import { useNavigate, Link } from "react-router-dom"; import { useTranslation } from "react-i18next"; import type { EventInfo, EventKind } from "@oxygen/shared"; @@ -8,6 +8,7 @@ import { LanguageSelector } from "../components/LanguageSelector"; import { UserChip } from "../components/UserChip"; import { BuildInfoLine } from "../components/BuildInfoLine"; import { LiveCompassLogo } from "../components/OxygenLogo"; +import { useCurrentUser } from "../context/CurrentUserContext"; import { EVENT_KIND_OPTIONS, eventKindDisplayLabel, @@ -39,33 +40,45 @@ export function CompetitionSelector() { const [showEventor, setShowEventor] = useState(false); const [search, setSearch] = useState(""); const [kind, setKind] = useState("all"); + const [onlyMine, setOnlyMine] = useState(false); + + // "My events" only means something when there is a signed-in user to + // own anything; with auth off every row reports ownedByMe=false. + const { user, authEnabled } = useCurrentUser(); + const canFilterMine = authEnabled && user !== null; const events = useMemo(() => competitions.data ?? [], [competitions.data]); const filtered = useMemo( - () => filterEvents(events, { query: search, kind }), - [events, search, kind], + () => + filterEvents(events, { + query: search, + kind, + onlyMine: canFilterMine && onlyMine, + }), + [events, search, kind, onlyMine, canFilterMine], ); const grouped = useMemo(() => groupEvents(filtered, formatDate(new Date())), [filtered]); - const hasFilters = search.trim() !== "" || kind !== "all"; + const hasFilters = search.trim() !== "" || kind !== "all" || onlyMine; + const clearFilters = () => { + setSearch(""); + setKind("all"); + setOnlyMine(false); + }; return (
- {/* Language Selector — top right */} -
- - -
- {/* Logo / Header */} -
+

{t("title")}

-

{t("selectCompetition")}

+ {/* The tagline earns its space on a desktop; on a phone the list + itself is the explanation. */} +

{t("selectCompetition")}

{/* Competition List */} @@ -104,28 +117,52 @@ export function CompetitionSelector() { {competitions.data && competitions.data.length > 0 && ( <> -
+ {/* Search on its own line on phones; the type select and the + "mine" toggle share the second line. A native setSearch(e.target.value)} placeholder={t("searchPlaceholder")} - className="flex-1 px-3 py-2 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + className="w-full sm:flex-1 min-w-0 px-3 py-2 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" /> - +
+ + {canFilterMine && ( + + )} +
{filtered.length === 0 ? (
@@ -134,10 +171,7 @@ export function CompetitionSelector() {
)} - {/* Footer */} -
-
{t("footer", { version: __APP_VERSION__ })}
- -
+ {/* Footer: who is signed in and which language, then build info. + Both used to sit above the logo, where they competed with the + header for the little vertical room a phone has. */} +
+
+ + +
+
+
{t("footer", { version: __APP_VERSION__ })}
+ +
+
); @@ -387,14 +432,22 @@ function EventGroup({ - {t("eventOwner", { owner: comp.owner })} + {/* "Created by" is dead weight on a phone — the bare + name reads fine next to the type badge. */} + {comp.owner} + + {t("eventOwner", { owner: comp.owner })} + )}
{/* `event.delete` is gated on `event.manage` server-side; - with auth off every row reports canManage. */} + with auth off every row reports canManage. + Hover-reveal only where hover exists: on touch devices the + button is always visible, otherwise it is unreachable. */} {comp.canManage && ( - {/* Step indicator */} -
- - {t("apiKeyStep")} - - → - - {t("selectImportStep")} - -
+ {keyStatus.isLoading && ( +
+
+
+ )} + {keyStatus.isError && ( +
{keyStatus.error.message}
+ )} - {/* Step 1: API Key */} - {step === "key" && ( -
-

- {t("apiKeyPrompt")} -

-
- setApiKey(e.target.value)} - placeholder={t("apiKeyPlaceholder")} - className="w-full px-4 py-2.5 border border-slate-200 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" - autoFocus - /> -
- - {validateMutation.isError && ( -
- {validateMutation.error.message} -
+ {keyStatus.data && !keyStatus.data.connected && ( +
+

{t("importNoKey")}

+ {canManageKeys ? ( + + {t("importNoKeyAdmin")} + + + ) : ( +

{t("importNoKeyMember")}

)} - +
)} - {/* Step 2: Event list with inline import */} - {step === "events" && ( + {keyStatus.data?.connected && ( setStepOverride("key")} + canManageKeys={canManageKeys} /> )}
@@ -706,12 +722,12 @@ function EventorEventList({ orgName, env, onImported, - onChangeKey, + canManageKeys, }: { orgName: string; env: "prod" | "test"; onImported: (nameId: string) => void; - onChangeKey: () => void; + canManageKeys: boolean; }) { const { t } = useTranslation("event"); const [search, setSearch] = useState(""); @@ -792,12 +808,15 @@ function EventorEventList({ {t("connectedTo", { name: orgName })} - + {canManageKeys && ( + + {t("manageKeys")} + + )} {/* Search */} diff --git a/packages/web/src/pages/SettingsPage.tsx b/packages/web/src/pages/SettingsPage.tsx index 0af9f58..e2d3ec6 100644 --- a/packages/web/src/pages/SettingsPage.tsx +++ b/packages/web/src/pages/SettingsPage.tsx @@ -11,10 +11,11 @@ import { LibraryGroupsTab } from "./LibraryGroupsTab"; import { LibraryClassesTab } from "./LibraryClassesTab"; import { UsersAdminPanel } from "../components/UsersAdminPanel"; import { MaintenancePanel } from "../components/MaintenancePanel"; +import { EventorKeysPanel } from "../components/EventorKeysPanel"; import { NorthLinesBadge } from "../components/NorthLinesBadge"; const CLUB_TABS = ["maps", "controls", "classes", "groups"] as const; -const ADMIN_TABS = ["users", "maintenance"] as const; +const ADMIN_TABS = ["users", "eventor", "maintenance"] as const; type SettingsTab = (typeof CLUB_TABS)[number] | (typeof ADMIN_TABS)[number]; @@ -24,6 +25,7 @@ const TAB_LABEL_KEYS = { classes: "tabClasses", groups: "tabGroups", users: "tabUsers", + eventor: "tabEventor", maintenance: "tabMaintenance", } as const satisfies Record; @@ -157,6 +159,8 @@ export function SettingsPage() { {tab === "users" ? ( + ) : tab === "eventor" ? ( + ) : tab === "maintenance" ? ( ) : tab === "groups" ? ( From 15ebc599d29e72671f4308212290832932cb8590 Mon Sep 17 00:00:00 2001 From: Marcus Kempe Date: Fri, 25 Sep 2026 21:08:58 +0200 Subject: [PATCH 2/2] Fit the editor, template and controls toolbars on a phone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Course editor / map toolbar: every toggle (Hide other controls, Auto slits, Descriptions, Show progress) is now a ToolbarButton — icon plus a label that is visible from `sm` up and collapses to the icon below it, with the label kept as aria-label/title. Undo and redo become 20px icons with touch-sized padding instead of 12px ⟲/⟳ glyphs. At 390px the row used to overflow and paint "Auto slits" over "Descriptions". The per-event "North lines under course purple" checkbox is removed from the editor's map footer. It is a base-map property and stays on the club-library map card; ISOM 2017 App. 1 puts blue/black 100 % lines above lower purple, so "below" is a deliberate deviation, not the spec. Template / map layout editor header: two rows on phones (title + window controls, then tools) and one row from `sm`, via `sm:contents` wrappers. The "Preview course" caption is dropped (name stays on the select). Controls page: the AIR+ / station-mode row wraps instead of widening the page. Co-authored-by: Cursor --- docs/map-color-stack.md | 19 +- docs/mobile-layout.md | 46 ++- e2e/mobile-layout.spec.ts | 103 ++++++ .../web/src/components/MapLayoutEditor.tsx | 313 ++++++++++-------- packages/web/src/components/MapPanel.tsx | 89 +++-- packages/web/src/components/ToolbarButton.tsx | 76 +++++ packages/web/src/components/map-icons.tsx | 68 ++++ packages/web/src/pages/ControlsPage.tsx | 17 +- packages/web/src/pages/CourseEditorPage.tsx | 88 +++-- 9 files changed, 565 insertions(+), 254 deletions(-) create mode 100644 packages/web/src/components/ToolbarButton.tsx diff --git a/docs/map-color-stack.md b/docs/map-color-stack.md index 26be236..43f03ef 100644 --- a/docs/map-color-stack.md +++ b/docs/map-color-stack.md @@ -58,11 +58,26 @@ renders pass `objects: file.objects.filter(o => Math.floor(o.sym / 1000) !== 601)` into `ocadToSvg` / `objectsInWindow`. The composite keeps all objects. +**What the spec says.** ISOM 2017 Appendix 1 (chapter 4, colour order) +puts *Black* and *Blue 100 % lines and point symbols* **above** *Purple +(course setting)* — the lower purple — and ISOM 601 magnetic north lines +are black or blue 100 % line symbols. So by the letter of the IOF colour +order north lines sit **on top of** control circles and legs (still under +upper purple: control numbers, marked routes). `north_lines_below = true` +is therefore a deliberate deviation for legibility, not the default the +spec prescribes. The option is kept because some clubs prefer it, but it +is a base-map property: it is set on the club-library map card +(**Settings → Base maps**) and is no longer offered per event on the +course editor's map footer. + ## UI / API - Event map: `course.setMapColorStack` + MapPanel select - (`data-testid="map-color-profile"`) and north-lines checkbox. -- Club library: `clubMap.setColorStack` + Settings → Maps card controls. + (`data-testid="map-color-profile"`). The north-lines checkbox was + removed from the editor in September 2026 (see above); the API field + remains. +- Club library: `clubMap.setColorStack` + Settings → Maps card controls + (colour profile select + north-lines checkbox). - `course.mapMetadata` exposes `colorProfile`, `northLinesBelow`, `resolvedProfile`, `resolvedBy`, and `renderKey`. diff --git a/docs/mobile-layout.md b/docs/mobile-layout.md index d8a0fd0..e5a1694 100644 --- a/docs/mobile-layout.md +++ b/docs/mobile-layout.md @@ -81,6 +81,47 @@ scroll the page underneath. A two-finger map gesture cancels any armed control tap and suppresses editor selections for 400 ms after movement, giving both fingers time to leave the screen. Descriptions start hidden below `sm`. +### Map toolbar buttons + +Every toggle on the map toolbar — **Hide other controls**, **Auto slits**, +**Descriptions**, **Show progress** — is a `ToolbarButton` +(`packages/web/src/components/ToolbarButton.tsx`): an icon from +`map-icons.tsx` plus a label that is visible from `sm` up and collapses to the +icon alone below it. The label always feeds `aria-label` and the tooltip, so +the accessible name (and `getByRole("button", { name })` in E2E) is the same +on both breakpoints. Undo / redo are icon-only everywhere. On phones the +buttons get `p-2` and 20px icons — a ~36px touch target — instead of the +12px `⟲` / `⟳` glyphs the editor used to ship. Without this the row of text +buttons did not fit 390px and **Auto slits** rendered on top of +**Descriptions**. + +The selected-control readout keeps the code and SRR/radio badges on phones +but hides the millimetre position (`hidden sm:inline`). + +The **North lines under course purple** checkbox is no longer on the editor's +map footer. It is a property of the base map, not of one event's course work, +so it lives only on the club-library map card (**Settings → Base maps**). See +[map-color-stack.md](map-color-stack.md#north-lines-under-the-course) for what +the IOF colour order actually says about it. + +### Map layout / template editor header + +`MapLayoutEditor`'s header is one row from `sm` up and two rows below: title + +help + fullscreen/close first, then undo / redo / zoom / preview-course select +/ save status. The two row wrappers switch to `display: contents` at `sm` +(`sm:contents`), so their children join the header's flex line directly and +`order` puts the window controls last. The **Preview course** caption is gone; +the select carries the name as `aria-label` / `title`, and its "no course" +option reads as the label. On phones the "Rendering map preview…" pill is +spinner-only. + +### Controls page toolbar + +The count / AIR+ / awake-hours group and the **Program Controls** / **Read +Controls** buttons are two `flex-wrap` groups inside a wrapping row +(`data-testid="controls-toolbar"`), so on a phone the station buttons drop to +their own line instead of pushing the page wider than the screen. + ## Shell header (narrow viewports) Below the `sm` breakpoint (~640px): @@ -152,7 +193,10 @@ to Google Accounts and the cross-origin OAuth response is rejected by CORS. | Map gestures | `packages/web/src/components/MapViewer.tsx` | | GPS locate | `packages/web/src/lib/locate-mode.ts`, `useGeolocationWatch` | | Session recovery | `packages/web/src/lib/session-recovery.ts`, `CompetitionShell` | -| Map footer / height | `packages/web/src/components/MapPanel.tsx` | +| Map footer / height / toolbar | `packages/web/src/components/MapPanel.tsx` | +| Toolbar buttons + icons | `packages/web/src/components/ToolbarButton.tsx`, `map-icons.tsx` | +| Template editor header | `packages/web/src/components/MapLayoutEditor.tsx` | +| Controls page toolbar | `packages/web/src/pages/ControlsPage.tsx` | | Shell header | `packages/web/src/pages/CompetitionShell.tsx` | | Editor dismiss wire-up | `packages/web/src/pages/CourseEditorPage.tsx` | | E2E | `e2e/mobile-layout.spec.ts`, `e2e/session-recovery.spec.ts` | diff --git a/e2e/mobile-layout.spec.ts b/e2e/mobile-layout.spec.ts index 3219a3e..6fe5571 100644 --- a/e2e/mobile-layout.spec.ts +++ b/e2e/mobile-layout.spec.ts @@ -231,6 +231,109 @@ test.describe.serial("mobile layout", () => { } }); + test("editor, template and controls toolbars fit a phone", async ({ page }) => { + test.setTimeout(120_000); + await page.goto("/"); + await page.getByText("My example tävling").click(); + await expect(page.getByRole("link", { name: "Dashboard" })).toBeVisible({ + timeout: 15000, + }); + // Map was uploaded in the first serial test. + await expect(page.getByTestId("map-viewer").first()).toBeVisible({ timeout: 60000 }); + + // ── Course editor: icon-only toggles, touch-sized undo/redo, one row. + await clickTab(page, "Course Editor"); + await expect(page.getByTestId("course-editor-page")).toBeVisible({ timeout: 15000 }); + const mapToolbar = page.getByTestId("map-toolbar"); + await expect(mapToolbar).toBeVisible(); + const toolbarBox = (await mapToolbar.boundingBox())!; + expect(toolbarBox.x + toolbarBox.width).toBeLessThanOrEqual(390); + // Every control sits inside the toolbar's own box — nothing spills + // over or overlaps a neighbour (the "Auto slits" over "Descriptions" + // bug). + const controls = [ + page.getByTestId("editor-undo"), + page.getByTestId("editor-redo"), + page.getByTestId("editor-hide-others"), + page.getByTestId("editor-toggle-cuts"), + page.getByTestId("map-toggle-descriptions"), + ]; + const boxes = []; + for (const control of controls) { + await expect(control).toBeVisible(); + const box = (await control.boundingBox())!; + expect(box.x).toBeGreaterThanOrEqual(toolbarBox.x); + expect(box.x + box.width).toBeLessThanOrEqual(toolbarBox.x + toolbarBox.width + 0.5); + boxes.push(box); + } + for (let i = 1; i < boxes.length; i++) { + expect(boxes[i].x, `control ${i} overlaps its neighbour`).toBeGreaterThanOrEqual( + boxes[i - 1].x + boxes[i - 1].width - 0.5, + ); + } + // Undo / redo are real touch targets now, not 12px glyphs. + expect(boxes[0].height).toBeGreaterThanOrEqual(36); + expect(boxes[0].width).toBeGreaterThanOrEqual(36); + // Labels collapse to icons below `sm`; the accessible names stay. + await expect(page.getByTestId("editor-toggle-cuts").getByText("Auto slits")).toBeHidden(); + await expect( + page.getByTestId("editor-hide-others").getByText("Hide other controls"), + ).toBeHidden(); + await expect(page.getByRole("button", { name: "Auto slits" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Descriptions" })).toBeVisible(); + // The per-event "north lines under purple" checkbox is gone from the + // editor; it stays a club-library map setting. + await expect(page.getByTestId("map-north-lines-below")).toHaveCount(0); + + // ── Controls page: the AIR+ / station-mode row wraps instead of + // pushing the page wider than the screen. + await clickTab(page, "Controls"); + await expect(page.getByTestId("controls-toolbar")).toBeVisible({ timeout: 15000 }); + const controlsToolbar = (await page.getByTestId("controls-toolbar").boundingBox())!; + expect(controlsToolbar.x + controlsToolbar.width).toBeLessThanOrEqual(390); + await expect(page.getByRole("button", { name: "Program Controls" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Read Controls" })).toBeVisible(); + expect( + await page.evaluate(() => document.documentElement.scrollWidth), + ).toBeLessThanOrEqual(390); + + // ── Template editor: two header rows, no "Preview course" caption. + await clickTab(page, "Map templates"); + await expect(page.getByTestId("map-templates-page")).toBeVisible({ timeout: 15000 }); + await page.getByTestId("new-map-template").click(); + await page.getByTestId("map-template-name").fill("E2E Mobile header"); + await page.getByTestId("map-template-submit").click(); + const templateRow = page + .locator("[data-testid^='map-template-']") + .filter({ hasText: "E2E Mobile header" }); + await expect(templateRow).toBeVisible({ timeout: 15000 }); + const templateSeq = (await templateRow.getAttribute("data-testid"))!.replace( + "map-template-", + "", + ); + await page.getByTestId(`edit-template-layout-${templateSeq}`).click(); + await expect(page.getByTestId("map-layout-editor")).toBeVisible(); + + const header = page.getByTestId("map-editor-header"); + const headerBox = (await header.boundingBox())!; + expect(headerBox.x + headerBox.width).toBeLessThanOrEqual(390); + const undoBox = (await page.getByTestId("map-editor-undo").boundingBox())!; + const closeBox = (await page.getByTestId("map-editor-close").boundingBox())!; + expect(undoBox.height).toBeGreaterThanOrEqual(36); + // Window controls on the first row, tools on the second — and nothing + // on a third: the header is no taller than those two rows. + expect(undoBox.y).toBeGreaterThan(closeBox.y + closeBox.height - 1); + expect(headerBox.height).toBeLessThan(undoBox.height * 2 + 24); + await expect(page.getByText("Preview course")).toHaveCount(0); + const previewSelect = page.getByTestId("map-template-preview-course"); + await expect(previewSelect).toBeVisible(); + await expect(previewSelect).toHaveAccessibleName("Preview course"); + const selectBox = (await previewSelect.boundingBox())!; + expect(selectBox.x + selectBox.width).toBeLessThanOrEqual(390); + await page.getByTestId("map-editor-close").click(); + await expect(page.getByTestId("map-layout-editor")).toHaveCount(0); + }); + test("switching events resets viewport and tile state", async ({ page }) => { test.setTimeout(120_000); const uniqueName = `E2E Mobile Map Switch ${Date.now()}`; diff --git a/packages/web/src/components/MapLayoutEditor.tsx b/packages/web/src/components/MapLayoutEditor.tsx index 68cbf0c..309c363 100644 --- a/packages/web/src/components/MapLayoutEditor.tsx +++ b/packages/web/src/components/MapLayoutEditor.tsx @@ -45,6 +45,8 @@ import { EditorHelp } from "./EditorHelp"; import { IconFullscreenEnter, IconFullscreenExit, + IconRedo, + IconUndo, } from "./map-icons"; import { fileToBase64 } from "../lib/file-to-base64"; import { @@ -1659,100 +1661,156 @@ export function MapLayoutEditor({ data-testid="map-layout-editor" className="fixed inset-0 z-50 flex flex-col bg-slate-100" > -
-

- {t("editorTitle")}: {mapName} -

- - - - -
- + {/* Two rows on a phone — title + window controls, then the tools — + and a single row from `sm` up. The row wrappers switch to + `display: contents` at `sm`, so their children join the header's + flex line directly and `order` puts the window controls last. */} +
+
+

+ {t("editorTitle")}: {mapName} +

+ +
+ + +
+
+
+ -
- {mode === "template" && onPreviewCourseChange && ( - - )} -
- {!previewReady && !previewError && ( - - - {t("previewLoading")} - )} - {saveStatusLabel && ( - - {saveStatusLabel} - - )} - {saveError && ( - - {saveError} - - )} - - + {saveStatusLabel && ( + + {saveStatusLabel} + + )} + {saveError && ( + + {saveError} + + )} +
diff --git a/packages/web/src/components/MapPanel.tsx b/packages/web/src/components/MapPanel.tsx index daccd0e..b3e4f2b 100644 --- a/packages/web/src/components/MapPanel.tsx +++ b/packages/web/src/components/MapPanel.tsx @@ -7,9 +7,14 @@ import { useCurrentUser } from "../context/CurrentUserContext"; import { MapViewer, type ControlOverlay, type CourseOverlay, type MapViewerEditorProps } from "./MapViewer"; import { useIsWideViewport } from "./map-pane-shared"; import { + IconDescriptions, + IconEye, + IconEyeOff, IconFullscreenEnter, IconFullscreenExit, + IconProgress, } from "./map-icons"; +import { ToolbarButton } from "./ToolbarButton"; import { NorthLinesBadge } from "./NorthLinesBadge"; import { formatStalenessDeg, isMeridianStale } from "../lib/north-lines"; @@ -628,7 +633,7 @@ function MapPanelImpl({ const paneToolbar = renderToolbar ? (
{fillContainer && !toolbar && (

@@ -636,61 +641,51 @@ function MapPanelImpl({

)} {toolbar} -
+ {/* Every toggle here is icon + label from `sm` up and icon-only on + a phone (ToolbarButton), so the row fits a 390px screen without + the buttons overlapping each other. */} +
{/* In editor mode the page drives filtering through `filterMode` and renders its own toggle — a second, internal toggle here would fight it (two buttons, diverging state). */} {canFilter && !editor && ( - + /> )} {(highlightedCourseNamesList.length > 0 || descriptionsAllControls) && ( - + /> )} {onCompletionToggle && ( - + /> )} - + /> {onPaneCollapse && (
diff --git a/packages/web/src/components/ToolbarButton.tsx b/packages/web/src/components/ToolbarButton.tsx new file mode 100644 index 0000000..51bd96f --- /dev/null +++ b/packages/web/src/components/ToolbarButton.tsx @@ -0,0 +1,76 @@ +import type { ReactNode } from "react"; + +export type ToolbarTone = "purple" | "emerald" | "blue"; + +const ACTIVE_TONE: Record = { + purple: "bg-purple-100 text-purple-700 font-medium", + emerald: "bg-emerald-100 text-emerald-700 font-medium", + blue: "bg-blue-100 text-blue-700 font-medium", +}; + +/** + * Icon + label toolbar button for the map / editor toolbars. + * + * The label is visible from `sm` up and collapses to the icon alone on a + * phone, where a row of text buttons ("Auto slits", "Descriptions", …) + * does not fit. The label always feeds `aria-label` and (unless a richer + * `title` is given) the tooltip, so the button keeps its accessible name + * and Playwright's `getByRole("button", { name })` on both breakpoints. + * + * `iconOnly` keeps it a pure icon everywhere (undo / redo). Padding grows + * on phones so the hit area is a comfortable touch target. + */ +export function ToolbarButton({ + icon, + label, + title, + onClick, + active, + tone = "purple", + disabled = false, + iconOnly = false, + testId, + className = "", +}: { + icon: ReactNode; + label: string; + /** Tooltip; defaults to `label`. */ + title?: string; + onClick: () => void; + /** Pressed / toggled state — also sets `aria-pressed`. Leave undefined + * for plain action buttons (undo / redo) so they are not announced as + * toggles. */ + active?: boolean; + tone?: ToolbarTone; + disabled?: boolean; + iconOnly?: boolean; + testId?: string; + className?: string; +}) { + // A toggled button that is briefly disabled (mutation in flight) keeps + // its "on" colour rather than flashing grey. + const state = disabled + ? active + ? `${ACTIVE_TONE[tone]} opacity-60 cursor-wait` + : "text-slate-300 cursor-not-allowed" + : active + ? `${ACTIVE_TONE[tone]} cursor-pointer` + : "text-slate-500 hover:text-slate-700 hover:bg-slate-100 cursor-pointer"; + return ( + + ); +} diff --git a/packages/web/src/components/map-icons.tsx b/packages/web/src/components/map-icons.tsx index dda1166..d5727fb 100644 --- a/packages/web/src/components/map-icons.tsx +++ b/packages/web/src/components/map-icons.tsx @@ -107,3 +107,71 @@ export function IconUndo({ className, title }: IconProps) { ); } + +export function IconRedo({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + ); +} + +/** Eye with a slash — "hide other controls". */ +export function IconEyeOff({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + + + ); +} + +/** Open eye — "show all controls". */ +export function IconEye({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + ); +} + +/** Scissors — automatic circle slits / leg gaps. */ +export function IconScissors({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + + ); +} + +/** Control-description sheet — a small grid with a header row. */ +export function IconDescriptions({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + ); +} + +/** Check inside a circle — "show progress" (control completion). */ +export function IconProgress({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + ); +} diff --git a/packages/web/src/pages/ControlsPage.tsx b/packages/web/src/pages/ControlsPage.tsx index b31586d..b5319da 100644 --- a/packages/web/src/pages/ControlsPage.tsx +++ b/packages/web/src/pages/ControlsPage.tsx @@ -214,9 +214,14 @@ export function ControlsPage() {
- {/* AIR+ toggle + station mode buttons */} -
-
+ {/* AIR+ toggle + station mode buttons. Both groups wrap so the row + never runs past a phone screen: the station buttons drop to their + own line, and inside each group the items reflow. */} +
+
{t("controlsCount", { count: items.length })} @@ -248,10 +253,10 @@ export function ControlsPage() {
-
+
- - - - + /> {selectedControl && ( {t("editor.selectedControl", { code: selectedControl.code })} - {" · "} - {t("editor.position", { - x: selectedControl.mapX.toFixed(1), - y: selectedControl.mapY.toFixed(1), - })} + {/* Coordinates are desktop-only; on a phone the code plus + badges is all the row has room for. */} + + {" · "} + {t("editor.position", { + x: selectedControl.mapX.toFixed(1), + y: selectedControl.mapY.toFixed(1), + })} + {contextBadge && (