Files
TimeTracker/app/templates/components/ui.html
T
Dries Peeters f5c3c3f59f fix: resolve keyboard shortcut conflicts and notification errors
Fixed multiple issues with keyboard shortcuts and browser notifications:

Keyboard Shortcuts:
- Fixed Ctrl+/ not working to focus search input
- Resolved conflict between three event handlers (base.html, commands.js, keyboard-shortcuts-advanced.js)
- Changed inline handler from Ctrl+K to Ctrl+/ to avoid command palette conflict
- Updated search bar UI badge to display Ctrl+/ instead of Ctrl+K
- Removed conflicting ? key handler from commands.js (now uses Shift+? for shortcuts panel)
- Improved key detection to properly handle special characters like / and ?
- Added debug logging for troubleshooting keyboard events

Final keyboard mapping:
- Ctrl+K: Open Command Palette
- Ctrl+/: Focus Search Input
- Shift+?: Show All Keyboard Shortcuts
- Esc: Close Modals/Panels

Notification System:
- Fixed "right-hand side of 'in' should be an object" error in smart-notifications.js
- Changed notification permission request to follow browser security policies
- Permission now checked silently on load, only requested on user interaction
- Added "Enable Notifications" banner in notification center panel
- Fixed service worker sync check to properly verify registration object

Browser Compatibility:
- All fixes respect browser security policies for notification permissions
- Graceful degradation when service worker features unavailable
- Works correctly on Chrome, Firefox, Safari, and Edge

Files modified:
- app/static/enhanced-search.js
- app/static/keyboard-shortcuts-advanced.js
- app/static/smart-notifications.js
- app/templates/base.html
- app/static/commands.js

Closes issues with keyboard shortcuts not responding and browser console errors.
2025-10-20 13:00:39 +02:00

443 lines
21 KiB
HTML

{# ============================================
UNIFIED COMPONENT LIBRARY - Tailwind CSS
All UI components in one place for consistency
============================================ #}
{# ============================================
PAGE HEADERS
============================================ #}
{% macro page_header(icon_class, title_text, subtitle_text=None, actions_html=None, breadcrumbs=None) %}
<div class="bg-gradient-to-r from-card-light to-card-light/50 dark:from-card-dark dark:to-card-dark/50 rounded-lg shadow-sm mb-6 overflow-hidden">
{% if breadcrumbs %}
<div class="px-6 pt-4 pb-2">
{{ breadcrumb_nav(breadcrumbs) }}
</div>
{% endif %}
<div class="p-6 flex flex-col md:flex-row justify-between items-start md:items-center">
<div class="mb-4 md:mb-0">
<div class="flex items-center mb-2">
{% if icon_class %}
<div class="w-14 h-14 bg-primary/10 dark:bg-primary/20 rounded-full flex items-center justify-center mr-4 backdrop-blur-sm shadow-sm">
<i class="{{ icon_class }} text-primary text-xl"></i>
</div>
{% endif %}
<div>
<h1 class="text-3xl font-bold bg-gradient-to-r from-text-light to-primary dark:from-text-dark dark:to-primary bg-clip-text text-transparent">
{{ _(title_text) }}
</h1>
{% if subtitle_text %}
<p class="text-text-muted-light dark:text-text-muted-dark text-sm mt-1">{{ _(subtitle_text) }}</p>
{% endif %}
</div>
</div>
</div>
{% if actions_html %}
<div class="flex flex-wrap gap-2 items-center">
{{ actions_html|safe }}
</div>
{% endif %}
</div>
</div>
{% endmacro %}
{# ============================================
BREADCRUMBS
============================================ #}
{% macro breadcrumb_nav(items) %}
<nav class="flex" aria-label="Breadcrumb">
<ol class="inline-flex items-center space-x-1 md:space-x-3">
<li class="inline-flex items-center">
<a href="{{ url_for('main.dashboard') }}" class="inline-flex items-center text-sm font-medium text-text-muted-light dark:text-text-muted-dark hover:text-primary transition-colors">
<i class="fas fa-home mr-2"></i>
{{ _('Home') }}
</a>
</li>
{% for item in items %}
<li>
<div class="flex items-center">
<i class="fas fa-chevron-right text-text-muted-light dark:text-text-muted-dark text-xs mx-2"></i>
{% if item.url and not loop.last %}
<a href="{{ item.url }}" class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark hover:text-primary transition-colors">
{{ _(item.text) }}
</a>
{% else %}
<span class="text-sm font-medium text-text-light dark:text-text-dark">{{ _(item.text) }}</span>
{% endif %}
</div>
</li>
{% endfor %}
</ol>
</nav>
{% endmacro %}
{# ============================================
STAT CARDS
============================================ #}
{% macro stat_card(title, value, icon_class, color="primary", trend=None, subtitle=None) %}
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow-sm p-6 hover:shadow-md transition-shadow duration-200 relative overflow-hidden group">
<div class="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-{{ color }} to-{{ color }}/50"></div>
<div class="flex items-start justify-between">
<div class="flex-1">
<p class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark mb-1">{{ _(title) }}</p>
<h3 class="text-3xl font-bold text-text-light dark:text-text-dark mb-2" data-count-up="{{ value }}">{{ value }}</h3>
{% if subtitle %}
<p class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _(subtitle) }}</p>
{% endif %}
{% if trend %}
<div class="mt-2 flex items-center text-sm">
{% if trend > 0 %}
<span class="text-green-600 dark:text-green-400 flex items-center">
<i class="fas fa-arrow-up mr-1"></i>
{{ "%.1f"|format(trend) }}%
</span>
{% elif trend < 0 %}
<span class="text-red-600 dark:text-red-400 flex items-center">
<i class="fas fa-arrow-down mr-1"></i>
{{ "%.1f"|format(trend|abs) }}%
</span>
{% else %}
<span class="text-text-muted-light dark:text-text-muted-dark flex items-center">
<i class="fas fa-minus mr-1"></i>
0%
</span>
{% endif %}
</div>
{% endif %}
</div>
<div class="w-12 h-12 bg-{{ color }}/10 dark:bg-{{ color }}/20 rounded-lg flex items-center justify-center backdrop-blur-sm group-hover:scale-110 transition-transform duration-200">
<i class="{{ icon_class }} text-{{ color }} text-xl"></i>
</div>
</div>
</div>
{% endmacro %}
{# ============================================
EMPTY STATES
============================================ #}
{% macro empty_state(icon_class, title, message, actions_html=None, type="default") %}
{% set type_colors = {
'default': 'primary',
'no-data': 'gray-500',
'no-results': 'amber-500',
'error': 'red-500',
'success': 'green-500',
'info': 'blue-500'
} %}
{% set color = type_colors.get(type, 'primary') %}
<div class="text-center py-12 px-4 fade-in-up">
<div class="inline-flex items-center justify-center w-24 h-24 rounded-full bg-{{ color }}/10 dark:bg-{{ color }}/20 mb-6 animate-float">
<i class="{{ icon_class }} text-{{ color }} text-4xl"></i>
</div>
<h3 class="text-xl font-semibold text-text-light dark:text-text-dark mb-2">{{ _(title) }}</h3>
<p class="text-text-muted-light dark:text-text-muted-dark max-w-md mx-auto mb-6">{{ _(message) }}</p>
{% if actions_html %}
<div class="flex flex-wrap gap-3 justify-center">
{{ actions_html|safe }}
</div>
{% endif %}
</div>
{% endmacro %}
{# ============================================
LOADING STATES
============================================ #}
{% macro loading_spinner(size="md", text=None) %}
{% set size_classes = {'sm': 'w-6 h-6', 'md': 'w-10 h-10', 'lg': 'w-16 h-16'} %}
<div class="text-center">
<div class="inline-block {{ size_classes.get(size, 'w-10 h-10') }} border-4 border-primary/30 border-t-primary rounded-full animate-spin"></div>
{% if text %}
<p class="mt-3 text-sm text-text-muted-light dark:text-text-muted-dark">{{ _(text) }}</p>
{% endif %}
</div>
{% endmacro %}
{% macro skeleton_card() %}
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow-sm p-6 animate-pulse">
<div class="flex items-start justify-between">
<div class="flex-1 space-y-3">
<div class="h-4 bg-gray-300 dark:bg-gray-600 rounded w-1/2"></div>
<div class="h-8 bg-gray-300 dark:bg-gray-600 rounded w-3/4"></div>
<div class="h-3 bg-gray-300 dark:bg-gray-600 rounded w-1/3"></div>
</div>
<div class="w-12 h-12 bg-gray-300 dark:bg-gray-600 rounded-lg"></div>
</div>
</div>
{% endmacro %}
{% macro skeleton_table(rows=5, cols=4) %}
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow-sm p-6 animate-pulse">
<div class="space-y-4">
{% for i in range(rows) %}
<div class="grid grid-cols-{{ cols }} gap-4">
{% for j in range(cols) %}
<div class="h-4 bg-gray-300 dark:bg-gray-600 rounded"></div>
{% endfor %}
</div>
{% endfor %}
</div>
</div>
{% endmacro %}
{# ============================================
BADGES & CHIPS
============================================ #}
{% macro badge(text, color="primary", icon=None, size="md") %}
{% set size_classes = {
'sm': 'text-xs px-2 py-0.5',
'md': 'text-sm px-3 py-1',
'lg': 'text-base px-4 py-1.5'
} %}
<span class="inline-flex items-center {{ size_classes.get(size, size_classes['md']) }} rounded-full font-medium bg-{{ color }}/10 dark:bg-{{ color }}/20 text-{{ color }} dark:text-{{ color }}">
{% if icon %}<i class="{{ icon }} mr-1"></i>{% endif %}
{{ _(text) }}
</span>
{% endmacro %}
{# ============================================
BUTTONS
============================================ #}
{% macro button(text, url=None, icon_class=None, variant="primary", size="md", type="button", attributes="", loading=False) %}
{% set size_classes = {
'sm': 'px-3 py-1.5 text-sm',
'md': 'px-4 py-2',
'lg': 'px-6 py-3 text-lg'
} %}
{% set variant_classes = {
'primary': 'bg-primary text-white hover:bg-primary/90',
'secondary': 'bg-gray-500 text-white hover:bg-gray-600',
'success': 'bg-green-600 text-white hover:bg-green-700',
'danger': 'bg-red-600 text-white hover:bg-red-700',
'warning': 'bg-amber-500 text-white hover:bg-amber-600',
'outline': 'border-2 border-primary text-primary hover:bg-primary hover:text-white',
'ghost': 'text-primary hover:bg-primary/10'
} %}
{% set tag = 'a' if url else 'button' %}
<{{ tag }}
{% if url %}href="{{ url }}"{% endif %}
{% if type and tag == 'button' %}type="{{ type }}"{% endif %}
class="inline-flex items-center justify-center {{ size_classes.get(size, size_classes['md']) }} {{ variant_classes.get(variant, variant_classes['primary']) }} rounded-lg font-medium transition-all duration-200 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
{{ attributes|safe }}
{% if loading %}disabled{% endif %}
>
{% if loading %}
<i class="fas fa-spinner fa-spin mr-2"></i>
{% elif icon_class %}
<i class="{{ icon_class }} mr-2"></i>
{% endif %}
{{ _(text) }}
</{{ tag }}>
{% endmacro %}
{# ============================================
FILTER BADGES
============================================ #}
{% macro filter_badge(label, value, remove_url) %}
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm bg-primary/10 dark:bg-primary/20 text-primary border border-primary/20 dark:border-primary/30">
<span class="font-medium">{{ _(label) }}:</span>
<span class="ml-1">{{ value }}</span>
<a href="{{ remove_url }}" class="ml-2 hover:text-red-600 transition-colors">
<i class="fas fa-times"></i>
</a>
</span>
{% endmacro %}
{# ============================================
PROGRESS BARS
============================================ #}
{% macro progress_bar(current, total, color="primary", show_label=True, animate=True) %}
{% set percentage = (current / total * 100) if total > 0 else 0 %}
<div class="space-y-2">
{% if show_label %}
<div class="flex justify-between text-sm">
<span class="text-text-muted-light dark:text-text-muted-dark">{{ current }} / {{ total }}</span>
<span class="font-semibold text-{{ color }}">{{ "%.0f"|format(percentage) }}%</span>
</div>
{% endif %}
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2.5 overflow-hidden">
<div class="bg-{{ color }} h-full rounded-full {% if animate %}transition-all duration-500{% endif %} relative overflow-hidden"
style="width: {{ percentage }}%">
{% if animate %}
<div class="absolute inset-0 bg-gradient-to-r from-transparent via-white/30 to-transparent animate-shimmer"></div>
{% endif %}
</div>
</div>
</div>
{% endmacro %}
{# ============================================
ALERTS & NOTIFICATIONS
============================================ #}
{% macro alert(message, type="info", icon=None, dismissible=True) %}
{% set type_config = {
'info': {'bg': 'blue-50', 'border': 'blue-200', 'text': 'blue-800', 'icon': 'fa-info-circle'},
'success': {'bg': 'green-50', 'border': 'green-200', 'text': 'green-800', 'icon': 'fa-check-circle'},
'warning': {'bg': 'amber-50', 'border': 'amber-200', 'text': 'amber-800', 'icon': 'fa-exclamation-triangle'},
'error': {'bg': 'red-50', 'border': 'red-200', 'text': 'red-800', 'icon': 'fa-exclamation-circle'}
} %}
{% set config = type_config.get(type, type_config['info']) %}
<div class="bg-{{ config.bg }} dark:bg-{{ config.text }}/10 border border-{{ config.border }} dark:border-{{ config.text }}/20 text-{{ config.text }} dark:text-{{ config.text }} px-4 py-3 rounded-lg flex items-center justify-between fade-in" role="alert">
<div class="flex items-center">
<i class="fas {{ icon or config.icon }} mr-3 text-lg"></i>
<span>{{ _(message) }}</span>
</div>
{% if dismissible %}
<button type="button" class="ml-4 hover:opacity-70 transition-opacity" onclick="this.parentElement.remove()">
<i class="fas fa-times"></i>
</button>
{% endif %}
</div>
{% endmacro %}
{# ============================================
MODAL WRAPPER
============================================ #}
{% macro modal(id, title, content_html, footer_html=None, size="md") %}
{% set size_classes = {
'sm': 'max-w-md',
'md': 'max-w-lg',
'lg': 'max-w-2xl',
'xl': 'max-w-4xl',
'full': 'max-w-full mx-4'
} %}
<div id="{{ id }}" class="fixed inset-0 z-50 hidden overflow-y-auto" aria-labelledby="{{ id }}-title" role="dialog" aria-modal="true">
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75 dark:bg-gray-900 dark:bg-opacity-75" aria-hidden="true" onclick="document.getElementById('{{ id }}').classList.add('hidden')"></div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<div class="inline-block align-bottom bg-card-light dark:bg-card-dark rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle {{ size_classes.get(size, size_classes['md']) }} w-full">
<div class="bg-card-light dark:bg-card-dark px-6 pt-5 pb-4">
<div class="flex items-start justify-between mb-4">
<h3 class="text-lg font-semibold text-text-light dark:text-text-dark" id="{{ id }}-title">{{ _(title) }}</h3>
<button type="button" class="text-text-muted-light dark:text-text-muted-dark hover:text-text-light dark:hover:text-text-dark" onclick="document.getElementById('{{ id }}').classList.add('hidden')">
<i class="fas fa-times text-xl"></i>
</button>
</div>
<div class="mt-2">
{{ content_html|safe }}
</div>
</div>
{% if footer_html %}
<div class="bg-gray-50 dark:bg-gray-800 px-6 py-4 flex justify-end gap-3">
{{ footer_html|safe }}
</div>
{% endif %}
</div>
</div>
</div>
{% endmacro %}
{# ============================================
CONFIRMATION DIALOG
============================================ #}
{% macro confirm_dialog(id, title, message, confirm_text="Confirm", cancel_text="Cancel", confirm_class="danger") %}
<div id="{{ id }}" class="fixed inset-0 z-50 hidden overflow-y-auto" role="dialog" aria-modal="true">
<div class="flex items-center justify-center min-h-screen px-4">
<div class="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75 dark:bg-gray-900 dark:bg-opacity-75" onclick="document.getElementById('{{ id }}').classList.add('hidden')"></div>
<div class="relative bg-card-light dark:bg-card-dark rounded-lg shadow-xl max-w-md w-full p-6 zoom-in">
<div class="flex items-start mb-4">
<div class="flex-shrink-0">
<div class="w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<i class="fas fa-exclamation-triangle text-red-600 dark:text-red-400 text-xl"></i>
</div>
</div>
<div class="ml-4 flex-1">
<h3 class="text-lg font-semibold text-text-light dark:text-text-dark mb-2">{{ _(title) }}</h3>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _(message) }}</p>
</div>
</div>
<div class="flex justify-end gap-3 mt-6">
<button type="button" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors" onclick="document.getElementById('{{ id }}').classList.add('hidden')">
{{ _(cancel_text) }}
</button>
<button type="button" class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors" onclick="document.getElementById('{{ id }}-form').submit()">
{{ _(confirm_text) }}
</button>
</div>
</div>
</div>
</div>
{% endmacro %}
{# ============================================
DATA TABLE WRAPPER
============================================ #}
{% macro data_table(headers, rows, actions=None, empty_message="No data available", sortable=True) %}
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow-sm overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-left">
<thead class="bg-gray-50 dark:bg-gray-800 border-b border-border-light dark:border-border-dark">
<tr>
{% for header in headers %}
<th class="px-4 py-3 text-xs font-medium text-text-muted-light dark:text-text-muted-dark uppercase tracking-wider {% if sortable %}cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700{% endif %}" {% if sortable %}data-sortable{% endif %}>
<div class="flex items-center">
{{ _(header.label) }}
{% if sortable %}
<i class="fas fa-sort ml-2 text-gray-400"></i>
{% endif %}
</div>
</th>
{% endfor %}
{% if actions %}
<th class="px-4 py-3 text-xs font-medium text-text-muted-light dark:text-text-muted-dark uppercase tracking-wider">
{{ _('Actions') }}
</th>
{% endif %}
</tr>
</thead>
<tbody class="divide-y divide-border-light dark:divide-border-dark">
{% if rows %}
{{ rows|safe }}
{% else %}
<tr>
<td colspan="{{ headers|length + (1 if actions else 0) }}" class="px-4 py-8 text-center text-text-muted-light dark:text-text-muted-dark">
{{ _(empty_message) }}
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
{% endmacro %}
{# ============================================
TABS
============================================ #}
{% macro tabs(items, active_tab) %}
<div class="border-b border-border-light dark:border-border-dark mb-6">
<nav class="flex space-x-8" aria-label="Tabs">
{% for item in items %}
<a href="{{ item.url }}"
class="py-4 px-1 border-b-2 font-medium text-sm transition-colors {% if item.id == active_tab %}border-primary text-primary{% else %}border-transparent text-text-muted-light dark:text-text-muted-dark hover:text-text-light dark:hover:text-text-dark hover:border-gray-300 dark:hover:border-gray-600{% endif %}">
{% if item.icon %}<i class="{{ item.icon }} mr-2"></i>{% endif %}
{{ _(item.label) }}
{% if item.count is defined %}
<span class="ml-2 py-0.5 px-2 rounded-full text-xs bg-gray-100 dark:bg-gray-700">{{ item.count }}</span>
{% endif %}
</a>
{% endfor %}
</nav>
</div>
{% endmacro %}
{# ============================================
TIMELINE ITEM
============================================ #}
{% macro timeline_item(icon, title, description, time, color="primary", is_last=False) %}
<div class="flex">
<div class="flex flex-col items-center mr-4">
<div class="w-10 h-10 rounded-full bg-{{ color }}/10 dark:bg-{{ color }}/20 flex items-center justify-center">
<i class="{{ icon }} text-{{ color }}"></i>
</div>
{% if not is_last %}
<div class="w-0.5 h-full bg-gray-200 dark:bg-gray-700 mt-2"></div>
{% endif %}
</div>
<div class="pb-8 flex-1">
<h4 class="text-sm font-semibold text-text-light dark:text-text-dark mb-1">{{ _(title) }}</h4>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-2">{{ _(description) }}</p>
<span class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ time }}</span>
</div>
</div>
{% endmacro %}