Files
galaxy-game/ui/frontend/src/lib/header/view-menu.svelte
T
Ilia Denisov 8565942392
Build · Site / build (push) Successful in 8s
Tests · Go / test (push) Successful in 2m22s
Tests · UI / test (push) Failing after 2m42s
feat(deploy): single-origin path-based deployment + project site
Serve the whole stack behind one host: site at /, game UI at /game/,
gateway REST at /api + /healthz, Connect at /rpc (prefix stripped by the
edge Caddy). The built artifact is domain-agnostic — the UI talks to the
gateway same-origin via relative URLs, so the same bundle runs under any
host with no rebuild and with CORS disabled.

- Rename the Connect proto service galaxy.gateway.v1.EdgeGateway ->
  edge.v1.Gateway; regenerate Go + TS; public path /rpc/edge.v1.Gateway.
- Move the game UI under base path /game (env BASE_PATH); make the
  manifest, service-worker scope, WASM loader, and all navigation
  base-aware via a withBase helper.
- Relative API + /rpc Connect prefix; Vite dev proxy mirrors the strip.
- Rewrite the edge Caddy (dev + prod) for path-based routing; empty CORS
  allow-lists (same-origin); single host.
- New VitePress project site (site/): i18n en/ru with switcher, LaTeX
  math, minimal monospace theme; built and served at /.
- dev-deploy compose/Makefile + CI (dev-deploy, prod-build, new
  site-build) build and seed the site; probes hit /, /game/, /healthz.
- Sync docs (ARCHITECTURE, gateway README/openapi, dev-deploy &
  local-dev READMEs, CLAUDE.md, ui/PLAN).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 18:19:07 +02:00

278 lines
6.7 KiB
Svelte

<!--
Active-view picker that drives navigation between every in-game view.
The trigger swaps icon between desktop dropdown (▾) and mobile
hamburger (☰) at the 1024 px breakpoint via CSS only; the surface
itself is identical. The same component is reused for the mobile
"More" drawer entry of `bottom-tabs.svelte`.
Lists the seven IA destinations: map, tables (sub-list of six
entities), report, battle, mail, ship-class designer, science
designer. Closes on Escape, on outside click, and after a
navigation. Phase 26 introduces the history-mode entry; Phase 35
polishes microcopy.
-->
<script lang="ts">
import { withBase } from "$lib/paths";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import { mailStore } from "$lib/mail-store.svelte";
import { restoreFocus } from "$lib/a11y/restore-focus";
type Props = { gameId: string };
let { gameId }: Props = $props();
const mailUnread = $derived(mailStore.unreadCount);
let open = $state(false);
let rootEl: HTMLDivElement | null = $state(null);
const tableEntities: ReadonlyArray<{ slug: string; key: TranslationKey }> = [
{ slug: "planets", key: "game.view.table.planets" },
{ slug: "ship-classes", key: "game.view.table.ship_classes" },
{ slug: "ship-groups", key: "game.view.table.ship_groups" },
{ slug: "fleets", key: "game.view.table.fleets" },
{ slug: "sciences", key: "game.view.table.sciences" },
{ slug: "races", key: "game.view.table.races" },
];
function toggleOpen(): void {
open = !open;
}
function go(path: string): void {
open = false;
void goto(withBase(path));
}
function onKeyDown(event: KeyboardEvent): void {
if (event.key === "Escape" && open) {
open = false;
}
}
onMount(() => {
const handleClick = (event: MouseEvent): void => {
if (!open || rootEl === null) return;
const target = event.target;
if (target instanceof Node && rootEl.contains(target)) return;
open = false;
};
document.addEventListener("click", handleClick, true);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("click", handleClick, true);
document.removeEventListener("keydown", onKeyDown);
};
});
</script>
<div class="view-menu" bind:this={rootEl}>
<button
type="button"
class="trigger"
data-testid="view-menu-trigger"
aria-haspopup="menu"
aria-expanded={open}
aria-label={open
? i18n.t("game.shell.menu.close_views")
: i18n.t("game.shell.menu.open_views")}
onclick={toggleOpen}
>
<span class="icon-dropdown" aria-hidden="true"></span>
<span class="icon-hamburger" aria-hidden="true"></span>
</button>
{#if open}
<div
class="surface"
role="menu"
data-testid="view-menu-list"
use:restoreFocus
>
<button
type="button"
role="menuitem"
data-testid="view-menu-item-map"
onclick={() => go(`/games/${gameId}/map`)}
>
{i18n.t("game.view.map")}
</button>
<details data-testid="view-menu-tables">
<summary>{i18n.t("game.view.table")}</summary>
<div class="sub">
{#each tableEntities as entry (entry.slug)}
<button
type="button"
role="menuitem"
data-testid="view-menu-item-table-{entry.slug}"
onclick={() => go(`/games/${gameId}/table/${entry.slug}`)}
>
{i18n.t(entry.key)}
</button>
{/each}
</div>
</details>
<button
type="button"
role="menuitem"
data-testid="view-menu-item-report"
onclick={() => go(`/games/${gameId}/report`)}
>
{i18n.t("game.view.report")}
</button>
<button
type="button"
role="menuitem"
data-testid="view-menu-item-battle"
onclick={() => go(`/games/${gameId}/battle`)}
>
{i18n.t("game.view.battle")}
</button>
<button
type="button"
role="menuitem"
data-testid="view-menu-item-mail"
class="with-badge"
onclick={() => go(`/games/${gameId}/mail`)}
>
<span>{i18n.t("game.view.mail")}</span>
{#if mailUnread > 0}
<span class="badge" data-testid="view-menu-item-mail-badge">
{i18n.t("game.view.mail.badge", { count: String(mailUnread) })}
</span>
{/if}
</button>
<button
type="button"
role="menuitem"
data-testid="view-menu-item-designer-science"
onclick={() => go(`/games/${gameId}/designer/science`)}
>
{i18n.t("game.view.designer.science")}
</button>
</div>
{/if}
</div>
<style>
.view-menu {
position: relative;
}
.trigger {
font: inherit;
font-size: 1.1rem;
padding: 0.25rem 0.6rem;
background: transparent;
color: inherit;
border: 1px solid var(--color-border);
border-radius: 4px;
cursor: pointer;
}
.trigger:hover {
background: var(--color-surface-hover);
}
.icon-dropdown {
display: inline;
}
.icon-hamburger {
display: none;
}
@media (max-width: 767.98px) {
.icon-dropdown {
display: none;
}
.icon-hamburger {
display: inline;
}
}
.surface {
position: absolute;
top: calc(100% + 0.25rem);
right: 0;
min-width: 14rem;
display: flex;
flex-direction: column;
background: var(--color-surface-overlay);
border: 1px solid var(--color-border);
border-radius: 6px;
box-shadow: var(--shadow-lg);
z-index: 50;
}
.surface > button,
.surface > details > summary {
text-align: left;
font: inherit;
padding: 0.45rem 0.75rem;
background: transparent;
color: inherit;
border: 0;
cursor: pointer;
}
.surface > button.with-badge {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.surface > button.with-badge .badge {
min-width: 1.5rem;
padding: 0 0.4rem;
text-align: center;
font-size: 0.75rem;
border-radius: 999px;
background: var(--color-accent);
color: var(--color-accent-contrast);
}
.surface > button:hover,
.surface > details > summary:hover {
background: var(--color-surface-hover);
}
.surface > details > summary {
list-style: none;
}
.surface > details > summary::-webkit-details-marker {
display: none;
}
.surface > details > summary::after {
content: "▸";
float: right;
opacity: 0.7;
}
.surface > details[open] > summary::after {
content: "▾";
}
.sub {
display: flex;
flex-direction: column;
padding-left: 0.5rem;
border-left: 1px solid var(--color-border);
margin: 0 0.5rem 0.25rem;
}
.sub > button {
text-align: left;
font: inherit;
padding: 0.35rem 0.5rem;
background: transparent;
color: inherit;
border: 0;
cursor: pointer;
}
.sub > button:hover {
background: var(--color-surface-hover);
}
@media (max-width: 767.98px) {
.surface {
position: fixed;
top: 3rem;
right: 0;
left: 0;
min-width: 0;
border-radius: 0;
border-left: 0;
border-right: 0;
max-height: calc(100vh - 3rem);
overflow-y: auto;
}
}
</style>