Add sidenav layout for authenticated dashboard

- Create dashboard_base.html: standalone app shell with 56px sticky
  header (logo + user email + sign out), 220px left sidebar with
  Overview/Countries/Settings nav items (SVG icons, active state via
  request.path), and fixed mobile bottom tab bar (md:hidden)
- Add CSS component classes: .app-shell, .app-header, .app-sidebar,
  .sidebar-item, .app-content, .mobile-bottom-nav, .mobile-nav-item
- Extract feedback widget into _feedback_widget.html partial; include
  from both base.html and dashboard_base.html
- Switch index.html, countries.html, settings.html to extend
  dashboard_base.html; remove <main class="container-page"> wrappers
- Remove "Back to Dashboard" button from countries.html (sidebar
  provides persistent navigation)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-21 01:15:25 +01:00
parent 4dcf1e7e84
commit 1a39082514
7 changed files with 350 additions and 96 deletions

View File

@@ -1,4 +1,4 @@
{% extends "base.html" %} {% extends "dashboard_base.html" %}
{% block title %}Country Comparison — {{ config.APP_NAME }}{% endblock %} {% block title %}Country Comparison — {{ config.APP_NAME }}{% endblock %}
@@ -7,7 +7,6 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<main class="container-page py-8">
<!-- Page Header --> <!-- Page Header -->
<div class="page-header"> <div class="page-header">
<h1>Country Comparison</h1> <h1>Country Comparison</h1>
@@ -51,8 +50,6 @@
</div> </div>
{% endif %} {% endif %}
<a href="{{ url_for('dashboard.index') }}" class="btn-outline">Back to Dashboard</a>
</main>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}

View File

@@ -0,0 +1,130 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{{ config.APP_NAME }}{% endblock %}</title>
<link rel="icon" href="{{ url_for('static', filename='favicon.svg') }}" type="image/svg+xml">
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=Fraunces:ital,opsz,wght@0,9..144,100..900;1,9..144,100..900&display=swap" rel="stylesheet">
<!-- Tailwind CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/output.css') }}">
{% block head %}{% endblock %}
</head>
<body class="bg-cream">
<!-- ── App Shell ── -->
<div class="app-shell">
<!-- Slim App Header -->
<header class="app-header">
<a href="{{ url_for('public.landing') }}" class="nav-logo">{{ config.APP_NAME }}</a>
<div class="app-header-right">
{% if session.get('is_admin') %}
<a href="{{ url_for('admin.index') }}" class="nav-badge hidden sm:inline">Admin</a>
{% endif %}
<span class="app-header-email">{{ user.email }}</span>
<form method="post" action="{{ url_for('auth.logout') }}" class="nav-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline btn-sm">Sign Out</button>
</form>
</div>
</header>
<!-- Body: sidebar + content -->
<div class="app-body">
<!-- ── Sidebar (desktop) ── -->
<nav class="app-sidebar" aria-label="Main navigation">
<a href="{{ url_for('dashboard.index') }}"
class="sidebar-item{% if request.path == '/dashboard/' %} active{% endif %}">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24" aria-hidden="true">
<rect x="3" y="3" width="7" height="7" rx="1.5"/>
<rect x="14" y="3" width="7" height="7" rx="1.5"/>
<rect x="3" y="14" width="7" height="7" rx="1.5"/>
<rect x="14" y="14" width="7" height="7" rx="1.5"/>
</svg>
Overview
</a>
<a href="{{ url_for('dashboard.countries') }}"
class="sidebar-item{% if request.path.startswith('/dashboard/countries') %} active{% endif %}">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="9"/>
<path d="M3 12h18"/>
<path d="M12 3c-3.5 4.5-3.5 13.5 0 18"/>
<path d="M12 3c3.5 4.5 3.5 13.5 0 18"/>
</svg>
Countries
</a>
<a href="{{ url_for('dashboard.settings') }}"
class="sidebar-item{% if request.path.startswith('/dashboard/settings') %} active{% endif %}">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="3"/>
<path d="M12 2v2M12 20v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M2 12h2M20 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
</svg>
Settings
</a>
</nav>
<!-- ── Main Content ── -->
<main class="app-content">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="{% if category == 'error' %}flash-error{% elif category == 'success' %}flash-success{% elif category == 'warning' %}flash-warning{% else %}flash{% endif %} mb-4">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
</div>
</div>
<!-- ── Mobile Bottom Nav ── -->
<nav class="mobile-bottom-nav" aria-label="Mobile navigation">
<a href="{{ url_for('dashboard.index') }}"
class="mobile-nav-item{% if request.path == '/dashboard/' %} active{% endif %}">
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24">
<rect x="3" y="3" width="7" height="7" rx="1.5"/>
<rect x="14" y="3" width="7" height="7" rx="1.5"/>
<rect x="3" y="14" width="7" height="7" rx="1.5"/>
<rect x="14" y="14" width="7" height="7" rx="1.5"/>
</svg>
<span>Overview</span>
</a>
<a href="{{ url_for('dashboard.countries') }}"
class="mobile-nav-item{% if request.path.startswith('/dashboard/countries') %} active{% endif %}">
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="9"/>
<path d="M3 12h18"/>
<path d="M12 3c-3.5 4.5-3.5 13.5 0 18"/>
<path d="M12 3c3.5 4.5 3.5 13.5 0 18"/>
</svg>
<span>Countries</span>
</a>
<a href="{{ url_for('dashboard.settings') }}"
class="mobile-nav-item{% if request.path.startswith('/dashboard/settings') %} active{% endif %}">
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="3"/>
<path d="M12 2v2M12 20v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M2 12h2M20 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
</svg>
<span>Settings</span>
</a>
</nav>
<!-- HTMX -->
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
{% include "_feedback_widget.html" %}
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -1,4 +1,4 @@
{% extends "base.html" %} {% extends "dashboard_base.html" %}
{% block title %}Dashboard — {{ config.APP_NAME }}{% endblock %} {% block title %}Dashboard — {{ config.APP_NAME }}{% endblock %}
@@ -7,7 +7,6 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<main class="container-page py-8">
<!-- Page Header --> <!-- Page Header -->
<div class="page-header"> <div class="page-header">
<h1>Coffee Dashboard</h1> <h1>Coffee Dashboard</h1>
@@ -154,7 +153,6 @@
<a href="{{ url_for('dashboard.settings') }}" class="btn-outline text-center">Settings</a> <a href="{{ url_for('dashboard.settings') }}" class="btn-outline text-center">Settings</a>
<a href="{{ url_for('dashboard.settings') }}#api-keys" class="btn-outline text-center">API Keys</a> <a href="{{ url_for('dashboard.settings') }}#api-keys" class="btn-outline text-center">API Keys</a>
</div> </div>
</main>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}

View File

@@ -1,9 +1,8 @@
{% extends "base.html" %} {% extends "dashboard_base.html" %}
{% block title %}Settings — {{ config.APP_NAME }}{% endblock %} {% block title %}Settings — {{ config.APP_NAME }}{% endblock %}
{% block content %} {% block content %}
<main class="container-page py-8">
<!-- Page Header --> <!-- Page Header -->
<div class="page-header"> <div class="page-header">
<h1>Settings</h1> <h1>Settings</h1>
@@ -153,5 +152,4 @@
</div> </div>
</details> </details>
</div> </div>
</main>
{% endblock %} {% endblock %}

View File

@@ -153,6 +153,136 @@
@apply max-w-6xl mx-auto px-4 sm:px-6 lg:px-8; @apply max-w-6xl mx-auto px-4 sm:px-6 lg:px-8;
} }
/* ── App Shell (authenticated dashboard layout) ── */
.app-shell {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.app-header {
position: sticky;
top: 0;
z-index: 50;
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1.5rem;
background: rgba(255, 251, 245, 0.92);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border-bottom: 1px solid rgba(232, 223, 213, 0.7);
flex-shrink: 0;
}
.app-header-right {
display: flex;
align-items: center;
gap: 1rem;
}
.app-header-email {
font-size: 0.8125rem;
color: var(--color-stone);
display: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
}
@media (min-width: 640px) {
.app-header-email { display: block; }
}
.app-body {
display: flex;
flex: 1;
min-height: 0;
}
.app-sidebar {
width: 220px;
flex-shrink: 0;
padding: 1.25rem 0.75rem;
border-right: 1px solid var(--color-parchment);
position: sticky;
top: 56px;
height: calc(100vh - 56px);
overflow-y: auto;
display: none;
}
@media (min-width: 768px) {
.app-sidebar { display: block; }
}
.sidebar-item {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.5rem 0.75rem;
border-radius: 0.625rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-stone-dark);
text-decoration: none;
transition: background 0.12s, color 0.12s;
margin-bottom: 2px;
}
.sidebar-item:hover {
background: var(--color-latte);
color: var(--color-espresso);
}
.sidebar-item.active {
background: rgba(180, 83, 9, 0.08);
color: var(--color-copper);
font-weight: 600;
}
.sidebar-item.active svg {
stroke: var(--color-copper);
}
.app-content {
flex: 1;
min-width: 0;
padding: 2rem 2rem 5rem;
}
@media (min-width: 768px) {
.app-content { padding: 2rem 2.5rem 2rem; }
}
/* ── Mobile bottom navigation ── */
.mobile-bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 56px;
display: flex;
align-items: stretch;
background: rgba(255, 251, 245, 0.97);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border-top: 1px solid rgba(232, 223, 213, 0.7);
z-index: 50;
}
@media (min-width: 768px) {
.mobile-bottom-nav { display: none; }
}
.mobile-nav-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
font-size: 0.6875rem;
font-weight: 500;
color: var(--color-stone);
text-decoration: none;
transition: color 0.12s;
}
.mobile-nav-item:hover,
.mobile-nav-item.active {
color: var(--color-copper);
}
.mobile-nav-item.active svg {
stroke: var(--color-copper);
}
/* ── Display headings (Fraunces serif) ── */ /* ── Display headings (Fraunces serif) ── */
.heading-display { .heading-display {
font-family: var(--font-display); font-family: var(--font-display);

View File

@@ -0,0 +1,86 @@
<!-- Feedback Widget -->
<div id="feedback-widget" style="position:fixed;bottom:20px;right:20px;z-index:100;">
<button
id="feedback-btn"
onclick="document.getElementById('feedback-popover').classList.toggle('open')"
style="
background:#2C1810;color:#fff;border:none;border-radius:20px;
padding:8px 16px;font-size:13px;font-weight:600;cursor:pointer;
box-shadow:0 4px 12px rgba(0,0,0,0.2);display:flex;align-items:center;gap:6px;
transition:background 0.15s;
"
onmouseover="this.style.background='#4A2C1A'"
onmouseout="this.style.background='#2C1810'"
>
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
Feedback
</button>
<div
id="feedback-popover"
style="
display:none;position:absolute;bottom:44px;right:0;
width:300px;background:#fff;border:1px solid #E8DFD5;
border-radius:10px;box-shadow:0 8px 30px rgba(0,0,0,0.12);
padding:16px;
"
>
<p style="font-size:14px;font-weight:600;color:#2C1810;margin:0 0 4px;">Share feedback</p>
<p style="font-size:12px;color:#78716C;margin:0 0 12px;">What's on your mind?</p>
<form
hx-post="{{ url_for('public.feedback') }}"
hx-target="#feedback-result"
hx-swap="innerHTML"
hx-on::after-request="this.reset()"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="page_url" id="feedback-page-url" value="">
<textarea
name="message"
placeholder="Tell us what you think..."
required
maxlength="2000"
style="
width:100%;box-sizing:border-box;resize:vertical;min-height:80px;
border:1px solid #E8DFD5;border-radius:6px;padding:8px 10px;
font-size:13px;color:#2C1810;outline:none;font-family:inherit;
transition:border-color 0.15s;
"
onfocus="this.style.borderColor='#B45309'"
onblur="this.style.borderColor='#E8DFD5'"
></textarea>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:8px;">
<div id="feedback-result" style="font-size:12px;"></div>
<button
type="submit"
style="
background:#B45309;color:#fff;border:none;border-radius:6px;
padding:6px 14px;font-size:13px;font-weight:600;cursor:pointer;
transition:background 0.15s;flex-shrink:0;
"
onmouseover="this.style.background='#92400E'"
onmouseout="this.style.background='#B45309'"
>Send</button>
</div>
</form>
</div>
</div>
<style>
#feedback-popover.open { display: block; }
</style>
<script>
var urlInput = document.getElementById('feedback-page-url');
if (urlInput) urlInput.value = window.location.href;
document.addEventListener('click', function(e) {
var widget = document.getElementById('feedback-widget');
if (widget && !widget.contains(e.target)) {
var popover = document.getElementById('feedback-popover');
if (popover) popover.classList.remove('open');
}
});
</script>

View File

@@ -96,92 +96,7 @@
<!-- HTMX --> <!-- HTMX -->
<script src="https://unpkg.com/htmx.org@2.0.4"></script> <script src="https://unpkg.com/htmx.org@2.0.4"></script>
<!-- Feedback Widget --> {% include "_feedback_widget.html" %}
<div id="feedback-widget" style="position:fixed;bottom:20px;right:20px;z-index:100;">
<button
id="feedback-btn"
onclick="document.getElementById('feedback-popover').classList.toggle('open')"
style="
background:#2C1810;color:#fff;border:none;border-radius:20px;
padding:8px 16px;font-size:13px;font-weight:600;cursor:pointer;
box-shadow:0 4px 12px rgba(0,0,0,0.2);display:flex;align-items:center;gap:6px;
transition:background 0.15s;
"
onmouseover="this.style.background='#4A2C1A'"
onmouseout="this.style.background='#2C1810'"
>
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
Feedback
</button>
<div
id="feedback-popover"
style="
display:none;position:absolute;bottom:44px;right:0;
width:300px;background:#fff;border:1px solid #E8DFD5;
border-radius:10px;box-shadow:0 8px 30px rgba(0,0,0,0.12);
padding:16px;
"
>
<p style="font-size:14px;font-weight:600;color:#2C1810;margin:0 0 4px;">Share feedback</p>
<p style="font-size:12px;color:#78716C;margin:0 0 12px;">What's on your mind?</p>
<form
hx-post="{{ url_for('public.feedback') }}"
hx-target="#feedback-result"
hx-swap="innerHTML"
hx-on::after-request="this.reset()"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="page_url" id="feedback-page-url" value="">
<textarea
name="message"
placeholder="Tell us what you think..."
required
maxlength="2000"
style="
width:100%;box-sizing:border-box;resize:vertical;min-height:80px;
border:1px solid #E8DFD5;border-radius:6px;padding:8px 10px;
font-size:13px;color:#2C1810;outline:none;font-family:inherit;
transition:border-color 0.15s;
"
onfocus="this.style.borderColor='#B45309'"
onblur="this.style.borderColor='#E8DFD5'"
></textarea>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:8px;">
<div id="feedback-result" style="font-size:12px;"></div>
<button
type="submit"
style="
background:#B45309;color:#fff;border:none;border-radius:6px;
padding:6px 14px;font-size:13px;font-weight:600;cursor:pointer;
transition:background 0.15s;flex-shrink:0;
"
onmouseover="this.style.background='#92400E'"
onmouseout="this.style.background='#B45309'"
>Send</button>
</div>
</form>
</div>
</div>
<style>
#feedback-popover.open { display: block; }
</style>
<script>
var urlInput = document.getElementById('feedback-page-url');
if (urlInput) urlInput.value = window.location.href;
document.addEventListener('click', function(e) {
var widget = document.getElementById('feedback-widget');
if (widget && !widget.contains(e.target)) {
var popover = document.getElementById('feedback-popover');
if (popover) popover.classList.remove('open');
}
});
</script>
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
</body> </body>