Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 123 additions & 31 deletions examples/petpassport/petpassport/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -44,6 +65,8 @@ function renderPaths(paths) {
return;
}

rememberPaths(paths);

if (paths.length === 0) {
pathsList.innerHTML = '<div class="empty-state">No paths generated yet.</div>';
return;
Expand All @@ -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 = `
<h3>${path.breed} Walk</h3>
<p>${path.postal_code}</p>
<div class="path-actions">
<span class="walked-badge">${path.walked ? 'Walked' : 'Not Walked'}</span>
</div>
`;
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) {
Expand All @@ -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 += `<img src="${url}" alt="${path.breed} image" style="max-width: 300px; border-radius: 8px; margin-top: 8px;">`;
const image = document.createElement('img');
image.src = url;
image.alt = `${path.breed || 'Pet'} image`;
images.appendChild(image);
});
}

detailsContent.innerHTML = `
<h2>🐾 ${path.breed} Walk Passport 🐾</h2>
<p><strong>Location:</strong> ${path.postal_code}</p>
<div class="itinerary-content">
${processText(path.route_details)}
</div>
<div class="images-container">
${imagesHtml}
</div>
<div class="modal-actions">
<span class="walked-badge">${path.walked ? 'Walked' : 'Not Walked'}</span>
</div>
`;
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);
}
}

Expand Down
84 changes: 73 additions & 11 deletions examples/petpassport/petpassport/static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -291,23 +341,35 @@ 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 {
margin-top: 20px;
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 {
Expand Down
Loading