fix a few things

This commit is contained in:
Ross
2026-06-08 10:57:01 +01:00
parent 9919af2b20
commit 161da980cf
12 changed files with 436 additions and 36 deletions
+18
View File
@@ -3,8 +3,17 @@ from .models import Case, CaseCollection, Series
def user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access(function):
"""Decorator to check if user is author of the series,
an atlas editor, an atlas marker, or if the series is open access.
Used for series-level permissions.
Requires the decorated view to have a "pk" or "series_id" kwarg to identify the series.
"""
def wrap(request, *args, **kwargs):
if "pk" in kwargs:
series = Series.objects.get(pk=kwargs["pk"])
elif "series_id" in kwargs:
series = Series.objects.get(pk=kwargs["series_id"])
if (
request.user in series.get_author_objects()
or series.open_access
@@ -23,7 +32,10 @@ def user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access(functi
def user_is_author_or_atlas_series_checker(function):
def wrap(request, *args, **kwargs):
if "pk" in kwargs:
series = Series.objects.get(pk=kwargs["pk"])
elif "series_id" in kwargs:
series = Series.objects.get(pk=kwargs["series_id"])
if (
request.user in series.get_author_objects()
or request.user.groups.filter(name="atlas_editor").exists()
@@ -40,7 +52,10 @@ def user_is_author_or_atlas_series_checker(function):
def user_has_case_view_access(function):
def wrap(request, *args, **kwargs):
if "pk" in kwargs:
atlas = Case.objects.get(pk=kwargs["pk"])
elif "case_id" in kwargs:
atlas = Case.objects.get(pk=kwargs["case_id"])
# If open access everyone can view
if atlas.open_access:
@@ -62,7 +77,10 @@ def user_has_case_view_access(function):
def user_is_author_or_atlas_editor(function):
def wrap(request, *args, **kwargs):
if "pk" in kwargs:
atlas = Case.objects.get(pk=kwargs["pk"])
elif "case_id" in kwargs:
atlas = Case.objects.get(pk=kwargs["case_id"])
if (
request.user in atlas.author.all()
or request.user.groups.filter(name="atlas_editor").exists()
+7 -8
View File
@@ -405,23 +405,22 @@ def series_reconstruct_task(
axis_positions_mm = np.arange(axis_length, dtype=float) * float(axis_spacing)
end_pos = axis_positions_mm[-1] if axis_positions_mm.size else 0.0
start_positions_mm = np.arange(0.0, end_pos + 1e-6, output_spacing, dtype=float)
if start_positions_mm.size == 0:
start_positions_mm = np.array([0.0], dtype=float)
center_positions_mm = np.arange(0.0, end_pos + 1e-6, output_spacing, dtype=float)
if center_positions_mm.size == 0:
center_positions_mm = np.array([0.0], dtype=float)
slabs = []
centers_idx = []
for start_mm in start_positions_mm:
end_mm = start_mm + slab_thickness
mask = (axis_positions_mm >= (start_mm - 1e-6)) & (axis_positions_mm < (end_mm - 1e-6))
half_thickness = slab_thickness / 2.0
for center_mm in center_positions_mm:
mask = np.abs(axis_positions_mm - center_mm) <= (half_thickness + 1e-6)
if not mask.any():
nearest = int(np.argmin(np.abs(axis_positions_mm - start_mm)))
nearest = int(np.argmin(np.abs(axis_positions_mm - center_mm)))
idxs = np.array([nearest], dtype=int)
else:
idxs = np.where(mask)[0].astype(int)
slabs.append(idxs)
center_mm = start_mm + (slab_thickness / 2.0)
centers_idx.append(float(center_mm / axis_spacing) if axis_spacing > 0 else float(idxs[0]))
return slabs, centers_idx
@@ -1,5 +1,13 @@
<div class="case-search-widget">
{% with input_id=input_id|default:'case-search-input' target_id=target_id|default:'case-search-results' %}
<style>
.case-search-loading {
display: none;
}
.case-search-widget.is-loading .case-search-loading {
display: inline-flex;
}
</style>
<label for="{{ input_id }}" class="form-label">Search cases</label>
{% if collection %}
<input type="hidden" id="{{ input_id }}-collection" name="collection" value="{{ collection.pk }}">
@@ -13,8 +21,14 @@
hx-include="closest .case-search-widget"
hx-target="#{{ target_id }}"
hx-trigger="input delay:400ms"
hx-indicator="#{{ input_id }}-loading"
autocomplete="off">
<div id="{{ input_id }}-loading" class="case-search-loading align-items-center gap-2 mt-2 text-muted small">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span>Searching cases...</span>
</div>
<div id="{{ target_id }}" class="mt-2">
{# Render initial results (search + recent) on initial GET; HTMX searches will replace this div with partial results #}
{% include 'atlas/partials/case_search_results.html' with cases=cases recent_cases=recent_cases collection=collection show_select_button=show_select_button %}
@@ -27,7 +41,14 @@
try {
var input = document.getElementById('{{ input_id|default:"case-search-input" }}');
if (!input) return;
var widget = input.closest('.case-search-widget');
var timer = null;
function setLoading(on) {
if (!widget) return;
widget.classList.toggle('is-loading', !!on);
}
input.addEventListener('input', function(){
if (timer) clearTimeout(timer);
timer = setTimeout(function(){
@@ -36,12 +57,13 @@
var url = "{% url 'atlas:case_search' %}?q=" + q + ({% if collection %} "&collection={{ collection.pk }}" {% else %} '' {% endif %}) + ({% if show_select_button %} "&show_select=1" {% else %} '' {% endif %});
var target = '#{{ target_id|default:"case-search-results" }}';
if (window.htmx && typeof window.htmx.ajax === 'function') {
window.htmx.ajax('GET', url, { target: target, swap: 'innerHTML' });
window.htmx.ajax('GET', url, { target: target, swap: 'innerHTML', indicator: '#{{ input_id|default:"case-search-input" }}-loading' });
} else {
setLoading(true);
fetch(url, { credentials: 'same-origin' }).then(function(r){ return r.text(); }).then(function(html){
var el = document.querySelector(target);
if (el) el.innerHTML = html;
}).catch(function(e){ console.error('case search fetch error', e); });
}).catch(function(e){ console.error('case search fetch error', e); }).finally(function(){ setLoading(false); });
}
} catch(e) { console.error(e); }
}, 400);
@@ -61,6 +83,17 @@
var targetEl = document.getElementById(targetId);
if (!container || !targetEl) return;
document.body.addEventListener('htmx:beforeRequest', function (event) {
if (event.target === inputEl) {
container.classList.add('is-loading');
}
});
document.body.addEventListener('htmx:afterRequest', function (event) {
if (event.target === inputEl) {
container.classList.remove('is-loading');
}
});
// Delegate clicks on result items and dispatch a scoped custom event
targetEl.addEventListener('click', function(e){
var item = e.target.closest('.list-group-item');
@@ -3,6 +3,10 @@
<div id="current-case-meta" data-current-case="{{ case.pk }}" data-current-case-title="{{ case.title|escape }}" style="display:none;"></div>
{% endif %}
{% with input_id=input_id|default:'displayset-search-input' target_id=target_id|default:'displayset-search-results' %}
<style>
.displayset-search-loading { display: none; }
.displayset-search-widget.is-loading .displayset-search-loading { display: inline-flex; }
</style>
<label for="{{ input_id }}" class="form-label">Search display sets</label>
<input id="{{ input_id }}" name="q" class="form-control" type="search"
placeholder="Type to search display sets..."
@@ -11,8 +15,14 @@
{% if case %}hx-vals='{"case": "{{ case.pk }}"}'{% endif %}
hx-target="#{{ target_id }}"
hx-trigger="keyup changed delay:400ms"
hx-indicator="#{{ input_id }}-loading"
autocomplete="off">
<div id="{{ input_id }}-loading" class="displayset-search-loading align-items-center gap-2 mt-2 text-muted small">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span>Searching display sets...</span>
</div>
<div class="mt-1">
<span id="{{ input_id }}-case-badge" class="badge bg-secondary" style="display: {% if case %}inline-block{% else %}none{% endif %};">
{% if case %}Filtering: {{ case.title|escape }}{% endif %}
@@ -31,7 +41,14 @@
try {
var input = document.getElementById('{{ input_id|default:"displayset-search-input" }}');
if (!input) return;
var widget = input.closest('.displayset-search-widget');
var timer = null;
function setLoading(on) {
if (!widget) return;
widget.classList.toggle('is-loading', !!on);
}
input.addEventListener('input', function(){
if (timer) clearTimeout(timer);
timer = setTimeout(function(){
@@ -40,16 +57,28 @@
var url = "{% url 'atlas:displayset_search' %}?q=" + q + ({% if case %} "&case={{ case.pk }}" {% else %} '' {% endif %});
var target = '#{{ target_id|default:"displayset-search-results" }}';
if (window.htmx && typeof window.htmx.ajax === 'function') {
window.htmx.ajax('GET', url, { target: target, swap: 'innerHTML' });
window.htmx.ajax('GET', url, { target: target, swap: 'innerHTML', indicator: '#{{ input_id|default:"displayset-search-input" }}-loading' });
} else {
setLoading(true);
fetch(url, { credentials: 'same-origin' }).then(function(r){ return r.text(); }).then(function(html){
var el = document.querySelector(target);
if (el) el.innerHTML = html;
}).catch(function(e){ console.error('displayset search fetch error', e); });
}).catch(function(e){ console.error('displayset search fetch error', e); }).finally(function(){ setLoading(false); });
}
} catch(e) { console.error(e); }
}, 400);
});
document.body.addEventListener('htmx:beforeRequest', function (event) {
if (event.target === input) {
setLoading(true);
}
});
document.body.addEventListener('htmx:afterRequest', function (event) {
if (event.target === input) {
setLoading(false);
}
});
} catch(e) { console.error('displayset search widget init error', e); }
})();
</script>
@@ -1,5 +1,9 @@
<div class="finding-search-widget">
{% with input_id=input_id|default:'finding-search-input' target_id=target_id|default:'finding-search-results' %}
<style>
.finding-search-loading { display: none; }
.finding-search-widget.is-loading .finding-search-loading { display: inline-flex; }
</style>
<label for="{{ input_id }}" class="form-label">Search findings</label>
<input id="{{ input_id }}" name="q" class="form-control" type="search"
placeholder="Type to search findings..."
@@ -8,8 +12,14 @@
{% if case %}hx-vals='{"case": "{{ case.pk }}"}'{% endif %}
hx-target="#{{ target_id }}"
hx-trigger="keyup changed delay:400ms"
hx-indicator="#{{ input_id }}-loading"
autocomplete="off">
<div id="{{ input_id }}-loading" class="finding-search-loading align-items-center gap-2 mt-2 text-muted small">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span>Searching findings...</span>
</div>
<div class="mt-1">
<span id="{{ input_id }}-case-badge" class="badge bg-secondary" style="display: {% if case %}inline-block{% else %}none{% endif %};">
{% if case %}Filtering: {{ case.title|escape }}{% endif %}
@@ -28,8 +38,14 @@
try {
var input = document.getElementById('{{ input_id|default:"finding-search-input" }}');
if (!input) return;
var widget = input.closest('.finding-search-widget');
var timer = null;
function setLoading(on) {
if (!widget) return;
widget.classList.toggle('is-loading', !!on);
}
// If server provided a case via inclusion, ensure widget sets dataset and nearby textarea dataset
var meta = document.getElementById('current-case-meta');
var casePk = meta ? meta.getAttribute('data-current-case') : null;
@@ -70,16 +86,28 @@
var url = "{% url 'atlas:finding_search' %}?q=" + q + caseParam;
var target = '#{{ target_id|default:"finding-search-results" }}';
if (window.htmx && typeof window.htmx.ajax === 'function') {
window.htmx.ajax('GET', url, { target: target, swap: 'innerHTML' });
window.htmx.ajax('GET', url, { target: target, swap: 'innerHTML', indicator: '#{{ input_id|default:"finding-search-input" }}-loading' });
} else {
setLoading(true);
fetch(url, { credentials: 'same-origin' }).then(function(r){ return r.text(); }).then(function(html){
var el = document.querySelector(target);
if (el) el.innerHTML = html;
}).catch(function(e){ console.error('finding search fetch error', e); });
}).catch(function(e){ console.error('finding search fetch error', e); }).finally(function(){ setLoading(false); });
}
} catch(e) { console.error(e); }
}, 400);
});
document.body.addEventListener('htmx:beforeRequest', function (event) {
if (event.target === input) {
setLoading(true);
}
});
document.body.addEventListener('htmx:afterRequest', function (event) {
if (event.target === input) {
setLoading(false);
}
});
} catch(e) { console.error('finding search widget init error', e); }
})();
</script>
@@ -0,0 +1,154 @@
{% extends 'base.html' %}
{% load static %}
{% block css %}
{{ block.super }}
<style>
.recon-advanced-viewer {
width: 100%;
min-height: 62vh;
box-sizing: border-box;
background: #111;
border: 1px solid var(--bs-border-color);
border-radius: 0.5rem;
overflow: hidden;
}
.recon-advanced-help {
font-size: 0.9rem;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3">
<div>
<h2 class="mb-1">Advanced Series Reconstruction</h2>
<p class="text-muted mb-0">Plan reconstruction from interactive DV3D MPR viewports with crosshair-linked navigation.</p>
</div>
<a class="btn btn-outline-secondary btn-sm" href="{{ series.get_absolute_url }}">
<i class="bi bi-arrow-left me-1"></i> Back to series
</a>
</div>
<div class="alert alert-info recon-advanced-help">
<div><strong>Workflow:</strong> click <em>Apply MPR layout</em>, switch all panes to volume mode if needed, enable crosshairs in the DV3D toolbar, then position the crosshair planes as desired before generating reconstructions.</div>
</div>
<div id="advanced_recon_root" class="dicom-viewer-root recon-advanced-viewer" data-images='{{ image_ids_json|safe }}'></div>
<div class="card border-0 shadow-sm mt-3">
<div class="card-body">
<form
id="advanced-recon-form"
class="row g-3"
method="post"
action="{% url 'atlas:series_optimize' series_id=series.pk %}"
hx-post="{% url 'atlas:series_optimize' series_id=series.pk %}"
hx-target="#advanced-recon-feedback"
hx-swap="innerHTML"
hx-indicator="#advanced-recon-indicator"
>
{% csrf_token %}
<input type="hidden" name="operation" value="reconstruct">
<input type="hidden" name="recon_viewerstate_json" id="id_recon_viewerstate_json">
<div class="col-12 col-lg-4">
<div class="fw-semibold mb-2">Output planes</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="adv-recon-axial" name="recon_planes" value="axial" checked>
<label class="form-check-label" for="adv-recon-axial">Axial</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="adv-recon-coronal" name="recon_planes" value="coronal" checked>
<label class="form-check-label" for="adv-recon-coronal">Coronal</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="adv-recon-sagittal" name="recon_planes" value="sagittal" checked>
<label class="form-check-label" for="adv-recon-sagittal">Sagittal</label>
</div>
</div>
<div class="col-12 col-lg-4">
<label class="form-label" for="adv-recon-thickness">Slab thickness (mm)</label>
<input id="adv-recon-thickness" type="number" step="0.1" min="0.1" name="recon_slice_thickness" class="form-control" placeholder="e.g. 2.5">
<label class="form-label mt-2" for="adv-recon-spacing">Slice spacing (mm)</label>
<input id="adv-recon-spacing" type="number" step="0.1" min="0.1" name="recon_slice_spacing" class="form-control" placeholder="e.g. 1.0">
</div>
<div class="col-12 col-lg-4">
<label class="form-label" for="adv-recon-mode">Projection mode</label>
<select id="adv-recon-mode" name="recon_thickness_mode" class="form-select">
<option value="mean" selected>Average intensity</option>
<option value="max">Maximum intensity</option>
<option value="min">Minimum intensity</option>
</select>
<div class="d-flex flex-wrap gap-2 mt-3">
<button type="button" id="apply-mpr-layout" class="btn btn-outline-primary btn-sm">Apply MPR layout</button>
<button type="submit" class="btn btn-primary btn-sm">Generate recon series</button>
</div>
</div>
</form>
</div>
<div class="card-footer d-flex align-items-center gap-2">
<span id="advanced-recon-indicator" class="spinner-border spinner-border-sm text-primary htmx-indicator" role="status" aria-hidden="true"></span>
<small class="text-muted">The current viewer state is captured at submit and attached to the reconstruction request.</small>
</div>
</div>
<div id="advanced-recon-feedback" class="small mt-3"></div>
</div>
<script src="{% static 'dv3d/index.js' %}" type="module" defer="defer"></script>
<script>
(function () {
function getViewerApi(name) {
return window[name + '_advanced_recon_root'] || window[name + '_advanced_recon'] || window[name + '_root'] || window[name];
}
function buildMprState() {
return {
grid: { rows: 1, cols: 3 },
modes: ['volume', 'volume', 'volume'],
orientations: ['axial', 'coronal', 'sagittal'],
volumeSliceIndices: [null, null, null],
};
}
var applyBtn = document.getElementById('apply-mpr-layout');
if (applyBtn) {
applyBtn.addEventListener('click', function () {
var importFn = getViewerApi('importViewerState');
if (typeof importFn === 'function') {
try {
importFn(buildMprState());
} catch (err) {
console.warn('Failed to apply MPR layout', err);
}
}
});
}
var form = document.getElementById('advanced-recon-form');
var hidden = document.getElementById('id_recon_viewerstate_json');
if (form && hidden) {
form.addEventListener('submit', function () {
var exportFn = getViewerApi('exportViewerState');
if (typeof exportFn !== 'function') {
hidden.value = '';
return;
}
try {
var state = exportFn();
hidden.value = typeof state === 'string' ? state : JSON.stringify(state || {});
} catch (err) {
hidden.value = '';
console.warn('Failed to capture viewer state', err);
}
});
}
})();
</script>
{% endblock %}
+3
View File
@@ -416,6 +416,9 @@
<div class="mt-2">
<button class="btn btn-sm btn-outline-primary" type="submit" name="operation" value="reconstruct">Generate recon series</button>
<a class="btn btn-sm btn-outline-info ms-1" href="{% url 'atlas:series_reconstruct_advanced' series_id=series.pk %}" target="_blank" rel="noopener">
Advanced reconstruction page
</a>
</div>
</div>
</form>
+5
View File
@@ -570,6 +570,11 @@ urlpatterns = [
# TODO: case context series viewing (so that we can view series in the context of a case)
path("series/<int:pk>", views.series_detail, name="series_detail"),
path("series/<int:pk>/viewer", views.series_viewer, name="series_viewer"),
path(
"series/<int:series_id>/reconstruct/advanced/",
views.series_reconstruct_advanced,
name="series_reconstruct_advanced",
),
path(
"series/<int:series_id>/truncate/<int:start>/<int:end>/",
views.series_truncate_htmx,
+24
View File
@@ -873,6 +873,30 @@ def series_optimize_htmx(request, series_id):
return HttpResponse('<div class="alert alert-danger mb-0">Unknown optimize operation.</div>')
@login_required
@user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access
def series_reconstruct_advanced(request, series_id):
"""Advanced reconstruction planning page using DV3D MPR/crosshair workflow.
Scope: users with access to the source series.
Functionality: mounts DV3D in an MPR-focused layout, allows manual planning,
and submits reconstruction requests while preserving the existing series tools flow.
"""
series = get_object_or_404(Series, pk=series_id)
if not series.check_user_can_edit(request.user):
return HttpResponse("Permission denied", status=403)
image_urls = [f"{REMOTE_URL}{img.image.url}" for img in series.get_images() if getattr(img, "image", None)]
image_ids = [f"wadouri:{url}" for url in image_urls]
context = {
"series": series,
"image_ids_json": json.dumps(image_ids),
}
return render(request, "atlas/series_reconstruct_advanced.html", context)
@login_required
def series_reconstruct_status_htmx(request, series_id, task_id):
"""HTMX endpoint returning progress for a queued series reconstruction task."""
+2 -1
View File
@@ -594,7 +594,8 @@ def media_cleanup(request):
removed_files = set(media_inventory_before) - set(media_inventory_after)
output = out.getvalue().strip()
recovered_size = sum(media_inventory_before[path] for path in removed_files)
# Use before/after total delta so reported recovered space matches real disk impact.
recovered_size = max(0, media_size_before - media_size_after)
context.update(
{
"ran": True,
+55 -15
View File
@@ -26,6 +26,21 @@
border-radius: 0.375rem;
padding: 0.75rem;
}
.logs-filter-group {
border: 1px solid var(--bs-border-color);
border-radius: 0.5rem;
padding: 0.75rem;
background: rgba(255, 255, 255, 0.02);
}
.logs-filter-group legend {
width: auto;
padding: 0 0.35rem;
margin: 0;
font-size: 0.8rem;
color: var(--bs-secondary-color);
text-transform: uppercase;
letter-spacing: 0.04em;
}
</style>
{% endblock %}
@@ -58,35 +73,59 @@
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<form method="get" class="row g-2">
<div class="col-12 col-md-5">
<form method="get" class="row g-3">
<div class="col-12 col-lg-5">
<label for="id_logs_query" class="form-label small text-muted mb-1">Keyword</label>
<input type="search"
id="id_logs_query"
name="q"
class="form-control"
placeholder="Search logs by keyword (error, warning, etc)…"
value="{{ search_query }}">
</div>
<div class="col-12 col-md-3">
<select name="level" class="form-select">
<div class="col-12 col-lg-3">
<label for="id_logs_level" class="form-label small text-muted mb-1">Level</label>
<select id="id_logs_level" name="level" class="form-select">
<option value="">All levels</option>
{% for level in available_levels %}
<option value="{{ level }}" {% if level_filter == level %}selected{% endif %}>{{ level }}</option>
{% endfor %}
</select>
</div>
<div class="col-12 col-md-2">
<input type="date" name="date_from" class="form-control" value="{{ date_from }}" aria-label="Date from">
<div class="col-12 col-lg-4">
<fieldset class="logs-filter-group">
<legend>Date Range</legend>
<div class="row g-2">
<div class="col-12 col-sm-6">
<label for="id_logs_date_from" class="form-label small mb-1">From date</label>
<input id="id_logs_date_from" type="date" name="date_from" class="form-control" value="{{ date_from }}" aria-label="Date from">
</div>
<div class="col-12 col-md-2">
<input type="date" name="date_to" class="form-control" value="{{ date_to }}" aria-label="Date to">
<div class="col-12 col-sm-6">
<label for="id_logs_date_to" class="form-label small mb-1">To date</label>
<input id="id_logs_date_to" type="date" name="date_to" class="form-control" value="{{ date_to }}" aria-label="Date to">
</div>
<div class="col-12 col-md-1">
<input type="time" name="time_from" class="form-control" value="{{ time_from }}" aria-label="Time from">
</div>
<div class="col-12 col-md-1">
<input type="time" name="time_to" class="form-control" value="{{ time_to }}" aria-label="Time to">
</fieldset>
</div>
<div class="col-12 col-md-2 d-flex align-items-center">
<div class="col-12 col-lg-4">
<fieldset class="logs-filter-group">
<legend>Time Range</legend>
<div class="row g-2">
<div class="col-12 col-sm-6">
<label for="id_logs_time_from" class="form-label small mb-1">From time</label>
<input id="id_logs_time_from" type="time" name="time_from" class="form-control" value="{{ time_from }}" aria-label="Time from">
</div>
<div class="col-12 col-sm-6">
<label for="id_logs_time_to" class="form-label small mb-1">To time</label>
<input id="id_logs_time_to" type="time" name="time_to" class="form-control" value="{{ time_to }}" aria-label="Time to">
</div>
</div>
</fieldset>
</div>
<div class="col-12 col-lg-4 d-flex align-items-center">
<div class="form-check mt-1">
<input class="form-check-input" type="checkbox" value="1" id="include_noise" name="include_noise" {% if include_noise %}checked{% endif %}>
<label class="form-check-label small" for="include_noise">
@@ -94,10 +133,11 @@
</label>
</div>
</div>
<div class="col-12 col-md-auto">
<div class="col-12 col-md-auto d-flex flex-wrap gap-2">
<button type="submit" class="btn btn-primary">Search</button>
{% if search_query or level_filter %}
<a href="{% url 'logs_view' %}" class="btn btn-outline-secondary ms-1">Clear</a>
<a href="{% url 'logs_view' %}" class="btn btn-outline-secondary">Clear</a>
{% endif %}
</div>
<div class="col-12 col-md-auto">
+66
View File
@@ -15,6 +15,10 @@
pointer-events: none;
opacity: 0.7;
}
.media-cleanup-modal-spinner {
width: 2.5rem;
height: 2.5rem;
}
</style>
{% endblock %}
@@ -92,6 +96,8 @@
name="action"
value="preview"
class="btn btn-primary"
data-loading-title="Previewing unused files"
data-loading-message="Scanning media storage for orphaned files."
>
<i class="bi bi-search me-1"></i> Preview unused files
</button>
@@ -101,6 +107,8 @@
value="cleanup"
class="btn btn-danger"
hx-confirm="Delete unused media files now? This cannot be undone."
data-loading-title="Deleting unused files"
data-loading-message="Removing orphaned media and recalculating disk usage."
>
<i class="bi bi-trash me-1"></i> Delete unused files
</button>
@@ -118,4 +126,62 @@
{% include 'rad/partials/_media_cleanup_result.html' %}
</div>
</div>
<div class="modal fade" id="media-cleanup-loading-modal" tabindex="-1" aria-labelledby="media-cleanup-loading-title" aria-hidden="true" data-bs-backdrop="static" data-bs-keyboard="false">
<div class="modal-dialog modal-dialog-centered modal-sm">
<div class="modal-content border-0 shadow">
<div class="modal-body text-center py-4">
<div class="spinner-border text-info media-cleanup-modal-spinner mb-3" role="status" aria-hidden="true"></div>
<h5 id="media-cleanup-loading-title" class="mb-1">Working...</h5>
<p id="media-cleanup-loading-message" class="text-muted mb-0 small">Please wait while this operation completes.</p>
</div>
</div>
</div>
</div>
<script>
(function () {
var form = document.getElementById('media-cleanup-form');
var modalEl = document.getElementById('media-cleanup-loading-modal');
if (!form || !modalEl || typeof bootstrap === 'undefined') {
return;
}
var titleEl = document.getElementById('media-cleanup-loading-title');
var messageEl = document.getElementById('media-cleanup-loading-message');
var loadingModal = bootstrap.Modal.getOrCreateInstance(modalEl);
var activeButton = null;
form.addEventListener('click', function (event) {
var btn = event.target.closest('button[type="submit"]');
if (!btn || !form.contains(btn)) {
return;
}
activeButton = btn;
});
document.body.addEventListener('htmx:beforeRequest', function (event) {
if (event.target !== form) {
return;
}
var title = activeButton ? activeButton.getAttribute('data-loading-title') : null;
var message = activeButton ? activeButton.getAttribute('data-loading-message') : null;
titleEl.textContent = title || 'Working...';
messageEl.textContent = message || 'Please wait while this operation completes.';
loadingModal.show();
});
function hideLoadingModal(event) {
if (event.target !== form) {
return;
}
loadingModal.hide();
activeButton = null;
}
document.body.addEventListener('htmx:afterRequest', hideLoadingModal);
document.body.addEventListener('htmx:responseError', hideLoadingModal);
document.body.addEventListener('htmx:sendError', hideLoadingModal);
})();
</script>
{% endblock %}