04/30 Web Checker web app ver. 1.0

This commit is contained in:
2026-04-30 17:15:56 -04:00
parent feb8e5051c
commit cd63d5a020
39 changed files with 8031 additions and 1 deletions
+479
View File
@@ -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">&nbsp;·&nbsp; {{ 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 {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 %}