avatar to main merge
I have completed the character creation screen on the avatar branch and am preparing to merge it into main.
This commit is contained in:
270
lib/avatar-customization-storage.ts
Normal file
270
lib/avatar-customization-storage.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import { notifyHabitPetDataUpdated } from "@/lib/app-events";
|
||||
import {
|
||||
hexToHsl,
|
||||
hslToHex,
|
||||
type HSL,
|
||||
} from "@/lib/character/color-utils";
|
||||
import {
|
||||
assertValidVariants,
|
||||
normalizeVariantId,
|
||||
normalizeVariants,
|
||||
} from "@/lib/character/variant-validation";
|
||||
import {
|
||||
buildDefaultVariants,
|
||||
DEFAULT_GRAY_COLOR,
|
||||
LAYER_DEFAULT_COLORS,
|
||||
NONE_VARIANT_ID,
|
||||
} from "@/lib/character/presets";
|
||||
import type { LayerColorState, LayerVariantState } from "@/lib/character/types";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
|
||||
export type AvatarCustomization = {
|
||||
name: string;
|
||||
colors: LayerColorState;
|
||||
variants: LayerVariantState;
|
||||
customized: boolean;
|
||||
equippedItems: string[];
|
||||
};
|
||||
|
||||
type AvatarStateRow = {
|
||||
avatar_name: string | null;
|
||||
skin_color: string | null;
|
||||
pants_style: string | null;
|
||||
pants_color: string | null;
|
||||
shoe_style: string | null;
|
||||
shoe_color: string | null;
|
||||
torso_style: string | null;
|
||||
torso_color: string | null;
|
||||
eye_type: string | null;
|
||||
eye_color: string | null;
|
||||
head_style: string | null;
|
||||
head_color: string | null;
|
||||
avatar_customized: boolean | null;
|
||||
equipped_items: string[] | null;
|
||||
};
|
||||
|
||||
export const DEFAULT_AVATAR_NAME = "Pixel Me";
|
||||
const DEFAULT_HEX = DEFAULT_GRAY_COLOR.hex;
|
||||
const HEX_COLOR_PATTERN = /^#[0-9A-Fa-f]{6}$/;
|
||||
|
||||
export const defaultAvatarCustomization: AvatarCustomization = {
|
||||
name: DEFAULT_AVATAR_NAME,
|
||||
colors: { ...LAYER_DEFAULT_COLORS },
|
||||
variants: buildDefaultVariants(),
|
||||
customized: false,
|
||||
equippedItems: [],
|
||||
};
|
||||
|
||||
const avatarSelectFields =
|
||||
"avatar_name, skin_color, pants_style, pants_color, shoe_style, shoe_color, torso_style, torso_color, eye_type, eye_color, head_style, head_color, avatar_customized, equipped_items";
|
||||
|
||||
function colorFromHex(hex: string | null | undefined, fallback: HSL): HSL {
|
||||
if (!hex?.trim() || !HEX_COLOR_PATTERN.test(hex)) {
|
||||
return { ...fallback };
|
||||
}
|
||||
|
||||
try {
|
||||
return hexToHsl(hex);
|
||||
} catch {
|
||||
return { ...fallback };
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHexColor(color: HSL, fallback: HSL) {
|
||||
const hex = hslToHex(color);
|
||||
|
||||
if (!HEX_COLOR_PATTERN.test(hex)) {
|
||||
return hslToHex(fallback);
|
||||
}
|
||||
|
||||
return hex.toLowerCase();
|
||||
}
|
||||
|
||||
export function mapRowToAvatarCustomization(
|
||||
row: AvatarStateRow | null | undefined,
|
||||
): AvatarCustomization {
|
||||
if (!row) {
|
||||
return { ...defaultAvatarCustomization };
|
||||
}
|
||||
|
||||
const defaults = defaultAvatarCustomization;
|
||||
|
||||
return {
|
||||
name: row.avatar_name?.trim() || DEFAULT_AVATAR_NAME,
|
||||
colors: {
|
||||
skin: colorFromHex(row.skin_color, defaults.colors.skin),
|
||||
pants: colorFromHex(row.pants_color, defaults.colors.pants),
|
||||
shoes: colorFromHex(row.shoe_color, defaults.colors.shoes),
|
||||
torso: colorFromHex(row.torso_color, defaults.colors.torso),
|
||||
eyes: colorFromHex(row.eye_color, defaults.colors.eyes),
|
||||
head: colorFromHex(row.head_color, defaults.colors.head),
|
||||
},
|
||||
variants: {
|
||||
skin: "skin-1",
|
||||
pants: normalizeVariantId("pants", row.pants_style),
|
||||
shoes: normalizeVariantId("shoes", row.shoe_style),
|
||||
torso: normalizeVariantId("torso", row.torso_style),
|
||||
eyes: normalizeVariantId("eyes", row.eye_type),
|
||||
head: normalizeVariantId("head", row.head_style),
|
||||
},
|
||||
customized: row.avatar_customized === true,
|
||||
equippedItems: row.equipped_items ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export function mapAvatarCustomizationToRow(customization: AvatarCustomization) {
|
||||
const normalizedVariants = normalizeVariants(customization.variants);
|
||||
assertValidVariants(normalizedVariants);
|
||||
|
||||
const { colors, name } = customization;
|
||||
const defaults = defaultAvatarCustomization.colors;
|
||||
|
||||
return {
|
||||
avatar_name: name.trim() || DEFAULT_AVATAR_NAME,
|
||||
skin_color: normalizeHexColor(colors.skin, defaults.skin),
|
||||
pants_style: normalizedVariants.pants,
|
||||
pants_color: normalizeHexColor(colors.pants, defaults.pants),
|
||||
shoe_style: normalizedVariants.shoes,
|
||||
shoe_color: normalizeHexColor(colors.shoes, defaults.shoes),
|
||||
torso_style: normalizedVariants.torso,
|
||||
torso_color: normalizeHexColor(colors.torso, defaults.torso),
|
||||
eye_type: normalizedVariants.eyes,
|
||||
eye_color: normalizeHexColor(colors.eyes, defaults.eyes),
|
||||
head_style: normalizedVariants.head,
|
||||
head_color: normalizeHexColor(colors.head, defaults.head),
|
||||
avatar_customized: true,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async function getAuthenticatedUserId() {
|
||||
const supabase = createClient();
|
||||
const {
|
||||
data: { user },
|
||||
error,
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (error || !user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return user.id;
|
||||
}
|
||||
|
||||
export async function getAvatarCustomization(): Promise<AvatarCustomization> {
|
||||
const userId = await getAuthenticatedUserId();
|
||||
|
||||
if (!userId) {
|
||||
return { ...defaultAvatarCustomization };
|
||||
}
|
||||
|
||||
const supabase = createClient();
|
||||
const { data, error } = await supabase
|
||||
.from("avatar_state")
|
||||
.select(avatarSelectFields)
|
||||
.eq("user_id", userId)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
const { error: insertError } = await supabase.from("avatar_state").insert({
|
||||
user_id: userId,
|
||||
avatar_name: DEFAULT_AVATAR_NAME,
|
||||
skin_color: DEFAULT_HEX,
|
||||
pants_style: NONE_VARIANT_ID,
|
||||
pants_color: DEFAULT_HEX,
|
||||
shoe_style: NONE_VARIANT_ID,
|
||||
shoe_color: DEFAULT_HEX,
|
||||
torso_style: NONE_VARIANT_ID,
|
||||
torso_color: DEFAULT_HEX,
|
||||
eye_type: NONE_VARIANT_ID,
|
||||
eye_color: DEFAULT_HEX,
|
||||
head_style: NONE_VARIANT_ID,
|
||||
head_color: DEFAULT_HEX,
|
||||
});
|
||||
|
||||
if (insertError) {
|
||||
throw new Error(insertError.message);
|
||||
}
|
||||
|
||||
return { ...defaultAvatarCustomization };
|
||||
}
|
||||
|
||||
return mapRowToAvatarCustomization(data as AvatarStateRow);
|
||||
}
|
||||
|
||||
export async function saveAvatarCustomization(
|
||||
customization: AvatarCustomization,
|
||||
options?: { completeOnboarding?: boolean },
|
||||
) {
|
||||
const userId = await getAuthenticatedUserId();
|
||||
|
||||
if (!userId) {
|
||||
throw new Error("You must be signed in to save your avatar.");
|
||||
}
|
||||
|
||||
const supabase = createClient();
|
||||
const payload = mapAvatarCustomizationToRow(customization);
|
||||
|
||||
const { error } = await supabase
|
||||
.from("avatar_state")
|
||||
.update(payload)
|
||||
.eq("user_id", userId);
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
if (options?.completeOnboarding) {
|
||||
const { error: profileError } = await supabase
|
||||
.from("profiles")
|
||||
.update({ onboarding_complete: true })
|
||||
.eq("id", userId);
|
||||
|
||||
if (profileError) {
|
||||
throw new Error(profileError.message);
|
||||
}
|
||||
|
||||
const { error: authError } = await supabase.auth.updateUser({
|
||||
data: {
|
||||
onboarding_completed: true,
|
||||
onboarding_quiz_completed: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (authError) {
|
||||
throw new Error(authError.message);
|
||||
}
|
||||
}
|
||||
|
||||
notifyHabitPetDataUpdated();
|
||||
return customization;
|
||||
}
|
||||
|
||||
export async function saveAvatarName(name: string) {
|
||||
const userId = await getAuthenticatedUserId();
|
||||
|
||||
if (!userId) {
|
||||
throw new Error("You must be signed in to rename your avatar.");
|
||||
}
|
||||
|
||||
const trimmed = name.trim() || DEFAULT_AVATAR_NAME;
|
||||
const supabase = createClient();
|
||||
const { error } = await supabase
|
||||
.from("avatar_state")
|
||||
.update({
|
||||
avatar_name: trimmed,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("user_id", userId);
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
notifyHabitPetDataUpdated();
|
||||
return trimmed;
|
||||
}
|
||||
Reference in New Issue
Block a user