16 Commits
Author SHA1 Message Date
Keith SmithandClaude Sonnet 4.5 1d6b07bfed Implement recurring tasks functionality
Adds automatic creation of next task instance when recurring tasks are completed.

- Add calculate_next_due_date() and create_next_recurrence() methods to Task model
- Update task completion handlers in views and sync API to create next recurrence
- Add hourly Celery task to process any missed recurring tasks
- Support daily, weekly, biweekly, monthly, yearly recurrence patterns
- Respect recurrence_end_date limits

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-04 10:00:34 -07:00
Keith Smith c51fd846a2 Fix mobile sidebar background - use --surface like desktop 2025-12-27 08:58:36 -07:00
Keith Smith fcb5ab5ca3 Make mobile overlay transparent - sidebar slides over content without darkening 2025-12-27 08:56:57 -07:00
Keith Smith fc1a4844a3 Remove mobile sidebar overlay transparency - make fully opaque 2025-12-27 08:55:37 -07:00
Keith SmithandClaude Sonnet 4.5 7f2096db86 Increase mobile sidebar overlay opacity
Changed mobile overlay from 50% to 75% opacity (25% transparent)
to make the sidebar content more visible when open on mobile devices.

Updated rgba(0, 0, 0, 0.5) to rgba(0, 0, 0, 0.75)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-27 08:53:18 -07:00
Keith Smith ea40beb408 Update debug endpoint to show User-Agent detection in HTML 2025-12-26 22:31:25 -07:00
Keith SmithandClaude Sonnet 4.5 123d6af430 Update middleware to ADD security headers for browsers
Middleware now:
- Detects mobile app vs browser via User-Agent
- Mobile app: Removes frame-blocking headers (allows iframe)
- Browsers: Adds CSP and X-Frame-Options headers (security)

This ensures:
✓ Browsers get full CSP protection
✓ Mobile app can embed content
✓ No need for django-csp package
✓ All security managed in one place

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-26 22:29:00 -07:00
Keith SmithandClaude Sonnet 4.5 c686021f96 Fix middleware to also remove CSP frame-ancestors header
The CSP_FRAME_ANCESTORS = ("'none'",) setting in production.py was
blocking iframe embedding even after removing X-Frame-Options.

Updated middleware to:
- Detect Android WebView via 'wv' in User-Agent (more reliable)
- Remove both X-Frame-Options AND Content-Security-Policy headers
- This allows mobile app iframe embedding while keeping browser protection

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-26 22:18:03 -07:00
Keith Smith 41abdf4074 Add debug endpoint to check mobile app User-Agent 2025-12-26 22:13:07 -07:00
Keith SmithandClaude Sonnet 4.5 31a0c3c8c5 Add middleware to allow mobile app iframe embedding
Created Django middleware that detects requests from the KeepItGoing
mobile app (via User-Agent) and removes X-Frame-Options header to
allow iframe embedding.

Changes:
- Created tasks/middleware/mobile_app.py with AllowMobileAppFramingMiddleware
- Added middleware to settings after XFrameOptionsMiddleware
- Detects Capacitor WebView User-Agent patterns
- Removes X-Frame-Options only for mobile app, keeps protection for browsers

This allows the mobile app to embed the website in an iframe while
maintaining clickjacking protection for regular web browsers.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-26 22:08:05 -07:00
Keith SmithandClaude Sonnet 4.5 8db9f99701 CRITICAL FIX: Override .detail-closed specificity at mobile
Root cause: The .app-layout.detail-closed class (2-class specificity)
was overriding .app-layout (1-class specificity) at mobile, creating
a 3-column grid "0 1fr 0" instead of single column "1fr".

The task-pane was placed in column 1 (0 width), making it invisible.

Fix: Explicitly override both .app-layout and .app-layout.detail-closed
with equal specificity in the 768px breakpoint.

Cache bust: v=6

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-26 21:33:11 -07:00
Keith SmithandClaude Sonnet 4.5 8b71447024 Fix task pane layout at mobile - add explicit grid positioning
The task-pane was not displaying at mobile because it lacked explicit
grid positioning and width constraints. Added:
- grid-row: 2 and grid-column: 1 for explicit placement
- width: 100%, min-width: 0, max-width: 100% to ensure full width
- Moved header grid positioning into mobile breakpoint for clarity

Also updated header to have explicit grid-row: 1 positioning.

Cache bust: v=5

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-26 21:30:06 -07:00
Keith SmithandClaude Sonnet 4.5 f836769ad7 CRITICAL FIX: Move base CSS before media queries
The sidebar-toggle and mobile-overlay base definitions were placed
AFTER the media queries, causing them to override the responsive
styles. This prevented the hamburger menu from showing and broke
the sidebar drawer functionality.

Fixed by moving base definitions before the Mobile Responsive section.

CSS order is now correct:
1. Base styles (display: none for sidebar-toggle)
2. Media queries (display: flex at 768px)

This allows the media query to properly override the base style.

Updated cache version to v=4.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-26 21:18:14 -07:00
Keith SmithandClaude Sonnet 4.5 d83b1e3091 Fix mobile header layout - remove vertical stacking
The flex-direction: column on header-user was causing buttons to
overflow outside the header bar. Changed approach to keep buttons
horizontal but make them smaller on very small screens.

Changes:
- Removed flex-direction: column from 480px breakpoint
- Keep buttons horizontal with smaller font size
- Reduced button min-height to 36px on small screens
- Reduced touch targets slightly (40px) for very small screens
- Smaller brand font size (0.9rem)

Updated cache version to v=3 to force reload.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-26 21:14:30 -07:00
Keith Smith e038e47247 Add cache busting to CSS for mobile updates 2025-12-26 21:07:07 -07:00
Keith SmithandClaude Sonnet 4.5 d5010580a4 Implement mobile responsive design improvements
Fixed critical mobile layout issues on phones (400-600px width):

Header improvements:
- Hide email on mobile to save space
- Compact user menu layout
- Adjust brand font size for mobile
- Better spacing with adjusted padding

Sidebar drawer fixes:
- Properly hide sidebar off-screen (translateX(-100%))
- Increase z-index to 100 for proper layering
- Add background and shadow for drawer effect
- Wider drawer (280px) for better touch targets

Touch target improvements:
- All buttons minimum 44px height (Apple HIG standard)
- Larger touch areas for sidebar toggle and theme toggle
- Improved checkbox sizing (24px)

Task list mobile optimization:
- Smaller fonts for tags and priority badges
- Text ellipsis for long tag names
- Better wrapping for task metadata
- Reduced padding for mobile screens

Modal and form improvements:
- Full-width detail pane on mobile
- Responsive modal sizing (95% width, max 400px)
- Better form field spacing

New 480px breakpoint:
- Ultra-small phone optimization
- Vertical stack for Profile/Logout buttons
- Further reduced font sizes and padding

Mobile utility classes:
- .mobile-only / .desktop-only for conditional display
- .mobile-full-width for full-width elements

All changes are CSS-only, no JavaScript modifications needed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-26 21:01:17 -07:00
12 changed files with 431 additions and 27 deletions
+4
View File
@@ -30,6 +30,10 @@ app.conf.beat_schedule = {
'task': 'notifications.tasks.check_overdue_tasks', 'task': 'notifications.tasks.check_overdue_tasks',
'schedule': crontab(hour=8, minute=0), # Daily at 8 AM 'schedule': crontab(hour=8, minute=0), # Daily at 8 AM
}, },
'process-recurring-tasks': {
'task': 'notifications.tasks.process_recurring_tasks',
'schedule': crontab(minute=0), # Every hour
},
} }
+1
View File
@@ -44,6 +44,7 @@ MIDDLEWARE = [
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',
'tasks.middleware.AllowMobileAppFramingMiddleware', # Allow mobile app iframe embedding
] ]
ROOT_URLCONF = 'config.urls' ROOT_URLCONF = 'config.urls'
+4
View File
@@ -11,12 +11,16 @@ from users.urls import api_urlpatterns as users_api_urls, web_urlpatterns as use
from tasks.urls import api_urlpatterns as tasks_api_urls, web_urlpatterns as tasks_web_urls from tasks.urls import api_urlpatterns as tasks_api_urls, web_urlpatterns as tasks_web_urls
from sync.urls import api_urlpatterns as sync_api_urls from sync.urls import api_urlpatterns as sync_api_urls
from notifications.urls import api_urlpatterns as notifications_api_urls from notifications.urls import api_urlpatterns as notifications_api_urls
from tasks.views_debug import debug_user_agent
from .views import api_root from .views import api_root
urlpatterns = [ urlpatterns = [
# Admin # Admin
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
# Debug endpoint (remove in production)
path('debug/user-agent/', debug_user_agent, name='debug-user-agent'),
# API root # API root
path('api/', api_root, name='api-root'), path('api/', api_root, name='api-root'),
+48
View File
@@ -236,3 +236,51 @@ def schedule_task_reminder(task_id):
task=task, task=task,
remind_at=remind_at remind_at=remind_at
) )
@shared_task
def process_recurring_tasks():
"""
Process completed recurring tasks and create next instances.
This runs periodically as a safety net to catch any tasks that weren't
automatically handled when marked as completed.
Runs every hour via Celery Beat.
"""
from tasks.models import Task
# Find completed recurring tasks that don't have a next instance created yet
# We look for tasks completed in the last 24 hours to avoid reprocessing old tasks
yesterday = timezone.now() - timedelta(days=1)
completed_recurring_tasks = Task.objects.filter(
status='completed',
recurrence__in=['daily', 'weekly', 'biweekly', 'monthly', 'yearly', 'custom'],
completed_at__gte=yesterday
).exclude(recurrence='none')
tasks_created = 0
for task in completed_recurring_tasks:
# Check if a next recurrence already exists
# Look for pending tasks with the same title, user, and recurrence pattern
next_due_date = task.calculate_next_due_date()
if next_due_date:
# Check if we already created this recurrence
existing = Task.objects.filter(
user=task.user,
title=task.title,
due_date=next_due_date,
recurrence=task.recurrence,
status='pending'
).exists()
if not existing:
# Create the next recurrence
new_task = task.create_next_recurrence()
if new_task:
tasks_created += 1
logger.info(f"Created recurring task: {new_task.title} (due: {new_task.due_date})")
if tasks_created > 0:
logger.info(f"Created {tasks_created} recurring task instances")
return tasks_created
+171 -21
View File
@@ -857,6 +857,34 @@ a:hover {
font-size: var(--font-sm); font-size: var(--font-sm);
} }
/* Sidebar toggle button (hidden on desktop, shown on mobile) */
.sidebar-toggle {
display: none;
background: none;
border: none;
padding: var(--space-sm);
cursor: pointer;
color: var(--text-secondary);
}
.sidebar-toggle svg {
width: 24px;
height: 24px;
}
/* Mobile overlay - removed dark background, sidebar slides over content */
.mobile-overlay {
display: none;
position: fixed;
inset: 0;
background-color: transparent;
z-index: 40;
}
.mobile-overlay.visible {
display: block;
}
/* ============================================ /* ============================================
Mobile Responsive Mobile Responsive
============================================ */ ============================================ */
@@ -886,19 +914,53 @@ a:hover {
--sidebar-width: 0; --sidebar-width: 0;
} }
.app-layout { /* Override both app-layout variants to use single column */
.app-layout,
.app-layout.detail-closed {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
/* Explicit grid positioning for mobile */
.app-header {
grid-row: 1;
grid-column: 1;
padding: var(--space-sm) var(--space-md);
}
/* Force task pane into the single column with full width */
.task-pane {
grid-row: 2;
grid-column: 1;
width: 100%;
min-width: 0;
max-width: 100%;
}
.header-user span {
display: none; /* Hide email on mobile */
}
.app-header-actions {
gap: var(--space-xs);
}
.app-brand {
font-size: 1.1rem; /* Slightly smaller on mobile */
}
/* Sidebar drawer - improved positioning */
.app-sidebar { .app-sidebar {
position: fixed; position: fixed;
left: 0; left: 0;
top: var(--header-height); top: var(--header-height);
bottom: 0; bottom: 0;
width: 260px; width: 280px; /* Slightly wider for touch targets */
z-index: 50; z-index: 100; /* Higher z-index */
transform: translateX(-100%); transform: translateX(-100%);
transition: transform var(--anim-normal); transition: transform 0.3s ease-in-out;
background-color: var(--surface);
box-shadow: var(--shadow-lg);
overflow-y: auto;
} }
.app-sidebar.open { .app-sidebar.open {
@@ -909,40 +971,128 @@ a:hover {
display: flex; display: flex;
} }
/* Detail pane mobile */
.detail-pane { .detail-pane {
width: 100%; width: 100%;
max-width: 100vw;
padding: var(--space-md);
} }
/* Forms */
.form-row { .form-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
}
/* Sidebar toggle button (visible on mobile) */ .form-group {
.sidebar-toggle { margin-bottom: var(--space-md);
display: none; }
background: none;
border: none; /* Task list mobile optimization */
.task-item {
padding: var(--space-sm); padding: var(--space-sm);
cursor: pointer; }
color: var(--text-secondary);
}
.sidebar-toggle svg { .task-meta {
flex-wrap: wrap;
gap: var(--space-xs);
font-size: 0.75rem;
}
.priority-badge {
font-size: 0.7rem;
padding: 2px 6px;
}
.tag {
font-size: 0.7rem;
padding: 2px 6px;
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
}
/* Touch targets - 44px minimum */
.task-item-checkbox {
width: 24px; width: 24px;
height: 24px; height: 24px;
}
.btn {
min-height: 44px;
padding: var(--space-sm) var(--space-md);
}
.sidebar-toggle {
min-width: 44px;
min-height: 44px;
padding: var(--space-sm);
}
.theme-toggle {
min-width: 44px;
min-height: 44px;
}
/* Modal on mobile */
.modal {
width: 95%;
max-width: 400px;
max-height: 90vh;
overflow-y: auto;
}
/* Mobile utility classes */
.mobile-only {
display: block;
}
.desktop-only {
display: none !important;
}
.mobile-full-width {
width: 100% !important;
}
} }
/* Mobile overlay */ /* Very small phones breakpoint */
.mobile-overlay { @media (max-width: 480px) {
.app-header {
padding: var(--space-xs) var(--space-sm);
}
.app-brand {
font-size: 0.9rem;
}
.app-sidebar {
width: 260px; /* Narrower on very small screens */
}
/* Smaller buttons on very small screens */
.header-user .btn {
font-size: 0.75rem;
padding: var(--space-xs) var(--space-sm);
min-height: 36px;
}
.theme-toggle {
min-width: 40px;
min-height: 40px;
}
.sidebar-toggle {
min-width: 40px;
min-height: 40px;
}
}
/* Mobile utility classes base definitions */
.mobile-only {
display: none; display: none;
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 40;
} }
.mobile-overlay.visible { .desktop-only {
display: block; display: block;
} }
+7
View File
@@ -291,6 +291,9 @@ def process_time_entry_changes(user, entry_changes, last_sync_at):
def update_task_from_data(task, data): def update_task_from_data(task, data):
"""Update a task from sync data.""" """Update a task from sync data."""
# Track old status to detect completion
old_status = task.status
task.title = data.get('title', task.title) task.title = data.get('title', task.title)
task.description = data.get('description', task.description) task.description = data.get('description', task.description)
task.status = data.get('status', task.status) task.status = data.get('status', task.status)
@@ -302,6 +305,10 @@ def update_task_from_data(task, data):
task.recurrence_rule = data.get('recurrence_rule', task.recurrence_rule) task.recurrence_rule = data.get('recurrence_rule', task.recurrence_rule)
task.sort_order = data.get('sort_order', task.sort_order) task.sort_order = data.get('sort_order', task.sort_order)
# Create next recurrence if task is being marked as completed
if old_status != 'completed' and task.status == 'completed' and task.recurrence != 'none':
task.create_next_recurrence()
task.save() task.save()
# Handle tags # Handle tags
+3
View File
@@ -0,0 +1,3 @@
from .mobile_app import AllowMobileAppFramingMiddleware
__all__ = ['AllowMobileAppFramingMiddleware']
+61
View File
@@ -0,0 +1,61 @@
"""
Middleware to allow iframe embedding for the KeepItGoing mobile app.
The mobile app uses Capacitor WebView which embeds the website in an iframe.
This middleware detects requests from the mobile app and removes both
X-Frame-Options and Content-Security-Policy frame-ancestors headers to allow
iframe embedding, while keeping clickjacking protection for regular web browsers.
"""
class AllowMobileAppFramingMiddleware:
"""
Remove frame-blocking headers for requests from KeepItGoing mobile app.
The mobile app uses a Capacitor WebView. We detect these requests via
User-Agent and remove X-Frame-Options and CSP frame-ancestors headers.
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
# Check if request is from KeepItGoing mobile app
user_agent = request.META.get('HTTP_USER_AGENT', '')
# Detect Capacitor/Android WebView patterns
is_mobile_app = (
'wv' in user_agent.lower() or # Android WebView
'CapacitorHttp' in user_agent or
'com.firebugit.keepitgoing' in user_agent or
('KeepItGoing' in user_agent and 'Mobile' in user_agent)
)
if is_mobile_app:
# Mobile app: Allow iframe embedding - don't add frame-blocking headers
# Remove any existing frame headers
if 'X-Frame-Options' in response:
del response['X-Frame-Options']
if 'Content-Security-Policy' in response:
del response['Content-Security-Policy']
else:
# Regular browsers: Add security headers for clickjacking protection
if 'X-Frame-Options' not in response:
response['X-Frame-Options'] = 'DENY'
if 'Content-Security-Policy' not in response:
response['Content-Security-Policy'] = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: https:; "
"font-src 'self' data:; "
"connect-src 'self'; "
"frame-ancestors 'none'; "
"base-uri 'self'; "
"form-action 'self';"
)
return response
+73
View File
@@ -174,6 +174,79 @@ class Task(models.Model):
secs = seconds % 60 secs = seconds % 60
return f"{hours}:{minutes:02d}:{secs:02d}" return f"{hours}:{minutes:02d}:{secs:02d}"
def calculate_next_due_date(self, from_date=None):
"""
Calculate the next due date based on recurrence pattern.
Returns None if task doesn't recur or has reached end date.
"""
if self.recurrence == 'none':
return None
from datetime import timedelta
from dateutil.relativedelta import relativedelta
base_date = from_date or self.due_date
if not base_date:
from django.utils import timezone
base_date = timezone.now().date()
# Calculate next date based on recurrence type
if self.recurrence == 'daily':
next_date = base_date + timedelta(days=1)
elif self.recurrence == 'weekly':
next_date = base_date + timedelta(weeks=1)
elif self.recurrence == 'biweekly':
next_date = base_date + timedelta(weeks=2)
elif self.recurrence == 'monthly':
next_date = base_date + relativedelta(months=1)
elif self.recurrence == 'yearly':
next_date = base_date + relativedelta(years=1)
elif self.recurrence == 'custom' and self.recurrence_rule:
# TODO: Implement RRULE parsing for custom recurrence
# For now, default to weekly
next_date = base_date + timedelta(weeks=1)
else:
return None
# Check if we've passed the end date
if self.recurrence_end_date and next_date > self.recurrence_end_date:
return None
return next_date
def create_next_recurrence(self):
"""
Create the next instance of this recurring task.
Returns the new task or None if no recurrence should be created.
"""
if self.recurrence == 'none':
return None
next_due_date = self.calculate_next_due_date()
if not next_due_date:
return None
# Create new task with same properties
new_task = Task.objects.create(
user=self.user,
parent=self.parent,
title=self.title,
description=self.description,
status='pending',
priority=self.priority,
due_date=next_due_date,
due_time=self.due_time,
recurrence=self.recurrence,
recurrence_rule=self.recurrence_rule,
recurrence_end_date=self.recurrence_end_date,
sort_order=self.sort_order,
)
# Copy tags
new_task.tags.set(self.tags.all())
return new_task
class TimeEntry(models.Model): class TimeEntry(models.Model):
""" """
+13
View File
@@ -425,6 +425,9 @@ class TaskDetailView(View):
task.title = request.POST.get('title', task.title) task.title = request.POST.get('title', task.title)
task.description = request.POST.get('description', '') task.description = request.POST.get('description', '')
# Track old status to detect completion
old_status = task.status
# Validate status against allowed choices # Validate status against allowed choices
status = request.POST.get('status', task.status) status = request.POST.get('status', task.status)
valid_statuses = [choice[0] for choice in Task.STATUS_CHOICES] valid_statuses = [choice[0] for choice in Task.STATUS_CHOICES]
@@ -451,6 +454,10 @@ class TaskDetailView(View):
else: else:
task.recurrence = 'none' task.recurrence = 'none'
# Create next recurrence if task is being marked as completed
if old_status != 'completed' and task.status == 'completed' and task.recurrence != 'none':
task.create_next_recurrence()
task.save() task.save()
# Handle tags # Handle tags
@@ -564,7 +571,13 @@ def task_toggle_status(request, task_id):
if task.status == 'completed': if task.status == 'completed':
task.status = 'pending' task.status = 'pending'
else: else:
# Mark as completed
task.status = 'completed' task.status = 'completed'
# Create next recurrence if this is a recurring task
if task.recurrence != 'none':
task.create_next_recurrence()
task.save() task.save()
next_url = request.POST.get('next', 'dashboard') next_url = request.POST.get('next', 'dashboard')
+40
View File
@@ -0,0 +1,40 @@
"""Debug views for troubleshooting mobile app."""
from django.http import JsonResponse, HttpResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
@csrf_exempt
@require_http_methods(["GET", "POST"])
def debug_user_agent(request):
"""Return the User-Agent header for debugging mobile app."""
user_agent = request.META.get('HTTP_USER_AGENT', 'No User-Agent')
# Simple HTML response that's easy to see in iframe
html = f"""
<!DOCTYPE html>
<html>
<head>
<title>Debug Info</title>
<style>
body {{ font-family: monospace; padding: 20px; }}
.detected {{ color: green; font-weight: bold; }}
.not-detected {{ color: red; font-weight: bold; }}
</style>
</head>
<body>
<h2>Mobile App Debug Info</h2>
<p><strong>User-Agent:</strong><br>{user_agent}</p>
<p><strong>Contains 'wv':</strong> {'wv' in user_agent.lower()}</p>
<p><strong>Contains 'CapacitorHttp':</strong> {'CapacitorHttp' in user_agent}</p>
<p><strong>Is Mobile App Detected:</strong>
<span class="{'detected' if ('wv' in user_agent.lower() or 'CapacitorHttp' in user_agent) else 'not-detected'}">
{('wv' in user_agent.lower() or 'CapacitorHttp' in user_agent)}
</span>
</p>
</body>
</html>
"""
return HttpResponse(html)
+1 -1
View File
@@ -10,7 +10,7 @@
<link rel="icon" type="image/svg+xml" href="{% static 'favicons/favicon.svg' %}"> <link rel="icon" type="image/svg+xml" href="{% static 'favicons/favicon.svg' %}">
<link rel="alternate icon" href="{% static 'favicons/favicon.svg' %}" type="image/svg+xml"> <link rel="alternate icon" href="{% static 'favicons/favicon.svg' %}" type="image/svg+xml">
<link rel="stylesheet" href="{% static 'css/app.css' %}"> <link rel="stylesheet" href="{% static 'css/app.css' %}?v=6">
{% block extra_css %}{% endblock %} {% block extra_css %}{% endblock %}
</head> </head>
<body> <body>