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
+68
View File
@@ -0,0 +1,68 @@
{% extends "base.html" %}
{% block title %}Dashboard — Website Checker{% endblock %}
{% block content %}
<div class="page-header">
<h1 class="page-title">Dashboard</h1>
<span class="text-muted" id="today-date"></span>
</div>
<!-- KPI Cards -->
<div class="kpi-grid">
<div class="kpi-card">
<div class="kpi-value">{{ stats.total_users }}</div>
<div class="kpi-label">Total Users</div>
</div>
<div class="kpi-card kpi-accent">
<div class="kpi-value">{{ stats.active_today }}</div>
<div class="kpi-label">Active Today</div>
</div>
<div class="kpi-card">
<div class="kpi-value">{{ stats.total_sites }}</div>
<div class="kpi-label">Total Sites</div>
</div>
</div>
<!-- Per-user completion table -->
<div class="card mt-4">
<div class="card-header">
<h2 class="card-title">Today's Completion</h2>
<button class="btn btn-ghost btn-sm" onclick="location.reload()">↻ Refresh</button>
</div>
<table class="table">
<thead>
<tr>
<th>User</th>
<th>Checked</th>
<th>Total</th>
<th style="width:200px">Progress</th>
<th>%</th>
</tr>
</thead>
<tbody>
{% for u in stats.user_stats %}
<tr>
<td>{{ u.full_name }}</td>
<td>{{ u.checked_count }}</td>
<td>{{ u.total_sites }}</td>
<td>
<div class="progress">
<div class="progress-bar {% if (u.pct_complete or 0) == 100 %}progress-success{% elif (u.pct_complete or 0) >= 50 %}progress-warn{% else %}progress-danger{% endif %}"
style="width: {{ u.pct_complete or 0 }}%"></div>
</div>
</td>
<td><strong>{{ u.pct_complete or 0 }}%</strong></td>
</tr>
{% else %}
<tr><td colspan="5" class="text-center text-muted">No user data for today.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<script>
document.getElementById('today-date').textContent =
new Date().toLocaleDateString('en-US', {weekday:'long', year:'numeric', month:'long', day:'numeric'});
// Auto-refresh every 60 seconds
setTimeout(() => location.reload(), 60000);
</script>
{% endblock %}
+83
View File
@@ -0,0 +1,83 @@
{% extends "base.html" %}
{% block title %}Logs — Website Checker{% endblock %}
{% block content %}
<div class="page-header">
<h1 class="page-title">Logs</h1>
</div>
<!-- Tab bar -->
<div class="tab-bar">
<a class="tab-item {{ 'active' if tab == 'activity' }}"
href="{{ url_for('admin_logs.logs', tab='activity') }}">📋 Activity Log</a>
<a class="tab-item {{ 'active' if tab == 'app' }}"
href="{{ url_for('admin_logs.logs', tab='app') }}">🖥 App Log</a>
</div>
<!-- Search / filter bar -->
<form class="filter-bar" method="get">
<input type="hidden" name="tab" value="{{ tab }}">
<input class="form-control" name="search" placeholder="Search…" value="{{ search }}">
{% if tab == 'app' %}
<select class="form-control" name="level">
<option value="">All Levels</option>
{% for lvl in ['DEBUG','INFO','WARNING','ERROR','CRITICAL'] %}
<option {{ 'selected' if level_filter == lvl }}>{{ lvl }}</option>
{% endfor %}
</select>
{% endif %}
<select class="form-control" name="limit">
{% for n in [100, 200, 500] %}
<option value="{{ n }}" {{ 'selected' if limit == n }}>{{ n }} rows</option>
{% endfor %}
</select>
<button class="btn btn-ghost" type="submit">Filter</button>
</form>
{% if tab == 'activity' %}
<div class="card mt-2">
<table class="table table-sm">
<thead><tr><th>Time</th><th>User</th><th>Action</th><th>Entity</th><th>Detail</th></tr></thead>
<tbody>
{% for row in activity_rows %}
<tr>
<td class="text-muted nowrap">{{ row.created_at.strftime('%Y-%m-%d %H:%M:%S') if row.created_at else '—' }}</td>
<td>{{ row.username or '—' }}</td>
<td><code class="action-badge">{{ row.action }}</code></td>
<td class="text-muted">{{ row.entity or '' }} {% if row.entity_id %}#{{ row.entity_id }}{% endif %}</td>
<td class="text-muted">{{ row.detail or '' }}</td>
</tr>
{% else %}
<tr><td colspan="5" class="text-center text-muted">No records found.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card mt-2">
<div class="card-header">
<span></span>
<form method="post" action="{{ url_for('admin_logs.purge') }}"
onsubmit="return confirm('Purge old app log records?')" style="display:flex;gap:.5rem;align-items:center">
<input class="form-control" name="days" type="number" value="30" style="width:80px">
<label class="form-label mb-0">days</label>
<button class="btn btn-danger btn-sm" type="submit">Purge</button>
</form>
</div>
<table class="table table-sm">
<thead><tr><th>Time</th><th>Level</th><th>Logger</th><th>Message</th></tr></thead>
<tbody>
{% for row in app_rows %}
<tr class="log-{{ row.level | lower }}">
<td class="text-muted nowrap">{{ row.logged_at.strftime('%Y-%m-%d %H:%M:%S') if row.logged_at else '—' }}</td>
<td><span class="badge badge-log-{{ row.level | lower }}">{{ row.level }}</span></td>
<td class="text-muted">{{ row.logger_name }}</td>
<td class="log-message">{{ row.message }}</td>
</tr>
{% else %}
<tr><td colspan="4" class="text-center text-muted">No records found.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endblock %}
+95
View File
@@ -0,0 +1,95 @@
{% extends "base.html" %}
{% block title %}Reports — Website Checker{% endblock %}
{% block content %}
<div class="page-header">
<h1 class="page-title">Reports</h1>
<a class="btn btn-ghost" href="{{ url_for('admin_reports.export_csv', **request.args) }}">⬇ Export CSV</a>
</div>
<div class="tab-bar">
<a class="tab-item {{ 'active' if tab == 'detail' }}" href="{{ url_for('admin_reports.reports', tab='detail', date_from=date_from, date_to=date_to) }}">Shift Detail</a>
<a class="tab-item {{ 'active' if tab == 'unchecked' }}" href="{{ url_for('admin_reports.reports', tab='unchecked', target_date=target_date) }}">Unchecked</a>
<a class="tab-item {{ 'active' if tab == 'summary' }}" href="{{ url_for('admin_reports.reports', tab='summary', date_from=date_from, date_to=date_to) }}">Summary</a>
</div>
<form class="filter-bar mt-2" method="get">
<input type="hidden" name="tab" value="{{ tab }}">
{% if tab == 'unchecked' %}
<label class="form-label mb-0">Date:</label>
<input class="form-control" type="date" name="target_date" value="{{ target_date }}">
{% else %}
<label class="form-label mb-0">From:</label>
<input class="form-control" type="date" name="date_from" value="{{ date_from }}">
<label class="form-label mb-0">To:</label>
<input class="form-control" type="date" name="date_to" value="{{ date_to }}">
{% endif %}
{% if tab == 'detail' or tab == 'unchecked' %}
<select class="form-control" name="user_id">
<option value="">All Users</option>
{% for u in users %}<option value="{{ u.id }}" {{ 'selected' if user_id == u.id|string }}>{{ u.username }}</option>{% endfor %}
</select>
{% endif %}
{% if tab == 'detail' %}
<select class="form-control" name="website_id">
<option value="">All Websites</option>
{% for w in websites %}<option value="{{ w.id }}" {{ 'selected' if website_id == w.id|string }}>{{ w.name }}</option>{% endfor %}
</select>
{% endif %}
<button class="btn btn-ghost" type="submit">Filter</button>
</form>
<div class="card mt-2">
{% if tab == 'detail' %}
<table class="table table-sm">
<thead><tr><th>Date</th><th>Time</th><th>User</th><th>Website</th><th>URL</th><th>Note</th></tr></thead>
<tbody>
{% for r in rows %}
<tr>
<td>{{ r.check_date }}</td>
<td class="text-muted nowrap">{{ r.checked_at.strftime('%H:%M:%S') if r.checked_at else '' }}</td>
<td>{{ r.full_name }}</td>
<td>{{ r.website_name }}</td>
<td><a href="{{ r.url }}" target="_blank" rel="noopener">{{ r.url[:40] }}…</a></td>
<td class="text-muted">{{ r.user_note }}</td>
</tr>
{% else %}<tr><td colspan="6" class="text-center text-muted">No data.</td></tr>{% endfor %}
</tbody>
</table>
{% elif tab == 'unchecked' %}
<table class="table table-sm">
<thead><tr><th>Date</th><th>User</th><th>Website</th><th>URL</th><th>Status</th></tr></thead>
<tbody>
{% for r in rows %}
<tr>
<td>{{ r.check_date }}</td>
<td>{{ r.full_name }}</td>
<td>{{ r.website_name }}</td>
<td><a href="{{ r.url }}" target="_blank" rel="noopener">{{ r.url[:40] }}…</a></td>
<td><span class="badge badge-danger">Not Checked</span></td>
</tr>
{% else %}<tr><td colspan="5" class="text-center text-muted">No data.</td></tr>{% endfor %}
</tbody>
</table>
{% elif tab == 'summary' %}
<table class="table table-sm">
<thead><tr><th>Date</th><th>User</th><th>Checked</th><th>Total</th><th>Progress</th><th>%</th></tr></thead>
<tbody>
{% for r in rows %}
<tr>
<td>{{ r.check_date }}</td>
<td>{{ r.full_name }}</td>
<td>{{ r.checked_count }}</td>
<td>{{ r.total_sites }}</td>
<td>
<div class="progress"><div class="progress-bar" style="width:{{ r.pct_complete or 0 }}%"></div></div>
</td>
<td><strong>{{ r.pct_complete or 0 }}%</strong></td>
</tr>
{% else %}<tr><td colspan="6" class="text-center text-muted">No data.</td></tr>{% endfor %}
</tbody>
</table>
{% endif %}
</div>
{% endblock %}
+85
View File
@@ -0,0 +1,85 @@
{% extends "base.html" %}
{% block title %}Settings — Website Checker{% endblock %}
{% block content %}
<div class="page-header"><h1 class="page-title">Application Settings</h1></div>
<div class="settings-grid">
<!-- Email Settings -->
<div class="card">
<div class="card-header"><h2 class="card-title">📧 Email Report Settings</h2></div>
<form method="post" action="{{ url_for('admin_settings.save_email') }}">
<div class="modal-body">
<div class="form-group">
<label class="form-label">Enable Email Reports</label>
<select class="form-control" name="email_enabled">
<option value="1" {{ 'selected' if email.get('email.enabled')=='1' }}>Enabled</option>
<option value="0" {{ 'selected' if email.get('email.enabled')!='1' }}>Disabled</option>
</select>
</div>
<div class="form-group">
<label class="form-label">SMTP Host</label>
<input class="form-control" name="email_smtp_host" value="{{ email.get('email.smtp_host','') }}">
</div>
<div class="form-row">
<div class="form-group flex-1">
<label class="form-label">Port</label>
<input class="form-control" name="email_smtp_port" value="{{ email.get('email.smtp_port','587') }}">
</div>
<div class="form-group flex-1">
<label class="form-label">Security</label>
<select class="form-control" name="email_security">
{% for mode in ['starttls','ssl','none'] %}
<option {{ 'selected' if email.get('email.security')==mode }}>{{ mode }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">SMTP Username</label>
<input class="form-control" name="email_smtp_user" value="{{ email.get('email.smtp_user','') }}">
</div>
<div class="form-group">
<label class="form-label">SMTP Password</label>
<input class="form-control" type="password" name="email_smtp_password" placeholder="(unchanged)">
</div>
<div class="form-group">
<label class="form-label">Recipients (comma-separated)</label>
<input class="form-control" name="email_recipients" value="{{ email.get('email.recipients','') }}">
</div>
<div class="form-group">
<label class="form-label">Send Time (HH:MM)</label>
<input class="form-control" type="time" name="email_send_time" value="{{ email.get('email.send_time','08:00') }}">
</div>
</div>
<div class="modal-footer">
<button class="btn btn-primary" type="submit">Save Email Settings</button>
</div>
</form>
</div>
<!-- Groq / AI Settings -->
<div class="card">
<div class="card-header"><h2 class="card-title">🤖 Groq AI Settings</h2></div>
<form method="post" action="{{ url_for('admin_settings.save_groq') }}">
<div class="modal-body">
<div class="form-group">
<label class="form-label">Groq API Key</label>
<input class="form-control" type="password" name="groq_api_key"
placeholder="{{ '(key set)' if groq.get('groq.api_key') else 'Enter API key' }}">
</div>
<div class="form-group">
<label class="form-label">Default Model</label>
<select class="form-control" name="groq_model">
{% for m in ['llama-3.3-70b-versatile','llama-3.1-8b-instant','gemma2-9b-it'] %}
<option {{ 'selected' if groq.get('groq.model')==m }}>{{ m }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-primary" type="submit">Save AI Settings</button>
</div>
</form>
</div>
</div>
{% endblock %}
+173
View File
@@ -0,0 +1,173 @@
{% extends "base.html" %}
{% block title %}Shifts — Website Checker{% endblock %}
{% block content %}
<div class="page-header">
<h1 class="page-title">Shift Management</h1>
<button class="btn btn-primary" onclick="openModal('modal-create-shift')"> New Shift</button>
</div>
<div class="card">
<table class="table table-hover">
<thead>
<tr><th>Name</th><th>Days</th><th>Time</th><th>Users</th><th>Sites</th><th>Active</th><th>Actions</th></tr>
</thead>
<tbody>
{% for s in shifts %}
<tr>
<td><strong>{{ s.name }}</strong><br><span class="text-muted text-sm">{{ s.note or '' }}</span></td>
<td>{{ s.days_of_week }}</td>
<td class="text-muted">{{ s.start_time }} {{ s.end_time }}</td>
<td>{{ s.user_count }}</td>
<td>{{ s.website_count }}</td>
<td><span class="badge badge-{{ 'success' if s.is_active else 'neutral' }}">{{ 'Active' if s.is_active else 'Inactive' }}</span></td>
<td class="actions">
<button class="btn btn-ghost btn-sm" onclick="loadEditShift({{ s.id }})">✎ Edit</button>
<form method="post" action="{{ url_for('admin_shifts.delete', shift_id=s.id) }}"
style="display:inline" onsubmit="return confirm('Deactivate shift {{ s.name }}?')">
<button class="btn btn-danger btn-sm" type="submit"></button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="7" class="text-center text-muted">No shifts configured.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Create Shift Modal -->
<div class="modal-overlay" id="modal-create-shift">
<div class="modal-dialog modal-lg">
<div class="modal-header">
<h3>New Shift</h3>
<button class="modal-close" onclick="closeModal('modal-create-shift')"></button>
</div>
<form method="post" action="{{ url_for('admin_shifts.create') }}">
<div class="modal-body">
<div class="form-group">
<label class="form-label">Shift Name *</label>
<input class="form-control" name="name" required>
</div>
<div class="form-group">
<label class="form-label">Days of Week</label>
<div class="day-checkboxes">
{% for label, val in day_map %}
<label class="day-check">
<input type="checkbox" name="days_of_week[]" value="{{ val }}"> {{ label }}
</label>
{% endfor %}
</div>
</div>
<div class="form-row">
<div class="form-group flex-1">
<label class="form-label">Start Time</label>
<input class="form-control" type="time" name="start_time" value="08:00">
</div>
<div class="form-group flex-1">
<label class="form-label">End Time</label>
<input class="form-control" type="time" name="end_time" value="17:00">
</div>
</div>
<div class="form-group">
<label class="form-label">Assign Users</label>
<div class="checkbox-list">
{% for u in all_users %}
<label><input type="checkbox" name="user_ids[]" value="{{ u.id }}"> {{ u.username }}</label>
{% endfor %}
</div>
</div>
<div class="form-group">
<label class="form-label">Assign Websites</label>
<div class="checkbox-list">
{% for w in all_sites %}
<label><input type="checkbox" name="website_ids[]" value="{{ w.id }}"> {{ w.name }}</label>
{% endfor %}
</div>
</div>
<div class="form-group">
<label class="form-label">Note</label>
<textarea class="form-control" name="note" rows="2"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-ghost" onclick="closeModal('modal-create-shift')">Cancel</button>
<button type="submit" class="btn btn-primary">Create Shift</button>
</div>
</form>
</div>
</div>
<!-- Edit Shift Modal -->
<div class="modal-overlay" id="modal-edit-shift">
<div class="modal-dialog modal-lg">
<div class="modal-header">
<h3>Edit Shift</h3>
<button class="modal-close" onclick="closeModal('modal-edit-shift')"></button>
</div>
<form method="post" id="edit-shift-form">
<div class="modal-body" id="edit-shift-body"><div class="text-center text-muted">Loading…</div></div>
<div class="modal-footer">
<button type="button" class="btn btn-ghost" onclick="closeModal('modal-edit-shift')">Cancel</button>
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</form>
</div>
</div>
<script>
const ALL_USERS = {{ all_users | tojson }};
const ALL_SITES = {{ all_sites | tojson }};
const DAY_MAP = {{ day_map | tojson }};
function loadEditShift(id) {
fetch('/admin/shifts/' + id + '/detail')
.then(r => r.json())
.then(s => {
document.getElementById('edit-shift-form').action = '/admin/shifts/' + id + '/edit';
document.getElementById('edit-shift-body').innerHTML = buildShiftForm(s);
openModal('modal-edit-shift');
});
}
function buildShiftForm(s) {
const days = DAY_MAP.map(([lbl, val]) => `
<label class="day-check">
<input type="checkbox" name="days_of_week[]" value="${val}"
${s.days_of_week.includes(val)?'checked':''}>${lbl}
</label>`).join('');
const users = ALL_USERS.map(u => `
<label><input type="checkbox" name="user_ids[]" value="${u.id}"
${s.user_ids.includes(u.id)?'checked':''}> ${u.username}</label>`).join('');
const sites = ALL_SITES.map(w => `
<label><input type="checkbox" name="website_ids[]" value="${w.id}"
${s.website_ids.includes(w.id)?'checked':''}> ${w.name}</label>`).join('');
return `
<div class="form-group"><label class="form-label">Shift Name *</label>
<input class="form-control" name="name" value="${esc(s.name)}" required></div>
<div class="form-group"><label class="form-label">Days of Week</label>
<div class="day-checkboxes">${days}</div></div>
<div class="form-row">
<div class="form-group flex-1"><label class="form-label">Start Time</label>
<input class="form-control" type="time" name="start_time" value="${esc(s.start_time)}"></div>
<div class="form-group flex-1"><label class="form-label">End Time</label>
<input class="form-control" type="time" name="end_time" value="${esc(s.end_time)}"></div>
</div>
<div class="form-group"><label class="form-label">Active</label>
<select class="form-control" name="is_active">
<option value="1" ${s.is_active?'selected':''}>Yes</option>
<option value="0" ${!s.is_active?'selected':''}>No</option>
</select></div>
<div class="form-group"><label class="form-label">Assign Users</label>
<div class="checkbox-list">${users}</div></div>
<div class="form-group"><label class="form-label">Assign Websites</label>
<div class="checkbox-list">${sites}</div></div>
<div class="form-group"><label class="form-label">Note</label>
<textarea class="form-control" name="note" rows="2">${esc(s.note)}</textarea></div>`;
}
function esc(s) { return (s||'').replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;'); }
</script>
{% endblock %}
+145
View File
@@ -0,0 +1,145 @@
{% extends "base.html" %}
{% block title %}Users — Website Checker{% endblock %}
{% block content %}
<div class="page-header">
<h1 class="page-title">User Management</h1>
<button class="btn btn-primary" onclick="openModal('modal-create-user')"> Add User</button>
</div>
<div class="card">
<table class="table table-hover">
<thead>
<tr>
<th>ID</th><th>Username</th><th>Full Name</th><th>Email</th>
<th>Role</th><th>Active</th><th>Created</th><th>Actions</th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr>
<td>{{ u.id }}</td>
<td><strong>{{ u.username }}</strong></td>
<td>{{ u.full_name or '—' }}</td>
<td>{{ u.email or '—' }}</td>
<td><span class="badge badge-{{ 'accent' if u.role == 'admin' else 'neutral' }}">{{ u.role }}</span></td>
<td><span class="badge badge-{{ 'success' if u.is_active else 'danger' }}">{{ 'Yes' if u.is_active else 'No' }}</span></td>
<td class="text-muted">{{ u.created_at.strftime('%Y-%m-%d') if u.created_at else '—' }}</td>
<td class="actions">
<button class="btn btn-ghost btn-sm"
onclick='openEditUser({{ u|tojson }})'>✎ Edit</button>
<form method="post" action="{{ url_for('admin_users.delete', user_id=u.id) }}"
style="display:inline"
onsubmit="return confirm('Delete user {{ u.username }}?')">
<button class="btn btn-danger btn-sm" type="submit"></button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="8" class="text-center text-muted">No users found.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Create User Modal -->
<div class="modal-overlay" id="modal-create-user">
<div class="modal-dialog">
<div class="modal-header">
<h3>Add User</h3>
<button class="modal-close" onclick="closeModal('modal-create-user')"></button>
</div>
<form method="post" action="{{ url_for('admin_users.create') }}">
<div class="modal-body">
<div class="form-group">
<label class="form-label">Username *</label>
<input class="form-control" name="username" required>
</div>
<div class="form-group">
<label class="form-label">Full Name</label>
<input class="form-control" name="full_name">
</div>
<div class="form-group">
<label class="form-label">Email</label>
<input class="form-control" type="email" name="email">
</div>
<div class="form-group">
<label class="form-label">Role</label>
<select class="form-control" name="role">
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Password *</label>
<input class="form-control" type="password" name="password" required>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-ghost" onclick="closeModal('modal-create-user')">Cancel</button>
<button type="submit" class="btn btn-primary">Create User</button>
</div>
</form>
</div>
</div>
<!-- Edit User Modal -->
<div class="modal-overlay" id="modal-edit-user">
<div class="modal-dialog">
<div class="modal-header">
<h3>Edit User</h3>
<button class="modal-close" onclick="closeModal('modal-edit-user')"></button>
</div>
<form method="post" id="edit-user-form">
<div class="modal-body">
<div class="form-group">
<label class="form-label">Username *</label>
<input class="form-control" name="username" id="edit-username" required>
</div>
<div class="form-group">
<label class="form-label">Full Name</label>
<input class="form-control" name="full_name" id="edit-full-name">
</div>
<div class="form-group">
<label class="form-label">Email</label>
<input class="form-control" type="email" name="email" id="edit-email">
</div>
<div class="form-group">
<label class="form-label">Role</label>
<select class="form-control" name="role" id="edit-role">
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Active</label>
<select class="form-control" name="is_active" id="edit-is-active">
<option value="1">Yes</option>
<option value="0">No</option>
</select>
</div>
<div class="form-group">
<label class="form-label">New Password (leave blank to keep current)</label>
<input class="form-control" type="password" name="password">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-ghost" onclick="closeModal('modal-edit-user')">Cancel</button>
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</form>
</div>
</div>
<script>
function openEditUser(u) {
document.getElementById('edit-username').value = u.username;
document.getElementById('edit-full-name').value = u.full_name || '';
document.getElementById('edit-email').value = u.email || '';
document.getElementById('edit-role').value = u.role;
document.getElementById('edit-is-active').value = u.is_active ? '1' : '0';
document.getElementById('edit-user-form').action =
'/admin/users/' + u.id + '/edit';
openModal('modal-edit-user');
}
</script>
{% endblock %}
+176
View File
@@ -0,0 +1,176 @@
{% extends "base.html" %}
{% block title %}Websites — Website Checker{% endblock %}
{% block content %}
<div class="page-header">
<h1 class="page-title">Website Link Management</h1>
<button class="btn btn-primary" onclick="openModal('modal-create-site')"> Add Website</button>
</div>
<div class="card">
<table class="table table-hover">
<thead>
<tr><th>ID</th><th>Name</th><th>Type</th><th>URL</th><th>Note</th><th>Visibility</th><th>Created By</th><th>Actions</th></tr>
</thead>
<tbody>
{% for w in websites %}
<tr class="{{ 'row-weekly' if w.check_type == 'weekly' else '' }}">
<td>{{ w.id }}</td>
<td><strong>{{ w.name }}</strong></td>
<td><span class="badge badge-{{ 'warn' if w.check_type == 'weekly' else 'neutral' }}">{{ w.check_type }}</span></td>
<td class="url-cell"><a href="{{ w.url }}" target="_blank" rel="noopener">{{ w.url[:60] }}{% if w.url|length > 60 %}…{% endif %}</a></td>
<td class="text-muted">{{ (w.note or '')[:40] }}{% if (w.note or '')|length > 40 %}…{% endif %}</td>
<td><span class="badge badge-{{ 'accent' if w.visibility == 'assigned' else 'neutral' }}">{{ w.visibility }}</span></td>
<td class="text-muted">{{ w.creator or '—' }}</td>
<td class="actions">
<button class="btn btn-ghost btn-sm" onclick="loadEditSite({{ w.id }})">✎ Edit</button>
<form method="post" action="{{ url_for('admin_websites.delete', website_id=w.id) }}"
style="display:inline" onsubmit="return confirm('Soft-delete {{ w.name }}?')">
<button class="btn btn-danger btn-sm" type="submit"></button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="8" class="text-center text-muted">No websites configured.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Create Website Modal -->
<div class="modal-overlay" id="modal-create-site">
<div class="modal-dialog modal-lg">
<div class="modal-header">
<h3>Add Website</h3>
<button class="modal-close" onclick="closeModal('modal-create-site')"></button>
</div>
<form method="post" action="{{ url_for('admin_websites.create') }}">
<div class="modal-body">
<div class="form-row">
<div class="form-group flex-1">
<label class="form-label">Name *</label>
<input class="form-control" name="name" required>
</div>
<div class="form-group flex-1">
<label class="form-label">Check Type</label>
<select class="form-control" name="check_type">
<option value="daily">📅 Daily</option>
<option value="weekly">🗓 Weekly</option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">URL *</label>
<input class="form-control" name="url" type="url" required>
</div>
<div class="form-group">
<label class="form-label">Visibility</label>
<select class="form-control" name="visibility">
<option value="all">All users</option>
<option value="assigned">Assigned users only</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Note</label>
<textarea class="form-control" name="note" rows="2"></textarea>
</div>
<hr>
<h4 style="margin-bottom:.5rem">Credentials <button type="button" class="btn btn-secondary btn-sm" onclick="addCredRow(this)"> Add</button></h4>
<div class="cred-rows">
<div class="cred-row">
<div class="form-group"><label class="form-label">Label</label><input class="form-control" name="cred_label[]" placeholder="e.g. Admin"></div>
<div class="form-group"><label class="form-label">Username</label><input class="form-control" name="cred_username[]"></div>
<div class="form-group"><label class="form-label">Password</label><input class="form-control" name="cred_password[]" type="password"></div>
<div class="form-group"><label class="form-label">&nbsp;</label><button type="button" class="btn btn-danger btn-sm" onclick="removeRow(this)"></button></div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-ghost" onclick="closeModal('modal-create-site')">Cancel</button>
<button type="submit" class="btn btn-primary">Create Website</button>
</div>
</form>
</div>
</div>
<!-- Edit Website Modal -->
<div class="modal-overlay" id="modal-edit-site">
<div class="modal-dialog modal-lg">
<div class="modal-header">
<h3>Edit Website</h3>
<button class="modal-close" onclick="closeModal('modal-edit-site')"></button>
</div>
<form method="post" id="edit-site-form">
<div class="modal-body" id="edit-site-body">
<div class="text-center text-muted">Loading…</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-ghost" onclick="closeModal('modal-edit-site')">Cancel</button>
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</form>
</div>
</div>
<script>
function loadEditSite(id) {
fetch('/admin/websites/' + id + '/detail')
.then(r => r.json())
.then(site => {
const form = document.getElementById('edit-site-form');
form.action = '/admin/websites/' + id + '/edit';
document.getElementById('edit-site-body').innerHTML = buildSiteForm(site);
openModal('modal-edit-site');
});
}
function buildSiteForm(s) {
const creds = (s.credentials || []).map(c => `
<div class="cred-row">
<input class="form-control" name="cred_label[]" placeholder="Label" value="${esc(c.label)}">
<input class="form-control" name="cred_username[]" placeholder="Username" value="${esc(c.username)}">
<input class="form-control" name="cred_password[]" placeholder="Password" type="password" value="${esc(c.password)}">
<button type="button" class="btn btn-danger btn-sm" onclick="removeRow(this)">✕</button>
</div>`).join('') || emptyCredRow();
return `
<div class="form-group"><label class="form-label">Name *</label>
<input class="form-control" name="name" value="${esc(s.name)}" required></div>
<div class="form-row">
<div class="form-group flex-1"><label class="form-label">Check Type</label>
<select class="form-control" name="check_type">
<option value="daily" ${s.check_type==='daily'?'selected':''}>📅 Daily</option>
<option value="weekly" ${s.check_type==='weekly'?'selected':''}>🗓 Weekly</option>
</select></div>
<div class="form-group flex-1"><label class="form-label">Visibility</label>
<select class="form-control" name="visibility">
<option value="all" ${s.visibility==='all'?'selected':''}>All users</option>
<option value="assigned" ${s.visibility==='assigned'?'selected':''}>Assigned only</option>
</select></div>
</div>
<div class="form-group"><label class="form-label">URL *</label>
<input class="form-control" name="url" type="url" value="${esc(s.url)}" required></div>
<div class="form-group"><label class="form-label">Note</label>
<textarea class="form-control" name="note" rows="2">${esc(s.note)}</textarea></div>
<hr>
<h4>Credentials <button type="button" class="btn btn-ghost btn-sm" onclick="addCredRow(this)"> Add</button></h4>
<div class="cred-rows">${creds}</div>`;
}
function emptyCredRow() {
return `<div class="cred-row">
<input class="form-control" name="cred_label[]" placeholder="Label">
<input class="form-control" name="cred_username[]" placeholder="Username">
<input class="form-control" name="cred_password[]" placeholder="Password" type="password">
<button type="button" class="btn btn-danger btn-sm" onclick="removeRow(this)">✕</button>
</div>`;
}
function addCredRow(btn) {
btn.closest('.modal-body').querySelector('.cred-rows').insertAdjacentHTML('beforeend', emptyCredRow());
}
function removeRow(btn) { btn.closest('.cred-row').remove(); }
function esc(s) { return (s||'').replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;'); }
</script>
{% endblock %}
+542
View File
@@ -0,0 +1,542 @@
{% extends "base.html" %}
{% block title %}AI Summary — Website Checker{% endblock %}
{% block content %}
<div class="page-header">
<h1>🤖 AI Document Analysis</h1>
{% if is_admin %}
<a href="{{ url_for('admin_settings.settings') }}" class="btn btn-secondary btn-sm">⚙ API Settings</a>
{% endif %}
</div>
{% if not groq_key_set %}
<div class="alert alert-warning mb-2">⚠ Groq API key not configured. An admin must add it under Settings.</div>
{% endif %}
<div class="ai-layout">
<!-- ── Left panel ──────────────────────────────────────────── -->
<div class="ai-left">
<!-- Upload card -->
<div class="card">
<div class="card-header"><span class="card-title">Upload Documents</span></div>
<div class="card-body-pad">
{% if is_admin %}
<div class="form-group">
<label>Model</label>
<select id="ai-model">
{% for m in groq_models %}
<option {{ 'selected' if m == groq_model }}>{{ m }}</option>
{% endfor %}
</select>
</div>
{% endif %}
<div class="form-group">
<label>Documents <span class="text-muted">(PDF, DOCX, TXT, CSV, XLSX)</span></label>
<input type="file" id="ai-files" multiple
accept=".pdf,.docx,.doc,.txt,.csv,.xlsx,.xls,.md">
</div>
<button class="btn btn-primary w-full" id="btn-analyze" onclick="runAnalysis()">
🔍 Analyze with AI
</button>
</div>
</div>
<!-- Criteria card -->
<div class="card mt-2">
<div class="card-header">
<span class="card-title">Evaluation Criteria</span>
{% if is_admin %}
<button class="btn btn-secondary btn-sm" onclick="openModal('modal-add-criterion')"> Add</button>
{% endif %}
</div>
<div class="criteria-list">
{% for c in criteria %}
<div class="criterion-row {{ '' if c.is_active else 'criterion-inactive' }}"
data-title="{{ c.title }}"
data-desc="{{ c.description }}"
data-id="{{ c.id }}"
data-active="{{ '1' if c.is_active else '0' }}"
data-order="{{ c.sort_order }}">
<div class="criterion-header">
<strong class="criterion-title">{{ c.title }}</strong>
<div class="criterion-badges">
{% if not c.is_active %}<span class="badge badge-muted">Inactive</span>{% endif %}
{% if is_admin %}
<button class="btn btn-secondary btn-sm js-edit-criterion">✎ Edit</button>
<form method="post"
action="{{ url_for('ai_summary.delete_criterion_view', criterion_id=c.id) }}"
style="display:inline"
onsubmit="return confirm('Delete this criterion?')">
<button class="btn btn-danger btn-sm" type="submit"></button>
</form>
{% endif %}
</div>
</div>
<div class="criterion-desc">{{ c.description[:140] }}{% if c.description|length > 140 %}…{% endif %}</div>
<button class="btn-link-sm js-view-criterion">View full details </button>
</div>
{% else %}
<p class="text-muted text-center" style="padding:1rem">No criteria defined yet.</p>
{% endfor %}
</div>
</div>
</div>
<!-- ── Right panel ─────────────────────────────────────────── -->
<div class="ai-right">
<!-- Output card -->
<div class="card ai-output-card">
<div class="card-header">
<span class="card-title">Extracted Information</span>
<div class="flex gap-1">
<button class="btn btn-secondary btn-sm" id="btn-copy" onclick="copyOutput()" style="display:none">📋 Copy</button>
<button class="btn btn-secondary btn-sm" id="btn-save" onclick="saveOutput()" style="display:none">💾 Save as TXT</button>
<button class="btn btn-secondary btn-sm" id="btn-clear" onclick="clearOutput()" style="display:none">🗑 Clear</button>
</div>
</div>
<!-- Verdict banner — hidden until result arrives -->
<div id="verdict-banner" style="display:none"></div>
<!-- Output body -->
<div id="ai-output" class="ai-output-body">
<div class="ai-placeholder">
<p>Upload a document and click <strong>Analyze with AI</strong> to begin.</p>
<p class="text-muted text-sm mt-1">The AI will extract: solicitation number &amp; type, set-aside, scope of work, work site, pre-proposal conference, POC, square footage, driving distance &amp; travel time, due date, and key deadlines.</p>
</div>
</div>
</div>
<!-- History card -->
<div class="card mt-2">
<div class="card-header"><span class="card-title">Analysis History</span></div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Date</th>
{% if is_admin %}<th>User</th>{% endif %}
<th>Files</th>
<th>Verdict</th>
</tr>
</thead>
<tbody>
{% for h in history %}
<tr class="history-row" data-id="{{ h.id }}" style="cursor:pointer" title="Click to view result">
<td class="nowrap text-muted">{{ h.analyzed_at.strftime('%Y-%m-%d %H:%M') if h.analyzed_at else '—' }}</td>
{% if is_admin %}<td class="text-muted">{{ h.username or '—' }}</td>{% endif %}
<td class="text-muted">{{ h.file_names[:50] }}{% if h.file_names|length > 50 %}…{% endif %}</td>
<td>
{% if h.verdict %}
<span class="badge badge-{{ 'success' if h.verdict=='PURSUE' else 'danger' if h.verdict=='PASS' else 'warning' }}">
{{ h.verdict }}
</span>
{% else %}—{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="4" class="text-center text-muted" style="padding:1rem">No history yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- ── View Criterion Modal ─────────────────────────────────── -->
<div class="modal-overlay" id="modal-view-criterion">
<div class="modal">
<div class="modal-header">
<span class="modal-title" id="vc-title">Criterion Details</span>
<button class="modal-close" onclick="closeModal('modal-view-criterion')"></button>
</div>
<div class="modal-body">
<p id="vc-desc" style="white-space:pre-wrap;line-height:1.7;color:var(--text-secondary)"></p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('modal-view-criterion')">Close</button>
</div>
</div>
</div>
{% if is_admin %}
<!-- ── Add Criterion Modal ──────────────────────────────────── -->
<div class="modal-overlay" id="modal-add-criterion">
<div class="modal">
<div class="modal-header">
<span class="modal-title">Add Criterion</span>
<button class="modal-close" onclick="closeModal('modal-add-criterion')"></button>
</div>
<form method="post" action="{{ url_for('ai_summary.create_criterion_view') }}">
<div class="modal-body">
<div class="form-group"><label>Title *</label><input name="title" required></div>
<div class="form-group"><label>Description *</label><textarea name="description" rows="5" required></textarea></div>
<div class="form-row">
<div class="form-group"><label>Sort Order</label><input type="number" name="sort_order" value="0"></div>
<div class="form-group"><label>Active</label>
<select name="is_active"><option value="1">Yes</option><option value="0">No</option></select>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal('modal-add-criterion')">Cancel</button>
<button type="submit" class="btn btn-primary">Add Criterion</button>
</div>
</form>
</div>
</div>
<!-- ── Edit Criterion Modal ─────────────────────────────────── -->
<div class="modal-overlay" id="modal-edit-criterion">
<div class="modal">
<div class="modal-header">
<span class="modal-title">Edit Criterion</span>
<button class="modal-close" onclick="closeModal('modal-edit-criterion')"></button>
</div>
<form method="post" id="edit-criterion-form">
<div class="modal-body">
<div class="form-group"><label>Title *</label><input name="title" id="ec-title" required></div>
<div class="form-group"><label>Description *</label><textarea name="description" id="ec-desc" rows="5" required></textarea></div>
<div class="form-row">
<div class="form-group"><label>Sort Order</label><input type="number" name="sort_order" id="ec-order"></div>
<div class="form-group"><label>Active</label>
<select name="is_active" id="ec-active"><option value="1">Yes</option><option value="0">No</option></select>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal('modal-edit-criterion')">Cancel</button>
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</form>
</div>
</div>
{% endif %}
<!-- ── History Detail Modal ─────────────────────────────────── -->
<div class="modal-overlay" id="modal-history">
<div class="modal" style="max-width:780px">
<div class="modal-header">
<span class="modal-title" id="hist-title">Analysis Result</span>
<button class="modal-close" onclick="closeModal('modal-history')"></button>
</div>
<div id="hist-verdict-banner" style="display:none"></div>
<div class="modal-body" style="padding:0">
<div class="ai-meta-bar" id="hist-meta"></div>
<div id="hist-body" class="ai-rendered-output" style="max-height:65vh;overflow-y:auto;padding:1.25rem 1.5rem"></div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('modal-history')">Close</button>
</div>
</div>
</div>
<!-- marked.js for markdown rendering -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js"></script>
<script>
/* ─── Markdown renderer config ──────────────────────────────── */
marked.setOptions({ breaks: true, gfm: true });
/* ─── Render AI text: markdown + escape safety ──────────────── */
function renderAI(text) {
// marked.parse handles markdown; it escapes HTML internally for safety
return marked.parse(String(text || ''));
}
/* ─── Verdict banner HTML ───────────────────────────────────── */
function verdictBannerHTML(verdict) {
if (!verdict) return '';
var cfg = {
'PURSUE': { bg:'#16a34a', icon:'✅', label:'PURSUE THIS OPPORTUNITY', sub:'Strong alignment with your evaluation criteria.' },
'PASS': { bg:'#dc2626', icon:'🚫', label:'PASS ON THIS OPPORTUNITY', sub:'Does not meet one or more key criteria.' },
'UNCLEAR': { bg:'#d97706', icon:'⚠️', label:'UNCLEAR — REVIEW MANUALLY', sub:'Insufficient information for a confident determination.' },
};
var c = cfg[verdict.toUpperCase()] || { bg:'#4b5563', icon:'•', label: verdict, sub:'' };
return '<div class="verdict-banner" style="background:' + c.bg + '">'
+ '<span class="verdict-icon">' + c.icon + '</span>'
+ '<div><div class="verdict-label">' + c.label + '</div>'
+ (c.sub ? '<div class="verdict-sub">' + c.sub + '</div>' : '')
+ '</div></div>';
}
/* ─── Show/clear output ─────────────────────────────────────── */
var _rawText = ''; // kept for copy/save
function showResult(data) {
_rawText = data.summary || '';
var fileList = (data.file_names || '').split(', ').map(function(f) {
return ' - ' + f;
}).join('\n');
var criteriaNote = data.criteria_count > 0
? 'Criteria evaluated: ' + data.criteria_count
: 'No evaluation criteria configured.';
var header = 'AI Extraction | Model: ' + (data.model || '')
+ '\nFiles analyzed (' + (data.file_count || 1) + '):\n' + fileList
+ '\n' + criteriaNote
+ '\n' + '='.repeat(60);
// Verdict banner
var banner = document.getElementById('verdict-banner');
if (data.verdict) {
banner.innerHTML = verdictBannerHTML(data.verdict);
banner.style.display = '';
} else {
banner.style.display = 'none';
banner.innerHTML = '';
}
// Render output: header as plain preformatted, then markdown body
document.getElementById('ai-output').innerHTML =
'<pre class="ai-header-block">' + escHtml(header) + '</pre>'
+ '<div class="ai-rendered-output">' + renderAI(_rawText) + '</div>';
// Show action buttons
document.getElementById('btn-copy').style.display = '';
document.getElementById('btn-save').style.display = '';
document.getElementById('btn-clear').style.display = '';
}
function clearOutput() {
_rawText = '';
document.getElementById('ai-output').innerHTML =
'<div class="ai-placeholder"><p>Upload a document and click <strong>Analyze with AI</strong> to begin.</p></div>';
document.getElementById('verdict-banner').style.display = 'none';
document.getElementById('verdict-banner').innerHTML = '';
document.getElementById('btn-copy').style.display = 'none';
document.getElementById('btn-save').style.display = 'none';
document.getElementById('btn-clear').style.display = 'none';
}
function copyOutput() {
if (!_rawText) return;
navigator.clipboard.writeText(_rawText).then(function() {
var btn = document.getElementById('btn-copy');
var orig = btn.textContent;
btn.textContent = '✓ Copied';
setTimeout(function() { btn.textContent = orig; }, 2000);
});
}
function saveOutput() {
if (!_rawText) return;
var blob = new Blob([_rawText], { type: 'text/plain' });
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'ai_extraction.txt';
a.click();
}
/* ─── Analyze ───────────────────────────────────────────────── */
async function runAnalysis() {
var files = document.getElementById('ai-files').files;
if (!files.length) { alert('Please select at least one file.'); return; }
var modelEl = document.getElementById('ai-model');
var model = modelEl ? modelEl.value : '';
var btn = document.getElementById('btn-analyze');
btn.disabled = true;
btn.textContent = '⏳ Analysing…';
document.getElementById('ai-output').innerHTML =
'<div class="ai-placeholder"><p class="text-muted">Running AI analysis… this may take a moment.</p></div>';
document.getElementById('verdict-banner').style.display = 'none';
document.getElementById('btn-copy').style.display = 'none';
document.getElementById('btn-save').style.display = 'none';
document.getElementById('btn-clear').style.display = 'none';
var fd = new FormData();
for (var i = 0; i < files.length; i++) fd.append('documents[]', files[i]);
if (model) fd.append('model', model);
try {
var resp = await fetch('/ai-summary/analyze', { method: 'POST', body: fd });
var data = await resp.json();
if (data.error) {
document.getElementById('ai-output').innerHTML =
'<div class="alert alert-danger" style="margin:1rem">' + escHtml(data.error) + '</div>';
} else {
// Attach metadata for header rendering
data.file_names = Array.from(files).map(function(f){ return f.name; }).join(', ');
data.file_count = files.length;
data.model = model || '{{ groq_model }}';
data.criteria_count = {{ criteria | selectattr('is_active') | list | length }};
showResult(data);
}
} catch(e) {
document.getElementById('ai-output').innerHTML =
'<div class="alert alert-danger" style="margin:1rem">Request failed: ' + escHtml(String(e)) + '</div>';
} finally {
btn.disabled = false;
btn.textContent = '🔍 Analyze with AI';
}
}
/* ─── Criteria modals ───────────────────────────────────────── */
document.querySelectorAll('.js-view-criterion').forEach(function(btn) {
btn.addEventListener('click', function() {
var row = btn.closest('.criterion-row');
document.getElementById('vc-title').textContent = row.dataset.title;
document.getElementById('vc-desc').textContent = row.dataset.desc;
openModal('modal-view-criterion');
});
});
document.querySelectorAll('.js-edit-criterion').forEach(function(btn) {
btn.addEventListener('click', function() {
var row = btn.closest('.criterion-row');
document.getElementById('ec-title').value = row.dataset.title;
document.getElementById('ec-desc').value = row.dataset.desc;
document.getElementById('ec-order').value = row.dataset.order;
document.getElementById('ec-active').value = row.dataset.active;
document.getElementById('edit-criterion-form').action =
'/ai-summary/criteria/' + row.dataset.id + '/edit';
openModal('modal-edit-criterion');
});
});
/* ─── History rows ──────────────────────────────────────────── */
document.querySelectorAll('.history-row').forEach(function(row) {
row.addEventListener('click', function() {
var id = row.dataset.id;
document.getElementById('hist-title').textContent = 'Loading…';
document.getElementById('hist-meta').textContent = '';
document.getElementById('hist-body').innerHTML = '';
document.getElementById('hist-verdict-banner').style.display = 'none';
openModal('modal-history');
fetch('/ai-summary/history/' + id)
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.error) {
document.getElementById('hist-body').textContent = d.error;
return;
}
document.getElementById('hist-title').textContent =
'Analysis — ' + (d.file_names || '');
document.getElementById('hist-meta').textContent =
(d.analyzed_at || '').slice(0, 16)
+ (d.username ? ' · ' + d.username : '')
+ ' · ' + (d.model || '');
if (d.verdict) {
var vb = document.getElementById('hist-verdict-banner');
vb.innerHTML = verdictBannerHTML(d.verdict);
vb.style.display = '';
}
document.getElementById('hist-body').innerHTML =
renderAI(d.summary_text || '');
})
.catch(function() {
document.getElementById('hist-body').textContent = 'Failed to load analysis.';
});
});
});
/* ─── Utility ───────────────────────────────────────────────── */
function escHtml(str) {
return String(str || '').replace(/[&<>"']/g, function(c) {
return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];
});
}
</script>
<style>
/* ── Layout ────────────────────────────────────────────────── */
.ai-layout{display:grid;grid-template-columns:320px 1fr;gap:1.25rem;align-items:start}
.card-body-pad{padding:1.1rem 1.4rem 1.4rem}
.ai-output-card{display:flex;flex-direction:column;overflow:hidden}
/* ── Verdict banner ────────────────────────────────────────── */
.verdict-banner{
display:flex;align-items:center;gap:1rem;
padding:.9rem 1.4rem;color:#fff;
}
.verdict-icon{font-size:1.3rem;flex-shrink:0}
.verdict-label{font-size:1rem;font-weight:700;letter-spacing:.01em}
.verdict-sub{font-size:.8rem;opacity:.9;margin-top:2px}
/* ── Output area ───────────────────────────────────────────── */
.ai-output-body{
flex:1;overflow-y:auto;
min-height:300px;max-height:calc(100vh - 280px);
}
.ai-placeholder{
padding:2rem 1.5rem;color:var(--text-muted);
}
.ai-header-block{
background:var(--bg-subtle);border-bottom:1px solid var(--border);
padding:.85rem 1.4rem;font-family:'DM Mono',monospace;font-size:.78rem;
color:var(--text-secondary);margin:0;white-space:pre-wrap;line-height:1.6;
}
.ai-meta-bar{
padding:.6rem 1.5rem;font-size:.78rem;font-family:'DM Mono',monospace;
color:var(--text-muted);background:var(--bg-subtle);
border-bottom:1px solid var(--border);
}
/* ── Rendered markdown output ──────────────────────────────── */
.ai-rendered-output{
padding:1.1rem 1.4rem 1.5rem;
font-size:.9rem;line-height:1.8;color:var(--text-secondary);
}
.ai-rendered-output h1,.ai-rendered-output h2{
font-size:1rem;font-weight:700;color:var(--text);
margin:1.4rem 0 .5rem;padding-bottom:.3rem;
border-bottom:1px solid var(--border);
}
.ai-rendered-output h3,.ai-rendered-output h4{
font-size:.9rem;font-weight:700;color:var(--text);margin:1rem 0 .35rem;
}
.ai-rendered-output p{margin:.35rem 0}
.ai-rendered-output strong{color:var(--text);font-weight:700}
.ai-rendered-output ul,.ai-rendered-output ol{
padding-left:1.4rem;margin:.35rem 0;
}
.ai-rendered-output li{margin:.2rem 0}
.ai-rendered-output hr{
border:none;border-top:2px solid var(--border);margin:1.25rem 0;
}
.ai-rendered-output pre,.ai-rendered-output code{
font-family:'DM Mono',monospace;font-size:.82rem;
background:var(--bg-subtle);border-radius:var(--r-sm);
}
.ai-rendered-output pre{padding:.75rem 1rem;overflow-x:auto}
.ai-rendered-output code{padding:.1rem .3rem}
/* ── Criteria list ─────────────────────────────────────────── */
.criteria-list{display:flex;flex-direction:column}
.criterion-row{
padding:.8rem 1.2rem;border-bottom:1px solid var(--border);
transition:background var(--t);
}
.criterion-row:last-child{border-bottom:none}
.criterion-row:hover{background:var(--bg-subtle)}
.criterion-inactive{opacity:.55}
.criterion-header{display:flex;align-items:center;justify-content:space-between;
gap:.5rem;margin-bottom:.2rem}
.criterion-title{font-size:.875rem;font-weight:600;color:var(--text)}
.criterion-badges{display:flex;align-items:center;gap:.35rem;flex-shrink:0}
.criterion-desc{font-size:.78rem;color:var(--text-muted);line-height:1.5;margin-bottom:.3rem}
.btn-link-sm{
background:none;border:none;padding:0;font-size:.73rem;
color:var(--accent);cursor:pointer;font-family:inherit;
}
.btn-link-sm:hover{text-decoration:underline}
/* ── History ───────────────────────────────────────────────── */
.history-row:hover td{background:var(--bg-subtle)}
@media(max-width:960px){
.ai-layout{grid-template-columns:1fr}
.ai-output-body{max-height:60vh}
}
</style>
{% endblock %}
+68
View File
@@ -0,0 +1,68 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Website Checker{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body class="layout">
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-brand">
<span class="brand-icon">🌐</span>
<span class="brand-name">Website<br>Checker</span>
</div>
<hr class="sidebar-divider">
{% if current_user %}
{% if current_user.role == 'admin' %}
<p class="nav-group-label">MANAGEMENT</p>
<a href="{{ url_for('admin_dashboard.dashboard') }}" class="nav-item {% if request.endpoint == 'admin_dashboard.dashboard' %}active{% endif %}">🏠 Dashboard</a>
<a href="{{ url_for('admin_users.users_list') }}" class="nav-item {% if 'admin_users' in request.endpoint %}active{% endif %}">👤 Users</a>
<a href="{{ url_for('admin_websites.websites_list') }}" class="nav-item {% if 'admin_websites' in request.endpoint %}active{% endif %}">🌐 Websites</a>
<a href="{{ url_for('admin_shifts.shifts_list') }}" class="nav-item {% if 'admin_shifts' in request.endpoint %}active{% endif %}">🗓 Shifts</a>
<a href="{{ url_for('admin_reports.reports') }}" class="nav-item {% if 'admin_reports' in request.endpoint %}active{% endif %}">📑 Reports</a>
<a href="{{ url_for('admin_logs.logs') }}" class="nav-item {% if 'admin_logs' in request.endpoint %}active{% endif %}">📋 Activity Log</a>
<a href="{{ url_for('admin_settings.settings') }}" class="nav-item {% if 'admin_settings' in request.endpoint %}active{% endif %}">⚙ Settings</a>
<hr class="sidebar-divider">
<p class="nav-group-label">MY WORKSPACE</p>
{% endif %}
<a href="{{ url_for('user_dashboard.my_shifts') }}" class="nav-item {% if 'user_dashboard' in request.endpoint %}active{% endif %}">📊 My Shifts</a>
<a href="{{ url_for('ai_summary.ai_summary') }}" class="nav-item {% if 'ai_summary' in request.endpoint %}active{% endif %}">🤖 AI Summary</a>
<a href="{{ url_for('bid_tracker.bids_list') }}" class="nav-item {% if 'bid_tracker' in request.endpoint %}active{% endif %}">📌 Bid Tracker</a>
{% endif %}
<div class="sidebar-footer">
{% if current_user %}
<div class="user-info">
<div class="user-name">{{ current_user.full_name or current_user.username }}</div>
<div class="user-role">{{ current_user.role | capitalize }}</div>
</div>
<a href="{{ url_for('auth.change_password_view') }}" class="sidebar-btn btn-ghost">🔑 Change Password</a>
<a href="{{ url_for('auth.logout') }}" class="sidebar-btn btn-danger">⇠ Sign Out</a>
{% endif %}
</div>
</aside>
<!-- Main content -->
<main class="main-content">
<!-- Flash messages -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-container">
{% for cat, msg in messages %}
<div class="alert alert-{{ cat }}">{{ msg }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
</body>
</html>
+540
View File
@@ -0,0 +1,540 @@
{% extends "base.html" %}
{% block title %}Bid Tracker — Website Checker{% endblock %}
{% block content %}
<div class="page-header">
<h1>📌 Bid Tracker</h1>
<button class="btn btn-primary" onclick="openModal('modal-add-bid')"> Add Bid</button>
</div>
<!-- ── Toolbar ─────────────────────────────────────────────── -->
<div class="bt-toolbar">
<div class="search-wrap" style="max-width:260px">
<span class="search-icon">🔍</span>
<input type="text" id="bid-search" placeholder="Search title, source, sol #…" autocomplete="off">
</div>
<div class="bt-filters">
<button class="bt-filter-btn active" data-status="">All</button>
{% for s in bid_statuses %}
<button class="bt-filter-btn" data-status="{{ s }}">{{ status_labels[s] }}</button>
{% endfor %}
</div>
<button class="btn btn-secondary btn-sm" onclick="loadBids()">↻ Refresh</button>
</div>
<!-- ── Split pane ─────────────────────────────────────────── -->
<div class="bt-layout">
<!-- Left — bid list -->
<div class="bt-left">
<div id="bid-list" class="bt-list">
<div class="bt-loading">Loading…</div>
</div>
</div>
<!-- Right — detail panel -->
<div class="bt-right" id="bt-detail">
<div class="bt-empty-state">
<div class="empty-icon">📌</div>
<p>Select a bid to view its details and updates.</p>
</div>
</div>
</div>
<!-- ══════════════════════════════════════════════════════════
ADD BID MODAL
══════════════════════════════════════════════════════════ -->
<div class="modal-overlay" id="modal-add-bid">
<div class="modal" style="max-width:600px">
<div class="modal-header">
<span class="modal-title">Add Opportunity</span>
<button class="modal-close" onclick="closeModal('modal-add-bid')"></button>
</div>
<form method="post" action="{{ url_for('bid_tracker.create') }}">
<div class="modal-body">
{% include "_bid_form_fields.html" ignore missing %}
<div class="form-group">
<label>Title *</label>
<input type="text" name="title" required>
</div>
<div class="form-group">
<label>URL *</label>
<input type="url" name="url" placeholder="https://" required>
</div>
<div class="form-row">
<div class="form-group">
<label>Source</label>
<input type="text" name="source" placeholder="e.g. SAM.gov">
</div>
<div class="form-group">
<label>Solicitation #</label>
<input type="text" name="solicitation_number">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Status</label>
<select name="status">
{% for s in bid_statuses %}
<option value="{{ s }}" {% if s == 'open' %}selected{% endif %}>{{ status_labels[s] }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Due Date</label>
<input type="date" name="due_date">
</div>
</div>
<div class="form-group">
<label>Notes</label>
<textarea name="notes" rows="3" placeholder="Any relevant notes…"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal('modal-add-bid')">Cancel</button>
<button type="submit" class="btn btn-primary">Add Bid</button>
</div>
</form>
</div>
</div>
<!-- ══════════════════════════════════════════════════════════
EDIT BID MODAL
══════════════════════════════════════════════════════════ -->
<div class="modal-overlay" id="modal-edit-bid">
<div class="modal" style="max-width:600px">
<div class="modal-header">
<span class="modal-title">Edit Opportunity</span>
<button class="modal-close" onclick="closeModal('modal-edit-bid')"></button>
</div>
<form method="post" id="edit-bid-form">
<div class="modal-body">
<div class="form-group">
<label>Title *</label>
<input type="text" name="title" id="eb-title" required>
</div>
<div class="form-group">
<label>URL *</label>
<input type="url" name="url" id="eb-url" placeholder="https://" required>
</div>
<div class="form-row">
<div class="form-group">
<label>Source</label>
<input type="text" name="source" id="eb-source">
</div>
<div class="form-group">
<label>Solicitation #</label>
<input type="text" name="solicitation_number" id="eb-sol">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Status</label>
<select name="status" id="eb-status">
{% for s in bid_statuses %}
<option value="{{ s }}">{{ status_labels[s] }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Due Date</label>
<input type="date" name="due_date" id="eb-due">
</div>
</div>
<div class="form-group">
<label>Notes</label>
<textarea name="notes" id="eb-notes" rows="3"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal('modal-edit-bid')">Cancel</button>
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</form>
</div>
</div>
<script>
/* ══════════════════════════════════════════════════════════
State
══════════════════════════════════════════════════════════ */
var _activeBidId = null;
var _activeStatus = '';
var _allBids = []; // cached from last load
var _currentUserId = {{ session['user']['id'] }};
var _isAdmin = {{ 'true' if session['user']['role'] == 'admin' else 'false' }};
/* ══════════════════════════════════════════════════════════
Status config
══════════════════════════════════════════════════════════ */
var STATUS_LABELS = {
'open': '🟢 Open',
'monitoring': '🔵 Monitoring',
'awarded': '🏆 Awarded',
'no_bid': '⛔ No Bid',
'cancelled': '🚫 Cancelled',
};
var STATUS_BADGE = {
'open': 'badge-success',
'monitoring': 'badge-info',
'awarded': 'badge-warning',
'no_bid': 'badge-danger',
'cancelled': 'badge-muted',
};
/* ══════════════════════════════════════════════════════════
Load / render bid list
══════════════════════════════════════════════════════════ */
function loadBids() {
fetch('/bids/list/json?status=' + encodeURIComponent(_activeStatus))
.then(function(r) { return r.json(); })
.then(function(bids) {
_allBids = bids;
renderBidList(bids);
// Restore selection
if (_activeBidId) {
var row = document.querySelector('.bt-row[data-id="' + _activeBidId + '"]');
if (row) row.classList.add('active');
}
})
.catch(function() {
document.getElementById('bid-list').innerHTML =
'<p class="text-muted text-center" style="padding:1rem">Failed to load bids.</p>';
});
}
function renderBidList(bids) {
var q = document.getElementById('bid-search').value.trim().toLowerCase();
var filtered = q ? bids.filter(function(b) {
return (b.title || '').toLowerCase().includes(q)
|| (b.source || '').toLowerCase().includes(q)
|| (b.solicitation_number || '').toLowerCase().includes(q);
}) : bids;
var list = document.getElementById('bid-list');
if (!filtered.length) {
list.innerHTML = '<p class="text-muted text-center" style="padding:1.5rem">No bids found.</p>';
return;
}
list.innerHTML = filtered.map(function(b) {
var due = b.due_date ? b.due_date.slice(0, 10) : '—';
var badge = STATUS_BADGE[b.status] || 'badge-muted';
var label = STATUS_LABELS[b.status] || b.status;
var active = b.id === _activeBidId ? ' active' : '';
var upds = b.update_count || 0;
return '<div class="bt-row' + active + '" data-id="' + b.id + '" onclick="selectBid(' + b.id + ')">'
+ '<div class="bt-row-title">' + esc(b.title) + '</div>'
+ '<div class="bt-row-meta">'
+ '<span class="badge ' + badge + ' badge-sm">' + label + '</span>'
+ '<span class="text-muted text-xs">Due: ' + due + '</span>'
+ (upds ? '<span class="text-muted text-xs">💬 ' + upds + '</span>' : '')
+ '</div>'
+ '</div>';
}).join('');
}
/* ══════════════════════════════════════════════════════════
Select + load detail
══════════════════════════════════════════════════════════ */
function selectBid(id) {
_activeBidId = id;
document.querySelectorAll('.bt-row').forEach(function(r) {
r.classList.toggle('active', r.dataset.id == id);
});
loadDetail(id);
}
function loadDetail(id) {
var panel = document.getElementById('bt-detail');
panel.innerHTML = '<div class="bt-detail-loading">Loading…</div>';
fetch('/bids/' + id + '/json')
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.error) { panel.innerHTML = '<p class="text-muted" style="padding:1rem">' + esc(data.error) + '</p>'; return; }
renderDetail(data);
})
.catch(function() {
panel.innerHTML = '<p class="text-muted" style="padding:1rem">Failed to load details.</p>';
});
}
function renderDetail(data) {
var b = data.bid;
var updates = data.updates;
var canEdit = data.can_edit;
var userId = data.user_id;
var due = b.due_date ? b.due_date.slice(0, 10) : 'Not specified';
var badge = STATUS_BADGE[b.status] || 'badge-muted';
var label = STATUS_LABELS[b.status] || b.status;
var sol = b.solicitation_number || '—';
var src = b.source || '—';
var addedBy = b.added_by_username || '—';
var addedAt = (b.created_at || '').slice(0, 16);
var notes = b.notes || '';
var updatesHtml = updates.length ? updates.map(function(u) {
var poster = u.posted_by_full_name || u.posted_by_username || 'Unknown';
var dt = (u.created_at || '').slice(0, 16);
var canDel = data.is_admin || u.user_id === userId;
var delBtn = canDel
? '<button class="bt-upd-del" onclick="deleteUpdate(' + u.id + ',' + b.id + ')" title="Delete update">✕</button>'
: '';
return '<div class="bt-update-card">'
+ '<div class="bt-upd-hdr">'
+ '<span class="bt-upd-poster">👤 ' + esc(poster) + '</span>'
+ '<span class="bt-upd-dt text-muted text-xs">' + dt + '</span>'
+ delBtn
+ '</div>'
+ '<div class="bt-upd-body">' + esc(u.content) + '</div>'
+ '</div>';
}).join('') : '<p class="text-muted text-sm" style="padding:.5rem 0">No updates yet. Be the first to post one.</p>';
var editBtn = canEdit
? '<button class="btn btn-secondary btn-sm" onclick="openEditModal(' + b.id + ')">✎ Edit</button>'
+ '<form method="post" action="/bids/' + b.id + '/delete" style="display:inline"'
+ ' onsubmit="return confirm(\'Delete \\\'' + esc(b.title) + '\\\'?\')">'
+ '<button class="btn btn-danger btn-sm" type="submit">✕ Delete</button></form>'
: '';
document.getElementById('bt-detail').innerHTML = ''
+ '<div class="bt-detail-header">'
+ '<div class="bt-detail-title-row">'
+ '<h2 class="bt-detail-title">' + esc(b.title) + '</h2>'
+ '<span class="badge ' + badge + '">' + label + '</span>'
+ '</div>'
+ '<div class="bt-detail-actions">'
+ editBtn
+ '<a href="' + esc(b.url) + '" target="_blank" rel="noopener" class="btn btn-secondary btn-sm">↗ Open URL</a>'
+ '</div>'
+ '</div>'
+ '<div class="bt-meta-grid">'
+ metaItem('Due Date', due)
+ metaItem('Sol #', sol)
+ metaItem('Source', src)
+ metaItem('Added by', addedBy + (addedAt ? ' · ' + addedAt : ''))
+ '</div>'
+ (notes ? '<div class="bt-notes"><strong>Notes:</strong> ' + esc(notes) + '</div>' : '')
+ '<div class="bt-updates-section">'
+ '<div class="bt-updates-header">📝 Updates</div>'
+ '<div class="bt-compose">'
+ '<textarea id="upd-text-' + b.id + '" class="bt-compose-txt" rows="2"'
+ ' placeholder="Write an update, e.g. \'Amendment 1 issued — due date extended to…\'"></textarea>'
+ '<button class="btn btn-primary btn-sm" onclick="postUpdate(' + b.id + ')">📤 Post Update</button>'
+ '</div>'
+ '<div class="bt-updates-list" id="upd-list-' + b.id + '">' + updatesHtml + '</div>'
+ '</div>';
}
function metaItem(label, value) {
return '<div class="bt-meta-item"><span class="bt-meta-key">' + label + '</span>'
+ '<span class="bt-meta-val">' + esc(value) + '</span></div>';
}
/* ══════════════════════════════════════════════════════════
Post update (AJAX — no page reload)
══════════════════════════════════════════════════════════ */
function postUpdate(bidId) {
var ta = document.getElementById('upd-text-' + bidId);
var content = ta ? ta.value.trim() : '';
if (!content) { alert('Please write an update before posting.'); return; }
var fd = new FormData();
fd.append('content', content);
fetch('/bids/' + bidId + '/updates/json', { method: 'POST', body: fd, credentials: 'same-origin' })
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.error) { alert(d.error); return; }
ta.value = '';
// Reload detail to show new update
loadDetail(bidId);
// Refresh list to update count
loadBids();
})
.catch(function(e) { alert('Failed to post update: ' + e); });
}
/* ══════════════════════════════════════════════════════════
Delete update (AJAX)
══════════════════════════════════════════════════════════ */
function deleteUpdate(updateId, bidId) {
if (!confirm('Delete this update?')) return;
var fd = new FormData();
fetch('/bids/updates/' + updateId + '/delete/json', { method: 'POST', body: fd, credentials: 'same-origin' })
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.error) { alert(d.error); return; }
loadDetail(bidId);
loadBids();
})
.catch(function(e) { alert('Failed to delete update: ' + e); });
}
/* ══════════════════════════════════════════════════════════
Edit modal
══════════════════════════════════════════════════════════ */
function openEditModal(bidId) {
fetch('/bids/' + bidId + '/json')
.then(function(r) { return r.json(); })
.then(function(data) {
var b = data.bid;
document.getElementById('eb-title').value = b.title || '';
document.getElementById('eb-url').value = b.url || '';
document.getElementById('eb-source').value = b.source || '';
document.getElementById('eb-sol').value = b.solicitation_number || '';
document.getElementById('eb-status').value = b.status || 'open';
document.getElementById('eb-due').value = b.due_date ? b.due_date.slice(0, 10) : '';
document.getElementById('eb-notes').value = b.notes || '';
document.getElementById('edit-bid-form').action = '/bids/' + bidId + '/edit';
openModal('modal-edit-bid');
});
}
/* ══════════════════════════════════════════════════════════
Filter buttons
══════════════════════════════════════════════════════════ */
document.querySelectorAll('.bt-filter-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
document.querySelectorAll('.bt-filter-btn').forEach(function(b) { b.classList.remove('active'); });
btn.classList.add('active');
_activeStatus = btn.dataset.status;
loadBids();
});
});
/* ── Search ─────────────────────────────────────────────── */
document.getElementById('bid-search').addEventListener('input', function() {
renderBidList(_allBids);
// Restore active class after re-render
if (_activeBidId) {
var row = document.querySelector('.bt-row[data-id="' + _activeBidId + '"]');
if (row) row.classList.add('active');
}
});
/* ── Escape ─────────────────────────────────────────────── */
function esc(str) {
return String(str || '').replace(/[&<>"']/g, function(c) {
return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];
});
}
/* ── Init ───────────────────────────────────────────────── */
loadBids();
</script>
<style>
/* ── Toolbar ───────────────────────────────────────────── */
.bt-toolbar{display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;margin-bottom:1rem}
.bt-filters{display:flex;gap:.35rem;flex-wrap:wrap}
.bt-filter-btn{
padding:.3rem .75rem;border-radius:999px;font-size:.75rem;font-weight:600;
border:1px solid var(--border-strong);background:var(--bg-white);
color:var(--text-muted);cursor:pointer;transition:all var(--t);
font-family:'DM Sans',sans-serif;
}
.bt-filter-btn:hover{background:var(--bg-hover);color:var(--text)}
.bt-filter-btn.active{background:var(--accent);color:#fff;border-color:var(--accent)}
/* ── Layout ────────────────────────────────────────────── */
.bt-layout{display:grid;grid-template-columns:340px 1fr;gap:1rem;
align-items:start;min-height:calc(100vh - 200px)}
.bt-left{background:var(--bg-white);border:1px solid var(--border);
border-radius:var(--r-lg);overflow:hidden;box-shadow:var(--shadow-sm)}
.bt-right{background:var(--bg-white);border:1px solid var(--border);
border-radius:var(--r-lg);box-shadow:var(--shadow-sm);
min-height:400px;overflow:hidden}
/* ── Bid list rows ─────────────────────────────────────── */
.bt-list{display:flex;flex-direction:column}
.bt-loading{padding:1.5rem;text-align:center;color:var(--text-muted);font-size:.875rem}
.bt-row{
padding:.8rem 1rem;border-bottom:1px solid var(--border);
cursor:pointer;transition:background var(--t);
}
.bt-row:last-child{border-bottom:none}
.bt-row:hover{background:var(--bg-subtle)}
.bt-row.active{background:var(--accent-light);border-left:3px solid var(--accent)}
.bt-row-title{font-size:.875rem;font-weight:600;color:var(--text);
margin-bottom:.3rem;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.bt-row-meta{display:flex;align-items:center;gap:.6rem;flex-wrap:wrap}
/* ── Detail panel ──────────────────────────────────────── */
.bt-empty-state{display:flex;flex-direction:column;align-items:center;
justify-content:center;padding:3rem 1rem;color:var(--text-muted);
text-align:center}
.bt-detail-loading{padding:2rem;text-align:center;color:var(--text-muted)}
.bt-detail-header{
padding:1.1rem 1.25rem .9rem;border-bottom:1px solid var(--border);
background:var(--bg-subtle);
}
.bt-detail-title-row{display:flex;align-items:flex-start;gap:.6rem;margin-bottom:.6rem}
.bt-detail-title{font-size:1.1rem;font-weight:700;color:var(--text);flex:1;line-height:1.3}
.bt-detail-actions{display:flex;align-items:center;gap:.4rem;flex-wrap:wrap}
/* ── Metadata grid ─────────────────────────────────────── */
.bt-meta-grid{
display:grid;grid-template-columns:1fr 1fr;gap:0;
padding:.75rem 1.25rem;border-bottom:1px solid var(--border);
}
.bt-meta-item{padding:.35rem 0;display:flex;flex-direction:column;gap:2px}
.bt-meta-key{font-size:.68rem;font-weight:700;letter-spacing:.07em;
text-transform:uppercase;color:var(--text-muted)}
.bt-meta-val{font-size:.85rem;color:var(--text-secondary)}
.bt-notes{
padding:.7rem 1.25rem;font-size:.85rem;color:var(--text-secondary);
border-bottom:1px solid var(--border);line-height:1.6;
}
/* ── Updates section ───────────────────────────────────── */
.bt-updates-section{padding:0 1.25rem 1.25rem}
.bt-updates-header{font-size:.85rem;font-weight:700;color:var(--text);
padding:.9rem 0 .6rem;border-bottom:1px solid var(--border);
margin-bottom:.75rem}
.bt-compose{display:flex;flex-direction:column;gap:.5rem;margin-bottom:.9rem}
.bt-compose-txt{
width:100%;padding:.55rem .75rem;border:1px solid var(--border-strong);
border-radius:var(--r-sm);font-family:'DM Sans',sans-serif;font-size:.875rem;
color:var(--text);background:var(--bg-white);resize:vertical;outline:none;
transition:border-color var(--t),box-shadow var(--t);
}
.bt-compose-txt:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-glow)}
.bt-compose .btn{align-self:flex-end}
/* ── Update cards ──────────────────────────────────────── */
.bt-updates-list{display:flex;flex-direction:column;gap:.6rem;
max-height:380px;overflow-y:auto;padding-right:2px}
.bt-update-card{
background:var(--bg-subtle);border:1px solid var(--border);
border-radius:var(--r);padding:.7rem .9rem;
}
.bt-upd-hdr{display:flex;align-items:center;gap:.6rem;margin-bottom:.4rem}
.bt-upd-poster{font-size:.8rem;font-weight:600;color:var(--text);flex:1}
.bt-upd-dt{flex-shrink:0}
.bt-upd-del{
background:none;border:none;color:var(--text-muted);cursor:pointer;
font-size:.8rem;padding:.1rem .3rem;border-radius:var(--r-sm);
transition:all var(--t);
}
.bt-upd-del:hover{background:var(--danger-bg);color:var(--danger)}
.bt-upd-body{font-size:.875rem;color:var(--text-secondary);line-height:1.6;
white-space:pre-wrap}
@media(max-width:900px){
.bt-layout{grid-template-columns:1fr;grid-template-rows:auto auto}
.bt-right{min-height:300px}
}
</style>
{% endblock %}
+27
View File
@@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block title %}Change Password — Website Checker{% endblock %}
{% block content %}
<div class="page-header"><h1 class="page-title">🔑 Change Password</h1></div>
<div class="card" style="max-width:480px">
<div class="modal-body">
{% if success %}<div class="alert alert-success">{{ success }}</div>{% endif %}
{% if error %}<div class="alert alert-danger">{{ error }}</div>{% endif %}
<form method="post" action="{{ url_for('auth.change_password_view') }}">
<div class="form-group">
<label class="form-label">Current Password</label>
<input class="form-control" type="password" name="old_password" required>
</div>
<div class="form-group">
<label class="form-label">New Password</label>
<input class="form-control" type="password" name="new_password" required>
<small class="text-muted">Min 8 chars, uppercase, number, special character.</small>
</div>
<div class="form-group">
<label class="form-label">Confirm New Password</label>
<input class="form-control" type="password" name="confirm_password" required>
</div>
<button class="btn btn-primary" type="submit">Change Password</button>
</form>
</div>
</div>
{% endblock %}
+38
View File
@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login — Website Checker</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body class="login-wrap">
<div class="login-box">
<div class="login-logo">
<span class="brand-icon">🌐</span>
<h1>Website Checker</h1>
<p>Shift Monitoring Tool</p>
</div>
{% if error %}
<div class="alert alert-danger mb-2">{{ error }}</div>
{% endif %}
<form method="post" action="{{ url_for('auth.login') }}">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username"
autocomplete="username" autofocus required>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password"
autocomplete="current-password" required>
</div>
<button class="btn btn-primary" type="submit">Sign In</button>
</form>
</div>
</body>
</html>
+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 %}