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(),