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 += `
`;
+ 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}
-
-
- ${path.walked ? 'Walked' : 'Not Walked'}
-
- `;
+ 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 {