mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 11:10:42 +00:00
test
This commit is contained in:
@@ -220,6 +220,13 @@
|
||||
"title": "Account",
|
||||
"connected_as": "Connected as",
|
||||
"stats_overview": "Stats Overview",
|
||||
"achievements": "Achievements",
|
||||
"achievement_label": "Achievement",
|
||||
"achieved_on": "Achieved on",
|
||||
"status": "Status",
|
||||
"no_achievements": "No player achievements unlocked yet.",
|
||||
"not_unlocked_yet": "Not unlocked yet",
|
||||
"unknown_difficulty": "Unknown",
|
||||
"link_discord": "Link Discord Account",
|
||||
"log_out": "Log Out",
|
||||
"sign_in_desc": "Sign in to save your stats and progress",
|
||||
@@ -235,6 +242,10 @@
|
||||
"enter_email_address": "Please enter an email address",
|
||||
"personal_player_id": "Personal Player ID:"
|
||||
},
|
||||
"achivements": {
|
||||
"win_no_nukes": "Win Without Nukes",
|
||||
"win_no_nukes_desc": "Win a free-for-all match without launching any nukes."
|
||||
},
|
||||
"leaderboard_modal": {
|
||||
"title": "Leaderboard",
|
||||
"ranked_tab": "1v1 Ranked",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"win_no_nukes": {
|
||||
"difficulty": "Hard"
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { fetchPlayerById, getUserMe } from "./Api";
|
||||
import { discordLogin, logOut, sendMagicLink } from "./Auth";
|
||||
import "./components/baseComponents/stats/DiscordUserHeader";
|
||||
import "./components/baseComponents/stats/GameList";
|
||||
import "./components/baseComponents/stats/PlayerAchievements";
|
||||
import "./components/baseComponents/stats/PlayerStatsTable";
|
||||
import "./components/baseComponents/stats/PlayerStatsTree";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
@@ -132,6 +133,7 @@ export class AccountModal extends BaseModal {
|
||||
private renderAccountInfo() {
|
||||
const me = this.userMeResponse?.user;
|
||||
const isLinked = me?.discord ?? me?.email;
|
||||
const achievements = this.userMeResponse?.player?.achievements ?? [];
|
||||
|
||||
if (!isLinked) {
|
||||
return this.renderLoginOptions();
|
||||
@@ -174,6 +176,15 @@ export class AccountModal extends BaseModal {
|
||||
</div>`
|
||||
: ""}
|
||||
|
||||
<div class="bg-white/5 rounded-xl border border-white/10 p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4">
|
||||
${translateText("account_modal.achievements")}
|
||||
</h3>
|
||||
<player-achievements
|
||||
.achievementGroups=${achievements}
|
||||
></player-achievements>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Row: Recent Games Section -->
|
||||
<div class="bg-white/5 rounded-xl border border-white/10 p-6">
|
||||
<h3
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import playerAchievementMetadataJson from "../../../../../resources/playerAchievementMetadata.json" with { type: "json" };
|
||||
import type {
|
||||
AchievementsResponse,
|
||||
PlayerAchievementJson,
|
||||
} from "../../../../core/ApiSchemas";
|
||||
import type { Difficulty } from "../../../../core/game/Game";
|
||||
import { translateText } from "../../../Utils";
|
||||
|
||||
type PlayerAchievementMetadata = {
|
||||
difficulty: Difficulty;
|
||||
};
|
||||
|
||||
type PlayerAchievementCard = {
|
||||
achievement: string;
|
||||
achievedAt: string | null;
|
||||
isUnlocked: boolean;
|
||||
};
|
||||
|
||||
const playerAchievementMetadata = playerAchievementMetadataJson as Record<
|
||||
string,
|
||||
PlayerAchievementMetadata
|
||||
>;
|
||||
|
||||
@customElement("player-achievements")
|
||||
export class PlayerAchievements extends LitElement {
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
@property({ attribute: false }) achievementGroups: AchievementsResponse = [];
|
||||
|
||||
private get unlockedAchievements(): PlayerAchievementJson[] {
|
||||
return this.achievementGroups
|
||||
.flatMap((group) => (group.type === "player" ? group.data : []))
|
||||
.slice()
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.achievedAt).getTime() - new Date(a.achievedAt).getTime(),
|
||||
);
|
||||
}
|
||||
|
||||
private get achievements(): PlayerAchievementCard[] {
|
||||
const unlockedByKey = new Map(
|
||||
this.unlockedAchievements.map((achievement) => [
|
||||
achievement.achievement,
|
||||
achievement,
|
||||
]),
|
||||
);
|
||||
const knownKeys = Object.keys(playerAchievementMetadata);
|
||||
const achievementKeys = [
|
||||
...knownKeys,
|
||||
...this.unlockedAchievements
|
||||
.map((achievement) => achievement.achievement)
|
||||
.filter((achievement) => !knownKeys.includes(achievement)),
|
||||
];
|
||||
const originalOrder = new Map(
|
||||
achievementKeys.map((achievement, index) => [achievement, index]),
|
||||
);
|
||||
|
||||
return achievementKeys
|
||||
.map((achievement) => {
|
||||
const unlockedAchievement = unlockedByKey.get(achievement);
|
||||
return {
|
||||
achievement,
|
||||
achievedAt: unlockedAchievement?.achievedAt ?? null,
|
||||
isUnlocked: unlockedAchievement !== undefined,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.isUnlocked !== b.isUnlocked) {
|
||||
return Number(b.isUnlocked) - Number(a.isUnlocked);
|
||||
}
|
||||
if (a.achievedAt && b.achievedAt) {
|
||||
return (
|
||||
new Date(b.achievedAt).getTime() - new Date(a.achievedAt).getTime()
|
||||
);
|
||||
}
|
||||
return (
|
||||
(originalOrder.get(a.achievement) ?? 0) -
|
||||
(originalOrder.get(b.achievement) ?? 0)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private formatDate(achievedAt: string): string {
|
||||
const date = new Date(achievedAt);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return achievedAt;
|
||||
}
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
private resolveTitle(achievementKey: string): string {
|
||||
const translationKey = `achivements.${achievementKey}`;
|
||||
const translated = translateText(translationKey);
|
||||
return translated === translationKey ? achievementKey : translated;
|
||||
}
|
||||
|
||||
private resolveDescription(achievementKey: string): string | null {
|
||||
const translationKey = `achivements.${achievementKey}_desc`;
|
||||
const translated = translateText(translationKey);
|
||||
return translated === translationKey ? null : translated;
|
||||
}
|
||||
|
||||
private resolveDifficulty(achievementKey: string): Difficulty | null {
|
||||
return playerAchievementMetadata[achievementKey]?.difficulty ?? null;
|
||||
}
|
||||
|
||||
private difficultyClasses(difficulty: Difficulty): string {
|
||||
switch (difficulty) {
|
||||
case "Easy":
|
||||
return "bg-emerald-500/15 text-emerald-300 border-emerald-400/25";
|
||||
case "Medium":
|
||||
return "bg-amber-500/15 text-amber-200 border-amber-400/25";
|
||||
case "Hard":
|
||||
return "bg-rose-500/15 text-rose-200 border-rose-400/25";
|
||||
case "Impossible":
|
||||
return "bg-violet-500/15 text-violet-200 border-violet-400/25";
|
||||
default:
|
||||
return "bg-white/5 text-white/60 border-white/10";
|
||||
}
|
||||
}
|
||||
|
||||
private renderDifficultyBadge(difficulty: Difficulty | null) {
|
||||
if (!difficulty) {
|
||||
return html`
|
||||
<span
|
||||
class="inline-flex items-center rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs font-semibold uppercase tracking-wider text-white/50"
|
||||
>
|
||||
${translateText("account_modal.unknown_difficulty")}
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
const translationKey = `difficulty.${difficulty.toLowerCase()}`;
|
||||
const translated = translateText(translationKey);
|
||||
const label = translated === translationKey ? difficulty : translated;
|
||||
|
||||
return html`
|
||||
<span
|
||||
class="inline-flex items-center rounded-full border px-3 py-1 text-xs font-semibold uppercase tracking-wider ${this.difficultyClasses(
|
||||
difficulty,
|
||||
)}"
|
||||
>
|
||||
${label}
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderAchievementCard(achievement: PlayerAchievementCard) {
|
||||
const difficulty = this.resolveDifficulty(achievement.achievement);
|
||||
const description = this.resolveDescription(achievement.achievement);
|
||||
const cardClasses = achievement.isUnlocked
|
||||
? "border-white/10 bg-gradient-to-br from-slate-900/70 via-slate-900/40 to-black/20"
|
||||
: "border-white/6 bg-gradient-to-br from-slate-900/40 via-slate-900/20 to-black/10 opacity-80";
|
||||
|
||||
return html`
|
||||
<article
|
||||
class="rounded-2xl border p-5 shadow-lg shadow-black/20 ${cardClasses}"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div
|
||||
class="text-[11px] font-bold uppercase tracking-[0.24em] text-white/35"
|
||||
>
|
||||
${translateText("account_modal.achievement_label")}
|
||||
</div>
|
||||
<h4 class="mt-2 text-lg font-semibold text-white">
|
||||
${this.resolveTitle(achievement.achievement)}
|
||||
</h4>
|
||||
${description
|
||||
? html`
|
||||
<p class="mt-2 text-sm leading-6 text-white/60">
|
||||
${description}
|
||||
</p>
|
||||
`
|
||||
: null}
|
||||
</div>
|
||||
${this.renderDifficultyBadge(difficulty)}
|
||||
</div>
|
||||
|
||||
<div class="mt-5 rounded-xl border border-white/10 bg-black/20 p-4">
|
||||
<div
|
||||
class="text-[11px] font-bold uppercase tracking-[0.24em] text-white/35"
|
||||
>
|
||||
${achievement.isUnlocked
|
||||
? translateText("account_modal.achieved_on")
|
||||
: translateText("account_modal.status")}
|
||||
</div>
|
||||
${achievement.isUnlocked && achievement.achievedAt
|
||||
? html`
|
||||
<time
|
||||
class="mt-2 block text-sm font-medium text-white/80"
|
||||
datetime=${achievement.achievedAt}
|
||||
>
|
||||
${this.formatDate(achievement.achievedAt)}
|
||||
</time>
|
||||
`
|
||||
: html`
|
||||
<div class="mt-2 text-sm font-medium text-white/50">
|
||||
${translateText("account_modal.not_unlocked_yet")}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.achievements.length === 0) {
|
||||
return html`
|
||||
<div
|
||||
class="rounded-2xl border border-dashed border-white/10 bg-black/10 px-5 py-6 text-sm text-white/45"
|
||||
>
|
||||
${translateText("account_modal.no_achievements")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="max-h-[36rem] overflow-y-auto pr-1 custom-scrollbar">
|
||||
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
${this.achievements.map((achievement) =>
|
||||
this.renderAchievementCard(achievement),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
+25
-8
@@ -60,6 +60,29 @@ const SingleplayerMapAchievementSchema = z.object({
|
||||
difficulty: z.enum(Difficulty),
|
||||
});
|
||||
|
||||
export const PlayerAchievementSchema = z.object({
|
||||
playerId: z.string(),
|
||||
achievement: z.string(),
|
||||
achievedAt: z.iso.datetime(),
|
||||
gameId: z.string(),
|
||||
game: z.string(),
|
||||
});
|
||||
export type PlayerAchievementJson = z.infer<typeof PlayerAchievementSchema>;
|
||||
|
||||
export const AchievementsResponseSchema = z.array(
|
||||
z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
type: z.literal("singleplayer-map"),
|
||||
data: z.array(SingleplayerMapAchievementSchema),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("player"),
|
||||
data: z.array(PlayerAchievementSchema),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
export type AchievementsResponse = z.infer<typeof AchievementsResponseSchema>;
|
||||
|
||||
export const UserMeResponseSchema = z.object({
|
||||
user: z.object({
|
||||
discord: DiscordUserSchema.optional(),
|
||||
@@ -69,14 +92,7 @@ export const UserMeResponseSchema = z.object({
|
||||
publicId: z.string(),
|
||||
roles: z.string().array().optional(),
|
||||
flares: z.string().array().optional(),
|
||||
achievements: z
|
||||
.array(
|
||||
z.object({
|
||||
type: z.literal("singleplayer-map"), // TODO: change the shape to be more flexible when we have more achievements
|
||||
data: z.array(SingleplayerMapAchievementSchema),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
achievements: AchievementsResponseSchema.optional(),
|
||||
leaderboard: z
|
||||
.object({
|
||||
oneVone: z
|
||||
@@ -127,6 +143,7 @@ export const PlayerProfileSchema = z.object({
|
||||
user: DiscordUserSchema.optional(),
|
||||
games: PlayerGameSchema.array(),
|
||||
stats: PlayerStatsTreeSchema,
|
||||
achievements: AchievementsResponseSchema.optional(),
|
||||
});
|
||||
export type PlayerProfile = z.infer<typeof PlayerProfileSchema>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user