mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 07:50:45 +00:00
feat: friends panel (#3990)
## Description: # Add Friends tab to Account modal ## Summary - Adds a "Friends" tab to the Account modal, alongside Account / Stats / Games. - New `<friends-list>` Lit component covering the full friend lifecycle: send request, accept / deny incoming, withdraw outgoing, remove friend, paginated list with "Load more". - New `FriendsApi.ts` wrapping `GET/POST/DELETE /friends*` endpoints with typed error codes (`not_found` / `conflict` / `bad_request` / `request_failed`). - Zod schemas for the friend API responses in `core/ApiSchemas.ts`. - Translations under a new `friends.*` block in `en.json`. Friends and pending requests are displayed by public ID via `copy-button`, matching the existing clan convention. ## 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: evan
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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`<friends-list .myPublicId=${myPublicId}></friends-list>`;
|
||||
}
|
||||
|
||||
private renderAccountTab(): TemplateResult {
|
||||
return html`
|
||||
<div class="flex flex-col gap-6">
|
||||
|
||||
@@ -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<Response> {
|
||||
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<FriendsListResponse | false> {
|
||||
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<SendFriendRequestResponse | FriendActionError> {
|
||||
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<true | FriendActionError> {
|
||||
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<true | FriendActionError> {
|
||||
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<true | FriendActionError> {
|
||||
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";
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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`
|
||||
<div class="flex items-center justify-center p-12">
|
||||
<div
|
||||
class="w-10 h-10 border-4 border-blue-500/30 border-t-blue-500 rounded-full animate-spin"
|
||||
></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="flex flex-col gap-6">
|
||||
${this.renderAddSection()} ${this.renderRequestsSection()}
|
||||
${this.renderFriendsSection()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderAddSection(): TemplateResult {
|
||||
return html`
|
||||
<div class="bg-white/5 rounded-xl border border-white/10 p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4 flex items-center gap-2">
|
||||
<span class="text-blue-400">➕</span>
|
||||
${translateText("friends.add_friend")}
|
||||
</h3>
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
<input
|
||||
type="text"
|
||||
.value=${this.addInput}
|
||||
@input=${(e: Event) =>
|
||||
(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}
|
||||
/>
|
||||
<button
|
||||
@click=${() => void this.handleSend()}
|
||||
?disabled=${this.actionPending || this.addInput.trim().length === 0}
|
||||
class="px-4 py-2 text-sm font-bold text-white bg-blue-600 hover:bg-blue-500 rounded-lg transition-colors disabled:opacity-50 disabled:pointer-events-none"
|
||||
>
|
||||
${translateText("friends.send_request")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderRequestsSection(): TemplateResult | "" {
|
||||
if (this.incoming.length === 0 && this.outgoing.length === 0) return "";
|
||||
return html`
|
||||
<div class="bg-white/5 rounded-xl border border-white/10 p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4 flex items-center gap-2">
|
||||
<span class="text-blue-400">✉️</span>
|
||||
${translateText("friends.pending_requests")}
|
||||
</h3>
|
||||
${this.incoming.length > 0
|
||||
? html`
|
||||
<div
|
||||
class="text-[10px] font-bold uppercase tracking-wider text-white/40 mb-2"
|
||||
>
|
||||
${translateText("friends.incoming")}
|
||||
</div>
|
||||
<div class="space-y-2 mb-4">
|
||||
${this.incoming.map((r) =>
|
||||
this.renderRequestRow(r, "incoming"),
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
${this.outgoing.length > 0
|
||||
? html`
|
||||
<div
|
||||
class="text-[10px] font-bold uppercase tracking-wider text-white/40 mb-2"
|
||||
>
|
||||
${translateText("friends.outgoing")}
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
${this.outgoing.map((r) =>
|
||||
this.renderRequestRow(r, "outgoing"),
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderRequestRow(
|
||||
entry: FriendEntry,
|
||||
direction: "incoming" | "outgoing",
|
||||
): TemplateResult {
|
||||
return html`
|
||||
<div
|
||||
class="flex items-center gap-3 bg-white/5 rounded-lg border border-white/10 p-3"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<copy-button
|
||||
compact
|
||||
.copyText=${entry.publicId}
|
||||
.displayText=${entry.publicId}
|
||||
.showVisibilityToggle=${false}
|
||||
.showCopyIcon=${false}
|
||||
></copy-button>
|
||||
<div class="text-white/30 text-[10px] mt-0.5">
|
||||
${this.formatDate(entry.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
${direction === "incoming"
|
||||
? html`
|
||||
<button
|
||||
@click=${() => void this.handleAccept(entry.publicId)}
|
||||
?disabled=${this.actionPending}
|
||||
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("friends.accept")}
|
||||
</button>
|
||||
<button
|
||||
@click=${() =>
|
||||
void this.handleDenyOrWithdraw(entry.publicId, "incoming")}
|
||||
?disabled=${this.actionPending}
|
||||
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("friends.deny")}
|
||||
</button>
|
||||
`
|
||||
: html`
|
||||
<button
|
||||
@click=${() =>
|
||||
void this.handleDenyOrWithdraw(entry.publicId, "outgoing")}
|
||||
?disabled=${this.actionPending}
|
||||
class="px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider rounded-lg bg-white/10 text-white/70 border border-white/10 hover:bg-white/20 transition-all disabled:opacity-50 disabled:pointer-events-none"
|
||||
>
|
||||
${translateText("friends.withdraw")}
|
||||
</button>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderFriendsSection(): TemplateResult {
|
||||
if (this.friendsTotal === 0) {
|
||||
return html`
|
||||
<div
|
||||
class="bg-white/5 rounded-xl border border-white/10 p-12 flex flex-col items-center justify-center text-center"
|
||||
>
|
||||
<div class="text-4xl mb-3">👥</div>
|
||||
<p class="text-white/60 text-sm">
|
||||
${translateText("friends.no_friends")}
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
const hasMore = this.friends.length < this.friendsTotal;
|
||||
return html`
|
||||
<div class="bg-white/5 rounded-xl border border-white/10 p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4 flex items-center gap-2">
|
||||
<span class="text-blue-400">👥</span>
|
||||
${translateText("friends.your_friends")}
|
||||
<span class="text-xs text-white/40 font-medium">
|
||||
(${this.friendsTotal})
|
||||
</span>
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
${this.friends.map(
|
||||
(f) => html`
|
||||
<div
|
||||
class="flex items-center gap-3 bg-white/5 rounded-lg border border-white/10 p-3"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<copy-button
|
||||
compact
|
||||
.copyText=${f.publicId}
|
||||
.displayText=${f.publicId}
|
||||
.showVisibilityToggle=${false}
|
||||
.showCopyIcon=${false}
|
||||
></copy-button>
|
||||
<div class="text-white/30 text-[10px] mt-0.5">
|
||||
${translateText("friends.friends_since", {
|
||||
date: this.formatDate(f.createdAt),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click=${() => void this.handleRemove(f.publicId)}
|
||||
?disabled=${this.actionPending}
|
||||
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 shrink-0"
|
||||
>
|
||||
${translateText("friends.remove")}
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
${hasMore
|
||||
? html`
|
||||
<div class="flex justify-center mt-4">
|
||||
<button
|
||||
@click=${() => void this.loadMore()}
|
||||
?disabled=${this.actionPending}
|
||||
class="px-4 py-2 text-xs font-bold uppercase tracking-wider rounded-lg bg-white/10 text-white/80 border border-white/10 hover:bg-white/20 transition-all disabled:opacity-50 disabled:pointer-events-none"
|
||||
>
|
||||
${translateText("friends.load_more")}
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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<typeof FriendEntrySchema>;
|
||||
|
||||
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<typeof FriendsListResponseSchema>;
|
||||
|
||||
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(),
|
||||
|
||||
Reference in New Issue
Block a user