mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-03 22:50:35 +00:00
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:
@@ -2,8 +2,6 @@ import newsItemsFallback from "resources/news.json";
|
||||
import { z } from "zod";
|
||||
import type { NewsItem } from "../core/ApiSchemas";
|
||||
import {
|
||||
ClanLeaderboardResponse,
|
||||
ClanLeaderboardResponseSchema,
|
||||
NewsItemSchema,
|
||||
PlayerProfile,
|
||||
PlayerProfileSchema,
|
||||
@@ -236,40 +234,6 @@ export async function fetchGameById(
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchClanLeaderboard(): Promise<
|
||||
ClanLeaderboardResponse | false
|
||||
> {
|
||||
try {
|
||||
const res = await fetch(`${getApiBase()}/public/clans/leaderboard`, {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.warn(
|
||||
"fetchClanLeaderboard: unexpected status",
|
||||
res.status,
|
||||
res.statusText,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
const parsed = ClanLeaderboardResponseSchema.safeParse(json);
|
||||
if (!parsed.success) {
|
||||
console.warn(
|
||||
"fetchClanLeaderboard: Zod validation failed",
|
||||
parsed.error.toString(),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
} catch (err) {
|
||||
console.warn("fetchClanLeaderboard: request failed", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchPlayerLeaderboard(
|
||||
page: number,
|
||||
): Promise<RankedLeaderboardResponse | "reached_limit" | false> {
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
import {
|
||||
type ClanBansResponse,
|
||||
ClanBansResponseSchema,
|
||||
type ClanBrowseResponse,
|
||||
ClanBrowseResponseSchema,
|
||||
type ClanInfo,
|
||||
ClanInfoSchema,
|
||||
type ClanLeaderboardResponse,
|
||||
ClanLeaderboardResponseSchema,
|
||||
type ClanMembersResponse,
|
||||
ClanMembersResponseSchema,
|
||||
type ClanRequestsResponse,
|
||||
ClanRequestsResponseSchema,
|
||||
type ClanStats,
|
||||
ClanStatsSchema,
|
||||
JoinClanResponseSchema,
|
||||
} from "../core/ClanApiSchemas";
|
||||
import { getApiBase } from "./Api";
|
||||
import { getAuthHeader } from "./Auth";
|
||||
export type {
|
||||
ClanBan,
|
||||
ClanBansResponse,
|
||||
ClanBrowseResponse,
|
||||
ClanInfo,
|
||||
ClanJoinRequest,
|
||||
ClanMember,
|
||||
ClanMembersResponse,
|
||||
ClanMemberStats,
|
||||
ClanMemberWL,
|
||||
ClanRequestsResponse,
|
||||
ClanStats,
|
||||
} from "../core/ClanApiSchemas";
|
||||
|
||||
async function clanFetch(
|
||||
path: string,
|
||||
options?: RequestInit,
|
||||
): Promise<Response> {
|
||||
const url = `${getApiBase()}${path}`;
|
||||
return fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
...options?.headers,
|
||||
Authorization: await getAuthHeader(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchClanLeaderboard(): Promise<
|
||||
ClanLeaderboardResponse | false
|
||||
> {
|
||||
try {
|
||||
const res = await fetch(`${getApiBase()}/public/clans/leaderboard`, {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.warn(
|
||||
"fetchClanLeaderboard: unexpected status",
|
||||
res.status,
|
||||
res.statusText,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
const parsed = ClanLeaderboardResponseSchema.safeParse(json);
|
||||
if (!parsed.success) {
|
||||
console.warn(
|
||||
"fetchClanLeaderboard: Zod validation failed",
|
||||
parsed.error.toString(),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
} catch (err) {
|
||||
console.warn("fetchClanLeaderboard: request failed", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchClanStats(tag: string): Promise<ClanStats | false> {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${getApiBase()}/public/clan/${encodeURIComponent(tag)}`,
|
||||
{ headers: { Accept: "application/json" } },
|
||||
);
|
||||
if (!res.ok) return false;
|
||||
const json = await res.json();
|
||||
const parsed = ClanStatsSchema.safeParse(json?.clan);
|
||||
if (!parsed.success) {
|
||||
console.warn("fetchClanStats: Zod validation failed", parsed.error);
|
||||
return false;
|
||||
}
|
||||
return parsed.data;
|
||||
} catch (err) {
|
||||
console.warn("fetchClanStats: request failed", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchClans(
|
||||
search?: string,
|
||||
page = 1,
|
||||
limit = 20,
|
||||
): Promise<ClanBrowseResponse | false> {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set("page", String(page));
|
||||
params.set("limit", String(limit));
|
||||
if (search && search.length >= 3) params.set("search", search);
|
||||
const res = await clanFetch(`/clans?${params}`);
|
||||
if (!res.ok) return false;
|
||||
const json = await res.json();
|
||||
const parsed = ClanBrowseResponseSchema.safeParse(json);
|
||||
if (!parsed.success) {
|
||||
console.warn("fetchClans: Zod validation failed", parsed.error);
|
||||
return false;
|
||||
}
|
||||
return parsed.data;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchClanDetail(tag: string): Promise<ClanInfo | false> {
|
||||
try {
|
||||
const res = await clanFetch(`/clans/${encodeURIComponent(tag)}`);
|
||||
if (!res.ok) return false;
|
||||
const json = await res.json();
|
||||
const parsed = ClanInfoSchema.safeParse(json);
|
||||
if (!parsed.success) {
|
||||
console.warn("fetchClanDetail: Zod validation failed", parsed.error);
|
||||
return false;
|
||||
}
|
||||
return parsed.data;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export type ClanMemberSort =
|
||||
| "default"
|
||||
| "winsTotal"
|
||||
| "lossesTotal"
|
||||
| "winsFfa"
|
||||
| "lossesFfa"
|
||||
| "winsTeam"
|
||||
| "lossesTeam"
|
||||
| "winsHvn"
|
||||
| "lossesHvn"
|
||||
| "winsRanked"
|
||||
| "lossesRanked"
|
||||
| "wins1v1"
|
||||
| "losses1v1";
|
||||
export type ClanMemberOrder = "asc" | "desc";
|
||||
|
||||
export async function fetchClanMembers(
|
||||
tag: string,
|
||||
page = 1,
|
||||
limit = 20,
|
||||
sort: ClanMemberSort = "default",
|
||||
order?: ClanMemberOrder,
|
||||
): Promise<ClanMembersResponse | false> {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set("page", String(page));
|
||||
params.set("limit", String(limit));
|
||||
if (sort !== "default") params.set("sort", sort);
|
||||
if (order) params.set("order", order);
|
||||
const res = await clanFetch(
|
||||
`/clans/${encodeURIComponent(tag)}/members?${params}`,
|
||||
);
|
||||
if (!res.ok) return false;
|
||||
const json = await res.json();
|
||||
const parsed = ClanMembersResponseSchema.safeParse(json);
|
||||
if (!parsed.success) {
|
||||
console.warn("fetchClanMembers: Zod validation failed", parsed.error);
|
||||
return false;
|
||||
}
|
||||
return parsed.data;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function joinClan(
|
||||
tag: string,
|
||||
): Promise<
|
||||
{ status: "joined" | "requested" } | { error: string; reason?: string }
|
||||
> {
|
||||
try {
|
||||
const res = await clanFetch(`/clans/${encodeURIComponent(tag)}/join`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (res.status === 409) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
const msg = (body as { message?: string }).message ?? "";
|
||||
return {
|
||||
error: msg.toLowerCase().includes("request")
|
||||
? "clan_modal.error_request_pending"
|
||||
: "clan_modal.error_already_member",
|
||||
};
|
||||
}
|
||||
if (res.status === 429) {
|
||||
return { error: "clan_modal.error_rate_limited_generic" };
|
||||
}
|
||||
if (res.status === 403) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
const b = body as { code?: string; reason?: string | null };
|
||||
if (b.code === "BANNED") {
|
||||
return {
|
||||
error: b.reason
|
||||
? "clan_modal.error_banned_reason"
|
||||
: "clan_modal.error_banned",
|
||||
...(b.reason ? { reason: b.reason } : {}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
error: "clan_modal.error_failed",
|
||||
};
|
||||
}
|
||||
if (!res.ok) {
|
||||
return {
|
||||
error: "clan_modal.error_failed",
|
||||
};
|
||||
}
|
||||
const json = await res.json();
|
||||
const parsed = JoinClanResponseSchema.safeParse(json);
|
||||
if (!parsed.success) {
|
||||
console.warn("joinClan: Zod validation failed", parsed.error);
|
||||
return { error: "clan_modal.error_failed" };
|
||||
}
|
||||
return parsed.data;
|
||||
} catch {
|
||||
return { error: "clan_modal.error_network" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function leaveClan(
|
||||
tag: string,
|
||||
): Promise<true | { error: string }> {
|
||||
try {
|
||||
const res = await clanFetch(`/clans/${encodeURIComponent(tag)}/leave`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) {
|
||||
return {
|
||||
error: "clan_modal.error_failed",
|
||||
};
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return { error: "clan_modal.error_network" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateClan(
|
||||
tag: string,
|
||||
patch: { name?: string; description?: string; isOpen?: boolean },
|
||||
): Promise<ClanInfo | { error: string }> {
|
||||
try {
|
||||
const res = await clanFetch(`/clans/${encodeURIComponent(tag)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
if (!res.ok) {
|
||||
return {
|
||||
error: "clan_modal.error_failed",
|
||||
};
|
||||
}
|
||||
const json = await res.json();
|
||||
const parsed = ClanInfoSchema.safeParse(json);
|
||||
if (!parsed.success) {
|
||||
console.warn("updateClan: Zod validation failed", parsed.error);
|
||||
return { error: "clan_modal.error_failed" };
|
||||
}
|
||||
return parsed.data;
|
||||
} catch {
|
||||
return { error: "clan_modal.error_network" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function disbandClan(
|
||||
tag: string,
|
||||
): Promise<true | { error: string }> {
|
||||
try {
|
||||
const res = await clanFetch(`/clans/${encodeURIComponent(tag)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) {
|
||||
return {
|
||||
error: "clan_modal.error_failed",
|
||||
};
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return { error: "clan_modal.error_network" };
|
||||
}
|
||||
}
|
||||
|
||||
async function memberAction(
|
||||
tag: string,
|
||||
targetPublicId: string,
|
||||
action: string,
|
||||
): Promise<true | { error: string }> {
|
||||
try {
|
||||
const res = await clanFetch(`/clans/${encodeURIComponent(tag)}/${action}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ targetPublicId }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
return { error: "clan_modal.error_failed" };
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return { error: "clan_modal.error_network" };
|
||||
}
|
||||
}
|
||||
|
||||
export const kickMember = (tag: string, targetPublicId: string) =>
|
||||
memberAction(tag, targetPublicId, "kick");
|
||||
|
||||
export const promoteMember = (tag: string, targetPublicId: string) =>
|
||||
memberAction(tag, targetPublicId, "promote");
|
||||
|
||||
export const demoteMember = (tag: string, targetPublicId: string) =>
|
||||
memberAction(tag, targetPublicId, "demote");
|
||||
|
||||
export const transferLeadership = (tag: string, targetPublicId: string) =>
|
||||
memberAction(tag, targetPublicId, "transfer");
|
||||
|
||||
export async function fetchClanRequests(
|
||||
tag: string,
|
||||
page = 1,
|
||||
limit = 20,
|
||||
): Promise<ClanRequestsResponse | false> {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set("page", String(page));
|
||||
params.set("limit", String(limit));
|
||||
const res = await clanFetch(
|
||||
`/clans/${encodeURIComponent(tag)}/requests?${params}`,
|
||||
);
|
||||
if (!res.ok) return false;
|
||||
const json = await res.json();
|
||||
const parsed = ClanRequestsResponseSchema.safeParse(json);
|
||||
if (!parsed.success) {
|
||||
console.warn("fetchClanRequests: Zod validation failed", parsed.error);
|
||||
return false;
|
||||
}
|
||||
return parsed.data;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function approveClanRequest(
|
||||
tag: string,
|
||||
targetPublicId: string,
|
||||
): Promise<true | { error: string }> {
|
||||
try {
|
||||
const res = await clanFetch(
|
||||
`/clans/${encodeURIComponent(tag)}/requests/approve`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ targetPublicId }),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
return {
|
||||
error: "clan_modal.error_failed",
|
||||
};
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return { error: "clan_modal.error_network" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function denyClanRequest(
|
||||
tag: string,
|
||||
targetPublicId: string,
|
||||
): Promise<true | { error: string }> {
|
||||
try {
|
||||
const res = await clanFetch(
|
||||
`/clans/${encodeURIComponent(tag)}/requests/deny`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ targetPublicId }),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
return {
|
||||
error: "clan_modal.error_failed",
|
||||
};
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return { error: "clan_modal.error_network" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function withdrawClanRequest(
|
||||
tag: string,
|
||||
): Promise<true | { error: string }> {
|
||||
try {
|
||||
const res = await clanFetch(
|
||||
`/clans/${encodeURIComponent(tag)}/requests/withdraw`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
if (!res.ok) {
|
||||
return {
|
||||
error: "clan_modal.error_failed",
|
||||
};
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return { error: "clan_modal.error_network" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function banClanMember(
|
||||
tag: string,
|
||||
targetPublicId: string,
|
||||
reason?: string,
|
||||
): Promise<true | { error: string }> {
|
||||
try {
|
||||
const body: { targetPublicId: string; reason?: string } = {
|
||||
targetPublicId,
|
||||
};
|
||||
if (reason) body.reason = reason;
|
||||
const res = await clanFetch(`/clans/${encodeURIComponent(tag)}/ban`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
return { error: "clan_modal.error_failed" };
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return { error: "clan_modal.error_network" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function unbanClanMember(
|
||||
tag: string,
|
||||
targetPublicId: string,
|
||||
): Promise<true | { error: string }> {
|
||||
try {
|
||||
const res = await clanFetch(`/clans/${encodeURIComponent(tag)}/unban`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ targetPublicId }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
return { error: "clan_modal.error_failed" };
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return { error: "clan_modal.error_network" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchClanBans(
|
||||
tag: string,
|
||||
page = 1,
|
||||
limit = 20,
|
||||
): Promise<ClanBansResponse | false> {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set("page", String(page));
|
||||
params.set("limit", String(limit));
|
||||
const res = await clanFetch(
|
||||
`/clans/${encodeURIComponent(tag)}/bans?${params}`,
|
||||
);
|
||||
if (!res.ok) return false;
|
||||
const json = await res.json();
|
||||
const parsed = ClanBansResponseSchema.safeParse(json);
|
||||
if (!parsed.success) {
|
||||
console.warn("fetchClanBans: Zod validation failed", parsed.error);
|
||||
return false;
|
||||
}
|
||||
return parsed.data;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
import { html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { getUserMe, invalidateUserMe } from "./Api";
|
||||
import { type ClanInfo, type ClanMember, type ClanStats } from "./ClanApi";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
import "./components/clan/ClanBansView";
|
||||
import "./components/clan/ClanBrowseView";
|
||||
import type { BrowseState } from "./components/clan/ClanBrowseView";
|
||||
import "./components/clan/ClanCard";
|
||||
import "./components/clan/ClanDetailView";
|
||||
import "./components/clan/ClanManageView";
|
||||
import "./components/clan/ClanMyRequestsView";
|
||||
import "./components/clan/ClanRequestsView";
|
||||
import type { ClanRole } from "./components/clan/ClanShared";
|
||||
import "./components/clan/ClanTransferView";
|
||||
import "./components/ConfirmDialog";
|
||||
import "./components/CopyButton";
|
||||
import { modalHeader } from "./components/ui/ModalHeader";
|
||||
import { translateText } from "./Utils";
|
||||
|
||||
type Tab = "my-clans" | "browse";
|
||||
type View =
|
||||
| "list"
|
||||
| "detail"
|
||||
| "manage"
|
||||
| "transfer"
|
||||
| "requests"
|
||||
| "bans"
|
||||
| "my-requests";
|
||||
|
||||
@customElement("clan-modal")
|
||||
export class ClanModal extends BaseModal {
|
||||
@state() private activeTab: Tab = "my-clans";
|
||||
@state() private view: View = "list";
|
||||
@state() private loading = false;
|
||||
|
||||
@state() private myClans: ClanInfo[] = [];
|
||||
@state() private myPendingRequests: {
|
||||
tag: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
}[] = [];
|
||||
|
||||
@state() private selectedClanTag = "";
|
||||
@state() private selectedClan: ClanInfo | null = null;
|
||||
@state() private myRole: ClanRole | null = null;
|
||||
private myPublicId: string | null = null;
|
||||
@state() private myClanRoles = new Map<string, ClanRole>();
|
||||
|
||||
// Lifted browse state — survives tab switches
|
||||
private browseCache: BrowseState | null = null;
|
||||
|
||||
// Lifted detail cache — survives sub-view navigation
|
||||
private detailCache: {
|
||||
tag: string;
|
||||
members: ClanMember[];
|
||||
membersTotal: number;
|
||||
pendingRequestCount: number;
|
||||
stats: ClanStats | null;
|
||||
} | null = null;
|
||||
|
||||
render() {
|
||||
const content = this.renderInner();
|
||||
if (this.inline) return content;
|
||||
return html`
|
||||
<o-modal
|
||||
id="clan-modal"
|
||||
title=""
|
||||
?hideCloseButton=${true}
|
||||
?inline=${this.inline}
|
||||
hideHeader
|
||||
>
|
||||
${content}
|
||||
</o-modal>
|
||||
`;
|
||||
}
|
||||
|
||||
protected onOpen(): void {
|
||||
this.loadMyClans();
|
||||
}
|
||||
|
||||
protected onClose(): void {
|
||||
this.activeTab = "my-clans";
|
||||
this.view = "list";
|
||||
this.selectedClan = null;
|
||||
this.selectedClanTag = "";
|
||||
this.myRole = null;
|
||||
this.browseCache = null;
|
||||
this.detailCache = null;
|
||||
}
|
||||
|
||||
private async loadMyClans() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const me = await getUserMe();
|
||||
if (!this.isModalOpen) return;
|
||||
if (!me || Object.keys(me.user).length === 0) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("show-message", {
|
||||
detail: {
|
||||
message: translateText("clan_modal.sign_in_for_clans"),
|
||||
color: "red",
|
||||
duration: 3000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
this.close();
|
||||
window.showPage?.("page-account");
|
||||
return;
|
||||
}
|
||||
this.myPublicId = me.player.publicId;
|
||||
this.myPendingRequests = me.player.clanRequests ?? [];
|
||||
const roles = new Map<string, ClanRole>();
|
||||
const clans: ClanInfo[] = [];
|
||||
for (const c of me.player.clans ?? []) {
|
||||
roles.set(c.tag, c.role);
|
||||
clans.push({
|
||||
tag: c.tag,
|
||||
name: c.name,
|
||||
description: "",
|
||||
isOpen: false,
|
||||
memberCount: c.memberCount,
|
||||
});
|
||||
}
|
||||
this.myClanRoles = roles;
|
||||
this.myClans = clans;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private renderInner() {
|
||||
if (this.loading) {
|
||||
return html`
|
||||
<div class="${this.modalContainerClass}">
|
||||
${modalHeader({
|
||||
title: translateText("clan_modal.title"),
|
||||
onBack: () => this.close(),
|
||||
ariaLabel: translateText("common.back"),
|
||||
})}
|
||||
${this.renderLoadingSpinner()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (this.view === "my-requests") {
|
||||
return html`<clan-my-requests-view
|
||||
.myPendingRequests=${this.myPendingRequests}
|
||||
@navigate-back=${() => (this.view = "list")}
|
||||
@request-withdrawn=${(e: CustomEvent<{ tag: string }>) => {
|
||||
this.myPendingRequests = this.myPendingRequests.filter(
|
||||
(r) => r.tag !== e.detail.tag,
|
||||
);
|
||||
if (this.myPendingRequests.length === 0) this.view = "list";
|
||||
}}
|
||||
></clan-my-requests-view>`;
|
||||
}
|
||||
|
||||
if (this.selectedClanTag) {
|
||||
if (this.view === "manage") {
|
||||
return html`<clan-manage-view
|
||||
.clanTag=${this.selectedClanTag}
|
||||
.selectedClan=${this.selectedClan}
|
||||
.myPublicId=${this.myPublicId}
|
||||
.myRole=${this.myRole}
|
||||
@navigate-detail=${() => (this.view = "detail")}
|
||||
@navigate-bans=${() => (this.view = "bans")}
|
||||
@navigate-transfer=${() => (this.view = "transfer")}
|
||||
@clan-updated=${(e: CustomEvent<Partial<ClanInfo>>) => {
|
||||
if (this.selectedClan) {
|
||||
this.selectedClan = { ...this.selectedClan, ...e.detail };
|
||||
}
|
||||
this.detailCache = null;
|
||||
invalidateUserMe();
|
||||
}}
|
||||
@clan-disbanded=${(e: CustomEvent<{ tag: string }>) => {
|
||||
const roles = new Map(this.myClanRoles);
|
||||
roles.delete(e.detail.tag);
|
||||
this.myClanRoles = roles;
|
||||
this.myClans = this.myClans.filter((c) => c.tag !== e.detail.tag);
|
||||
this.selectedClan = null;
|
||||
this.selectedClanTag = "";
|
||||
this.myRole = null;
|
||||
this.view = "list";
|
||||
this.loadMyClans();
|
||||
}}
|
||||
></clan-manage-view>`;
|
||||
}
|
||||
if (this.view === "transfer") {
|
||||
return html`<clan-transfer-view
|
||||
.clanTag=${this.selectedClanTag}
|
||||
.selectedClan=${this.selectedClan}
|
||||
@navigate-back=${() => (this.view = "manage")}
|
||||
@leadership-transferred=${() => {
|
||||
this.loadMyClans().then(() =>
|
||||
this.openDetail(this.selectedClanTag),
|
||||
);
|
||||
}}
|
||||
></clan-transfer-view>`;
|
||||
}
|
||||
if (this.view === "requests") {
|
||||
return html`<clan-requests-view
|
||||
.clanTag=${this.selectedClanTag}
|
||||
.selectedClan=${this.selectedClan}
|
||||
@navigate-back=${() => (this.view = "detail")}
|
||||
@request-approved=${() => {
|
||||
if (this.selectedClan) {
|
||||
this.selectedClan = {
|
||||
...this.selectedClan,
|
||||
memberCount: (this.selectedClan.memberCount ?? 0) + 1,
|
||||
};
|
||||
}
|
||||
this.detailCache = null;
|
||||
}}
|
||||
></clan-requests-view>`;
|
||||
}
|
||||
if (this.view === "bans") {
|
||||
return html`<clan-bans-view
|
||||
.clanTag=${this.selectedClanTag}
|
||||
@navigate-back=${() => (this.view = "manage")}
|
||||
></clan-bans-view>`;
|
||||
}
|
||||
// Default: detail view
|
||||
return html`<clan-detail-view
|
||||
.clanTag=${this.selectedClanTag}
|
||||
.cachedClan=${this.selectedClan}
|
||||
.myPublicId=${this.myPublicId}
|
||||
.myClanRoles=${this.myClanRoles}
|
||||
.myPendingRequests=${this.myPendingRequests}
|
||||
.cachedDetail=${this.detailCache?.tag === this.selectedClanTag
|
||||
? this.detailCache
|
||||
: null}
|
||||
@navigate-back=${() => {
|
||||
this.view = "list";
|
||||
this.selectedClan = null;
|
||||
this.selectedClanTag = "";
|
||||
this.myRole = null;
|
||||
this.detailCache = null;
|
||||
}}
|
||||
@detail-loaded=${(
|
||||
e: CustomEvent<{
|
||||
clan: ClanInfo;
|
||||
myRole: ClanRole | null;
|
||||
members: ClanMember[];
|
||||
membersTotal: number;
|
||||
pendingRequestCount: number;
|
||||
stats: ClanStats | null;
|
||||
}>,
|
||||
) => {
|
||||
this.selectedClan = e.detail.clan;
|
||||
this.myRole = e.detail.myRole;
|
||||
this.detailCache = {
|
||||
tag: e.detail.clan.tag,
|
||||
members: e.detail.members,
|
||||
membersTotal: e.detail.membersTotal,
|
||||
pendingRequestCount: e.detail.pendingRequestCount,
|
||||
stats: e.detail.stats,
|
||||
};
|
||||
}}
|
||||
@navigate-manage=${() => (this.view = "manage")}
|
||||
@navigate-requests=${() => (this.view = "requests")}
|
||||
@clan-joined=${(e: CustomEvent<{ tag: string }>) => {
|
||||
this.myClanRoles = new Map([
|
||||
...this.myClanRoles,
|
||||
[e.detail.tag, "member" as ClanRole],
|
||||
]);
|
||||
this.openDetail(e.detail.tag);
|
||||
}}
|
||||
@clan-left=${(e: CustomEvent<{ tag: string }>) => {
|
||||
const roles = new Map(this.myClanRoles);
|
||||
roles.delete(e.detail.tag);
|
||||
this.myClanRoles = roles;
|
||||
this.selectedClan = null;
|
||||
this.selectedClanTag = "";
|
||||
this.myRole = null;
|
||||
this.view = "list";
|
||||
this.loadMyClans();
|
||||
}}
|
||||
@request-sent=${(e: CustomEvent<{ tag: string; name: string }>) => {
|
||||
this.myPendingRequests = [
|
||||
...this.myPendingRequests,
|
||||
{
|
||||
tag: e.detail.tag,
|
||||
name: e.detail.name,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
}}
|
||||
></clan-detail-view>`;
|
||||
}
|
||||
|
||||
// List view (tabs + my clans / browse)
|
||||
return html`
|
||||
<div class="${this.modalContainerClass}">
|
||||
${modalHeader({
|
||||
title: translateText("clan_modal.title"),
|
||||
onBack: () => this.close(),
|
||||
ariaLabel: translateText("common.back"),
|
||||
})}
|
||||
${this.renderTabs()}
|
||||
<div class="flex-1 overflow-y-auto custom-scrollbar mr-1">
|
||||
${this.activeTab === "my-clans"
|
||||
? this.renderMyClans()
|
||||
: html`<clan-browse-view
|
||||
.myClanRoles=${this.myClanRoles}
|
||||
.myPendingRequests=${this.myPendingRequests}
|
||||
.cachedState=${this.browseCache}
|
||||
@browse-updated=${(e: CustomEvent<BrowseState>) => {
|
||||
this.browseCache = e.detail;
|
||||
}}
|
||||
@clan-select=${(e: CustomEvent<{ tag: string }>) =>
|
||||
this.openDetail(e.detail.tag)}
|
||||
></clan-browse-view>`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private openDetail(tag: string) {
|
||||
this.selectedClanTag = tag;
|
||||
this.view = "detail";
|
||||
}
|
||||
|
||||
private renderTabs() {
|
||||
const tabs: { key: Tab; label: string }[] = [
|
||||
{ key: "my-clans", label: translateText("clan_modal.my_clans") },
|
||||
{ key: "browse", label: translateText("clan_modal.browse") },
|
||||
];
|
||||
|
||||
return html`
|
||||
<div class="flex border-b border-white/10 px-4 lg:px-6 gap-1">
|
||||
${tabs.map(
|
||||
(tab) => html`
|
||||
<button
|
||||
@click=${() => {
|
||||
this.activeTab = tab.key;
|
||||
this.view = "list";
|
||||
this.selectedClan = null;
|
||||
this.selectedClanTag = "";
|
||||
if (tab.key === "my-clans") {
|
||||
this.loadMyClans();
|
||||
}
|
||||
}}
|
||||
class="px-4 py-3 text-sm font-bold uppercase tracking-wider transition-all relative
|
||||
${this.activeTab === tab.key
|
||||
? "text-aquarius"
|
||||
: "text-white/40 hover:text-white/70"}"
|
||||
>
|
||||
${tab.label}
|
||||
${this.activeTab === tab.key
|
||||
? html`<div
|
||||
class="absolute bottom-0 left-0 right-0 h-0.5 bg-malibu-blue"
|
||||
></div>`
|
||||
: ""}
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderMyClans() {
|
||||
const hasClans = this.myClans.length > 0;
|
||||
const hasRequests = this.myPendingRequests.length > 0;
|
||||
|
||||
if (!hasClans && !hasRequests) {
|
||||
return html`
|
||||
<div class="flex flex-col items-center justify-center p-12 text-center">
|
||||
<p class="text-white/40 text-sm mb-4">
|
||||
${translateText("clan_modal.no_clans")}
|
||||
</p>
|
||||
<button
|
||||
@click=${() => (this.activeTab = "browse")}
|
||||
class="px-6 py-2 text-sm font-bold text-white uppercase tracking-wider bg-malibu-blue hover:bg-aquarius active:bg-malibu-blue/80 rounded-lg transition-all"
|
||||
>
|
||||
${translateText("clan_modal.browse")}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="p-4 lg:p-6 space-y-3">
|
||||
${hasRequests ? this.renderPendingRequestsButton() : ""}
|
||||
${this.myClans.map(
|
||||
(clan) => html`
|
||||
<clan-card
|
||||
.clan=${clan}
|
||||
.clanRole=${this.myClanRoles.get(clan.tag)}
|
||||
@clan-select=${(e: CustomEvent<{ tag: string }>) =>
|
||||
this.openDetail(e.detail.tag)}
|
||||
></clan-card>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderPendingRequestsButton() {
|
||||
const count = this.myPendingRequests.length;
|
||||
return html`
|
||||
<button
|
||||
@click=${() => (this.view = "my-requests")}
|
||||
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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-left">
|
||||
<span class="text-amber-400 text-sm font-bold">
|
||||
${translateText("clan_modal.pending_applications")}
|
||||
</span>
|
||||
<span class="text-amber-400/60 text-xs block">
|
||||
${translateText("clan_modal.pending_requests_count", {
|
||||
count,
|
||||
})}
|
||||
</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"
|
||||
>
|
||||
${count}
|
||||
</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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import "./AccountModal";
|
||||
import { getUserMe } from "./Api";
|
||||
import { userAuth } from "./Auth";
|
||||
import "./ClanModal";
|
||||
import { joinLobby, type JoinLobbyResult } from "./ClientGameRunner";
|
||||
import { getPlayerCosmeticsRefs } from "./Cosmetics";
|
||||
import { crazyGamesSDK } from "./CrazyGamesSDK";
|
||||
@@ -826,6 +827,7 @@ class Client {
|
||||
"leaderboard-button",
|
||||
"token-login",
|
||||
"matchmaking-modal",
|
||||
"clan-modal",
|
||||
"lang-selector",
|
||||
"homepage-promos",
|
||||
].forEach((tag) => {
|
||||
|
||||
@@ -678,6 +678,18 @@ export function getServerNow(
|
||||
return localNowMs + serverTimeOffsetMs;
|
||||
}
|
||||
|
||||
export function showToast(
|
||||
message: string,
|
||||
color: "red" | "green",
|
||||
duration = 3500,
|
||||
): void {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("show-message", {
|
||||
detail: { message, color, duration },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function getSecondsUntilServerTimestamp(
|
||||
targetServerTimestampMs: number,
|
||||
serverTimeOffsetMs: number,
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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 = "";
|
||||
}
|
||||
}
|
||||
@@ -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 "
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"}"
|
||||
>
|
||||
<
|
||||
</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"}"
|
||||
>
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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 }),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"}"
|
||||
>
|
||||
<<
|
||||
</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"}"
|
||||
>
|
||||
<
|
||||
</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"}"
|
||||
>
|
||||
>
|
||||
</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"}"
|
||||
>
|
||||
>>
|
||||
</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 =
|
||||
|
||||
+23
-24
@@ -14,7 +14,7 @@ const LeaderboardUsernameSchema = z
|
||||
.string()
|
||||
.transform(stripClanTagFromUsername)
|
||||
.pipe(z.string().min(1).max(64));
|
||||
const LeaderboardClanTagSchema = ClanTagSchema.unwrap();
|
||||
const RequiredClanTagSchema = ClanTagSchema.unwrap();
|
||||
|
||||
export const RefreshResponseSchema = z.object({
|
||||
token: z.string(),
|
||||
@@ -96,6 +96,26 @@ export const UserMeResponseSchema = z.object({
|
||||
hard: z.coerce.number(),
|
||||
})
|
||||
.optional(),
|
||||
clans: z
|
||||
.array(
|
||||
z.object({
|
||||
tag: RequiredClanTagSchema,
|
||||
name: z.string(),
|
||||
role: z.enum(["leader", "officer", "member"]),
|
||||
joinedAt: z.iso.datetime(),
|
||||
memberCount: z.number().int().min(1),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
clanRequests: z
|
||||
.array(
|
||||
z.object({
|
||||
tag: RequiredClanTagSchema,
|
||||
name: z.string(),
|
||||
createdAt: z.iso.datetime(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
}),
|
||||
});
|
||||
export type UserMeResponse = z.infer<typeof UserMeResponseSchema>;
|
||||
@@ -140,32 +160,11 @@ export const PlayerProfileSchema = z.object({
|
||||
});
|
||||
export type PlayerProfile = z.infer<typeof PlayerProfileSchema>;
|
||||
|
||||
export const ClanLeaderboardEntrySchema = z.object({
|
||||
clanTag: LeaderboardClanTagSchema,
|
||||
games: z.number(),
|
||||
wins: z.number(),
|
||||
losses: z.number(),
|
||||
playerSessions: z.number(),
|
||||
weightedWins: z.number(),
|
||||
weightedLosses: z.number(),
|
||||
weightedWLRatio: z.number(),
|
||||
});
|
||||
export type ClanLeaderboardEntry = z.infer<typeof ClanLeaderboardEntrySchema>;
|
||||
|
||||
export const ClanLeaderboardResponseSchema = z.object({
|
||||
start: z.iso.datetime(),
|
||||
end: z.iso.datetime(),
|
||||
clans: ClanLeaderboardEntrySchema.array(),
|
||||
});
|
||||
export type ClanLeaderboardResponse = z.infer<
|
||||
typeof ClanLeaderboardResponseSchema
|
||||
>;
|
||||
|
||||
export const PlayerLeaderboardEntrySchema = z.object({
|
||||
rank: z.number(),
|
||||
playerId: z.string(),
|
||||
username: LeaderboardUsernameSchema,
|
||||
clanTag: LeaderboardClanTagSchema.nullable().optional(),
|
||||
clanTag: RequiredClanTagSchema.nullable().optional(),
|
||||
flag: z.string().optional(),
|
||||
elo: z.number(),
|
||||
games: z.number(),
|
||||
@@ -194,7 +193,7 @@ export const RankedLeaderboardEntrySchema = z.object({
|
||||
public_id: z.string(),
|
||||
user: DiscordUserSchema.nullable().optional(),
|
||||
username: LeaderboardUsernameSchema,
|
||||
clanTag: LeaderboardClanTagSchema.nullable().optional(),
|
||||
clanTag: RequiredClanTagSchema.nullable().optional(),
|
||||
});
|
||||
export type RankedLeaderboardEntry = z.infer<
|
||||
typeof RankedLeaderboardEntrySchema
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { z } from "zod";
|
||||
import { ClanTagSchema } from "./Schemas";
|
||||
|
||||
const RequiredClanTagSchema = ClanTagSchema.unwrap();
|
||||
|
||||
export const ClanLeaderboardEntrySchema = z.object({
|
||||
clanTag: RequiredClanTagSchema,
|
||||
games: z.number(),
|
||||
wins: z.number(),
|
||||
losses: z.number(),
|
||||
playerSessions: z.number(),
|
||||
weightedWins: z.number(),
|
||||
weightedLosses: z.number(),
|
||||
weightedWLRatio: z.number(),
|
||||
});
|
||||
export type ClanLeaderboardEntry = z.infer<typeof ClanLeaderboardEntrySchema>;
|
||||
|
||||
export const ClanLeaderboardResponseSchema = z.object({
|
||||
start: z.iso.datetime(),
|
||||
end: z.iso.datetime(),
|
||||
clans: ClanLeaderboardEntrySchema.array(),
|
||||
total: z.number().optional(),
|
||||
limit: z.number().optional(),
|
||||
});
|
||||
export type ClanLeaderboardResponse = z.infer<
|
||||
typeof ClanLeaderboardResponseSchema
|
||||
>;
|
||||
|
||||
export const ClanInfoSchema = z.object({
|
||||
name: z.string().max(35),
|
||||
tag: RequiredClanTagSchema,
|
||||
description: z.string().max(200),
|
||||
isOpen: z.boolean(),
|
||||
createdAt: z.iso.datetime().optional(),
|
||||
memberCount: z.number().optional(),
|
||||
});
|
||||
export type ClanInfo = z.infer<typeof ClanInfoSchema>;
|
||||
|
||||
export const ClanBrowseResponseSchema = z.object({
|
||||
results: ClanInfoSchema.array(),
|
||||
total: z.number(),
|
||||
page: z.number(),
|
||||
limit: z.number(),
|
||||
});
|
||||
export type ClanBrowseResponse = z.infer<typeof ClanBrowseResponseSchema>;
|
||||
|
||||
export const ClanMemberWLSchema = z.object({
|
||||
wins: z.number(),
|
||||
losses: z.number(),
|
||||
});
|
||||
export type ClanMemberWL = z.infer<typeof ClanMemberWLSchema>;
|
||||
|
||||
export const ClanMemberStatsSchema = z.object({
|
||||
total: ClanMemberWLSchema,
|
||||
ffa: ClanMemberWLSchema,
|
||||
team: ClanMemberWLSchema,
|
||||
hvn: ClanMemberWLSchema,
|
||||
ranked: ClanMemberWLSchema,
|
||||
"1v1": ClanMemberWLSchema,
|
||||
});
|
||||
export type ClanMemberStats = z.infer<typeof ClanMemberStatsSchema>;
|
||||
|
||||
export const ClanMemberSchema = z.object({
|
||||
role: z.enum(["leader", "officer", "member"]),
|
||||
joinedAt: z.iso.datetime(),
|
||||
publicId: z.string(),
|
||||
stats: ClanMemberStatsSchema.optional(),
|
||||
});
|
||||
export type ClanMember = z.infer<typeof ClanMemberSchema>;
|
||||
|
||||
export const ClanMembersResponseSchema = z.object({
|
||||
results: ClanMemberSchema.array(),
|
||||
total: z.number(),
|
||||
page: z.number(),
|
||||
limit: z.number(),
|
||||
pendingRequests: z.number().optional(),
|
||||
});
|
||||
export type ClanMembersResponse = z.infer<typeof ClanMembersResponseSchema>;
|
||||
|
||||
export const ClanJoinRequestSchema = z.object({
|
||||
publicId: z.string(),
|
||||
createdAt: z.iso.datetime(),
|
||||
});
|
||||
export type ClanJoinRequest = z.infer<typeof ClanJoinRequestSchema>;
|
||||
|
||||
export const ClanRequestsResponseSchema = z.object({
|
||||
results: ClanJoinRequestSchema.array(),
|
||||
total: z.number(),
|
||||
page: z.number(),
|
||||
limit: z.number(),
|
||||
});
|
||||
export type ClanRequestsResponse = z.infer<typeof ClanRequestsResponseSchema>;
|
||||
|
||||
export const ClanBanSchema = z.object({
|
||||
publicId: z.string(),
|
||||
bannedBy: z.string(),
|
||||
reason: z.string().max(200).nullable(),
|
||||
createdAt: z.iso.datetime(),
|
||||
});
|
||||
export type ClanBan = z.infer<typeof ClanBanSchema>;
|
||||
|
||||
export const ClanBansResponseSchema = z.object({
|
||||
results: ClanBanSchema.array(),
|
||||
total: z.number(),
|
||||
page: z.number(),
|
||||
limit: z.number(),
|
||||
});
|
||||
export type ClanBansResponse = z.infer<typeof ClanBansResponseSchema>;
|
||||
|
||||
export const JoinClanResponseSchema = z.object({
|
||||
status: z.enum(["joined", "requested"]),
|
||||
});
|
||||
export type JoinClanResponse = z.infer<typeof JoinClanResponseSchema>;
|
||||
|
||||
export const ClanStatsSchema = z.object({
|
||||
clanTag: RequiredClanTagSchema,
|
||||
games: z.number(),
|
||||
wins: z.number(),
|
||||
losses: z.number(),
|
||||
stats: ClanMemberStatsSchema,
|
||||
teamTypeWL: z.record(
|
||||
z.string(),
|
||||
z.object({ wl: z.tuple([z.number(), z.number()]) }),
|
||||
),
|
||||
teamCountWL: z.record(
|
||||
z.string(),
|
||||
z.object({ wl: z.tuple([z.number(), z.number()]) }),
|
||||
),
|
||||
});
|
||||
export type ClanStats = z.infer<typeof ClanStatsSchema>;
|
||||
Reference in New Issue
Block a user