mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2025-12-30 15:49:44 -06:00
feat: enhance offline sync and improve performance (v4.3.2)
- Add comprehensive offline sync improvements with enhanced IndexedDB support - Optimize task model with cached total_hours calculation for better performance - Improve task service query optimization and eager loading strategies - Update CSP policy to allow CDN connections for improved resource loading - Enhance service worker with better background sync capabilities - Improve error handling and offline queue processing - Update base template and comment templates for better UX - Bump version to 4.3.2
This commit is contained in:
@@ -715,7 +715,7 @@ def create_app(config=None):
|
||||
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://fonts.googleapis.com https://cdn.datatables.net https://uicdn.toast.com; "
|
||||
"font-src 'self' https://fonts.gstatic.com https://cdnjs.cloudflare.com data:; "
|
||||
"script-src 'self' 'unsafe-inline' https://code.jquery.com https://cdn.datatables.net https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://esm.sh https://uicdn.toast.com; "
|
||||
"connect-src 'self' ws: wss:; "
|
||||
"connect-src 'self' ws: wss: https://cdn.jsdelivr.net https://cdnjs.cloudflare.com; "
|
||||
"frame-ancestors 'none'"
|
||||
)
|
||||
response.headers["Content-Security-Policy"] = csp
|
||||
|
||||
@@ -74,26 +74,16 @@ class Task(db.Model):
|
||||
@property
|
||||
def total_hours(self):
|
||||
"""Calculate total hours spent on this task"""
|
||||
# Use cached value if available (set by TaskService.list_tasks for performance)
|
||||
if hasattr(self, '_cached_total_hours'):
|
||||
return self._cached_total_hours
|
||||
|
||||
try:
|
||||
from .time_entry import TimeEntry
|
||||
|
||||
total_seconds = (
|
||||
db.session.query(
|
||||
db.func.sum(
|
||||
db.func.coalesce(
|
||||
db.func.extract(
|
||||
"epoch",
|
||||
db.func.greatest(
|
||||
db.func.least(
|
||||
db.func.coalesce(self.time_entries.end_time, now_in_app_timezone()),
|
||||
now_in_app_timezone(),
|
||||
)
|
||||
- self.time_entries.start_time
|
||||
),
|
||||
),
|
||||
0,
|
||||
)
|
||||
)
|
||||
)
|
||||
.filter(self.time_entries.project_id == self.project_id)
|
||||
db.session.query(db.func.sum(TimeEntry.duration_seconds))
|
||||
.filter(TimeEntry.task_id == self.id, TimeEntry.end_time.isnot(None))
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
@@ -190,12 +190,14 @@ def create_task():
|
||||
def view_task(task_id):
|
||||
"""View task details - REFACTORED to use service layer with eager loading"""
|
||||
from app.services import TaskService
|
||||
from sqlalchemy.orm import joinedload
|
||||
from app.models import Comment
|
||||
|
||||
task_service = TaskService()
|
||||
|
||||
# Get task with all relations using eager loading (prevents N+1 queries)
|
||||
task = task_service.get_task_with_details(
|
||||
task_id=task_id, include_time_entries=True, include_comments=True, include_activities=True
|
||||
task_id=task_id, include_time_entries=False, include_comments=False, include_activities=False
|
||||
)
|
||||
|
||||
if not task:
|
||||
@@ -207,13 +209,12 @@ def view_task(task_id):
|
||||
flash(_("You do not have access to this task"), "error")
|
||||
return redirect(url_for("tasks.list_tasks"))
|
||||
|
||||
# Get time entries (time_entries is a dynamic relationship, so query it)
|
||||
# Get time entries with pagination (limit to 100 most recent to avoid loading too many)
|
||||
# Eagerly load user relationship to prevent N+1 queries
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
time_entries = (
|
||||
task.time_entries.options(joinedload(TimeEntry.user))
|
||||
.order_by(TimeEntry.start_time.desc(), TimeEntry.id.desc())
|
||||
.limit(100)
|
||||
.all()
|
||||
)
|
||||
|
||||
@@ -226,10 +227,28 @@ def view_task(task_id):
|
||||
.all()
|
||||
)
|
||||
|
||||
# Get comments for this task
|
||||
from app.models import Comment
|
||||
|
||||
comments = Comment.get_task_comments(task_id, include_replies=True)
|
||||
# Get comments for this task with eager loading of authors and replies to prevent N+1 queries
|
||||
# Load all comments (including replies) with their authors to avoid lazy loading issues
|
||||
# Use selectinload for replies to load them in a separate query, preventing circular loading
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
# Load all comments for this task with eager loading
|
||||
all_comments = (
|
||||
Comment.query.filter_by(task_id=task_id)
|
||||
.options(
|
||||
joinedload(Comment.author), # Eagerly load author for all comments
|
||||
# Load replies with their authors - selectinload loads all direct replies in one query
|
||||
# This prevents N+1 queries when accessing comment.replies in the template
|
||||
selectinload(Comment.replies).joinedload(Comment.author)
|
||||
)
|
||||
.order_by(Comment.created_at.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
# Filter to only top-level comments (no parent_id) for the template
|
||||
# The replies relationship is now eagerly loaded for direct replies
|
||||
# Nested replies beyond the first level will be loaded lazily, but the template depth limit prevents issues
|
||||
comments = [c for c in all_comments if c.parent_id is None]
|
||||
|
||||
return render_template(
|
||||
"tasks/view.html", task=task, time_entries=time_entries, activities=activities, comments=comments
|
||||
|
||||
@@ -234,4 +234,31 @@ class TaskService:
|
||||
# Paginate
|
||||
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
|
||||
|
||||
return {"tasks": pagination.items, "pagination": pagination, "total": pagination.total}
|
||||
# Pre-calculate total_hours for all tasks in a single query to avoid N+1
|
||||
# This prevents the template from triggering individual queries for each task
|
||||
tasks = pagination.items
|
||||
if tasks:
|
||||
from app.models import TimeEntry
|
||||
task_ids = [task.id for task in tasks]
|
||||
|
||||
# Calculate total hours for all tasks in one query
|
||||
results = (
|
||||
db.session.query(
|
||||
TimeEntry.task_id,
|
||||
db.func.sum(TimeEntry.duration_seconds).label('total_seconds')
|
||||
)
|
||||
.filter(
|
||||
TimeEntry.task_id.in_(task_ids),
|
||||
TimeEntry.end_time.isnot(None)
|
||||
)
|
||||
.group_by(TimeEntry.task_id)
|
||||
.all()
|
||||
)
|
||||
total_hours_map = {task_id: total_seconds for task_id, total_seconds in results}
|
||||
|
||||
# Cache the calculated values on task objects to avoid property queries
|
||||
for task in tasks:
|
||||
total_seconds = total_hours_map.get(task.id, 0) or 0
|
||||
task._cached_total_hours = round(total_seconds / 3600, 2) if total_seconds else 0.0
|
||||
|
||||
return {"tasks": tasks, "pagination": pagination, "total": pagination.total}
|
||||
|
||||
@@ -47,10 +47,12 @@ class EnhancedErrorHandler {
|
||||
this.handleOffline();
|
||||
});
|
||||
|
||||
// Periodic online check
|
||||
// Periodic online check - every 30 seconds
|
||||
// This helps detect cases where browser thinks it's online but server is unreachable
|
||||
// The browser's online/offline events handle most cases, this is a fallback
|
||||
setInterval(() => {
|
||||
this.checkOnlineStatus();
|
||||
}, 5000);
|
||||
}, 30000); // Check every 30 seconds instead of 5
|
||||
}
|
||||
|
||||
checkOnlineStatus() {
|
||||
|
||||
@@ -122,13 +122,68 @@ class OfflineSyncManager {
|
||||
|
||||
async getPendingSyncCount() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction(['syncQueue'], 'readonly');
|
||||
const store = transaction.objectStore('syncQueue');
|
||||
const index = store.index('processed');
|
||||
const request = index.count(IDBKeyRange.only(false));
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result || 0);
|
||||
try {
|
||||
const transaction = this.db.transaction(['syncQueue'], 'readonly');
|
||||
const store = transaction.objectStore('syncQueue');
|
||||
|
||||
// Check if the index exists
|
||||
if (!store.indexNames.contains('processed')) {
|
||||
// If index doesn't exist, count manually
|
||||
const request = store.openCursor();
|
||||
let count = 0;
|
||||
request.onsuccess = (event) => {
|
||||
const cursor = event.target.result;
|
||||
if (cursor) {
|
||||
if (cursor.value.processed === false || !cursor.value.processed) {
|
||||
count++;
|
||||
}
|
||||
cursor.continue();
|
||||
} else {
|
||||
resolve(count);
|
||||
}
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const index = store.index('processed');
|
||||
// Use getAll with a key range for boolean values
|
||||
// IndexedDB can be finicky with boolean values, so we'll use a cursor approach
|
||||
const request = index.openCursor(IDBKeyRange.only(false));
|
||||
let count = 0;
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
const cursor = event.target.result;
|
||||
if (cursor) {
|
||||
count++;
|
||||
cursor.continue();
|
||||
} else {
|
||||
resolve(count);
|
||||
}
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
// Fallback: count manually if index query fails
|
||||
const fallbackRequest = store.openCursor();
|
||||
let fallbackCount = 0;
|
||||
fallbackRequest.onsuccess = (event) => {
|
||||
const cursor = event.target.result;
|
||||
if (cursor) {
|
||||
if (cursor.value.processed === false || !cursor.value.processed) {
|
||||
fallbackCount++;
|
||||
}
|
||||
cursor.continue();
|
||||
} else {
|
||||
resolve(fallbackCount);
|
||||
}
|
||||
};
|
||||
fallbackRequest.onerror = () => reject(fallbackRequest.error);
|
||||
};
|
||||
} catch (error) {
|
||||
// If there's any error, return 0 instead of rejecting
|
||||
console.warn('[OfflineSync] Error counting pending sync, returning 0:', error);
|
||||
resolve(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -326,13 +381,52 @@ class OfflineSyncManager {
|
||||
|
||||
async getUnsyncedEntries(storeName) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction([storeName], 'readonly');
|
||||
const store = transaction.objectStore(storeName);
|
||||
const index = store.index('synced');
|
||||
const request = index.getAll(IDBKeyRange.only(false));
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result || []);
|
||||
try {
|
||||
const transaction = this.db.transaction([storeName], 'readonly');
|
||||
const store = transaction.objectStore(storeName);
|
||||
|
||||
// Check if the index exists
|
||||
if (!store.indexNames.contains('synced')) {
|
||||
// If index doesn't exist, filter manually
|
||||
const request = store.getAll();
|
||||
request.onsuccess = () => {
|
||||
const all = request.result || [];
|
||||
const unsynced = all.filter(entry => entry.synced === false || !entry.synced);
|
||||
resolve(unsynced);
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const index = store.index('synced');
|
||||
// Use openCursor for boolean values to avoid IDBKeyRange issues
|
||||
const request = index.openCursor(IDBKeyRange.only(false));
|
||||
const results = [];
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
const cursor = event.target.result;
|
||||
if (cursor) {
|
||||
results.push(cursor.value);
|
||||
cursor.continue();
|
||||
} else {
|
||||
resolve(results);
|
||||
}
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
// Fallback: filter manually if index query fails
|
||||
const fallbackRequest = store.getAll();
|
||||
fallbackRequest.onsuccess = () => {
|
||||
const all = fallbackRequest.result || [];
|
||||
const unsynced = all.filter(entry => entry.synced === false || !entry.synced);
|
||||
resolve(unsynced);
|
||||
};
|
||||
fallbackRequest.onerror = () => reject(fallbackRequest.error);
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('[OfflineSync] Error getting unsynced entries, returning empty array:', error);
|
||||
resolve([]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -380,23 +474,69 @@ class OfflineSyncManager {
|
||||
if (!this.db) return;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction(['syncQueue'], 'readwrite');
|
||||
const store = transaction.objectStore('syncQueue');
|
||||
const index = store.index('processed');
|
||||
const request = index.openCursor(IDBKeyRange.only(false));
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = async (event) => {
|
||||
const cursor = event.target.result;
|
||||
if (cursor) {
|
||||
const item = cursor.value;
|
||||
// Process queue item based on type
|
||||
// This will be handled by specific sync methods
|
||||
cursor.continue();
|
||||
} else {
|
||||
resolve();
|
||||
try {
|
||||
const transaction = this.db.transaction(['syncQueue'], 'readwrite');
|
||||
const store = transaction.objectStore('syncQueue');
|
||||
|
||||
// Check if the index exists
|
||||
if (!store.indexNames.contains('processed')) {
|
||||
// If index doesn't exist, iterate manually
|
||||
const request = store.openCursor();
|
||||
request.onsuccess = async (event) => {
|
||||
const cursor = event.target.result;
|
||||
if (cursor) {
|
||||
const item = cursor.value;
|
||||
// Process queue item based on type
|
||||
// This will be handled by specific sync methods
|
||||
if (item.processed === false || !item.processed) {
|
||||
// Process unprocessed items
|
||||
}
|
||||
cursor.continue();
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const index = store.index('processed');
|
||||
const request = index.openCursor(IDBKeyRange.only(false));
|
||||
|
||||
request.onerror = () => {
|
||||
// Fallback: iterate manually if index query fails
|
||||
const fallbackRequest = store.openCursor();
|
||||
fallbackRequest.onsuccess = async (event) => {
|
||||
const cursor = event.target.result;
|
||||
if (cursor) {
|
||||
const item = cursor.value;
|
||||
if (item.processed === false || !item.processed) {
|
||||
// Process queue item based on type
|
||||
// This will be handled by specific sync methods
|
||||
}
|
||||
cursor.continue();
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
fallbackRequest.onerror = () => reject(fallbackRequest.error);
|
||||
};
|
||||
|
||||
request.onsuccess = async (event) => {
|
||||
const cursor = event.target.result;
|
||||
if (cursor) {
|
||||
const item = cursor.value;
|
||||
// Process queue item based on type
|
||||
// This will be handled by specific sync methods
|
||||
cursor.continue();
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('[OfflineSync] Error processing sync queue:', error);
|
||||
resolve(); // Resolve instead of reject to prevent blocking
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ const CACHE_VERSION = 'v1.0.1';
|
||||
const CACHE_NAME = `timetracker-${CACHE_VERSION}`;
|
||||
|
||||
// Resources to cache immediately
|
||||
// Note: External CDN resources are excluded due to CSP restrictions
|
||||
// They will be cached on-demand when fetched by the browser
|
||||
const PRECACHE_URLS = [
|
||||
'/',
|
||||
'/static/dist/output.css',
|
||||
@@ -14,8 +16,7 @@ const PRECACHE_URLS = [
|
||||
'/static/enhanced-ui.js',
|
||||
'/static/charts.js',
|
||||
'/static/interactions.js',
|
||||
'/static/images/timetracker-logo.svg',
|
||||
'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css'
|
||||
'/static/images/timetracker-logo.svg'
|
||||
];
|
||||
|
||||
// Resources to cache on first use
|
||||
@@ -34,7 +35,20 @@ self.addEventListener('install', event => {
|
||||
caches.open(CACHE_NAME)
|
||||
.then(cache => {
|
||||
console.log('[ServiceWorker] Precaching app shell');
|
||||
return cache.addAll(PRECACHE_URLS);
|
||||
// Only cache same-origin resources to avoid CSP violations
|
||||
const sameOriginUrls = PRECACHE_URLS.filter(url => {
|
||||
try {
|
||||
const urlObj = new URL(url, self.location.origin);
|
||||
return urlObj.origin === self.location.origin;
|
||||
} catch {
|
||||
// Relative URLs are same-origin
|
||||
return !url.startsWith('http://') && !url.startsWith('https://');
|
||||
}
|
||||
});
|
||||
return cache.addAll(sameOriginUrls).catch(error => {
|
||||
console.warn('[ServiceWorker] Some resources failed to cache:', error);
|
||||
// Continue even if some resources fail
|
||||
});
|
||||
})
|
||||
.then(() => self.skipWaiting())
|
||||
);
|
||||
|
||||
@@ -278,8 +278,7 @@ class SmartNotificationManager {
|
||||
// Check every hour
|
||||
setInterval(sendSummary, 60 * 60 * 1000);
|
||||
|
||||
// Check immediately
|
||||
sendSummary();
|
||||
// Don't check immediately on page load - only show at scheduled time (6 PM)
|
||||
}
|
||||
|
||||
async sendDailySummary() {
|
||||
|
||||
@@ -1434,50 +1434,67 @@
|
||||
|
||||
// Install prompt
|
||||
let deferredPrompt;
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
e.preventDefault();
|
||||
deferredPrompt = e;
|
||||
|
||||
// Check if user has previously dismissed the install prompt
|
||||
const installPromptDismissed = localStorage.getItem('pwa-install-dismissed');
|
||||
if (installPromptDismissed === 'true') {
|
||||
return; // Don't show the prompt if it was dismissed before
|
||||
}
|
||||
|
||||
// Show install button in UI
|
||||
if (window.toastManager) {
|
||||
const toast = window.toastManager.info('Install TimeTracker as an app!', 0);
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = 'Install';
|
||||
btn.className = 'ml-2 px-3 py-1 bg-primary text-white rounded hover:bg-primary/90';
|
||||
btn.onclick = async () => {
|
||||
deferredPrompt.prompt();
|
||||
const { outcome } = await deferredPrompt.userChoice;
|
||||
if (outcome === 'accepted') {
|
||||
window.toastManager.success('App installed successfully!');
|
||||
localStorage.setItem('pwa-install-dismissed', 'true');
|
||||
} else {
|
||||
// User declined, remember their choice
|
||||
localStorage.setItem('pwa-install-dismissed', 'true');
|
||||
}
|
||||
deferredPrompt = null;
|
||||
toast.remove();
|
||||
};
|
||||
|
||||
// Check if user has previously dismissed the install prompt or app is already installed
|
||||
const installPromptDismissed = localStorage.getItem('pwa-install-dismissed');
|
||||
const isAppInstalled = window.matchMedia('(display-mode: standalone)').matches ||
|
||||
window.navigator.standalone === true ||
|
||||
document.referrer.includes('android-app://');
|
||||
|
||||
// Only register the event listener if prompt hasn't been dismissed and app isn't installed
|
||||
if (installPromptDismissed !== 'true' && !isAppInstalled) {
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Add a dismiss button
|
||||
const dismissBtn = document.createElement('button');
|
||||
dismissBtn.textContent = '×';
|
||||
dismissBtn.className = 'ml-2 px-2 py-1 text-white hover:bg-white/20 rounded';
|
||||
dismissBtn.title = 'Dismiss permanently';
|
||||
dismissBtn.onclick = () => {
|
||||
localStorage.setItem('pwa-install-dismissed', 'true');
|
||||
toast.remove();
|
||||
};
|
||||
// Double-check dismissal status when event fires
|
||||
const stillDismissed = localStorage.getItem('pwa-install-dismissed');
|
||||
if (stillDismissed === 'true') {
|
||||
return; // Don't show the prompt if it was dismissed
|
||||
}
|
||||
|
||||
toast.appendChild(btn);
|
||||
toast.appendChild(dismissBtn);
|
||||
}
|
||||
});
|
||||
deferredPrompt = e;
|
||||
|
||||
// Show install button in UI
|
||||
if (window.toastManager) {
|
||||
const toast = window.toastManager.info('Install TimeTracker as an app!', 0);
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = 'Install';
|
||||
btn.className = 'ml-2 px-3 py-1 bg-primary text-white rounded hover:bg-primary/90';
|
||||
btn.onclick = async () => {
|
||||
if (!deferredPrompt) return;
|
||||
deferredPrompt.prompt();
|
||||
const { outcome } = await deferredPrompt.userChoice;
|
||||
if (outcome === 'accepted') {
|
||||
window.toastManager.success('App installed successfully!');
|
||||
}
|
||||
// Always mark as dismissed after user interaction
|
||||
localStorage.setItem('pwa-install-dismissed', 'true');
|
||||
deferredPrompt = null;
|
||||
toast.remove();
|
||||
};
|
||||
|
||||
// Add a dismiss button
|
||||
const dismissBtn = document.createElement('button');
|
||||
dismissBtn.textContent = '×';
|
||||
dismissBtn.className = 'ml-2 px-2 py-1 text-white hover:bg-white/20 rounded';
|
||||
dismissBtn.title = 'Dismiss permanently';
|
||||
dismissBtn.onclick = () => {
|
||||
localStorage.setItem('pwa-install-dismissed', 'true');
|
||||
deferredPrompt = null;
|
||||
toast.remove();
|
||||
};
|
||||
|
||||
toast.appendChild(btn);
|
||||
toast.appendChild(dismissBtn);
|
||||
}
|
||||
});
|
||||
|
||||
// Also listen for app installation to mark as dismissed
|
||||
window.addEventListener('appinstalled', () => {
|
||||
localStorage.setItem('pwa-install-dismissed', 'true');
|
||||
deferredPrompt = null;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
{# Set default depth if not provided #}
|
||||
{% set depth = depth | default(0) %}
|
||||
{% set max_depth = 10 %}
|
||||
|
||||
{# Prevent infinite recursion by limiting depth #}
|
||||
{% if depth < max_depth %}
|
||||
<!-- Single comment template -->
|
||||
<div class="comment" id="comment-{{ comment.id }}" data-comment-id="{{ comment.id }}">
|
||||
<div class="comment-header d-flex align-items-center mb-2">
|
||||
@@ -89,15 +95,29 @@
|
||||
</div>
|
||||
|
||||
<!-- Replies -->
|
||||
{% if comment.replies %}
|
||||
{% if comment.replies and depth < max_depth %}
|
||||
<div class="comment-replies mt-3">
|
||||
{% for reply in comment.replies %}
|
||||
<div class="comment-reply ms-4">
|
||||
{% with comment=reply %}
|
||||
{% with comment=reply, depth=depth+1 %}
|
||||
{% include 'comments/_comment.html' %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elif comment.replies and depth >= max_depth %}
|
||||
<div class="comment-replies mt-3">
|
||||
<div class="text-muted small">
|
||||
<i class="fas fa-info-circle"></i> {{ _('Replies are too deeply nested to display.') }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
{# Depth limit reached - show placeholder #}
|
||||
<div class="comment" id="comment-{{ comment.id }}" data-comment-id="{{ comment.id }}">
|
||||
<div class="text-muted small">
|
||||
<i class="fas fa-info-circle"></i> {{ _('Comment nesting too deep to display.') }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -57,7 +57,9 @@
|
||||
<div class="comment-item mb-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
{% include 'comments/_comment.html' with context %}
|
||||
{% with depth=0 %}
|
||||
{% include 'comments/_comment.html' %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user