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.