Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,9 @@
"logged_in_as": "Logged in as {email}",
"fetching_account": "Fetching account information...",
"logged_in_with_discord": "Logged in with Discord",
"recovery_email_sent": "Recovery email sent to {email}"
"recovery_email_sent": "Recovery email sent to {email}",
"make_public_profile": "Make my profile public",
"failed_update_public_setting": "Failed to update public setting"
},
"stats_modal": {
"title": "Stats",
Expand Down
94 changes: 92 additions & 2 deletions src/client/AccountModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
getApiBase,
getUserMe,
logOut,
updateUserMe,
} from "./jwt";
import { isInIframe, translateText } from "./Utils";

Expand All @@ -29,6 +30,7 @@ export class AccountModal extends LitElement {

@state() private email: string = "";
@state() private isLoadingUser: boolean = false;
@state() private isPublicOptimistic: boolean | null = null;

private loggedInEmail: string | null = null;
private loggedInDiscord: string | null = null;
Expand Down Expand Up @@ -124,6 +126,7 @@ export class AccountModal extends LitElement {
.games=${this.recentGames}
.onViewGame=${(id: string) => this.viewGame(id)}
></game-list>
${this.publicProfileToggle()}
</div>
</div>
`;
Expand Down Expand Up @@ -153,6 +156,65 @@ export class AccountModal extends LitElement {
`;
}

// Toggle for making profile stats public/private
private publicProfileToggle(): TemplateResult {
const actualValue = this.userMeResponse?.user?.public ?? false;
const checked = this.isPublicOptimistic ?? actualValue;

return html`
<div class="flex items-center justify-between w-full max-w-md mt-4 px-4">
<label class="text-white text-sm">
${translateText("account_modal.make_public_profile")}
</label>
<label class="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
class="sr-only peer"
.checked=${checked}
?disabled=${this.isPublicOptimistic !== null}
@change=${async (e: Event) => {
const input = e.target as HTMLInputElement;
const newValue = input.checked;

// Prevent concurrent requests
if (this.isPublicOptimistic !== null) return;

// Optimistically update UI immediately
this.isPublicOptimistic = newValue;

const success = await updateUserMe({ public: newValue });
if (success) {
// Update local state directly since GET /users/@me doesn't return public field
if (this.userMeResponse) {
this.userMeResponse = {
...this.userMeResponse,
user: {
...this.userMeResponse.user,
public: newValue,
},
};
}
this.isPublicOptimistic = null;
this.requestUpdate();
} else {
// Revert on failure
this.isPublicOptimistic = null;
alert(
translateText("account_modal.failed_update_public_setting"),
);
this.requestUpdate();
}
}}
/>
<div
class="w-11 h-6 rounded-full peer peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-red-300 after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:after:translate-x-full peer-checked:after:border-white"
style="background-color: ${checked ? "#4caf50" : "#d9534f"}"
></div>
</label>
</div>
`;
}

private renderLoginOptions() {
return html`
<div class="p-6">
Expand Down Expand Up @@ -282,12 +344,13 @@ export class AccountModal extends LitElement {
this.isLoadingUser = true;

void getUserMe()
.then((userMe) => {
.then(async (userMe) => {
if (userMe) {
this.loggedInEmail = userMe.user.email ?? null;
this.loggedInDiscord = userMe.user.discord?.global_name ?? null;
if (this.playerId) {
this.loadFromApi(this.playerId);
await this.loadFromApi(this.playerId);
await this.loadPublicStatus();
}
} else {
this.loggedInEmail = null;
Expand Down Expand Up @@ -315,6 +378,33 @@ export class AccountModal extends LitElement {
window.location.reload();
}

// Workaround: GET /users/@me doesn't return public status,
// so check the public profile API to determine current state
private async loadPublicStatus(): Promise<void> {
if (!this.userMeResponse?.player?.publicId) return;

try {
const response = await fetch(
`${getApiBase()}/public/player/${this.userMeResponse.player.publicId}`,
);
if (response.ok) {
const data = await response.json();
if (this.userMeResponse) {
this.userMeResponse = {
...this.userMeResponse,
user: {
...this.userMeResponse.user,
public: !!data.user,
},
};
this.requestUpdate();
}
}
} catch (err) {
console.warn("Failed to load public status:", err);
}
}

private async loadFromApi(playerId: string): Promise<void> {
try {
const data = await fetchPlayerById(playerId);
Expand Down
29 changes: 29 additions & 0 deletions src/client/jwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,35 @@ export async function getUserMe(): Promise<UserMeResponse | false> {
}
}

export async function updateUserMe(updates: {
public?: boolean;
}): Promise<boolean> {
try {
const token = getToken();
if (!token) return false;

// call the API to update the public field for the current user
const call = await fetch(getApiBase() + "/users/@me", {
method: "POST",
headers: {
authorization: `Bearer ${token}`,
"content-type": "application/json",
},
body: JSON.stringify(updates),
});
if (call.status === 401) {
clearToken();
return false;
}
if (call.status === 204) {
return true;
}
return false;
} catch (e) {
return false;
}
}

export async function fetchPlayerById(
playerId: string,
): Promise<PlayerProfile | false> {
Expand Down
1 change: 1 addition & 0 deletions src/core/ApiSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export const UserMeResponseSchema = z.object({
user: z.object({
discord: DiscordUserSchema.optional(),
email: z.string().optional(),
public: z.boolean().optional().default(false),
}),
player: z.object({
publicId: z.string(),
Expand Down
Loading