Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
45 changes: 12 additions & 33 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ <h1>Hamster Fashion Studio</h1>
<div class="spinner" aria-hidden="true"></div>
<p id="ai-overlay-text">Summoning hamster glam...</p>
</div>
<div class="photo-status is-hidden" id="hamster-photo-status" role="status"></div>
<svg
class="hamster-canvas"
viewBox="0 0 220 260"
Expand Down Expand Up @@ -92,38 +93,16 @@ <h1>Hamster Fashion Studio</h1>
</g>
</g>
<g class="hamster hamster-photo is-hidden" id="hamster-photo">
<ellipse class="shadow" cx="110" cy="240" rx="66" ry="16"></ellipse>
<g class="body-group">
<ellipse class="body" cx="110" cy="144" rx="76" ry="98"></ellipse>
<ellipse class="belly" cx="110" cy="172" rx="48" ry="66"></ellipse>
<g class="ears">
<ellipse class="ear" cx="58" cy="54" rx="28" ry="30"></ellipse>
<ellipse class="ear" cx="162" cy="54" rx="28" ry="30"></ellipse>
<ellipse class="ear-inner" cx="58" cy="58" rx="18" ry="22"></ellipse>
<ellipse class="ear-inner" cx="162" cy="58" rx="18" ry="22"></ellipse>
</g>
<g class="face">
<ellipse class="cheek" cx="84" cy="128" rx="20" ry="18"></ellipse>
<ellipse class="cheek" cx="136" cy="128" rx="20" ry="18"></ellipse>
<ellipse class="eye" cx="88" cy="110" rx="11" ry="13"></ellipse>
<ellipse class="eye" cx="132" cy="110" rx="11" ry="13"></ellipse>
<circle class="eye-highlight" cx="84" cy="104" r="3"></circle>
<circle class="eye-highlight" cx="128" cy="104" r="3"></circle>
<path class="nose" d="M108 122 q4 -4 8 0 v10 h-8z"></path>
<path class="mouth" d="M100 138 q10 10 20 0" fill="none"></path>
<path class="chin" d="M104 146 q6 6 12 0" fill="none"></path>
<g class="whiskers">
<path d="M64 126 h32" />
<path d="M64 138 h28" />
<path d="M156 126 h-32" />
<path d="M156 138 h-28" />
</g>
</g>
<g class="paws">
<ellipse cx="74" cy="214" rx="22" ry="26"></ellipse>
<ellipse cx="146" cy="214" rx="22" ry="26"></ellipse>
</g>
</g>
<rect class="photo-backdrop" x="6" y="12" width="208" height="232" rx="18"></rect>
<image
id="hamster-photo-image"
x="10"
y="16"
width="200"
height="224"
preserveAspectRatio="xMidYMid slice"
role="img"
></image>
</g>
<g id="fashion-layer"></g>
<g id="effect-layer"></g>
Expand All @@ -142,7 +121,7 @@ <h2 id="options-title">Choose the next look</h2>
<label class="style-label" for="style-selector">Image style</label>
<select id="style-selector" class="style-selector" aria-label="Select hamster image style">
<option value="cartoon">Cartoon</option>
<option value="photograph">Photograph</option>
<option value="realistic">Realistic</option>
</select>
</div>
<div class="option-grid" id="option-grid" aria-labelledby="options-title"></div>
Expand Down
205 changes: 193 additions & 12 deletions script.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,16 @@ const STYLE_SELECTOR = document.getElementById("style-selector");
const HAMSTER_FRAME = document.getElementById("hamster-frame");
const HAMSTER_CARTOON = document.getElementById("hamster-cartoon");
const HAMSTER_PHOTO = document.getElementById("hamster-photo");
const HAMSTER_PHOTO_IMAGE = document.getElementById("hamster-photo-image");
const HAMSTER_PHOTO_STATUS = document.getElementById("hamster-photo-status");
const SAVE_FORM = document.getElementById("save-form");
const OUTFIT_NAME_INPUT = document.getElementById("outfit-name");
const SAVED_OUTFITS_LIST = document.getElementById("saved-outfits");

const STYLE_CARTOON = "cartoon";
const STYLE_REALISTIC = "realistic";
const LEGACY_STYLE_PHOTOGRAPH = "photograph";

const FASHION_ITEMS = [
{
id: "flower-crown",
Expand Down Expand Up @@ -315,14 +321,25 @@ const selections = [];
const savedOutfits = [];
const SAVED_OUTFITS_STORAGE_KEY = "hamster-fashion-saved-outfits";
let storyRequestId = 0;
let currentImageStyle = "cartoon";
let currentImageStyle = STYLE_CARTOON;
const realisticImageCache = new Map();
let realisticPhotoRequestId = 0;
let pendingPhotoSignature = null;

function normalizeImageStyle(style) {
if (style === STYLE_REALISTIC || style === LEGACY_STYLE_PHOTOGRAPH) {
return STYLE_REALISTIC;
}
return STYLE_CARTOON;
}

function init() {
hydrateSavedOutfits();
if (STYLE_SELECTOR && STYLE_SELECTOR.value) {
setImageStyle(STYLE_SELECTOR.value);
} else {
applyImageStyle(currentImageStyle);
renderHamster();
}
renderSavedOutfits();
updateOptions();
Expand Down Expand Up @@ -368,7 +385,7 @@ function hydrateSavedOutfits() {
return;
}

const normalizedStyle = style === "photograph" ? "photograph" : "cartoon";
const normalizedStyle = normalizeImageStyle(style);
const filteredItems = items.filter((itemId) => ITEM_LOOKUP.has(itemId));
if (filteredItems.length === 0) {
return;
Expand Down Expand Up @@ -456,7 +473,7 @@ function handleSelection(optionId) {
}

toggleOptionButtons(true);
showAiOverlay(item.name);
showAiOverlay(`Dreaming up ${item.name} magic...`);

const wait = 500 + Math.random() * 700;
window.setTimeout(() => {
Expand All @@ -465,13 +482,26 @@ function handleSelection(optionId) {
renderHistory();
updateOptions();
updateAiStory();
hideAiOverlay();
if (currentImageStyle !== STYLE_REALISTIC) {
hideAiOverlay();
}
toggleOptionButtons(false);
}, wait);
}

function renderHamster() {
const selectedItems = selections.map((entry) => ITEM_LOOKUP.get(entry.id)).filter(Boolean);
if (currentImageStyle === STYLE_REALISTIC) {
renderRealisticHamster(selectedItems);
} else {
renderCartoonHamster(selectedItems);
}
}

function renderCartoonHamster(selectedItems) {
if (!BACKGROUND_LAYER || !FASHION_LAYER || !EFFECT_LAYER) {
return;
}

BACKGROUND_LAYER.innerHTML = selectedItems
.filter((item) => item.layer === "background")
Expand All @@ -489,6 +519,136 @@ function renderHamster() {
.join("");
}

function clearCartoonLayers() {
if (BACKGROUND_LAYER) {
BACKGROUND_LAYER.innerHTML = "";
}
if (FASHION_LAYER) {
FASHION_LAYER.innerHTML = "";
}
if (EFFECT_LAYER) {
EFFECT_LAYER.innerHTML = "";
}
}

async function renderRealisticHamster(selectedItems) {
if (!HAMSTER_PHOTO_IMAGE) {
return;
}

clearCartoonLayers();
hidePhotoStatus();

const signature = getSelectionSignature(selectedItems);
const cached = realisticImageCache.get(signature);
if (cached) {
applyRealisticPhoto(cached);
hideAiOverlay();
return;
}

if (pendingPhotoSignature === signature) {
return;
}

pendingPhotoSignature = signature;
showAiOverlay("Capturing a realistic hamster photo...");

const requestId = ++realisticPhotoRequestId;

try {
const response = await fetch("/api/hamster-photo", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
selections: selectedItems.map((item) => ({
id: item.id,
name: item.name,
category: item.category,
description: item.description,
})),
}),
});

if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}

const data = await response.json();
if (requestId !== realisticPhotoRequestId || currentImageStyle !== STYLE_REALISTIC) {
return;
}

if (data?.image) {
const photo = { src: data.image, alt: data.alt };
realisticImageCache.set(signature, photo);
applyRealisticPhoto(photo);
hidePhotoStatus();
hideAiOverlay();
} else {
throw new Error("Missing image data");
}
} catch (error) {
if (requestId === realisticPhotoRequestId && currentImageStyle === STYLE_REALISTIC) {
console.error("Realistic hamster generation failed", error);
showPhotoStatus("We couldn't snap a hamster photo. Try again soon.", true);
hideAiOverlay();
}
} finally {
if (requestId === realisticPhotoRequestId) {
pendingPhotoSignature = null;
}
}
}

function getSelectionSignature(selectedItems) {
if (!selectedItems || selectedItems.length === 0) {
return "__empty__";
}
return selectedItems
.map((item) => item?.id)
.filter(Boolean)
.sort()
.join("|");
}

function applyRealisticPhoto(photo) {
if (!HAMSTER_PHOTO_IMAGE || !photo || !photo.src) {
return;
}
HAMSTER_PHOTO_IMAGE.setAttribute("href", photo.src);
if (typeof HAMSTER_PHOTO_IMAGE.setAttributeNS === "function") {
HAMSTER_PHOTO_IMAGE.setAttributeNS("http://www.w3.org/1999/xlink", "href", photo.src);
}
if (photo.alt) {
HAMSTER_PHOTO_IMAGE.setAttribute("aria-label", photo.alt);
} else {
HAMSTER_PHOTO_IMAGE.removeAttribute("aria-label");
}
}

function showPhotoStatus(message, isError = false) {
if (!HAMSTER_PHOTO_STATUS) {
return;
}
HAMSTER_PHOTO_STATUS.textContent = message;
if (isError) {
HAMSTER_PHOTO_STATUS.classList.add("error");
} else {
HAMSTER_PHOTO_STATUS.classList.remove("error");
}
HAMSTER_PHOTO_STATUS.classList.remove("is-hidden");
}

function hidePhotoStatus() {
if (!HAMSTER_PHOTO_STATUS) {
return;
}
HAMSTER_PHOTO_STATUS.textContent = "";
HAMSTER_PHOTO_STATUS.classList.remove("error");
HAMSTER_PHOTO_STATUS.classList.add("is-hidden");
}

function renderHistory() {
HISTORY_LIST.innerHTML = "";
if (selections.length === 0) {
Expand Down Expand Up @@ -605,12 +765,18 @@ function removeSelection(optionId) {
updateAiStory();
}

function showAiOverlay(name) {
AI_OVERLAY_TEXT.textContent = `Dreaming up ${name} magic...`;
function showAiOverlay(message) {
if (!AI_OVERLAY || !AI_OVERLAY_TEXT) {
return;
}
AI_OVERLAY_TEXT.textContent = message;
AI_OVERLAY.classList.remove("hidden");
}

function hideAiOverlay() {
if (!AI_OVERLAY) {
return;
}
AI_OVERLAY.classList.add("hidden");
}

Expand All @@ -622,26 +788,39 @@ function toggleOptionButtons(disabled) {
}

function setImageStyle(style) {
const normalized = style === "photograph" ? "photograph" : "cartoon";
const normalized = normalizeImageStyle(style);
currentImageStyle = normalized;
applyImageStyle(normalized);
if (STYLE_SELECTOR && STYLE_SELECTOR.value !== normalized) {
STYLE_SELECTOR.value = normalized;
if (STYLE_SELECTOR) {
const selectorHasValue = Array.from(STYLE_SELECTOR.options).some(
(option) => option.value === normalized,
);
const legacyValue = normalized === STYLE_REALISTIC ? LEGACY_STYLE_PHOTOGRAPH : normalized;
const targetValue = selectorHasValue ? normalized : legacyValue;
if (STYLE_SELECTOR.value !== targetValue) {
STYLE_SELECTOR.value = targetValue;
}
}
renderHamster();
}

function applyImageStyle(style) {
if (!HAMSTER_CARTOON || !HAMSTER_PHOTO || !HAMSTER_FRAME) {
return;
}
if (style === "photograph") {
const isRealistic = style === STYLE_REALISTIC || style === LEGACY_STYLE_PHOTOGRAPH;
if (isRealistic) {
HAMSTER_CARTOON.classList.add("is-hidden");
HAMSTER_PHOTO.classList.remove("is-hidden");
HAMSTER_FRAME.classList.add("photo-style");
} else {
HAMSTER_CARTOON.classList.remove("is-hidden");
HAMSTER_PHOTO.classList.add("is-hidden");
HAMSTER_FRAME.classList.remove("photo-style");
hidePhotoStatus();
realisticPhotoRequestId += 1;
pendingPhotoSignature = null;
hideAiOverlay();
}
}

Expand Down Expand Up @@ -759,7 +938,8 @@ function buildOutfitPreview(outfit) {
}

function formatSavedOutfitMeta(outfit) {
const styleLabel = outfit.style === "photograph" ? "Photograph" : "Cartoon";
const normalizedStyle = normalizeImageStyle(outfit.style);
const styleLabel = normalizedStyle === STYLE_REALISTIC ? "Realistic" : "Cartoon";
const savedAt = outfit.savedAt instanceof Date ? outfit.savedAt : new Date(outfit.savedAt);
return `${styleLabel} • ${savedAt.toLocaleString(undefined, {
month: "short",
Expand Down Expand Up @@ -840,7 +1020,8 @@ function arraysEqual(a, b) {
}

function generateOutfitName() {
const base = currentImageStyle === "photograph" ? "Photo Look" : "Cartoon Look";
const normalizedStyle = normalizeImageStyle(currentImageStyle);
const base = normalizedStyle === STYLE_REALISTIC ? "Realistic Look" : "Cartoon Look";
const count = savedOutfits.filter((outfit) => outfit.name.startsWith(base)).length + 1;
return `${base} #${count}`;
}
Expand Down
Loading