Implement resource search functionality and enhance resource management in case details

This commit is contained in:
Ross
2025-12-15 09:53:19 +00:00
parent 1e2300bd70
commit 4bec329aff
7 changed files with 321 additions and 130 deletions
+39 -9
View File
@@ -201,15 +201,45 @@
</div>
</div>
</div>
<p class="pre-whitespace"><b>Resource</b><br/>{% for caseresource in case.caseresource_set.all %}
<a href='{% url "atlas:resource_detail" caseresource.resource.pk %}'>{{caseresource.resource.name}}</a>
{% if caseresource.pre_review %}
<span title="This resource will be avalible to review when or taking a case">(pre)</span>
{% else %}
<span title="This resource will only be available when reviewing a completed case">(post)</span>
{% endif %}
{% endfor %}
</p>
<div class="mb-3">
<h5>Resources</h5>
<div class="mb-2">
<form id="resource-search-form" class="row g-2" hx-get="{% url 'atlas:resource_search_widget' %}" hx-target="#resource-search-results" hx-swap="innerHTML" hx-trigger="keyup changed delay:500ms from:#resource-search-q">
<input type="hidden" name="case_id" value="{{ case.pk }}" />
<div class="col-auto" style="flex: 1 1 300px;">
<input type="search" id="resource-search-q" name="q" class="form-control form-control-sm" placeholder="Search resources by name, description or URL" />
</div>
<div class="col-auto">
<select name="site" class="form-select form-select-sm">
<option value="">All sites</option>
{% for s in site_queryset %}
<option value="{{ s.pk }}">{{ s.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-auto">
<select name="subspecialty" class="form-select form-select-sm">
<option value="">All subspecialties</option>
{% for sp in subspecialty_queryset %}
<option value="{{ sp.pk }}">{{ sp.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-auto">
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="resource-open-access-{{ case.pk }}" name="open_access" value="1">
<label class="form-check-label small" for="resource-open-access-{{ case.pk }}">Open</label>
</div>
</div>
</form>
<div id="resource-search-results" class="mt-2"></div>
</div>
<div>
{% include 'atlas/partials/case_resources_list.html' with case=case %}
</div>
</div>
<div class="pre-whitespace multi-image-block"><b>Series:</b>
<form>
@@ -0,0 +1,19 @@
<div id="case-resources-list">
{% if case.caseresource_set.exists %}
<ul class="list-unstyled mb-0">
{% for cr in case.caseresource_set.all %}
<li class="py-1 d-flex justify-content-between align-items-center" data-caseresource-id="{{ cr.id }}">
<div>
<a href="{% url 'atlas:resource_detail' cr.resource.pk %}">{{ cr.resource.name }}</a>
{% if cr.pre_review %}<span class="badge bg-info text-dark ms-2 small">pre</span>{% endif %}
</div>
<div>
<button type="button" class="btn btn-sm btn-outline-danger" hx-post="{% url 'atlas:case_detach_resource' pk=case.pk %}" hx-vals='{"resource_id": "{{ cr.resource.pk }}"}' hx-swap="outerHTML" hx-target="#case-resources-list">Remove</button>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<div class="text-muted">No resources attached to this case.</div>
{% endif %}
</div>
+119 -119
View File
@@ -5,142 +5,142 @@
</div>
<script>
(function(){
var wrapperId = 'resource_media_wrapper_{{ resource.pk }}';
if (document.getElementById(wrapperId).dataset._inited) return;
var wrap = document.getElementById(wrapperId);
if (!wrap) return;
wrap.dataset._inited = '1';
var src = wrap.getAttribute('data-src');
if (!src) return;
var lower = src.toLowerCase();
(function(){
var wrapperId = 'resource_media_wrapper_{{ resource.pk }}';
if (document.getElementById(wrapperId).dataset._inited) return;
var wrap = document.getElementById(wrapperId);
if (!wrap) return;
wrap.dataset._inited = '1';
var src = wrap.getAttribute('data-src');
if (!src) return;
var lower = src.toLowerCase();
function createImg(src) {
var img = document.createElement('img');
img.src = src;
img.className = 'img-fluid resource-media-element rounded';
img.style.maxHeight = '60vh';
img.style.objectFit = 'contain';
img.style.cursor = 'zoom-in';
return img;
}
function createVideo(src) {
var video = document.createElement('video');
video.className = 'resource-media-element rounded';
video.controls = true;
video.preload = 'metadata';
video.style.maxWidth = '100%';
video.style.maxHeight = '60vh';
video.style.objectFit = 'contain';
var srcEl = document.createElement('source');
srcEl.src = src;
video.appendChild(srcEl);
return video;
}
function createIframe(src, embedUrl) {
var iframe = document.createElement('iframe');
iframe.src = embedUrl || src;
iframe.setAttribute('frameborder','0');
iframe.setAttribute('allowfullscreen','');
iframe.className = 'resource-media-element rounded';
iframe.style.width = '100%';
iframe.style.height = '60vh';
return iframe;
}
function createImg(src) {
var img = document.createElement('img');
img.src = src;
img.className = 'img-fluid resource-media-element rounded';
img.style.maxHeight = '60vh';
img.style.objectFit = 'contain';
img.style.cursor = 'zoom-in';
return img;
}
function createVideo(src) {
var video = document.createElement('video');
video.className = 'resource-media-element rounded';
video.controls = true;
video.preload = 'metadata';
video.style.maxWidth = '100%';
video.style.maxHeight = '60vh';
video.style.objectFit = 'contain';
var srcEl = document.createElement('source');
srcEl.src = src;
video.appendChild(srcEl);
return video;
}
function createIframe(src, embedUrl) {
var iframe = document.createElement('iframe');
iframe.src = embedUrl || src;
iframe.setAttribute('frameborder','0');
iframe.setAttribute('allowfullscreen','');
iframe.className = 'resource-media-element rounded';
iframe.style.width = '100%';
iframe.style.height = '60vh';
return iframe;
}
var el = null;
var el = null;
// YouTube
if (lower.indexOf('youtube.com') !== -1 || lower.indexOf('youtu.be') !== -1) {
if (lower.indexOf('youtube.com') !== -1 || lower.indexOf('youtu.be') !== -1) {
// build embed url
var videoId = null;
try {
var m = src.match(/(?:v=|v\/|embed\/|youtu\.be\/)([A-Za-z0-9_-]{6,})/);
if (m && m[1]) videoId = m[1];
} catch(e){}
var embed = videoId ? ('https://www.youtube.com/embed/' + videoId) : src;
el = createIframe(src, embed);
} else if (lower.indexOf('vimeo.com') !== -1) {
var m2 = src.match(/vimeo\.com\/(\d+)/);
var embed2 = m2 && m2[1] ? ('https://player.vimeo.com/video/' + m2[1]) : src;
el = createIframe(src, embed2);
} else if (lower.match(/\.(mp4|webm|ogg)(?:$|\?|#)/)) {
el = createVideo(src);
} else if (lower.match(/\.(jpe?g|png|gif|webp|svg)(?:$|\?|#)/)) {
el = createImg(src);
} else {
var videoId = null;
try {
var m = src.match(/(?:v=|v\/|embed\/|youtu\.be\/)([A-Za-z0-9_-]{6,})/);
if (m && m[1]) videoId = m[1];
} catch(e){}
var embed = videoId ? ('https://www.youtube.com/embed/' + videoId) : src;
el = createIframe(src, embed);
} else if (lower.indexOf('vimeo.com') !== -1) {
var m2 = src.match(/vimeo\.com\/(\d+)/);
var embed2 = m2 && m2[1] ? ('https://player.vimeo.com/video/' + m2[1]) : src;
el = createIframe(src, embed2);
} else if (lower.match(/\.(mp4|webm|ogg)(?:$|\?|#)/)) {
el = createVideo(src);
} else if (lower.match(/\.(jpe?g|png|gif|webp|svg)(?:$|\?|#)/)) {
el = createImg(src);
} else {
// unknown: try image first, fallback to link
el = createImg(src);
el = createImg(src);
// if image fails to load, replace with link
el.addEventListener('error', function(){
var a = document.createElement('a');
a.href = src; a.target = '_blank'; a.rel = 'noopener'; a.textContent = 'Open resource';
wrap.innerHTML = ''; wrap.appendChild(a);
});
}
el.addEventListener('error', function(){
var a = document.createElement('a');
a.href = src; a.target = '_blank'; a.rel = 'noopener'; a.textContent = 'Open resource';
wrap.innerHTML = ''; wrap.appendChild(a);
});
}
// append preview element
if (el) {
wrap.appendChild(el);
if (el) {
wrap.appendChild(el);
// click -> open modal/full-size
el.addEventListener('click', function(e) {
e.preventDefault();
el.addEventListener('click', function(e) {
e.preventDefault();
// create modal container if not present
var modalId = 'resource_media_modal_{{ resource.pk }}';
var existing = document.getElementById(modalId);
if (!existing) {
var modalDiv = document.createElement('div');
modalDiv.className = 'modal fade resource-media-modal';
modalDiv.id = modalId;
modalDiv.tabIndex = -1;
modalDiv.innerHTML = '<div class="modal-dialog modal-xl modal-dialog-centered"><div class="modal-content bg-dark"><div class="modal-body p-0 text-center"></div></div></div>';
document.body.appendChild(modalDiv);
}
var modalEl = document.getElementById(modalId);
var body = modalEl.querySelector('.modal-body');
body.innerHTML = '';
var modalId = 'resource_media_modal_{{ resource.pk }}';
var existing = document.getElementById(modalId);
if (!existing) {
var modalDiv = document.createElement('div');
modalDiv.className = 'modal fade resource-media-modal';
modalDiv.id = modalId;
modalDiv.tabIndex = -1;
modalDiv.innerHTML = '<div class="modal-dialog modal-xl modal-dialog-centered"><div class="modal-content bg-dark"><div class="modal-body p-0 text-center"></div></div></div>';
document.body.appendChild(modalDiv);
}
var modalEl = document.getElementById(modalId);
var body = modalEl.querySelector('.modal-body');
body.innerHTML = '';
// create full-size node depending on type
if (el.tagName.toLowerCase() === 'img') {
var big = document.createElement('img');
big.src = src;
big.style.maxWidth = '95vw';
big.style.maxHeight = '95vh';
big.style.objectFit = 'contain';
big.className = 'img-fluid';
body.appendChild(big);
} else if (el.tagName.toLowerCase() === 'video') {
var bigv = document.createElement('video');
bigv.controls = true; bigv.autoplay = false; bigv.style.maxWidth = '95vw'; bigv.style.maxHeight = '95vh'; bigv.style.objectFit = 'contain'; bigv.className = 'w-100';
var s = document.createElement('source'); s.src = src; bigv.appendChild(s);
body.appendChild(bigv);
} else if (el.tagName.toLowerCase() === 'iframe') {
var frame = document.createElement('iframe');
frame.src = el.src;
frame.setAttribute('frameborder','0');
frame.setAttribute('allowfullscreen','');
frame.style.width = '95vw'; frame.style.height = '80vh';
body.appendChild(frame);
} else {
var a = document.createElement('a'); a.href = src; a.target = '_blank'; a.rel = 'noopener'; a.textContent = 'Open resource'; body.appendChild(a);
}
if (el.tagName.toLowerCase() === 'img') {
var big = document.createElement('img');
big.src = src;
big.style.maxWidth = '95vw';
big.style.maxHeight = '95vh';
big.style.objectFit = 'contain';
big.className = 'img-fluid';
body.appendChild(big);
} else if (el.tagName.toLowerCase() === 'video') {
var bigv = document.createElement('video');
bigv.controls = true; bigv.autoplay = false; bigv.style.maxWidth = '95vw'; bigv.style.maxHeight = '95vh'; bigv.style.objectFit = 'contain'; bigv.className = 'w-100';
var s = document.createElement('source'); s.src = src; bigv.appendChild(s);
body.appendChild(bigv);
} else if (el.tagName.toLowerCase() === 'iframe') {
var frame = document.createElement('iframe');
frame.src = el.src;
frame.setAttribute('frameborder','0');
frame.setAttribute('allowfullscreen','');
frame.style.width = '95vw'; frame.style.height = '80vh';
body.appendChild(frame);
} else {
var a = document.createElement('a'); a.href = src; a.target = '_blank'; a.rel = 'noopener'; a.textContent = 'Open resource'; body.appendChild(a);
}
// show modal using Bootstrap if available
try {
var bsModal = new bootstrap.Modal(modalEl);
bsModal.show();
} catch (e) {
try {
var bsModal = new bootstrap.Modal(modalEl);
bsModal.show();
} catch (e) {
// fallback: open in new tab
window.open(src, '_blank', 'noopener');
}
});
}
})();
window.open(src, '_blank', 'noopener');
}
});
}
})();
</script>
<style>
.resource-media-wrapper .resource-media-element { display: inline-block; }
.resource-media-wrapper img.resource-media-element { max-width: 100%; height: auto; }
.resource-media-wrapper video.resource-media-element { max-width: 100%; height: auto; }
.resource-media-modal .modal-content { background: rgba(0,0,0,0.95); }
.resource-media-wrapper .resource-media-element { display: inline-block; }
.resource-media-wrapper img.resource-media-element { max-width: 100%; height: auto; }
.resource-media-wrapper video.resource-media-element { max-width: 100%; height: auto; }
.resource-media-modal .modal-content { background: rgba(0,0,0,0.95); }
</style>
@@ -0,0 +1,37 @@
{% if results %}
<ul class="list-unstyled mb-0" id="resource_search_results_list_{{ field }}">
{% for item in results %}
<li class="py-1 d-flex justify-content-between align-items-center" data-resource-id="{{ item.id }}">
<div class="flex-grow-1">
<a href="{% url 'atlas:resource_detail' item.pk %}" target="_blank">{{ item.name|escape }}</a>
<div class="small text-muted">{{ item.get_display|safe }}</div>
</div>
<div class="d-flex gap-2">
<button
type="button"
class="btn btn-sm btn-success resource-attach-btn"
hx-post="{% url 'atlas:case_attach_resource' pk=case_id %}"
hx-vals='{"resource_id": "{{ item.id }}", "pre_review": "0"}'
hx-swap="outerHTML"
hx-target="#case-resources-list">
Attach
</button>
<button
type="button"
class="btn btn-sm btn-outline-success resource-attach-pre-btn"
hx-post="{% url 'atlas:case_attach_resource' pk=case_id %}"
hx-vals='{"resource_id": "{{ item.id }}", "pre_review": "1"}'
hx-swap="outerHTML"
hx-target="#case-resources-list">
Attach (pre)
</button>
</div>
</li>
{% endfor %}
</ul>
<div class="mt-2 text-end">
<small class="text-muted">{{ results|length }} results</small>
</div>
{% else %}
<div><em>No resources found</em></div>
{% endif %}
+1 -1
View File
@@ -45,7 +45,7 @@
<dd class="col-sm-9">
<a href="{{ resource.file.url }}" target="_blank" rel="noopener">Download</a>
{% if resource.file.name %}
<small class="text-muted ms-2">({{ resource.file.name|truncatechars:40 }})</small>
<small class="text-muted ms-2">({{ resource.file.name|truncatechars:40 }})</small>
{% endif %}
</dd>
{% endif %}