Files
galaxy-game/ui/frontend/src/lib/active-view/mail/compose.svelte
T
Ilia Denisov f7300f25a3
Tests · UI / test (push) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m36s
Tests · Go / test (pull_request) Successful in 3m19s
Tests · UI / test (pull_request) Waiting to run
Phase 28 (Steps 6+9): mail active view + i18n keys
Step 6 — mail active view + subcomponents.

- `lib/active-view/mail.svelte` replaces the Phase 10 stub with the
  list / detail layout: two-pane on desktop, one-pane stack on
  mobile (CSS media query, no separate route).
- `lib/active-view/mail/thread-list.svelte` renders per-race
  threads collapsed to their last message plus stand-alone
  system / admin / outgoing-broadcast items, with unread badges.
- `lib/active-view/mail/thread-pane.svelte` is the chat-style
  transcript for one race; bodies render through `textContent`,
  per-message Show original / translation toggles flip the
  rendering when a translated body is present, and a persistent
  reply box at the bottom calls `mailStore.composePersonal`.
- `lib/active-view/mail/system-item-pane.svelte` renders one
  stand-alone item read-only with the same translation toggle.
- `lib/active-view/mail/compose.svelte` is the compose dialog:
  recipient race picker fed from `report.races[]`, kind toggle
  (personal / broadcast / admin), admin sub-toggle for target
  user / all and recipient-scope picker. Server-side enforces
  paid-tier and owner gating; the UI surfaces 403 inline.
- `lib/active-view/mail/system-titles.ts` keeps the keyword →
  i18n-title mapping for lifecycle-hook system mail so both the
  list and the detail pane pick the same canonical title.

Step 9 — i18n strings (en + ru).

`game.mail.*`, `game.view.mail.badge`, `game.events.mail_new.*`,
`game.mail.system.*` keys added in lockstep across both locales
covering compose labels / validation copy / per-system titles /
translation toggle / reply / delete affordances.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:43:09 +02:00

274 lines
6.7 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
Phase 28 — compose dialog for diplomatic mail. The recipient picker
reads `gameState.report.races[]` (Phase 22), the kind toggle exposes
personal / broadcast / admin; broadcast and admin sends are gated
server-side, the UI surfaces the resulting 403 inline.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import { mailStore } from "$lib/mail-store.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
type ComposeKind = "personal" | "broadcast" | "admin";
type AdminAudience = "active" | "active_and_removed" | "all_members";
let {
onClose,
onSent,
}: {
onClose: () => void;
onSent: (raceName: string | null) => void;
} = $props();
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const races = $derived.by<string[]>(() => {
const r = rendered?.report;
if (!r) {
return [];
}
return r.races.map((race) => race.name);
});
let kind = $state<ComposeKind>("personal");
let raceName = $state("");
let adminTarget = $state<"user" | "all">("user");
let adminAudience = $state<AdminAudience>("active");
let subject = $state("");
let body = $state("");
let error = $state<string | null>(null);
let sending = $state(false);
$effect(() => {
if (raceName === "" && races.length > 0) {
raceName = races[0];
}
});
async function submit(event: SubmitEvent): Promise<void> {
event.preventDefault();
error = null;
const bodyText = body.trim();
if (bodyText === "") {
error = i18n.t("game.mail.body_required");
return;
}
const needsRecipient = kind === "personal" || (kind === "admin" && adminTarget === "user");
if (needsRecipient && raceName === "") {
error = i18n.t("game.mail.recipient_required");
return;
}
sending = true;
try {
if (kind === "personal") {
await mailStore.composePersonal({
raceName,
subject,
body: bodyText,
});
onSent(raceName);
return;
}
if (kind === "broadcast") {
await mailStore.composeBroadcast({ subject, body: bodyText });
onSent(null);
return;
}
await mailStore.composeAdmin({
target: adminTarget,
raceName: adminTarget === "user" ? raceName : undefined,
recipients: adminTarget === "all" ? adminAudience : undefined,
subject,
body: bodyText,
});
onSent(null);
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
sending = false;
}
}
</script>
<div class="overlay" data-testid="mail-compose">
<form class="dialog" onsubmit={submit}>
<header>
<h3>{i18n.t("game.mail.compose_action")}</h3>
<button type="button" class="close" onclick={onClose}>×</button>
</header>
<label>
{i18n.t("game.mail.compose.target_label")}
<select bind:value={kind} data-testid="mail-compose-kind">
<option value="personal">{i18n.t("game.mail.compose.target_personal")}</option>
<option value="broadcast">{i18n.t("game.mail.compose.target_broadcast")}</option>
<option value="admin">{i18n.t("game.mail.compose.target_admin")}</option>
</select>
</label>
{#if kind === "admin"}
<label>
{i18n.t("game.mail.compose.recipients_label")}
<select bind:value={adminTarget} data-testid="mail-compose-admin-target">
<option value="user">{i18n.t("game.mail.compose.target_personal")}</option>
<option value="all">{i18n.t("game.mail.compose.target_broadcast")}</option>
</select>
</label>
{#if adminTarget === "all"}
<label>
{i18n.t("game.mail.compose.recipients_label")}
<select bind:value={adminAudience} data-testid="mail-compose-admin-audience">
<option value="active">{i18n.t("game.mail.compose.recipients_active")}</option>
<option value="active_and_removed">{i18n.t("game.mail.compose.recipients_active_and_removed")}</option>
<option value="all_members">{i18n.t("game.mail.compose.recipients_all_members")}</option>
</select>
</label>
{/if}
{/if}
{#if kind === "personal" || (kind === "admin" && adminTarget === "user")}
<label>
{i18n.t("game.mail.recipient_label")}
<select bind:value={raceName} data-testid="mail-compose-recipient">
{#each races as race (race)}
<option value={race}>{race}</option>
{/each}
</select>
</label>
{/if}
<label>
<span class="visually-hidden">{i18n.t("game.mail.subject_placeholder")}</span>
<input
type="text"
bind:value={subject}
placeholder={i18n.t("game.mail.subject_placeholder")}
data-testid="mail-compose-subject"
/>
</label>
<label>
<span class="visually-hidden">{i18n.t("game.mail.body_placeholder")}</span>
<textarea
bind:value={body}
placeholder={i18n.t("game.mail.body_placeholder")}
rows="6"
data-testid="mail-compose-body"
></textarea>
</label>
{#if error}
<p class="error" data-testid="mail-compose-error">{error}</p>
{/if}
<footer>
<button type="button" onclick={onClose}>
{i18n.t("game.mail.compose.cancel")}
</button>
<button type="submit" disabled={sending} data-testid="mail-compose-send">
{i18n.t("game.mail.compose.send")}
</button>
</footer>
</form>
</div>
<style>
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
}
.dialog {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1rem 1.25rem;
background: #161616;
border: 1px solid #2a2a2a;
border-radius: 8px;
min-width: min(420px, 90vw);
max-width: min(560px, 95vw);
}
header {
display: flex;
align-items: center;
justify-content: space-between;
}
header h3 {
margin: 0;
font-size: 1rem;
}
.close {
font: inherit;
background: transparent;
border: none;
color: inherit;
font-size: 1.25rem;
cursor: pointer;
}
label {
display: flex;
flex-direction: column;
gap: 0.2rem;
font-size: 0.85rem;
color: #ccc;
}
input,
textarea,
select {
font: inherit;
padding: 0.4rem 0.5rem;
border: 1px solid #444;
background: #111;
color: inherit;
border-radius: 4px;
}
footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.5rem;
}
footer button {
font: inherit;
padding: 0.35rem 0.75rem;
border: 1px solid #444;
background: #1a1a1a;
color: #fff;
border-radius: 4px;
cursor: pointer;
}
footer button[type="submit"] {
background: #2a4d7d;
border-color: #2a4d7d;
}
footer button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.error {
color: #c62828;
font-size: 0.85rem;
margin: 0;
}
.visually-hidden {
position: absolute;
clip: rect(0 0 0 0);
clip-path: inset(50%);
height: 1px;
width: 1px;
overflow: hidden;
}
</style>