diff --git a/resources/lang/en.json b/resources/lang/en.json
index d8456c6cd..aa3d59363 100644
--- a/resources/lang/en.json
+++ b/resources/lang/en.json
@@ -434,9 +434,39 @@
"tab_account": "Account",
"tab_stats": "Stats",
"tab_games": "Games",
+ "tab_friends": "Friends",
"no_stats": "No stats available yet. Play some games to start tracking.",
"no_games": "No games played yet."
},
+ "friends": {
+ "add_friend": "Add Friend",
+ "public_id_placeholder": "Enter their public player ID",
+ "send_request": "Send Request",
+ "pending_requests": "Pending Requests",
+ "incoming": "Incoming",
+ "outgoing": "Outgoing",
+ "accept": "Accept",
+ "deny": "Deny",
+ "withdraw": "Withdraw",
+ "your_friends": "Your Friends",
+ "no_friends": "You haven't added any friends yet.",
+ "friends_since": "Friends since {date}",
+ "remove": "Remove",
+ "load_more": "Load More",
+ "confirm_remove": "Remove {publicId} from your friends?",
+ "request_sent": "Friend request sent",
+ "request_auto_accepted": "Friend request accepted — you are now friends",
+ "request_accepted": "Friend request accepted",
+ "request_denied": "Friend request denied",
+ "request_withdrawn": "Friend request withdrawn",
+ "friend_removed": "Friend removed",
+ "load_failed": "Failed to load friends",
+ "cannot_friend_self": "You can't add yourself as a friend",
+ "error_not_found": "Player not found",
+ "error_conflict": "Already friends or request already sent",
+ "error_bad_request": "Invalid request",
+ "error_generic": "Something went wrong. Please try again."
+ },
"leaderboard_modal": {
"title": "Leaderboard",
"ranked_tab": "1v1 Ranked",
diff --git a/src/client/AccountModal.ts b/src/client/AccountModal.ts
index 2b547720d..08f4f6fdc 100644
--- a/src/client/AccountModal.ts
+++ b/src/client/AccountModal.ts
@@ -18,6 +18,7 @@ import { BaseModal } from "./components/BaseModal";
import "./components/CopyButton";
import "./components/CurrencyDisplay";
import "./components/Difficulties";
+import "./components/FriendsList";
import "./components/SubscriptionPanel";
import { modalHeader } from "./components/ui/ModalHeader";
import { fetchCosmetics } from "./Cosmetics";
@@ -107,6 +108,7 @@ export class AccountModal extends BaseModal {
{ key: "account", label: translateText("account_modal.tab_account") },
{ key: "stats", label: translateText("account_modal.tab_stats") },
{ key: "games", label: translateText("account_modal.tab_games") },
+ { key: "friends", label: translateText("account_modal.tab_friends") },
],
};
}
@@ -135,11 +137,18 @@ export class AccountModal extends BaseModal {
return this.renderStatsTab();
case "games":
return this.renderGamesTab();
+ case "friends":
+ return this.renderFriendsTab();
default:
return this.renderAccountTab();
}
}
+ private renderFriendsTab(): TemplateResult {
+ const myPublicId = this.userMeResponse?.player?.publicId ?? "";
+ return html``;
+ }
+
private renderAccountTab(): TemplateResult {
return html`
diff --git a/src/client/FriendsApi.ts b/src/client/FriendsApi.ts
new file mode 100644
index 000000000..34ca3d86c
--- /dev/null
+++ b/src/client/FriendsApi.ts
@@ -0,0 +1,146 @@
+import {
+ type FriendRequestsResponse,
+ FriendRequestsResponseSchema,
+ type FriendsListResponse,
+ FriendsListResponseSchema,
+ type SendFriendRequestResponse,
+ SendFriendRequestResponseSchema,
+} from "../core/ApiSchemas";
+import { getApiBase } from "./Api";
+import { getAuthHeader } from "./Auth";
+
+async function friendsFetch(
+ path: string,
+ options?: RequestInit,
+): Promise
{
+ return fetch(`${getApiBase()}${path}`, {
+ ...options,
+ headers: {
+ Accept: "application/json",
+ ...options?.headers,
+ Authorization: await getAuthHeader(),
+ },
+ });
+}
+
+export type FriendActionError =
+ | "not_found"
+ | "conflict"
+ | "bad_request"
+ | "request_failed";
+
+export async function fetchFriendRequests(): Promise<
+ FriendRequestsResponse | false
+> {
+ try {
+ const res = await friendsFetch("/friends/requests");
+ if (!res.ok) return false;
+ const parsed = FriendRequestsResponseSchema.safeParse(await res.json());
+ if (!parsed.success) {
+ console.warn("fetchFriendRequests: zod failed", parsed.error);
+ return false;
+ }
+ return parsed.data;
+ } catch (err) {
+ console.warn("fetchFriendRequests: request failed", err);
+ return false;
+ }
+}
+
+export async function fetchFriends(
+ page: number,
+ limit: number,
+): Promise {
+ try {
+ const url = new URL(`${getApiBase()}/friends`);
+ url.searchParams.set("page", String(page));
+ url.searchParams.set("limit", String(limit));
+ const res = await fetch(url.toString(), {
+ headers: {
+ Accept: "application/json",
+ Authorization: await getAuthHeader(),
+ },
+ });
+ if (!res.ok) return false;
+ const parsed = FriendsListResponseSchema.safeParse(await res.json());
+ if (!parsed.success) {
+ console.warn("fetchFriends: zod failed", parsed.error);
+ return false;
+ }
+ return parsed.data;
+ } catch (err) {
+ console.warn("fetchFriends: request failed", err);
+ return false;
+ }
+}
+
+export async function sendFriendRequest(
+ publicId: string,
+): Promise {
+ try {
+ const res = await friendsFetch(
+ `/friends/requests/${encodeURIComponent(publicId)}`,
+ { method: "POST" },
+ );
+ if (res.status === 404) return "not_found";
+ if (res.status === 409) return "conflict";
+ if (res.status === 400) return "bad_request";
+ if (!res.ok) return "request_failed";
+ const parsed = SendFriendRequestResponseSchema.safeParse(await res.json());
+ if (!parsed.success) return "request_failed";
+ return parsed.data;
+ } catch (err) {
+ console.warn("sendFriendRequest: request failed", err);
+ return "request_failed";
+ }
+}
+
+export async function acceptFriendRequest(
+ publicId: string,
+): Promise {
+ try {
+ const res = await friendsFetch(
+ `/friends/requests/${encodeURIComponent(publicId)}/accept`,
+ { method: "POST" },
+ );
+ if (res.status === 404) return "not_found";
+ if (!res.ok) return "request_failed";
+ return true;
+ } catch (err) {
+ console.warn("acceptFriendRequest: request failed", err);
+ return "request_failed";
+ }
+}
+
+export async function deleteFriendRequest(
+ publicId: string,
+): Promise {
+ try {
+ const res = await friendsFetch(
+ `/friends/requests/${encodeURIComponent(publicId)}`,
+ { method: "DELETE" },
+ );
+ if (res.status === 404) return "not_found";
+ if (!res.ok) return "request_failed";
+ return true;
+ } catch (err) {
+ console.warn("deleteFriendRequest: request failed", err);
+ return "request_failed";
+ }
+}
+
+export async function removeFriend(
+ publicId: string,
+): Promise {
+ try {
+ const res = await friendsFetch(`/friends/${encodeURIComponent(publicId)}`, {
+ method: "DELETE",
+ });
+ if (res.status === 404) return "not_found";
+ if (!res.ok) return "request_failed";
+ return true;
+ } catch (err) {
+ console.warn("removeFriend: request failed", err);
+ return "request_failed";
+ }
+}
diff --git a/src/client/components/FriendsList.ts b/src/client/components/FriendsList.ts
new file mode 100644
index 000000000..1c5b6a502
--- /dev/null
+++ b/src/client/components/FriendsList.ts
@@ -0,0 +1,430 @@
+import { html, LitElement, TemplateResult } from "lit";
+import { customElement, property, state } from "lit/decorators.js";
+import type { FriendEntry } from "../../core/ApiSchemas";
+import {
+ acceptFriendRequest,
+ deleteFriendRequest,
+ fetchFriendRequests,
+ fetchFriends,
+ removeFriend,
+ sendFriendRequest,
+} from "../FriendsApi";
+import { showToast, translateText } from "../Utils";
+import "./CopyButton";
+
+const PAGE_LIMIT = 20;
+
+@customElement("friends-list")
+export class FriendsList extends LitElement {
+ createRenderRoot() {
+ return this;
+ }
+
+ @property({ type: String }) myPublicId = "";
+
+ @state() private loading = true;
+ @state() private actionPending = false;
+ @state() private friends: FriendEntry[] = [];
+ @state() private friendsTotal = 0;
+ @state() private friendsPage = 0;
+ @state() private incoming: FriendEntry[] = [];
+ @state() private outgoing: FriendEntry[] = [];
+ @state() private addInput = "";
+
+ connectedCallback(): void {
+ super.connectedCallback();
+ void this.loadAll();
+ }
+
+ private async loadAll(): Promise {
+ this.loading = true;
+ try {
+ const [requests, firstPage] = await Promise.all([
+ fetchFriendRequests(),
+ fetchFriends(1, PAGE_LIMIT),
+ ]);
+ if (requests) {
+ this.incoming = requests.incoming;
+ this.outgoing = requests.outgoing;
+ }
+ if (firstPage) {
+ this.friends = firstPage.results;
+ this.friendsTotal = firstPage.total;
+ this.friendsPage = firstPage.page;
+ }
+ } finally {
+ this.loading = false;
+ }
+ }
+
+ private async loadMore(): Promise {
+ if (this.actionPending) return;
+ this.actionPending = true;
+ try {
+ const next = await fetchFriends(this.friendsPage + 1, PAGE_LIMIT);
+ if (!next) {
+ showToast(translateText("friends.load_failed"), "red");
+ return;
+ }
+ this.friends = [...this.friends, ...next.results];
+ this.friendsPage = next.page;
+ this.friendsTotal = next.total;
+ } finally {
+ this.actionPending = false;
+ }
+ }
+
+ // Re-fetch every currently-loaded page from page 1. Server pagination is
+ // offset-based, so any insert/delete shifts later pages — leaving the local
+ // cache divergent from the server. Call this after add/remove to resync.
+ private async refreshFriends(): Promise {
+ const targetPages = Math.max(1, this.friendsPage);
+ const accumulated: FriendEntry[] = [];
+ let total = this.friendsTotal;
+ let lastPage = 0;
+ for (let p = 1; p <= targetPages; p++) {
+ const data = await fetchFriends(p, PAGE_LIMIT);
+ if (!data) return;
+ accumulated.push(...data.results);
+ total = data.total;
+ lastPage = data.page;
+ if (data.results.length < PAGE_LIMIT) break;
+ }
+ this.friends = accumulated;
+ this.friendsTotal = total;
+ this.friendsPage = lastPage;
+ }
+
+ private async handleSend(): Promise {
+ const target = this.addInput.trim();
+ if (!target) return;
+ if (target === this.myPublicId) {
+ showToast(translateText("friends.cannot_friend_self"), "red");
+ return;
+ }
+ if (this.actionPending) return;
+ this.actionPending = true;
+ try {
+ const result = await sendFriendRequest(target);
+ if (typeof result === "string") {
+ showToast(translateText(this.errorKey(result)), "red");
+ return;
+ }
+ this.addInput = "";
+ if (result.status === "accepted") {
+ showToast(translateText("friends.request_auto_accepted"), "green");
+ await this.loadAll();
+ } else {
+ showToast(translateText("friends.request_sent"), "green");
+ this.outgoing = [
+ ...this.outgoing,
+ { publicId: target, createdAt: new Date().toISOString() },
+ ];
+ }
+ } finally {
+ this.actionPending = false;
+ }
+ }
+
+ private async handleAccept(publicId: string): Promise {
+ if (this.actionPending) return;
+ this.actionPending = true;
+ try {
+ const result = await acceptFriendRequest(publicId);
+ if (result !== true) {
+ showToast(translateText(this.errorKey(result)), "red");
+ return;
+ }
+ this.incoming = this.incoming.filter((r) => r.publicId !== publicId);
+ this.friends = [
+ { publicId, createdAt: new Date().toISOString() },
+ ...this.friends,
+ ];
+ this.friendsTotal++;
+ showToast(translateText("friends.request_accepted"), "green");
+ await this.refreshFriends();
+ } finally {
+ this.actionPending = false;
+ }
+ }
+
+ private async handleDenyOrWithdraw(
+ publicId: string,
+ direction: "incoming" | "outgoing",
+ ): Promise {
+ if (this.actionPending) return;
+ this.actionPending = true;
+ try {
+ const result = await deleteFriendRequest(publicId);
+ if (result !== true) {
+ showToast(translateText(this.errorKey(result)), "red");
+ return;
+ }
+ if (direction === "incoming") {
+ this.incoming = this.incoming.filter((r) => r.publicId !== publicId);
+ showToast(translateText("friends.request_denied"), "green");
+ } else {
+ this.outgoing = this.outgoing.filter((r) => r.publicId !== publicId);
+ showToast(translateText("friends.request_withdrawn"), "green");
+ }
+ } finally {
+ this.actionPending = false;
+ }
+ }
+
+ private async handleRemove(publicId: string): Promise {
+ if (this.actionPending) return;
+ const confirmed = window.confirm(
+ translateText("friends.confirm_remove", { publicId }),
+ );
+ if (!confirmed) return;
+ this.actionPending = true;
+ try {
+ const result = await removeFriend(publicId);
+ if (result !== true) {
+ showToast(translateText(this.errorKey(result)), "red");
+ return;
+ }
+ this.friends = this.friends.filter((f) => f.publicId !== publicId);
+ this.friendsTotal = Math.max(0, this.friendsTotal - 1);
+ showToast(translateText("friends.friend_removed"), "green");
+ await this.refreshFriends();
+ } finally {
+ this.actionPending = false;
+ }
+ }
+
+ private errorKey(err: string): string {
+ switch (err) {
+ case "not_found":
+ return "friends.error_not_found";
+ case "conflict":
+ return "friends.error_conflict";
+ case "bad_request":
+ return "friends.error_bad_request";
+ default:
+ return "friends.error_generic";
+ }
+ }
+
+ private formatDate(iso: string): string {
+ return new Date(iso).toLocaleDateString();
+ }
+
+ render(): TemplateResult {
+ if (this.loading) {
+ return html`
+
+ `;
+ }
+
+ return html`
+
+ ${this.renderAddSection()} ${this.renderRequestsSection()}
+ ${this.renderFriendsSection()}
+
+ `;
+ }
+
+ private renderAddSection(): TemplateResult {
+ return html`
+
+
+ ➕
+ ${translateText("friends.add_friend")}
+
+
+
+ (this.addInput = (e.target as HTMLInputElement).value)}
+ @keydown=${(e: KeyboardEvent) => {
+ if (e.key === "Enter") void this.handleSend();
+ }}
+ class="flex-1 px-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder-white/30 focus:outline-none focus:ring-2 focus:ring-malibu-blue/50 focus:border-malibu-blue/50 transition-all font-mono text-sm"
+ placeholder=${translateText("friends.public_id_placeholder")}
+ maxlength="22"
+ ?disabled=${this.actionPending}
+ />
+
+
+
+ `;
+ }
+
+ private renderRequestsSection(): TemplateResult | "" {
+ if (this.incoming.length === 0 && this.outgoing.length === 0) return "";
+ return html`
+
+
+ ✉️
+ ${translateText("friends.pending_requests")}
+
+ ${this.incoming.length > 0
+ ? html`
+
+ ${translateText("friends.incoming")}
+
+
+ ${this.incoming.map((r) =>
+ this.renderRequestRow(r, "incoming"),
+ )}
+
+ `
+ : ""}
+ ${this.outgoing.length > 0
+ ? html`
+
+ ${translateText("friends.outgoing")}
+
+
+ ${this.outgoing.map((r) =>
+ this.renderRequestRow(r, "outgoing"),
+ )}
+
+ `
+ : ""}
+
+ `;
+ }
+
+ private renderRequestRow(
+ entry: FriendEntry,
+ direction: "incoming" | "outgoing",
+ ): TemplateResult {
+ return html`
+
+
+
+
+ ${this.formatDate(entry.createdAt)}
+
+
+
+ ${direction === "incoming"
+ ? html`
+
+
+ `
+ : html`
+
+ `}
+
+
+ `;
+ }
+
+ private renderFriendsSection(): TemplateResult {
+ if (this.friendsTotal === 0) {
+ return html`
+
+
👥
+
+ ${translateText("friends.no_friends")}
+
+
+ `;
+ }
+ const hasMore = this.friends.length < this.friendsTotal;
+ return html`
+
+
+ 👥
+ ${translateText("friends.your_friends")}
+
+ (${this.friendsTotal})
+
+
+
+ ${this.friends.map(
+ (f) => html`
+
+
+
+
+ ${translateText("friends.friends_since", {
+ date: this.formatDate(f.createdAt),
+ })}
+
+
+
+
+ `,
+ )}
+
+ ${hasMore
+ ? html`
+
+
+
+ `
+ : ""}
+
+ `;
+ }
+}
diff --git a/src/core/ApiSchemas.ts b/src/core/ApiSchemas.ts
index 44a19e2f5..78022ce90 100644
--- a/src/core/ApiSchemas.ts
+++ b/src/core/ApiSchemas.ts
@@ -218,6 +218,35 @@ export type RankedLeaderboardResponse = z.infer<
typeof RankedLeaderboardResponseSchema
>;
+export const FriendEntrySchema = z.object({
+ publicId: z.string(),
+ createdAt: z.iso.datetime(),
+});
+export type FriendEntry = z.infer;
+
+export const FriendRequestsResponseSchema = z.object({
+ incoming: FriendEntrySchema.array(),
+ outgoing: FriendEntrySchema.array(),
+});
+export type FriendRequestsResponse = z.infer<
+ typeof FriendRequestsResponseSchema
+>;
+
+export const FriendsListResponseSchema = z.object({
+ results: FriendEntrySchema.array(),
+ total: z.number(),
+ page: z.number(),
+ limit: z.number(),
+});
+export type FriendsListResponse = z.infer;
+
+export const SendFriendRequestResponseSchema = z.object({
+ status: z.enum(["requested", "accepted"]),
+});
+export type SendFriendRequestResponse = z.infer<
+ typeof SendFriendRequestResponseSchema
+>;
+
export const NewsItemSchema = z.object({
id: z.string(),
title: z.string(),