1/* custom-cards.js 2 "My Created Cards" -- user-uploaded image + user-typed meaning/ 3 translations, a personal flashcard alongside the curated deck. 4 5 Design: a custom card's Supabase row id starts at 9,000,001 (see 6 migrations/2026-09-20_add_custom_cards.sql), safely above the real 7 curated concept_id range. That id is merged straight into the global 8 `CARDS` array (script.js) with the same shape a real concept has, so 9 every already-existing mechanism that keys off `concept_id` -- 10 Favorites, "add to My Categories" (account.js), Notes (notes.js), 11 openConcept()'s lookup, renderConceptTile()'s grid rendering -- works 12 for a custom card with no changes of its own. The two places that 13 previously assumed every concept_id draws from the shared 200-item 14 free-tier pool (saveNote() in notes.js, handleAddToExistingCategory/ 15 handleAddToNewCategory in account.js) each get a one-line bypass: a 16 card a user made themselves isn't curated content, so it was never 17 meant to count against that allowance. 18 19 Uses global `supabaseClient`, `getCurrentUser()` (auth.js), `CARDS` 20 (script.js), `playAudio()`/`escapeHtml()` (script.js), 21 `renderNotePanel()` (notes.js), `openAddToCategoryPicker()` (account.js). */ 22 23const PTW_CUSTOM_CARD_ID_START = 9000001; 24 25function isCustomCardId(id) { 26 const n = Number(id); 27 return Number.isFinite(n) && n >= PTW_CUSTOM_CARD_ID_START; 28} 29 30// ââ Merge into CARDS âââââââââââââââââââââââââââââââââââââââââââââ 31function customCardToConceptShape(row) { 32 const en = row.en || ''; 33 return { 34 concept_id: String(row.id), 35 category_id: 'my_created', 36 category_label: 'My Created Cards', 37 english: en, 38 labels: { en, ja: row.ja || '', hi: row.hi || '', fr: row.fr || '' }, 39 sort_ja: row.ja || en, 40 // A single synthetic "direction" -- custom cards have one photo and 41 // aren't studied in a JA_EN/HI_EN/FR_EN pair the way the curated deck 42 // is, so there's nothing to pick between. firstAvailableDirection() 43 // (script.js) already falls back to Object.keys(directions)[0] when 44 // prefDirection isn't found, so this key can be anything. 45 directions: { ALL: { label: 'My Created Cards', image_url: row.image || '' } }, 46 is_custom: true, 47 custom_row: row, 48 }; 49} 50 51async function loadCustomCardsIntoCards() { 52 const user = await getCurrentUser(); 53 // Drop any custom cards from a previous load/user before re-adding -- 54 // otherwise switching accounts, or calling this again after a create/ 55 // edit/delete, would leave stale or duplicate entries in CARDS. 56 CARDS = CARDS.filter(c => !c.is_custom); 57 if (!user) return; 58 59 const { data, error } = await supabaseClient 60 .from('custom_cards') 61 .select('id, image, en, ja, hi, fr') 62 .eq('user_id', user.id) 63 .order('created_at', { ascending: false }); 64 if (error || !data) return; 65 66 for (const row of data) CARDS.push(customCardToConceptShape(row)); 67} 68 69// ââ "My Created Cards" list (avatar menu entry, same pattern as 70// openFavorites()/openMyCategories()/openHistory() in account.js) ââ 71async function openMyCards() { 72 document.getElementById('account-overlay').classList.add('open'); 73 renderMyCardsTab(); 74} 75 76async function renderMyCardsTab() { 77 const body = document.getElementById('account-tab-body'); 78 body.innerHTML = '<p class="notes-empty">Loadingâ¦</p>'; 79 const user = await getCurrentUser(); 80 if (!user) { body.innerHTML = accountLoginPrompt(); return; } 81 82 await loadCustomCardsIntoCards(); 83 const concepts = CARDS.filter(c => c.is_custom); 84 85 const gridHtml = concepts.length 86 ? `<div class="deck">${concepts.map(c => renderConceptTile(c, `deleteCustomCard('${c.concept_id}')`)).join('')}</div>` 87 : '<p class="notes-empty">No cards yet -- create your first one below.</p>'; 88 89 body.innerHTML = ` 90 <h3 style="font-size:14px; margin-bottom:10px;">My Created Cards (${concepts.length})</h3> 91 <button class="btn-primary" style="margin-bottom:14px;" onclick="openCreateCardForm()">+ Create a card</button> 92 ${gridHtml} 93 `; 94 applyStarDisplay(); 95} 96 97// ââ Create / edit form âââââââââââââââââââââââââââââââââââââââââââ 98// One form for both: editCustomCard() pre-fills it and submitCustomCard() 99// updates instead of inserting when ptwEditingCardId is set. 100let ptwEditingCardId = null; 101let ptwCreateCardImageDataUrl = null; 102 103function openCreateCardForm() { 104 ptwEditingCardId = null; 105 ptwCreateCardImageDataUrl = null; 106 document.getElementById('create-card-title').textContent = 'Create a card'; 107 document.getElementById('create-card-delete-btn').style.display = 'none'; 108 ['en', 'ja', 'hi', 'fr'].forEach(l => { document.getElementById(`create-card-${l}`).value = ''; }); 109 document.getElementById('create-card-image-input').value = ''; 110 document.getElementById('create-card-image-preview').innerHTML = ''; 111 document.getElementById('create-card-status').textContent = ''; 112 document.getElementById('create-card-overlay').classList.add('open'); 113} 114 115async function editCustomCard(conceptId) { 116 const concept = CARDS.find(c => c.concept_id === String(conceptId)); 117 if (!concept || !concept.is_custom) return;
118 const row = concept.custom_row; 119 ptwEditingCardId = row.id; 120 ptwCreateCardImageDataUrl = row.image || null; 121 document.getElementById('create-card-title').textContent = 'Edit card'; 122 document.getElementById('create-card-delete-btn').style.display = ''; 123 document.getElementById('create-card-en').value = row.en || ''; 124 document.getElementById('create-card-ja').value = row.ja || ''; 125 document.getElementById('create-card-hi').value = row.hi || ''; 126 document.getElementById('create-card-fr').value = row.fr || ''; 127 document.getElementById('create-card-image-input').value = ''; 128 document.getElementById('create-card-image-preview').innerHTML = 129 row.image ? `<img src="${row.image}" alt="">` : ''; 130 document.getElementById('create-card-status').textContent = ''; 131 closeFocus(); 132 document.getElementById('create-card-overlay').classList.add('open'); 133} 134 135function closeCreateCardForm() { 136 document.getElementById('create-card-overlay').classList.remove('open'); 137} 138 139// Resizes to a max of 640px on the longest side and re-encodes as JPEG at 140// 0.75 quality before it ever reaches Supabase -- a phone photo straight 141// out of a gallery can be several MB, and this feature stores the image 142// as a base64 text column rather than provisioning a new Supabase Storage 143// bucket (this project has never used Storage before), so keeping the 144// encoded size small (typically tens of KB after this) is what makes 145// that simpler design viable. 146function resizeImageFile(file, maxDim = 640, quality = 0.75) { 147 return new Promise((resolve, reject) => { 148 const img = new Image(); 149 const reader = new FileReader(); 150 reader.onload = () => { img.src = reader.result; }; 151 reader.onerror = () => reject(new Error('Could not read that image file.')); 152 img.onload = () => { 153 let { width, height } = img; 154 if (width > height && width > maxDim) { height = Math.round(height * maxDim / width); width = maxDim; } 155 else if (height > maxDim) { width = Math.round(width * maxDim / height); height = maxDim; } 156 const canvas = document.createElement('canvas'); 157 canvas.width = width; 158 canvas.height = height; 159 canvas.getContext('2d').drawImage(img, 0, 0, width, height); 160 resolve(canvas.toDataURL('image/jpeg', quality)); 161 }; 162 img.onerror = () => reject(new Error('Could not read that image file.')); 163 reader.readAsDataURL(file); 164 }); 165} 166 167async function handleCreateCardImagePick(file) { 168 if (!file) return; 169 const statusEl = document.getElementById('create-card-status'); 170 statusEl.textContent = 'Processing imageâ¦'; 171 try { 172 ptwCreateCardImageDataUrl = await resizeImageFile(file); 173 document.getElementById('create-card-image-preview').innerHTML = 174 `<img src="${ptwCreateCardImageDataUrl}" alt="">`; 175 statusEl.textContent = ''; 176 } catch (e) { 177 statusEl.textContent = e.message; 178 } 179} 180 181async function submitCustomCard() { 182 const statusEl = document.getElementById('create-card-status'); 183 const user = await getCurrentUser(); 184 if (!user) { statusEl.textContent = 'Please log in first.'; return; } 185 186 const en = document.getElementById('create-card-en').value.trim(); 187 const ja = document.getElementById('create-card-ja').value.trim(); 188 const hi = document.getElementById('create-card-hi').value.trim(); 189 const fr = document.getElementById('create-card-fr').value.trim(); 190 if (!ptwCreateCardImageDataUrl) { statusEl.textContent = 'Add a photo first.'; return; } 191 if (!en && !ja && !hi && !fr) { statusEl.textContent = 'Add at least one language.'; return; } 192 193 statusEl.textContent = 'Savingâ¦'; 194 const row = { user_id: user.id, image: ptwCreateCardImageDataUrl, en, ja, hi, fr }; 195 const { error } = ptwEditingCardId 196 ? await supabaseClient.from('custom_cards').update(row).eq('id', ptwEditingCardId) 197 : await supabaseClient.from('custom_cards').insert(row); 198 199 if (error) { statusEl.textContent = 'Could not save. Try a smaller photo.'; return; } 200 closeCreateCardForm(); 201 await loadCustomCardsIntoCards(); 202 if (document.getElementById('account-overlay').classList.contains('open')) renderMyCardsTab(); 203} 204 205async function deleteCustomCard(conceptId) { 206 const id = Number(conceptId); 207 await supabaseClient.from('custom_cards').delete().eq('id', id);
208 // Best-effort cleanup so a deleted card doesn't leave an orphan row 209 // sitting in a user's Favorites/Notes/My Categories -- harmless if it 210 // did (CARDS.find(...).filter(Boolean) already drops anything that no 211 // longer resolves), but tidier not to leave it. 212 const user = await getCurrentUser(); 213 if (user) { 214 await supabaseClient.from('favourites').delete().eq('user_id', user.id).eq('concept_id', id); 215 await supabaseClient.from('notes').delete().eq('user_id', user.id).eq('concept_id', id); 216 const { data: cats } = await supabaseClient 217 .from('custom_categories').select('id').eq('user_id', user.id); 218 if (cats && cats.length) { 219 await supabaseClient.from('custom_category_cards').delete() 220 .eq('concept_id', id).in('category_id', cats.map(c => c.id)); 221 } 222 } 223 closeFocus(); 224 await loadCustomCardsIntoCards(); 225 if (document.getElementById('account-overlay').classList.contains('open')) renderMyCardsTab(); 226} 227 228// ââ Detail view (injected into #focus-card-inner by openConcept(), 229// which already opens/sizes/closes the shared overlay itself) ââ 230function renderCustomCardDetail(concept) { 231 const row = concept.custom_row; 232 const langRow = (lang, label, text) => text ? ` 233 <div class="custom-card-lang-row"> 234 <span class="custom-card-lang-label">${label}</span> 235 <span class="custom-card-lang-text">${escapeHtml(text)}</span> 236 <button class="audio-btn" onclick="playAudio(null, '${escapeHtml(text).replace(/'/g, "\\'")}', '${lang}')">ð</button> 237 </div>` : ''; 238 239 document.getElementById('focus-card-inner').innerHTML = ` 240 <div class="focus-card-img-wrap"> 241 <img class="focus-card-img" src="${row.image || ''}" alt="${escapeHtml(row.en || '')}"> 242 <button class="focus-zoom-btn" onclick="toggleFocusZoom()" aria-label="Enlarge card" title="Enlarge card">⤢</button> 243 </div> 244 <div class="custom-card-langs"> 245 ${langRow('en', 'English', row.en)} 246 ${langRow('ja', 'Japanese', row.ja)} 247 ${langRow('hi', 'Hindi', row.hi)} 248 ${langRow('fr', 'French', row.fr)} 249 </div> 250 <div class="card-actions"> 251 <span onclick="editCustomCard('${concept.concept_id}')" class="card-action">Edit</span> 252 <span onclick="openAddToCategoryPicker('${concept.concept_id}', 'ALL')" class="card-action">+ Add to Category</span> 253 <span onclick="if(confirm('Delete this card?'))deleteCustomCard('${concept.concept_id}')" 254 class="card-action card-action-quiet">Delete</span> 255 </div> 256 <button class="note-panel-toggle" id="note-panel-toggle" onclick="toggleNotePanel()"> 257 <span>ð Example sentences / notes</span> 258 <span class="chevron">â¾</span> 259 </button> 260 <div class="note-panel-body" id="note-panel-body"> 261 <div id="note-panel"></div> 262 </div> 263 `; 264 if (typeof renderNotePanel === 'function') renderNotePanel(concept.concept_id, 'ALL'); 265}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.