diff --git a/examples/petpassport/petpassport/static/app.js b/examples/petpassport/petpassport/static/app.js index 75f34b2..f44db2c 100644 --- a/examples/petpassport/petpassport/static/app.js +++ b/examples/petpassport/petpassport/static/app.js @@ -19,16 +19,37 @@ userInput.addEventListener('keypress', (e) => { if (e.key === 'Enter') sendMessage(); }); +const pathsById = new Map(); +let openPathId = null; + async function loadPaths() { try { const response = await fetch(`/api/paths?user_id=${userId}`); const paths = await response.json(); renderPaths(paths); + if (openPathId != null && !detailsContainer.classList.contains('hidden')) { + const updated = pathsById.get(String(openPathId)); + if (updated) refreshWalkedToggles(updated); + } } catch (error) { console.error('Error loading paths:', error); } } +function rememberPaths(paths) { + pathsById.clear(); + paths.forEach((path) => { + if (path && path.id != null) pathsById.set(String(path.id), path); + }); +} + +function currentPath(path) { + if (path && path.id != null && pathsById.has(String(path.id))) { + return pathsById.get(String(path.id)); + } + return path; +} + function renderPaths(paths) { if (typeof paths === 'string') { try { @@ -44,6 +65,8 @@ function renderPaths(paths) { return; } + rememberPaths(paths); + if (paths.length === 0) { pathsList.innerHTML = '
No paths generated yet.
'; return; @@ -52,19 +75,30 @@ function renderPaths(paths) { pathsList.innerHTML = ''; try { paths.forEach(path => { - const card = document.createElement('div'); + const card = document.createElement('article'); card.className = 'path-card'; - card.innerHTML = ` -

${path.breed} Walk

-

${path.postal_code}

-
- ${path.walked ? 'Walked' : 'Not Walked'} -
- `; - card.addEventListener('click', () => { - showDetails(path); - toggleWalked(path); - }); + if (path.id != null) card.dataset.pathId = String(path.id); + if (openPathId != null && String(path.id) === String(openPathId)) { + card.classList.add('is-open'); + } + + const openButton = document.createElement('button'); + openButton.type = 'button'; + openButton.className = 'path-open'; + openButton.setAttribute('aria-label', `Open ${path.breed || 'pet'} walk`); + + const title = document.createElement('h3'); + title.textContent = `${path.breed || 'Pet'} Walk`; + const place = document.createElement('p'); + place.textContent = path.postal_code || ''; + openButton.append(title, place); + openButton.addEventListener('click', () => showDetails(currentPath(path))); + + const actions = document.createElement('div'); + actions.className = 'path-actions'; + actions.append(createWalkedToggle(path)); + + card.append(openButton, actions); pathsList.appendChild(card); }); } catch (e) { @@ -73,52 +107,110 @@ function renderPaths(paths) { } } +function createWalkedToggle(path) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'walked-toggle'; + if (path.id != null) button.dataset.pathId = String(path.id); + syncWalkedToggle(button, path); + button.addEventListener('click', (event) => { + event.stopPropagation(); + toggleWalked(currentPath(path)); + }); + return button; +} + +function syncWalkedToggle(button, path) { + const walked = Boolean(path.walked); + button.classList.toggle('is-walked', walked); + button.classList.toggle('is-pending', !walked); + button.textContent = walked ? 'Walked' : 'Not walked'; + button.setAttribute('aria-pressed', String(walked)); + button.setAttribute('aria-label', walked ? 'Mark as not walked' : 'Mark as walked'); +} + +function refreshWalkedToggles(path) { + if (path.id == null) return; + document.querySelectorAll(`.walked-toggle[data-path-id="${CSS.escape(String(path.id))}"]`).forEach((button) => { + syncWalkedToggle(button, path); + }); +} + function showDetails(path) { + openPathId = path.id; + document.querySelectorAll('.path-card').forEach((card) => { + card.classList.toggle('is-open', path.id != null && card.dataset.pathId === String(path.id)); + }); + chatContainer.classList.add('hidden'); detailsContainer.classList.remove('hidden'); - let imagesHtml = ''; + const heading = document.createElement('h2'); + heading.textContent = `🐾 ${path.breed || 'Pet'} Walk Passport 🐾`; + + const location = document.createElement('p'); + const locationLabel = document.createElement('strong'); + locationLabel.textContent = 'Location: '; + location.append(locationLabel, document.createTextNode(path.postal_code || '')); + + const itinerary = document.createElement('div'); + itinerary.className = 'itinerary-content'; + itinerary.innerHTML = processText(path.route_details); + + const images = document.createElement('div'); + images.className = 'images-container'; if (path.image_paths && path.image_paths.length > 0) { path.image_paths.forEach(src => { let url = src; - if (url.startsWith('file:///tmp/')) { + if (typeof url === 'string' && url.startsWith('file:///tmp/')) { url = url.replace('file:///tmp/', '/tmp/'); } - imagesHtml += `${path.breed} image`; + const image = document.createElement('img'); + image.src = url; + image.alt = `${path.breed || 'Pet'} image`; + images.appendChild(image); }); } - detailsContent.innerHTML = ` -

🐾 ${path.breed} Walk Passport 🐾

-

Location: ${path.postal_code}

-
- ${processText(path.route_details)} -
-
- ${imagesHtml} -
- - `; + const actions = document.createElement('div'); + actions.className = 'modal-actions'; + actions.append(createWalkedToggle(path)); + + detailsContent.replaceChildren(heading, location, itinerary, images, actions); } backBtn.addEventListener('click', () => { + openPathId = null; + document.querySelectorAll('.path-card.is-open').forEach((card) => card.classList.remove('is-open')); detailsContainer.classList.add('hidden'); chatContainer.classList.remove('hidden'); }); +const walkSavesInFlight = new Set(); + async function toggleWalked(path) { - path.walked = !path.walked; + const key = path.id != null ? String(path.id) : path; + if (walkSavesInFlight.has(key)) return; + walkSavesInFlight.add(key); + + const previous = Boolean(path.walked); + path.walked = !previous; + refreshWalkedToggles(path); try { - await fetch(`/api/paths?user_id=${userId}`, { + const response = await fetch(`/api/paths?user_id=${userId}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(path) }); - loadPaths(); + if (!response.ok) { + throw new Error(`Save failed (${response.status})`); + } } catch (error) { + path.walked = previous; + refreshWalkedToggles(path); console.error('Error updating path:', error); + } finally { + walkSavesInFlight.delete(key); } } diff --git a/examples/petpassport/petpassport/static/style.css b/examples/petpassport/petpassport/static/style.css index 9f0892b..50a764e 100644 --- a/examples/petpassport/petpassport/static/style.css +++ b/examples/petpassport/petpassport/static/style.css @@ -186,15 +186,39 @@ body { border-radius: 12px; padding: 16px; margin-bottom: 12px; - cursor: pointer; - transition: transform 0.2s, background 0.2s; + transition: transform 0.2s, background 0.2s, border-color 0.2s; } -.path-card:hover { +.path-card:hover, +.path-card.is-open { transform: translateY(-2px); background: rgba(255, 255, 255, 0.1); } +.path-card.is-open { + border-color: rgba(255, 255, 255, 0.45); +} + +.path-open { + display: block; + width: 100%; + padding: 0; + margin: 0 0 8px; + background: transparent; + border: none; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.path-open:focus-visible, +.walked-toggle:focus-visible, +.back-btn:focus-visible { + outline: 2px solid #ffffff; + outline-offset: 2px; +} + .path-card h3 { font-size: 0.95rem; font-weight: 600; @@ -204,19 +228,34 @@ body { .path-card p { font-size: 0.8rem; color: var(--text-muted); - margin-bottom: 8px; + margin-bottom: 0; } .path-actions { display: flex; - justify-content: space-between; + justify-content: flex-start; align-items: center; font-size: 0.8rem; } -.walked-badge { - padding: 2px 6px; - border-radius: 4px; +.walked-toggle { + border: none; + font: inherit; + font-size: 0.75rem; + font-weight: 600; + line-height: 1.2; + white-space: nowrap; + cursor: pointer; + padding: 4px 10px; + border-radius: 999px; +} + +.walked-toggle.is-pending { + background: rgba(255, 255, 255, 0.14); + color: var(--text-muted); +} + +.walked-toggle.is-walked { background: rgba(16, 185, 129, 0.2); color: #10b981; } @@ -257,11 +296,22 @@ body { .details-header { display: flex; + flex-wrap: wrap; align-items: center; justify-content: space-between; + gap: 12px; margin-bottom: 24px; } +.details-header h2 { + font-family: 'Outfit', sans-serif; + font-size: 1.35rem; + line-height: 1.2; + margin: 0; + min-width: 0; + flex: 1 1 10rem; +} + .back-btn { background: rgba(255, 255, 255, 0.1); color: white; @@ -291,8 +341,11 @@ body { .details-content h2 { font-family: 'Outfit', sans-serif; + font-size: 1.5rem; + line-height: 1.25; margin-bottom: 16px; text-align: center; + overflow-wrap: break-word; } .itinerary-content { @@ -300,14 +353,23 @@ body { line-height: 1.6; overflow-wrap: break-word; word-wrap: break-word; - white-space: pre-wrap; - word-break: break-all; } .itinerary-content * { overflow-wrap: break-word; word-wrap: break-word; - word-break: break-all; +} + +.itinerary-content p, +.itinerary-content ul, +.itinerary-content ol { + margin-bottom: 0.75rem; +} + +.images-container img { + max-width: 300px; + border-radius: 8px; + margin-top: 8px; } .images-container {