04/30 Web Checker web app ver. 1.0
This commit is contained in:
@@ -0,0 +1,479 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}My Shifts — Website Checker{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<!-- ── Page header ─────────────────────────────────────────── -->
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>My Shift — Website Checklist</h1>
|
||||
<div class="page-subtitle" id="today-date"></div>
|
||||
</div>
|
||||
<div class="flex gap-1 items-center flex-wrap">
|
||||
{% for s in shifts %}
|
||||
<span class="badge badge-accent">{{ s.name }}
|
||||
{% if s.start_time and s.end_time %}
|
||||
· {{ s.start_time|hhmm }}–{{ s.end_time|hhmm }}
|
||||
{% endif %}
|
||||
</span>
|
||||
{% endfor %}
|
||||
<button class="btn btn-secondary btn-sm" onclick="location.reload()">↻ Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Progress bar ─────────────────────────────────────────── -->
|
||||
<div class="progress-strip">
|
||||
<div class="progress-strip-bar">
|
||||
<div class="progress-strip-fill {% if pct == 100 %}fill-success{% elif pct >= 50 %}fill-warning{% else %}fill-danger{% endif %}"
|
||||
style="width:{{ pct }}%"></div>
|
||||
</div>
|
||||
<div class="progress-strip-label">
|
||||
<strong>{{ checked }} / {{ total }}</strong> checked
|
||||
<span class="text-muted"> · {{ pct }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Action bar ───────────────────────────────────────────── -->
|
||||
<div class="action-bar">
|
||||
<!-- Search: magnifier on the RIGHT -->
|
||||
<div class="search-wrap">
|
||||
<input type="text" id="site-search" placeholder="Search sites…" autocomplete="off">
|
||||
<span class="search-icon">🔍</span>
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-sm" id="btn-select-all">☑ Select All Unchecked</button>
|
||||
<button class="btn btn-success btn-sm" id="btn-bulk-check">✔ Mark Selected Checked</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Site list grouped by frequency ──────────────────────── -->
|
||||
<div id="site-list">
|
||||
|
||||
{# Group sites by check_type #}
|
||||
{% set daily_sites = sites | selectattr('check_type', 'equalto', 'daily') | list %}
|
||||
{% set weekly_sites = sites | selectattr('check_type', 'equalto', 'weekly') | list %}
|
||||
{% set other_sites = sites | rejectattr('check_type', 'in', ['daily','weekly']) | list %}
|
||||
|
||||
{% for group_name, group_sites in [('Daily', daily_sites), ('Weekly', weekly_sites), ('Other', other_sites)] %}
|
||||
{% if group_sites %}
|
||||
|
||||
<!-- Group header (collapsible) -->
|
||||
<div class="group-header" data-group="{{ group_name | lower }}" onclick="toggleGroup('{{ group_name | lower }}')">
|
||||
<span class="group-toggle" id="toggle-{{ group_name | lower }}">▾</span>
|
||||
<span class="group-label">{{ group_name }}</span>
|
||||
<span class="group-count">
|
||||
{{ group_sites | selectattr('check_id') | list | length }} / {{ group_sites | length }} checked
|
||||
</span>
|
||||
<div class="group-mini-bar">
|
||||
{% set g_total = group_sites | length %}
|
||||
{% set g_checked = group_sites | selectattr('check_id') | list | length %}
|
||||
{% set g_pct = ((g_checked / g_total) * 100) | int if g_total > 0 else 0 %}
|
||||
<div class="group-mini-fill
|
||||
{% if g_pct == 100 %}fill-success{% elif g_pct >= 50 %}fill-warning{% else %}fill-danger{% endif %}"
|
||||
style="width:{{ g_pct }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sites in this group -->
|
||||
<div class="site-group" id="group-{{ group_name | lower }}">
|
||||
{% for site in group_sites %}
|
||||
<div class="site-card {% if site.check_id %}is-checked{% endif %}"
|
||||
data-id="{{ site.id }}"
|
||||
data-group="{{ group_name | lower }}"
|
||||
data-name="{{ site.name | lower }}"
|
||||
data-url="{{ (site.url or '') | lower }}"
|
||||
data-site-name="{{ site.name }}"
|
||||
data-user-note="{{ site.user_note or '' }}">
|
||||
|
||||
<!-- Row 1: checkbox · status · info · actions · health -->
|
||||
<div class="sc-row1">
|
||||
|
||||
<div class="sc-select">
|
||||
<input type="checkbox" class="bulk-cb" data-id="{{ site.id }}"
|
||||
{% if site.check_id %}disabled{% endif %}>
|
||||
</div>
|
||||
|
||||
<div class="sc-status">
|
||||
{% if site.check_id %}
|
||||
<span class="status-dot dot-checked"
|
||||
title="Checked at {{ site.checked_at.strftime('%H:%M') if site.checked_at else 'today' }}">✔</span>
|
||||
{% else %}
|
||||
<span class="status-dot dot-unchecked">○</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="sc-body">
|
||||
<div class="sc-title-row">
|
||||
<a class="sc-name" href="{{ site.url }}" target="_blank" rel="noopener">{{ site.name }}</a>
|
||||
<span class="badge {% if site.check_type == 'weekly' %}badge-warning{% else %}badge-muted{% endif %}">
|
||||
{{ site.check_type | capitalize }}
|
||||
</span>
|
||||
{% if site.check_id and site.checked_at %}
|
||||
<span class="sc-checked-time text-success">✔ {{ site.checked_at.strftime('%H:%M') }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="sc-url">{{ site.url }}</div>
|
||||
<div class="sc-meta">
|
||||
{% if site.site_note %}
|
||||
<span class="sc-meta-item">📌 {{ site.site_note }}</span>
|
||||
{% endif %}
|
||||
{% if site.shift_names %}
|
||||
<span class="sc-meta-item text-accent">🕐 {{ site.shift_names }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sc-actions">
|
||||
{% if site.check_id %}
|
||||
<span class="btn btn-success btn-sm checked-indicator">✔ Checked</span>
|
||||
<button class="btn btn-secondary btn-sm js-note"
|
||||
data-id="{{ site.id }}"
|
||||
data-note="{{ site.user_note or '' }}">📝 Note</button>
|
||||
<button class="btn btn-secondary btn-sm js-creds"
|
||||
data-id="{{ site.id }}">🔑 Creds</button>
|
||||
<form method="post"
|
||||
action="{{ url_for('user_dashboard.uncheck_site', website_id=site.id) }}"
|
||||
style="display:inline">
|
||||
<button class="btn btn-secondary btn-sm js-undo" type="button"
|
||||
data-name="{{ site.name }}">↩ Undo</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<button class="btn btn-primary btn-sm js-check"
|
||||
data-id="{{ site.id }}"
|
||||
data-name="{{ site.name }}">✔ Mark Checked</button>
|
||||
<button class="btn btn-secondary btn-sm js-note"
|
||||
data-id="{{ site.id }}"
|
||||
data-note="">📝 Note</button>
|
||||
<button class="btn btn-secondary btn-sm js-creds"
|
||||
data-id="{{ site.id }}">🔑 Creds</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="sc-health">
|
||||
<span class="health-dot" id="health-{{ site.id }}" title="Checking…">●</span>
|
||||
</div>
|
||||
|
||||
</div><!-- /sc-row1 -->
|
||||
|
||||
<!-- Row 2: user note (only shown when a note exists) -->
|
||||
{% if site.user_note %}
|
||||
<div class="sc-row2">
|
||||
<span class="sc-note-row">📝 {{ site.user_note }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div><!-- /site-card -->
|
||||
{% endfor %}
|
||||
</div><!-- /site-group -->
|
||||
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if not sites %}
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">✅</div>
|
||||
<p>No sites to check today, or you're all done!</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div><!-- /site-list -->
|
||||
|
||||
<!-- ── Mark Checked Modal ───────────────────────────────────── -->
|
||||
<div class="modal-overlay" id="modal-check">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title" id="check-modal-title">Mark Checked</span>
|
||||
<button class="modal-close" onclick="closeModal('modal-check')">✕</button>
|
||||
</div>
|
||||
<form method="post" id="check-form">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>Note (optional)</label>
|
||||
<textarea name="user_note" id="check-note" rows="3"
|
||||
placeholder="Any observations or notes…"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeModal('modal-check')">Cancel</button>
|
||||
<button type="submit" class="btn btn-success">✔ Confirm Checked</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Note Modal ──────────────────────────────────────────── -->
|
||||
<div class="modal-overlay" id="modal-note">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title">Update Note</span>
|
||||
<button class="modal-close" onclick="closeModal('modal-note')">✕</button>
|
||||
</div>
|
||||
<form method="post" id="note-form">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>Note</label>
|
||||
<textarea name="user_note" id="note-text" rows="3"
|
||||
placeholder="Any observations or notes…"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeModal('modal-note')">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save Note</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Credentials Modal ────────────────────────────────────── -->
|
||||
<div class="modal-overlay" id="modal-creds">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title">🔑 Credentials</span>
|
||||
<button class="modal-close" onclick="closeModal('modal-creds')">✕</button>
|
||||
</div>
|
||||
<div class="modal-body" id="creds-body">
|
||||
<div class="text-center text-muted">Loading…</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeModal('modal-creds')">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/* ── Date ──────────────────────────────────────────────────── */
|
||||
document.getElementById('today-date').textContent =
|
||||
new Date().toLocaleDateString('en-US', {weekday:'long',year:'numeric',month:'long',day:'numeric'});
|
||||
|
||||
/* ── Group collapse/expand ─────────────────────────────────── */
|
||||
var _collapsed = {};
|
||||
|
||||
function toggleGroup(name) {
|
||||
var group = document.getElementById('group-' + name);
|
||||
var toggle = document.getElementById('toggle-' + name);
|
||||
if (!group) return;
|
||||
_collapsed[name] = !_collapsed[name];
|
||||
group.style.display = _collapsed[name] ? 'none' : '';
|
||||
toggle.textContent = _collapsed[name] ? '▸' : '▾';
|
||||
}
|
||||
|
||||
/* ── Search — shows matching cards, auto-expands collapsed groups ── */
|
||||
document.getElementById('site-search').addEventListener('input', function() {
|
||||
var q = this.value.trim().toLowerCase();
|
||||
document.querySelectorAll('.site-card').forEach(function(card) {
|
||||
var match = !q || card.dataset.name.includes(q) || card.dataset.url.includes(q);
|
||||
card.style.display = match ? '' : 'none';
|
||||
|
||||
// Auto-expand the group containing a matched card
|
||||
if (match && q) {
|
||||
var group = card.closest('.site-group');
|
||||
if (group) {
|
||||
group.style.display = '';
|
||||
var name = group.id.replace('group-', '');
|
||||
var toggle = document.getElementById('toggle-' + name);
|
||||
if (toggle) toggle.textContent = '▾';
|
||||
_collapsed[name] = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/* ── Bulk select ───────────────────────────────────────────── */
|
||||
document.getElementById('btn-select-all').addEventListener('click', function() {
|
||||
document.querySelectorAll('.bulk-cb:not(:disabled)').forEach(function(cb) {
|
||||
var card = cb.closest('.site-card');
|
||||
if (!card.classList.contains('is-checked') && card.style.display !== 'none')
|
||||
cb.checked = true;
|
||||
});
|
||||
});
|
||||
|
||||
/* ── Bulk check ────────────────────────────────────────────── */
|
||||
document.getElementById('btn-bulk-check').addEventListener('click', function() {
|
||||
var selected = Array.from(document.querySelectorAll('.bulk-cb:checked')).map(function(cb){ return cb.dataset.id; });
|
||||
if (!selected.length) { alert('No sites selected. Tick the checkboxes first.'); return; }
|
||||
if (!confirm('Mark ' + selected.length + ' site(s) as checked?')) return;
|
||||
Promise.all(selected.map(function(id) {
|
||||
return fetch('/dashboard/check/' + id, {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: 'user_note='
|
||||
});
|
||||
})).then(function() { location.reload(); });
|
||||
});
|
||||
|
||||
/* ── Delegated button handler ──────────────────────────────── */
|
||||
document.getElementById('site-list').addEventListener('click', function(e) {
|
||||
var btn = e.target.closest('button[class]');
|
||||
if (!btn) return;
|
||||
|
||||
/* Mark Checked */
|
||||
if (btn.classList.contains('js-check')) {
|
||||
document.getElementById('check-modal-title').textContent = 'Mark Checked — ' + btn.dataset.name;
|
||||
document.getElementById('check-form').action = '/dashboard/check/' + btn.dataset.id;
|
||||
document.getElementById('check-note').value = '';
|
||||
openModal('modal-check');
|
||||
return;
|
||||
}
|
||||
|
||||
/* Note */
|
||||
if (btn.classList.contains('js-note')) {
|
||||
document.getElementById('note-form').action = '/dashboard/note/' + btn.dataset.id;
|
||||
document.getElementById('note-text').value = btn.dataset.note || '';
|
||||
openModal('modal-note');
|
||||
return;
|
||||
}
|
||||
|
||||
/* Credentials */
|
||||
if (btn.classList.contains('js-creds')) {
|
||||
var body = document.getElementById('creds-body');
|
||||
body.innerHTML = '<div class="text-center text-muted">Loading…</div>';
|
||||
openModal('modal-creds');
|
||||
fetch('/dashboard/credentials/' + btn.dataset.id)
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(creds) {
|
||||
if (!creds.length) {
|
||||
body.innerHTML = '<p class="text-muted text-center">No credentials stored.</p>';
|
||||
return;
|
||||
}
|
||||
body.innerHTML = creds.map(function(c, i) {
|
||||
var uid = 'pw-' + i + '-' + btn.dataset.id;
|
||||
return '<div class="' + (i > 0 ? 'mt-2' : '') + '">'
|
||||
+ (c.label ? '<div class="cred-label-heading">' + esc(c.label) + '</div>' : '')
|
||||
+ '<div class="cred-row"><span class="cred-key">Username</span>'
|
||||
+ '<span class="cred-val">' + esc(c.username || '—') + '</span>'
|
||||
+ '<button class="btn btn-secondary btn-sm" data-copy="' + esc(c.username) + '">📋</button></div>'
|
||||
+ '<div class="cred-row"><span class="cred-key">Password</span>'
|
||||
+ '<span class="cred-val pw-mask" id="' + uid + '">' + esc(c.password) + '</span>'
|
||||
+ '<button class="btn btn-secondary btn-sm" data-toggle-pw="' + uid + '">👁</button>'
|
||||
+ '<button class="btn btn-secondary btn-sm" data-copy="' + esc(c.password) + '">📋</button></div>'
|
||||
+ '</div>'
|
||||
+ (i < creds.length - 1 ? '<hr class="divider">' : '');
|
||||
}).join('');
|
||||
})
|
||||
.catch(function(){ body.innerHTML = '<p class="alert alert-danger">Failed to load.</p>'; });
|
||||
return;
|
||||
}
|
||||
|
||||
/* Undo */
|
||||
if (btn.classList.contains('js-undo')) {
|
||||
if (confirm('Remove check for ' + btn.dataset.name + '?')) {
|
||||
btn.closest('form').submit();
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
/* ── Credential modal actions ──────────────────────────────── */
|
||||
document.getElementById('modal-creds').addEventListener('click', function(e) {
|
||||
var copyBtn = e.target.closest('[data-copy]');
|
||||
if (copyBtn) { copyToClipboard(copyBtn.dataset.copy, copyBtn); return; }
|
||||
var toggleBtn = e.target.closest('[data-toggle-pw]');
|
||||
if (toggleBtn) {
|
||||
var el = document.getElementById(toggleBtn.dataset.togglePw);
|
||||
if (el) {
|
||||
var masked = el.classList.toggle('pw-mask');
|
||||
toggleBtn.textContent = masked ? '👁' : '🙈';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/* ── HTML escape ───────────────────────────────────────────── */
|
||||
function esc(str) {
|
||||
return String(str || '').replace(/[&<>"']/g, function(c) {
|
||||
return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c];
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Health dots ───────────────────────────────────────────── */
|
||||
document.querySelectorAll('.site-card').forEach(function(card) {
|
||||
var id = card.dataset.id;
|
||||
var url = card.dataset.url;
|
||||
var dot = document.getElementById('health-' + id);
|
||||
if (!dot || !url) return;
|
||||
var img = new Image();
|
||||
var t0 = Date.now();
|
||||
img.onload = function() {
|
||||
var ms = Date.now() - t0;
|
||||
dot.style.color = ms > 3000 ? '#d97706' : '#16a34a';
|
||||
dot.title = ms > 3000 ? 'Slow (' + ms + 'ms)' : 'Reachable (' + ms + 'ms)';
|
||||
};
|
||||
img.onerror = function() {
|
||||
var ms = Date.now() - t0;
|
||||
dot.style.color = ms < 6000 ? '#d97706' : '#dc2626';
|
||||
dot.title = ms < 6000 ? 'Reachable (restricted)' : 'Unreachable';
|
||||
};
|
||||
img.src = 'https://www.google.com/s2/favicons?domain=' + encodeURIComponent(url) + '&t=' + Date.now();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* ── Group header ───────────────────────────────────────────── */
|
||||
.group-header {
|
||||
display:flex; align-items:center; gap:.65rem;
|
||||
padding:.6rem 1rem; margin-bottom:2px; margin-top:.75rem;
|
||||
background:var(--bg-subtle); border:1px solid var(--border);
|
||||
border-radius:var(--r); cursor:pointer;
|
||||
transition:background var(--t);
|
||||
user-select:none;
|
||||
}
|
||||
.group-header:first-child { margin-top:0; }
|
||||
.group-header:hover { background:var(--bg-hover); }
|
||||
.group-toggle { font-size:.8rem; color:var(--text-muted); width:12px; flex-shrink:0; }
|
||||
.group-label { font-size:.85rem; font-weight:700; color:var(--text);
|
||||
text-transform:uppercase; letter-spacing:.05em; }
|
||||
.group-count { font-size:.8rem; color:var(--text-muted); margin-left:.25rem; }
|
||||
.group-mini-bar {
|
||||
flex:1; height:5px; background:var(--border); border-radius:999px;
|
||||
overflow:hidden; max-width:120px;
|
||||
}
|
||||
.group-mini-fill {
|
||||
height:100%; border-radius:999px; background:var(--accent);
|
||||
transition:width .4s ease;
|
||||
}
|
||||
.group-mini-fill.fill-success { background:var(--success); }
|
||||
.group-mini-fill.fill-warning { background:var(--warning); }
|
||||
.group-mini-fill.fill-danger { background:var(--danger); }
|
||||
|
||||
/* ── Site group wrapper ─────────────────────────────────────── */
|
||||
.site-group { display:flex; flex-direction:column; gap:.45rem; margin-bottom:.5rem; }
|
||||
|
||||
/* ── Site card ──────────────────────────────────────────────── */
|
||||
.site-card {
|
||||
background:var(--bg-white); border:1px solid var(--border);
|
||||
border-radius:var(--r-lg); box-shadow:var(--shadow-sm);
|
||||
transition:box-shadow var(--t), border-color var(--t);
|
||||
overflow:hidden;
|
||||
}
|
||||
.site-card:hover { box-shadow:var(--shadow); border-color:var(--border-strong); }
|
||||
.site-card.is-checked { border-color:var(--success-border); background:#fafffe; }
|
||||
.site-card.is-checked:hover { border-color:#86efac; }
|
||||
|
||||
/* ── Row 1: main content ────────────────────────────────────── */
|
||||
.sc-row1 {
|
||||
display:flex; align-items:center; gap:.85rem; padding:.95rem 1rem;
|
||||
}
|
||||
|
||||
/* ── Row 2: note row ────────────────────────────────────────── */
|
||||
.sc-row2 {
|
||||
padding:.45rem 1rem .6rem calc(1rem + 15px + .85rem + 26px + .85rem);
|
||||
/* indent to align with sc-body, past checkbox + status */
|
||||
border-top:1px dashed var(--border);
|
||||
}
|
||||
.sc-note-row {
|
||||
font-size:.875rem; color:var(--warning);
|
||||
background:var(--warning-bg); border:1px solid var(--warning-border);
|
||||
border-radius:var(--r-sm); padding:.25rem .65rem;
|
||||
display:inline-block; line-height:1.5;
|
||||
}
|
||||
|
||||
/* ── Font size bumps ─────────────────────────────────────────── */
|
||||
.sc-name { font-size:1rem; }
|
||||
.sc-url { font-size:.76rem; }
|
||||
.sc-meta-item{ font-size:.82rem; }
|
||||
.sc-checked-time { font-size:.78rem; color:var(--success); font-weight:600; }
|
||||
|
||||
@media(max-width:700px) {
|
||||
.sc-row1 { flex-wrap:wrap; }
|
||||
.sc-actions { margin-left:0; flex-direction:column; align-items:flex-start; }
|
||||
.sc-url { display:none; }
|
||||
.sc-row2 { padding-left:1rem; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user