Fix AJAX navigation, WebSocket script scope, and Daphne media serving 404s

This commit is contained in:
2026-05-24 17:38:00 +02:00
parent 88bdfc797b
commit 6767695639
2 changed files with 93 additions and 33 deletions
+57 -27
View File
@@ -324,8 +324,12 @@
</style>
</head>
<body>
<div id="app-root">
{% block body %}{% endblock %}
</div>
<!-- Build version badge -->
<div id="app-version" style="position:fixed;bottom:4px;left:4px;font-size:0.6rem;color:rgba(255,255,255,0.25);z-index:9999;pointer-events:none;font-family:monospace;">v2026.05.24-b</div>
<!-- Voice connected status panel -->
<div class="voice-status-panel" id="voice-status-panel">
<div class="voice-status-info">
@@ -341,7 +345,7 @@
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
<script id="base-script">
(function() {
const currentUserId = {% if request.user.is_authenticated %}{{ request.user.id }}{% else %}null{% endif %};
const currentUsername = "{% if request.user.is_authenticated %}{{ request.user.username|escapejs }}{% endif %}";
@@ -596,15 +600,20 @@
});
// AJAX Shell Navigation
// Pages that should always do a full-page load (non-SPA layouts, auth pages)
const FULL_PAGE_KEYWORDS = ['create', 'profile', 'admin', 'login', 'register', 'logout', 'delete', 'block'];
function shouldFullPageNavigate(href) {
if (!href || href.startsWith('http') || href.startsWith('#') || href.startsWith('javascript')) return true;
return FULL_PAGE_KEYWORDS.some(kw => href.includes(kw));
}
document.body.addEventListener('click', function(e) {
const link = e.target.closest('a');
if (!link) return;
const href = link.getAttribute('href');
if (!href || href.startsWith('http') || href.startsWith('#') || href.includes('delete') || href.includes('logout') || href.includes('admin') || href.includes('profile')) {
return;
}
if (shouldFullPageNavigate(href)) return;
if (link.target === '_blank') return;
e.preventDefault();
@@ -612,35 +621,47 @@
});
function loadPage(url) {
// Close existing channel/DM WebSocket before navigating
if (window.channelWs) {
window.channelWs.close();
window.channelWs = null;
}
fetch(url)
.then(res => res.text())
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.text();
})
.then(html => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const newMain = doc.querySelector('.main-content');
const currentMain = document.querySelector('.main-content');
if (newMain && currentMain) {
currentMain.replaceWith(newMain);
}
const newRoot = doc.getElementById('app-root');
const currentRoot = document.getElementById('app-root');
document.querySelectorAll('.channel-item.active').forEach(el => el.classList.remove('active'));
const activeLink = document.querySelector(`.channel-item[href="${url}"]`);
if (activeLink) activeLink.classList.add('active');
if (window.channelWs) {
window.channelWs.close();
if (newRoot && currentRoot) {
currentRoot.replaceWith(newRoot);
} else {
// Fallback: full page load if structure doesn't match
window.location.href = url;
return;
}
history.pushState({ url }, '', url);
// Execute only PAGE-SPECIFIC scripts (those inside the scripts block).
// SKIP the base-script IIFE — it's already running and re-executing
// it would duplicate global WebSocket connections and event handlers.
// Wrap each script in an IIFE to prevent 'const' redeclaration errors
// when navigating between channels (each has const channelId, etc.).
doc.querySelectorAll('script').forEach(script => {
if (!script.src && (script.textContent.includes('WebSocket') || script.textContent.includes('channelWs'))) {
if (script.id === 'base-script') return;
if (script.src) return;
if (!script.textContent.trim()) return;
const newScript = document.createElement('script');
newScript.textContent = script.textContent;
newScript.textContent = `(function(){${script.textContent}})();`;
document.body.appendChild(newScript);
newScript.remove();
}
});
})
.catch(err => {
@@ -657,27 +678,36 @@
}
};
// AJAX form submit handler — only for file upload forms inside chat/DM views.
// Forms for auth, profile, channel create, admin actions submit natively.
document.body.addEventListener('submit', function(e) {
const form = e.target.closest('form');
if (!form || form.getAttribute('action')?.includes('login') || form.getAttribute('action')?.includes('register') || form.getAttribute('action')?.includes('profile') || form.getAttribute('action')?.includes('create')) {
return;
}
if (!form) return;
const action = form.getAttribute('action') || '';
// Let these forms submit natively (full page reload)
if (FULL_PAGE_KEYWORDS.some(kw => action.includes(kw))) return;
// Only AJAX-submit file upload forms (they have enctype=multipart)
if (!form.enctype || form.enctype !== 'multipart/form-data') return;
e.preventDefault();
const formData = new FormData(form);
const action = form.getAttribute('action') || location.pathname;
const submitUrl = action || location.pathname;
fetch(action, {
fetch(submitUrl, {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(() => {
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
// Reload current page to show the uploaded file
loadPage(location.pathname);
})
.catch(err => console.error("Upload form failed:", err));
.catch(err => console.error("File upload failed:", err));
});
})();
</script>
+33 -3
View File
@@ -1,13 +1,43 @@
from django.contrib import admin
from django.urls import path, include
from django.urls import path, re_path, include
from django.conf import settings
from django.conf.urls.static import static
from django.views.static import serve as static_serve
from django.http import FileResponse, Http404
import os
from urllib.parse import unquote
def serve_media(request, path):
"""Serve media files — works under Daphne/ASGI where static() does not."""
# URL-decode the path (handles %20 for spaces, etc.)
decoded_path = unquote(path)
file_path = os.path.join(settings.MEDIA_ROOT, decoded_path)
if os.path.isfile(file_path):
return FileResponse(open(file_path, 'rb'))
# Fallback: try the raw (non-decoded) path
raw_path = os.path.join(settings.MEDIA_ROOT, path)
if os.path.isfile(raw_path):
return FileResponse(open(raw_path, 'rb'))
print(f"[MEDIA 404] Requested: {path}")
print(f"[MEDIA 404] Decoded: {decoded_path}")
print(f"[MEDIA 404] Full path: {file_path}")
print(f"[MEDIA 404] Exists: {os.path.exists(file_path)}")
print(f"[MEDIA 404] Dir listing: {os.listdir(os.path.dirname(file_path)) if os.path.isdir(os.path.dirname(file_path)) else 'DIR NOT FOUND'}")
raise Http404(f"Media file not found: {decoded_path}")
urlpatterns = [
path('admin/', admin.site.urls),
# Serve media files explicitly — needed because Daphne (ASGI) does not
# honour the static() helper the way Django's runserver does.
# Must be BEFORE the catch-all chat.urls include.
re_path(r'^media/(?P<path>.*)$', serve_media),
path('', include('chat.urls')),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) \
+ static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
handler404 = 'chat.views.handler404'
handler500 = 'chat.views.handler500'