Skip to content
Merged
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
51 changes: 37 additions & 14 deletions web/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -425,18 +425,19 @@ class Status:
return s


def _get_recent_clips(limit=6):
"""Get the most recent clips with their captions."""
def _get_recent_clips(limit=12, offset=0):
"""Return a page of recent clips (newest first) plus the total count on disk."""
if not config.CLIPS_DIR.exists():
return []
return [], 0

mp4s = sorted(config.CLIPS_DIR.glob("*.mp4"), key=os.path.getmtime, reverse=True)[:limit]
all_mp4s = sorted(config.CLIPS_DIR.glob("*.mp4"), key=os.path.getmtime, reverse=True)
total = len(all_mp4s)
page_files = all_mp4s[offset:offset + limit]
clips = []
for p in mp4s:
for p in page_files:
size_mb = p.stat().st_size / 1_048_576
mtime = datetime.fromtimestamp(p.stat().st_mtime, tz=_local_tz).strftime("%Y-%m-%d %H:%M:%S")

# Load caption if it exists
caption_path = p.with_suffix(".txt")
caption = ""
if caption_path.exists():
Expand All @@ -451,7 +452,7 @@ def _get_recent_clips(limit=6):
"time": mtime,
"caption": caption,
})
return clips
return clips, total


def _classify_log_line(line):
Expand Down Expand Up @@ -556,7 +557,19 @@ def dashboard():

@app.route("/gallery")
def gallery_page():
return render_template("gallery.html", clips=_get_recent_clips())
page = max(1, request.args.get("page", 1, type=int) or 1)
per_page = max(1, min(60, request.args.get("per_page", 12, type=int) or 12))
offset = (page - 1) * per_page
clips, total = _get_recent_clips(limit=per_page, offset=offset)
total_pages = max(1, (total + per_page - 1) // per_page)
return render_template(
"gallery.html",
clips=clips,
page=page,
per_page=per_page,
total=total,
total_pages=total_pages,
)


@app.route("/feeders")
Expand Down Expand Up @@ -1635,12 +1648,22 @@ def serve_training_photo(label, filename):

@app.route("/api/clips/list")
def api_clips_list():
"""JSON list of recent clips for live updates."""
clips = _get_recent_clips()
return {"clips": [
{"name": c["name"], "size": c["size"], "time": c["time"], "caption": c.get("caption", "")}
for c in clips
]}
"""JSON list of recent clips for live updates, with pagination metadata."""
page = max(1, request.args.get("page", 1, type=int) or 1)
per_page = max(1, min(60, request.args.get("per_page", 12, type=int) or 12))
offset = (page - 1) * per_page
clips, total = _get_recent_clips(limit=per_page, offset=offset)
total_pages = max(1, (total + per_page - 1) // per_page)
return {
"clips": [
{"name": c["name"], "size": c["size"], "time": c["time"], "caption": c.get("caption", "")}
for c in clips
],
"page": page,
"per_page": per_page,
"total": total,
"total_pages": total_pages,
}


@app.route("/api/season", methods=["POST"])
Expand Down
111 changes: 89 additions & 22 deletions web/templates/gallery.html
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,44 @@
gap: 8px;
border-top: 1px solid #1e3625;
}
.gallery-meta {
color: #a3b8a6;
font-size: 0.9em;
margin-left: 10px;
}
.gallery-pagination {
margin-top: 20px;
display: flex;
gap: 12px;
align-items: center;
justify-content: center;
flex-wrap: wrap;
}
.gallery-pagination .btn-page {
background: #1e3625;
color: #e8f0ea;
padding: 8px 16px;
font-size: 0.9em;
}
.gallery-pagination .btn-page:hover { background: #2a4933; }
.gallery-pagination .btn-page.disabled {
opacity: 0.4;
pointer-events: none;
cursor: not-allowed;
}
.gallery-pagination .page-indicator {
color: #a3b8a6;
font-size: 0.9em;
}
{% endblock %}

{% block content %}
<div class="section">
<h2 style="display:inline-block;">Gallery</h2>
{% if clips %}
<span class="gallery-meta" id="clip-count">
Showing {{ (page - 1) * per_page + 1 }}&ndash;{{ (page - 1) * per_page + clips|length }} of {{ total }}
</span>
<span class="section-actions">
<button class="btn btn-delete-all" onclick="showDeleteAll()">Delete All Clips</button>
</span>
Expand All @@ -58,6 +90,21 @@ <h2 style="display:inline-block;">Gallery</h2>
</div>
{% endfor %}
</div>
{% if total_pages > 1 %}
<div class="gallery-pagination">
<a class="btn btn-page {% if page <= 1 %}disabled{% endif %}"
href="?page={{ page - 1 }}&per_page={{ per_page }}">&larr; Prev</a>
<span class="page-indicator" id="page-indicator">Page {{ page }} of {{ total_pages }}</span>
<a class="btn btn-page {% if page >= total_pages %}disabled{% endif %}"
href="?page={{ page + 1 }}&per_page={{ per_page }}">Next &rarr;</a>
</div>
{% endif %}
{% elif total and page > 1 %}
<p style="color: #a3b8a6; margin-top: 15px;">
Page {{ page }} is empty.
<a href="?page=1&per_page={{ per_page }}" style="color: #b08ad4;">Go to page 1</a>
({{ total }} clip{{ 's' if total != 1 else '' }} total).
</p>
{% else %}
<p style="color: #a3b8a6; margin-top: 15px;">No clips recorded yet. Waiting for hummingbirds...</p>
{% endif %}
Expand All @@ -75,6 +122,10 @@ <h2 style="display:inline-block;">Gallery</h2>

{% block scripts %}
<script>
const urlParams = new URLSearchParams(window.location.search);
const currentPage = parseInt(urlParams.get('page')) || 1;
const currentPerPage = parseInt(urlParams.get('per_page')) || 12;

function deleteClip(filename, cardId) {
if (!confirm('Delete ' + filename + ' and its caption?')) return;
fetch('/api/clips/' + filename, { method: 'DELETE' })
Expand Down Expand Up @@ -116,38 +167,54 @@ <h2 style="display:inline-block;">Gallery</h2>
});
}

// Poll for new clips
// Poll for new clips, keeping the user on their current page
let knownClipsFingerprint = '';
function clipsFingerprint(clips) {
return clips.map(c => c.name + '|' + (c.caption || '')).join(';');
}
function pollClips() {
fetch('/api/clips/list')
fetch(`/api/clips/list?page=${currentPage}&per_page=${currentPerPage}`)
.then(r => r.json())
.then(data => {
// If the current page emptied but clips still exist (e.g. last clip
// on this page was deleted), jump to page 1 so something is visible.
if (data.clips.length === 0 && data.total > 0 && currentPage > 1) {
window.location.href = `?page=1&per_page=${currentPerPage}`;
return;
}
const fp = clipsFingerprint(data.clips);
if (fp !== knownClipsFingerprint) {
const grid = document.querySelector('.clip-grid');
if (!grid && data.clips.length > 0) {
location.reload();
return;
}
if (grid) {
grid.innerHTML = data.clips.map((c, i) => `
<div class="clip-card" id="clip-dyn-${i}">
<video controls preload="metadata">
<source src="/clips/${c.name}" type="video/mp4">
</video>
${c.caption ? `<div class="clip-caption">"${escapeHtml(c.caption)}"</div>` : ''}
<div class="clip-info">${c.name} &mdash; ${c.size} &mdash; ${c.time}</div>
<div class="clip-actions">
<button class="btn btn-delete" onclick="deleteClip('${c.name}', 'clip-dyn-${i}')">Delete Clip</button>
</div>
if (fp === knownClipsFingerprint) return;

const grid = document.querySelector('.clip-grid');
if (!grid && data.clips.length > 0) {
location.reload();
return;
}
if (grid) {
grid.innerHTML = data.clips.map((c, i) => `
<div class="clip-card" id="clip-dyn-${i}">
<video controls preload="metadata">
<source src="/clips/${c.name}" type="video/mp4">
</video>
${c.caption ? `<div class="clip-caption">"${escapeHtml(c.caption)}"</div>` : ''}
<div class="clip-info">${c.name} &mdash; ${c.size} &mdash; ${c.time}</div>
<div class="clip-actions">
<button class="btn btn-delete" onclick="deleteClip('${c.name}', 'clip-dyn-${i}')">Delete Clip</button>
</div>
`).join('');
}
knownClipsFingerprint = fp;
</div>
`).join('');
}
const countEl = document.getElementById('clip-count');
if (countEl && data.total > 0 && data.clips.length > 0) {
const start = (data.page - 1) * data.per_page + 1;
const end = (data.page - 1) * data.per_page + data.clips.length;
countEl.textContent = `Showing ${start}–${end} of ${data.total}`;
}
const pageEl = document.getElementById('page-indicator');
if (pageEl) {
pageEl.textContent = `Page ${data.page} of ${data.total_pages}`;
}
knownClipsFingerprint = fp;
})
.catch(() => {});
}
Expand Down
Loading