8565942392
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>
278 lines
6.7 KiB
Svelte
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>
|