PageSourceSearch

https://picturethewords.com/custom-cards.js?v=1

js picturethewords.com collected 2026-10-02 01:39:16 UTC 12,437 bytes, 265 lines download raw bytes

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.