mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-19 12:50:11 -05:00
2ee8da33a0
Quick wins (Phase A): - A1: Quick timer actions — last timer context, Repeat last button, Quick start one-click form; pre-fill modal and tags from last entry - A2: Unified empty states using empty_state macro on custom_view, time_entry_templates, saved_filters, issues; add loading_placeholder macro - A3: Dashboard hierarchy — Activity and Support/Donate moved to secondary row below fold with reduced visual weight - A4: Error/feedback consistency (flash-to-toast already in place) Medium impact (Phase B): - B5: Split API v1 — api_v1_common.py (shared helpers), api_v1_time_entries.py sub-blueprint for time-entries and timer/*; register api_v1_time_entries_bp - B6: Start Timer UX — templates as prominent chips at top of modal; default last context and quick start from A1 - B7: Week in review — ReportingService.get_week_in_review(), route /reports/week-in-review, template and link from reports index - B8: Tags discoverability — GET /api/tags, recent_tags in dashboard, tags input with datalist in Start Timer modal; last context includes tags - B9: Frontend consolidation — document onboarding.js vs onboarding-enhanced.js in base.html - B10: API validation — Marshmallow TimeEntryCreateSchema/TimeEntryUpdateSchema and handle_validation_error in api_v1_time_entries create/update UX: Remove duplicate Timer actions — single Repeat last and Start Timer in header; body shows only Resume when recent entries exist (no duplicate Repeat last or Start new).
1479 lines
90 KiB
HTML
1479 lines
90 KiB
HTML
{% extends "base.html" %}
|
||
{% from "components/cards.html" import info_card, stat_card %}
|
||
{% from "components/ui.html" import confirm_dialog, modal %}
|
||
{% from "components/client_select.html" import client_select %}
|
||
|
||
{% block extra_css %}
|
||
<link rel="stylesheet" href="https://uicdn.toast.com/editor/latest/toastui-editor.min.css">
|
||
<link rel="stylesheet" href="https://uicdn.toast.com/editor/latest/theme/toastui-editor-dark.css">
|
||
{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-8">
|
||
<div>
|
||
<h1 class="text-3xl font-bold text-text-light dark:text-text-dark mb-2">{{ _('Dashboard') }}</h1>
|
||
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _("Here's a quick overview of your work.") }}</p>
|
||
</div>
|
||
<div class="text-sm text-text-muted-light dark:text-text-muted-dark mt-2 md:mt-0 flex items-center gap-2">
|
||
<i class="fas fa-tachometer-alt text-primary"></i>
|
||
<span>/ {{ _('Dashboard') }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Key Stats with Sparklines -->
|
||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 mb-6">
|
||
<!-- Today's Hours Card -->
|
||
<div class="bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-800/20 border border-blue-200 dark:border-blue-800 p-6 rounded-xl shadow-sm dashboard-widget" id="todayHours">
|
||
<div class="flex items-center justify-between mb-4">
|
||
<div>
|
||
<p class="text-sm font-medium text-blue-600 dark:text-blue-400 mb-1">{{ _('Today\'s Hours') }}</p>
|
||
<div class="flex items-baseline gap-2">
|
||
<span class="text-2xl lg:text-3xl font-bold text-blue-900 dark:text-blue-100 stat-value" id="todayHoursValue">{{ "%.2f"|format(today_hours) }}</span>
|
||
<span class="text-sm text-blue-600/70 dark:text-blue-400/70">hours</span>
|
||
</div>
|
||
{% if standard_hours_per_day is defined and today_overtime_hours is defined %}
|
||
<div id="todayOvertimeLine" class="mt-1 text-xs text-blue-700 dark:text-blue-300" {% if today_overtime_hours <= 0 %}style="display: none;"{% endif %}>
|
||
{% if today_overtime_hours > 0 %}
|
||
<span class="font-medium">+ {{ "%.2f"|format(today_overtime_hours) }}h {{ _('overtime') }}</span>
|
||
{% elif standard_hours_per_day %}
|
||
<span class="text-blue-600/70 dark:text-blue-400/70">{{ "%.2f"|format(today_hours) }}h / {{ "%.1f"|format(standard_hours_per_day) }}h</span>
|
||
{% endif %}
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
<div class="bg-blue-500/10 dark:bg-blue-400/10 p-4 rounded-full">
|
||
<i class="fas fa-clock text-blue-600 dark:text-blue-400 text-2xl"></i>
|
||
</div>
|
||
</div>
|
||
<div class="sparkline-container" data-sparkline='[0,0,0,0,0,0,0]' data-sparkline-id="today" data-color="#3b82f6" id="sparkline-today" aria-label="{{ _('Loading...') }}"></div>
|
||
</div>
|
||
<!-- Week's Hours Card -->
|
||
<div class="bg-gradient-to-br from-green-50 to-green-100 dark:from-green-900/20 dark:to-green-800/20 border border-green-200 dark:border-green-800 p-6 rounded-xl shadow-sm dashboard-widget" id="weekHours">
|
||
<div class="flex items-center justify-between mb-4">
|
||
<div>
|
||
<p class="text-sm font-medium text-green-600 dark:text-green-400 mb-1">{{ _('Week\'s Hours') }}</p>
|
||
<div class="flex items-baseline gap-2">
|
||
<span class="text-2xl lg:text-3xl font-bold text-green-900 dark:text-green-100 stat-value" id="weekHoursValue">{{ "%.2f"|format(week_hours) }}</span>
|
||
<span class="text-sm text-green-600/70 dark:text-green-400/70">hours</span>
|
||
</div>
|
||
{% if standard_hours_per_day is defined and week_overtime_hours is defined %}
|
||
<div id="weekOvertimeLine" class="mt-1 text-xs text-green-700 dark:text-green-300" {% if week_overtime_hours <= 0 %}style="display: none;"{% endif %}>
|
||
{% if week_overtime_hours > 0 %}<span class="font-medium">+ {{ "%.2f"|format(week_overtime_hours) }}h {{ _('overtime') }}</span>{% endif %}
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
<div class="bg-green-500/10 dark:bg-green-400/10 p-4 rounded-full">
|
||
<i class="fas fa-calendar-week text-green-600 dark:text-green-400 text-2xl"></i>
|
||
</div>
|
||
</div>
|
||
<div class="sparkline-container" data-sparkline='[0,0,0,0,0,0,0]' data-sparkline-id="week" data-color="#10b981" id="sparkline-week" aria-label="{{ _('Loading...') }}"></div>
|
||
</div>
|
||
<!-- Month's Hours Card -->
|
||
<div class="bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-900/20 dark:to-purple-800/20 border border-purple-200 dark:border-purple-800 p-6 rounded-xl shadow-sm dashboard-widget" id="monthHours">
|
||
<div class="flex items-center justify-between mb-4">
|
||
<div>
|
||
<p class="text-sm font-medium text-purple-600 dark:text-purple-400 mb-1">{{ _('Month\'s Hours') }}</p>
|
||
<div class="flex items-baseline gap-2">
|
||
<span class="text-2xl lg:text-3xl font-bold text-purple-900 dark:text-purple-100 stat-value" id="monthHoursValue">{{ "%.2f"|format(month_hours) }}</span>
|
||
<span class="text-sm text-purple-600/70 dark:text-purple-400/70">hours</span>
|
||
</div>
|
||
</div>
|
||
<div class="bg-purple-500/10 dark:bg-purple-400/10 p-4 rounded-full">
|
||
<i class="fas fa-calendar-alt text-purple-600 dark:text-purple-400 text-2xl"></i>
|
||
</div>
|
||
</div>
|
||
<div class="sparkline-container" data-sparkline='[0,0,0,0,0,0,0]' data-sparkline-id="month" data-color="#8b5cf6" id="sparkline-month" aria-label="{{ _('Loading...') }}"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
|
||
<!-- Left Column: Active Timer & Recent Entries -->
|
||
<div class="lg:col-span-2 space-y-6">
|
||
<!-- Active Timer -->
|
||
<div class="{% if active_timer %}bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 border border-blue-200 dark:border-blue-800{% else %}bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark{% endif %} p-6 rounded-xl shadow-sm dashboard-widget animated-card" {% if active_timer %}data-timer-start="{{ active_timer.start_time.isoformat() }}" data-today-hours="{{ today_hours }}" data-week-hours="{{ week_hours }}" data-month-hours="{{ month_hours }}"{% endif %}>
|
||
<div class="flex items-center justify-between mb-4">
|
||
<div class="flex items-center gap-3">
|
||
<div class="{% if active_timer %}bg-blue-500/10 dark:bg-blue-400/10{% else %}bg-gray-100 dark:bg-gray-800{% endif %} p-3 rounded-lg">
|
||
<i class="fas fa-stopwatch {% if active_timer %}text-blue-600 dark:text-blue-400 animate-pulse{% else %}text-gray-600 dark:text-gray-400{% endif %} text-xl"></i>
|
||
</div>
|
||
<h2 class="text-lg font-semibold text-text-light dark:text-text-dark">{{ _('Timer') }}</h2>
|
||
</div>
|
||
{% if not active_timer %}
|
||
<div class="flex flex-wrap items-center gap-2">
|
||
{% if last_timer_context %}
|
||
<button type="button" class="btn btn-secondary js-repeat-last-timer" title="{{ _('Start timer with same project, task and notes as last entry') }}">
|
||
<i class="fas fa-redo"></i>{{ _('Repeat last') }}
|
||
</button>
|
||
<form method="POST" action="{{ url_for('timer.start_timer') }}" class="inline js-quick-start-last-form" data-confirm-message="{{ _('Start timer with last project and notes?') }}">
|
||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||
<input type="hidden" name="project_id" value="{{ last_timer_context.project_id or '' }}">
|
||
<input type="hidden" name="client_id" value="{{ last_timer_context.client_id or '' }}">
|
||
<input type="hidden" name="task_id" value="{{ last_timer_context.task_id or '' }}">
|
||
<input type="hidden" name="notes" value="{{ last_timer_context.notes or '' }}">
|
||
<input type="hidden" name="tags" value="{{ last_timer_context.tags or '' }}">
|
||
<button type="submit" class="btn btn-primary btn-sm">
|
||
<i class="fas fa-bolt"></i>{{ _('Quick start') }}{% if last_timer_context.project_name %} ({{ last_timer_context.project_name }}){% elif last_timer_context.client_name %} ({{ last_timer_context.client_name }}){% endif %}
|
||
</button>
|
||
</form>
|
||
{% endif %}
|
||
<button type="button" class="btn btn-primary js-open-start-timer">
|
||
<i class="fas fa-play"></i>{{ _('Start Timer') }}
|
||
</button>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
{% if active_timer %}
|
||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||
<div class="flex-1">
|
||
<div class="flex items-center gap-2 mb-2">
|
||
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
|
||
<span class="w-2 h-2 bg-blue-600 dark:bg-blue-400 rounded-full mr-2 animate-pulse"></span>
|
||
{{ _('Running') }}
|
||
</span>
|
||
</div>
|
||
<p class="font-semibold text-lg text-text-light dark:text-text-dark mb-1">
|
||
{% if active_timer.project %}
|
||
<i class="fas fa-folder text-blue-600 dark:text-blue-400 mr-2"></i>{{ active_timer.project.name }}
|
||
{% elif active_timer.client %}
|
||
<i class="fas fa-building text-blue-600 dark:text-blue-400 mr-2"></i>{{ active_timer.client.name }} <span class="text-xs text-gray-500">({{ _('Direct') }})</span>
|
||
{% else %}
|
||
{{ _('No project') }}
|
||
{% endif %}
|
||
</p>
|
||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">
|
||
<i class="fas fa-clock mr-1"></i>{{ _('Started at') }} {{ active_timer.start_time|user_time }}
|
||
</p>
|
||
<p class="text-sm font-semibold text-text-light dark:text-text-dark mt-2">
|
||
{{ _('Elapsed') }}: <span id="dashboard-timer-elapsed">{{ active_timer.duration_formatted }}</span>
|
||
</p>
|
||
<div class="mt-3 flex flex-wrap items-center gap-2">
|
||
<span class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Adjust time') }}:</span>
|
||
<form action="{{ url_for('timer.adjust_timer') }}" method="POST" class="inline">
|
||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||
<input type="hidden" name="delta_minutes" value="-15">
|
||
<button type="submit" class="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-text-light dark:text-text-dark text-sm font-medium transition-colors" title="{{ _('Subtract 15 min') }}">−15</button>
|
||
</form>
|
||
<form action="{{ url_for('timer.adjust_timer') }}" method="POST" class="inline">
|
||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||
<input type="hidden" name="delta_minutes" value="-5">
|
||
<button type="submit" class="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-text-light dark:text-text-dark text-sm font-medium transition-colors" title="{{ _('Subtract 5 min') }}">−5</button>
|
||
</form>
|
||
<form action="{{ url_for('timer.adjust_timer') }}" method="POST" class="inline">
|
||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||
<input type="hidden" name="delta_minutes" value="5">
|
||
<button type="submit" class="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-text-light dark:text-text-dark text-sm font-medium transition-colors" title="{{ _('Add 5 min') }}">+5</button>
|
||
</form>
|
||
<form action="{{ url_for('timer.adjust_timer') }}" method="POST" class="inline">
|
||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||
<input type="hidden" name="delta_minutes" value="15">
|
||
<button type="submit" class="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-text-light dark:text-text-dark text-sm font-medium transition-colors" title="{{ _('Add 15 min') }}">+15</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
<div class="flex flex-col sm:flex-row gap-2">
|
||
<form action="{{ url_for('timer.stop_timer') }}" method="POST" class="inline">
|
||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||
<button type="submit" class="bg-red-500 hover:bg-red-600 text-white px-5 py-2.5 rounded-lg font-semibold shadow-lg hover:shadow-xl transition-all duration-200 transform hover:-translate-y-0.5" title="{{ _('Stop and save current time') }}">
|
||
<i class="fas fa-stop mr-2"></i>{{ _('Stop & save') }}
|
||
</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
{% else %}
|
||
<div class="text-center py-4">
|
||
<p class="text-text-muted-light dark:text-text-muted-dark mb-3">{{ _('No active timer.') }}</p>
|
||
{% if recent_entries %}
|
||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-3">{{ _('Resume your last session or use the buttons above to repeat last / start a new timer.') }}</p>
|
||
<div class="flex flex-wrap justify-center gap-3">
|
||
<a href="{{ url_for('timer.resume_timer', timer_id=recent_entries[0].id) }}" class="inline-flex items-center bg-green-500 hover:bg-green-600 text-white px-5 py-2.5 rounded-lg font-semibold shadow-lg hover:shadow-xl transition-all duration-200 transform hover:-translate-y-0.5">
|
||
<i class="fas fa-play mr-2"></i>{{ _('Resume') }}{% if recent_entries[0].project %} ({{ recent_entries[0].project.name }}){% elif recent_entries[0].client %} ({{ recent_entries[0].client.name }}){% endif %}
|
||
</a>
|
||
</div>
|
||
{% else %}
|
||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Click "Start Timer" above to begin tracking your time.') }}</p>
|
||
{% endif %}
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
|
||
<!-- Recent Entries -->
|
||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 dashboard-widget animated-card">
|
||
<div class="flex items-center justify-between mb-6">
|
||
<div class="flex items-center gap-3">
|
||
<div class="bg-primary/10 dark:bg-primary/20 p-3 rounded-lg">
|
||
<i class="fas fa-history text-primary text-xl"></i>
|
||
</div>
|
||
<h2 class="text-lg font-semibold text-text-light dark:text-text-dark">{{ _('Recent Entries') }}</h2>
|
||
</div>
|
||
</div>
|
||
<div class="overflow-x-auto">
|
||
<table class="w-full text-left responsive-cards">
|
||
<thead class="border-b-2 border-border-light dark:border-border-dark">
|
||
<tr>
|
||
<th class="p-4 text-sm font-semibold text-text-muted-light dark:text-text-muted-dark">{{ _('Source') }}</th>
|
||
<th class="p-4 text-sm font-semibold text-text-muted-light dark:text-text-muted-dark">{{ _('Task') }}</th>
|
||
<th class="p-4 text-sm font-semibold text-text-muted-light dark:text-text-muted-dark">{{ _('Notes') }}</th>
|
||
<th class="p-4 text-sm font-semibold text-text-muted-light dark:text-text-muted-dark">{{ _('Tags') }}</th>
|
||
<th class="p-4 text-sm font-semibold text-text-muted-light dark:text-text-muted-dark">{{ _('Duration') }}</th>
|
||
<th class="p-4 text-sm font-semibold text-text-muted-light dark:text-text-muted-dark">{{ _('Date') }}</th>
|
||
<th class="p-4 text-sm font-semibold text-text-muted-light dark:text-text-muted-dark">{{ _('Actions') }}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for entry in recent_entries %}
|
||
<tr class="border-b border-border-light dark:border-border-dark hover:bg-background-light dark:hover:bg-background-dark transition-colors duration-150">
|
||
<td class="p-4" data-label="{{ _('Source') }}">
|
||
{% if entry.project %}
|
||
<a href="{{ url_for('projects.view_project', project_id=entry.project.id) }}" class="inline-flex items-center gap-2 text-primary hover:text-primary-dark hover:underline font-medium">
|
||
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
|
||
<i class="fas fa-folder mr-1"></i>{{ entry.project.name }}
|
||
</span>
|
||
</a>
|
||
{% elif entry.client %}
|
||
<a href="{{ url_for('clients.view_client', client_id=entry.client.id) }}" class="inline-flex items-center gap-2 text-primary hover:text-primary-dark hover:underline font-medium">
|
||
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400">
|
||
<i class="fas fa-building mr-1"></i>{{ entry.client.name }} <span class="ml-1 text-xs opacity-75">({{ _('Direct') }})</span>
|
||
</span>
|
||
</a>
|
||
{% else %}
|
||
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('N/A') }}</span>
|
||
{% endif %}
|
||
</td>
|
||
<td class="p-4" data-label="{{ _('Task') }}">
|
||
{% if entry.task %}
|
||
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
||
{{ entry.task.name }}
|
||
</span>
|
||
{% else %}
|
||
<span class="text-text-muted-light dark:text-text-muted-dark">-</span>
|
||
{% endif %}
|
||
</td>
|
||
<td class="p-4" data-label="{{ _('Notes') }}">
|
||
{% if entry.notes %}
|
||
<span class="text-sm text-text-light dark:text-text-dark" title="{{ entry.notes|striptags }}">{{ entry.notes|striptags|truncate(60) }}</span>
|
||
{% else %}
|
||
<span class="text-text-muted-light dark:text-text-muted-dark">-</span>
|
||
{% endif %}
|
||
</td>
|
||
<td class="p-4" data-label="{{ _('Tags') }}">
|
||
{% if entry.tags %}
|
||
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
|
||
{{ entry.tags }}
|
||
</span>
|
||
{% else %}
|
||
<span class="text-text-muted-light dark:text-text-muted-dark">-</span>
|
||
{% endif %}
|
||
</td>
|
||
<td class="p-4" data-label="{{ _('Duration') }}">
|
||
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
|
||
<i class="fas fa-clock mr-1"></i>{% if active_timer and entry.id == active_timer.id %}<span class="dashboard-live-duration" data-timer-start="{{ active_timer.start_time.isoformat() }}">{{ entry.duration_formatted }}</span>{% else %}{{ entry.duration_formatted }}{% endif %}
|
||
</span>
|
||
</td>
|
||
<td class="p-4 text-sm text-text-light dark:text-text-dark" data-label="{{ _('Date') }}">{{ entry.start_time|user_datetime }}</td>
|
||
<td class="p-4" data-label="{{ _('Actions') }}">
|
||
<div class="flex flex-wrap gap-2">
|
||
<a href="{{ url_for('timer.resume_timer', timer_id=entry.id) }}" class="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-green-100 hover:bg-green-200 dark:bg-green-900/30 dark:hover:bg-green-900/50 text-green-600 dark:text-green-400 transition-colors" title="{{ _('Resume - Start a new timer with same properties') }}">
|
||
<i class="fas fa-play text-xs"></i>
|
||
</a>
|
||
<a href="{{ url_for('timer.edit_timer', timer_id=entry.id) }}" class="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 hover:bg-primary/20 dark:bg-primary/20 dark:hover:bg-primary/30 text-primary dark:text-primary transition-colors" title="{{ _('Edit entry') }}">
|
||
<i class="fas fa-edit text-xs"></i>
|
||
</a>
|
||
<a href="{{ url_for('timer.duplicate_timer', timer_id=entry.id) }}" class="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-blue-100 hover:bg-blue-200 dark:bg-blue-900/30 dark:hover:bg-blue-900/50 text-blue-600 dark:text-blue-400 transition-colors" title="{{ _('Duplicate entry') }}">
|
||
<i class="fas fa-copy text-xs"></i>
|
||
</a>
|
||
{% if current_user.is_admin or entry.user_id == current_user.id %}
|
||
<form id="confirmDeleteEntry-{{ entry.id }}-form" method="POST" action="{{ url_for('timer.delete_timer', timer_id=entry.id) }}" class="hidden">
|
||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||
</form>
|
||
<button type="button" class="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-red-100 hover:bg-red-200 dark:bg-red-900/30 dark:hover:bg-red-900/50 text-red-600 dark:text-red-400 transition-colors" title="{{ _('Delete entry') }}" onclick="document.getElementById('confirmDeleteEntry-{{ entry.id }}').classList.remove('hidden')">
|
||
<i class="fas fa-trash text-xs"></i>
|
||
</button>
|
||
{% endif %}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
{% else %}
|
||
<tr>
|
||
<td colspan="7" class="p-12 text-center">
|
||
<div class="flex flex-col items-center justify-center">
|
||
<div class="bg-gray-100 dark:bg-gray-800 rounded-full p-6 w-20 h-20 mx-auto mb-4 flex items-center justify-center">
|
||
<i class="fas fa-inbox text-3xl text-gray-400"></i>
|
||
</div>
|
||
<p class="text-text-muted-light dark:text-text-muted-dark font-medium mb-1">{{ _('No recent entries found.') }}</p>
|
||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Start tracking time to see entries here.') }}</p>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Right Column: Real Insights -->
|
||
<div class="space-y-6">
|
||
<!-- Weekly Goal Widget -->
|
||
{% if is_module_enabled('weekly_goals') %}
|
||
{% if current_week_goal %}
|
||
<div class="bg-gradient-to-br from-blue-500 to-purple-600 p-6 rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 dashboard-widget animated-card text-white">
|
||
<div class="flex items-center justify-between mb-4">
|
||
<div class="flex items-center gap-3">
|
||
<div class="bg-white/20 backdrop-blur-sm p-3 rounded-lg">
|
||
<i class="fas fa-bullseye text-xl"></i>
|
||
</div>
|
||
<h2 class="text-lg font-semibold">{{ _('Weekly Goal') }}</h2>
|
||
</div>
|
||
<a href="{{ url_for('weekly_goals.index') }}" class="text-white/80 hover:text-white transition-colors">
|
||
<i class="fas fa-external-link-alt"></i>
|
||
</a>
|
||
</div>
|
||
<div class="mb-4">
|
||
<div class="flex justify-between text-sm mb-3 opacity-90">
|
||
<span class="font-medium">{{ current_week_goal.actual_hours }}h / {{ current_week_goal.target_hours }}h</span>
|
||
<span class="font-bold">{{ current_week_goal.progress_percentage }}%</span>
|
||
</div>
|
||
<div class="w-full bg-white/30 backdrop-blur-sm rounded-full h-3 overflow-hidden shadow-inner">
|
||
<div class="bg-white rounded-full h-3 transition-all duration-500 shadow-sm"
|
||
style="width: {{ current_week_goal.progress_percentage }}%"></div>
|
||
</div>
|
||
</div>
|
||
<div class="grid grid-cols-2 gap-3 text-sm mb-3">
|
||
<div class="bg-white/20 backdrop-blur-sm rounded-lg p-3 border border-white/10">
|
||
<div class="opacity-90 text-xs mb-1">{{ _('Remaining') }}</div>
|
||
<div class="font-bold text-lg">{{ current_week_goal.remaining_hours }}h</div>
|
||
</div>
|
||
<div class="bg-white/20 backdrop-blur-sm rounded-lg p-3 border border-white/10">
|
||
<div class="opacity-90 text-xs mb-1">{{ _('Days Left') }}</div>
|
||
<div class="font-bold text-lg">{{ current_week_goal.days_remaining }}</div>
|
||
</div>
|
||
</div>
|
||
{% if current_week_goal.days_remaining > 0 and current_week_goal.remaining_hours > 0 %}
|
||
<div class="mt-3 text-sm opacity-90 bg-white/10 backdrop-blur-sm rounded-lg p-2 border border-white/10">
|
||
<i class="fas fa-info-circle mr-2"></i>
|
||
{{ _('Need') }} {{ current_week_goal.average_hours_per_day }}h/day {{ _('to reach goal') }}
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
{% else %}
|
||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 animated-card border-2 border-dashed border-gray-300 dark:border-gray-600">
|
||
<div class="text-center py-2">
|
||
<div class="bg-gray-100 dark:bg-gray-800 rounded-full p-6 w-20 h-20 mx-auto mb-4 flex items-center justify-center">
|
||
<i class="fas fa-bullseye text-3xl text-gray-400"></i>
|
||
</div>
|
||
<h3 class="text-base font-semibold text-text-light dark:text-text-dark mb-2">
|
||
{{ _('No Weekly Goal') }}
|
||
</h3>
|
||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-4">
|
||
{{ _('Set a weekly time goal to track your progress') }}
|
||
</p>
|
||
<a href="{{ url_for('weekly_goals.create') }}"
|
||
class="inline-flex items-center justify-center bg-blue-600 hover:bg-blue-700 text-white px-5 py-2.5 rounded-lg text-sm font-semibold shadow-md hover:shadow-lg transition-all duration-200 transform hover:-translate-y-0.5">
|
||
<i class="fas fa-plus mr-2"></i> {{ _('Create Goal') }}
|
||
</a>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
{% endif %}
|
||
|
||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 dashboard-widget animated-card">
|
||
<div class="flex items-center justify-between mb-6">
|
||
<div class="flex items-center gap-3">
|
||
<div class="bg-purple-500/10 dark:bg-purple-400/10 p-3 rounded-lg">
|
||
<i class="fas fa-chart-line text-purple-600 dark:text-purple-400 text-xl"></i>
|
||
</div>
|
||
<h2 class="text-lg font-semibold text-text-light dark:text-text-dark">{{ _('Top Projects (30 days)') }}</h2>
|
||
</div>
|
||
</div>
|
||
{% if top_projects %}
|
||
{% set max_hours = top_projects[0].hours if top_projects and top_projects[0].hours > 0 else 1 %}
|
||
<ul class="space-y-4">
|
||
{% for item in top_projects %}
|
||
<li class="group">
|
||
<div class="flex items-center justify-between mb-2">
|
||
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||
<div class="flex-shrink-0 w-8 h-8 rounded-lg bg-gradient-to-br from-purple-500 to-indigo-500 flex items-center justify-center text-white text-xs font-bold">
|
||
{{ loop.index }}
|
||
</div>
|
||
<div class="flex-1 min-w-0">
|
||
<div class="font-semibold text-text-light dark:text-text-dark truncate">{{ item.project.name }}</div>
|
||
<div class="text-xs text-text-muted-light dark:text-text-muted-dark flex items-center gap-2 mt-1">
|
||
<span class="inline-flex items-center">
|
||
<i class="fas fa-dollar-sign text-green-600 dark:text-green-400 mr-1"></i>
|
||
{{ _('Billable') }}: {{ '%.1f'|format(item.billable_hours) }}h
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="text-right ml-3">
|
||
<div class="font-bold text-lg text-text-light dark:text-text-dark">{{ '%.1f'|format(item.hours) }}h</div>
|
||
</div>
|
||
</div>
|
||
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 overflow-hidden">
|
||
<div class="bg-gradient-to-r from-purple-500 to-indigo-500 h-2 rounded-full transition-all duration-500" style="width: {{ (item.hours / max_hours * 100)|round }}%"></div>
|
||
</div>
|
||
</li>
|
||
{% endfor %}
|
||
</ul>
|
||
{% else %}
|
||
<div class="text-center py-8">
|
||
<div class="bg-gray-100 dark:bg-gray-800 rounded-full p-6 w-20 h-20 mx-auto mb-4 flex items-center justify-center">
|
||
<i class="fas fa-folder-open text-3xl text-gray-400"></i>
|
||
</div>
|
||
<p class="text-text-muted-light dark:text-text-muted-dark font-medium mb-1">{{ _('No activity in the last 30 days.') }}</p>
|
||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Start tracking time on projects to see them here.') }}</p>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Secondary row: Activity and Support (below main focus) -->
|
||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mt-6">
|
||
<!-- Activity Timeline Widget - reduced visual weight -->
|
||
<div class="lg:col-span-2 bg-card-light dark:bg-card-dark p-5 rounded-xl border border-border-light dark:border-border-dark shadow-sm dashboard-widget">
|
||
<div class="flex items-center justify-between mb-4">
|
||
<div class="flex items-center gap-2">
|
||
<div class="bg-indigo-500/10 dark:bg-indigo-400/10 p-2 rounded-lg">
|
||
<i class="fas fa-stream text-indigo-600 dark:text-indigo-400"></i>
|
||
</div>
|
||
<h2 class="text-base font-semibold text-text-light dark:text-text-dark">{{ _('Recent Activity') }}</h2>
|
||
</div>
|
||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
|
||
<span class="w-1.5 h-1.5 bg-green-600 dark:bg-green-400 rounded-full mr-1.5"></span>
|
||
{{ _('Live') }}
|
||
</span>
|
||
</div>
|
||
<div id="activityTimeline" class="activity-timeline">
|
||
{% if recent_activities %}
|
||
{% for activity in recent_activities %}
|
||
<div class="activity-timeline-item group">
|
||
<div class="flex items-start gap-3">
|
||
<div class="flex-shrink-0 relative">
|
||
<div class="w-8 h-8 rounded-full bg-indigo-500/20 dark:bg-indigo-400/20 flex items-center justify-center">
|
||
<i class="fas fa-circle text-xs text-indigo-500 dark:text-indigo-400"></i>
|
||
</div>
|
||
{% if not loop.last %}
|
||
<div class="absolute left-1/2 top-8 w-0.5 h-4 bg-border-light dark:bg-border-dark transform -translate-x-1/2"></div>
|
||
{% endif %}
|
||
</div>
|
||
<div class="flex-1 pb-4 group-last:pb-0">
|
||
<p class="text-sm font-medium text-text-light dark:text-text-dark mb-0.5">{{ activity.description or 'Activity' }}</p>
|
||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark">
|
||
<i class="fas fa-clock mr-1"></i>{{ activity.created_at|local_datetime_short }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endfor %}
|
||
{% else %}
|
||
<div class="text-center py-8">
|
||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('No recent activity') }}</p>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
{% if (not settings or not getattr(settings, 'donate_ui_hidden', false)) and current_user.ui_show_donate %}
|
||
<!-- Support TimeTracker Widget - compact, below fold -->
|
||
<div class="bg-card-light dark:bg-card-dark border border-amber-200 dark:border-amber-800 p-5 rounded-xl shadow-sm dashboard-widget">
|
||
<div class="flex items-center gap-2 mb-3">
|
||
<div class="bg-amber-500/10 dark:bg-amber-400/10 p-2 rounded-lg">
|
||
<i class="fas fa-mug-saucer text-amber-600 dark:text-amber-400"></i>
|
||
</div>
|
||
<h2 class="text-base font-semibold text-text-light dark:text-text-dark">{{ _('Support TimeTracker') }}</h2>
|
||
</div>
|
||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-3">
|
||
{{ _('Support updates and new features. One-time key removes prompts for this instance.') }}
|
||
</p>
|
||
{% if time_entries_count > 0 or total_hours > 0 %}
|
||
<div class="bg-background-light dark:bg-background-dark rounded-lg p-2 mb-3 text-xs text-text-muted-light dark:text-text-muted-dark space-y-1">
|
||
{% if time_entries_count > 0 %}
|
||
<div><i class="fas fa-check-circle text-green-500 mr-1"></i>{{ _('You\'ve tracked %(count)s time entries', count=time_entries_count) }}</div>
|
||
{% endif %}
|
||
{% if total_hours > 0 %}
|
||
<div><i class="fas fa-check-circle text-green-500 mr-1"></i>{{ _('You\'ve logged %(hours)s hours', hours="%.1f"|format(total_hours)) }}</div>
|
||
{% endif %}
|
||
</div>
|
||
{% endif %}
|
||
<div class="flex flex-col sm:flex-row gap-2">
|
||
<a href="{{ url_for('main.donate') }}" class="inline-flex items-center justify-center flex-1 bg-amber-500 hover:bg-amber-600 text-white px-3 py-2 rounded-lg font-medium text-sm transition-colors">
|
||
<i class="fas fa-heart mr-1.5"></i>{{ _('Support / Get key') }}
|
||
</a>
|
||
<a href="https://buymeacoffee.com/DryTrix?utm_source=timetracker&utm_medium=dashboard&utm_campaign=support" target="_blank" rel="noopener noreferrer" onclick="trackDonationClick('dashboard_widget')" class="inline-flex items-center justify-center flex-1 border border-amber-500 text-amber-600 dark:text-amber-400 px-3 py-2 rounded-lg font-medium text-sm hover:bg-amber-500/10 transition-colors">
|
||
<i class="fas fa-mug-saucer mr-1.5"></i>{{ _('Donate') }} <i class="fas fa-external-link-alt ml-1 text-xs"></i>
|
||
</a>
|
||
</div>
|
||
<p class="mt-2 text-xs text-text-muted-light dark:text-text-muted-dark">
|
||
{{ _('Remove prompts with a one-time key.') }}
|
||
<a href="{{ support_purchase_url }}" target="_blank" rel="noopener noreferrer" onclick="trackDonationClick('dashboard_widget_key')" class="underline hover:no-underline font-medium text-amber-600 dark:text-amber-400">{{ _('Get key') }}</a>
|
||
</p>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
<!-- Delete Entry Confirmation Dialogs -->
|
||
{% for entry in recent_entries %}
|
||
{% if current_user.is_admin or entry.user_id == current_user.id %}
|
||
{{ confirm_dialog(
|
||
'confirmDeleteEntry-' ~ entry.id,
|
||
'Delete Time Entry',
|
||
'Are you sure you want to delete this time entry? This action cannot be undone.',
|
||
'Delete',
|
||
'Cancel',
|
||
'danger'
|
||
) }}
|
||
{% endif %}
|
||
{% endfor %}
|
||
|
||
<!-- Start Timer Modal -->
|
||
<div id="startTimerModal"
|
||
class="fixed inset-0 z-50 hidden overflow-y-auto"
|
||
aria-hidden="true"
|
||
data-last-timer-context="{{ (last_timer_context|tojson)|e if last_timer_context else '{}' }}"
|
||
data-tasks-api-url="{{ url_for('api.get_project_tasks', project_id=0) }}"
|
||
data-create-task-url="{{ url_for('api.create_task_inline') }}"
|
||
data-no-task="{{ _('No task')|e }}"
|
||
data-create-new-task="{{ _('Create new task...')|e }}"
|
||
data-loading-tasks="{{ _('Loading tasks...')|e }}"
|
||
data-new-task-prompt="{{ _('Enter new task name:')|e }}"
|
||
data-task-create-failed="{{ _('Failed to create task: ')|e }}"
|
||
data-select-project-client="{{ _('Please select either a project or a client')|e }}"
|
||
data-complete-task-create="{{ _('Please complete creating the task or select an existing task')|e }}"
|
||
data-error-title="{{ _('Error')|e }}"
|
||
data-notes-placeholder="{{ _('What are you working on?')|e }}"
|
||
data-only-one-client="{{ 'true' if only_one_client|default(false) else 'false' }}"
|
||
data-single-client-id="{{ single_client.id if single_client else '' }}"
|
||
data-require-task="{{ 'true' if getattr(settings, 'time_entry_require_task', false) else 'false' }}"
|
||
data-require-description="{{ 'true' if getattr(settings, 'time_entry_require_description', false) else 'false' }}"
|
||
data-description-min-length="{{ getattr(settings, 'time_entry_description_min_length', 20) }}"
|
||
data-task-required-msg="{{ _('A task must be selected when logging time for a project')|e }}"
|
||
data-description-required-msg="{{ _('A description is required when logging time')|e }}"
|
||
data-description-min-msg="{{ _('Description must be at least %(min)s characters', min=getattr(settings, 'time_entry_description_min_length', 20))|e }}">
|
||
<div class="absolute inset-0 bg-black/50" data-overlay></div>
|
||
<div class="relative max-w-lg mx-auto mt-24 max-h-[90vh] overflow-y-auto bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark rounded-lg shadow-lg" data-modal-content>
|
||
<div class="p-4 border-b border-border-light dark:border-border-dark flex items-center justify-between gap-2">
|
||
<div class="text-lg font-semibold">{{ _('Start Timer') }}</div>
|
||
<div class="flex items-center gap-2 shrink-0">
|
||
<button type="submit" form="startTimerForm" id="startTimerSubmitBtn" class="btn btn-primary btn-sm">{{ _('Start') }}</button>
|
||
<button type="button" data-close class="px-2 py-1 text-sm hover:bg-background-light dark:hover:bg-background-dark rounded">{{ _('Close') }}</button>
|
||
</div>
|
||
</div>
|
||
<form method="POST" action="{{ url_for('timer.start_timer') }}" id="startTimerForm" class="p-4">
|
||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||
<div class="space-y-4">
|
||
{% if templates %}
|
||
<div class="pb-3 border-b border-border-light dark:border-border-dark">
|
||
<label class="form-label">{{ _('Quick start with a template') }}</label>
|
||
<div class="flex flex-wrap gap-2 mt-2">
|
||
{% for template in templates %}
|
||
<button type="button"
|
||
onclick="applyTemplate({{ template.id }})"
|
||
class="inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-primary/30 bg-primary/5 dark:bg-primary/10 hover:bg-primary/15 dark:hover:bg-primary/20 text-primary font-medium text-sm transition">
|
||
<i class="fas fa-bolt text-xs"></i>
|
||
{{ template.name }}{% if template.project %} · {{ template.project.name }}{% endif %}
|
||
</button>
|
||
{% endfor %}
|
||
<a href="{{ url_for('time_entry_templates.list_templates') }}" class="inline-flex items-center gap-1 px-3 py-2 text-sm text-text-muted-light dark:text-text-muted-dark hover:text-primary transition">
|
||
{{ _('All templates') }} <i class="fas fa-external-link-alt text-xs"></i>
|
||
</a>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div>
|
||
<label for="startTimerProject" class="form-label">{{ _('Project') }}</label>
|
||
<select id="startTimerProject" name="project_id" class="form-input">
|
||
<option value="">{{ _('Select a project (optional)') }}</option>
|
||
{% for project in active_projects %}
|
||
<option value="{{ project.id }}">{{ project.name }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label for="startTimerClient" class="form-label">{{ _('Client') }}</label>
|
||
{{ client_select('client_id', active_clients, required=False, only_one_client=only_one_client|default(false), single_client=single_client, id='startTimerClient') }}
|
||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">
|
||
{{ _('Select either a project or a client') }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label for="startTimerTask" class="form-label">{% if getattr(settings, 'time_entry_require_task', false) %}{{ _('Task') }} *{% else %}{{ _('Task (optional)') }}{% endif %}</label>
|
||
<select id="startTimerTask" name="task_id" class="form-input w-full">
|
||
<option value="">{{ _('No task') }}</option>
|
||
<!-- Options populated dynamically when project is selected -->
|
||
</select>
|
||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">
|
||
{{ _('Select a project first to load tasks, or choose "Create new task..." to add one') }}
|
||
</p>
|
||
</div>
|
||
<div>
|
||
<label for="startTimerNotes" class="form-label">{% if getattr(settings, 'time_entry_require_description', false) %}{{ _('Notes') }} *{% else %}{{ _('Notes (optional)') }}{% endif %}</label>
|
||
<div class="markdown-editor-wrapper">
|
||
<textarea id="startTimerNotes" name="notes" class="hidden" placeholder="{{ _('What are you working on?') }}"></textarea>
|
||
<div id="startTimerNotes_editor"></div>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label for="startTimerTags" class="form-label">{{ _('Tags (optional)') }}</label>
|
||
<input type="text" id="startTimerTags" name="tags" class="form-input w-full" placeholder="{{ _('e.g. meeting, dev, admin') }}"
|
||
list="startTimerTagsList" autocomplete="off">
|
||
<datalist id="startTimerTagsList">
|
||
{% for tag in recent_tags %}
|
||
<option value="{{ tag|e }}"></option>
|
||
{% endfor %}
|
||
</datalist>
|
||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Comma-separated; recent tags shown as suggestions.') }}</p>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
{% set create_task_modal_content %}
|
||
<div class="space-y-3">
|
||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Enter a name for the new task.') }}</p>
|
||
<input type="text" id="createTaskNameInput" class="form-input w-full" placeholder="{{ _('Task name') }}">
|
||
<p id="createTaskError" class="text-sm text-red-600 hidden"></p>
|
||
</div>
|
||
{% endset %}
|
||
{% set create_task_modal_footer %}
|
||
<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" data-create-task-cancel>{{ _('Cancel') }}</button>
|
||
<button type="button" class="btn btn-primary" data-create-task-confirm>{{ _('Create') }}</button>
|
||
{% endset %}
|
||
{{ modal('createTaskModal', _('Create task'), create_task_modal_content, create_task_modal_footer, 'sm') }}
|
||
{% endblock %}
|
||
|
||
|
||
{% block scripts_extra %}
|
||
<script>
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
// Live timer: update elapsed every second when a timer is running; also live Today/Week/Month hours
|
||
(function initLiveTimer() {
|
||
var timerWidget = document.querySelector('[data-timer-start]');
|
||
if (!timerWidget) return;
|
||
var startIso = timerWidget.getAttribute('data-timer-start');
|
||
if (!startIso) return;
|
||
var startTime = new Date(startIso).getTime();
|
||
var todayHours = parseFloat(timerWidget.getAttribute('data-today-hours')) || 0;
|
||
var weekHours = parseFloat(timerWidget.getAttribute('data-week-hours')) || 0;
|
||
var monthHours = parseFloat(timerWidget.getAttribute('data-month-hours')) || 0;
|
||
var startDate = new Date(startIso);
|
||
function formatElapsed(seconds) {
|
||
var h = Math.floor(seconds / 3600);
|
||
var m = Math.floor((seconds % 3600) / 60);
|
||
var s = seconds % 60;
|
||
return String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0');
|
||
}
|
||
function tick() {
|
||
var now = Date.now();
|
||
var elapsedSec = (now - startTime) / 1000;
|
||
var elapsedHours = elapsedSec / 3600;
|
||
var display = formatElapsed(Math.floor(elapsedSec));
|
||
var elapsedEl = document.getElementById('dashboard-timer-elapsed');
|
||
if (elapsedEl) elapsedEl.textContent = display;
|
||
document.querySelectorAll('.dashboard-live-duration').forEach(function(span) {
|
||
span.textContent = display;
|
||
});
|
||
var nowDate = new Date(now);
|
||
var sameDay = startDate.getFullYear() === nowDate.getFullYear() && startDate.getMonth() === nowDate.getMonth() && startDate.getDate() === nowDate.getDate();
|
||
var startWeek = new Date(startDate); startWeek.setDate(startWeek.getDate() - startWeek.getDay());
|
||
var nowWeek = new Date(nowDate); nowWeek.setDate(nowWeek.getDate() - nowWeek.getDay());
|
||
var sameWeek = startWeek.getTime() === nowWeek.getTime();
|
||
var sameMonth = startDate.getFullYear() === nowDate.getFullYear() && startDate.getMonth() === nowDate.getMonth();
|
||
var todayEl = document.getElementById('todayHoursValue');
|
||
var weekEl = document.getElementById('weekHoursValue');
|
||
var monthEl = document.getElementById('monthHoursValue');
|
||
if (todayEl && sameDay) todayEl.textContent = (todayHours + elapsedHours).toFixed(2);
|
||
if (weekEl && sameWeek) weekEl.textContent = (weekHours + elapsedHours).toFixed(2);
|
||
if (monthEl && sameMonth) monthEl.textContent = (monthHours + elapsedHours).toFixed(2);
|
||
}
|
||
tick();
|
||
setInterval(tick, 1000);
|
||
})();
|
||
|
||
try {
|
||
if (typeof anime !== 'undefined') {
|
||
anime({
|
||
targets: '.animated-card',
|
||
translateY: [20, 0],
|
||
opacity: [0, 1],
|
||
delay: (window.anime && anime.stagger) ? anime.stagger(100) : undefined,
|
||
duration: 500,
|
||
easing: 'easeOutQuad'
|
||
});
|
||
}
|
||
} catch(e) { /* no-op if animation lib missing */ }
|
||
|
||
const modal = document.getElementById('startTimerModal');
|
||
// Open modal: direct binding (reliable) and delegation (backup)
|
||
function openStartTimerModal(e) {
|
||
if (e) e.preventDefault();
|
||
if (modal) {
|
||
modal.classList.remove('hidden');
|
||
modal.setAttribute('aria-hidden', 'false');
|
||
try {
|
||
const projectSelect = document.getElementById('startTimerProject');
|
||
if (projectSelect && projectSelect.value) {
|
||
loadTasksForProject(projectSelect.value);
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
}
|
||
document.querySelectorAll('.js-open-start-timer').forEach(function(openBtn) {
|
||
if (openBtn && modal) openBtn.addEventListener('click', openStartTimerModal);
|
||
});
|
||
if (modal) {
|
||
const closeBtn = modal.querySelector('[data-close]');
|
||
if (closeBtn) {
|
||
closeBtn.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
modal.classList.add('hidden');
|
||
modal.setAttribute('aria-hidden', 'true');
|
||
});
|
||
}
|
||
// Close on overlay click
|
||
modal.addEventListener('click', (e) => {
|
||
if (e.target === modal || e.target.hasAttribute('data-overlay')) {
|
||
modal.classList.add('hidden');
|
||
modal.setAttribute('aria-hidden', 'true');
|
||
}
|
||
});
|
||
const modalContent = modal.querySelector('[data-modal-content]');
|
||
if (modalContent) {
|
||
modalContent.addEventListener('click', (e) => {
|
||
// Close button: handle here since stopPropagation would block modal handler
|
||
if (e.target.closest('[data-close]')) {
|
||
modal.classList.add('hidden');
|
||
modal.setAttribute('aria-hidden', 'true');
|
||
e.preventDefault();
|
||
return;
|
||
}
|
||
e.stopPropagation(); // Prevent overlay-close when clicking card
|
||
});
|
||
}
|
||
|
||
const projectSelect = document.getElementById('startTimerProject');
|
||
const clientSelect = document.getElementById('startTimerClient');
|
||
const taskSelect = document.getElementById('startTimerTask');
|
||
const createNewTaskValue = '__new__';
|
||
const tasksApiUrlTemplate = modal?.dataset?.tasksApiUrl || '';
|
||
const createTaskUrl = modal?.dataset?.createTaskUrl || '';
|
||
const noTaskText = modal?.dataset?.noTask || 'No task';
|
||
const createNewTaskText = modal?.dataset?.createNewTask || 'Create new task...';
|
||
const loadingTasksText = modal?.dataset?.loadingTasks || 'Loading tasks...';
|
||
const newTaskPromptText = modal?.dataset?.newTaskPrompt || 'Enter new task name:';
|
||
const taskCreateFailedText = modal?.dataset?.taskCreateFailed || 'Failed to create task: ';
|
||
const selectProjectOrClientText = modal?.dataset?.selectProjectClient || 'Please select either a project or a client';
|
||
const completeTaskCreationText = modal?.dataset?.completeTaskCreate || 'Please complete creating the task or select an existing task';
|
||
const errorTitleText = modal?.dataset?.errorTitle || 'Error';
|
||
const taskNameRequiredText = '{{ _("Task name is required.") }}';
|
||
|
||
const createTaskModal = document.getElementById('createTaskModal');
|
||
const createTaskNameInput = document.getElementById('createTaskNameInput');
|
||
const createTaskError = document.getElementById('createTaskError');
|
||
const createTaskConfirmBtn = createTaskModal ? createTaskModal.querySelector('[data-create-task-confirm]') : null;
|
||
const createTaskCancelBtn = createTaskModal ? createTaskModal.querySelector('[data-create-task-cancel]') : null;
|
||
let createTaskModalResolver = null;
|
||
|
||
function closeCreateTaskModal(result) {
|
||
if (!createTaskModal) return;
|
||
createTaskModal.classList.add('hidden');
|
||
createTaskModal.setAttribute('aria-hidden', 'true');
|
||
if (createTaskModalResolver) {
|
||
createTaskModalResolver(result);
|
||
createTaskModalResolver = null;
|
||
}
|
||
}
|
||
|
||
function openCreateTaskModal() {
|
||
if (!createTaskModal || !createTaskNameInput) {
|
||
return Promise.resolve(null);
|
||
}
|
||
if (createTaskError) {
|
||
createTaskError.textContent = '';
|
||
createTaskError.classList.add('hidden');
|
||
}
|
||
createTaskNameInput.value = '';
|
||
createTaskModal.classList.remove('hidden');
|
||
createTaskModal.setAttribute('aria-hidden', 'false');
|
||
setTimeout(() => createTaskNameInput.focus(), 0);
|
||
return new Promise((resolve) => {
|
||
createTaskModalResolver = resolve;
|
||
});
|
||
}
|
||
|
||
if (createTaskConfirmBtn && createTaskNameInput) {
|
||
createTaskConfirmBtn.addEventListener('click', () => {
|
||
const name = createTaskNameInput.value.trim();
|
||
if (!name) {
|
||
if (createTaskError) {
|
||
createTaskError.textContent = taskNameRequiredText;
|
||
createTaskError.classList.remove('hidden');
|
||
}
|
||
return;
|
||
}
|
||
closeCreateTaskModal(name);
|
||
});
|
||
}
|
||
|
||
if (createTaskCancelBtn) {
|
||
createTaskCancelBtn.addEventListener('click', () => closeCreateTaskModal(null));
|
||
}
|
||
|
||
if (createTaskModal) {
|
||
createTaskModal.addEventListener('click', (e) => {
|
||
if (e.target === createTaskModal) {
|
||
closeCreateTaskModal(null);
|
||
}
|
||
});
|
||
}
|
||
|
||
if (createTaskNameInput) {
|
||
createTaskNameInput.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
if (createTaskConfirmBtn) createTaskConfirmBtn.click();
|
||
} else if (e.key === 'Escape') {
|
||
e.preventDefault();
|
||
closeCreateTaskModal(null);
|
||
}
|
||
});
|
||
}
|
||
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape' && createTaskModal && !createTaskModal.classList.contains('hidden')) {
|
||
closeCreateTaskModal(null);
|
||
}
|
||
});
|
||
|
||
function buildTasksUrl(projectId) {
|
||
const pid = String(projectId || '').trim();
|
||
if (!pid) return tasksApiUrlTemplate;
|
||
return String(tasksApiUrlTemplate).replace(/\/0\/tasks$/, '/' + encodeURIComponent(pid) + '/tasks');
|
||
}
|
||
|
||
async function loadTasksForProject(pid, preserveTaskId = null) {
|
||
if (!taskSelect) return;
|
||
|
||
if (!pid) {
|
||
taskSelect.innerHTML = '<option value="">' + noTaskText + '</option>';
|
||
taskSelect.disabled = false;
|
||
return;
|
||
}
|
||
|
||
taskSelect.innerHTML = '<option value="">' + loadingTasksText + '</option>';
|
||
taskSelect.disabled = true;
|
||
try {
|
||
const res = await fetch(buildTasksUrl(pid), { credentials: 'same-origin' });
|
||
const data = await res.json();
|
||
var opts = [];
|
||
var o0 = document.createElement('option');
|
||
o0.value = '';
|
||
o0.textContent = noTaskText;
|
||
opts.push(o0);
|
||
if (data && data.tasks) {
|
||
data.tasks.forEach(function(t) {
|
||
var o = document.createElement('option');
|
||
o.value = t.id;
|
||
o.textContent = t.name || '';
|
||
opts.push(o);
|
||
});
|
||
}
|
||
var oNew = document.createElement('option');
|
||
oNew.value = createNewTaskValue;
|
||
oNew.textContent = createNewTaskText;
|
||
opts.push(oNew);
|
||
var frag = document.createDocumentFragment();
|
||
opts.forEach(function(o) { frag.appendChild(o); });
|
||
taskSelect.innerHTML = '';
|
||
taskSelect.appendChild(frag);
|
||
if (preserveTaskId) {
|
||
var found = Array.from(taskSelect.options).some(function(o) { return o.value === String(preserveTaskId); });
|
||
if (found) taskSelect.value = String(preserveTaskId);
|
||
}
|
||
taskSelect.disabled = false;
|
||
} catch (err) {
|
||
console.error('Failed to load tasks', err);
|
||
taskSelect.innerHTML = '<option value="">' + noTaskText + '</option>';
|
||
taskSelect.disabled = false;
|
||
}
|
||
}
|
||
|
||
// Handle "Create new task..." selection
|
||
if (taskSelect) {
|
||
taskSelect.addEventListener('change', async function() {
|
||
if (this.value !== createNewTaskValue) return;
|
||
const pid = projectSelect ? projectSelect.value : '';
|
||
if (!pid) {
|
||
this.value = '';
|
||
return;
|
||
}
|
||
let taskName = await openCreateTaskModal();
|
||
if (taskName === null && !createTaskModal) {
|
||
taskName = prompt(newTaskPromptText);
|
||
}
|
||
if (!taskName || !taskName.trim()) {
|
||
this.value = '';
|
||
return;
|
||
}
|
||
try {
|
||
const csrfToken = modal ? modal.querySelector('input[name="csrf_token"]')?.value : '';
|
||
const res = await fetch(createTaskUrl, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', 'X-CSRFToken': csrfToken || '' },
|
||
credentials: 'same-origin',
|
||
body: JSON.stringify({ name: taskName.trim(), project_id: parseInt(pid) })
|
||
});
|
||
if (!res.ok) {
|
||
const errData = await res.json().catch(() => ({}));
|
||
throw new Error(errData.error || 'Failed to create task');
|
||
}
|
||
const taskData = await res.json();
|
||
if (taskData && taskData.id) {
|
||
await loadTasksForProject(pid, taskData.id);
|
||
} else {
|
||
this.value = '';
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to create task:', err);
|
||
alert(taskCreateFailedText + (err.message || err));
|
||
this.value = '';
|
||
}
|
||
});
|
||
}
|
||
|
||
const onlyOneClient = (modal?.dataset?.onlyOneClient || 'false') === 'true';
|
||
const singleClientId = (modal?.dataset?.singleClientId || '') || null;
|
||
|
||
// Repeat last: pre-fill modal from last_timer_context and open
|
||
document.querySelectorAll('.js-repeat-last-timer').forEach(function(btn) {
|
||
btn.addEventListener('click', async function(e) {
|
||
e.preventDefault();
|
||
if (!modal) return;
|
||
var ctxRaw = modal.getAttribute('data-last-timer-context');
|
||
var ctx = {};
|
||
try { ctx = ctxRaw ? JSON.parse(ctxRaw) : {}; } catch (err) { ctx = {}; }
|
||
if (!ctx.project_id && !ctx.client_id) {
|
||
openStartTimerModal(e);
|
||
return;
|
||
}
|
||
if (projectSelect) projectSelect.value = ctx.project_id || '';
|
||
if (clientSelect) clientSelect.value = ctx.client_id || '';
|
||
if (ctx.project_id && typeof loadTasksForProject === 'function') {
|
||
await loadTasksForProject(ctx.project_id, ctx.task_id || null);
|
||
} else if (taskSelect) {
|
||
taskSelect.innerHTML = '<option value="">' + noTaskText + '</option>';
|
||
taskSelect.disabled = false;
|
||
}
|
||
var notesEl = document.getElementById('startTimerNotes');
|
||
if (notesEl) notesEl.value = ctx.notes || '';
|
||
if (window.dashboardNotesEditor && typeof window.dashboardNotesEditor.setMarkdown === 'function') {
|
||
try { window.dashboardNotesEditor.setMarkdown(ctx.notes || ''); } catch (err) {}
|
||
}
|
||
var tagsEl = document.getElementById('startTimerTags');
|
||
if (tagsEl && ctx.tags) tagsEl.value = ctx.tags;
|
||
modal.classList.remove('hidden');
|
||
modal.setAttribute('aria-hidden', 'false');
|
||
});
|
||
});
|
||
|
||
// Task loading: attach when project and task elements exist (independent of client)
|
||
if (projectSelect && taskSelect) {
|
||
projectSelect.addEventListener('change', () => {
|
||
const pid = projectSelect.value;
|
||
if (pid && clientSelect) {
|
||
clientSelect.value = '';
|
||
} else if (onlyOneClient && singleClientId) {
|
||
clientSelect.value = singleClientId;
|
||
}
|
||
loadTasksForProject(pid);
|
||
});
|
||
}
|
||
|
||
// Client/project mutual exclusivity (when client select exists)
|
||
if (clientSelect && taskSelect) {
|
||
clientSelect.addEventListener('change', function() {
|
||
var cid = clientSelect.value;
|
||
if (cid) {
|
||
if (projectSelect) projectSelect.value = '';
|
||
taskSelect.innerHTML = '<option value="">' + noTaskText + '</option>';
|
||
taskSelect.disabled = true;
|
||
} else {
|
||
// Only enable task select if no project selected (otherwise loadTasksForProject will handle it)
|
||
if (!projectSelect || !projectSelect.value) taskSelect.disabled = false;
|
||
}
|
||
});
|
||
}
|
||
|
||
// Form validation: ensure either project or client is selected
|
||
const startTimerForm = modal ? modal.querySelector('form') : null;
|
||
if (startTimerForm) {
|
||
// Store original button state to restore if needed
|
||
const submitBtn = modal.querySelector('#startTimerSubmitBtn');
|
||
let originalButtonHTML = submitBtn ? submitBtn.innerHTML : null;
|
||
|
||
startTimerForm.addEventListener('submit', async function(e) {
|
||
e.preventDefault();
|
||
|
||
// Validate project or client selection
|
||
const projectVal = projectSelect ? projectSelect.value : '';
|
||
const clientVal = clientSelect ? clientSelect.value : '';
|
||
if (!projectVal && !clientVal) {
|
||
const errorMsg = selectProjectOrClientText;
|
||
if (window.toastManager && typeof window.toastManager.error === 'function') {
|
||
window.toastManager.error(errorMsg, errorTitleText, 5000);
|
||
} else {
|
||
alert(errorMsg);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// Prevent submit if "Create new task..." is still selected (incomplete flow)
|
||
const taskVal = taskSelect ? taskSelect.value : '';
|
||
if (taskVal === createNewTaskValue) {
|
||
if (window.toastManager && typeof window.toastManager.error === 'function') {
|
||
window.toastManager.error(completeTaskCreationText, errorTitleText, 5000);
|
||
} else {
|
||
alert(completeTaskCreationText);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// Validate time entry requirements (task, description)
|
||
const notesEl = document.getElementById('startTimerNotes');
|
||
const requireTask = modal && modal.dataset.requireTask === 'true';
|
||
const requireDescription = modal && modal.dataset.requireDescription === 'true';
|
||
const descMinLen = modal ? parseInt(modal.dataset.descriptionMinLength || '20', 10) : 20;
|
||
if (projectVal && requireTask && !taskVal) {
|
||
const msg = modal?.dataset.taskRequiredMsg || 'A task must be selected when logging time for a project';
|
||
if (window.toastManager && typeof window.toastManager.error === 'function') {
|
||
window.toastManager.error(msg, errorTitleText, 5000);
|
||
} else {
|
||
alert(msg);
|
||
}
|
||
return false;
|
||
}
|
||
if (requireDescription) {
|
||
let notesVal = notesEl ? notesEl.value : '';
|
||
if (window.dashboardNotesEditor && typeof window.dashboardNotesEditor.getMarkdown === 'function') {
|
||
try { notesVal = window.dashboardNotesEditor.getMarkdown(); } catch (e) {}
|
||
}
|
||
const notesTrimmed = (notesVal || '').trim();
|
||
if (!notesTrimmed) {
|
||
const msg = modal?.dataset.descriptionRequiredMsg || 'A description is required when logging time';
|
||
if (window.toastManager && typeof window.toastManager.error === 'function') {
|
||
window.toastManager.error(msg, errorTitleText, 5000);
|
||
} else {
|
||
alert(msg);
|
||
}
|
||
return false;
|
||
}
|
||
if (notesTrimmed.length < descMinLen) {
|
||
const msg = (modal?.dataset.descriptionMinMsg || 'Description must be at least ' + descMinLen + ' characters').replace(/\d+/, String(descMinLen));
|
||
if (window.toastManager && typeof window.toastManager.error === 'function') {
|
||
window.toastManager.error(msg, errorTitleText, 5000);
|
||
} else {
|
||
alert(msg);
|
||
}
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// Sync Toast UI Editor notes into hidden textarea (programmatic submit does not fire submit event)
|
||
if (notesEl && window.dashboardNotesEditor && typeof window.dashboardNotesEditor.getMarkdown === 'function') {
|
||
try {
|
||
notesEl.value = window.dashboardNotesEditor.getMarkdown();
|
||
} catch (err) {
|
||
console.error('Failed to sync markdown editor:', err);
|
||
}
|
||
}
|
||
|
||
// Now submit the form normally
|
||
startTimerForm.submit();
|
||
}, true); // Use capture phase to run before other handlers
|
||
}
|
||
}
|
||
|
||
// Template application function
|
||
window.applyTemplate = async function(templateId) {
|
||
try {
|
||
const response = await fetch(`/api/templates/${templateId}`, { credentials: 'same-origin' });
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP ${response.status}`);
|
||
}
|
||
const template = await response.json();
|
||
|
||
// Get form elements (re-select to avoid scope issues)
|
||
const projectSelect = document.getElementById('startTimerProject');
|
||
const taskSelect = document.getElementById('startTimerTask');
|
||
const notesField = document.getElementById('startTimerNotes');
|
||
|
||
if (!projectSelect || !taskSelect || !notesField) {
|
||
throw new Error('Form elements not found');
|
||
}
|
||
|
||
// Apply template values to form
|
||
if (template.project_id) {
|
||
projectSelect.value = template.project_id;
|
||
// Trigger change event to load tasks
|
||
projectSelect.dispatchEvent(new Event('change'));
|
||
|
||
// Wait for tasks to load, then select task
|
||
setTimeout(() => {
|
||
if (template.task_id) {
|
||
const found = Array.from(taskSelect.options).some(o => o.value === String(template.task_id));
|
||
if (found) taskSelect.value = String(template.task_id);
|
||
}
|
||
}, 350);
|
||
}
|
||
if (template.default_notes) {
|
||
notesField.value = template.default_notes;
|
||
// Update markdown editor if it exists
|
||
if (window.dashboardNotesEditor && typeof window.dashboardNotesEditor.setMarkdown === 'function') {
|
||
try {
|
||
window.dashboardNotesEditor.setMarkdown(template.default_notes);
|
||
} catch (e) {}
|
||
}
|
||
}
|
||
|
||
// Mark template as used
|
||
const csrfToken = modal ? modal.querySelector('input[name="csrf_token"]')?.value : '';
|
||
fetch(`/api/templates/${templateId}/use`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-CSRFToken': csrfToken || ''
|
||
}
|
||
}).catch(() => {}); // Silently fail if marking fails
|
||
|
||
} catch (error) {
|
||
console.error('Error applying template:', error);
|
||
alert('Failed to apply template. Please try again.');
|
||
}
|
||
};
|
||
|
||
// Initialize Toast UI Editor for timer notes
|
||
const notesInput = document.getElementById('startTimerNotes');
|
||
const notesPlaceholderText = document.getElementById('startTimerModal')?.dataset?.notesPlaceholder || 'What are you working on?';
|
||
if (notesInput && window.toastui && window.toastui.Editor) {
|
||
const theme = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
|
||
window.dashboardNotesEditor = new toastui.Editor({
|
||
el: document.getElementById('startTimerNotes_editor'),
|
||
height: '250px',
|
||
initialEditType: 'wysiwyg',
|
||
previewStyle: 'vertical',
|
||
usageStatistics: false,
|
||
hideModeSwitch: false,
|
||
placeholder: notesPlaceholderText,
|
||
theme: theme,
|
||
toolbarItems: [
|
||
['heading', 'bold', 'italic', 'strike'],
|
||
['hr', 'quote'],
|
||
['ul', 'ol', 'task'],
|
||
['link', 'code', 'codeblock', 'table'],
|
||
['image'],
|
||
['scrollSync']
|
||
],
|
||
initialValue: notesInput.value || ''
|
||
});
|
||
|
||
// Apply theme changes dynamically if supported
|
||
const observer = new MutationObserver(function(mutations) {
|
||
mutations.forEach(function(mutation) {
|
||
if (mutation.type === 'attributes' && mutation.attributeName === 'class' && window.dashboardNotesEditor) {
|
||
const nextTheme = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
|
||
try {
|
||
if (typeof window.dashboardNotesEditor.setTheme === 'function') {
|
||
window.dashboardNotesEditor.setTheme(nextTheme);
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
});
|
||
});
|
||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
||
|
||
// Multiple image upload handler - improved version
|
||
window.dashboardNotesEditor.removeHook && window.dashboardNotesEditor.removeHook('addImageBlobHook');
|
||
|
||
// Create custom multiple file input with unique ID
|
||
const dashboardFileInputId = 'dashboard-image-input-' + Date.now();
|
||
let dashboardFileInput = document.getElementById(dashboardFileInputId);
|
||
if (!dashboardFileInput) {
|
||
dashboardFileInput = document.createElement('input');
|
||
dashboardFileInput.id = dashboardFileInputId;
|
||
dashboardFileInput.type = 'file';
|
||
dashboardFileInput.accept = 'image/*';
|
||
dashboardFileInput.setAttribute('multiple', 'multiple'); // Use setAttribute to ensure it's set
|
||
dashboardFileInput.style.display = 'none';
|
||
dashboardFileInput.style.position = 'absolute';
|
||
dashboardFileInput.style.left = '-9999px';
|
||
document.body.appendChild(dashboardFileInput);
|
||
}
|
||
|
||
// Verify multiple attribute is set
|
||
if (!dashboardFileInput.hasAttribute('multiple')) {
|
||
dashboardFileInput.setAttribute('multiple', 'multiple');
|
||
}
|
||
console.log('File input multiple attribute:', dashboardFileInput.multiple, dashboardFileInput.hasAttribute('multiple'));
|
||
|
||
// Function to find image button with multiple strategies
|
||
function findImageButton(toolbar, retries = 0) {
|
||
if (!toolbar) return null;
|
||
|
||
// Try multiple selectors in order of reliability
|
||
const selectors = [
|
||
'[data-tooltip="Insert image"]',
|
||
'[data-tooltip*="image" i]',
|
||
'[aria-label*="image" i]',
|
||
'.toastui-editor-toolbar-icons.image',
|
||
'button[class*="image"]',
|
||
'.toastui-editor-toolbar-group button:nth-child(5)', // Common position for image button
|
||
'.toastui-editor-toolbar-group button:nth-child(6)',
|
||
'.toastui-editor-toolbar-group button:nth-child(4)'
|
||
];
|
||
|
||
for (const selector of selectors) {
|
||
try {
|
||
const button = toolbar.querySelector(selector);
|
||
if (button) {
|
||
// Verify it's likely the image button by checking if it has image-related attributes or classes
|
||
const buttonText = (button.textContent || '').toLowerCase();
|
||
const buttonTitle = (button.title || button.getAttribute('title') || '').toLowerCase();
|
||
if (buttonText.includes('image') || buttonTitle.includes('image') ||
|
||
selector.includes('image') || button.classList.toString().includes('image')) {
|
||
return button;
|
||
}
|
||
}
|
||
} catch (e) {
|
||
// Continue to next selector
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
// Function to intercept image button with retry logic
|
||
function setupImageButtonInterception(editor, input, maxRetries = 5) {
|
||
let attempts = 0;
|
||
let intercepted = false;
|
||
|
||
function tryIntercept() {
|
||
attempts++;
|
||
try {
|
||
// Get the editor's root element - try multiple ways
|
||
const editorContainer = document.getElementById('startTimerNotes_editor');
|
||
if (!editorContainer) {
|
||
throw new Error('Editor container element not found');
|
||
}
|
||
|
||
// Find toolbar - try multiple ways to access it
|
||
let toolbar = null;
|
||
// Try finding toolbar relative to editor container
|
||
let editorWrapper = null;
|
||
if (editorContainer.closest) {
|
||
editorWrapper = editorContainer.closest('.toastui-editor');
|
||
}
|
||
if (!editorWrapper && editorContainer.parentElement) {
|
||
editorWrapper = editorContainer.parentElement;
|
||
}
|
||
if (editorWrapper) {
|
||
toolbar = editorWrapper.querySelector('.toastui-editor-toolbar');
|
||
}
|
||
// Fallback: search entire document
|
||
if (!toolbar) {
|
||
toolbar = document.querySelector('.toastui-editor-toolbar');
|
||
}
|
||
|
||
if (toolbar && !intercepted) {
|
||
const imageButton = findImageButton(toolbar, attempts);
|
||
if (imageButton) {
|
||
// Use capture phase to intercept before ToastUI's handler
|
||
imageButton.addEventListener('click', function handler(e) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
e.stopImmediatePropagation();
|
||
|
||
// Small delay to ensure default behavior is prevented
|
||
setTimeout(() => {
|
||
input.click();
|
||
}, 10);
|
||
|
||
return false;
|
||
}, true); // Use capture phase
|
||
|
||
intercepted = true;
|
||
console.log('Successfully intercepted image button for multiple file selection');
|
||
return true;
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.warn('Error intercepting image button (attempt ' + attempts + '):', err);
|
||
}
|
||
|
||
// Retry if we haven't exceeded max retries
|
||
if (attempts < maxRetries && !intercepted) {
|
||
setTimeout(tryIntercept, 200 * attempts); // Exponential backoff
|
||
return false;
|
||
} else if (!intercepted) {
|
||
console.warn('Could not intercept image button after ' + maxRetries + ' attempts. Multiple image selection may not work via toolbar button.');
|
||
return false;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// Start with initial delay to ensure toolbar is ready
|
||
setTimeout(tryIntercept, 100);
|
||
}
|
||
|
||
// Override the addImageBlobHook to use multiple file selection
|
||
// This is the fallback if button interception fails
|
||
if (window.dashboardNotesEditor && typeof window.dashboardNotesEditor.addHook === 'function') {
|
||
window.dashboardNotesEditor.addHook('addImageBlobHook', async function(blob, callback) {
|
||
// Prevent the default single file behavior
|
||
// Trigger our multiple file input instead
|
||
setTimeout(function() {
|
||
dashboardFileInput.click();
|
||
}, 10);
|
||
// Don't call callback - let dashboardFileInput.change handle it
|
||
});
|
||
}
|
||
|
||
// Setup button interception
|
||
if (window.dashboardNotesEditor) {
|
||
setupImageButtonInterception(window.dashboardNotesEditor, dashboardFileInput);
|
||
}
|
||
|
||
// Handle multiple file selection with progress feedback
|
||
dashboardFileInput.addEventListener('change', async (e) => {
|
||
const files = Array.from(e.target.files);
|
||
console.log('File input changed. Files selected:', files.length, 'Multiple attribute:', dashboardFileInput.multiple);
|
||
|
||
if (files.length === 0) {
|
||
console.log('No files selected');
|
||
return;
|
||
}
|
||
|
||
// Log file details for debugging
|
||
files.forEach((file, index) => {
|
||
console.log(`File ${index + 1}: ${file.name}, type: ${file.type}, size: ${file.size}`);
|
||
});
|
||
|
||
// Validate file types
|
||
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp'];
|
||
const validFiles = files.filter(file => {
|
||
const isValid = allowedTypes.includes(file.type) || /\.(png|jpg|jpeg|gif|webp)$/i.test(file.name);
|
||
if (!isValid) {
|
||
console.warn('Skipping invalid file type:', file.name, file.type);
|
||
}
|
||
return isValid;
|
||
});
|
||
|
||
if (validFiles.length === 0) {
|
||
alert('{{ _("No valid image files selected. Please select PNG, JPG, GIF, or WebP images.") }}');
|
||
dashboardFileInput.value = '';
|
||
return;
|
||
}
|
||
|
||
if (validFiles.length < files.length) {
|
||
console.warn('Some files were skipped due to invalid type');
|
||
}
|
||
|
||
// Show loading feedback
|
||
const fileCount = validFiles.length;
|
||
const loadingMsg = fileCount > 1 ?
|
||
`{{ _("Uploading") }} ${fileCount} {{ _("images") }}...` :
|
||
'{{ _("Uploading image") }}...';
|
||
|
||
// Try to show a temporary loading indicator (if toast/notification system exists)
|
||
let loadingIndicator = null;
|
||
try {
|
||
// Check if there's a toast/notification system we can use
|
||
if (window.showToast || window.showNotification) {
|
||
const showToast = window.showToast || window.showNotification;
|
||
loadingIndicator = showToast(loadingMsg, 'info', { duration: 0 });
|
||
}
|
||
} catch (e) {
|
||
// No toast system available, continue without it
|
||
}
|
||
|
||
try {
|
||
const formData = new FormData();
|
||
validFiles.forEach(file => {
|
||
formData.append('images', file);
|
||
});
|
||
|
||
const res = await fetch('{{ url_for('api.upload_editor_images_bulk') }}', {
|
||
method: 'POST',
|
||
body: formData
|
||
});
|
||
|
||
const data = await res.json();
|
||
if (data && data.urls && data.urls.length > 0) {
|
||
// Insert all images into editor
|
||
const imagesMarkdown = data.urls.map((url, index) => {
|
||
const fileName = validFiles[index].name || 'image';
|
||
return ``;
|
||
}).join('\n\n');
|
||
|
||
// Get current markdown content
|
||
const currentMarkdown = window.dashboardNotesEditor.getMarkdown() || '';
|
||
|
||
// Append images to current content (with proper spacing)
|
||
const newMarkdown = currentMarkdown
|
||
? currentMarkdown + '\n\n' + imagesMarkdown
|
||
: imagesMarkdown;
|
||
|
||
// Set the markdown which will properly render images in WYSIWYG mode
|
||
window.dashboardNotesEditor.setMarkdown(newMarkdown);
|
||
|
||
// Show success message
|
||
const successMsg = data.urls.length > 1 ?
|
||
`{{ _("Successfully uploaded") }} ${data.urls.length} {{ _("images") }}` :
|
||
'{{ _("Image uploaded successfully") }}';
|
||
|
||
if (loadingIndicator && typeof loadingIndicator.update === 'function') {
|
||
loadingIndicator.update(successMsg, 'success');
|
||
setTimeout(() => loadingIndicator.remove && loadingIndicator.remove(), 2000);
|
||
}
|
||
|
||
// Show warnings if any
|
||
if (data.warnings && data.warnings.length > 0) {
|
||
console.warn('Some images failed to upload:', data.warnings);
|
||
const warningMsg = `{{ _("Warning") }}: ${data.warnings.length} {{ _("image(s) failed to upload") }}`;
|
||
if (window.showToast || window.showNotification) {
|
||
const showToast = window.showToast || window.showNotification;
|
||
showToast(warningMsg, 'warning');
|
||
} else {
|
||
alert(warningMsg);
|
||
}
|
||
}
|
||
} else {
|
||
const errorMsg = data.error || '{{ _("Failed to upload images. Please try again.") }}';
|
||
console.error('Upload failed', data);
|
||
if (loadingIndicator && typeof loadingIndicator.remove === 'function') {
|
||
loadingIndicator.remove();
|
||
}
|
||
alert(errorMsg);
|
||
}
|
||
} catch (error) {
|
||
console.error('Multiple image upload error', error);
|
||
if (loadingIndicator && typeof loadingIndicator.remove === 'function') {
|
||
loadingIndicator.remove();
|
||
}
|
||
alert('{{ _("Failed to upload images. Please check your connection and try again.") }}');
|
||
}
|
||
|
||
// Reset file input
|
||
dashboardFileInput.value = '';
|
||
});
|
||
|
||
}
|
||
});
|
||
</script>
|
||
|
||
<!-- Toast UI Editor -->
|
||
<script src="https://uicdn.toast.com/editor/latest/toastui-editor-all.min.js"></script>
|
||
{% endblock %}
|