Files
TimeTracker/app/templates/partials/_bottom_nav.html
T
Dries Peeters b4e0b69796 feat(web): mobile bottom navigation with More drawer
Add an authenticated-only bottom bar below the md breakpoint with
Heroicon-style tabs for Dashboard, Timer, Time entries, Projects, and
More. More opens a slide-up sheet (backdrop, close, Escape) for
Invoices, Clients, Reports, and user Settings, gated by module flags
where applicable.

Align shell layout to Tailwind md (768px): sidebar hidden md:flex,
main md:ml-64 / md:ml-16 when collapsed, mobile hamburger md:hidden,
RTL mainContent margin reset at 767px. Main column uses pb-16 on
small screens so content clears the bar; bar and sheet use pb-safe
(env safe-area) with a Tailwind safelist and @layer utilities rule.

Remove the legacy six-slot FAB bottom nav from base.html.

Docs: README UI overview, CHANGELOG [Unreleased], UI_GUIDELINES layout
and file reference.
2026-04-26 09:16:51 +02:00

92 lines
12 KiB
HTML

{% if current_user.is_authenticated %}
{% set ep = request.endpoint or '' %}
{% set timer_tab_active = ep.startswith('timer.') and ep != 'timer.time_entries_overview' %}
{% set reports_in_more = ep.startswith('reports.') and not ep.startswith('scheduled_reports.') and not ep.startswith('custom_reports.') %}
{% set more_tab_active = ep.startswith('invoices.') or ep.startswith('clients.') or reports_in_more or ep.startswith('user.settings') %}
{# Backdrop + drawer sit above the tab bar so taps dismiss correctly #}
<div id="bottomNavMoreBackdrop" class="hidden fixed inset-0 z-[55] bg-black/40 md:hidden" aria-hidden="true"></div>
<section
id="bottomNavMorePanel"
class="bottom-nav-more-panel pointer-events-none fixed inset-x-0 bottom-0 z-[60] flex max-h-[min(85vh,32rem)] translate-y-full transform flex-col rounded-t-2xl border border-border-light dark:border-border-dark bg-card-light dark:bg-card-dark pb-safe shadow-2xl transition-transform duration-300 ease-out md:hidden"
role="dialog"
aria-modal="true"
aria-labelledby="bottomNavMoreTitle"
aria-hidden="true"
>
<div class="flex items-center justify-between gap-3 px-4 pt-4 pb-2 border-b border-border-light dark:border-border-dark shrink-0">
<h2 id="bottomNavMoreTitle" class="text-base font-semibold text-text-light dark:text-text-dark">{{ _('More') }}</h2>
<button type="button" id="bottomNavMoreClose" class="p-2 rounded-lg text-text-muted-light dark:text-text-muted-dark hover:bg-background-light dark:hover:bg-background-dark" aria-label="{{ _('Close') }}">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
<nav class="overflow-y-auto px-3 py-3 space-y-1" aria-label="{{ _('More navigation') }}">
{% if is_module_enabled('invoices') %}
<a href="{{ url_for('invoices.list_invoices') }}" class="bottom-nav-more-link flex items-center gap-3 min-h-[44px] px-3 rounded-xl text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark {% if ep.startswith('invoices.') %}text-primary font-semibold bg-primary/5 dark:bg-primary/10{% endif %}">
<svg class="w-6 h-6 shrink-0 text-current opacity-80" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
</svg>
<span>{{ _('Invoices') }}</span>
</a>
{% endif %}
{% if is_module_enabled('clients') %}
<a href="{{ url_for('clients.list_clients') }}" class="bottom-nav-more-link flex items-center gap-3 min-h-[44px] px-3 rounded-xl text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark {% if ep.startswith('clients.') %}text-primary font-semibold bg-primary/5 dark:bg-primary/10{% endif %}">
<svg class="w-6 h-6 shrink-0 text-current opacity-80" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.433-2.582M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" />
</svg>
<span>{{ _('Clients') }}</span>
</a>
{% endif %}
{% if is_module_enabled('reports') %}
<a href="{{ url_for('reports.reports') }}" class="bottom-nav-more-link flex items-center gap-3 min-h-[44px] px-3 rounded-xl text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark {% if reports_in_more %}text-primary font-semibold bg-primary/5 dark:bg-primary/10{% endif %}">
<svg class="w-6 h-6 shrink-0 text-current opacity-80" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" />
</svg>
<span>{{ _('Reports') }}</span>
</a>
{% endif %}
<a href="{{ url_for('user.settings') }}" class="bottom-nav-more-link flex items-center gap-3 min-h-[44px] px-3 rounded-xl text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark {% if ep.startswith('user.settings') %}text-primary font-semibold bg-primary/5 dark:bg-primary/10{% endif %}">
<svg class="w-6 h-6 shrink-0 text-current opacity-80" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.372.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.075-.124l-1.217.456a1.125 1.125 0 0 1-1.372-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281Z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
<span>{{ _('Settings') }}</span>
</a>
</nav>
</section>
<nav class="mobile-bottom-nav fixed bottom-0 inset-x-0 z-50 flex md:hidden w-full min-h-[52px] items-stretch border-t border-border-light dark:border-border-dark bg-card-light dark:bg-card-dark pb-safe shadow-[0_-2px_10px_rgba(0,0,0,0.06)] dark:shadow-[0_-2px_10px_rgba(0,0,0,0.2)]" role="navigation" aria-label="{{ _('Main') }}">
<a href="{{ url_for('main.dashboard') }}" class="relative flex min-h-[44px] min-w-0 flex-1 flex-col items-center justify-center px-0.5 py-1.5 text-center transition-colors {% if ep == 'main.dashboard' %}bg-primary/10 font-medium text-primary dark:bg-primary/20{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" aria-label="{{ _('Dashboard') }}" {% if ep == 'main.dashboard' %}aria-current="page"{% endif %}>
<svg class="h-6 w-6 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
</svg>
<span class="bottom-nav-text mt-0.5 w-full truncate px-0.5 text-center text-xs leading-tight">{{ _('Dashboard') }}</span>
</a>
<a href="{{ url_for('timer.manual_entry') }}" class="relative flex min-h-[44px] min-w-0 flex-1 flex-col items-center justify-center px-0.5 py-1.5 text-center transition-colors {% if timer_tab_active %}bg-primary/10 font-medium text-primary dark:bg-primary/20{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" aria-label="{{ _('Timer') }}" {% if timer_tab_active %}aria-current="page"{% endif %}>
<svg class="h-6 w-6 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 0 1 0 1.971l-11.54 6.348a1.125 1.125 0 0 1-1.667-.985V5.653Z" />
</svg>
<span class="bottom-nav-text mt-0.5 w-full truncate px-0.5 text-center text-xs leading-tight">{{ _('Timer') }}</span>
</a>
<a href="{{ url_for('timer.time_entries_overview') }}" class="relative flex min-h-[44px] min-w-0 flex-1 flex-col items-center justify-center px-0.5 py-1.5 text-center transition-colors {% if ep == 'timer.time_entries_overview' %}bg-primary/10 font-medium text-primary dark:bg-primary/20{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" aria-label="{{ _('Time entries') }}" {% if ep == 'timer.time_entries_overview' %}aria-current="page"{% endif %}>
<svg class="h-6 w-6 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
<span class="bottom-nav-text mt-0.5 w-full truncate px-0.5 text-center text-xs leading-tight">{{ _('Time entries') }}</span>
</a>
<a href="{{ url_for('projects.list_projects') }}" class="relative flex min-h-[44px] min-w-0 flex-1 flex-col items-center justify-center px-0.5 py-1.5 text-center transition-colors {% if ep.startswith('projects.') %}bg-primary/10 font-medium text-primary dark:bg-primary/20{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" aria-label="{{ _('Projects') }}" {% if ep.startswith('projects.') %}aria-current="page"{% endif %}>
<svg class="h-6 w-6 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.25 12.75V12A2.25 2.25 0 0 1 4.5 9.75h15A2.25 2.25 0 0 1 21.75 12v.75m-8.69-6.44-2.12-2.12a1.5 1.5 0 0 0-1.061-.44H4.5A2.25 2.25 0 0 0 2.25 6v12a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9a2.25 2.25 0 0 0-2.25-2.25h-5.379a1.5 1.5 0 0 0-1.06-.44Z" />
</svg>
<span class="bottom-nav-text mt-0.5 w-full truncate px-0.5 text-center text-xs leading-tight">{{ _('Projects') }}</span>
</a>
<button type="button" id="bottomNavMoreBtn" class="relative flex min-h-[44px] min-w-0 flex-1 flex-col items-center justify-center px-0.5 py-1.5 text-center transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 {% if more_tab_active %}bg-primary/10 font-medium text-primary dark:bg-primary/20{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" aria-label="{{ _('More') }}" aria-expanded="false" aria-controls="bottomNavMorePanel">
<svg class="h-6 w-6 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25A2.25 2.25 0 0 1 8.25 10.5H6a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75A2.25 2.25 0 0 1 15.75 18H18a2.25 2.25 0 0 0 2.25-2.25V15.75a2.25 2.25 0 0 0-2.25-2.25h-2.25a2.25 2.25 0 0 0-2.25 2.25V18ZM13.5 6A2.25 2.25 0 0 1 15.75 3.75H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25A2.25 2.25 0 0 1 13.5 8.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25Z" />
</svg>
<span class="bottom-nav-text mt-0.5 w-full truncate px-0.5 text-center text-xs leading-tight">{{ _('More') }}</span>
</button>
</nav>
{% endif %}