Files
penracourses/atlas/templates/atlas/case_display_block.html
T

1348 lines
63 KiB
HTML
Executable File

{% load case_widgets %}
{% partialdef case-series %}
{% for series in case.get_ordered_series %}
<span class="series-block" id="series-block-{{ series.pk }}">
<span>
<span class="series-block-series-number">Series {{ forloop.counter }}:</span><br>
<a href="{% url 'atlas:series_detail' pk=series.pk %}">
{{series.get_block}}
</a>
<br>
<button type="button"
class="btn btn-outline-primary btn-sm select-series-btn"
style="display: none;"
data-series-id="{{series.pk}}">
Select
</button>
<input type="checkbox" class="hide" name="series-ids" value="{{series.pk}}">
<span class="series-block-popup-link">
<a href="#"
onclick="return window.create_popup_window('/atlas/series/{{series.pk}}', 'Series')">Popup</a>
</span>
</span>
</span>
{% endfor %}
{% endpartialdef %}
<style>
.case-inline-field {
position: relative;
}
.case-inline-field .quick-edit-button {
border: 0;
background: transparent;
color: var(--bs-secondary-color, #6c757d);
font-size: 0.8rem;
line-height: 1.1;
padding: 0;
text-decoration: none;
}
.case-inline-field .quick-edit-button:hover {
text-decoration: underline;
}
.case-inline-field .quick-edit-compact {
position: absolute;
top: 0;
right: 0;
z-index: 2;
opacity: 0.35;
pointer-events: auto;
}
.case-inline-field .quick-edit-trigger {
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
}
.case-inline-field:hover .quick-edit-trigger,
.case-inline-field:focus-within .quick-edit-trigger {
opacity: 0.65;
pointer-events: auto;
}
.case-inline-field .quick-edit-trigger:hover {
opacity: 1;
}
.case-inline-field .quick-edit-compact.quick-edit-trigger {
opacity: 0.35;
pointer-events: auto;
}
.case-inline-field:hover .quick-edit-compact.quick-edit-trigger,
.case-inline-field:focus-within .quick-edit-compact.quick-edit-trigger {
opacity: 0.9;
}
/* Generic hover-only quick-edit behaviour for host-wrapped buttons.
Buttons are positioned absolutely so revealing them does not shift layout. */
.quick-edit-wrap { position: relative; display: inline-flex; align-items: center; }
.quick-edit-wrap .quick-edit-button {
position: absolute;
right: 0.25rem;
top: 50%;
transform: translateY(-50%);
z-index: 5;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease, transform 0.15s ease;
background: rgba(255,255,255,0.95) !important;
border-radius: .25rem;
padding: 0 .25rem;
color: #0d6efd !important; /* clearer action colour */
}
.quick-edit-wrap:hover .quick-edit-button,
.quick-edit-wrap:focus-within .quick-edit-button {
opacity: 0.95;
pointer-events: auto;
transform: translateY(-50%);
}
.quick-edit-wrap .quick-edit-button:hover { opacity: 1; }
@media (max-width: 767.98px) {
.quick-edit-wrap .quick-edit-button,
.case-meta-item .quick-edit-button,
.case-inline-field .quick-edit-compact {
opacity: 0.95;
pointer-events: auto;
padding: 0.15rem 0.35rem;
font-size: 0.9rem;
}
}
/* Metadata-specific quick-edit: ensure edit button overlays without moving badges */
.case-meta-item { position: relative; }
.case-meta-item .quick-edit-button {
position: absolute;
right: 0.25rem;
top: 50%;
transform: translateY(-50%);
z-index: 6;
opacity: 0;
pointer-events: none;
background: rgba(255,255,255,0.95) !important;
padding: 0 .25rem;
border-radius: .25rem;
color: #0d6efd !important;
border: 1px solid rgba(13,110,253,0.08);
}
.case-meta-item:hover .quick-edit-button,
.case-meta-item:focus-within .quick-edit-button {
opacity: 0.95;
pointer-events: auto;
}
/* Normal group visual style */
.normal-group { border: 1px solid rgba(13,110,253,0.15); background: #f8fbff; padding: .75rem; border-radius: .375rem; margin-bottom: .75rem; }
.normal-group .normal-heading { font-weight: 600; color: #0d6efd; margin-bottom: .5rem; }
.normal-group .badge { margin-right: .25rem; }
.section-icon { display:inline-block; width:10px; height:10px; border-radius:50%; margin-right:0.5rem; }
.section-icon.description { background:#6c757d; }
.section-icon.presentation { background:#0d6efd; }
.section-icon.history { background:#198754; }
.section-icon.discussion { background:#fd7e14; }
.section-icon.report { background:#dc3545; }
/* Series block base (Bootstrap-like card appearance) */
.series-block {
display: inline-block;
vertical-align: top;
min-width: 180px;
max-width: 320px;
margin: .25rem;
padding: .5rem;
border-radius: .375rem;
background: #f8f9fa;
border: 1px solid rgba(0,0,0,0.06);
text-align: left;
}
/* Also support dark theme when site sets a theme attribute/class */
.atlas[data-bs-theme="dark"] .series-block,
[data-bs-theme="dark"] .series-block,
body[data-bs-theme="dark"] .series-block,
body.bg-dark .series-block,
.bg-dark .series-block,
.dark .series-block,
.theme-dark .series-block {
background: #071018 !important;
border-color: rgba(255,255,255,0.03) !important;
color: #e6eef8 !important;
}
.case-metadata { border-left: 1px solid #eee; padding-left: 1rem; text-align:left; }
.case-meta-item { margin-bottom: .5rem; }
.case-meta-label { font-weight:600; font-size:0.9rem; display:block; }
@media (min-width: 768px) {
/* Keep metadata stacked (one per row), but put label and value on same row */
.case-metadata { text-align: right; }
.case-meta-item { display: flex; justify-content: space-between; align-items: center; margin-bottom: .5rem; }
.case-meta-value { display: flex; align-items: center; gap: .5rem; justify-content: flex-end; }
.case-meta-item .badge { display: inline-block; margin-left: .25rem; margin-right: 0; }
.case-meta-item > .case-meta-value > div { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
}
/* Description & Presentation visual improvements */
.case-description { background: #eef3f73d; border: 1px solid rgba(0,0,0,0.08); padding: .75rem; border-radius: .375rem; }
.case-description strong { font-size: 1.05rem; margin-right: .5rem; }
.case-description .case-inline-field { margin-top: .5rem; }
.case-presentation ul { list-style: none; padding-left: 0; margin: .25rem 0 0 0; display: flex; flex-wrap: wrap; gap: .5rem; }
.case-presentation li { background: rgba(13,110,253,0.12); color: #0b5ed7; padding: .25rem .5rem; border-radius: .25rem; font-size: .9rem; }
.case-presentation b { margin-right: .5rem; }
.displayset-empty-state {
display: inline-flex;
align-items: center;
gap: .6rem;
margin: .4rem 0;
padding: .45rem .65rem;
border-radius: .5rem;
border: 1px solid rgba(13, 110, 253, 0.2);
background: rgba(13, 110, 253, 0.06);
}
.displayset-empty-link {
display: inline-block;
padding: .25rem .6rem;
border-radius: 999px;
background: #0d6efd;
color: #fff;
font-size: .82rem;
font-weight: 600;
text-decoration: none;
transition: background-color .15s ease, transform .15s ease;
}
.displayset-empty-link:hover,
.displayset-empty-link:focus {
background: #0b5ed7;
color: #fff;
transform: translateY(-1px);
text-decoration: none;
}
/* Dark mode adjustments */
@media (prefers-color-scheme: dark) {
.case-description {
background: #0f1720;
border: 1px solid rgba(255,255,255,0.06);
color: #e6eef8;
}
.case-description strong { color: #ffffff; }
.case-description .case-inline-field { color: #e6eef8; }
.case-presentation li { background: rgba(255,255,255,0.03); color: #9ec5ff; }
.quick-edit-wrap .quick-edit-button,
.case-meta-item .quick-edit-button {
background: rgba(255,255,255,0.04) !important;
color: #9ec5ff !important;
border: 1px solid rgba(255,255,255,0.06);
}
.displayset-empty-state {
border-color: rgba(158, 197, 255, 0.3);
background: rgba(158, 197, 255, 0.1);
}
.displayset-empty-link {
background: #9ec5ff;
color: #042b61;
}
.displayset-empty-link:hover,
.displayset-empty-link:focus {
background: #bfdbff;
color: #042b61;
}
/* Series block dark theme adjustments */
.series-block {
background: #071018;
border-color: rgba(255,255,255,0.03);
color: #e6eef8;
box-shadow: none;
}
.series-block a { color: inherit; }
.series-block .series-block-series-number { color: #cfe6ff; }
.series-block .series-block-popup-link a { color: #9ec5ff; }
#case-series-row { gap: .5rem; }
}
</style>
<div class="atlas {% if case.scrapped %}atlas-scrapped{% endif %}">
<div class="btn-group btn-group-sm link-buttons" role="group">
<button type="button" class="btn btn-primary btn-sm"
onclick="window.location.href='/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:case_dicom_json' case.pk %}'">View case in OHIF</button>
<button type="button" class="btn btn-primary btn-sm dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
<span class="visually-hidden">Toggle dropdown</span>
</button>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:case_dicom_json' case.pk %}">Open in new tab</a>
</li>
{% if can_edit %}
<li>
<a class="dropdown-item" href="{% url 'atlas:case_download' case.pk %}">Download DICOMs + metadata (ZIP)</a>
</li>
{% endif %}
</ul>
</div>
<div class="normal-block">
<div class="mt-1">
{% include "atlas/partials/_normal_toggle.html" %}
</div>
</div>
<h2 class="d-flex align-items-center" style="gap: .5rem; white-space: nowrap;">
<span>Case:</span>
<div class="d-flex align-items-center quick-edit-wrap">
{% url 'atlas:case_inline_field' case.pk 'title' as title_url %}
{% with endpoint_default=title_url|add:"?show_label=0" endpoint_edit=title_url|add:"?edit=1&show_label=0" %}
{% include 'atlas/partials/case_inline_field.html' with show_label=0 editing=False field_label="Title" can_edit=can_edit endpoint_url_default=endpoint_default endpoint_url_edit=endpoint_edit container_id='case-inline-title' field_kind='text' value=case.title display_value=case.title field_name='title' error_message='' %}
{% endwith %}
{% if can_edit %}
<button class="btn btn-sm btn-link text-muted p-0 ms-2 quick-edit-button"
hx-get="{% url 'atlas:case_inline_field' case.pk 'title' %}?edit=1&show_label=0"
hx-target="#case-inline-title"
hx-swap="outerHTML">quick edit</button>
{% endif %}
</div>
</h2>
{% if case.previous_case or case.next_case %}
<div class="alert alert-info" role="alert">
This case is part of a <a href='{% url "atlas:linked_cases_overview" case.pk %}'>series.</a><br/>
<div class="row">
{% if case.previous_case %}
<div class="col-sm-6 mb-3 mb-sm-0">
<h5 class="card-title">Previous Case</h5>
{{ case.previous_case.get_link }}
</div>
{% endif %}
{% if case.next_case %}
<div class="col-sm-6">
<h5 class="card-title">Next Case</h5>
{{ case.next_case.get_link }}
</div>
{% endif %}
</div>
</div>
{% endif %}
<div>
{% if case.cimar_uuid %}
<details><summary>Details</summary>
Case uuid: {{case.cimar_uuid}}
<br/>
{% if cimar_sid %}
<a href="{{case.get_cimar_case.viewer_link}}?sid={{cimar_sid}}" target="_blank">External</a>
{% else %}
<a href="{{case.get_cimar_case.viewer_link}}" target="_blank">External</a>
{% endif %}
<button
hx-get='{% url "cimar_study_viewer_embed" case.cimar_uuid %}'
hx-target="#modals-here"
hx-trigger="click"
data-bs-toggle="modal"
data-bs-target="#modals-here"
class="btn primary">Open Modal</button>
<div id="modals-here"
class="modal modal-blur fade"
style="display: none"
aria-hidden="false"
tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-centered" role="document">
<div class="modal-content"></div>
</div>
</div>
<a href='{% url "cimar_study_viewer" case.cimar_uuid %}'>Viewer</a>
<details>
<summary>Study details</summary>
{{ case.get_cimar_case.get_pretty_study_details }}
</details>
<details>
<summary>Study schema</summary>
{{ case.get_cimar_case.get_pretty_study_schema }}
</details>
<details>
<summary>Study series</summary>
<button
id='load-series'
hx-get='{% url "cimar_study_series_block" case.cimar_uuid %}'
hx-trigger='load'
hx-swap='outerHTML'>
Load Series
</button>
</details>
<a href='{% url "generic:cimar_case_refresh" case.cimar_uuid %}'>Refresh</a>
</details>
{% else %}
{% endif %}
<div class="clearfix mb-2">
<div class="float-start">
<span class="id">ID: {{ case.id }}</span>
<span class="mx-1">/</span>
<span class="date">{{ case.created_date|date:"d/m/Y" }}</span>
</div>
</div>
</div>
<details id="dicom-viewer-details">
<summary>Viewer</summary>
<div id="main_viewer" class="dicom-viewer-root"
style="box-sizing: border-box; background: #222; width: 100%; height: 600px;"
data-auto-cache-stack="false"
data-named-stacks='{{case.get_case_named_stacks}}'
></div>
</details>
<div class="pre-whitespace multi-image-block"><b>Series:</b>
<form>
<div class="d-flex flex-wrap gap-2 align-items-start" id="case-series-row">
{% partial case-series %}
</div>
</form>
<div class="d-flex flex-column gap-2 align-items-start">
<div>
<button type="button"
class="btn btn-outline-primary btn-sm"
onclick="window.location.href='{% url 'atlas:user_uploads_case' case_id=case.pk %}'">
Import new uploads
</button>
</div>
<details class="series-actions" id="series-actions">
<summary>
<button type="button" class="btn btn-outline-secondary btn-sm" title="Click to manage series" onclick="this.closest('details').open = !this.closest('details').open;">
Manage Series
</button>
</summary>
<div class="series-actions-body">
<a class="btn btn-sm btn-outline-secondary text-start" href="{% url 'atlas:series_id_create' pk=case.pk %}">Create and add new series</a>
<div class="series-selection-toolbar d-flex flex-wrap gap-2">
<button type="button" class="btn btn-sm btn-outline-primary" id="series-select-all-btn">Select all</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="series-deselect-all-btn">Deselect all</button>
</div>
<div class="series-actions-grid d-flex flex-column gap-2">
<button class="btn btn-sm btn-outline-secondary text-start" hx-get="{% url 'atlas:case_order_dicom' pk=case.pk %}"
title="order dicom by slice location"
hx-target="#series-action-results"
hx-swap="innerHTML"
hx-confirm="This will reorder all case series based upon slice location"
>
Order dicoms by slice location
</button>
<button class="btn btn-sm btn-outline-primary text-start" hx-post="{% url 'atlas:combine_series' %}"
title="merge series"
hx-include="[name='series-ids']"
hx-target="#series-action-results"
hx-swap="innerHTML"
hx-confirm="This will merge all selected series into one"
>
Merge selected series
</button>
<button class="btn btn-sm btn-outline-secondary text-start" hx-post="{% url 'atlas:use_dates_as_descriptions' case.pk %}"
title="use dicom dates as series descriptions"
hx-include="[name='series-ids']"
hx-target="#series-action-results"
hx-swap="innerHTML"
hx-confirm="This will use the dicom date as the series description"
>
Use dates as description
</button>
<button hx-post="{% url 'atlas:remove_selected_series_from_case' case.pk %}"
title="remove selected series from this case"
hx-include="[name='series-ids']:checked"
hx-target="#series-action-results"
hx-swap="innerHTML"
hx-confirm="Are you sure you want to remove the selected series from this case? They will not be deleted from the database."
class="btn btn-sm btn-warning text-start"
type="button"
>
Remove selected series from case
</button>
<button type="button"
class="btn btn-sm btn-secondary text-start"
data-bs-toggle="modal"
data-bs-target="#reorderSeriesModal">
Reorder series
</button>
</div>
<details class="series-move-panel border rounded p-2">
<summary class="small fw-semibold">Move selected series to another case</summary>
{% if move_series_form %}
<form id="move-selected-series-form" class="d-flex flex-column gap-2 mt-2">
{{ move_series_form.destination_case }}
<div class="small text-muted">Click any case row below to select the destination case.</div>
<div id="selected-move-case" class="small text-muted">No destination case selected.</div>
{% case_search_widget collection=None input_id='move-series-case-search-input' target_id='move-series-case-search-results' show_select_button=True %}
<button type="button"
id="move-selected-series-submit"
class="btn btn-sm btn-outline-primary text-start"
disabled
hx-post="{% url 'atlas:move_selected_series_to_case' case.pk %}"
hx-include="[name='series-ids']:checked, #move-selected-series-form [name='destination_case']"
hx-target="#series-action-results"
hx-swap="innerHTML"
hx-confirm="Move selected series to the chosen destination case?">
Move selected series
</button>
</form>
<script>
(function () {
if (window.__moveSeriesCaseSelectionBound) return;
window.__moveSeriesCaseSelectionBound = true;
function applyMoveCaseSelection(casePk, caseTitle) {
if (!casePk) return;
const form = document.getElementById('move-selected-series-form');
const hiddenInput = form ? form.querySelector("[name='destination_case']") : null;
const label = document.getElementById('selected-move-case');
const results = document.getElementById('move-series-case-search-results');
if (!hiddenInput) return;
hiddenInput.value = casePk;
if (label) {
label.innerHTML = 'Selected destination: <strong>' + caseTitle + ' (' + casePk + ')</strong>';
label.classList.remove('text-muted');
}
if (results) {
results.querySelectorAll('.list-group-item[data-case-pk]').forEach(function (item) {
item.classList.toggle('active', String(item.getAttribute('data-case-pk')) === String(casePk));
});
}
syncMoveSeriesSubmitState();
}
function syncMoveSeriesSubmitState() {
const form = document.getElementById('move-selected-series-form');
const hiddenInput = form ? form.querySelector("[name='destination_case']") : null;
const submitBtn = document.getElementById('move-selected-series-submit');
if (!hiddenInput || !submitBtn) return;
submitBtn.disabled = !hiddenInput.value;
}
document.body.addEventListener('case:selected', function (e) {
try {
const detail = e.detail || {};
const casePk = detail.casePk;
const caseTitle = detail.caseTitle || ('Case #' + casePk);
applyMoveCaseSelection(casePk, caseTitle);
} catch (err) {
console.error('Move series case:selected handler error', err);
}
});
syncMoveSeriesSubmitState();
})();
</script>
{% else %}
<div class="text-muted small mt-2">Case move options are unavailable in this view.</div>
{% endif %}
</details>
</div>
<div class="alert alert-info mt-2" id="series-action-alert" style="display:none;">
<span id="series-action-results" ></span>
</div>
</details>
</div>
</div>
<div class="container">
<div class="row g-3 mb-3">
<div class="col-12 col-md-8">
<div class="mb-3 case-description">
<div class="d-flex align-items-start justify-content-between quick-edit-wrap">
<div class="d-flex align-items-start flex-grow-1">
<span class="section-icon description" aria-hidden="true"></span>
<strong>Description:</strong>
{% url 'atlas:case_inline_field' case.pk 'description' as desc_url %}
{% with endpoint_default=desc_url|add:"?show_label=0" endpoint_edit=desc_url|add:"?edit=1&show_label=0" %}
{% include 'atlas/partials/case_inline_field.html' with show_label=0 editing=False field_label="Description" can_edit=can_edit endpoint_url_default=endpoint_default endpoint_url_edit=endpoint_edit container_id='case-inline-description' field_kind='textarea' value=case.description display_value=case.description field_name='description' error_message='' %}
{% endwith %}
</div>
{% if can_edit %}
<button class="btn btn-sm btn-link text-muted p-0 ms-2 quick-edit-button"
hx-get="{% url 'atlas:case_inline_field' case.pk 'description' %}?edit=1&show_label=0"
hx-target="#case-inline-description"
hx-swap="outerHTML">quick edit</button>
{% endif %}
</div>
</div>
{# Presentation moved to metadata column for tighter layout #}
<div class="row g-3 mb-3">
<div class="col-12">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center quick-edit-wrap">
<span><span class="section-icon history" aria-hidden="true"></span>History</span>
{% if can_edit %}
<button class="btn btn-sm btn-link text-muted p-0 quick-edit-button"
hx-get="{% url 'atlas:case_inline_field' case.pk 'history' %}?edit=1&show_label=0"
hx-target="#case-inline-history"
hx-swap="outerHTML">quick edit</button>
{% endif %}
</div>
<div class="card-body">
{% url 'atlas:case_inline_field' case.pk 'history' as history_url %}
{% with endpoint_default=history_url|add:"?show_label=0" endpoint_edit=history_url|add:"?edit=1&show_label=0" %}
{% include 'atlas/partials/case_inline_field.html' with show_label=0 editing=False field_label='History' can_edit=can_edit endpoint_url_default=endpoint_default endpoint_url_edit=endpoint_edit container_id='case-inline-history' field_kind='textarea' value=case.history display_value=case.history field_name='history' error_message='' %}
{% endwith %}
</div>
</div>
</div>
<div class="col-12">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center quick-edit-wrap">
<span><span class="section-icon discussion" aria-hidden="true"></span>Discussion</span>
{% if can_edit %}
<button class="btn btn-sm btn-link text-muted p-0 quick-edit-button"
hx-get="{% url 'atlas:case_inline_field' case.pk 'discussion' %}?edit=1&show_label=0"
hx-target="#case-inline-discussion"
hx-swap="outerHTML">quick edit</button>
{% endif %}
</div>
<div class="card-body">
{% url 'atlas:case_inline_field' case.pk 'discussion' as discussion_url %}
{% with endpoint_default=discussion_url|add:"?show_label=0" endpoint_edit=discussion_url|add:"?edit=1&show_label=0" %}
{% include 'atlas/partials/case_inline_field.html' with show_label=0 editing=False field_label='Discussion' can_edit=can_edit endpoint_url_default=endpoint_default endpoint_url_edit=endpoint_edit container_id='case-inline-discussion' field_kind='textarea' value=case.discussion display_value=case.discussion field_name='discussion' error_message='' %}
{% endwith %}
</div>
</div>
</div>
<div class="col-12">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center quick-edit-wrap">
<span><span class="section-icon report" aria-hidden="true"></span>Report</span>
{% if can_edit %}
<button class="btn btn-sm btn-link text-muted p-0 quick-edit-button"
hx-get="{% url 'atlas:case_inline_field' case.pk 'report' %}?edit=1&show_label=0"
hx-target="#case-inline-report"
hx-swap="outerHTML">quick edit</button>
{% endif %}
</div>
<div class="card-body">
{% url 'atlas:case_inline_field' case.pk 'report' as report_url %}
{% with endpoint_default=report_url|add:"?show_label=0" endpoint_edit=report_url|add:"?edit=1&show_label=0" %}
{% include 'atlas/partials/case_inline_field.html' with show_label=0 editing=False field_label='Report' can_edit=can_edit endpoint_url_default=endpoint_default endpoint_url_edit=endpoint_edit container_id='case-inline-report' field_kind='textarea' value=case.report display_value=case.report field_name='report' error_message='' %}
{% endwith %}
</div>
</div>
</div>
</div>
</div>
<aside class="col-12 col-md-4 case-metadata">
{# ID/date shown above; removed duplicate here #}
<div class="case-meta-item">
<span class="case-meta-label">Presentation</span>
<div class="case-meta-value d-flex align-items-center justify-content-end">
<div id="case-inline-presentation">
{% if case.presentation.all %}
{% for presentation in case.presentation.all %}
<span class="badge bg-secondary ms-1">{{ presentation.get_link|safe }}</span>
{% endfor %}
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
{% if can_edit %}
<button class="btn btn-sm btn-link text-muted p-0 quick-edit-button ms-2"
hx-get="{% url 'atlas:case_inline_field' case.pk 'presentation' %}?edit=1&show_label=0"
hx-target="#case-inline-presentation"
hx-swap="outerHTML">quick edit</button>
{% endif %}
</div>
</div>
<div class="case-meta-item">
<span class="case-meta-label">Subspecialty</span>
<div class="case-meta-value d-flex align-items-center justify-content-end">
<div id="case-inline-subspecialty">
{% if case.subspecialty.all %}
{% for sub in case.subspecialty.all %}
<span class="badge bg-secondary ms-1">{{ sub.get_link|safe }}</span>
{% endfor %}
{% else %}
<span class="badge bg-secondary ms-1 text-muted">None</span>
{% endif %}
</div>
{% if can_edit %}
<button class="btn btn-sm btn-link text-muted p-0 quick-edit-button ms-2"
hx-get="{% url 'atlas:case_inline_field' case.pk 'subspecialty' %}?edit=1&show_label=0"
hx-target="#case-inline-subspecialty"
hx-swap="outerHTML">quick edit</button>
{% endif %}
</div>
</div>
<div class="case-meta-item">
<span class="case-meta-label">Pathological process</span>
<div class="case-meta-value d-flex align-items-center justify-content-end">
<div>
{% if case.pathological_process.all %}
{% for p in case.pathological_process.all %}
<span class="badge bg-secondary ms-1">{{ p.get_link|safe }}</span>
{% endfor %}
{% else %}
<span class="badge bg-secondary ms-1 text-muted">None</span>
{% endif %}
</div>
</div>
</div>
<div class="case-meta-item">
<span class="case-meta-label">Diagnostic certainty</span>
<div class="case-meta-value d-flex align-items-center justify-content-end">
{% url 'atlas:case_inline_field' case.pk 'diagnostic_certainty' as diag_url %}
{% with endpoint_default=diag_url|add:"?show_label=0" endpoint_edit=diag_url|add:"?edit=1&show_label=0" %}
{% include 'atlas/partials/case_inline_field.html' with show_label=0 editing=False field_label="Diagnostic certainty" can_edit=can_edit endpoint_url_default=endpoint_default endpoint_url_edit=endpoint_edit container_id='case-inline-diagnostic_certainty' field_kind='select' value=case.get_diagnostic_certainty_display display_value=case.get_diagnostic_certainty_display field_name='diagnostic_certainty' error_message='' %}
{% endwith %}
{% if can_edit %}
<button class="btn btn-sm btn-link text-muted p-0 quick-edit-button ms-2"
hx-get="{% url 'atlas:case_inline_field' case.pk 'diagnostic_certainty' %}?edit=1&show_label=0"
hx-target="#case-inline-diagnostic_certainty"
hx-swap="outerHTML">quick edit</button>
{% endif %}
</div>
</div>
</aside>
</div>
</div>
<div class="mb-3">
<h5>Resources</h5>
{% if can_edit%}
<div class="mb-2">
<div id="resource-search-panel-placeholder">
<button class="btn btn-sm btn-outline-primary"
hx-get="{% url 'atlas:resource_search_panel' %}?case_id={{ case.pk }}"
hx-target="#resource-search-panel-placeholder"
hx-swap="innerHTML">
Add Resource
</button>
</div>
</div>
{% endif %}
<div>
{% include 'atlas/partials/case_resources_list.html' with case=case %}
</div>
</div>
{% comment %} <p><b>Scrapped:</b> {{ case.scrapped }} <a
href="{% url 'atlas:case_scrap' pk=case.pk %}">(toggle)</a> {% endcomment %}
<div class="atlas-answer">
<div class="pre-whitespace">
Condition:
<ul>
{% for con in case.condition.all %}
<li>{{con.get_link}}</li>
{% endfor %}
</ul>
<div>
<strong>Procedure(s):</strong>
{% if case.procedures.all %}
<ul>
{% for proc in case.procedures.all %}
<li>{{ proc.get_link }}</li>
{% endfor %}
</ul>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
<div>
{% include 'atlas/partials/_series_findings.html' %}
{% include 'atlas/partials/_case_displaysets.html' %}
</div>
{% include 'atlas/partials/_case_differentials.html' %}
</div>
</div>
<b>Collections:</b>
{% if can_edit %}
<button class="btn btn-sm btn-primary"
hx-get="{% url 'atlas:case_collection_form' case.pk %}"
hx-target="#collection-form">
Add to collection</button>
{% endif %}
<div id="collection-form"></div>
<ul id="case-{{ case.pk }}-collections">
{% for collection in case.casecollection_set.all %}
<li id="collection-{{ collection.pk }}">
<a href="{% url 'atlas:collection_detail' pk=collection.pk %}">{{ collection.name }}</a>
{% if can_edit %}
<button
class="btn btn-sm btn-outline-danger remove-button"
hx-post="{% url 'atlas:remove_case_from_collection' case.pk collection.pk %}"
hx-target="#collection-{{ collection.pk }}"
hx-swap="outerHTML"
hx-confirm="Are you sure you want to remove this case from the collection?">
Remove
</button>
{% endif %}
</li>
{% endfor %}
</ul>
</p>
{% if casedetail %}
{% include 'atlas/partials/collection_question_block.html' with can_edit=can_edit %}
{% endif %}
<p class="pre-whitespace"><b>Previous case:</b> {{ case.previous_case.get_link }}</p>
<p class="pre-whitespace"><b>Next case:</b>
{% if case.next_case %}
{{ case.next_case.get_link }}
{% else %}
<a href="{% url 'atlas:case_clone_next' case.pk %}">Clone and add next case</a>
{% endif %}
</p>
<div>
{% include 'question_notes.html' %}
</div>
<p><b>Checked by:</b> {% for verified in case.verified.all %} <a
href="{% url 'atlas:verified_detail' pk=verified.pk %}">{{verified}}</a>, {% endfor %}</p>
<p><b>Author(s):</b> {% for author in case.author.all %} <a
href="{% url 'atlas:author_detail' pk=author.pk %}">{{author}}</a>, {% endfor %}</p>
<p><b>Case size:</b> {{ case.get_total_series_images_size | filesizeformat }}</p>
{% include 'atlas/partials/_finding_modal.html' %}
<!-- Reorder Series Modal -->
<div class="modal fade" id="reorderSeriesModal" tabindex="-1" aria-labelledby="reorderSeriesModalLabel" aria-hidden="true">
<div class="modal-dialog">
<form method="post" action="{% url 'atlas:reorder_series' case.pk %}" id="reorder-series-form">
{% csrf_token %}
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="reorderSeriesModalLabel">Reorder Series</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<ul id="series-reorder-list" class="list-group">
{% for series in case.get_ordered_series %}
<li class="list-group-item d-flex justify-content-between align-items-center" data-series-id="{{ series.pk }}">
<span>
<span class="series-block-series-number">Series {{ forloop.counter }}:</span>
{{ series.get_block|default:series }}
</span>
<span>
<button type="button" class="btn btn-sm btn-outline-secondary move-up"></button>
<button type="button" class="btn btn-sm btn-outline-secondary move-down"></button>
</span>
<input type="hidden" name="series_order" value="{{ series.pk }}">
</li>
{% endfor %}
</ul>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary">Save order</button>
</div>
</div>
</form>
</div>
</div>
<script>
// From series_viewer.html
function loadAnnotationAndViewportOnElement(annotation, viewport, dicom_element, current_image_id_index) {
cornerstoneTools.globalImageIdSpecificToolStateManager.clear(dicom_element);
cornerstone.getEnabledElement(dicom_element).viewport = viewport
cornerstoneTools.globalImageIdSpecificToolStateManager.restoreToolState(
annotation);
console.log("annotation", annotation)
console.log("viewport", viewport)
//cornerstone.getEnabledElement(dicom_element).toolStateManager.restoreToolState(annotationjson)
cornerstone.resize(dicom_element);
console.log("current_image_id_index", current_image_id_index)
//current_image_id_index = parseInt(current_image_id_index);
console.log(current_image_id_index, Number.isInteger(current_image_id_index))
if (Number.isInteger(current_image_id_index)) {
console.log("loading stack index")
dicomViewer.loadStackIndex(current_image_id_index, dicom_element);
} else {
try {
console.log("current_image_id_index", current_image_id_index)
dicomViewer.loadImageById(current_image_id_index, dicom_element);
} catch (e) {
console.log(e)
console.log("loading image by id failed")
console.log("loading next annotation image")
dicomViewer.getNextAnnotationImage(dicom_element);
}
}
}
document.addEventListener("DOMContentLoaded", function () {
const dicomDetails = document.getElementById("dicom-viewer-details");
let dicomViewerLoaded = false;
dicomDetails.addEventListener("toggle", function() {
if (!dicomViewerLoaded && dicomDetails.open) {
// Use safe mount helper to avoid arbitrary timeouts
try { mountDicomViewersSafely(); } catch (e) { if (window.mountDicomViewers) try { window.mountDicomViewers(); } catch (er) { console.warn(er); } }
dicomViewerLoaded = true;
}
});
// Move up/down logic
document.querySelectorAll("#series-reorder-list .move-up, #series-reorder-list .move-down").forEach(function(btn) {
btn.addEventListener("click", function(e) {
e.preventDefault();
const li = btn.closest("li");
if (btn.classList.contains("move-up") && li.previousElementSibling) {
li.parentNode.insertBefore(li, li.previousElementSibling);
}
if (btn.classList.contains("move-down") && li.nextElementSibling) {
li.parentNode.insertBefore(li.nextElementSibling, li);
}
});
});
// On submit, update the hidden inputs to match the new order
const reorderSeriesForm = document.getElementById("reorder-series-form");
if (reorderSeriesForm) {
reorderSeriesForm.addEventListener("submit", function(e) {
const lis = document.querySelectorAll("#series-reorder-list li");
lis.forEach((li, idx) => {
li.querySelector("input[name='series_order']").setAttribute("name", "series_order_" + idx);
});
});
}
// Drag and drop reordering for series list
const reorderList = document.getElementById("series-reorder-list");
let draggedItem = null;
if (reorderList) {
reorderList.querySelectorAll("li").forEach(function(li) {
li.setAttribute("draggable", "true");
li.addEventListener("dragstart", function(e) {
draggedItem = li;
li.classList.add("dragging");
e.dataTransfer.effectAllowed = "move";
});
li.addEventListener("dragend", function() {
draggedItem = null;
li.classList.remove("dragging");
});
li.addEventListener("dragover", function(e) {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
// Visual feedback
li.classList.add("drag-over");
});
li.addEventListener("dragleave", function() {
li.classList.remove("drag-over");
});
li.addEventListener("drop", function(e) {
e.preventDefault();
li.classList.remove("drag-over");
if (draggedItem && draggedItem !== li) {
// Insert before or after depending on mouse position
const bounding = li.getBoundingClientRect();
const offset = e.clientY - bounding.top;
if (offset < bounding.height / 2) {
li.parentNode.insertBefore(draggedItem, li);
} else {
li.parentNode.insertBefore(draggedItem, li.nextSibling);
}
}
});
});
}
const detailsElement = document.getElementById("series-actions");
const buttons = document.querySelectorAll(".select-series-btn");
const seriesCheckboxes = document.querySelectorAll("input[type='checkbox'][name='series-ids']");
const selectAllBtn = document.getElementById("series-select-all-btn");
const deselectAllBtn = document.getElementById("series-deselect-all-btn");
function toggleCheckboxes() {
const isOpen = detailsElement && detailsElement.hasAttribute("open");
buttons.forEach((button) => {
button.style.display = isOpen ? "inline-block" : "none";
});
}
function syncSeriesSelectionUi() {
document.querySelectorAll(".select-series-btn").forEach(function(btn) {
const parent = btn.closest(".series-block");
if (!parent) {
return;
}
const checkbox = parent.querySelector("input[type='checkbox'][name='series-ids']");
if (!checkbox) {
return;
}
btn.classList.toggle("btn-primary", checkbox.checked);
btn.classList.toggle("btn-outline-primary", !checkbox.checked);
btn.textContent = checkbox.checked ? "Selected" : "Select";
if (checkbox.checked) {
parent.classList.add("highlight-series");
} else {
parent.classList.remove("highlight-series");
}
});
const selectedCount = Array.from(seriesCheckboxes).filter((checkbox) => checkbox.checked).length;
if (selectAllBtn) {
selectAllBtn.disabled = selectedCount === seriesCheckboxes.length && seriesCheckboxes.length > 0;
}
if (deselectAllBtn) {
deselectAllBtn.disabled = selectedCount === 0;
}
}
document.querySelectorAll(".select-series-btn").forEach(function(btn) {
btn.addEventListener("click", function() {
const parent = btn.closest(".series-block");
const checkbox = parent.querySelector("input[type='checkbox'][name='series-ids']");
checkbox.checked = !checkbox.checked;
syncSeriesSelectionUi();
});
});
seriesCheckboxes.forEach((checkbox) => {
checkbox.addEventListener("change", syncSeriesSelectionUi);
});
if (selectAllBtn) {
selectAllBtn.addEventListener("click", function() {
seriesCheckboxes.forEach((checkbox) => {
checkbox.checked = true;
});
syncSeriesSelectionUi();
});
}
if (deselectAllBtn) {
deselectAllBtn.addEventListener("click", function() {
seriesCheckboxes.forEach((checkbox) => {
checkbox.checked = false;
});
syncSeriesSelectionUi();
});
}
// Initial state
toggleCheckboxes();
syncSeriesSelectionUi();
// Show the alert when a result is received
const resultsSpan = document.getElementById("series-action-results");
const alertDiv = document.getElementById("series-action-alert");
const observer = new MutationObserver(function(mutations) {
if (resultsSpan.textContent.trim() !== "") {
alertDiv.style.display = "block";
} else {
alertDiv.style.display = "none";
}
});
observer.observe(resultsSpan, { childList: true, subtree: true });
// Listen for toggle events on the <details> element
if (detailsElement) {
detailsElement.addEventListener("toggle", toggleCheckboxes);
}
// Display Set modal handler
const dsModal = new bootstrap.Modal(document.getElementById("displaysetModal"));
const dsModalBody = document.getElementById("displaysetModalBody");
const dsFullscreenModal = new bootstrap.Modal(document.getElementById("displaysetFullscreenModal"));
// Loader functions: can be called directly on reload
// Helper: wait for mount function and mount the viewers safely
function waitForMountFn(timeout = 5000) {
if (window.mountDicomViewers) return Promise.resolve(window.mountDicomViewers);
return new Promise((resolve, reject) => {
const start = Date.now();
function poll() {
if (window.mountDicomViewers) return resolve(window.mountDicomViewers);
if (Date.now() - start > timeout) return reject(new Error('mountDicomViewers not available'));
requestAnimationFrame(poll);
}
poll();
});
}
async function mountDicomViewersSafely(timeout = 5000) {
try {
// wait for layout to stabilise
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
const mountFn = await waitForMountFn(timeout);
try { mountFn(); } catch (e) { console.warn('mountDicomViewers failed', e); }
// allow any async render to settle then force a resize event
requestAnimationFrame(() => {
try { window.dispatchEvent(new Event('resize')); } catch (e) {}
if (typeof window.resizeDicomViewers === 'function') try { window.resizeDicomViewers(); } catch (e) {}
});
} catch (err) {
console.warn('Viewer initialization deferred/fallback:', err);
}
}
async function loadDisplaysetModal(url, dsId) {
if (!url && dsId) url = `/atlas/${dsId}/display_sets/modal`;
if (!url) return;
dsModalBody.innerHTML = "<p>Loading...</p>";
try {
const resp = await fetch(url);
const html = await resp.text();
dsModalBody.innerHTML = html;
try {
const fsBtn = document.querySelector('.displayset-fullscreen-btn');
if (fsBtn) {
fsBtn.dataset.url = url;
if (dsId) fsBtn.dataset.dsId = dsId;
}
} catch (e) { console.warn(e); }
try { setUrlParam('displayset', dsId); } catch (e) {}
// Initialize viewer deterministically after injecting HTML
mountDicomViewersSafely();
dsModal.show();
} catch (error) {
console.error('Error loading display set details:', error);
dsModalBody.innerHTML = "<p>Failed to load display set.</p>";
}
}
async function loadDisplaysetFullscreen(url, dsId) {
if (!url && dsId) url = `/atlas/${dsId}/display_sets/modal`;
if (!url) return;
document.getElementById('displaysetFullscreenLeft').innerHTML = '<p style="color:#fff; padding:1rem;">Loading viewer...</p>';
document.getElementById('displaysetFullscreenRight').innerHTML = '<p>Loading details...</p>';
try {
const resp = await fetch(url);
const html = await resp.text();
const tmp = document.createElement('div');
tmp.innerHTML = html;
const viewer = tmp.querySelector('.dicom-viewer-root');
if (viewer) {
const left = document.getElementById('displaysetFullscreenLeft');
left.innerHTML = '';
left.appendChild(viewer);
try { viewer.style.height='100%'; viewer.style.minHeight='100%'; viewer.style.width='100%'; viewer.style.display='block'; viewer.style.flex='1 1 auto'; } catch (e){}
} else {
document.getElementById('displaysetFullscreenLeft').innerHTML = '<p style="color:#fff; padding:1rem;">No viewer available</p>';
}
const viewerInTmp = tmp.querySelector('.dicom-viewer-root');
if (viewerInTmp) viewerInTmp.remove();
document.getElementById('displaysetFullscreenRight').innerHTML = tmp.innerHTML;
try { if (dsId) { setUrlParam('displayset', dsId); setUrlParam('displayset_full', '1'); } } catch (e) {}
dsFullscreenModal.show();
// Ensure viewer is mounted and resized after moving into fullscreen
mountDicomViewersSafely();
} catch (error) {
console.error('Error loading fullscreen display set:', error);
document.getElementById('displaysetFullscreenLeft').innerHTML = '<p style="color:#fff; padding:1rem;">Failed to load viewer</p>';
document.getElementById('displaysetFullscreenRight').innerHTML = '<p>Failed to load details.</p>';
}
}
async function loadFindingModal(findingId, url) {
if (!url && findingId) url = `/atlas/series_finding/${findingId}/details/`;
if (!url) return;
const modalBody = document.getElementById('findingModalBody');
modalBody.innerHTML = '<p>Loading...</p>';
try {
const resp = await fetch(url);
const html = await resp.text();
modalBody.innerHTML = html;
// Ensure viewers mount after content injection
mountDicomViewersSafely();
try { setUrlParam('finding', findingId); } catch (e) {}
const modal = new bootstrap.Modal(document.getElementById('findingModal'));
modal.show();
document.getElementById('reload-finding')?.addEventListener('click', function () { mountDicomViewersSafely(); });
} catch (e) {
console.error('Error loading finding details:', e);
modalBody.innerHTML = '<p>Failed to load finding details.</p>';
}
}
// remove displayset param when modal closed
document.getElementById('displaysetModal').addEventListener('hidden.bs.modal', function () {
try { removeUrlParam('displayset'); removeUrlParam('displayset_full'); } catch (e) {}
});
// remove fullscreen param when fullscreen modal closed
document.getElementById('displaysetFullscreenModal').addEventListener('hidden.bs.modal', function () {
try { removeUrlParam('displayset_full'); } catch (e) {}
});
// FINDING modal: push/remove param so it can be reopened after reload
document.querySelectorAll(".view-finding-modal").forEach(button => {
button.addEventListener("click", function () {
const findingId = this.getAttribute("data-finding-id");
const url = this.getAttribute('data-url');
loadFindingModal(findingId, url);
});
});
document.getElementById('findingModal').addEventListener('hidden.bs.modal', function () {
try { removeUrlParam('finding'); } catch (e) {}
});
// Helper: set/remove URL search params without reloading
function setUrlParam(key, value) {
if (!key) return;
const url = new URL(window.location.href);
if (value === null || value === undefined) {
url.searchParams.delete(key);
} else {
url.searchParams.set(key, value);
}
window.history.replaceState({}, '', url.toString());
}
function removeUrlParam(key) {
const url = new URL(window.location.href);
url.searchParams.delete(key);
window.history.replaceState({}, '', url.toString());
}
// On load: reopen modals if URL params present — call loader functions directly
(function reopenModalsFromUrl() {
const params = new URL(window.location.href).searchParams;
const dsId = params.get('displayset');
const dsFull = params.get('displayset_full');
const findingId = params.get('finding');
if (dsId) {
// prefer to use existing button url if present, otherwise construct endpoint
const btn = document.querySelector(`.view-displayset-modal[data-ds-id="${dsId}"]`);
const url = btn ? btn.dataset.url : null;
if (dsFull) {
loadDisplaysetFullscreen(url, dsId);
} else {
loadDisplaysetModal(url, dsId);
}
} else if (findingId) {
// open finding modal
const fbtn = document.querySelector(`.view-finding-modal[data-finding-id="${findingId}"]`);
const url = fbtn ? fbtn.getAttribute('data-url') : null;
loadFindingModal(findingId, url);
}
})();
});
</script>
<style>
/* Highlight style for the series-block (subtle Bootstrap-like accent) */
.highlight-series {
background-color: rgba(13,110,253,0.04);
border: 1px solid rgba(13,110,253,0.12);
transition: box-shadow 0.18s ease, transform 0.12s ease;
box-shadow: 0 4px 12px rgba(13,110,253,0.04);
}
#series-reorder-list .dragging {
opacity: 0.5;
}
#series-reorder-list .drag-over {
border-top: 2px solid #0d6efd;
}
.series-actions[open] {
border: 2px solid #0d6efd;
border-radius: 6px;
padding: 8px;
margin-top: 8px;
}
.series-actions {
flex: 1 1 auto;
min-width: 0; /* allow flex children to shrink without overflow */
display: flex;
flex-direction: column;
gap: .5rem;
border: 1px solid rgba(0,0,0,0.06);
border-radius: .375rem;
padding: .5rem;
background: rgba(255,255,255,0.02);
margin-top: .5rem;
}
.series-actions-body {
display: flex;
flex-direction: column;
gap: .5rem;
}
.series-selection-toolbar {
border-top: 1px solid rgba(0,0,0,0.08);
border-bottom: 1px solid rgba(0,0,0,0.08);
padding: .5rem 0;
}
.series-actions-grid .btn,
.series-actions-body > a.btn,
.series-move-panel .btn {
width: 100%;
}
.series-move-panel {
background: rgba(255,255,255,0.03);
}
.series-move-panel .case-details {
margin-bottom: .5rem;
}
.series-actions summary { list-style: none; }
.series-actions summary::-webkit-details-marker { display:none; }
.series-actions .btn {
vertical-align: middle;
text-align: left;
}
@media (prefers-color-scheme: dark) {
.series-actions { background: rgba(255,255,255,0.02); border-color: rgba(255,255,255,0.04); }
}
.link-buttons {
float: right;
}
.normal-block {
float: right;
}
</style>