- Updated ads.html to enhance the display of recent ads with better styling and layout. - Modified base.html to introduce a new color scheme and typography using Inter font and Phosphor icons. - Revamped dashboard.html to provide a clearer overview of statistics with improved card layouts. - Enhanced keyword_detail.html for better presentation of keyword configurations and recent matches. - Improved keywords.html to display keyword filters and subscriber counts more effectively. - Updated users.html to present user information in a more structured format with clear status indicators.
This commit is contained in:
@@ -1,40 +1,65 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Ads — Willhaben Tracker{% endblock %}
|
{% block title %}Ads — Willhaben Tracker{% endblock %}
|
||||||
{% block content %}
|
{% block title_in_topbar %}Recent Ads{% endblock %}
|
||||||
<div class="page-header">
|
|
||||||
<h2>Recent Ads</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
{% if not error and ads %}
|
{% if not error and ads %}
|
||||||
<div class="card" style="overflow-x: auto;">
|
<div class="table-container">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Title</th>
|
<th>Ad</th>
|
||||||
<th>Price</th>
|
|
||||||
<th>Location</th>
|
<th>Location</th>
|
||||||
<th>Postcode</th>
|
<th>First Tracked</th>
|
||||||
<th>URL</th>
|
<th>Link</th>
|
||||||
<th>Published</th>
|
|
||||||
<th>First Seen</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for ad in ads %}
|
{% for ad in ads %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ ad.title[:70] }}{% if ad.title|length > 70 %}…{% endif %}</td>
|
<td style="max-width: 400px; white-space: normal;">
|
||||||
<td>{{ format_price(ad.price) }}</td>
|
<div style="font-weight: 500; margin-bottom: 4px; line-height: 1.4;">
|
||||||
<td>{{ ad.location or '—' }}</td>
|
{{ ad.title }}
|
||||||
<td>{{ ad.postcode or '—' }}</td>
|
</div>
|
||||||
<td><a href="{{ ad.url }}" target="_blank">Link</a></td>
|
<div style="display: flex; gap: 12px; font-size: 13px;">
|
||||||
<td>{{ ad.published_at.strftime('%Y-%m-%d %H:%M') if ad.published_at else '—' }}</td>
|
<span style="font-weight: 600; color: var(--success-text);">{{ format_price(ad.price) }}</span>
|
||||||
<td>{{ ad.first_seen_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
{% if ad.published_at %}
|
||||||
|
<span style="color: var(--text-muted);">
|
||||||
|
<i class="ph ph-calendar-blank" style="vertical-align: middle;"></i>
|
||||||
|
Pub: {{ ad.published_at.strftime('%m/%d %H:%M') }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div style="display: flex; align-items: center; gap: 4px;">
|
||||||
|
<i class="ph ph-map-pin" style="color: var(--text-muted);"></i>
|
||||||
|
{% if ad.postcode %}{{ ad.postcode }} • {% endif %}
|
||||||
|
{{ ad.location or '—' }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div style="color: var(--text-secondary);">
|
||||||
|
{{ ad.first_seen_at.strftime('%Y-%m-%d') }}
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 12px; color: var(--text-muted); margin-top: 2px;">
|
||||||
|
{{ ad.first_seen_at.strftime('%H:%M:%S') }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="{{ ad.url }}" target="_blank" style="display: inline-flex; align-items: center; gap: 4px; padding: 6px 12px; background: var(--bg-surface-active); border-radius: var(--radius-md); font-size: 13px; font-weight: 500;">
|
||||||
|
Open <i class="ph-bold ph-arrow-up-right"></i>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{% elif not error %}
|
{% elif not error %}
|
||||||
<div class="card"><em>No ads found.</em></div>
|
<div class="empty-state">
|
||||||
|
<i class="ph ph-shopping-bag"></i>
|
||||||
|
<p>No ads tracked yet.</p>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
+339
-90
@@ -4,128 +4,377 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{% block title %}Willhaben Tracker{% endblock %}</title>
|
<title>{% block title %}Willhaben Tracker{% endblock %}</title>
|
||||||
|
<!-- Add Inter font and Phosphor icons -->
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<script src="https://unpkg.com/@phosphor-icons/web"></script>
|
||||||
<style>
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-body: #0b0f19;
|
||||||
|
--bg-surface: #111827;
|
||||||
|
--bg-surface-hover: #1f2937;
|
||||||
|
--bg-surface-active: #374151;
|
||||||
|
|
||||||
|
--border-default: #1f2937;
|
||||||
|
--border-hover: #374151;
|
||||||
|
|
||||||
|
--text-primary: #f3f4f6;
|
||||||
|
--text-secondary: #9ca3af;
|
||||||
|
--text-muted: #6b7280;
|
||||||
|
|
||||||
|
--brand-primary: #3b82f6;
|
||||||
|
--brand-primary-hover: #2563eb;
|
||||||
|
--brand-primary-light: rgba(59, 130, 246, 0.1);
|
||||||
|
|
||||||
|
--success-text: #34d399;
|
||||||
|
--success-bg: rgba(52, 211, 153, 0.1);
|
||||||
|
--danger-text: #f87171;
|
||||||
|
--danger-bg: rgba(248, 113, 113, 0.1);
|
||||||
|
--warning-text: #fbbf24;
|
||||||
|
--warning-bg: rgba(251, 191, 36, 0.1);
|
||||||
|
|
||||||
|
--radius-md: 12px;
|
||||||
|
--radius-lg: 16px;
|
||||||
|
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||||
|
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: system-ui, -apple-system, sans-serif;
|
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||||
background: #1a1a2e;
|
background: var(--bg-body);
|
||||||
color: #e0e0e0;
|
color: var(--text-primary);
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
/* Sidebar */
|
|
||||||
|
/* Modern Sidebar */
|
||||||
.sidebar {
|
.sidebar {
|
||||||
width: 220px;
|
width: 260px;
|
||||||
background: #16213e;
|
background: var(--bg-surface);
|
||||||
padding: 20px 0;
|
border-right: 1px solid var(--border-default);
|
||||||
border-right: 1px solid #0f3460;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
}
|
}
|
||||||
.sidebar h1 {
|
|
||||||
color: #e94560;
|
.sidebar-brand {
|
||||||
font-size: 18px;
|
height: 72px;
|
||||||
padding: 0 20px 20px;
|
display: flex;
|
||||||
border-bottom: 1px solid #0f3460;
|
align-items: center;
|
||||||
margin-bottom: 10px;
|
padding: 0 24px;
|
||||||
|
border-bottom: 1px solid var(--border-default);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
gap: 12px;
|
||||||
}
|
}
|
||||||
.sidebar a {
|
|
||||||
display: block;
|
.sidebar-brand i {
|
||||||
color: #a0a0b0;
|
color: var(--brand-primary);
|
||||||
text-decoration: none;
|
|
||||||
padding: 10px 20px;
|
|
||||||
font-size: 14px;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
.sidebar a:hover, .sidebar a.active {
|
|
||||||
background: #0f3460;
|
|
||||||
color: #e94560;
|
|
||||||
}
|
|
||||||
/* Main */
|
|
||||||
.main {
|
|
||||||
flex: 1;
|
|
||||||
padding: 30px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
.page-header {
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
.page-header h2 {
|
|
||||||
color: #e94560;
|
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
}
|
}
|
||||||
/* Cards */
|
|
||||||
.card {
|
.nav-links {
|
||||||
background: #16213e;
|
padding: 24px 16px;
|
||||||
border-radius: 8px;
|
display: flex;
|
||||||
padding: 20px;
|
flex-direction: column;
|
||||||
margin-bottom: 16px;
|
gap: 8px;
|
||||||
border: 1px solid #0f3460;
|
|
||||||
}
|
}
|
||||||
/* Tables */
|
|
||||||
|
.nav-links a {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a i {
|
||||||
|
font-size: 20px;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a:hover {
|
||||||
|
background: var(--bg-surface-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a.active {
|
||||||
|
background: var(--brand-primary-light);
|
||||||
|
color: var(--brand-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main Content Area */
|
||||||
|
.main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0; /* Important for flex-child truncation */
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
height: 72px;
|
||||||
|
background: rgba(11, 15, 25, 0.8);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border-bottom: 1px solid var(--border-default);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 40px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-container {
|
||||||
|
padding: 40px;
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
h2 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
margin: 32px 0 16px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modern Cards */
|
||||||
|
.card {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-subtext {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-subtext.success { color: var(--success-text); }
|
||||||
|
.stat-subtext.danger { color: var(--danger-text); }
|
||||||
|
.stat-subtext.muted { color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* Modern Data Tables */
|
||||||
|
.table-container {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
table {
|
table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
}
|
|
||||||
th {
|
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 12px 16px;
|
|
||||||
background: #0f3460;
|
|
||||||
color: #e94560;
|
|
||||||
font-size: 12px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
}
|
||||||
td {
|
|
||||||
padding: 10px 16px;
|
th {
|
||||||
border-bottom: 1px solid #0f3460;
|
background: var(--bg-surface);
|
||||||
font-size: 14px;
|
padding: 16px 24px;
|
||||||
}
|
|
||||||
tr:hover td {
|
|
||||||
background: rgba(15, 52, 96, 0.3);
|
|
||||||
}
|
|
||||||
/* Badges */
|
|
||||||
.badge {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 3px 10px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-bottom: 1px solid var(--border-default);
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.badge-green { background: #0f9b58; color: #fff; }
|
|
||||||
.badge-red { background: #e94560; color: #fff; }
|
td {
|
||||||
.badge-yellow { background: #f0ad4e; color: #000; }
|
padding: 16px 24px;
|
||||||
/* Error */
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-bottom: 1px solid var(--border-default);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
td strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover td {
|
||||||
|
background: var(--bg-surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Links */
|
||||||
|
a {
|
||||||
|
color: var(--brand-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: var(--brand-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Minimalist Badges */
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-green {
|
||||||
|
background: var(--success-bg);
|
||||||
|
color: var(--success-text);
|
||||||
|
border: 1px solid rgba(52, 211, 153, 0.2);
|
||||||
|
}
|
||||||
|
.badge-red {
|
||||||
|
background: var(--danger-bg);
|
||||||
|
color: var(--danger-text);
|
||||||
|
border: 1px solid rgba(248, 113, 113, 0.2);
|
||||||
|
}
|
||||||
|
.badge-yellow {
|
||||||
|
background: var(--warning-bg);
|
||||||
|
color: var(--warning-text);
|
||||||
|
border: 1px solid rgba(251, 191, 36, 0.2);
|
||||||
|
}
|
||||||
|
.badge-neutral {
|
||||||
|
background: var(--bg-surface-active);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error States */
|
||||||
.error-banner {
|
.error-banner {
|
||||||
background: #e94560;
|
background: var(--danger-bg);
|
||||||
color: #fff;
|
border: 1px solid rgba(248, 113, 113, 0.2);
|
||||||
padding: 16px 20px;
|
color: var(--danger-text);
|
||||||
border-radius: 8px;
|
padding: 16px 24px;
|
||||||
margin-bottom: 20px;
|
border-radius: var(--radius-md);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
/* Link */
|
|
||||||
a { color: #e94560; text-decoration: none; }
|
.empty-state {
|
||||||
a:hover { text-decoration: underline; }
|
padding: 48px;
|
||||||
/* Responsive */
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state i {
|
||||||
|
font-size: 32px;
|
||||||
|
color: var(--border-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.content-container { padding: 24px; }
|
||||||
|
.topbar { padding: 0 24px; }
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
body { flex-direction: column; }
|
body { flex-direction: column; }
|
||||||
.sidebar { width: 100%; border-right: none; border-bottom: 1px solid #0f3460; }
|
.sidebar {
|
||||||
.main { padding: 16px; }
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
position: relative;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--border-default);
|
||||||
|
}
|
||||||
|
.sidebar-brand { justify-content: center; }
|
||||||
|
.nav-links {
|
||||||
|
flex-direction: row;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 16px;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.nav-links a { padding: 8px 16px; white-space: nowrap; }
|
||||||
|
.content-container { padding: 16px; }
|
||||||
|
.topbar { display: none; } /* Hide sticky topbar on mobile */
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="sidebar">
|
<nav class="sidebar">
|
||||||
<h1>Willhaben Tracker</h1>
|
<div class="sidebar-brand">
|
||||||
<a href="/" class="{{ 'active' if request.url.path == '/' else '' }}">Dashboard</a>
|
<i class="ph-fill ph-magnifying-glass"></i>
|
||||||
<a href="/keywords" class="{{ 'active' if 'keywords' in request.url.path else '' }}">Keywords</a>
|
Willhaben Tracker
|
||||||
<a href="/users" class="{{ 'active' if request.url.path == '/users' else '' }}">Users</a>
|
</div>
|
||||||
<a href="/ads" class="{{ 'active' if request.url.path == '/ads' else '' }}">Ads</a>
|
<div class="nav-links">
|
||||||
|
<a href="/" class="{{ 'active' if request.url.path == '/' else '' }}">
|
||||||
|
<i class="ph ph-squares-four"></i> Dashboard
|
||||||
|
</a>
|
||||||
|
<a href="/keywords" class="{{ 'active' if 'keywords' in request.url.path else '' }}">
|
||||||
|
<i class="ph ph-tag"></i> Keywords
|
||||||
|
</a>
|
||||||
|
<a href="/users" class="{{ 'active' if request.url.path == '/users' else '' }}">
|
||||||
|
<i class="ph ph-users"></i> Users
|
||||||
|
</a>
|
||||||
|
<a href="/ads" class="{{ 'active' if request.url.path == '/ads' else '' }}">
|
||||||
|
<i class="ph ph-shopping-bag"></i> Ads
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<main class="main">
|
<main class="main">
|
||||||
{% if error %}
|
<div class="topbar">
|
||||||
<div class="error-banner">{{ error }}</div>
|
<h2>{% block title_in_topbar %}{% endblock %}</h2>
|
||||||
{% endif %}
|
</div>
|
||||||
{% block content %}{% endblock %}
|
<div class="content-container">
|
||||||
|
{% if error %}
|
||||||
|
<div class="error-banner">
|
||||||
|
<i class="ph-fill ph-warning-circle"></i>
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,52 +1,71 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Dashboard — Willhaben Tracker{% endblock %}
|
{% block title %}Dashboard — Willhaben Tracker{% endblock %}
|
||||||
|
{% block title_in_topbar %}Dashboard{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="page-header">
|
{% if data %}
|
||||||
<h2>Dashboard</h2>
|
<h3 style="margin-top: 0;">Overview</h3>
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 24px; margin-bottom: 32px;">
|
||||||
|
|
||||||
|
<div class="card stat-card">
|
||||||
|
<div class="stat-label">Total Keywords</div>
|
||||||
|
<div class="stat-value">{{ data.total_keywords }}</div>
|
||||||
|
<div class="stat-subtext success">
|
||||||
|
<i class="ph-fill ph-check-circle"></i> {{ data.active_keywords }} active
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card stat-card">
|
||||||
|
<div class="stat-label">Total Ads Tracked</div>
|
||||||
|
<div class="stat-value">{{ data.total_ads }}</div>
|
||||||
|
<div class="stat-subtext muted">Across all time</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card stat-card">
|
||||||
|
<div class="stat-label">Active Users</div>
|
||||||
|
<div class="stat-value">{{ data.total_users }}</div>
|
||||||
|
<div class="stat-subtext muted">Subscribed to updates</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card stat-card">
|
||||||
|
<div class="stat-label">Notifications Sent</div>
|
||||||
|
<div class="stat-value">{{ data.notifications_sent }}</div>
|
||||||
|
<div class="stat-subtext muted">Via Telegram</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if data %}
|
<h3>System Health</h3>
|
||||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px;">
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 24px;">
|
||||||
<div class="card">
|
|
||||||
<div style="font-size: 12px; color: #a0a0b0; text-transform: uppercase;">Total Keywords</div>
|
<div class="card stat-card" style="border-left: 4px solid var(--border-hover);">
|
||||||
<div style="font-size: 32px; font-weight: 700; margin-top: 8px;">{{ data.total_keywords }}</div>
|
<div class="stat-label">Last Scheduler Run</div>
|
||||||
<div style="font-size: 12px; color: #0f9b58; margin-top: 4px;">{{ data.active_keywords }} active</div>
|
<div class="stat-value" style="font-size: 24px;">
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<div style="font-size: 12px; color: #a0a0b0; text-transform: uppercase;">Total Ads</div>
|
|
||||||
<div style="font-size: 32px; font-weight: 700; margin-top: 8px;">{{ data.total_ads }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<div style="font-size: 12px; color: #a0a0b0; text-transform: uppercase;">Active Users</div>
|
|
||||||
<div style="font-size: 32px; font-weight: 700; margin-top: 8px;">{{ data.total_users }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<div style="font-size: 12px; color: #a0a0b0; text-transform: uppercase;">Notifications Sent</div>
|
|
||||||
<div style="font-size: 32px; font-weight: 700; margin-top: 8px;">{{ data.notifications_sent }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<div style="font-size: 12px; color: #a0a0b0; text-transform: uppercase;">Queue Pending</div>
|
|
||||||
<div style="font-size: 32px; font-weight: 700; margin-top: 8px;">{{ data.queue_pending }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<div style="font-size: 12px; color: #a0a0b0; text-transform: uppercase;">Queue Dead</div>
|
|
||||||
<div style="font-size: 32px; font-weight: 700; margin-top: 8px; color: #e94560;">{{ data.queue_dead }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<div style="font-size: 12px; color: #a0a0b0; text-transform: uppercase;">Last Scheduler Run</div>
|
|
||||||
<div style="font-size: 16px; font-weight: 700; margin-top: 8px;">
|
|
||||||
{{ data.last_scheduler.strftime('%Y-%m-%d %H:%M:%S') if data.last_scheduler else 'Never' }}
|
{{ data.last_scheduler.strftime('%Y-%m-%d %H:%M:%S') if data.last_scheduler else 'Never' }}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="stat-subtext muted">Worker polling rhythm</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
|
||||||
<div style="font-size: 12px; color: #a0a0b0; text-transform: uppercase;">HTTP Proxy</div>
|
<div class="card stat-card" style="border-left: 4px solid {% if data.proxy_enabled %}var(--success-text){% else %}var(--border-hover){% endif %};">
|
||||||
<div style="font-size: 32px; font-weight: 700; margin-top: 8px; color: {{ '#0f9b58' if data.proxy_enabled else '#e94560' }};">
|
<div class="stat-label">HTTP Proxy</div>
|
||||||
|
<div class="stat-value" style="font-size: 24px; color: {% if data.proxy_enabled %}var(--success-text){% else %}var(--text-primary){% endif %};">
|
||||||
{{ 'Enabled' if data.proxy_enabled else 'Disabled' }}
|
{{ 'Enabled' if data.proxy_enabled else 'Disabled' }}
|
||||||
</div>
|
</div>
|
||||||
<div style="font-size: 12px; color: #a0a0b0; margin-top: 4px;">
|
<div class="stat-subtext muted">
|
||||||
{{ 'Outbound scraping uses the proxy' if data.proxy_enabled else 'Outbound scraping goes direct' }}
|
{{ 'Outbound scraping uses proxy' if data.proxy_enabled else 'Direct connection' }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card stat-card" style="border-left: 4px solid {% if data.queue_dead > 0 %}var(--danger-text){% else %}var(--success-text){% endif %};">
|
||||||
|
<div class="stat-label">Delivery Queue</div>
|
||||||
|
<div class="stat-value" style="font-size: 24px;">
|
||||||
|
{{ data.queue_pending }} pending
|
||||||
|
</div>
|
||||||
|
<div class="stat-subtext {% if data.queue_dead > 0 %}danger{% else %}muted{% endif %}">
|
||||||
|
{{ data.queue_dead }} dead letters
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1,113 +1,153 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}{{ keyword.keyword }} — Willhaben Tracker{% endblock %}
|
{% block title %}{{ keyword.keyword }} — Willhaben Tracker{% endblock %}
|
||||||
{% block content %}
|
{% block title_in_topbar %}
|
||||||
<div class="page-header">
|
<div style="display: flex; align-items: center; gap: 12px;">
|
||||||
<h2>{{ keyword.keyword }}</h2>
|
{{ keyword.keyword }}
|
||||||
|
<span style="font-size: 14px; font-weight: normal; color: var(--text-muted); font-family: monospace;">{{ keyword.id[:8] }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
{% if keyword %}
|
{% if keyword %}
|
||||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin-bottom: 24px;">
|
<h3 style="margin-top: 0;">Configuration</h3>
|
||||||
<div class="card">
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 24px; margin-bottom: 32px;">
|
||||||
<div style="font-size: 12px; color: #a0a0b0; text-transform: uppercase;">Status</div>
|
<div class="card stat-card">
|
||||||
|
<div class="stat-label">Status</div>
|
||||||
<div style="margin-top: 8px;">
|
<div style="margin-top: 8px;">
|
||||||
{% if keyword.is_active %}
|
{% if keyword.is_active %}
|
||||||
<span class="badge badge-green">Active</span>
|
<span class="badge badge-green"><i class="ph-fill ph-play-circle" style="margin-right: 4px;"></i> Active</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge badge-red">Stopped</span>
|
<span class="badge badge-neutral"><i class="ph-fill ph-pause-circle" style="margin-right: 4px;"></i> Stopped</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
|
||||||
<div style="font-size: 12px; color: #a0a0b0; text-transform: uppercase;">Interval</div>
|
<div class="card stat-card">
|
||||||
<div style="font-size: 24px; font-weight: 700; margin-top: 8px;">{{ keyword.interval_minutes }}m</div>
|
<div class="stat-label">Polling Interval</div>
|
||||||
|
<div class="stat-value">{{ keyword.interval_minutes }}m</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
|
||||||
<div style="font-size: 12px; color: #a0a0b0; text-transform: uppercase;">Subscribers</div>
|
<div class="card stat-card">
|
||||||
<div style="font-size: 24px; font-weight: 700; margin-top: 8px;">{{ subscriber_count }}</div>
|
<div class="stat-label">Subscribers</div>
|
||||||
|
<div class="stat-value">{{ subscriber_count }}</div>
|
||||||
|
<div class="stat-subtext muted">Users tracking this</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
|
||||||
<div style="font-size: 12px; color: #a0a0b0; text-transform: uppercase;">Price Range</div>
|
<div class="card stat-card">
|
||||||
<div style="font-size: 16px; font-weight: 700; margin-top: 8px;">
|
<div class="stat-label">Price Filters</div>
|
||||||
{% if keyword.price_min %}€{{ (keyword.price_min / 100)|round(2) }}{% else %}No min{% endif %}
|
<div class="stat-value" style="font-size: 20px; line-height: 1.6;">
|
||||||
—
|
{% if keyword.price_min %}€{{ (keyword.price_min / 100)|round(2) }}{% else %}0{% endif %}
|
||||||
{% if keyword.price_max %}€{{ (keyword.price_max / 100)|round(2) }}{% else %}No max{% endif %}
|
<span style="color: var(--text-muted); font-weight: 400; padding: 0 4px;">to</span>
|
||||||
|
{% if keyword.price_max %}€{{ (keyword.price_max / 100)|round(2) }}{% else %}Max{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
|
||||||
<div style="font-size: 12px; color: #a0a0b0; text-transform: uppercase;">Postcodes</div>
|
<div class="card stat-card">
|
||||||
<div style="font-size: 16px; font-weight: 700; margin-top: 8px;">
|
<div class="stat-label">Postcodes Filter</div>
|
||||||
{{ ', '.join(keyword.allowed_postcodes) if keyword.allowed_postcodes else 'All' }}
|
<div class="stat-value" style="font-size: 20px; line-height: 1.6;">
|
||||||
|
{{ ', '.join(keyword.allowed_postcodes) if keyword.allowed_postcodes else 'Any' }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
|
||||||
<div style="font-size: 12px; color: #a0a0b0; text-transform: uppercase;">Last Scraped</div>
|
<div class="card stat-card">
|
||||||
<div style="font-size: 16px; font-weight: 700; margin-top: 8px;">
|
<div class="stat-label">Last Successful Scrape</div>
|
||||||
{{ keyword.last_scraped_at.strftime('%Y-%m-%d %H:%M') if keyword.last_scraped_at else 'Never' }}
|
<div class="stat-value" style="font-size: 20px; line-height: 1.6;">
|
||||||
|
{% if keyword.last_scraped_at %}
|
||||||
|
{{ keyword.last_scraped_at.strftime('%m/%d %H:%M') }}
|
||||||
|
{% else %}
|
||||||
|
<span class="muted">Never</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 style="color: #e94560; margin-bottom: 12px;">Recent Ads</h3>
|
<h3>Recent Matches</h3>
|
||||||
{% if ads %}
|
{% if ads %}
|
||||||
<div class="card" style="overflow-x: auto;">
|
<div class="table-container" style="margin-bottom: 32px;">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Title</th>
|
<th>Ad</th>
|
||||||
<th>Price</th>
|
|
||||||
<th>Location</th>
|
<th>Location</th>
|
||||||
<th>Postcode</th>
|
<th>Link</th>
|
||||||
<th>URL</th>
|
|
||||||
<th>Published</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for ad in ads %}
|
{% for ad in ads %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ ad.title[:60] }}{% if ad.title|length > 60 %}…{% endif %}</td>
|
<td style="max-width: 400px; white-space: normal;">
|
||||||
<td>{{ format_price(ad.price) }}</td>
|
<div style="font-weight: 500; margin-bottom: 4px; line-height: 1.4;">
|
||||||
<td>{{ ad.location or '—' }}</td>
|
{{ ad.title }}
|
||||||
<td>{{ ad.postcode or '—' }}</td>
|
</div>
|
||||||
<td><a href="{{ ad.url }}" target="_blank">Link</a></td>
|
<div style="display: flex; gap: 12px; font-size: 13px;">
|
||||||
<td>{{ ad.published_at.strftime('%Y-%m-%d') if ad.published_at else '—' }}</td>
|
<span style="font-weight: 600; color: var(--success-text);">{{ format_price(ad.price) }}</span>
|
||||||
|
{% if ad.published_at %}
|
||||||
|
<span style="color: var(--text-muted);">
|
||||||
|
<i class="ph ph-calendar-blank" style="vertical-align: middle;"></i>
|
||||||
|
{{ ad.published_at.strftime('%m/%d %H:%M') }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div style="display: flex; align-items: center; gap: 4px;">
|
||||||
|
<i class="ph ph-map-pin" style="color: var(--text-muted);"></i>
|
||||||
|
{% if ad.postcode %}{{ ad.postcode }} • {% endif %}
|
||||||
|
{{ ad.location or '—' }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="{{ ad.url }}" target="_blank" style="display: inline-flex; align-items: center; gap: 4px; padding: 6px 12px; background: var(--bg-surface-active); border-radius: var(--radius-md); font-size: 13px; font-weight: 500;">
|
||||||
|
Open <i class="ph-bold ph-arrow-up-right"></i>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="card"><em>No ads found for this keyword.</em></div>
|
<div class="empty-state" style="margin-bottom: 32px; background: var(--bg-surface); border: 1px solid var(--border-default); border-radius: var(--radius-lg);">
|
||||||
|
<i class="ph ph-shopping-bag"></i>
|
||||||
|
<p>No ads found for this keyword yet.</p>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<h3 style="color: #e94560; margin: 24px 0 12px;">Recent Scrape Logs</h3>
|
<h3>Scrape Telemetry</h3>
|
||||||
{% if logs %}
|
{% if logs %}
|
||||||
<div class="card" style="overflow-x: auto;">
|
<div class="table-container">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Time</th>
|
<th>Execution Time</th>
|
||||||
<th>Status</th>
|
<th>Result</th>
|
||||||
<th>Ads Found</th>
|
<th>Throughput</th>
|
||||||
<th>New Ads</th>
|
<th>Diagnostics</th>
|
||||||
<th>Error</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for log in logs %}
|
{% for log in logs %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ log.scraped_at.strftime('%Y-%m-%d %H:%M:%S') }}</td>
|
<td>
|
||||||
|
<div style="font-weight: 500;">{{ log.scraped_at.strftime('%Y-%m-%d') }}</div>
|
||||||
|
<div style="font-size: 13px; color: var(--text-muted); margin-top: 2px;">{{ log.scraped_at.strftime('%H:%M:%S') }}</div>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if log.status == 'success' %}
|
{% if log.status == 'success' %}
|
||||||
<span class="badge badge-green">Success</span>
|
<span class="badge badge-green"><i class="ph-fill ph-check-circle" style="margin-right: 4px;"></i> Success</span>
|
||||||
{% elif log.status == 'rate_limited' %}
|
{% elif log.status == 'rate_limited' %}
|
||||||
<span class="badge badge-yellow">Rate Limited</span>
|
<span class="badge badge-yellow"><i class="ph-fill ph-warning" style="margin-right: 4px;"></i> Blocked</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge badge-red">Error</span>
|
<span class="badge badge-red"><i class="ph-fill ph-x-circle" style="margin-right: 4px;"></i> Error</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>{{ log.ads_found }}</td>
|
<td>
|
||||||
<td>{{ log.new_ads }}</td>
|
<div style="display: flex; gap: 16px;">
|
||||||
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
|
<span><strong>{{ log.ads_found }}</strong> <span style="color: var(--text-muted); font-size: 13px;">scanned</span></span>
|
||||||
|
<span><strong>{{ log.new_ads }}</strong> <span style="color: var(--text-muted); font-size: 13px;">new</span></span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style="max-width: 300px; white-space: normal; font-size: 13px; font-family: monospace; color: var(--danger-text);">
|
||||||
{{ log.error_message or '—' }}
|
{{ log.error_message or '—' }}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -116,7 +156,10 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="card"><em>No scrape logs found.</em></div>
|
<div class="empty-state" style="background: var(--bg-surface); border: 1px solid var(--border-default); border-radius: var(--radius-lg);">
|
||||||
|
<i class="ph ph-terminal-window"></i>
|
||||||
|
<p>No telemetry recorded yet.</p>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1,22 +1,17 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Keywords — Willhaben Tracker{% endblock %}
|
{% block title %}Keywords — Willhaben Tracker{% endblock %}
|
||||||
{% block content %}
|
{% block title_in_topbar %}Keywords{% endblock %}
|
||||||
<div class="page-header">
|
|
||||||
<h2>Keywords</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
{% if not error and keywords %}
|
{% if not error and keywords %}
|
||||||
<div class="card" style="overflow-x: auto;">
|
<div class="table-container">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
|
||||||
<th>Keyword</th>
|
<th>Keyword</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Interval</th>
|
<th>Interval</th>
|
||||||
<th>Price Min</th>
|
<th>Filters</th>
|
||||||
<th>Price Max</th>
|
|
||||||
<th>Postcodes</th>
|
|
||||||
<th>Subscribers</th>
|
<th>Subscribers</th>
|
||||||
<th>Last Scraped</th>
|
<th>Last Scraped</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -24,25 +19,41 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for kw in keywords %}
|
{% for kw in keywords %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="/keywords/{{ kw.id }}">{{ kw.id[:8] }}…</a></td>
|
<td>
|
||||||
<td><strong>{{ kw.keyword }}</strong></td>
|
<a href="/keywords/{{ kw.id }}" style="font-weight: 600;">{{ kw.keyword }}</a>
|
||||||
|
<div style="font-size: 12px; color: var(--text-muted); margin-top: 4px; font-family: monospace;">{{ kw.id[:8] }}</div>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if kw.is_active %}
|
{% if kw.is_active %}
|
||||||
<span class="badge badge-green">Active</span>
|
<span class="badge badge-green"><i class="ph-fill ph-play-circle" style="margin-right: 4px;"></i> Active</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge badge-red">Stopped</span>
|
<span class="badge badge-neutral"><i class="ph-fill ph-pause-circle" style="margin-right: 4px;"></i> Stopped</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>{{ kw.interval_minutes }}m</td>
|
<td>{{ kw.interval_minutes }}m</td>
|
||||||
<td>{{ (kw.price_min / 100)|round(2) if kw.price_min else '—' }}</td>
|
<td style="font-size: 13px;">
|
||||||
<td>{{ (kw.price_max / 100)|round(2) if kw.price_max else '—' }}</td>
|
{% if kw.price_min or kw.price_max %}
|
||||||
<td>{{ ', '.join(kw.allowed_postcodes) if kw.allowed_postcodes else '—' }}</td>
|
<div style="margin-bottom: 2px;">
|
||||||
<td>{{ kw.subscriber_count }}</td>
|
<i class="ph ph-currency-eur" style="color: var(--text-muted); vertical-align: middle;"></i>
|
||||||
|
{{ (kw.price_min / 100)|round(2) if kw.price_min else '0' }} — {{ (kw.price_max / 100)|round(2) if kw.price_max else 'Max' }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if kw.allowed_postcodes %}
|
||||||
|
<div>
|
||||||
|
<i class="ph ph-map-pin" style="color: var(--text-muted); vertical-align: middle;"></i>
|
||||||
|
{{ kw.allowed_postcodes|length }} code(s)
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if not kw.price_min and not kw.price_max and not kw.allowed_postcodes %}
|
||||||
|
<span style="color: var(--text-muted);">None</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td><strong>{{ kw.subscriber_count }}</strong> <i class="ph-fill ph-users" style="color: var(--text-muted); font-size: 12px;"></i></td>
|
||||||
<td>
|
<td>
|
||||||
{% if kw.last_scraped_at %}
|
{% if kw.last_scraped_at %}
|
||||||
{{ kw.last_scraped_at.strftime('%Y-%m-%d %H:%M') }}
|
{{ kw.last_scraped_at.strftime('%Y-%m-%d %H:%M') }}
|
||||||
{% else %}
|
{% else %}
|
||||||
Never
|
<span style="color: var(--text-muted);">Never</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -51,6 +62,9 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{% elif not error %}
|
{% elif not error %}
|
||||||
<div class="card"><em>No keywords found.</em></div>
|
<div class="empty-state">
|
||||||
|
<i class="ph ph-tag"></i>
|
||||||
|
<p>No keywords are currently being tracked.</p>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1,66 +1,75 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Users — Willhaben Tracker{% endblock %}
|
{% block title %}Users — Willhaben Tracker{% endblock %}
|
||||||
{% block content %}
|
{% block title_in_topbar %}Registered Users{% endblock %}
|
||||||
<div class="page-header">
|
|
||||||
<h2>Users</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
{% if not error and users %}
|
{% if not error and users %}
|
||||||
<div class="card" style="overflow-x: auto;">
|
<div class="table-container">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Telegram ID</th>
|
<th>User</th>
|
||||||
<th>Username</th>
|
|
||||||
<th>First Name</th>
|
|
||||||
<th>Admin</th>
|
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Mute Hours</th>
|
<th>Role</th>
|
||||||
<th>Digest Mode</th>
|
<th>Mute Schedule</th>
|
||||||
<th>Digest Interval</th>
|
<th>Delivery Mode</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for user in users %}
|
{% for user in users %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ user.telegram_id }}</td>
|
|
||||||
<td>@{{ user.username }}{% if not user.username %}—{% endif %}</td>
|
|
||||||
<td>{{ user.first_name or '—' }}</td>
|
|
||||||
<td>
|
<td>
|
||||||
{% if user.is_admin %}
|
<div style="font-weight: 600;">{{ user.first_name or 'Unknown User' }}</div>
|
||||||
<span class="badge badge-yellow">Admin</span>
|
<div style="font-size: 13px; color: var(--text-muted); margin-top: 4px;">
|
||||||
{% else %}
|
{% if user.username %}@{{ user.username }} • {% endif %}{{ user.telegram_id }}
|
||||||
<span class="badge" style="background: #555; color: #fff;">User</span>
|
</div>
|
||||||
{% endif %}
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if user.is_active %}
|
{% if user.is_active %}
|
||||||
<span class="badge badge-green">Active</span>
|
<span class="badge badge-green"><i class="ph-fill ph-check-circle" style="margin-right: 4px;"></i> Active</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge badge-red">Inactive</span>
|
<span class="badge badge-red"><i class="ph-fill ph-x-circle" style="margin-right: 4px;"></i> Banned</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if user.is_admin %}
|
||||||
|
<span class="badge badge-yellow"><i class="ph-fill ph-shield-star" style="margin-right: 4px;"></i> Admin</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-neutral"><i class="ph-fill ph-user" style="margin-right: 4px;"></i> User</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if user.mute_start and user.mute_end %}
|
{% if user.mute_start and user.mute_end %}
|
||||||
{{ user.mute_start.strftime('%H:%M') }} — {{ user.mute_end.strftime('%H:%M') }}
|
<div style="display: flex; align-items: center; gap: 6px;">
|
||||||
|
<i class="ph ph-moon" style="color: var(--text-muted);"></i>
|
||||||
|
<span>{{ user.mute_start.strftime('%H:%M') }} — {{ user.mute_end.strftime('%H:%M') }}</span>
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
—
|
<span style="color: var(--text-muted);">None</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if user.digest_mode %}
|
{% if user.digest_mode %}
|
||||||
<span class="badge badge-green">On</span>
|
<div style="display: flex; align-items: center; gap: 6px;">
|
||||||
|
<i class="ph-fill ph-envelope-simple" style="color: var(--success-text);"></i>
|
||||||
|
<span>Digest every {{ user.digest_interval }}m</span>
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge" style="background: #555; color: #fff;">Off</span>
|
<div style="display: flex; align-items: center; gap: 6px;">
|
||||||
|
<i class="ph-fill ph-lightning" style="color: var(--brand-primary);"></i>
|
||||||
|
<span>Immediate</span>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>{{ user.digest_interval }}m</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{% elif not error %}
|
{% elif not error %}
|
||||||
<div class="card"><em>No users found.</em></div>
|
<div class="empty-state">
|
||||||
|
<i class="ph ph-users"></i>
|
||||||
|
<p>No users found in the database.</p>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user