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