Files
OpenFrontIO/src/client/graphics/layers/SendResourceModal.ts
T
Wraith e79c805804 refactor(ui): migrate tailwindcss v3 to v4 (#2735)
## Description:

migrate tailwindcss v3 to v4

## Please complete the following:

- [X] I have added screenshots for all UI updates
- [X] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [X] I have added relevant tests to the test directory
- [X] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

wraith4081

---------

Co-authored-by: iamlewis <lewismmmm@gmail.com>
Co-authored-by: Ryan <7389646+ryanbarlow97@users.noreply.github.com>
2026-01-06 14:21:52 -08:00

576 lines
18 KiB
TypeScript

import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { EventBus } from "../../../core/EventBus";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { within } from "../../../core/Util";
import {
SendDonateGoldIntentEvent,
SendDonateTroopsIntentEvent,
} from "../../Transport";
import { renderTroops, translateText } from "../../Utils";
import { UIState } from "../UIState";
@customElement("send-resource-modal")
export class SendResourceModal extends LitElement {
@property({ attribute: false }) eventBus: EventBus | null = null;
@property({ type: Boolean }) open: boolean = false;
@property({ type: String }) mode: "troops" | "gold" = "troops";
@property({ type: Object }) total: number | bigint = 0;
@property({ type: Object }) uiState: UIState | null = null; // to seed initial %
@property({ attribute: false }) format: (n: number) => string = renderTroops;
@property({ attribute: false }) myPlayer: PlayerView | null = null;
@property({ attribute: false }) target: PlayerView | null = null;
@property({ attribute: false }) gameView: GameView | null = null;
@property({ type: String }) heading: string | null = null;
@state() private sendAmount: number = 0;
@state() private selectedPercent: number | null = null;
private PRESETS = [10, 25, 50, 75, 100] as const;
createRenderRoot() {
return this;
}
connectedCallback() {
super.connectedCallback();
const initPct =
this.uiState && typeof this.uiState.attackRatio === "number"
? Math.round(this.uiState.attackRatio * 100)
: 100;
this.selectedPercent = this.sanitizePercent(initPct);
const basis = this.getPercentBasis();
this.sendAmount = this.clampSend(
Math.floor((basis * this.selectedPercent) / 100),
);
}
updated(changed: Map<string, unknown>) {
if (changed.has("open") && this.open) {
// If either side is dead, just close and do nothing
if (!this.isSenderAlive() || !this.isTargetAlive()) {
this.closeModal();
return;
}
queueMicrotask(() =>
(this.querySelector('[role="dialog"]') as HTMLElement | null)?.focus(),
);
}
if (
changed.has("total") ||
changed.has("mode") ||
changed.has("target") ||
changed.has("gameView")
) {
const basis = this.getPercentBasis();
if (this.selectedPercent !== null) {
const pct = this.sanitizePercent(this.selectedPercent);
const raw = Math.floor((basis * pct) / 100);
this.sendAmount = this.clampSend(raw);
} else {
this.sendAmount = this.clampSend(this.sendAmount);
}
}
}
private closeModal() {
this.dispatchEvent(new CustomEvent("close"));
}
private confirm() {
if (!this.isSenderAlive() || !this.isTargetAlive() || !this.eventBus) {
return;
}
const myPlayer = this.myPlayer;
const target = this.target;
const amount = this.limitAmount(this.sendAmount);
if (!myPlayer || !target || amount <= 0) return;
if (this.mode === "troops") {
const myTroops = Number(myPlayer.troops());
if (amount > myTroops) return;
this.eventBus.emit(new SendDonateTroopsIntentEvent(target, amount));
} else {
const myGold = Number(myPlayer.gold());
if (amount > myGold) return;
this.eventBus.emit(new SendDonateGoldIntentEvent(target, BigInt(amount)));
}
this.dispatchEvent(
new CustomEvent("confirm", {
detail: { amount, closePanel: true, success: true },
}),
);
this.closeModal();
}
private handleKeydown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
this.closeModal();
}
if (e.key === "Enter") {
e.preventDefault();
this.confirm();
}
};
private toNum(x: unknown): number {
if (typeof x === "bigint") return Number(x);
return Number(x ?? 0);
}
private getTotalNumber(): number {
const base = this.toNum(this.total);
return this.isSenderAlive() ? base : 0;
}
private sanitizePercent(p: number) {
return within(p, 0, 100);
}
/** Internal capacity only for troops; gold is unlimited. */
private getCapacityLeft(): number | null {
if (!this.isTargetAlive()) return 0;
if (this.mode !== "troops") return null;
if (!this.gameView || !this.target) return null;
const current = this.toNum(this.target.troops());
const max = this.toNum(this.gameView.config().maxTroops(this.target));
return Math.max(0, max - current);
}
private getPercentBasis(): number {
return this.getTotalNumber();
}
private limitAmount(proposed: number): number {
const cap = this.getCapacityLeft();
const total = this.getTotalNumber();
const hardMax = cap === null ? total : Math.min(total, cap);
return within(proposed, 0, hardMax);
}
private clampSend(n: number) {
const total = this.getTotalNumber();
const byTotal = within(n, 0, total);
return this.limitAmount(byTotal);
}
private percentOfBasis(n: number): number {
const basis = this.getPercentBasis();
return basis ? Math.round((n / basis) * 100) : 0;
}
private keepAfter(allowed: number): number {
const total = this.getTotalNumber();
return Math.max(0, total - allowed);
}
private getFillColor(): string {
return this.mode === "troops"
? "rgb(168 85 247)" /* purple */
: "rgb(234 179 8)" /* amber */;
}
private getMinKeepRatio(): number {
return this.mode === "troops" ? 0.3 : 0;
}
private isTargetAlive(): boolean {
return this.target?.isAlive() ?? false;
}
private isSenderAlive(): boolean {
return this.myPlayer?.isAlive() ?? false;
}
private i18n = {
title: (name: string) =>
this.mode === "troops"
? translateText("send_troops_modal.title_with_name", { name })
: translateText("send_gold_modal.title_with_name", { name }),
availableChip: () => translateText("common.available"),
availableTooltip: () =>
this.mode === "troops"
? translateText("send_troops_modal.available_tooltip")
: translateText("send_gold_modal.available_tooltip"),
max: () => translateText("common.preset_max"),
ariaSlider: () =>
this.mode === "troops"
? translateText("send_troops_modal.aria_slider")
: translateText("send_gold_modal.aria_slider"),
summarySend: () => translateText("common.summary_send"),
summaryKeep: () => translateText("common.summary_keep"),
closeLabel: () => translateText("common.close"),
cancel: () => translateText("common.cancel"),
send: () => translateText("common.send"),
cap: () => translateText("common.cap_label"),
capTooltip: () => translateText("common.cap_tooltip"),
sliderTooltip: (percent: number, amountStr: string) =>
this.mode === "troops"
? translateText("send_troops_modal.slider_tooltip", {
percent,
amount: amountStr,
})
: translateText("send_gold_modal.slider_tooltip", {
percent,
amount: amountStr,
}),
capacityNote: (amountStr: string) =>
translateText("send_troops_modal.capacity_note", { amount: amountStr }),
targetDeadTitle: () => translateText("common.target_dead"),
targetDeadNote: () => translateText("common.target_dead_note"),
};
private renderHeader() {
const name = this.target?.name?.() ?? "";
return html`
<div class="mb-3 flex items-center justify-between relative">
<h2
id="send-title"
class="text-lg font-semibold tracking-tight text-zinc-100"
>
${this.heading ?? this.i18n.title(name)}
</h2>
<!-- Close button -->
<button
type="button"
@click=${() => this.closeModal()}
class="absolute -top-3 -right-3 flex h-7 w-7 items-center justify-center rounded-full bg-zinc-700 text-white shadow-sm hover:bg-red-500 transition-colors focus-visible:ring-2 focus-visible:ring-white/30 focus:outline-hidden"
aria-label=${this.i18n.closeLabel()}
title=${this.i18n.closeLabel()}
>
</button>
</div>
`;
}
private renderAvailable() {
const total = this.getTotalNumber();
return html`
<div class="mb-4 pb-3 border-b border-zinc-800">
<div class="flex items-center gap-2 text-[13px]">
<!-- Available -->
<span
class="inline-flex items-center gap-1 rounded-full bg-indigo-600/15 px-2 py-0.5 ring-1 ring-indigo-400/40 text-indigo-100"
title=${this.i18n.availableTooltip()}
>
<span class="opacity-90">${this.i18n.availableChip()}</span>
<span class="font-mono tabular-nums">${this.format(total)}</span>
</span>
</div>
</div>
`;
}
private renderPresets(percentNow: number) {
const basis = this.getTotalNumber();
const dead = !this.isSenderAlive() || !this.isTargetAlive();
return html`
<div class="mb-8 grid grid-cols-5 gap-2">
${this.PRESETS.map((p) => {
const pct = this.sanitizePercent(p);
const active = (this.selectedPercent ?? percentNow) === pct;
const label = pct === 100 ? this.i18n.max() : `${pct}%`;
return html`
<button
?disabled=${dead}
class="rounded-lg px-3 py-2 text-sm ring-1 transition
${dead
? "bg-zinc-800/70 text-zinc-400 ring-zinc-700 cursor-not-allowed"
: active
? "bg-indigo-600 text-white ring-indigo-300/60"
: "bg-zinc-800 text-zinc-200 ring-zinc-700 hover:bg-zinc-700 hover:text-zinc-50"}"
@click=${() => {
if (dead) return;
this.selectedPercent = pct;
const raw = Math.floor((basis * pct) / 100);
this.sendAmount = this.clampSend(raw);
}}
?aria-pressed=${active}
title="${pct}%"
>
${label}
</button>
`;
})}
</div>
`;
}
private renderSlider(percentNow: number) {
const basis = this.getTotalNumber();
const cap = this.getCapacityLeft();
const hardMax = cap === null ? basis : Math.min(basis, cap);
const dead = !this.isSenderAlive() || !this.isTargetAlive();
// Where to draw the cap marker (as % of Available)
const capPercent =
cap === null
? null
: Math.max(
0,
Math.min(
100,
Math.round((Math.min(cap, basis) / (basis || 1)) * 100),
),
);
const fill = this.getFillColor();
const disabled = basis <= 0 || dead;
const sliderOuterMb = capPercent !== null ? "mb-8" : "mb-2";
return html`
<div class="${sliderOuterMb}">
<div
class="relative px-1 rounded-lg overflow-visible focus-within:ring-2 focus-within:ring-indigo-500/30"
>
<input
type="range"
min="0"
.max=${basis}
.value=${this.sendAmount}
?disabled=${disabled}
@input=${(e: Event) => {
if (dead) return;
const raw = Number((e.target as HTMLInputElement).value);
const pctRaw = basis ? Math.round((raw / basis) * 100) : 0;
this.selectedPercent = this.sanitizePercent(pctRaw);
const clamped = Math.min(raw, hardMax);
this.sendAmount = this.clampSend(clamped);
}}
class="w-full appearance-none bg-transparent range-x focus:outline-hidden"
aria-label=${this.i18n.ariaSlider()}
aria-valuemin="0"
aria-valuemax=${hardMax}
aria-valuetext=${this.i18n.sliderTooltip(
percentNow,
this.format(this.sendAmount),
)}
style="--percent:${percentNow}%; --fill:${fill}; --track: rgba(255,255,255,.28); --thumb-ring: rgb(24 24 27);"
/>
<!-- Tooltip -->
<div
class="pointer-events-none absolute -top-6 -translate-x-1/2 select-none left-(--pos)"
style="--pos: ${percentNow}%"
>
<div
class="rounded-sm bg-[#0f1116] ring-1 ring-zinc-700 text-zinc-100 px-1.5 py-0.5 text-[12px] shadow-sm whitespace-nowrap w-max z-50"
>
${percentNow}% • ${this.format(this.sendAmount)}
</div>
</div>
<!-- Cap marker -->
${capPercent !== null
? html`
<div
class="pointer-events-none absolute top-1/2 -translate-y-1/2 h-3 w-0.5 bg-amber-400/80 shadow-sm left-(--pos)"
style="--pos:${capPercent}%;"
title=${this.i18n.capTooltip()}
></div>
<div
class="pointer-events-none absolute top-full mt-1.5 -translate-x-1/2 select-none left-(--pos)"
style="--pos:${capPercent}%"
>
<div
class="rounded-sm bg-[#0f1116] ring-1 ring-amber-400/40 text-amber-200 px-1 py-0.5 text-[11px] shadow-sm whitespace-nowrap"
>
${this.i18n.cap()}
</div>
</div>
`
: html``}
</div>
</div>
`;
}
private renderCapacityNote(allowed: number) {
const capped = allowed !== this.sendAmount;
if (!capped) return html``;
return html`<p class="mt-1 text-xs text-amber-300">
${this.i18n.capacityNote(this.format(allowed))}
</p>`;
}
private renderSummary(allowed: number) {
const total = this.getTotalNumber();
const keep = this.keepAfter(allowed);
const belowMinKeep =
this.getMinKeepRatio() > 0 &&
keep < Math.floor(total * this.getMinKeepRatio());
return html`
<div class="mt-3 text-center text-sm text-zinc-200">
${this.i18n.summarySend()}
<span class="font-semibold text-indigo-400 font-mono"
>${this.format(allowed)}</span
>
· ${this.i18n.summaryKeep()}
<span
class="font-semibold font-mono ${belowMinKeep
? "text-amber-400"
: "text-emerald-400"}"
>
${this.format(keep)}
</span>
</div>
`;
}
private renderActions() {
const total = this.getTotalNumber();
const dead = !this.isSenderAlive() || !this.isTargetAlive();
const disabled = total <= 0 || this.clampSend(this.sendAmount) <= 0 || dead;
return html`
<div class="mt-5 flex justify-end gap-2">
<button
class="h-10 min-w-24 rounded-lg px-3 text-sm font-semibold
text-zinc-100 bg-zinc-800 ring-1 ring-zinc-700
hover:bg-zinc-700 focus:outline-hidden
focus-visible:ring-2 focus-visible:ring-white/20"
@click=${() => this.closeModal()}
>
${this.i18n.cancel()}
</button>
<button
class="h-10 min-w-24 rounded-lg px-3 text-sm font-semibold text-white
bg-indigo-600 enabled:hover:bg-indigo-500
focus:outline-hidden focus-visible:ring-2 focus-visible:ring-indigo-400/50
disabled:cursor-not-allowed disabled:opacity-50"
?disabled=${disabled}
@click=${() => this.confirm()}
>
${this.i18n.send()}
</button>
</div>
`;
}
private renderDeadNote() {
return html`
<div
class="mb-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-amber-200 text-sm"
>
<div class="font-semibold">${this.i18n.targetDeadTitle()}</div>
<div>${this.i18n.targetDeadNote()}</div>
</div>
`;
}
private renderSliderStyles() {
return html`
<style>
.range-x {
-webkit-appearance: none;
appearance: none;
height: 8px;
outline: none;
background: transparent;
}
.range-x::-webkit-slider-runnable-track {
height: 8px;
border-radius: 9999px;
background: linear-gradient(
90deg,
var(--fill) 0,
var(--fill) var(--percent),
/* allowed (clamped) fill */ rgba(255, 255, 255, 0.22)
var(--percent),
rgba(255, 255, 255, 0.22) 100%
);
}
.range-x::-webkit-slider-thumb {
-webkit-appearance: none;
height: 18px;
width: 18px;
border-radius: 9999px;
background: var(--fill);
border: 3px solid var(--thumb-ring);
margin-top: -5px;
}
.range-x::-moz-range-track {
height: 8px;
border-radius: 9999px;
background: rgba(255, 255, 255, 0.22);
}
.range-x::-moz-range-progress {
height: 8px;
border-radius: 9999px;
background: var(--fill);
}
.range-x::-moz-range-thumb {
height: 18px;
width: 18px;
border-radius: 9999px;
background: var(--fill);
border: 3px solid var(--thumb-ring);
}
</style>
`;
}
render() {
if (!this.open) return html``;
const percent = this.percentOfBasis(this.sendAmount);
const allowed = this.limitAmount(this.sendAmount);
return html`
<div class="absolute inset-0 z-1100 flex items-center justify-center p-4">
<div
class="absolute inset-0 bg-black/60 rounded-2xl"
@click=${() => this.closeModal()}
></div>
<div
role="dialog"
aria-modal="true"
aria-labelledby="send-title"
class="relative z-10 w-full max-w-135 focus:outline-hidden"
tabindex="0"
@keydown=${this.handleKeydown}
>
<div
class="rounded-2xl bg-zinc-900 p-5 shadow-2xl ring-1 ring-zinc-800 max-h-[90vh] text-zinc-200"
@click=${(e: MouseEvent) => e.stopPropagation()}
>
${this.renderHeader()} ${this.renderAvailable()}
${!this.isTargetAlive() ? this.renderDeadNote() : html``}
${this.renderPresets(percent)} ${this.renderSlider(percent)}
${this.mode === "troops"
? this.renderCapacityNote(allowed)
: html``}
${this.renderSummary(allowed)} ${this.renderActions()}
${this.renderSliderStyles()}
</div>
</div>
</div>
`;
}
}