Files
nextsnap/app/templates/queue.html
kamaji 5105b42c46 Prevent blob eviction: store as ArrayBuffer + request persistence
iOS Safari evicts Blob file-backed data from IndexedDB under memory
pressure, causing upload POSTs to throw 'Load failed' without ever
reaching the server. Two-pronged fix:

1. Store photos as ArrayBuffer (inline bytes) instead of Blob (file
   reference) in IndexedDB — ArrayBuffers are not subject to eviction
2. Request navigator.storage.persist() to signal the browser not to
   evict our storage under pressure

Also adds Storage.getBlob() helper for converting stored ArrayBuffer
back to Blob at upload/display time, with backward compat for any
existing Blob-stored photos.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 16:01:07 -06:00

620 lines
15 KiB
HTML

{% extends "base.html" %}
{% block title %}Queue - NextSnap{% endblock %}
{% block content %}
<div class="container">
<div class="queue-header">
<h2>Upload Queue</h2>
<div class="queue-actions">
<button class="btn btn-secondary btn-small" id="sync-now-btn" disabled>
<span class="btn-icon">🔄</span> Sync Now
</button>
</div>
</div>
<div class="queue-stats" id="queue-stats">
<div class="stat">
<span class="stat-value" id="pending-stat">0</span>
<span class="stat-label">Pending</span>
</div>
<div class="stat">
<span class="stat-value" id="uploading-stat">0</span>
<span class="stat-label">Uploading</span>
</div>
<div class="stat">
<span class="stat-value" id="total-size-stat">0 MB</span>
<span class="stat-label">Total Size</span>
</div>
</div>
<div class="queue-list" id="queue-list">
<p class="empty-state" id="empty-state">No photos in queue</p>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal" id="delete-modal" style="display: none;">
<div class="modal-content">
<h3>Delete Photo?</h3>
<p>Are you sure you want to delete this photo from the queue?</p>
<p class="warning-text">⚠️ This action cannot be undone.</p>
<div class="modal-actions">
<button class="btn btn-secondary" id="cancel-delete-btn">Cancel</button>
<button class="btn btn-danger" id="confirm-delete-btn">Delete</button>
</div>
</div>
</div>
{% endblock %}
{% block extra_css %}
<style>
.queue-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 0;
border-bottom: 1px solid var(--bg-tertiary);
}
.queue-header h2 {
font-size: 1.5rem;
margin: 0;
}
.queue-actions {
display: flex;
gap: 0.5rem;
}
.btn-small {
padding: 0.5rem 1rem;
font-size: 0.9rem;
min-height: auto;
}
.btn-icon {
display: inline-block;
margin-right: 0.25rem;
}
.btn-secondary {
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--accent);
}
.btn-secondary:active:not(:disabled) {
opacity: 0.8;
transform: scale(0.98);
}
.btn-secondary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-outline {
background: transparent;
color: var(--text-primary);
border: 1px solid var(--text-secondary);
}
.btn-danger {
background: var(--error);
color: white;
}
.queue-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
margin: 1.5rem 0;
}
.stat {
background: var(--bg-secondary);
padding: 1rem;
border-radius: 8px;
text-align: center;
}
.stat-value {
display: block;
font-size: 1.5rem;
font-weight: 700;
color: var(--accent);
margin-bottom: 0.25rem;
}
.stat-label {
display: block;
font-size: 0.85rem;
color: var(--text-secondary);
}
.queue-list {
margin-bottom: 2rem;
}
.empty-state {
text-align: center;
color: var(--text-secondary);
padding: 3rem 1rem;
}
.queue-item {
background: var(--bg-secondary);
border-radius: 8px;
padding: 0.75rem;
margin-bottom: 0.75rem;
display: flex;
gap: 0.75rem;
align-items: center;
position: relative;
}
.queue-item.uploading {
border: 2px solid var(--accent);
}
.queue-item.verified {
border: 1px solid var(--success, #4caf50);
}
.queue-item.error {
border: 2px solid var(--error);
}
.queue-item.completed {
opacity: 0.7;
}
.queue-divider {
text-align: center;
color: var(--text-secondary);
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 1rem 0 0.5rem;
margin-top: 0.5rem;
border-top: 1px solid var(--bg-tertiary);
}
.thumbnail-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
font-size: 2rem;
}
.queue-item-thumbnail {
width: 80px;
height: 80px;
border-radius: 6px;
overflow: hidden;
flex-shrink: 0;
background: var(--bg-tertiary);
}
.queue-item-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.queue-item-info {
flex: 1;
min-width: 0;
}
.queue-item-filename {
font-weight: 600;
margin-bottom: 0.25rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.queue-item-meta {
font-size: 0.85rem;
color: var(--text-secondary);
margin-bottom: 0.25rem;
}
.queue-item-status {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
}
.status-badge.pending {
background: var(--warning);
color: var(--bg-primary);
}
.status-badge.uploading {
background: var(--accent);
color: white;
}
.status-badge.verified {
background: var(--success, #4caf50);
color: white;
}
.status-badge.failed {
background: var(--error);
color: white;
}
.status-badge.error {
background: var(--error);
color: white;
}
.retry-info {
color: var(--text-secondary);
font-size: 0.75rem;
}
.error-message {
color: var(--error);
font-size: 0.75rem;
margin-top: 0.25rem;
}
.queue-item-actions {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.queue-item-delete {
background: var(--error);
color: white;
border: none;
padding: 0.5rem;
border-radius: 6px;
cursor: pointer;
font-size: 1.25rem;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.queue-item-delete:active {
opacity: 0.8;
transform: scale(0.95);
}
.queue-item-retry {
background: var(--accent);
color: white;
border: none;
padding: 0.5rem;
border-radius: 50%;
cursor: pointer;
font-size: 1.2rem;
width: 2.5rem;
height: 2.5rem;
display: flex;
align-items: center;
justify-content: center;
}
.queue-item-retry:active {
transform: scale(0.95);
opacity: 0.8;
}
/* Modal */
.modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
padding: 1rem;
}
.modal-content {
background: var(--bg-secondary);
padding: 1.5rem;
border-radius: 12px;
max-width: 400px;
width: 100%;
}
.modal-content h3 {
margin-bottom: 1rem;
}
.modal-content p {
margin-bottom: 1rem;
color: var(--text-secondary);
}
.warning-text {
color: var(--warning) !important;
font-weight: 600;
}
.modal-actions {
display: flex;
gap: 0.5rem;
margin-top: 1.5rem;
}
.modal-actions .btn {
flex: 1;
}
</style>
{% endblock %}
{% block extra_js %}
<script src="{{ url_for('static', filename='lib/dexie.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/storage.js') }}"></script>
<script src="{{ url_for('static', filename='js/sync.js') }}"></script>
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
<script>
const currentUsername = '{{ username }}';
let deletePhotoId = null;
// Initialize storage and load queue
Storage.init().then(() => {
SyncEngine.init(currentUsername);
loadQueue();
updateStats();
});
async function loadQueue() {
const queueList = document.getElementById('queue-list');
const emptyState = document.getElementById('empty-state');
const photos = await Storage.db.photos
.where('username').equals(currentUsername)
.sortBy('timestamp');
// Split into active (pending/uploading) and completed (verified/failed)
const activePhotos = photos.filter(p => p.status === 'pending' || p.status === 'uploading');
const completedPhotos = photos.filter(p => p.status === 'verified' || p.status === 'failed');
if (photos.length === 0) {
emptyState.style.display = 'block';
queueList.innerHTML = '';
queueList.appendChild(emptyState);
document.getElementById('sync-now-btn').disabled = true;
return;
}
emptyState.style.display = 'none';
queueList.innerHTML = '';
// Show active uploads first (newest first)
for (const photo of activePhotos.reverse()) {
queueList.appendChild(createQueueItem(photo, false));
}
// Show completed history (newest first, last 20 kept by pruneHistory)
if (completedPhotos.length > 0) {
const divider = document.createElement('div');
divider.className = 'queue-divider';
divider.textContent = 'Recent Uploads';
queueList.appendChild(divider);
for (const photo of completedPhotos.reverse()) {
queueList.appendChild(createQueueItem(photo, true));
}
}
document.getElementById('sync-now-btn').disabled = activePhotos.length === 0 || !navigator.onLine;
}
function createQueueItem(photo, isCompleted) {
const item = document.createElement('div');
item.className = 'queue-item';
if (isCompleted) {
item.classList.add('completed');
}
if (photo.status === 'uploading') {
item.classList.add('uploading');
} else if (photo.status === 'verified') {
item.classList.add('verified');
} else if (photo.status === 'failed' || photo.lastError || photo.error) {
item.classList.add('error');
}
// Create thumbnail (use placeholder if blob was stripped)
const thumbnail = document.createElement('div');
thumbnail.className = 'queue-item-thumbnail';
const photoBlob = Storage.getBlob(photo);
if (photoBlob && photoBlob.size > 0) {
const img = document.createElement('img');
img.src = URL.createObjectURL(photoBlob);
thumbnail.appendChild(img);
} else {
thumbnail.innerHTML = '<span class="thumbnail-placeholder">' +
(photo.status === 'verified' ? '\u2705' : '\u274C') + '</span>';
}
// Create info section
const info = document.createElement('div');
info.className = 'queue-item-info';
const filename = document.createElement('div');
filename.className = 'queue-item-filename';
filename.textContent = photo.filename;
const meta = document.createElement('div');
meta.className = 'queue-item-meta';
const date = new Date(photo.completedAt || photo.timestamp).toLocaleString();
if (photoBlob && photoBlob.size > 0) {
const size = (photoBlob.size / 1024 / 1024).toFixed(2);
meta.textContent = size + ' MB \u2022 ' + date;
} else {
meta.textContent = date;
}
const status = document.createElement('div');
status.className = 'queue-item-status';
const badge = document.createElement('span');
badge.className = 'status-badge ' + photo.status;
badge.textContent = photo.status.charAt(0).toUpperCase() + photo.status.slice(1);
status.appendChild(badge);
if (photo.retryCount > 0 && !isCompleted) {
const retry = document.createElement('span');
retry.className = 'retry-info';
retry.textContent = 'Retry #' + photo.retryCount;
status.appendChild(retry);
}
info.appendChild(filename);
info.appendChild(meta);
info.appendChild(status);
const errorMsg = photo.lastError || photo.error;
if (errorMsg && photo.status !== 'verified') {
const error = document.createElement('div');
error.className = 'error-message';
error.textContent = errorMsg;
info.appendChild(error);
}
item.appendChild(thumbnail);
item.appendChild(info);
// Only show action buttons for active (non-completed) photos
if (!isCompleted) {
const actions = document.createElement('div');
actions.className = 'queue-item-actions';
if (photo.status === 'failed' || photo.status === 'pending' || photo.lastError || photo.error) {
const retryBtn = document.createElement('button');
retryBtn.className = 'queue-item-retry';
retryBtn.textContent = '\uD83D\uDD04';
retryBtn.title = 'Retry upload';
retryBtn.addEventListener('click', async () => {
retryBtn.textContent = '\u23F3';
retryBtn.disabled = true;
await Storage.updatePhoto(photo.id, { status: 'pending', lastError: null, error: null, retryCount: 0 });
if (typeof SyncEngine !== 'undefined') {
SyncEngine.triggerSync();
}
setTimeout(() => loadQueue(), 500);
});
actions.appendChild(retryBtn);
}
const deleteBtn = document.createElement('button');
deleteBtn.className = 'queue-item-delete';
deleteBtn.textContent = '\uD83D\uDDD1\uFE0F';
deleteBtn.title = 'Delete photo';
deleteBtn.addEventListener('click', () => showDeleteModal(photo.id));
actions.appendChild(deleteBtn);
item.appendChild(actions);
}
return item;
}
async function updateStats() {
const photos = await Storage.db.photos
.where('username').equals(currentUsername)
.toArray();
const pendingCount = photos.filter(p => p.status === 'pending').length;
const uploadingCount = photos.filter(p => p.status === 'uploading').length;
const totalSize = photos
.map(p => Storage.getBlob(p))
.filter(b => b && b.size)
.reduce((sum, b) => sum + b.size, 0) / 1024 / 1024;
document.getElementById('pending-stat').textContent = pendingCount;
document.getElementById('uploading-stat').textContent = uploadingCount;
document.getElementById('total-size-stat').textContent = totalSize.toFixed(1) + ' MB';
}
function showDeleteModal(photoId) {
deletePhotoId = photoId;
document.getElementById('delete-modal').style.display = 'flex';
}
function hideDeleteModal() {
deletePhotoId = null;
document.getElementById('delete-modal').style.display = 'none';
}
// Event Listeners
document.getElementById('sync-now-btn').addEventListener('click', async () => {
if (navigator.onLine) {
await SyncEngine.triggerSync();
setTimeout(() => {
loadQueue();
updateStats();
}, 500);
}
});
document.getElementById('cancel-delete-btn').addEventListener('click', hideDeleteModal);
document.getElementById('confirm-delete-btn').addEventListener('click', async () => {
if (deletePhotoId) {
await Storage.deletePhoto(deletePhotoId);
hideDeleteModal();
loadQueue();
updateStats();
}
});
// Close modal on outside click
document.getElementById('delete-modal').addEventListener('click', (e) => {
if (e.target.id === 'delete-modal') {
hideDeleteModal();
}
});
// Listen for sync updates
window.addEventListener('online', () => {
document.getElementById('sync-now-btn').disabled = false;
});
window.addEventListener('offline', () => {
document.getElementById('sync-now-btn').disabled = true;
});
// Refresh queue periodically
setInterval(() => {
loadQueue();
updateStats();
}, 5000);
</script>
{% endblock %}