PageSourceSearch

https://tmsteph-redesign-2025.vercel.app/shopping-list/shopping-list.js

js tmsteph-redesign-2025.vercel.app collected 2026-10-03 07:53:08 UTC 11,375 bytes, 396 lines download raw bytes

1const RELAY_URL = 'https://gun-relay-3dvr.fly.dev/gun';
2const LIST_PARAM = 'list';
3const STORAGE_KEY = 'shoppingListId';
4
5const formatDate = (value) => {
6  if (!value) {
7    return '';
8  }
9  const parsed = new Date(`${value}T00:00:00`);
10  if (Number.isNaN(parsed.getTime())) {
11    return value;
12  }
13  return parsed.toLocaleDateString(undefined, {
14    weekday: 'short',
15    month: 'short',
16    day: 'numeric',
17    year: 'numeric',
18  });
19};
20
21export const initShoppingList = ({
22  Gun: GunLib = globalThis.Gun,
23  document: documentRef = globalThis.document,
24  window: windowRef = documentRef?.defaultView ?? globalThis.window,
25} = {}) => {
26  if (!GunLib || !documentRef) {
27    return null;
28  }
29
30  const getListId = () => {
31    const url = new URL(windowRef?.location?.href || 'https://example.com');
32    const fromUrl = url.searchParams.get(LIST_PARAM);
33    if (fromUrl) {
34      try {
35        windowRef?.localStorage?.setItem(STORAGE_KEY, fromUrl);
36      } catch (err) {
37        // ignore storage errors
38      }
39      return fromUrl;
40    }
41
42    let stored = null;
43    try {
44      stored = windowRef?.localStorage?.getItem(STORAGE_KEY);
45    } catch (err) {
46      // ignore storage errors
47    }
48
49    const nextId =
50      stored || `list-${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`;
51    url.searchParams.set(LIST_PARAM, nextId);
52    if (windowRef?.history?.replaceState) {
53      windowRef.history.replaceState({}, '', url.toString());
54    }
55
56    try {
57      windowRef?.localStorage?.setItem(STORAGE_KEY, nextId);
58    } catch (err) {
59      // ignore storage errors
60    }
61
62    return nextId;
63  };
64
65  const gun = GunLib({
66    peers: [RELAY_URL],
67    localStorage: true,
68  });
69
70  const form = documentRef.getElementById('shopping-form');
71  const nameInput = documentRef.getElementById('item-name');
72  const quantityInput = documentRef.getElementById('item-quantity');
73  const categoryInput = documentRef.getElementById('item-category');
74  const dateInput = documentRef.getElementById('item-date');
75  const storeInput = documentRef.getElementById('item-store');
76  const notesInput = documentRef.getElementById('item-notes');
77  const formTitle = documentRef.getElementById('shopping-form-title');
78  const submitButton = documentRef.getElementById('shopping-submit');
79  const cancelButton = documentRef.getElementById('shopping-cancel');
80  const list = documentRef.getElementById('shopping-list');
81  const emptyState = documentRef.getElementById('shopping-empty');
82  const sortSelect = documentRef.getElementById('shopping-sort');
83  const shareInput = documentRef.getElementById('shopping-share-link');
84  const copyLinkButton = documentRef.getElementById('shopping-copy-link');
85  const syncStatus = documentRef.getElementById('shopping-sync-status');
86
87  const listId = getListId();
88  const getShareUrl = () => {
89    const url = new URL(windowRef?.location?.href || 'https://example.com/shopping-list/');
90    url.searchParams.set(LIST_PARAM, listId);
91    return url.toString();
92  };
93  const shareUrl = getShareUrl();
94  const listRoot = gun.get('shopping-list').get(listId);
95  const entries = listRoot.get('items');
96  const itemIndex = listRoot.get('item-index');
97  const cache = new Map();
98  const subscribedItems = new Set();
99  let editingId = null;
100
101  if (shareInput) {
102    shareInput.value = shareUrl;
103  }
104
105  copyLinkButton?.addEventListener('click', async () => {
106    try {
107      if (windowRef?.navigator?.clipboard?.writeText) {
108        await windowRef.navigator.clipboard.writeText(shareUrl);
109      } else if (shareInput?.select && documentRef.execCommand) {
110        shareInput.select();
111        documentRef.execCommand('copy');
112      } else {
113        throw new Error('Clipboard unavailable');
114      }
115      if (syncStatus) {
116        syncStatus.textContent = 'Current list link copied.';
117      }
118    } catch (err) {
119      if (syncStatus) {
120        syncStatus.textContent = 'Copy failed. Select the link above.';
121      }
122    }
123  });
124
125  const resetForm = () => {
126    form.reset();
127    nameInput.value = '';
128    quantityInput.value = '';
129    storeInput.value = '';
130    notesInput.value = '';
131    if (!dateInput.value) {
132      dateInput.value = new Date().toISOString().split('T')[0];
133    }
134  };
135
136  const setEditState = (item) => {
137    editingId = item?.id ?? null;
138    if (editingId) {
139      formTitle.textContent = 'Edit item';
140      submitButton.textContent = 'Save changes';
141      cancelButton.hidden = false;
142    } else {
143      formTitle.textContent = 'New item';
144      submitButton.textContent = 'Add to list';
145      cancelButton.hidden = true;
146      resetForm();
147    }
148  };
149
150  const renderItems = () => {
151    const items = Array.from(cache.values()).filter((entry) => entry && entry.name);
152    const sortMode = sortSelect?.value ?? 'recent';
153    items.sort((a, b) => {
154      if (a.purchased !== b.purchased) {
155        return a.purchased ? 1 : -1;
156      }
157      if (sortMode === 'alpha') {
158        const nameCompare = (a.name ?? '').localeCompare(b.name ?? '', undefined, {
159          sensitivity: 'base',
160        });
161        if (nameCompare !== 0) {
162          return nameCompare;
163        }
164      }
165      return (b.createdAt ?? 0) - (a.createdAt ?? 0);
166    });
167
168    list.innerHTML = '';
169
170    if (items.length === 0) {
171      emptyState.hidden = false;
172      return;
173    }
174
175    emptyState.hidden = true;
176
177    for (const item of items) {
178      const li = documentRef.createElement('li');
179      li.className = 'meal-card';
180      if (item.purchased) {
181        li.classList.add('shopping-item--purchased');
182      }
183
184      const header = documentRef.createElement('div');
185      header.className = 'meal-card__header';
186
187      const title = documentRef.createElement('h4');
188      title.textContent = item.quantity ? `${item.name} · ${item.quantity}` : item.name;
189
190      const meta = documentRef.createElement('div');
191      meta.className = 'meal-meta';
192
193      const categoryTag = documentRef.createElement('span');
194      categoryTag.className = 'meal-tag';
195      categoryTag.textContent = item.category || 'General';
196
197      meta.appendChild(categoryTag);
198
199      if (item.neededBy) {
200        const dateTag = documentRef.createElement('span');
201        dateTag.className = 'meal-date';
202        dateTag.textContent = `Needed ${formatDate(item.neededBy)}`;
203        meta.appendChild(dateTag);
204      }
205
206      if (item.purchased) {
207        const purchasedTag = documentRef.createElement('span');
208        purchasedTag.className = 'meal-tag';
209        purchasedTag.textContent = 'Purchased';
210        meta.appendChild(purchasedTag);
211      }
212
213      header.append(title, meta);
214      li.appendChild(header);
215
216      const details = documentRef.createElement('ul');
217      details.className = 'cycle-card__details';
218
219      if (item.store) {
220        const storeLine = documentRef.createElement('li');
221        storeLine.textContent = `Store: ${item.store}`;
222        details.appendChild(storeLine);
223      }
224
225      if (item.notes) {
226        const notesLine = documentRef.createElement('li');
227        notesLine.textContent = `Notes: ${item.notes}`;
228        details.appendChild(notesLine);
229      }
230
231      if (details.children.length > 0) {
232        li.appendChild(details);
233      }
234
235      const actions = documentRef.createElement('div');
236      actions.className = 'shopping-actions';
237
238      const toggleButton = documentRef.createElement('button');
239      toggleButton.type = 'button';
240      toggleButton.className = 'shopping-action-btn';
241      toggleButton.textContent = item.purchased ? 'Mark unpurchased' : 'Mark purchased';
242      toggleButton.addEventListener('click', () => {
243        entries.get(item.id).put({
244          purchased: !item.purchased,
245          purchasedAt: !item.purchased ? Date.now() : null,
246        });
247      });
248
249      const editButton = documentRef.createElement('button');
250      editButton.type = 'button';
251      editButton.className = 'shopping-action-btn';
252      editButton.textContent = 'Edit';
253      editButton.addEventListener('click', () => {
254        nameInput.value = item.name ?? '';
255        quantityInput.value = item.quantity ?? '';
256        categoryInput.value = item.category ?? 'Other';
257        dateInput.value = item.neededBy ?? '';
258        storeInput.value = item.store ?? '';
259        notesInput.value = item.notes ?? '';
260        setEditState(item);
261        nameInput.focus();
262      });
263
264      const deleteButton = documentRef.createElement('button');
265      deleteButton.type = 'button';
266      deleteButton.className = 'shopping-action-btn shopping-action-btn--ghost';
267      deleteButton.textContent = 'Delete';
268      deleteButton.addEventListener('click', () => {
269        itemIndex.put({ [item.id]: false });
270        entries.get(item.id).put(null);
271        if (editingId === item.id) {
272          setEditState(null);
273        }
274      });
275
276      actions.append(toggleButton, editButton, deleteButton);
277      li.appendChild(actions);
278
279      list.appendChild(li);
280    }
281  };
282
283  const applyItemData = (data, key) => {
284    if (!data) {
285      cache.delete(key);
286      renderItems();
287      return;
288    }
289
290    cache.set(key, {
291      id: key,
292      name: data.name,
293      quantity: data.quantity,
294      category: data.category,
295      neededBy: data.neededBy,
296      store: data.store,
297      notes: data.notes,
298      createdAt: data.createdAt,
299      purchased: Boolean(data.purchased),
300      purchasedAt: data.purchasedAt,
301    });
302
303    renderItems();
304  };
305
306  const subscribeToItem = (key) => {
307    if (!key || subscribedItems.has(key)) {
308      return;
309    }
310    subscribedItems.add(key);
311    entries.get(key).on((data) => {
312      applyItemData(data, key);
313    });
314  };
315
316  itemIndex.map().on((active, key) => {
317    if (!active) {
318      cache.delete(key);
319      renderItems();
320      return;
321    }
322
323    subscribeToItem(key);
324  });
325
326  entries.map().on((data, key) => {
327    applyItemData(data, key);
328  });
329
330  sortSelect?.addEventListener('change', () => {
331    renderItems();
332  });
333
334  form.addEventListener('submit', (event) => {
335    event.preventDefault();
336
337    const name = nameInput.value.trim();
338    const quantity = quantityInput.value.trim();
339    const category = categoryInput.value.trim();
340    const neededBy = dateInput.value;
341    const store = storeInput.value.trim();
342    const notes = notesInput.value.trim();
343
344    if (!name || !category) {
345      return;
346    }
347
348    if (editingId) {
349      const existing = cache.get(editingId) ?? {};
350      entries.get(editingId).put({
351        name,
352        quantity,
353        category,
354        neededBy,
355        store,
356        notes,
357        createdAt: existing.createdAt ?? Date.now(),
358        purchased: existing.purchased ?? false,
359        purchasedAt: existing.purchasedAt ?? null,
360      });
361      itemIndex.put({ [editingId]: true });
362      setEditState(null);
363      return;
364    }
365
366    const id = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
367    entries.get(id).put({
368      name,
369      quantity,
370      category,
371      neededBy,
372      store,
373      notes,
374      createdAt: Date.now(),
375    });
376    itemIndex.put({ [id]: true });
377    resetForm();
378  });
379
380  cancelButton?.addEventListener('click', () => {
381    setEditState(null);
382  });
383
384  const today = new Date().toISOString().split('T')[0];
385  if (!dateInput.value) {
386    dateInput.value = today;
387  }
388
389  return { listId, shareUrl };
390};
391
392if (typeof window !== 'undefined') {
393  window.addEventListener('DOMContentLoaded', () => {
394    initShoppingList();
395  });
396}

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.