feat(ui): jobs page — card grid with badges and thumbnails
This commit is contained in:
1 parent
87c8da3961
commit
b1170f42a9
1 file changed
+328
-28
+328
-28
@@ -1,35 +1,335 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Jobs - Feedmill Recounter{% endblock %}
|
||||
{% block content %}
|
||||
<h2>All Jobs</h2>
|
||||
<style>
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-8);
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
font-size: var(--text-2xl);
|
||||
color: var(--color-neutral-900);
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: var(--space-16) var(--space-6);
|
||||
color: var(--color-neutral-500);
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
margin-bottom: var(--space-6);
|
||||
color: var(--color-neutral-300);
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: var(--text-xl);
|
||||
color: var(--color-neutral-700);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: var(--text-sm);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
/* Card grid */
|
||||
.jobs-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.jobs-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.jobs-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* Job card */
|
||||
.job-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.job-card:hover {
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.job-card-thumb {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: var(--color-neutral-900);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.job-card-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.job-card-thumb-placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-neutral-500);
|
||||
font-size: var(--text-2xl);
|
||||
}
|
||||
|
||||
.job-card-status {
|
||||
position: absolute;
|
||||
top: var(--space-3);
|
||||
right: var(--space-3);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.job-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.job-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.job-card-id {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--color-neutral-800);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--color-neutral-200);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-neutral-50);
|
||||
color: var(--color-neutral-500);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.copy-btn:hover {
|
||||
background: var(--color-neutral-100);
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.copy-btn.copied {
|
||||
background: var(--color-success-50);
|
||||
color: var(--color-success);
|
||||
border-color: var(--color-success);
|
||||
}
|
||||
|
||||
.copy-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.job-card-video {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-neutral-600);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.job-card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-neutral-500);
|
||||
}
|
||||
|
||||
/* Thin progress bar */
|
||||
.job-card-progress {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: var(--color-neutral-200);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.job-card-progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--color-primary-500), var(--color-primary-400));
|
||||
border-radius: 2px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
.job-card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-3) var(--space-5);
|
||||
border-top: 1px solid var(--color-neutral-100);
|
||||
}
|
||||
|
||||
.job-card-time {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-neutral-400);
|
||||
}
|
||||
|
||||
.job-card-link {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--color-primary);
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.job-card-link:hover {
|
||||
color: var(--color-primary-700);
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="page-header">
|
||||
<h2>Processing Jobs</h2>
|
||||
</div>
|
||||
|
||||
{% if jobs %}
|
||||
<table class="results-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Job ID</th>
|
||||
<th>Status</th>
|
||||
<th>Progress</th>
|
||||
<th>Models</th>
|
||||
<th>Created</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for job in jobs %}
|
||||
<tr>
|
||||
<td><a href="/status/{{ job.job_id }}">{{ job.job_id }}</a></td>
|
||||
<td class="status-{{ job.status.name|lower }}">{{ job.status.name }}</td>
|
||||
<td>{{ "%.0f"|format(job.progress * 100) }}%</td>
|
||||
<td>{{ job.model_configs|length }} model(s)</td>
|
||||
<td>{{ "%.1f"|format(job.created_at) }}</td>
|
||||
<td>
|
||||
<a href="/status/{{ job.job_id }}">View</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="jobs-grid">
|
||||
{% for job in jobs %}
|
||||
<div class="card job-card">
|
||||
<div class="job-card-thumb">
|
||||
<img src="/preview/thumb/{{ job.job_id }}" alt="Thumbnail for job {{ job.job_id }}" loading="lazy"
|
||||
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
|
||||
<div class="job-card-thumb-placeholder" style="display:none;" aria-hidden="true">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"/><line x1="7" y1="2" x2="7" y2="22"/><line x1="17" y1="2" x2="17" y2="22"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="2" y1="7" x2="7" y2="7"/><line x1="2" y1="17" x2="7" y2="17"/><line x1="17" y1="7" x2="22" y2="7"/><line x1="17" y1="17" x2="22" y2="17"/></svg>
|
||||
</div>
|
||||
<div class="job-card-status">
|
||||
<span class="badge
|
||||
{% if job.status.name == 'COMPLETED' %}badge-success
|
||||
{% elif job.status.name == 'RUNNING' %}badge-info
|
||||
{% elif job.status.name == 'PENDING' %}badge-warning
|
||||
{% elif job.status.name == 'FAILED' %}badge-error
|
||||
{% else %}badge-neutral{% endif %}">
|
||||
{{ job.status.name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="job-card-body">
|
||||
<div class="job-card-header">
|
||||
<span class="job-card-id" title="{{ job.job_id }}">{{ job.job_id[:12] }}{% if job.job_id|length > 12 %}…{% endif %}</span>
|
||||
<button class="copy-btn" onclick="copyJobId(this, '{{ job.job_id }}')" title="Copy Job ID" aria-label="Copy Job ID">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="job-card-video" title="{{ job.video_path }}">
|
||||
{{ job.video_path.split('/')[-1] }}
|
||||
</div>
|
||||
<div class="job-card-progress" role="progressbar" aria-valuenow="{{ job.progress * 100 }}" aria-valuemin="0" aria-valuemax="100">
|
||||
<div class="job-card-progress-fill" style="width: {{ job.progress * 100 }}%"></div>
|
||||
</div>
|
||||
<div class="job-card-meta">
|
||||
<span>{{ job.model_configs|length }} model{{ 's' if job.model_configs|length != 1 else '' }}</span>
|
||||
<span>{{ "%.0f"|format(job.progress * 100) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="job-card-footer">
|
||||
<span class="job-card-time" data-timestamp="{{ job.created_at }}"></span>
|
||||
<a href="/status/{{ job.job_id }}" class="job-card-link">View Details</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p>No jobs yet. <a href="/">Upload a video</a></p>
|
||||
<div class="empty-state">
|
||||
<svg class="empty-state-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"/>
|
||||
<line x1="7" y1="2" x2="7" y2="22"/>
|
||||
<line x1="17" y1="2" x2="17" y2="22"/>
|
||||
<line x1="2" y1="12" x2="22" y2="12"/>
|
||||
<line x1="2" y1="7" x2="7" y2="7"/>
|
||||
<line x1="2" y1="17" x2="7" y2="17"/>
|
||||
<line x1="17" y1="7" x2="22" y2="7"/>
|
||||
<line x1="17" y1="17" x2="22" y2="17"/>
|
||||
</svg>
|
||||
<h3>No jobs yet</h3>
|
||||
<p>Upload a video to start processing.</p>
|
||||
<a href="/" class="btn btn-primary">Upload a video</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
function formatRelativeTime(timestamp) {
|
||||
var now = Math.floor(Date.now() / 1000);
|
||||
var diff = now - timestamp;
|
||||
if (diff < 0) diff = 0;
|
||||
if (diff < 60) return 'just now';
|
||||
if (diff < 3600) {
|
||||
var mins = Math.floor(diff / 60);
|
||||
return mins + (mins === 1 ? ' minute ago' : ' minutes ago');
|
||||
}
|
||||
if (diff < 86400) {
|
||||
var hrs = Math.floor(diff / 3600);
|
||||
return hrs + (hrs === 1 ? ' hour ago' : ' hours ago');
|
||||
}
|
||||
var days = Math.floor(diff / 86400);
|
||||
return days + (days === 1 ? ' day ago' : ' days ago');
|
||||
}
|
||||
|
||||
function updateTimes() {
|
||||
var els = document.querySelectorAll('[data-timestamp]');
|
||||
for (var i = 0; i < els.length; i++) {
|
||||
var ts = parseFloat(els[i].getAttribute('data-timestamp'));
|
||||
if (!isNaN(ts)) {
|
||||
els[i].textContent = formatRelativeTime(ts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateTimes();
|
||||
setInterval(updateTimes, 60000);
|
||||
})();
|
||||
|
||||
function copyJobId(btn, id) {
|
||||
navigator.clipboard.writeText(id).then(function() {
|
||||
btn.classList.add('copied');
|
||||
btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
|
||||
setTimeout(function() {
|
||||
btn.classList.remove('copied');
|
||||
btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
|
||||
}, 1500);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in new issue
Block a user