[READ-ONLY] Mirror of https://github.com/aaronateataco/TomodachiMods. tomodachimods.vercel.app
0

Configure Feed

Select the types of activity you want to include in your feed.

fixes

aaronateataco (Jul 15, 2026, 1:08 PM +0100) f13af53e f9091e92

+223 -192
+40 -57
api/mii/[id].js
··· 8 8 'Referer': 'https://tomodachishare.com/', 9 9 }; 10 10 11 - async function fetchAPI(path) { 12 - const url = `${API_BASE}${path}`; 13 - const res = await fetch(url, { headers: FETCH_HEADERS }); 14 - if (!res.ok) throw new Error(`API ${res.status}: ${url}`); 15 - return res.json(); 16 - } 17 - 18 - async function checkImageExists(url) { 19 - try { 20 - const res = await fetch(url, { method: 'HEAD', headers: FETCH_HEADERS }); 21 - return res.ok && (res.headers.get('content-type') || '').startsWith('image/'); 22 - } catch { 23 - return false; 24 - } 25 - } 26 - 27 - function parseOutfits(description, miiId, availableImageTypes) { 28 - if (!description) return []; 11 + /** 12 + * Parse clothing instruction segments from a description. 13 + * Returns array of strings (one per clothing reference found). 14 + */ 15 + function parseOutfits(description, miiId, imageCount) { 16 + if (!description || imageCount === 0) return []; 29 17 30 18 const clothingKeywords = [ 31 19 'shirt', 'pants', 'trousers', 'dress', 'skirt', 'jacket', 'coat', ··· 36 24 'swimsuit', 'bikini', 'swimwear', 37 25 ]; 38 26 39 - const segments = description.split(/\n|(?:\s-\s)/).map(s => s.trim()).filter(Boolean); 40 - const imageOrder = availableImageTypes.filter(t => t !== 'image'); 27 + const segments = description 28 + .split(/\n|(?:\s-\s)/) 29 + .map(s => s.trim()) 30 + .filter(Boolean); 41 31 42 32 const clothingSegments = segments.filter(seg => { 43 33 const lower = seg.toLowerCase(); 44 34 return clothingKeywords.some(kw => lower.includes(kw)); 45 35 }); 46 36 47 - return imageOrder.map((imgType, idx) => ({ 48 - imageType: imgType, 49 - imageUrl: `${API_BASE}/mii/${miiId}/image?type=${imgType}`, 50 - description: clothingSegments[idx] || null, 51 - })); 37 + // image types for extra images: image0 = main, image1..imageN = outfit photos 38 + // The real site uses ?type=image0, image1, image2 ... up to imageCount-1 39 + const outfits = []; 40 + for (let i = 0; i < imageCount; i++) { 41 + outfits.push({ 42 + imageType: `image${i}`, 43 + imageUrl: `${API_BASE}/mii/${miiId}/image?type=image${i}`, 44 + description: clothingSegments[i] || null, 45 + }); 46 + } 47 + return outfits; 52 48 } 53 49 54 50 module.exports = async function handler(req, res) { ··· 59 55 const { id } = req.query; 60 56 61 57 try { 62 - let mii; 63 - for (const ep of [`/mii/${id}`, `/miis/${id}`]) { 64 - try { 65 - mii = await fetchAPI(ep); 66 - if (mii && (mii.id || mii.name)) break; 67 - } catch { /* try next */ } 68 - } 58 + // Real endpoint: /api/mii/:id/info 59 + const url = `${API_BASE}/api/mii/${id}/info`; 60 + const upstream = await fetch(url, { headers: FETCH_HEADERS }); 69 61 70 - if (!mii || (!mii.id && !mii.name)) { 71 - mii = { id }; 62 + if (!upstream.ok) { 63 + return res.status(upstream.status).json({ 64 + error: `Mii not found (upstream ${upstream.status})`, 65 + }); 72 66 } 73 67 68 + const mii = await upstream.json(); 74 69 const miiId = mii.id || id; 75 - 76 - const hasDownload = await checkImageExists(`${API_BASE}/mii/${miiId}/download`); 77 - if (!hasDownload) { 78 - return res.status(404).json({ error: 'This Mii does not have a shareable file.' }); 79 - } 70 + const imageCount = mii.imageCount || 0; 71 + const description = mii.description || ''; 80 72 81 - const imageTypes = ['image', 'image1', 'image2', 'image3', 'image4']; 82 - const availableImageTypes = []; 83 - await Promise.all( 84 - imageTypes.map(async (type) => { 85 - const exists = await checkImageExists(`${API_BASE}/mii/${miiId}/image?type=${type}`); 86 - if (exists) availableImageTypes.push(type); 87 - }) 88 - ); 89 - availableImageTypes.sort((a, b) => imageTypes.indexOf(a) - imageTypes.indexOf(b)); 73 + const outfits = parseOutfits(description, miiId, imageCount); 90 74 91 - const description = mii.description || mii.desc || ''; 92 - const outfits = parseOutfits(description, miiId, availableImageTypes); 75 + // Available image types derived from imageCount 76 + // image0 = main portrait, image1+ = extra/outfit shots 77 + const availableImageTypes = Array.from({ length: imageCount }, (_, i) => `image${i}`); 93 78 94 79 res.setHeader('Cache-Control', 's-maxage=300, stale-while-revalidate=600'); 95 80 return res.status(200).json({ 81 + // Pass through all real fields 82 + ...mii, 83 + // Computed / normalised 96 84 id: miiId, 97 - name: mii.name || mii.mii_name || 'Unknown', 98 - author: mii.author || mii.uploader || mii.creator || '', 99 85 description, 100 - mainImageUrl: `${API_BASE}/mii/${miiId}/image?type=image`, 101 - downloadUrl: `${API_BASE}/mii/${miiId}/download`, 102 - likes: mii.likes || mii.like_count || 0, 103 - createdAt: mii.created_at || mii.createdAt || mii.date || null, 104 - tags: mii.tags || [], 86 + mainImageUrl: `${API_BASE}/mii/${miiId}/image?type=mii`, 87 + downloadUrl: mii.isFromSaveFile ? `${API_BASE}/mii/${miiId}/download` : null, 105 88 availableImageTypes, 106 89 outfits, 107 90 });
+4 -1
api/mii/[id]/download.js
··· 16 16 const { id } = req.query; 17 17 18 18 try { 19 + // Real download endpoint: /mii/:id/download (no /api/ prefix) 19 20 const dlRes = await fetch(`${API_BASE}/mii/${id}/download`, { 20 21 headers: FETCH_HEADERS, 21 22 }); ··· 25 26 } 26 27 27 28 const contentType = dlRes.headers.get('content-type') || 'application/octet-stream'; 28 - const contentDisposition = dlRes.headers.get('content-disposition') || `attachment; filename="mii_${id}.sharemii"`; 29 + const contentDisposition = 30 + dlRes.headers.get('content-disposition') || 31 + `attachment; filename="mii_${id}.sharemii"`; 29 32 30 33 res.setHeader('Content-Type', contentType); 31 34 res.setHeader('Content-Disposition', contentDisposition);
+2 -1
api/mii/[id]/image.js
··· 13 13 return res.status(405).send('Method not allowed'); 14 14 } 15 15 16 - const { id, type = 'image' } = req.query; 16 + const { id, type = 'mii' } = req.query; 17 17 18 18 try { 19 + // Real image URL pattern: /mii/:id/image?type=mii (no /api/ prefix) 19 20 const imgRes = await fetch(`${API_BASE}/mii/${id}/image?type=${type}`, { 20 21 headers: FETCH_HEADERS, 21 22 });
+66 -21
api/miis.js
··· 8 8 'Referer': 'https://tomodachishare.com/', 9 9 }; 10 10 11 - async function fetchAPI(path) { 12 - const url = `${API_BASE}${path}`; 13 - const res = await fetch(url, { headers: FETCH_HEADERS }); 14 - if (!res.ok) throw new Error(`Upstream API returned ${res.status} for ${path}`); 15 - return res.json(); 11 + /** 12 + * Parses clothing keywords from a description string and returns matching segments. 13 + * Used client-side too, but we surface it here so the "hasOutfit" flag works. 14 + */ 15 + function hasClothingKeywords(description) { 16 + if (!description) return false; 17 + const kw = [ 18 + 'shirt', 'pants', 'trousers', 'dress', 'skirt', 'jacket', 'coat', 19 + 'hoodie', 'sweater', 'outfit', 'clothes', 'palette house', 20 + 'long sleeve', 'short sleeve', 'tuck in', 'accessory', 21 + ]; 22 + const lower = description.toLowerCase(); 23 + return kw.some(k => lower.includes(k)); 16 24 } 17 25 18 26 module.exports = async function handler(req, res) { ··· 21 29 } 22 30 23 31 try { 24 - const { page = 1, q = '', sort = 'new', limit = 20 } = req.query; 32 + // Mirror the exact query params the real site uses: 33 + // /api/mii/list?page=N&sort=newest&limit=24&... 34 + const { 35 + page = 1, 36 + sort = 'newest', 37 + limit = 24, 38 + q = '', 39 + tags = '', 40 + exclude = '', 41 + platform = '', 42 + gender = '', 43 + makeup = '', 44 + isFromSaveFile = '', 45 + allowCopying = '', 46 + quarantined = '', 47 + timeRange = '', 48 + parentPage = '', 49 + userId = '', 50 + bypassCache = '', 51 + } = req.query; 25 52 26 53 const qs = new URLSearchParams({ page, limit, sort }); 27 - if (q) qs.set('q', q); 54 + if (q) qs.set('q', q); 55 + if (tags) qs.set('tags', tags); 56 + if (exclude) qs.set('exclude', exclude); 57 + if (platform) qs.set('platform', platform); 58 + if (gender) qs.set('gender', gender); 59 + if (makeup) qs.set('makeup', makeup); 60 + if (isFromSaveFile) qs.set('isFromSaveFile', isFromSaveFile); 61 + if (allowCopying) qs.set('allowCopying', allowCopying); 62 + if (quarantined) qs.set('quarantined', quarantined); 63 + if (timeRange) qs.set('timeRange', timeRange); 64 + if (parentPage) qs.set('parentPage', parentPage); 65 + if (userId) qs.set('userId', userId); 66 + if (bypassCache) qs.set('bypassCache', bypassCache); 28 67 29 - const data = await fetchAPI(`/miis?${qs}`); 68 + // Real endpoint: /api/mii/list 69 + const url = `${API_BASE}/api/mii/list?${qs}`; 70 + const upstream = await fetch(url, { headers: FETCH_HEADERS }); 71 + 72 + if (!upstream.ok) { 73 + return res.status(upstream.status).json({ error: `Upstream returned ${upstream.status}` }); 74 + } 30 75 31 - const raw = Array.isArray(data) ? data : (data.miis || data.data || data.results || []); 76 + // Real response shape: { miis: [...], totalCount: N, lastPage: N } 77 + const data = await upstream.json(); 32 78 33 - const miis = raw 34 - .filter(mii => mii.id) 35 - .map(mii => ({ 36 - id: mii.id, 37 - name: mii.name || mii.mii_name || 'Unknown', 38 - author: mii.author || mii.uploader || mii.creator || '', 39 - description: mii.description || mii.desc || '', 40 - likes: mii.likes || mii.like_count || 0, 41 - createdAt: mii.created_at || mii.createdAt || mii.date || null, 42 - tags: mii.tags || [], 43 - })); 79 + // Normalize: add hasOutfit flag and proxy image URLs through our own handler 80 + const miis = (data.miis || []).map(mii => ({ 81 + ...mii, 82 + // image URL: /mii/:id/image?type=mii (no /api/ prefix on the real site) 83 + imageUrl: `/api/mii/${mii.id}/image?type=mii`, 84 + hasOutfit: mii.imageCount > 0 || hasClothingKeywords(mii.description), 85 + })); 44 86 45 87 res.setHeader('Cache-Control', 's-maxage=60, stale-while-revalidate=300'); 46 88 return res.status(200).json({ 47 89 miis, 90 + totalCount: data.totalCount || miis.length, 91 + lastPage: data.lastPage || 1, 92 + // legacy compat 48 93 page: Number(page), 49 - total: data.total || miis.length, 94 + total: data.totalCount || miis.length, 50 95 }); 51 96 } catch (err) { 52 97 console.error('GET /api/miis error:', err.message);
+36 -34
public/app.js
··· 1 1 // ─── State ──────────────────────────────────────────────────────────────────── 2 2 const state = { 3 3 page: 1, 4 - sort: 'new', 4 + sort: 'newest', // real API uses 'newest' not 'new' 5 5 query: '', 6 6 clothesOnly: false, 7 7 loading: false, 8 8 totalPages: 1, 9 9 }; 10 10 11 - const MIIS_PER_PAGE = 20; 11 + const MIIS_PER_PAGE = 24; 12 12 13 13 // ─── DOM refs ───────────────────────────────────────────────────────────────── 14 - const grid = document.getElementById('miiGrid'); 15 - const loading = document.getElementById('loadingState'); 16 - const empty = document.getElementById('emptyState'); 17 - const errorEl = document.getElementById('errorState'); 18 - const errorMsg = document.getElementById('errorMsg'); 19 - const pagination = document.getElementById('pagination'); 20 - const searchInput= document.getElementById('searchInput'); 21 - const searchBtn = document.getElementById('searchBtn'); 22 - const sortTabs = document.querySelectorAll('.sort-tab'); 23 - const clothesCb = document.getElementById('clothesOnly'); 14 + const grid = document.getElementById('miiGrid'); 15 + const loading = document.getElementById('loadingState'); 16 + const empty = document.getElementById('emptyState'); 17 + const errorEl = document.getElementById('errorState'); 18 + const errorMsg = document.getElementById('errorMsg'); 19 + const pagination = document.getElementById('pagination'); 20 + const searchInput = document.getElementById('searchInput'); 21 + const searchBtn = document.getElementById('searchBtn'); 22 + const sortTabs = document.querySelectorAll('.sort-tab'); 23 + const clothesCb = document.getElementById('clothesOnly'); 24 24 25 25 // ─── Helpers ────────────────────────────────────────────────────────────────── 26 26 function formatDate(ts) { 27 27 if (!ts) return ''; 28 - const d = new Date(typeof ts === 'number' ? ts * 1000 : ts); 28 + const d = new Date(ts); 29 29 if (isNaN(d)) return ''; 30 30 return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); 31 31 } 32 32 33 33 function hasClothingKeywords(description) { 34 34 if (!description) return false; 35 - const kw = ['shirt', 'pants', 'trousers', 'dress', 'skirt', 'jacket', 'coat', 36 - 'hoodie', 'sweater', 'outfit', 'clothes', 'palette house', 37 - 'long sleeve', 'short sleeve', 'tuck in', 'accessory']; 35 + const kw = [ 36 + 'shirt', 'pants', 'trousers', 'dress', 'skirt', 'jacket', 'coat', 37 + 'hoodie', 'sweater', 'outfit', 'clothes', 'palette house', 38 + 'long sleeve', 'short sleeve', 'tuck in', 'accessory', 39 + ]; 38 40 const lower = description.toLowerCase(); 39 41 return kw.some(k => lower.includes(k)); 40 42 } 41 43 42 44 function escHtml(str) { 43 - return String(str) 45 + return String(str || '') 44 46 .replace(/&/g, '&amp;') 45 47 .replace(/</g, '&lt;') 46 48 .replace(/>/g, '&gt;') ··· 48 50 } 49 51 50 52 // ─── Render card ────────────────────────────────────────────────────────────── 53 + // HAR shows Mii objects have: id, name, user{id,name}, platform, gender, makeup, 54 + // tags, imageCount, likeCount, createdAt, description (may be absent) 51 55 function renderCard(mii) { 52 - const clothes = hasClothingKeywords(mii.description); 53 - const dateStr = formatDate(mii.createdAt); 54 - const imgSrc = `/api/mii/${mii.id}/image?type=image`; 56 + // Show outfit badge if there are extra images OR clothing keywords in description 57 + const hasOutfit = (mii.imageCount > 0) || hasClothingKeywords(mii.description); 58 + const dateStr = formatDate(mii.createdAt); 59 + // Image proxy route: /api/mii/:id/image?type=mii 60 + const imgSrc = `/api/mii/${mii.id}/image?type=mii`; 55 61 56 62 const card = document.createElement('a'); 57 63 card.className = 'mii-card'; ··· 67 73 onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" 68 74 /> 69 75 <div class="card-img-placeholder" style="display:none">🧑</div> 70 - ${clothes ? '<span class="card-clothes-badge">👕 Outfit</span>' : ''} 76 + ${hasOutfit ? '<span class="card-clothes-badge">👕 Outfit</span>' : ''} 71 77 </div> 72 78 <div class="card-body"> 73 79 <div class="card-name">${escHtml(mii.name)}</div> 74 - ${mii.author ? `<div class="card-author">by ${escHtml(mii.author)}</div>` : ''} 80 + ${mii.user?.name ? `<div class="card-author">by ${escHtml(mii.user.name)}</div>` : ''} 75 81 <div class="card-footer"> 76 - ${mii.likes > 0 ? `<span class="card-likes">❤️ ${mii.likes}</span>` : '<span></span>'} 82 + ${mii.likeCount > 0 ? `<span class="card-likes">❤️ ${mii.likeCount}</span>` : '<span></span>'} 77 83 ${dateStr ? `<span class="card-date">${dateStr}</span>` : ''} 78 84 </div> 79 85 </div> ··· 86 92 if (state.loading) return; 87 93 state.loading = true; 88 94 89 - // Show loading state 90 95 grid.innerHTML = ''; 91 96 loading.classList.remove('hidden'); 92 97 empty.classList.add('hidden'); ··· 109 114 110 115 // Client-side filter: clothes only 111 116 if (state.clothesOnly) { 112 - miis = miis.filter(m => hasClothingKeywords(m.description)); 117 + miis = miis.filter(m => (m.imageCount > 0) || hasClothingKeywords(m.description)); 113 118 } 114 119 115 120 loading.classList.add('hidden'); ··· 122 127 grid.appendChild(frag); 123 128 } 124 129 125 - // Pagination 126 - const total = data.total || miis.length; 127 - state.totalPages = Math.max(1, Math.ceil(total / MIIS_PER_PAGE)); 130 + // Pagination — real API returns lastPage 131 + const lastPage = data.lastPage || Math.ceil((data.totalCount || miis.length) / MIIS_PER_PAGE); 132 + state.totalPages = Math.max(1, lastPage); 128 133 renderPagination(); 129 134 130 135 } catch (err) { ··· 157 162 158 163 pagination.appendChild(makeBtn('← Prev', state.page - 1, state.page <= 1)); 159 164 160 - // Show up to 7 page numbers around current 161 165 const range = 3; 162 166 let start = Math.max(1, state.page - range); 163 167 let end = Math.min(state.totalPages, state.page + range); ··· 177 181 } 178 182 179 183 // ─── Event listeners ────────────────────────────────────────────────────────── 180 - // Search 181 184 function doSearch() { 182 185 state.query = searchInput.value.trim(); 183 186 state.page = 1; ··· 186 189 searchBtn.addEventListener('click', doSearch); 187 190 searchInput.addEventListener('keydown', e => { if (e.key === 'Enter') doSearch(); }); 188 191 189 - // Sort tabs 190 192 sortTabs.forEach(tab => { 191 193 tab.addEventListener('click', () => { 192 194 sortTabs.forEach(t => t.classList.remove('active')); 193 195 tab.classList.add('active'); 194 - state.sort = tab.dataset.sort; 196 + // Map display labels → real API sort values 197 + const sortMap = { new: 'newest', top: 'likes', old: 'oldest' }; 198 + state.sort = sortMap[tab.dataset.sort] || tab.dataset.sort; 195 199 state.page = 1; 196 200 loadMiis(); 197 201 }); 198 202 }); 199 203 200 - // Clothes filter 201 204 clothesCb.addEventListener('change', () => { 202 205 state.clothesOnly = clothesCb.checked; 203 206 state.page = 1; ··· 205 208 }); 206 209 207 210 // ─── Init ───────────────────────────────────────────────────────────────────── 208 - // Restore any query from URL 209 211 const urlParams = new URLSearchParams(window.location.search); 210 212 if (urlParams.get('q')) { 211 213 state.query = urlParams.get('q');
+75 -78
public/mii.js
··· 9 9 10 10 function formatDate(ts) { 11 11 if (!ts) return ''; 12 - const d = new Date(typeof ts === 'number' ? ts * 1000 : ts); 12 + const d = new Date(ts); 13 13 if (isNaN(d)) return ''; 14 14 return d.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); 15 15 } 16 16 17 17 /** 18 18 * Linkify tomodachishare.com/mii/<id> URLs in a description string. 19 - * Returns HTML string. 20 19 */ 21 20 function linkifyDescription(text) { 22 21 if (!text) return ''; 23 22 const escaped = escHtml(text); 24 - // Match tomodachishare.com/mii/<id> and api.tomodachishare.com/mii/<id> 25 23 return escaped.replace( 26 24 /((?:https?:\/\/)?(?:api\.)?tomodachishare\.com\/mii\/(\d+)(?:\/[^\s<"]*)?)/g, 27 25 (match, full, id) => { ··· 32 30 } 33 31 34 32 /** 35 - * Extract tomodachishare Mii IDs from a description string. 33 + * Extract tomodachishare Mii IDs linked in a description. 36 34 */ 37 35 function extractLinkedMiiIds(text) { 38 36 if (!text) return []; ··· 46 44 } 47 45 48 46 /** 49 - * Parse the description into per-outfit clothing instruction segments. 50 - * 51 - * Strategy: 52 - * 1. Split description on newlines and " - " separators. 53 - * 2. Find segments that contain clothing keywords. 54 - * 3. Map them in order to image1, image2, image3 ... 55 - * 56 - * Returns array of strings (one per outfit image, in order). 47 + * Parse clothing instruction segments from a description. 48 + * Returns array of strings in order of appearance. 57 49 */ 58 50 function parseOutfitInstructions(description) { 59 51 if (!description) return []; ··· 67 59 'swimsuit', 'bikini', 'swimwear', 'sleeve', 68 60 ]; 69 61 70 - // Split on newlines and " - " (common separator in tomodachishare descriptions) 71 62 const segments = description 72 63 .split(/\n|(?:\s+-\s+)/) 73 64 .map(s => s.replace(/^[-•*]\s*/, '').trim()) 74 65 .filter(s => s.length > 4); 75 66 76 - const clothingSegments = segments.filter(seg => { 67 + return segments.filter(seg => { 77 68 const lower = seg.toLowerCase(); 78 69 return CLOTHING_KW.some(kw => lower.includes(kw)); 79 70 }); 71 + } 80 72 81 - return clothingSegments; 73 + /** 74 + * Bold-highlight known clothing terms in a string (returns HTML). 75 + */ 76 + function highlightClothingTerms(text) { 77 + const terms = [ 78 + 'long sleeve shirt', 'short sleeve shirt', 'long sleeve', 'short sleeve', 79 + 'shirt', 'pants', 'trousers', 'dress', 'skirt', 'jacket', 'coat', 80 + 'hoodie', 'sweater', 'turtleneck', 'tank top', 'blouse', 'top', 81 + 'shorts', 'jeans', 'leggings', 'uniform', 'outfit', 'palette house', 82 + 'tuck in the shirt', 'tuck in shirt', 'tuck in', 83 + 'accessory', 'hat', 'shoes', 'boots', 'gloves', 'scarf', 84 + 'swimsuit', 'bikini', 'swimwear', 85 + ]; 86 + let result = escHtml(text); 87 + terms.sort((a, b) => b.length - a.length); 88 + for (const term of terms) { 89 + const re = new RegExp(`(${term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi'); 90 + result = result.replace(re, '<strong>$1</strong>'); 91 + } 92 + return result; 82 93 } 83 94 84 95 // ─── DOM refs ───────────────────────────────────────────────────────────────── ··· 108 119 // ─── Fetch Mii data ─────────────────────────────────────────────────────────── 109 120 async function loadMii(id) { 110 121 try { 122 + // Our proxy hits /api/mii/:id which forwards to /api/mii/:id/info upstream 111 123 const res = await fetch(`/api/mii/${id}`); 112 124 const data = await res.json(); 113 125 ··· 125 137 } 126 138 127 139 // ─── Render Mii detail ──────────────────────────────────────────────────────── 140 + // Real API fields (from HAR /api/mii/list response): 141 + // id, name, user{id,name}, platform, gender, makeup, tags[], 142 + // imageCount, likeCount, createdAt, description, isFromSaveFile, 143 + // allowedCopying, quarantined, in_queue, needsFixing 144 + // Our proxy adds: outfits[], availableImageTypes[], mainImageUrl, downloadUrl 128 145 function renderMii(mii) { 129 - // Update page title 130 146 document.title = `${mii.name} — TomodachiShare`; 131 147 132 - // ── Main image ── 133 - mainMiiImage.src = `/api/mii/${mii.id}/image?type=image`; 148 + // ── Main portrait image ── 149 + // Real image URL uses ?type=mii for the headshot render 150 + mainMiiImage.src = `/api/mii/${mii.id}/image?type=mii`; 134 151 mainMiiImage.alt = mii.name; 135 - mainMiiImage.onerror = () => { 136 - mainMiiImage.style.display = 'none'; 137 - }; 152 + mainMiiImage.onerror = () => { mainMiiImage.style.display = 'none'; }; 138 153 139 - // ── Outfit images ── 140 - // availableImageTypes is sorted: ['image', 'image1', 'image2', ...] 141 - // outfits contains {imageType, imageUrl, description} for image1, image2, ... 154 + // ── Outfit / extra images ── 155 + // imageCount tells us how many gallery images exist (type=image0, image1, ...) 156 + const imageCount = mii.imageCount || 0; 157 + const outfits = mii.outfits || []; 142 158 const outfitInstructionsList = parseOutfitInstructions(mii.description); 143 - const outfits = mii.outfits || []; 144 159 145 160 outfitImagesEl.innerHTML = ''; 146 161 147 - if (outfits.length > 0) { 148 - outfits.forEach((outfit, idx) => { 149 - // Find matching instruction — either from server-parsed outfits or our local parse 150 - const instrText = outfit.description || outfitInstructionsList[idx] || null; 151 - const label = `Outfit ${idx + 1}`; 162 + if (imageCount > 0) { 163 + // image0 is the main island/body shot, image1+ are extra outfit photos 164 + for (let i = 0; i < imageCount; i++) { 165 + const imgType = `image${i}`; 166 + const instrText = outfits[i]?.description || outfitInstructionsList[i] || null; 167 + const label = i === 0 ? 'Island Photo' : `Outfit ${i}`; 152 168 153 169 const item = document.createElement('div'); 154 170 item.className = 'outfit-item'; ··· 156 172 <div class="outfit-image-wrap"> 157 173 <img 158 174 class="outfit-img" 159 - src="/api/mii/${mii.id}/image?type=${outfit.imageType}" 175 + src="/api/mii/${mii.id}/image?type=${imgType}" 160 176 alt="${escHtml(label)} for ${escHtml(mii.name)}" 161 177 loading="lazy" 162 178 onerror="this.closest('.outfit-item').style.display='none'" 163 179 /> 164 180 </div> 165 181 <div class="outfit-label-bar"> 166 - <span class="outfit-number">${idx + 1}</span> 167 - <span class="outfit-label-text">${escHtml(label)} (${outfit.imageType})</span> 182 + <span class="outfit-number">${i + 1}</span> 183 + <span class="outfit-label-text">${escHtml(label)}</span> 168 184 </div> 169 185 ${instrText ? `<div class="outfit-desc-inline">${escHtml(instrText)}</div>` : ''} 170 186 `; 171 187 outfitImagesEl.appendChild(item); 172 - }); 188 + } 173 189 } 174 190 175 191 // ── Name ── 176 192 detailName.textContent = mii.name; 177 193 178 - // ── Meta ── 179 - if (mii.author) { 180 - authorValue.textContent = mii.author; 194 + // ── Meta — author comes from user.name in real API ── 195 + const authorName = mii.user?.name || mii.author || ''; 196 + if (authorName) { 197 + authorValue.textContent = authorName; 181 198 metaAuthor.style.display = 'flex'; 182 199 } 183 200 const dateStr = formatDate(mii.createdAt); ··· 185 202 dateValue.textContent = dateStr; 186 203 metaDate.style.display = 'flex'; 187 204 } 188 - if (mii.likes > 0) { 189 - likesValue.textContent = `${mii.likes} likes`; 205 + const likes = mii.likeCount || mii.likes || 0; 206 + if (likes > 0) { 207 + likesValue.textContent = `${likes} likes`; 190 208 metaLikes.style.display = 'flex'; 191 209 } 192 210 ··· 205 223 206 224 // ── Outfit instructions panel ── 207 225 const instructions = outfitInstructionsList; 208 - if (instructions.length > 0 && outfits.length > 0) { 226 + if (instructions.length > 0 && imageCount > 1) { 227 + // Only show instructions for outfit images (skip image0 which is the island shot) 209 228 outfitList.innerHTML = ''; 210 - // Show one instruction per outfit image in order 211 - outfits.forEach((outfit, idx) => { 212 - const text = outfit.description || instructions[idx] || null; 213 - if (!text) return; 229 + for (let i = 0; i < Math.min(imageCount - 1, instructions.length); i++) { 230 + const text = outfits[i + 1]?.description || instructions[i] || null; 231 + if (!text) continue; 214 232 215 233 const li = document.createElement('li'); 216 234 li.className = 'outfit-list-item'; 217 - // Highlight clothing item names in the instruction text 218 - const highlighted = highlightClothingTerms(text); 219 235 li.innerHTML = ` 220 - <span class="outfit-list-num">${idx + 1}</span> 221 - <span class="outfit-list-text">${highlighted}</span> 236 + <span class="outfit-list-num">${i + 1}</span> 237 + <span class="outfit-list-text">${highlightClothingTerms(text)}</span> 222 238 `; 223 239 outfitList.appendChild(li); 224 - }); 225 - // If any items were added 240 + } 226 241 if (outfitList.children.length > 0) { 227 242 outfitInstructions.style.display = 'block'; 228 243 } 229 244 } else if (instructions.length > 0) { 230 - // No outfit images but description has clothing info 245 + // Description has clothing info but no extra images 231 246 outfitList.innerHTML = ''; 232 247 instructions.forEach((text, idx) => { 233 248 const li = document.createElement('li'); ··· 254 269 card.innerHTML = ` 255 270 <img 256 271 class="related-img" 257 - src="/api/mii/${linkedId}/image?type=image" 272 + src="/api/mii/${linkedId}/image?type=mii" 258 273 alt="Mii ${linkedId}" 259 274 loading="lazy" 260 275 onerror="this.style.opacity='0.3'" ··· 275 290 relatedMiis.style.display = 'block'; 276 291 } 277 292 278 - // ── Download ── 279 - downloadBtn.href = `/api/mii/${mii.id}/download`; 280 - downloadBtn.download = `mii_${mii.id}_${mii.name.replace(/[^a-zA-Z0-9]/g, '_')}.sharemii`; 293 + // ── Download — only available for save-file Miis ── 294 + if (mii.isFromSaveFile && mii.downloadUrl) { 295 + downloadBtn.href = `/api/mii/${mii.id}/download`; 296 + downloadBtn.download = `mii_${mii.id}_${mii.name.replace(/[^a-zA-Z0-9]/g, '_')}.sharemii`; 297 + downloadBtn.style.display = ''; 298 + } else { 299 + downloadBtn.style.display = 'none'; 300 + } 281 301 282 302 // ── Copy link ── 283 303 copyLinkBtn.addEventListener('click', () => { ··· 299 319 // ── Show detail ── 300 320 detailLoading.classList.add('hidden'); 301 321 detailEl.classList.remove('hidden'); 302 - } 303 - 304 - /** 305 - * Bold-highlight known clothing terms in a string (returns HTML). 306 - */ 307 - function highlightClothingTerms(text) { 308 - const terms = [ 309 - 'long sleeve shirt', 'short sleeve shirt', 'long sleeve', 'short sleeve', 310 - 'shirt', 'pants', 'trousers', 'dress', 'skirt', 'jacket', 'coat', 311 - 'hoodie', 'sweater', 'turtleneck', 'tank top', 'blouse', 'top', 312 - 'shorts', 'jeans', 'leggings', 'uniform', 'outfit', 'palette house', 313 - 'tuck in the shirt', 'tuck in shirt', 'tuck in', 314 - 'accessory', 'hat', 'shoes', 'boots', 'gloves', 'scarf', 315 - 'swimsuit', 'bikini', 'swimwear', 316 - ]; 317 - let result = escHtml(text); 318 - // Sort by length descending so longer phrases match first 319 - terms.sort((a, b) => b.length - a.length); 320 - for (const term of terms) { 321 - const re = new RegExp(`(${term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi'); 322 - result = result.replace(re, '<strong>$1</strong>'); 323 - } 324 - return result; 325 322 } 326 323 327 324 // ─── Init ─────────────────────────────────────────────────────────────────────