Clan System Part 2 - UI (#3625)

## Description:

Continuation from #3276 

Adds the complete client-side clan UI as a Lit web component
(`<clan-modal>`), a typed API client with Zod-validated responses,
shared response schemas, and a reusable `<confirm-dialog>` component.


### New: `ClanModal.ts`

| View | What it does |
|------|-------------|
| **My Clans** | Lists joined clans + pending join requests (built from
`/users/@me`, no extra fetches) |
| **Browse** | Search by tag (min 3 chars), paginated results,
configurable per-page (10/25/50) |
| **Clan Detail** | Stats, paginated + searchable member list, role
badges, join/leave/request actions |
| **Manage** | Edit name (max 35 chars) + description, toggle
open/invite-only, disband |
| **Transfer** | Leadership transfer with member selector + confirmation
|
| **Requests** | Approve/deny join requests (leader/officer) |
| **Bans** | View and unban (leader/officer) |
| **My Requests** | View and withdraw outgoing requests |

### New: `ConfirmDialog.ts`

Reusable `<confirm-dialog>` Lit component — replaces native
`confirm()`/`prompt()` which are blocked or broken on mobile and
CrazyGames iframes. Supports danger/warning variants and an optional
textarea (used for ban reasons). Fires `confirm`/`cancel` events.

### New: `ClanApi.ts`

Typed API client covering all clan endpoints. Every response is
Zod-validated. Auth header is always last in the spread (can't be
overridden by callers). Unknown server error messages always fall back
to a generic client-side string — never displayed verbatim.

### New: `ClanApiSchemas.ts` (in `src/core/`)

Shared Zod schemas for clan API responses with max-length constraints on
`name` (35) and `description` (200). Lives in `core/` so it can be
consumed by both client code and the leaderboard table.

### Modified: `ApiSchemas.ts`

- Added `clans` and `clanRequests` arrays to `UserMeResponseSchema`
- Moved clan leaderboard schemas out to `ClanApiSchemas.ts`
- Renamed `LeaderboardClanTagSchema` → `RequiredClanTagSchema`

### Modified: `Api.ts`

- Added `invalidateUserMe()` to bust the cached `/users/me` response
after mutations
- Removed `fetchClanLeaderboard` (moved to `ClanApi.ts`)

### Tests

- `ClanModal.test.ts` — rendering, view navigation, user actions
- `ClanApiQueries.test.ts` — fetch functions, error handling, pagination
- `ClanApiMutations.test.ts` — join, leave, kick, ban, promote,
transfer, etc.
- `ClanApiBans.test.ts` — ban/unban calls and error paths
- `ClanApiSchemas.test.ts` — Zod schema validation edge cases
- `LeaderboardModal.test.ts` — updated imports

## Notable design decisions

- **Not-logged-in state** — shows "Sign in to join clans" instead of
false "no clans" empty state
- **Rate limit feedback** — reads `Retry-After` header and surfaces wait
time to the user

## 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:

w.o.n

---------

Co-authored-by: evanpelle <evanpelle@gmail.com>
This commit is contained in:
Ryan
2026-05-01 04:27:35 +01:00
committed by GitHub
parent 38bbef6ecf
commit df05d21fc2
32 changed files with 7018 additions and 102 deletions
+34 -33
View File
@@ -154,42 +154,43 @@ export abstract class BaseModal extends LitElement {
}
}
/**
* Renders a standardized loading spinner with optional custom message.
* Use this for consistent loading states across all modals.
*
* @param message - Optional loading message text. Defaults to no message.
* @param spinnerColor - Optional spinner color. Defaults to 'blue'.
* @returns TemplateResult of the loading UI
*/
protected renderLoadingSpinner(
message?: string,
spinnerColor: "blue" | "green" | "yellow" | "white" = "blue",
): TemplateResult {
const colorClasses = {
blue: "border-blue-500/30 border-t-blue-500",
green: "border-green-500/30 border-t-green-500",
yellow: "border-yellow-500/30 border-t-yellow-500",
white: "border-white/20 border-t-white",
};
return html`
<div
class="flex flex-col items-center justify-center p-12 text-white h-full min-h-[400px]"
>
<div
class="w-12 h-12 border-4 ${colorClasses[
spinnerColor
]} rounded-full animate-spin mb-4"
></div>
${message
? html`<p
class="text-white/60 font-medium tracking-wide animate-pulse"
>
${message}
</p>`
: ""}
</div>
`;
return renderLoadingSpinner(message, spinnerColor);
}
}
const spinnerColorClasses: Record<string, string> = {
blue: "border-blue-500/30 border-t-blue-500",
green: "border-green-500/30 border-t-green-500",
yellow: "border-yellow-500/30 border-t-yellow-500",
white: "border-white/20 border-t-white",
};
/**
* Renders a standardized loading spinner with optional custom message.
* Use this for consistent loading states across all modals.
*/
export function renderLoadingSpinner(
message?: string,
spinnerColor: "blue" | "green" | "yellow" | "white" = "blue",
): TemplateResult {
return html`
<div
class="flex flex-col items-center justify-center p-12 text-white h-full min-h-[400px]"
>
<div
class="w-12 h-12 border-4 ${spinnerColorClasses[
spinnerColor
]} rounded-full animate-spin mb-4"
></div>
${message
? html`<p class="text-white/60 font-medium tracking-wide animate-pulse">
${message}
</p>`
: ""}
</div>
`;
}
+129
View File
@@ -0,0 +1,129 @@
import { html, LitElement, render as litRender } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { translateText } from "../Utils";
/**
* A reusable inline confirmation dialog.
*
* Usage:
* ```html
* <confirm-dialog
* .message=${"Are you sure?"}
* variant="danger"
* @confirm=${() => doThing()}
* @cancel=${() => {}}
* ></confirm-dialog>
* ```
*
* For ban-style flows, add a textarea:
* ```html
* <confirm-dialog
* .message=${"Ban this player?"}
* variant="warning"
* textareaPlaceholder="Reason (optional)"
* @confirm=${(e) => ban(e.detail.text)}
* @cancel=${() => {}}
* ></confirm-dialog>
* ```
*/
@customElement("confirm-dialog")
export class ConfirmDialog extends LitElement {
@property() message = "";
@property() variant: "danger" | "warning" = "danger";
@property() textareaPlaceholder = "";
@property({ type: Boolean }) disabled = false;
@state() private text = "";
private portal: HTMLDivElement | null = null;
createRenderRoot() {
return this;
}
connectedCallback() {
super.connectedCallback();
this.portal = document.createElement("div");
document.body.appendChild(this.portal);
}
disconnectedCallback() {
super.disconnectedCallback();
if (this.portal) {
litRender(html``, this.portal);
this.portal.remove();
this.portal = null;
}
}
render() {
if (this.portal) {
litRender(this.renderOverlay(), this.portal);
}
return html``;
}
private renderOverlay() {
const isDanger = this.variant === "danger";
const borderColor = isDanger ? "border-red-500/50" : "border-amber-500/50";
const cardBg = "bg-surface";
const textColor = isDanger ? "text-red-300" : "text-amber-300";
const btnClass = isDanger
? "bg-red-600 text-white hover:bg-red-700"
: "bg-amber-600 text-white hover:bg-amber-700";
return html`
<div
class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/80"
@click=${(e: Event) => {
if (e.target === e.currentTarget) this.handleCancel();
}}
>
<div
class="mx-4 w-full max-w-sm p-6 rounded-2xl border ${borderColor} ${cardBg} shadow-2xl"
>
<p class="text-sm font-medium ${textColor} mb-5">${this.message}</p>
${this.textareaPlaceholder
? html`<textarea
.value=${this.text}
@input=${(e: Event) =>
(this.text = (e.target as HTMLTextAreaElement).value)}
maxlength="200"
rows="2"
placeholder="${this.textareaPlaceholder}"
class="w-full px-3 py-2 mb-4 bg-white/5 border border-white/10 rounded-lg text-white placeholder-white/30 focus:outline-none focus:ring-2 focus:ring-amber-500/50 text-sm resize-none"
></textarea>`
: ""}
<div class="flex gap-3">
<button
@click=${() => this.handleCancel()}
?disabled=${this.disabled}
class="flex-1 px-4 py-2.5 text-xs font-bold uppercase tracking-wider rounded-xl bg-white/5 text-white/60 border border-white/10 hover:bg-white/10 hover:text-white/80 transition-all disabled:opacity-50 disabled:pointer-events-none"
>
${translateText("common.cancel")}
</button>
<button
@click=${() => this.handleConfirm()}
?disabled=${this.disabled}
class="flex-1 px-4 py-2.5 text-xs font-bold uppercase tracking-wider rounded-xl ${btnClass} transition-all disabled:opacity-50 disabled:pointer-events-none border-0"
>
${translateText("common.confirm")}
</button>
</div>
</div>
</div>
`;
}
private handleConfirm() {
this.dispatchEvent(
new CustomEvent("confirm", { detail: { text: this.text } }),
);
this.text = "";
}
private handleCancel() {
this.dispatchEvent(new CustomEvent("cancel"));
this.text = "";
}
}
+5
View File
@@ -123,6 +123,11 @@ export class DesktopNavBar extends LitElement {
data-page="page-leaderboard"
data-i18n="main.leaderboard"
></button>
<button
class="nav-menu-item text-white/70 hover:text-blue-500 font-medium tracking-wider uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
data-page="page-clan"
data-i18n="main.clans"
></button>
<div class="relative">
<button
class="nav-menu-item text-white/70 hover:text-malibu-blue font-medium tracking-wider uppercase cursor-pointer transition-colors [&.active]:text-malibu-blue "
+5
View File
@@ -114,6 +114,11 @@ export class MobileNavBar extends LitElement {
data-page="page-leaderboard"
data-i18n="main.leaderboard"
></button>
<button
class="nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
data-page="page-clan"
data-i18n="main.clans"
></button>
<div
class="no-crazygames nav-menu-item flex items-center w-full cursor-pointer"
data-page="page-item-store"
+225
View File
@@ -0,0 +1,225 @@
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { type ClanBan, fetchClanBans, unbanClanMember } from "../../ClanApi";
import { translateText } from "../../Utils";
import "../CopyButton";
import { modalHeader } from "../ui/ModalHeader";
import {
formatClanDate,
modalContainerClass,
renderLoadingSpinner,
renderMemberSearchInput,
renderServerPagination,
showToast,
} from "./ClanShared";
@customElement("clan-bans-view")
export class ClanBansView extends LitElement {
createRenderRoot() {
return this;
}
@property() clanTag = "";
@state() private bans: ClanBan[] = [];
@state() private bansTotal = 0;
@state() private bansPage = 1;
@state() private bansLimit = 20;
@state() private memberActionPending = false;
@state() private loading = false;
private memberSearch = "";
private memberSearchDebounce: ReturnType<typeof setTimeout> | null = null;
connectedCallback() {
super.connectedCallback();
this.loadBans(1);
}
disconnectedCallback() {
if (this.memberSearchDebounce) clearTimeout(this.memberSearchDebounce);
super.disconnectedCallback();
}
private async loadBans(page: number, showLoading = true) {
if (showLoading) this.loading = true;
else this.memberActionPending = true;
try {
const data = await fetchClanBans(this.clanTag, page);
if (!data) {
showToast(translateText("clan_modal.error_failed"), "red");
return;
}
if (data.results.length === 0 && page > 1) {
await this.loadBans(1, false);
return;
}
this.bans = data.results;
this.bansTotal = data.total;
this.bansLimit = data.limit;
this.bansPage = data.page;
} finally {
if (showLoading) this.loading = false;
else this.memberActionPending = false;
}
}
private async handleUnban(publicId: string) {
if (this.memberActionPending) return;
this.memberActionPending = true;
try {
const result = await unbanClanMember(this.clanTag, publicId);
if (result !== true) {
showToast(translateText(result.error), "red");
return;
}
this.bans = this.bans.filter((b) => b.publicId !== publicId);
this.bansTotal--;
showToast(translateText("clan_modal.member_unbanned"), "green");
if (this.bans.length === 0 && this.bansPage > 1) {
await this.loadBans(this.bansPage - 1, false);
}
} finally {
this.memberActionPending = false;
}
}
private onSearchInput(e: Event) {
if (this.memberSearchDebounce) clearTimeout(this.memberSearchDebounce);
this.memberSearchDebounce = setTimeout(() => {
this.memberSearch = (e.target as HTMLInputElement).value;
this.requestUpdate();
}, 200);
}
render() {
if (this.loading)
return html`<div class="${modalContainerClass}">
${modalHeader({
title: translateText("clan_modal.banned_players"),
onBack: () =>
this.dispatchEvent(
new CustomEvent("navigate-back", {
bubbles: true,
composed: true,
}),
),
ariaLabel: translateText("common.back"),
})}${renderLoadingSpinner()}
</div>`;
const totalPages = Math.ceil(this.bansTotal / this.bansLimit);
const filtered = this.memberSearch
? this.bans.filter((b) =>
b.publicId.toLowerCase().includes(this.memberSearch.toLowerCase()),
)
: this.bans;
return html`
<div class="${modalContainerClass}">
${modalHeader({
title: translateText("clan_modal.banned_players"),
onBack: () =>
this.dispatchEvent(
new CustomEvent("navigate-back", {
bubbles: true,
composed: true,
}),
),
ariaLabel: translateText("common.back"),
rightContent: html`<span
class="text-xs font-bold uppercase tracking-wider px-3 py-1 rounded-full bg-white/10 text-white/50 border border-white/10"
>${this.bansTotal}</span
>`,
})}
<div class="flex-1 overflow-y-auto custom-scrollbar mr-1 p-4 lg:p-6">
${renderMemberSearchInput(
(e) => this.onSearchInput(e),
"clan_modal.search_members_placeholder",
)}
${filtered.length === 0
? html`<div
class="flex flex-col items-center justify-center p-12 text-center"
>
<p class="text-white/40 text-sm">
${translateText("clan_modal.no_bans")}
</p>
</div>`
: html`
<div class="space-y-3">
${filtered.map(
(ban) => html`
<div
class="bg-white/5 rounded-xl border border-white/10 p-4 space-y-2"
>
<div class="flex items-center gap-2">
<div
class="w-8 h-8 rounded-full bg-red-500/20 flex items-center justify-center shrink-0"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-4 h-4 text-red-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
/>
</svg>
</div>
<copy-button
compact
.copyText=${ban.publicId}
.displayText=${ban.publicId}
.showVisibilityToggle=${false}
.showCopyIcon=${false}
></copy-button>
<span class="text-white/30 text-xs shrink-0"
>${translateText(
"clan_modal.banned_by_label",
)}</span
>
<copy-button
compact
.copyText=${ban.bannedBy}
.displayText=${ban.bannedBy}
.showVisibilityToggle=${false}
.showCopyIcon=${false}
></copy-button>
<span class="text-white/30 text-xs shrink-0"
>${formatClanDate(ban.createdAt)}</span
>
<div class="flex-1"></div>
<button
@click=${() => this.handleUnban(ban.publicId)}
?disabled=${this.memberActionPending}
class="px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider rounded-lg bg-green-500/20 text-green-400 border border-green-500/30 hover:bg-green-500/30 transition-all disabled:opacity-50 disabled:pointer-events-none shrink-0"
>
${translateText("clan_modal.unban")}
</button>
</div>
${ban.reason
? html`<div class="text-white/50 text-xs pl-10">
${translateText("clan_modal.ban_reason", {
reason: ban.reason,
})}
</div>`
: ""}
</div>
`,
)}
</div>
${totalPages > 1
? renderServerPagination(this.bansPage, totalPages, (p) =>
this.loadBans(p, false),
)
: ""}
`}
</div>
</div>
`;
}
}
@@ -0,0 +1,185 @@
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { type ClanBrowseResponse, fetchClans } from "../../ClanApi";
import { translateText } from "../../Utils";
import "./ClanCard";
import { type ClanRole, renderLoadingSpinner } from "./ClanShared";
export interface BrowseState {
data: ClanBrowseResponse | null;
page: number;
query: string;
}
@customElement("clan-browse-view")
export class ClanBrowseView extends LitElement {
createRenderRoot() {
return this;
}
@property({ type: Object }) myClanRoles: Map<string, ClanRole> = new Map();
@property({ type: Array }) myPendingRequests: { tag: string }[] = [];
@property({ type: Object }) cachedState: BrowseState | null = null;
@state() private searchQuery = "";
@state() private browseData: ClanBrowseResponse | null = null;
@state() private browsePage = 1;
@state() private loading = false;
@state() private errorMsg = "";
private searchDebounce: ReturnType<typeof setTimeout> | null = null;
private asyncGeneration = 0;
private emitState() {
this.dispatchEvent(
new CustomEvent("browse-updated", {
detail: {
data: this.browseData,
page: this.browsePage,
query: this.searchQuery,
} satisfies BrowseState,
bubbles: true,
composed: true,
}),
);
}
async loadBrowse() {
const gen = ++this.asyncGeneration;
this.loading = true;
this.errorMsg = "";
try {
const data = await fetchClans(
this.searchQuery || undefined,
this.browsePage,
);
if (gen !== this.asyncGeneration) return;
if (data === false) throw new Error("fetch failed");
this.browseData = data;
this.emitState();
} catch {
if (gen !== this.asyncGeneration) return;
this.errorMsg = translateText("clan_modal.error_loading");
} finally {
if (gen === this.asyncGeneration) this.loading = false;
}
}
private onSearchInput(e: Event) {
this.searchQuery = (e.target as HTMLInputElement).value;
if (this.searchDebounce) clearTimeout(this.searchDebounce);
this.searchDebounce = setTimeout(() => {
this.browsePage = 1;
this.loadBrowse();
}, 400);
}
connectedCallback() {
super.connectedCallback();
if (this.cachedState?.data) {
this.browseData = this.cachedState.data;
this.browsePage = this.cachedState.page;
this.searchQuery = this.cachedState.query;
} else {
this.loadBrowse();
}
}
disconnectedCallback() {
if (this.searchDebounce) clearTimeout(this.searchDebounce);
super.disconnectedCallback();
}
render() {
if (this.loading && !this.browseData)
return html`<div class="p-4 lg:p-6">${renderLoadingSpinner()}</div>`;
const totalPages = this.browseData
? Math.ceil(this.browseData.total / this.browseData.limit)
: 0;
const pendingTags = new Set(this.myPendingRequests.map((r) => r.tag));
const filtered = (this.browseData?.results ?? []).filter(
(clan) => !this.myClanRoles.has(clan.tag),
);
return html`
<div class="p-4 lg:p-6 space-y-4">
<div class="relative">
<input
type="text"
.value=${this.searchQuery}
@input=${(e: Event) => this.onSearchInput(e)}
class="w-full pl-10 pr-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/20 focus:outline-none focus:ring-2 focus:ring-malibu-blue/50 focus:border-malibu-blue/50 transition-all font-medium hover:bg-white/10 text-sm"
placeholder="${translateText("clan_modal.search_placeholder")}"
/>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-4 h-4 text-white/30 absolute left-3 top-1/2 -translate-y-1/2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
</div>
${this.errorMsg
? html`<p class="text-red-400 text-sm text-center py-4">
${this.errorMsg}
</p>`
: ""}
<div class="space-y-3">
${filtered.length === 0 && this.browseData
? html`<p class="text-white/40 text-sm text-center py-8">
${translateText("clan_modal.no_results")}
</p>`
: filtered.map(
(clan) =>
html`<clan-card
.clan=${clan}
?pending=${pendingTags.has(clan.tag)}
></clan-card>`,
)}
</div>
${totalPages > 1
? html`
<div class="flex items-center justify-center gap-2 pt-2">
<button
@click=${() => {
this.browsePage = Math.max(1, this.browsePage - 1);
this.loadBrowse();
}}
?disabled=${this.browsePage <= 1}
class="px-2 py-1 text-xs font-bold rounded-lg transition-all ${this
.browsePage <= 1
? "text-white/20 cursor-not-allowed"
: "text-white/60 hover:text-white hover:bg-white/10"}"
>
&lt;
</button>
<span class="text-xs text-white/50 font-medium">
${this.browsePage} / ${totalPages}
</span>
<button
@click=${() => {
this.browsePage = Math.min(totalPages, this.browsePage + 1);
this.loadBrowse();
}}
?disabled=${this.browsePage >= totalPages}
class="px-2 py-1 text-xs font-bold rounded-lg transition-all ${this
.browsePage >= totalPages
? "text-white/20 cursor-not-allowed"
: "text-white/60 hover:text-white hover:bg-white/10"}"
>
&gt;
</button>
</div>
`
: ""}
</div>
`;
}
}
+108
View File
@@ -0,0 +1,108 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import type { ClanInfo } from "../../ClanApi";
import { translateText } from "../../Utils";
import { translateClanRole } from "./ClanShared";
@customElement("clan-card")
export class ClanCard extends LitElement {
@property({ type: Object }) clan!: ClanInfo;
@property() clanRole?: string;
@property({ type: Boolean }) pending = false;
createRenderRoot() {
return this;
}
connectedCallback() {
super.connectedCallback();
this.style.display = "block";
}
private onClick() {
this.dispatchEvent(
new CustomEvent("clan-select", {
detail: { tag: this.clan.tag },
bubbles: true,
composed: true,
}),
);
}
private renderBadge() {
const base =
"text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-full shrink-0";
if (this.clanRole) {
const colors =
this.clanRole === "leader"
? "bg-amber-500/20 text-amber-400 border border-amber-500/30"
: "bg-malibu-blue/15 text-aquarius border border-malibu-blue/30";
return html`<span class="${base} ${colors}"
>${translateClanRole(this.clanRole)}</span
>`;
}
if (this.pending) {
return html`<span
class="${base} bg-amber-500/20 text-amber-400 border border-amber-500/30"
>${translateText("clan_modal.request_pending")}</span
>`;
}
if (this.clan.isOpen) {
return html`<span
class="${base} bg-green-500/20 text-green-400 border border-green-500/30"
>${translateText("clan_modal.open")}</span
>`;
}
return html`<span
class="${base} bg-red-500/20 text-red-400 border border-red-500/30"
>${translateText("clan_modal.invite_only")}</span
>`;
}
render() {
const clan = this.clan;
return html`
<button
@click=${() => this.onClick()}
class="w-full text-left bg-white/5 hover:bg-white/10 rounded-xl border border-white/10 hover:border-white/20 p-4 transition-all cursor-pointer group"
>
<div class="flex items-center gap-4">
<div
class="w-12 h-12 rounded-xl bg-gradient-to-br ${clan.isOpen
? "from-malibu-blue/20 to-aquarius/20"
: "from-amber-500/20 to-orange-500/20"} flex items-center justify-center border border-white/10 shrink-0"
>
<span class="text-white font-bold text-sm">${clan.tag}</span>
</div>
<div class="flex-1 min-w-0">
<span class="text-white font-bold truncate block"
>${clan.name}</span
>
<div class="flex items-center gap-4 mt-1 text-xs text-white/40">
<span
>${translateText("clan_modal.member_count", {
count: clan.memberCount ?? 0,
})}</span
>
</div>
</div>
${this.renderBadge()}
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-5 h-5 text-white/20 group-hover:text-white/50 transition-colors shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 5l7 7-7 7"
/>
</svg>
</div>
</button>
`;
}
}
@@ -0,0 +1,540 @@
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { invalidateUserMe } from "../../Api";
import {
type ClanInfo,
type ClanMember,
type ClanMemberOrder,
type ClanMemberSort,
type ClanStats,
fetchClanDetail,
fetchClanMembers,
fetchClanStats,
joinClan,
leaveClan,
} from "../../ClanApi";
import { translateText } from "../../Utils";
import "../ConfirmDialog";
import "../CopyButton";
import { modalHeader } from "../ui/ModalHeader";
import {
type ClanRole,
defaultOrderForSort,
filterMembersBySearch,
modalContainerClass,
renderClanWL,
renderLoadingSpinner,
renderMemberPagination,
renderMemberRow,
renderMemberSearchInput,
renderMemberSortControl,
renderStat,
showToast,
} from "./ClanShared";
@customElement("clan-detail-view")
export class ClanDetailView extends LitElement {
createRenderRoot() {
return this;
}
@property() clanTag = "";
@property() myPublicId: string | null = null;
@property({ type: Object }) myClanRoles: Map<string, ClanRole> = new Map();
@property({ type: Array }) myPendingRequests: {
tag: string;
name: string;
createdAt: string;
}[] = [];
@property({ type: Object }) cachedDetail: {
tag: string;
members: ClanMember[];
membersTotal: number;
pendingRequestCount: number;
stats: ClanStats | null;
} | null = null;
@property({ type: Object }) cachedClan: ClanInfo | null = null;
@state() private selectedClan: ClanInfo | null = null;
@state() private myRole: ClanRole | null = null;
@state() private members: ClanMember[] = [];
@state() private membersTotal = 0;
@state() private memberPage = 1;
@state() private membersPerPage = 10;
@state() private memberSort: ClanMemberSort = "default";
@state() private memberOrder: ClanMemberOrder = "asc";
@state() private pendingRequestCount = 0;
@state() private clanStats: ClanStats | null = null;
@state() private loading = false;
@state() private actionPending = false;
private memberSearch = "";
private memberSearchDebounce: ReturnType<typeof setTimeout> | null = null;
private asyncGeneration = 0;
connectedCallback() {
super.connectedCallback();
if (this.cachedDetail && this.cachedDetail.tag === this.clanTag) {
this.restoreFromCache(this.cachedDetail);
} else if (this.clanTag) {
this.loadDetail();
}
}
private restoreFromCache(cache: NonNullable<typeof this.cachedDetail>) {
this.selectedClan = this.cachedClan;
this.members = cache.members;
this.membersTotal = cache.membersTotal;
this.pendingRequestCount = cache.pendingRequestCount;
this.clanStats = cache.stats;
this.memberPage = 1;
const knownRole = this.myClanRoles.get(this.clanTag);
this.myRole = knownRole ?? null;
}
disconnectedCallback() {
if (this.memberSearchDebounce) clearTimeout(this.memberSearchDebounce);
super.disconnectedCallback();
}
private async loadDetail() {
const gen = ++this.asyncGeneration;
this.loading = true;
this.myRole = null;
this.pendingRequestCount = 0;
this.memberSearch = "";
const isMember = this.myClanRoles.has(this.clanTag);
const [detail, membersRes, stats] = await Promise.all([
fetchClanDetail(this.clanTag),
isMember
? fetchClanMembers(
this.clanTag,
1,
this.membersPerPage,
this.memberSort,
this.memberOrder,
)
: Promise.resolve(false as const),
fetchClanStats(this.clanTag),
]);
if (gen !== this.asyncGeneration) return;
this.clanStats = stats || null;
this.loading = false;
if (!detail) {
showToast(translateText("clan_modal.failed_to_load_clan"), "red");
this.dispatchEvent(
new CustomEvent("navigate-back", { bubbles: true, composed: true }),
);
return;
}
this.selectedClan = detail;
this.memberPage = 1;
if (membersRes) {
this.members = membersRes.results;
this.membersTotal = membersRes.total;
this.pendingRequestCount = membersRes.pendingRequests ?? 0;
const knownRole = this.myClanRoles.get(this.clanTag);
if (knownRole) {
this.myRole = knownRole;
} else {
const me = this.myPublicId
? membersRes.results.find((m) => m.publicId === this.myPublicId)
: null;
this.myRole = me ? me.role : null;
}
} else {
this.members = [];
this.membersTotal = 0;
this.myRole = null;
}
this.dispatchEvent(
new CustomEvent("detail-loaded", {
detail: {
clan: detail,
myRole: this.myRole,
members: this.members,
membersTotal: this.membersTotal,
pendingRequestCount: this.pendingRequestCount,
stats: this.clanStats,
},
bubbles: true,
composed: true,
}),
);
}
private async loadMemberPage(page: number) {
if (!this.selectedClan) return;
const res = await fetchClanMembers(
this.selectedClan.tag,
page,
this.membersPerPage,
this.memberSort,
this.memberOrder,
);
if (!res) return;
if (res.results.length === 0 && page > 1) {
await this.loadMemberPage(1);
return;
}
this.members = res.results;
this.membersTotal = res.total;
this.memberPage = page;
this.pendingRequestCount = res.pendingRequests ?? 0;
if (this.selectedClan.memberCount !== res.total) {
this.selectedClan = { ...this.selectedClan, memberCount: res.total };
}
}
private onSortChange(sort: ClanMemberSort) {
if (sort === this.memberSort) return;
this.memberSort = sort;
this.memberOrder = defaultOrderForSort(sort);
this.loadMemberPage(1);
}
private onOrderToggle() {
this.memberOrder = this.memberOrder === "asc" ? "desc" : "asc";
this.loadMemberPage(1);
}
private async handleJoin() {
if (!this.selectedClan || this.actionPending) return;
this.actionPending = true;
try {
const result = await joinClan(this.selectedClan.tag);
if ("error" in result) {
showToast(
result.reason
? translateText(result.error, { reason: result.reason })
: translateText(result.error),
"red",
);
return;
}
invalidateUserMe();
if (result.status === "joined") {
// Joining an open clan should immediately switch this detail page into
// member mode and refresh member-only data without requiring remount.
this.myRole = "member";
await this.loadMemberPage(1);
this.dispatchEvent(
new CustomEvent("clan-joined", {
detail: { tag: this.selectedClan.tag },
bubbles: true,
composed: true,
}),
);
} else {
this.dispatchEvent(
new CustomEvent("request-sent", {
detail: {
tag: this.selectedClan.tag,
name: this.selectedClan.name,
},
bubbles: true,
composed: true,
}),
);
showToast(translateText("clan_modal.join_request_sent"), "green");
}
} finally {
this.actionPending = false;
}
}
private async handleLeave() {
if (!this.selectedClan || this.actionPending) return;
this.actionPending = true;
try {
const result = await leaveClan(this.selectedClan.tag);
if (result !== true) {
showToast(translateText(result.error), "red");
return;
}
invalidateUserMe();
this.dispatchEvent(
new CustomEvent("clan-left", {
detail: { tag: this.selectedClan.tag },
bubbles: true,
composed: true,
}),
);
showToast(translateText("clan_modal.left_clan"), "green");
} finally {
this.actionPending = false;
}
}
private onSearchInput(e: Event) {
const val = (e.target as HTMLInputElement).value;
if (this.memberSearchDebounce) clearTimeout(this.memberSearchDebounce);
this.memberSearchDebounce = setTimeout(() => {
this.memberSearch = val;
this.requestUpdate();
}, 200);
}
render() {
if (this.loading) {
return html`
<div class="${modalContainerClass}">
${modalHeader({
title: translateText("clan_modal.title"),
onBack: () => this.back(),
ariaLabel: translateText("common.back"),
})}
${renderLoadingSpinner()}
</div>
`;
}
const clan = this.selectedClan;
if (!clan) return "";
const isMember = this.myRole !== null;
const isLeader = this.myRole === "leader";
const isOfficer = this.myRole === "officer";
const canManageRequests = isLeader || isOfficer;
const hasPendingRequest = this.myPendingRequests.some(
(r) => r.tag === clan.tag,
);
return html`
<div class="${modalContainerClass}">
${modalHeader({
title: clan.name,
onBack: () => this.back(),
ariaLabel: translateText("common.back"),
rightContent: html`
<span
class="text-xs font-bold uppercase tracking-wider px-3 py-1 rounded-full bg-white/10 text-white/50 border border-white/10"
>
[${clan.tag}]
</span>
`,
})}
<div class="flex-1 overflow-y-auto custom-scrollbar mr-1 p-4 lg:p-6">
<div class="space-y-6">
<div class="bg-white/5 rounded-xl border border-white/10 p-5">
<p class="text-white/70 text-sm">
${clan.description ||
translateText("clan_modal.no_description")}
</p>
</div>
<div class="grid grid-cols-2 gap-3">
${renderStat(
translateText("clan_modal.members"),
`${clan.memberCount ?? 0}`,
)}
${renderStat(
translateText("clan_modal.status"),
clan.isOpen
? translateText("clan_modal.open")
: translateText("clan_modal.invite_only"),
)}
</div>
${this.clanStats ? renderClanWL(this.clanStats) : ""}
${canManageRequests && this.pendingRequestCount > 0
? this.renderRequestsButton()
: ""}
${isMember ? this.renderMembersList() : ""}
<div class="flex flex-wrap gap-3">
${this.renderActionButtons(
isMember,
isLeader,
isOfficer,
hasPendingRequest,
clan,
)}
</div>
</div>
</div>
</div>
`;
}
private renderRequestsButton() {
return html`
<button
@click=${() =>
this.dispatchEvent(
new CustomEvent("navigate-requests", {
bubbles: true,
composed: true,
}),
)}
class="w-full flex items-center justify-between bg-amber-500/10 hover:bg-amber-500/15 rounded-xl border border-amber-500/20 p-4 transition-all cursor-pointer group"
>
<div class="flex items-center gap-3">
<div
class="w-10 h-10 rounded-xl bg-amber-500/20 flex items-center justify-center"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-5 h-5 text-amber-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"
/>
</svg>
</div>
<div class="text-left">
<span class="text-amber-400 text-sm font-bold">
${translateText("clan_modal.join_requests")}
</span>
<span class="text-amber-400/60 text-xs block">
${translateText("clan_modal.pending_requests_count", {
count: this.pendingRequestCount,
})}
</span>
</div>
</div>
<div class="flex items-center gap-2">
<span
class="px-2.5 py-1 text-xs font-bold rounded-full bg-amber-500/20 text-amber-400 border border-amber-500/30"
>
${this.pendingRequestCount}
</span>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-5 h-5 text-amber-400/40 group-hover:text-amber-400/70 transition-colors"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 5l7 7-7 7"
/>
</svg>
</div>
</button>
`;
}
private renderMembersList() {
const filtered = filterMembersBySearch(this.members, this.memberSearch);
return html`
<div class="bg-white/5 rounded-xl border border-white/10 p-5 space-y-3">
<h3 class="text-sm font-bold text-white/60 uppercase tracking-wider">
${translateText("clan_modal.members")}
</h3>
${renderMemberSearchInput(
(e: Event) => this.onSearchInput(e),
undefined,
renderMemberSortControl(
this.memberSort,
this.memberOrder,
(s) => this.onSortChange(s),
() => this.onOrderToggle(),
),
)}
<div class="space-y-2">
${filtered.map((m) => renderMemberRow(m, this.myPublicId))}
</div>
${renderMemberPagination(
this.memberPage,
this.membersTotal,
this.membersPerPage,
(p) => this.loadMemberPage(p),
(pp) => {
this.membersPerPage = pp;
this.loadMemberPage(1);
},
)}
</div>
`;
}
private renderActionButtons(
isMember: boolean,
isLeader: boolean,
isOfficer: boolean,
hasPendingRequest: boolean,
clan: ClanInfo,
) {
const buttons: ReturnType<typeof html>[] = [];
if (!isMember && hasPendingRequest) {
buttons.push(html`
<button
disabled
class="flex-1 px-6 py-3 text-sm font-bold text-white/40 uppercase tracking-wider bg-white/5 rounded-xl border border-white/10 cursor-not-allowed"
>
${translateText("clan_modal.request_pending")}
</button>
`);
} else if (!isMember && clan.isOpen) {
buttons.push(html`
<button
@click=${() => this.handleJoin()}
?disabled=${this.actionPending}
class="flex-1 px-6 py-3 text-sm font-bold text-white uppercase tracking-wider bg-malibu-blue hover:bg-aquarius active:bg-malibu-blue/80 rounded-xl transition-all disabled:opacity-50 disabled:pointer-events-none"
>
${translateText("clan_modal.join_clan")}
</button>
`);
} else if (!isMember && !clan.isOpen) {
buttons.push(html`
<button
@click=${() => this.handleJoin()}
?disabled=${this.actionPending}
class="flex-1 px-6 py-3 text-sm font-bold text-white uppercase tracking-wider bg-gradient-to-r from-amber-600 to-amber-700 hover:from-amber-500 hover:to-amber-600 rounded-xl transition-all shadow-lg hover:shadow-amber-900/40 border border-white/5 disabled:opacity-50 disabled:pointer-events-none"
>
${translateText("clan_modal.request_invite")}
</button>
`);
}
if (isMember && !isLeader) {
buttons.push(html`
<button
@click=${() => this.handleLeave()}
?disabled=${this.actionPending}
class="flex-1 px-6 py-3 text-sm font-bold text-white/70 uppercase tracking-wider bg-red-600/30 hover:bg-red-600/50 rounded-xl transition-all border border-red-500/30 disabled:opacity-50 disabled:pointer-events-none"
>
${translateText("clan_modal.leave_clan")}
</button>
`);
}
if (isLeader || isOfficer) {
buttons.push(html`
<button
@click=${() =>
this.dispatchEvent(
new CustomEvent("navigate-manage", {
bubbles: true,
composed: true,
}),
)}
class="flex-1 px-6 py-3 text-sm font-bold text-white uppercase tracking-wider bg-white/10 hover:bg-white/15 rounded-xl transition-all border border-white/10"
>
${translateText("clan_modal.manage_clan")}
</button>
`);
}
return buttons;
}
private back() {
this.dispatchEvent(
new CustomEvent("navigate-back", { bubbles: true, composed: true }),
);
}
}
@@ -0,0 +1,615 @@
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { invalidateUserMe } from "../../Api";
import {
banClanMember,
type ClanInfo,
type ClanMember,
type ClanMemberOrder,
type ClanMemberSort,
demoteMember,
disbandClan,
fetchClanMembers,
kickMember,
promoteMember,
updateClan,
} from "../../ClanApi";
import { translateText } from "../../Utils";
import "../ConfirmDialog";
import "../CopyButton";
import { modalHeader } from "../ui/ModalHeader";
import {
type ClanRole,
defaultOrderForSort,
filterMembersBySearch,
formatClanDate,
modalContainerClass,
renderLoadingSpinner,
renderMemberPagination,
renderMemberSearchInput,
renderMemberSortControl,
renderRoleIcon,
showToast,
} from "./ClanShared";
@customElement("clan-manage-view")
export class ClanManageView extends LitElement {
createRenderRoot() {
return this;
}
@property() clanTag = "";
@property({ type: Object }) selectedClan: ClanInfo | null = null;
@property() myPublicId: string | null = null;
@property() myRole: ClanRole | null = null;
@state() private manageName = "";
@state() private manageDescription = "";
@state() private manageIsOpen = true;
@state() private saving = false;
@state() private members: ClanMember[] = [];
@state() private membersTotal = 0;
@state() private memberPage = 1;
@state() private membersPerPage = 10;
@state() private memberSort: ClanMemberSort = "default";
@state() private memberOrder: ClanMemberOrder = "asc";
@state() private memberActionPending = false;
@state() private loading = false;
@state() private confirmAction: "disband" | "kick" | "ban" | null = null;
@state() private confirmTargetId: string | null = null;
@state() private pendingRequestCount = 0;
@state() private actionPending = false;
private memberSearch = "";
private memberSearchDebounce: ReturnType<typeof setTimeout> | null = null;
connectedCallback() {
super.connectedCallback();
if (this.selectedClan) {
this.manageName = this.selectedClan.name;
this.manageDescription = this.selectedClan.description ?? "";
this.manageIsOpen = this.selectedClan.isOpen ?? true;
}
this.loadMembers(1);
}
disconnectedCallback() {
if (this.memberSearchDebounce) clearTimeout(this.memberSearchDebounce);
super.disconnectedCallback();
}
private async loadMembers(page: number) {
if (this.members.length === 0) this.loading = true;
const res = await fetchClanMembers(
this.clanTag,
page,
this.membersPerPage,
this.memberSort,
this.memberOrder,
);
if (!res) {
this.loading = false;
return;
}
if (res.results.length === 0 && page > 1) {
await this.loadMembers(1);
return;
}
this.members = res.results;
this.membersTotal = res.total;
this.memberPage = page;
this.pendingRequestCount = res.pendingRequests ?? 0;
if (this.selectedClan && this.selectedClan.memberCount !== res.total) {
this.dispatchEvent(
new CustomEvent("clan-updated", {
detail: { memberCount: res.total },
bubbles: true,
composed: true,
}),
);
}
this.loading = false;
}
private async handleSaveSettings() {
const clan = this.selectedClan;
if (!clan) return;
const patch: { name?: string; description?: string; isOpen?: boolean } = {};
if (this.manageName !== clan.name) patch.name = this.manageName;
if ((this.manageDescription ?? "") !== (clan.description ?? ""))
patch.description = this.manageDescription;
if (this.manageIsOpen !== (clan.isOpen ?? true))
patch.isOpen = this.manageIsOpen;
if (Object.keys(patch).length === 0) return;
this.saving = true;
const result = await updateClan(this.clanTag, patch);
if ("error" in result) {
showToast(translateText(result.error), "red");
this.saving = false;
return;
}
this.dispatchEvent(
new CustomEvent("clan-updated", {
detail: {
name: result.name,
description: result.description,
isOpen: result.isOpen,
},
bubbles: true,
composed: true,
}),
);
this.saving = false;
showToast(translateText("clan_modal.settings_saved"), "green");
this.dispatchEvent(
new CustomEvent("navigate-detail", { bubbles: true, composed: true }),
);
}
private async handlePromote(publicId: string) {
if (this.memberActionPending) return;
this.memberActionPending = true;
try {
const result = await promoteMember(this.clanTag, publicId);
if (result !== true) {
showToast(translateText(result.error), "red");
return;
}
await this.loadMembers(this.memberPage);
showToast(translateText("clan_modal.member_promoted"), "green");
} finally {
this.memberActionPending = false;
}
}
private async handleDemote(publicId: string) {
if (this.memberActionPending) return;
this.memberActionPending = true;
try {
const result = await demoteMember(this.clanTag, publicId);
if (result !== true) {
showToast(translateText(result.error), "red");
return;
}
await this.loadMembers(this.memberPage);
showToast(translateText("clan_modal.member_demoted"), "green");
} finally {
this.memberActionPending = false;
}
}
private async handleKick(publicId: string) {
if (this.memberActionPending) return;
this.memberActionPending = true;
try {
const result = await kickMember(this.clanTag, publicId);
if (result !== true) {
showToast(translateText(result.error), "red");
return;
}
await this.loadMembers(this.memberPage);
showToast(translateText("clan_modal.member_kicked"), "green");
} finally {
this.memberActionPending = false;
}
}
private async handleBan(publicId: string, reason: string) {
if (this.memberActionPending) return;
this.memberActionPending = true;
try {
const result = await banClanMember(
this.clanTag,
publicId,
reason.trim().slice(0, 200) || undefined,
);
if (result !== true) {
showToast(translateText(result.error), "red");
return;
}
await this.loadMembers(this.memberPage);
showToast(translateText("clan_modal.member_banned"), "green");
} finally {
this.memberActionPending = false;
}
}
private async handleDisband() {
if (this.actionPending) return;
this.actionPending = true;
try {
const result = await disbandClan(this.clanTag);
if (result !== true) {
showToast(translateText(result.error), "red");
return;
}
invalidateUserMe();
this.dispatchEvent(
new CustomEvent("clan-disbanded", {
detail: { tag: this.clanTag },
bubbles: true,
composed: true,
}),
);
showToast(translateText("clan_modal.clan_disbanded"), "green");
} finally {
this.actionPending = false;
}
}
private clearConfirm() {
this.confirmAction = null;
this.confirmTargetId = null;
}
private onSearchInput(e: Event) {
if (this.memberSearchDebounce) clearTimeout(this.memberSearchDebounce);
this.memberSearchDebounce = setTimeout(() => {
this.memberSearch = (e.target as HTMLInputElement).value;
this.requestUpdate();
}, 200);
}
private onSortChange(sort: ClanMemberSort) {
if (sort === this.memberSort) return;
this.memberSort = sort;
this.memberOrder = defaultOrderForSort(sort);
this.loadMembers(1);
}
private onOrderToggle() {
this.memberOrder = this.memberOrder === "asc" ? "desc" : "asc";
this.loadMembers(1);
}
private navigateDetail = () =>
this.dispatchEvent(
new CustomEvent("navigate-detail", { bubbles: true, composed: true }),
);
render() {
if (this.loading)
return html`<div class="${modalContainerClass}">
${modalHeader({
title: translateText("clan_modal.manage_clan"),
onBack: this.navigateDetail,
ariaLabel: translateText("common.back"),
})}
${renderLoadingSpinner()}
</div>`;
const clan = this.selectedClan;
if (!clan) return "";
return html`${this.renderManageContent(clan)}${this.renderConfirmOverlay()}`;
}
private renderConfirmOverlay() {
if (!this.confirmAction) return "";
if (this.confirmAction === "disband") {
return html`<confirm-dialog
.message=${translateText("clan_modal.confirm_disband", {
tag: this.selectedClan?.tag ?? "",
name: this.selectedClan?.name ?? "",
})}
variant="danger"
?disabled=${this.actionPending}
@confirm=${() => {
this.clearConfirm();
this.handleDisband();
}}
@cancel=${() => this.clearConfirm()}
></confirm-dialog>`;
}
if (this.confirmAction === "kick" && this.confirmTargetId) {
return html`<confirm-dialog
.message=${translateText("clan_modal.confirm_kick")}
variant="warning"
?disabled=${this.memberActionPending}
@confirm=${() => {
const id = this.confirmTargetId!;
this.clearConfirm();
this.handleKick(id);
}}
@cancel=${() => this.clearConfirm()}
></confirm-dialog>`;
}
if (this.confirmAction === "ban" && this.confirmTargetId) {
return html`<confirm-dialog
.message=${translateText("clan_modal.confirm_ban")}
variant="warning"
textareaPlaceholder=${translateText("clan_modal.ban_reason_prompt")}
?disabled=${this.memberActionPending}
@confirm=${(e: CustomEvent<{ text: string }>) => {
const id = this.confirmTargetId!;
const reason = e.detail.text;
this.clearConfirm();
this.handleBan(id, reason);
}}
@cancel=${() => this.clearConfirm()}
></confirm-dialog>`;
}
return "";
}
private renderManageContent(clan: ClanInfo) {
return html`
<div class="${modalContainerClass}">
${modalHeader({
title: translateText("clan_modal.manage_clan"),
onBack: this.navigateDetail,
ariaLabel: translateText("common.back"),
rightContent: html`<span
class="text-xs font-bold uppercase tracking-wider px-3 py-1 rounded-full bg-white/10 text-white/50 border border-white/10"
>[${clan.tag}]</span
>`,
})}
<div class="flex-1 overflow-y-auto custom-scrollbar mr-1 p-4 lg:p-6">
<div class="space-y-6">
<!-- Edit Settings -->
<div
class="bg-white/5 rounded-2xl border border-white/10 p-6 space-y-5"
>
<h3
class="text-sm font-bold text-white/60 uppercase tracking-wider"
>
${translateText("clan_modal.clan_settings")}
</h3>
<div>
<label
class="block text-[10px] font-bold text-white/40 uppercase tracking-wider mb-2"
>${translateText("clan_modal.clan_name")}</label
>
<input
type="text"
.value=${this.manageName}
@input=${(e: Event) =>
(this.manageName = (e.target as HTMLInputElement).value)}
maxlength="35"
class="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/20 focus:outline-none focus:ring-2 focus:ring-malibu-blue/50 focus:border-malibu-blue/50 transition-all font-medium hover:bg-white/10 text-sm"
/>
</div>
<div>
<label
class="block text-[10px] font-bold text-white/40 uppercase tracking-wider mb-2"
>${translateText("clan_modal.description")}</label
>
<textarea
.value=${this.manageDescription}
@input=${(e: Event) =>
(this.manageDescription = (
e.target as HTMLTextAreaElement
).value)}
maxlength="200"
rows="3"
class="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/20 focus:outline-none focus:ring-2 focus:ring-malibu-blue/50 focus:border-malibu-blue/50 transition-all font-medium hover:bg-white/10 text-sm resize-none"
></textarea>
</div>
<div class="flex items-center justify-between">
<div>
<div class="text-white text-sm font-bold">
${translateText("clan_modal.open_clan")}
</div>
<div class="text-white/40 text-xs">
${translateText("clan_modal.open_clan_desc")}
</div>
</div>
<button
role="switch"
aria-checked="${this.manageIsOpen}"
aria-label="${translateText("clan_modal.open_clan")}"
@click=${() => (this.manageIsOpen = !this.manageIsOpen)}
class="relative w-12 h-7 rounded-full transition-all ${this
.manageIsOpen
? "bg-malibu-blue"
: "bg-white/20"}"
>
<div
class="absolute top-1 w-5 h-5 rounded-full bg-white shadow transition-all ${this
.manageIsOpen
? "left-6"
: "left-1"}"
></div>
</button>
</div>
<button
@click=${() => this.handleSaveSettings()}
?disabled=${this.saving}
class="w-full px-6 py-3 text-sm font-bold text-white uppercase tracking-wider bg-malibu-blue hover:bg-aquarius active:bg-malibu-blue/80 rounded-xl transition-all disabled:opacity-50"
>
${this.saving
? translateText("clan_modal.saving")
: translateText("clan_modal.save_changes")}
</button>
</div>
<!-- Member Management -->
<div
class="bg-white/5 rounded-2xl border border-white/10 p-6 space-y-4"
>
<h3
class="text-sm font-bold text-white/60 uppercase tracking-wider"
>
${translateText("clan_modal.members")}
(${clan.memberCount ?? 0})
</h3>
${renderMemberSearchInput(
(e) => this.onSearchInput(e),
undefined,
renderMemberSortControl(
this.memberSort,
this.memberOrder,
(s) => this.onSortChange(s),
() => this.onOrderToggle(),
),
)}
${(() => {
const filtered = filterMembersBySearch(
this.members,
this.memberSearch,
);
return html`
<div class="space-y-2">
${filtered.map((m) => this.renderManageMemberRow(m))}
</div>
${renderMemberPagination(
this.memberPage,
this.membersTotal,
this.membersPerPage,
(p) => this.loadMembers(p),
(pp) => {
this.membersPerPage = pp;
this.loadMembers(1);
},
)}
`;
})()}
</div>
<!-- Danger Zone -->
<div
class="bg-red-500/5 rounded-2xl border border-red-500/20 p-6 space-y-4"
>
<h3
class="text-sm font-bold text-red-400/80 uppercase tracking-wider"
>
${translateText("clan_modal.danger_zone")}
</h3>
<button
@click=${() =>
this.dispatchEvent(
new CustomEvent("navigate-bans", {
bubbles: true,
composed: true,
}),
)}
class="w-full px-6 py-3 text-sm font-bold text-red-400 uppercase tracking-wider bg-red-600/20 hover:bg-red-600/30 rounded-xl transition-all border border-red-500/30"
>
${translateText("clan_modal.banned_players")}
</button>
${this.myRole === "leader"
? html`
<button
@click=${() =>
this.dispatchEvent(
new CustomEvent("navigate-transfer", {
bubbles: true,
composed: true,
}),
)}
class="w-full px-6 py-3 text-sm font-bold text-amber-400 uppercase tracking-wider bg-amber-600/20 hover:bg-amber-600/30 rounded-xl transition-all border border-amber-500/30"
>
${translateText("clan_modal.transfer_leadership")}
</button>
<button
@click=${() => {
this.confirmAction = "disband";
this.confirmTargetId = null;
}}
?disabled=${this.confirmAction === "disband"}
class="w-full px-6 py-3 text-sm font-bold text-red-400 uppercase tracking-wider bg-red-600/20 hover:bg-red-600/30 rounded-xl transition-all border border-red-500/30 disabled:opacity-50 disabled:pointer-events-none"
>
${translateText("clan_modal.disband_clan")}
</button>
`
: ""}
</div>
</div>
</div>
</div>
`;
}
private renderManageMemberRow(member: ClanMember) {
const isLeader = member.role === "leader";
const isMe = member.publicId === this.myPublicId;
const canModerate =
!isMe &&
!isLeader &&
(this.myRole === "leader" ||
(this.myRole === "officer" && member.role === "member"));
const canPromote =
!isMe && this.myRole === "leader" && member.role === "member";
const canDemote =
!isMe && this.myRole === "leader" && member.role === "officer";
return html`
<div
class="flex flex-col py-2.5 px-3 rounded-xl border
${isMe
? "bg-malibu-blue/10 border-malibu-blue/20"
: "bg-white/5 border-white/10"}"
>
<div class="flex items-center flex-wrap gap-1.5">
<div
class="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold shrink-0
${isMe
? "bg-malibu-blue/20 text-aquarius"
: "bg-white/10 text-white/50"}"
>
${renderRoleIcon(member.role)}
</div>
<copy-button
compact
.copyText=${member.publicId}
.displayText=${member.publicId}
.showVisibilityToggle=${false}
.showCopyIcon=${false}
></copy-button>
<span class="text-white/30 text-[10px] whitespace-nowrap">
${translateText("clan_modal.joined_date", {
date: formatClanDate(member.joinedAt),
})}
</span>
<div class="flex items-center gap-1.5 ml-auto flex-wrap justify-end">
${canPromote
? html`<button
@click=${() => this.handlePromote(member.publicId)}
?disabled=${this.memberActionPending}
class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-full bg-purple-500/10 text-purple-400/70 border border-purple-500/20 hover:bg-purple-500/20 hover:text-purple-400 transition-all disabled:opacity-50 disabled:pointer-events-none"
>
${translateText("clan_modal.promote")}
</button>`
: ""}
${canDemote
? html`<button
@click=${() => this.handleDemote(member.publicId)}
?disabled=${this.memberActionPending}
class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-full bg-white/5 text-white/40 border border-white/10 hover:bg-white/10 hover:text-white/60 transition-all disabled:opacity-50 disabled:pointer-events-none"
>
${translateText("clan_modal.demote")}
</button>`
: ""}
${canModerate
? html`
<button
@click=${() => {
this.confirmAction = "kick";
this.confirmTargetId = member.publicId;
}}
?disabled=${this.memberActionPending ||
this.confirmAction !== null}
class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-full bg-red-500/10 text-red-400/70 border border-red-500/20 hover:bg-red-500/20 hover:text-red-400 transition-all disabled:opacity-50 disabled:pointer-events-none"
>
${translateText("clan_modal.kick")}
</button>
<button
@click=${() => {
this.confirmAction = "ban";
this.confirmTargetId = member.publicId;
}}
?disabled=${this.memberActionPending ||
this.confirmAction !== null}
class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-full bg-red-500/10 text-red-400/70 border border-red-500/20 hover:bg-red-500/20 hover:text-red-400 transition-all disabled:opacity-50 disabled:pointer-events-none"
>
${translateText("clan_modal.ban")}
</button>
`
: ""}
</div>
</div>
</div>
`;
}
}
@@ -0,0 +1,105 @@
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { invalidateUserMe } from "../../Api";
import { withdrawClanRequest } from "../../ClanApi";
import { translateText } from "../../Utils";
import { modalHeader } from "../ui/ModalHeader";
import { formatClanDate, modalContainerClass, showToast } from "./ClanShared";
@customElement("clan-my-requests-view")
export class ClanMyRequestsView extends LitElement {
createRenderRoot() {
return this;
}
@property({ type: Array }) myPendingRequests: {
tag: string;
name: string;
createdAt: string;
}[] = [];
@state() private actionPending = false;
async handleWithdrawRequest(tag: string) {
if (this.actionPending) return;
this.actionPending = true;
try {
const result = await withdrawClanRequest(tag);
if (result !== true) {
showToast(translateText(result.error), "red");
return;
}
invalidateUserMe();
showToast(translateText("clan_modal.join_request_cancelled"), "green");
this.dispatchEvent(
new CustomEvent("request-withdrawn", {
detail: { tag },
bubbles: true,
composed: true,
}),
);
} finally {
this.actionPending = false;
}
}
render() {
return html`
<div class="${modalContainerClass}">
${modalHeader({
title: translateText("clan_modal.pending_applications"),
onBack: () =>
this.dispatchEvent(
new CustomEvent("navigate-back", {
bubbles: true,
composed: true,
}),
),
ariaLabel: translateText("common.back"),
})}
<div class="flex-1 overflow-y-auto custom-scrollbar mr-1 p-4 lg:p-6">
${this.myPendingRequests.length === 0
? html`<p class="text-white/40 text-sm text-center py-8">
${translateText("clan_modal.no_pending_applications")}
</p>`
: html`
<div class="space-y-3">
${this.myPendingRequests.map(
(req) => html`
<div
class="flex items-center gap-3 bg-white/5 rounded-xl border border-white/10 p-4"
>
<div
class="w-10 h-10 rounded-xl bg-amber-500/10 flex items-center justify-center border border-amber-500/20 shrink-0"
>
<span class="text-amber-400 font-bold text-xs"
>${req.tag}</span
>
</div>
<div class="flex-1 min-w-0">
<span
class="text-white font-bold text-sm truncate block"
>${req.name}</span
>
<span class="text-white/30 text-xs">
${translateText("clan_modal.applied")}
${formatClanDate(req.createdAt)}
</span>
</div>
<button
@click=${() => this.handleWithdrawRequest(req.tag)}
?disabled=${this.actionPending}
class="text-[10px] font-bold uppercase tracking-wider px-3 py-1 rounded-full bg-red-500/15 text-red-400 border border-red-500/20 hover:bg-red-500/25 transition-all cursor-pointer disabled:opacity-50 disabled:pointer-events-none"
>
${translateText("clan_modal.cancel_request")}
</button>
</div>
`,
)}
</div>
`}
</div>
</div>
`;
}
}
@@ -0,0 +1,218 @@
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import {
approveClanRequest,
type ClanInfo,
type ClanJoinRequest,
denyClanRequest,
fetchClanRequests,
} from "../../ClanApi";
import { translateText } from "../../Utils";
import "../CopyButton";
import { modalHeader } from "../ui/ModalHeader";
import {
filterRequestsBySearch,
formatClanDate,
modalContainerClass,
renderLoadingSpinner,
renderMemberSearchInput,
renderServerPagination,
showToast,
} from "./ClanShared";
@customElement("clan-requests-view")
export class ClanRequestsView extends LitElement {
createRenderRoot() {
return this;
}
@property() clanTag = "";
@property({ type: Object }) selectedClan: ClanInfo | null = null;
@state() private requests: ClanJoinRequest[] = [];
@state() private requestsTotal = 0;
@state() private requestsPage = 1;
@state() private requestsLimit = 20;
@state() private memberActionPending = false;
@state() private loading = false;
private memberSearch = "";
private memberSearchDebounce: ReturnType<typeof setTimeout> | null = null;
connectedCallback() {
super.connectedCallback();
this.loadRequests(1);
}
disconnectedCallback() {
if (this.memberSearchDebounce) clearTimeout(this.memberSearchDebounce);
super.disconnectedCallback();
}
private async loadRequests(page: number, showLoading = true) {
if (showLoading) this.loading = true;
else this.memberActionPending = true;
try {
const data = await fetchClanRequests(this.clanTag, page);
if (!data) {
if (showLoading)
showToast(translateText("clan_modal.failed_to_load_requests"), "red");
return;
}
this.requests = data.results;
this.requestsTotal = data.total;
this.requestsLimit = data.limit;
this.requestsPage = page;
} finally {
if (showLoading) this.loading = false;
else this.memberActionPending = false;
}
}
private async handleApprove(publicId: string) {
if (this.memberActionPending) return;
this.memberActionPending = true;
try {
const result = await approveClanRequest(this.clanTag, publicId);
if (result !== true) {
showToast(translateText(result.error), "red");
return;
}
this.requests = this.requests.filter((r) => r.publicId !== publicId);
this.requestsTotal--;
this.dispatchEvent(
new CustomEvent("request-approved", {
detail: { publicId },
bubbles: true,
composed: true,
}),
);
showToast(translateText("clan_modal.request_approved"), "green");
} finally {
this.memberActionPending = false;
}
}
private async handleDeny(publicId: string) {
if (this.memberActionPending) return;
this.memberActionPending = true;
try {
const result = await denyClanRequest(this.clanTag, publicId);
if (result !== true) {
showToast(translateText(result.error), "red");
return;
}
this.requests = this.requests.filter((r) => r.publicId !== publicId);
this.requestsTotal--;
this.dispatchEvent(
new CustomEvent("request-denied", {
detail: { publicId },
bubbles: true,
composed: true,
}),
);
showToast(translateText("clan_modal.request_denied"), "green");
} finally {
this.memberActionPending = false;
}
}
private onSearchInput(e: Event) {
if (this.memberSearchDebounce) clearTimeout(this.memberSearchDebounce);
this.memberSearchDebounce = setTimeout(() => {
this.memberSearch = (e.target as HTMLInputElement).value;
this.requestUpdate();
}, 200);
}
render() {
if (this.loading)
return html`<div class="${modalContainerClass}">
${modalHeader({
title: translateText("clan_modal.join_requests"),
onBack: () => this.back(),
ariaLabel: translateText("common.back"),
})}${renderLoadingSpinner()}
</div>`;
const totalPages = Math.ceil(this.requestsTotal / this.requestsLimit);
const filtered = filterRequestsBySearch(this.requests, this.memberSearch);
return html`
<div class="${modalContainerClass}">
${modalHeader({
title: translateText("clan_modal.join_requests"),
onBack: () => this.back(),
ariaLabel: translateText("common.back"),
rightContent: html`<span
class="text-xs font-bold uppercase tracking-wider px-3 py-1 rounded-full bg-white/10 text-white/50 border border-white/10"
>${this.requestsTotal}</span
>`,
})}
<div class="flex-1 overflow-y-auto custom-scrollbar mr-1 p-4 lg:p-6">
${renderMemberSearchInput(
(e) => this.onSearchInput(e),
"clan_modal.search_requests_placeholder",
)}
${filtered.length === 0
? html`<div
class="flex flex-col items-center justify-center p-12 text-center"
>
<p class="text-white/40 text-sm">
${translateText("clan_modal.no_requests")}
</p>
</div>`
: html`
<div class="space-y-3">
${filtered.map(
(req) => html`
<div
class="flex items-center gap-3 bg-white/5 rounded-xl border border-white/10 p-4"
>
<div class="flex-1 min-w-0">
<copy-button
compact
.copyText=${req.publicId}
.displayText=${req.publicId}
.showVisibilityToggle=${false}
.showCopyIcon=${false}
></copy-button>
<span class="text-white/30 text-[10px]">
${translateText("clan_modal.requested_on", {
tag: this.selectedClan?.tag ?? this.clanTag,
date: formatClanDate(req.createdAt),
})}
</span>
</div>
<div class="flex items-center gap-2 shrink-0">
<button
@click=${() => this.handleApprove(req.publicId)}
?disabled=${this.memberActionPending}
class="px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider rounded-lg bg-green-500/20 text-green-400 border border-green-500/30 hover:bg-green-500/30 transition-all disabled:opacity-50 disabled:pointer-events-none"
>
${translateText("clan_modal.approve")}
</button>
<button
@click=${() => this.handleDeny(req.publicId)}
?disabled=${this.memberActionPending}
class="px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider rounded-lg bg-red-500/20 text-red-400 border border-red-500/30 hover:bg-red-500/30 transition-all disabled:opacity-50 disabled:pointer-events-none"
>
${translateText("clan_modal.deny")}
</button>
</div>
</div>
`,
)}
</div>
${totalPages > 1
? renderServerPagination(this.requestsPage, totalPages, (p) =>
this.loadRequests(p, false),
)
: ""}
`}
</div>
</div>
`;
}
private back() {
this.dispatchEvent(
new CustomEvent("navigate-back", { bubbles: true, composed: true }),
);
}
}
+477
View File
@@ -0,0 +1,477 @@
import { html, type TemplateResult } from "lit";
import type {
ClanJoinRequest,
ClanMember,
ClanMemberOrder,
ClanMemberSort,
ClanMemberStats,
ClanStats,
} from "../../ClanApi";
import { showToast, translateText } from "../../Utils";
export { renderLoadingSpinner } from "../BaseModal";
export { showToast };
export type ClanRole = "leader" | "officer" | "member";
export function defaultOrderForSort(sort: ClanMemberSort): ClanMemberOrder {
return sort === "default" ? "asc" : "desc";
}
export const modalContainerClass =
"h-full flex flex-col overflow-hidden bg-black/70 backdrop-blur-xl lg:rounded-2xl lg:border border-white/10";
const dateCache = new Map<string, string>();
export function formatClanDate(iso: string): string {
let cached = dateCache.get(iso);
if (!cached) {
cached = new Date(iso).toLocaleDateString();
dateCache.set(iso, cached);
}
return cached;
}
export function translateClanRole(role: string): string {
return translateText(`clan_modal.role_${role}`);
}
export function renderRoleIcon(role: string): TemplateResult {
if (role === "leader") {
return html`<span class="text-sm">👑</span>`;
}
if (role === "officer") {
return html`<svg
xmlns="http://www.w3.org/2000/svg"
class="w-4 h-4 text-purple-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
/>
</svg>`;
}
return html`<svg
xmlns="http://www.w3.org/2000/svg"
class="w-4 h-4 text-white/40"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>`;
}
export function renderStat(label: string, value: string): TemplateResult {
return html`
<div class="bg-white/5 rounded-xl border border-white/10 p-4 text-center">
<div
class="text-[10px] font-bold text-white/40 uppercase tracking-wider mb-1"
>
${label}
</div>
<div class="text-white font-bold text-sm truncate">${value}</div>
</div>
`;
}
export function renderClanWL(stats: ClanStats): TemplateResult | string {
if (stats.games === 0) return "";
return html`
<div class="bg-white/5 rounded-xl border border-white/10 p-5 space-y-3">
<h3 class="text-sm font-bold text-white/60 uppercase tracking-wider">
${translateText("clan_modal.statistics")}
</h3>
<div class="space-y-1.5">
${statBuckets.map(({ key, labelKey }) =>
renderWLBarRow(
translateText(labelKey),
stats.stats[key].wins,
stats.stats[key].losses,
),
)}
</div>
</div>
`;
}
function renderPaginationButtons(
currentPage: number,
totalPages: number,
onPageChange: (page: number) => void,
): TemplateResult {
return html`
<div class="flex items-center gap-1">
<button
@click=${() => onPageChange(1)}
?disabled=${currentPage <= 1}
class="px-2 py-1 text-xs font-bold rounded-lg transition-all
${currentPage <= 1
? "text-white/20 cursor-not-allowed"
: "text-white/60 hover:text-white hover:bg-white/10"}"
>
&lt;&lt;
</button>
<button
@click=${() => onPageChange(Math.max(1, currentPage - 1))}
?disabled=${currentPage <= 1}
class="px-2 py-1 text-xs font-bold rounded-lg transition-all
${currentPage <= 1
? "text-white/20 cursor-not-allowed"
: "text-white/60 hover:text-white hover:bg-white/10"}"
>
&lt;
</button>
<span class="text-xs text-white/50 font-medium px-1">
${currentPage} / ${totalPages}
</span>
<button
@click=${() => onPageChange(Math.min(totalPages, currentPage + 1))}
?disabled=${currentPage >= totalPages}
class="px-2 py-1 text-xs font-bold rounded-lg transition-all
${currentPage >= totalPages
? "text-white/20 cursor-not-allowed"
: "text-white/60 hover:text-white hover:bg-white/10"}"
>
&gt;
</button>
<button
@click=${() => onPageChange(totalPages)}
?disabled=${currentPage >= totalPages}
class="px-2 py-1 text-xs font-bold rounded-lg transition-all
${currentPage >= totalPages
? "text-white/20 cursor-not-allowed"
: "text-white/60 hover:text-white hover:bg-white/10"}"
>
&gt;&gt;
</button>
</div>
`;
}
export function renderServerPagination(
currentPage: number,
totalPages: number,
onPageChange: (page: number) => void,
): TemplateResult {
return html`
<div
class="flex items-center justify-center gap-1 pt-4 border-t border-white/10"
>
${renderPaginationButtons(currentPage, totalPages, onPageChange)}
</div>
`;
}
export function renderMemberSearchInput(
onInput: (e: Event) => void,
placeholderKey = "clan_modal.search_members_placeholder",
trailing?: TemplateResult,
): TemplateResult {
const input = html`
<div class="relative w-full sm:flex-1 sm:min-w-0">
<input
type="text"
@input=${onInput}
class="w-full h-10 pl-10 pr-4 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/20 focus:outline-none focus:ring-2 focus:ring-malibu-blue/50 focus:border-malibu-blue/50 transition-all font-medium hover:bg-white/10 text-sm"
placeholder="${translateText(placeholderKey)}"
/>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-4 h-4 text-white/30 absolute left-3 top-1/2 -translate-y-1/2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
</div>
`;
if (!trailing) {
return html`<div class="mb-3">${input}</div>`;
}
return html`
<div class="flex flex-col sm:flex-row sm:items-center gap-2 mb-3">
${input}${trailing}
</div>
`;
}
const sortOptions: { value: ClanMemberSort; labelKey: string }[] = [
{ value: "default", labelKey: "clan_modal.sort_default" },
{ value: "winsTotal", labelKey: "clan_modal.sort_total_wins" },
{ value: "lossesTotal", labelKey: "clan_modal.sort_total_losses" },
{ value: "winsFfa", labelKey: "clan_modal.sort_ffa_wins" },
{ value: "lossesFfa", labelKey: "clan_modal.sort_ffa_losses" },
{ value: "winsTeam", labelKey: "clan_modal.sort_team_wins" },
{ value: "lossesTeam", labelKey: "clan_modal.sort_team_losses" },
{ value: "winsHvn", labelKey: "clan_modal.sort_hvn_wins" },
{ value: "lossesHvn", labelKey: "clan_modal.sort_hvn_losses" },
{ value: "winsRanked", labelKey: "clan_modal.sort_ranked_wins" },
{ value: "lossesRanked", labelKey: "clan_modal.sort_ranked_losses" },
{ value: "wins1v1", labelKey: "clan_modal.sort_1v1_wins" },
{ value: "losses1v1", labelKey: "clan_modal.sort_1v1_losses" },
];
function renderOrderIcon(order: ClanMemberOrder): TemplateResult {
// asc: bars grow downward (-, --, ---). desc: bars shrink downward (---, --, -).
const widths =
order === "asc" ? ["w-1.5", "w-2.5", "w-3.5"] : ["w-3.5", "w-2.5", "w-1.5"];
return html`
<span
class="flex flex-col items-start justify-center gap-[3px] w-4 h-4"
aria-hidden="true"
>
${widths.map(
(w) => html`<span class="${w} h-[2px] bg-current rounded-sm"></span>`,
)}
</span>
`;
}
export function renderMemberSortControl(
sort: ClanMemberSort,
order: ClanMemberOrder,
onSortChange: (sort: ClanMemberSort) => void,
onOrderToggle: () => void,
): TemplateResult {
const orderLabel = translateText(
order === "asc"
? "clan_modal.sort_order_asc"
: "clan_modal.sort_order_desc",
);
return html`
<div class="flex items-center gap-2 shrink-0">
<label
class="text-[10px] font-bold text-white/40 uppercase tracking-wider hidden sm:inline"
>
${translateText("clan_modal.sort_by")}
</label>
<select
@change=${(e: Event) =>
onSortChange((e.target as HTMLSelectElement).value as ClanMemberSort)}
class="flex-1 sm:flex-none h-10 pl-3 pr-8 bg-white/5 border border-white/10 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-malibu-blue/50 focus:border-malibu-blue/50 transition-all font-medium hover:bg-white/10 text-sm appearance-none bg-no-repeat bg-[right_0.5rem_center] bg-[length:1rem] bg-[url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22rgba(255,255,255,0.5)%22 stroke-width=%222%22><path stroke-linecap=%22round%22 stroke-linejoin=%22round%22 d=%22m6 9 6 6 6-6%22/></svg>')]"
>
${sortOptions.map(
(opt) => html`
<option
value=${opt.value}
?selected=${opt.value === sort}
class="bg-neutral-900"
>
${translateText(opt.labelKey)}
</option>
`,
)}
</select>
<button
type="button"
@click=${onOrderToggle}
title=${orderLabel}
aria-label=${orderLabel}
class="h-10 w-10 shrink-0 flex items-center justify-center bg-white/5 border border-white/10 rounded-xl text-white/70 hover:text-white hover:bg-white/10 focus:outline-none focus:ring-2 focus:ring-malibu-blue/50 focus:border-malibu-blue/50 transition-all"
>
${renderOrderIcon(order)}
</button>
</div>
`;
}
const perPageOptions = [10, 25, 50] as const;
export function renderMemberPagination(
memberPage: number,
totalMembers: number,
membersPerPage: number,
onPageChange: (page: number) => void,
onPerPageChange: (perPage: number) => void,
): TemplateResult | string {
const totalPages = Math.ceil(totalMembers / membersPerPage);
if (totalMembers <= perPageOptions[0]) return "";
return html`
<div
class="flex flex-wrap items-center justify-between gap-3 pt-4 border-t border-white/10"
>
<div class="flex items-center gap-2">
<span
class="text-[10px] font-bold text-white/40 uppercase tracking-wider"
>
${translateText("clan_modal.per_page")}
</span>
${perPageOptions.map(
(opt) => html`
<button
@click=${() => onPerPageChange(opt)}
class="px-2 py-1 text-xs font-bold rounded-lg transition-all
${membersPerPage === opt
? "bg-malibu-blue/15 text-aquarius border border-malibu-blue/30"
: "text-white/40 hover:text-white/70 border border-transparent"}"
>
${opt}
</button>
`,
)}
</div>
${renderPaginationButtons(memberPage, totalPages, onPageChange)}
</div>
`;
}
const statBuckets = [
{ key: "total" as const, labelKey: "clan_modal.stats_total" },
{ key: "ffa" as const, labelKey: "clan_modal.stats_ffa" },
{ key: "team" as const, labelKey: "clan_modal.stats_team" },
{ key: "hvn" as const, labelKey: "clan_modal.stats_hvn" },
{ key: "ranked" as const, labelKey: "clan_modal.stats_ranked" },
{ key: "1v1" as const, labelKey: "clan_modal.stats_1v1" },
];
function renderWLBarRow(
label: string,
wins: number,
losses: number,
): TemplateResult {
const total = wins + losses;
const hasGames = total > 0;
const rate = hasGames ? Math.round((wins / total) * 100) : 0;
const winPct = hasGames ? (wins / total) * 100 : 0;
const lossPct = hasGames ? 100 - winPct : 0;
const rateClass = !hasGames
? "text-white/25"
: rate >= 50
? "text-green-400"
: "text-red-400/90";
return html`
<div class="flex items-center gap-2">
<span
class="text-[10px] font-bold uppercase tracking-wider text-white/50 w-14 shrink-0 truncate"
title=${label}
>
${label}
</span>
<div
class="flex-1 flex h-5 rounded-md overflow-hidden bg-white/5 text-[11px] font-bold text-white tabular-nums"
role="img"
aria-label="${wins} wins, ${losses} losses"
>
${wins > 0
? html`<div
class="bg-malibu-blue flex items-center px-1.5 overflow-hidden whitespace-nowrap"
style="width:${winPct}%"
>
${wins}W
</div>`
: ""}
${losses > 0
? html`<div
class="bg-red-500 flex items-center justify-end px-1.5 overflow-hidden whitespace-nowrap"
style="width:${lossPct}%"
>
${losses}L
</div>`
: ""}
</div>
<span
class="text-xs font-bold shrink-0 tabular-nums w-9 text-right ${rateClass}"
>
${hasGames ? `${rate}%` : "—"}
</span>
</div>
`;
}
export function renderMemberStats(
stats: ClanMemberStats | undefined,
): TemplateResult | string {
if (!stats) return "";
return html`
<div class="mt-1.5 space-y-1">
${statBuckets.map(({ key, labelKey }) =>
renderWLBarRow(
translateText(labelKey),
stats[key].wins,
stats[key].losses,
),
)}
</div>
`;
}
export function renderMemberRow(
member: ClanMember,
myPublicId: string | null,
): TemplateResult {
const isMe = member.publicId === myPublicId;
return html`
<div
class="flex flex-col py-2.5 px-3 rounded-xl border
${isMe
? "bg-malibu-blue/10 border-malibu-blue/20"
: "bg-white/5 border-white/10"}"
>
<div class="flex items-center gap-3">
<div
class="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold shrink-0
${isMe
? "bg-malibu-blue/20 text-aquarius"
: "bg-white/10 text-white/50"}"
>
${renderRoleIcon(member.role)}
</div>
<div class="flex-1 min-w-0 flex flex-col">
<div class="flex items-center justify-between gap-2">
<div class="min-w-0">
<copy-button
compact
.copyText=${member.publicId}
.displayText=${member.publicId}
.showVisibilityToggle=${false}
.showCopyIcon=${false}
></copy-button>
</div>
<span
class="text-white/30 text-[10px] shrink-0 text-right whitespace-nowrap"
>${translateText("clan_modal.joined_date", {
date: formatClanDate(member.joinedAt),
})}</span
>
</div>
</div>
</div>
${renderMemberStats(member.stats)}
</div>
`;
}
export function filterMembersBySearch(
members: ClanMember[],
search: string,
): ClanMember[] {
if (!search) return members;
const q = search.toLowerCase();
return members.filter(
(m) =>
m.publicId.toLowerCase().includes(q) || m.role.toLowerCase().includes(q),
);
}
export function filterRequestsBySearch(
requests: ClanJoinRequest[],
search: string,
): ClanJoinRequest[] {
if (!search) return requests;
const q = search.toLowerCase();
return requests.filter((r) => r.publicId.toLowerCase().includes(q));
}
@@ -0,0 +1,258 @@
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { invalidateUserMe } from "../../Api";
import {
type ClanInfo,
type ClanMember,
fetchClanMembers,
transferLeadership,
} from "../../ClanApi";
import { translateText } from "../../Utils";
import "../ConfirmDialog";
import "../CopyButton";
import { modalHeader } from "../ui/ModalHeader";
import {
filterMembersBySearch,
modalContainerClass,
renderLoadingSpinner,
renderMemberSearchInput,
renderRoleIcon,
renderServerPagination,
showToast,
translateClanRole,
} from "./ClanShared";
@customElement("clan-transfer-view")
export class ClanTransferView extends LitElement {
createRenderRoot() {
return this;
}
@property() clanTag = "";
@property({ type: Object }) selectedClan: ClanInfo | null = null;
@state() private transferTarget: string | null = null;
@state() private actionPending = false;
@state() private members: ClanMember[] = [];
@state() private membersTotal = 0;
@state() private memberPage = 1;
@state() private membersPerPage = 10;
@state() private loading = false;
@state() private errorMsg = "";
@state() private confirmAction: "transfer" | null = null;
private memberSearch = "";
private memberSearchDebounce: ReturnType<typeof setTimeout> | null = null;
connectedCallback() {
super.connectedCallback();
this.loadMembers(1);
}
disconnectedCallback() {
if (this.memberSearchDebounce) clearTimeout(this.memberSearchDebounce);
super.disconnectedCallback();
}
private async loadMembers(page: number) {
if (page === 1) this.loading = true;
const res = await fetchClanMembers(this.clanTag, page, this.membersPerPage);
if (!res) {
this.loading = false;
return;
}
if (res.results.length === 0 && page > 1) {
await this.loadMembers(1);
return;
}
this.members = res.results;
this.membersTotal = res.total;
this.memberPage = res.page;
this.transferTarget = null;
this.loading = false;
}
private async handleTransfer() {
if (!this.transferTarget || this.actionPending) return;
this.actionPending = true;
this.errorMsg = "";
try {
const result = await transferLeadership(
this.clanTag,
this.transferTarget,
);
if (result !== true) {
showToast(translateText(result.error), "red");
this.errorMsg = translateText(result.error);
return;
}
invalidateUserMe();
this.dispatchEvent(
new CustomEvent("leadership-transferred", {
detail: { tag: this.clanTag },
bubbles: true,
composed: true,
}),
);
showToast(translateText("clan_modal.leadership_transferred"), "green");
} finally {
this.actionPending = false;
}
}
private onSearchInput(e: Event) {
if (this.memberSearchDebounce) clearTimeout(this.memberSearchDebounce);
this.memberSearchDebounce = setTimeout(() => {
this.memberSearch = (e.target as HTMLInputElement).value;
this.requestUpdate();
}, 200);
}
render() {
if (this.loading)
return html`<div class="${modalContainerClass}">
${modalHeader({
title: translateText("clan_modal.transfer_leadership"),
onBack: () => this.back(),
ariaLabel: translateText("common.back"),
})}${renderLoadingSpinner()}
</div>`;
const nonLeaders = this.members.filter(
(m: ClanMember) => m.role !== "leader",
);
const totalMemberPages = Math.ceil(this.membersTotal / this.membersPerPage);
return html`
${this.renderContent(nonLeaders, totalMemberPages)}
${this.renderConfirmOverlay()}
`;
}
private renderConfirmOverlay() {
if (this.confirmAction !== "transfer" || !this.transferTarget) return "";
return html`<confirm-dialog
.message=${translateText("clan_modal.confirm_transfer", {
name: this.transferTarget,
})}
variant="warning"
?disabled=${this.actionPending}
@confirm=${() => {
this.confirmAction = null;
this.handleTransfer();
}}
@cancel=${() => {
this.confirmAction = null;
}}
></confirm-dialog>`;
}
private renderContent(nonLeaders: ClanMember[], totalMemberPages: number) {
return html`
<div class="${modalContainerClass}">
${modalHeader({
title: translateText("clan_modal.transfer_leadership"),
onBack: () => this.back(),
ariaLabel: translateText("common.back"),
})}
<div class="flex-1 overflow-y-auto custom-scrollbar mr-1 p-4 lg:p-6">
<div class="space-y-6">
${this.errorMsg
? html`<p class="text-red-400 text-sm">${this.errorMsg}</p>`
: ""}
<div
class="bg-amber-500/10 rounded-xl border border-amber-500/20 p-4"
>
<p class="text-amber-400/80 text-sm">
${translateText("clan_modal.transfer_warning")}
</p>
</div>
${renderMemberSearchInput((e) => this.onSearchInput(e))}
<div class="space-y-2">
${filterMembersBySearch(nonLeaders, this.memberSearch).map(
(m) => html`
<button
@click=${() => (this.transferTarget = m.publicId)}
class="w-full flex items-center gap-3 py-2.5 px-3 rounded-xl border cursor-pointer transition-all text-left focus:outline-none focus:ring-2 focus:ring-amber-500/50
${this.transferTarget === m.publicId
? "bg-amber-500/10 border-amber-500/20"
: "bg-white/5 border-white/10 hover:bg-white/10"}"
aria-selected=${this.transferTarget === m.publicId}
>
<div
class="w-8 h-8 rounded-full bg-white/10 flex items-center justify-center text-white/50 text-xs font-bold shrink-0"
>
${renderRoleIcon(m.role)}
</div>
<div class="flex-1 min-w-0">
<copy-button
compact
.copyText=${m.publicId}
.displayText=${m.publicId}
.showVisibilityToggle=${false}
.showCopyIcon=${false}
></copy-button>
</div>
<span
class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-full shrink-0
${m.role === "officer"
? "bg-purple-500/20 text-purple-400 border border-purple-500/30"
: "bg-white/10 text-white/40 border border-white/10"}"
>
${translateClanRole(m.role)}
</span>
${this.transferTarget === m.publicId
? html`<svg
xmlns="http://www.w3.org/2000/svg"
class="w-5 h-5 text-amber-400 shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M5 13l4 4L19 7"
/>
</svg>`
: ""}
</button>
`,
)}
</div>
${totalMemberPages > 1
? renderServerPagination(this.memberPage, totalMemberPages, (p) =>
this.loadMembers(p),
)
: ""}
<button
@click=${() => (this.confirmAction = "transfer")}
class="w-full px-6 py-3 text-sm font-bold text-white uppercase tracking-wider rounded-xl transition-all border disabled:opacity-50 disabled:pointer-events-none
${this.transferTarget && !this.actionPending
? "bg-gradient-to-r from-amber-600 to-amber-700 hover:from-amber-500 hover:to-amber-600 shadow-lg hover:shadow-amber-900/40 border-white/5"
: "bg-white/5 border-white/10 text-white/30 cursor-not-allowed"}"
?disabled=${!this.transferTarget || this.actionPending}
>
${this.transferTarget
? translateText("clan_modal.confirm_transfer", {
name: this.transferTarget,
})
: translateText("clan_modal.select_new_leader")}
</button>
</div>
</div>
</div>
`;
}
private back() {
this.dispatchEvent(
new CustomEvent("navigate-back", { bubbles: true, composed: true }),
);
}
}
@@ -3,8 +3,8 @@ import { customElement, state } from "lit/decorators.js";
import {
ClanLeaderboardEntry,
ClanLeaderboardResponse,
} from "../../../core/ApiSchemas";
import { fetchClanLeaderboard } from "../../Api";
} from "../../../core/ClanApiSchemas";
import { fetchClanLeaderboard } from "../../ClanApi";
import { translateText } from "../../Utils";
export type ClanSortColumn =