+ {mission && missionCode ? (
+
+ {missionCode}
+ {mission.title}
+
+ ) : null}
{entry.title}
)}
- {!entry.readAt && !isStudentPreview ? (
-
+ {!isStudentPreview ? (
+
+ {!entry.readAt ? (
+
+ ) : null}
+
+
) : null}
);
diff --git a/v2/src/pages/ProfilePage.tsx b/v2/src/pages/ProfilePage.tsx
new file mode 100644
index 0000000..befb68b
--- /dev/null
+++ b/v2/src/pages/ProfilePage.tsx
@@ -0,0 +1,385 @@
+import { useState, type FormEvent } from "react";
+import {
+ Check,
+ Glasses,
+ Github,
+ LoaderCircle,
+ Save,
+ Scissors,
+ Shirt,
+ Shuffle,
+ Smile,
+} from "lucide-react";
+import { Navigate } from "react-router-dom";
+import { ProfileAvatar } from "@/components/ProfileAvatar";
+import {
+ AVATAR_ACCESSORIES,
+ AVATAR_CLOTHES_COLORS,
+ AVATAR_CLOTHING,
+ AVATAR_EYES,
+ AVATAR_EYEBROWS,
+ AVATAR_FACIAL_HAIR,
+ AVATAR_HAIR_COLORS,
+ AVATAR_HAT_COLORS,
+ AVATAR_MOUTHS,
+ AVATAR_SKIN_COLORS,
+ AVATAR_TOPS,
+ defaultAvatarConfig,
+ sanitizeAvatarConfig,
+} from "@/lib/avatar";
+import { useClassroom } from "@/state/classroom-context";
+import type { AvatarConfig, Profile } from "@/types";
+
+type EditorTab = "hair" | "face" | "accessories" | "clothing";
+
+function AvatarOptionGrid({
+ label,
+ property,
+ options,
+ value,
+ profile,
+ config,
+ onChange,
+}: {
+ label: string;
+ property: keyof AvatarConfig;
+ options: readonly (readonly [string, string])[];
+ value: string;
+ profile: Profile;
+ config: AvatarConfig;
+ onChange: (property: keyof AvatarConfig, value: string) => void;
+}) {
+ return (
+
+ );
+}
+
+function ColorSwatches({
+ label,
+ property,
+ colors,
+ value,
+ onChange,
+}: {
+ label: string;
+ property: keyof AvatarConfig;
+ colors: readonly string[];
+ value: string;
+ onChange: (property: keyof AvatarConfig, value: string) => void;
+}) {
+ return (
+
+ );
+}
+
+export function Component() {
+ const {
+ profile,
+ isStudentPreview,
+ updateProfile,
+ } = useClassroom();
+ const [tab, setTab] = useState
("hair");
+ const [displayName, setDisplayName] = useState(profile?.displayName ?? "");
+ const [config, setConfig] = useState(() =>
+ sanitizeAvatarConfig(profile?.avatarConfig, profile?.id ?? "tomatin"),
+ );
+ const [saving, setSaving] = useState(false);
+ const [message, setMessage] = useState("");
+
+ if (!profile) return null;
+ if (isStudentPreview) return ;
+
+ function changeConfig(property: keyof AvatarConfig, value: string) {
+ setConfig((current) => ({ ...current, [property]: value }));
+ setMessage("");
+ }
+
+ async function submit(event: FormEvent) {
+ event.preventDefault();
+ setSaving(true);
+ setMessage("");
+ try {
+ await updateProfile({ displayName, avatarConfig: config });
+ setMessage("Perfil actualizado.");
+ } catch (error) {
+ setMessage(
+ error instanceof Error ? error.message : "No se pudo guardar el perfil.",
+ );
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ const tabs = [
+ ["hair", "Pelo y gorros", Scissors],
+ ["face", "Rostro", Smile],
+ ["accessories", "Accesorios", Glasses],
+ ["clothing", "Ropa", Shirt],
+ ] as const;
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/v2/src/pages/RankingPage.tsx b/v2/src/pages/RankingPage.tsx
index 134471d..c088d1b 100644
--- a/v2/src/pages/RankingPage.tsx
+++ b/v2/src/pages/RankingPage.tsx
@@ -1,5 +1,5 @@
import { Award, Medal, Trophy } from "lucide-react";
-import { initials } from "@/lib/format";
+import { ProfileAvatar } from "@/components/ProfileAvatar";
import { useClassroom } from "@/state/classroom-context";
export function Component() {
@@ -61,7 +61,10 @@ export function Component() {
{index === 0 ? : index === 1 ? : }
{index + 1}
-