add more multiuser support
This commit is contained in:
@@ -145,6 +145,9 @@
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'atlas:question_schema_overview' %}" title="View and edit question schemas"><i class="bi bi-journal-text me-1" aria-hidden="true"></i>Question Schemas</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'atlas:multiuser_dashboard' %}" title="Multiuser case sync and review"><i class="bi bi-people me-1" aria-hidden="true"></i>Multiuser Sync</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'atlas:help' %}" title="View the atlas help pages"><i class="bi bi-question-circle me-1" aria-hidden="true"></i>Help</a>
|
||||
</li>
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
{% extends 'atlas/base.html' %}
|
||||
|
||||
{% block title %}
|
||||
Multiuser Sync Portal - Atlas
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
<div class="row mb-4 align-items-center">
|
||||
<div class="col">
|
||||
<h1 class="h3 mb-1"><i class="bi bi-people-fill text-primary me-2"></i>Multiuser DICOM Portal</h1>
|
||||
<p class="text-muted">Create or join real-time collaborative review sessions with synchronized DICOM viewports.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Sidebar: Start a new session -->
|
||||
<div class="col-md-4 mb-4">
|
||||
<div class="card shadow-sm border-secondary">
|
||||
<div class="card-header bg-dark text-white fw-bold">
|
||||
<i class="bi bi-plus-circle me-1"></i> Start New Session
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{% url 'atlas:multiuser_create_session' %}">
|
||||
{% csrf_token %}
|
||||
<div class="mb-3">
|
||||
<label for="sessionName" class="form-label">Session Name / Topic (Optional)</label>
|
||||
<input type="text" class="form-control bg-dark text-white border-secondary" id="sessionName" name="name" placeholder="e.g. Case Review Cohort A" max_length="255">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="bi bi-play-fill me-1"></i> Start Session
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main content: Sessions list -->
|
||||
<div class="col-md-8">
|
||||
<!-- My Sessions -->
|
||||
<div class="card shadow-sm mb-4 bg-dark text-white border-secondary">
|
||||
<div class="card-header bg-secondary text-white fw-bold">
|
||||
<i class="bi bi-person-fill-check me-1"></i> Sessions You Managed
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if my_sessions %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-striped table-hover mb-0 align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Active Case</th>
|
||||
<th>Created At</th>
|
||||
<th class="text-end">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for session in my_sessions %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong class="text-info">{{ session.name|default:"Unnamed Session" }}</strong>
|
||||
<br>
|
||||
<small class="text-muted font-monospace">{{ session.id }}</small>
|
||||
</td>
|
||||
<td>
|
||||
{% if session.active_case %}
|
||||
<span class="badge bg-success">{{ session.active_case.title|default:"Case Loaded" }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">No case active</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ session.created_at|date:"d M Y, H:i" }}</td>
|
||||
<td class="text-end">
|
||||
<a href="{% url 'atlas:multiuser_session_detail' session_id=session.id %}" class="btn btn-outline-info btn-sm">
|
||||
Enter Workspace <i class="bi bi-arrow-right-short"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="p-4 text-center text-muted">
|
||||
<i class="bi bi-inbox-fill fs-2 mb-2 d-block"></i>
|
||||
You haven't created any sessions yet.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active public sessions -->
|
||||
<div class="card shadow-sm bg-dark text-white border-secondary">
|
||||
<div class="card-header bg-secondary text-white fw-bold">
|
||||
<i class="bi bi-globe me-1"></i> Active Invited Sessions (Last 24 Hours)
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if active_sessions %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-striped table-hover mb-0 align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Host</th>
|
||||
<th>Active Case</th>
|
||||
<th>Created At</th>
|
||||
<th class="text-end">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for session in active_sessions %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong class="text-info">{{ session.name|default:"Unnamed Session" }}</strong>
|
||||
<br>
|
||||
<small class="text-muted font-monospace">{{ session.id }}</small>
|
||||
</td>
|
||||
<td>{{ session.creator.get_full_name|default:session.creator.username }}</td>
|
||||
<td>
|
||||
{% if session.active_case %}
|
||||
<span class="badge bg-success">{{ session.active_case.title|default:"Case Loaded" }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">No case active</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ session.created_at|date:"d M Y, H:i" }}</td>
|
||||
<td class="text-end">
|
||||
<a href="{% url 'atlas:multiuser_session_detail' session_id=session.id %}" class="btn btn-outline-info btn-sm">
|
||||
Join Session <i class="bi bi-arrow-right-short"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="p-4 text-center text-muted">
|
||||
<i class="bi bi-broadcast fs-2 mb-2 d-block"></i>
|
||||
No other active sessions found. Create a session to invite others!
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,204 @@
|
||||
{% extends 'atlas/base.html' %}
|
||||
{% load static %}
|
||||
{% load django_htmx %}
|
||||
|
||||
{% block title %}
|
||||
Multiuser Workspace - {{ session.name|default:"Session" }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-3 px-4">
|
||||
<!-- Session Header -->
|
||||
<div class="row align-items-center mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<a href="{% url 'atlas:multiuser_dashboard' %}" class="btn btn-outline-secondary btn-sm" title="Back to Dashboard">
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
</a>
|
||||
<h1 class="h4 mb-0 text-white">
|
||||
<i class="bi bi-people text-info me-2"></i>{{ session.name|default:"Unnamed Session" }}
|
||||
</h1>
|
||||
<span class="badge bg-secondary font-monospace small">{{ session.id|stringformat:".8s" }}</span>
|
||||
</div>
|
||||
<div class="small text-muted mt-1">
|
||||
Managed by <strong>{{ session.creator.get_full_name|default:session.creator.username }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Invite Sharing Link Widget -->
|
||||
<div class="col-md-6 mt-2 mt-md-0 d-flex justify-content-md-end">
|
||||
<div class="input-group input-group-sm" style="max-width: 420px;">
|
||||
<span class="input-group-text bg-secondary text-white border-secondary">
|
||||
<i class="bi bi-link-45deg"></i> Share Invite
|
||||
</span>
|
||||
<input type="text" id="inviteLinkInput" class="form-control bg-dark text-white border-secondary" readonly value="">
|
||||
<button class="btn btn-info" type="button" id="copyLinkBtn" onclick="copyInviteLink()">
|
||||
<i class="bi bi-clipboard me-1"></i> Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manager Control Bar -->
|
||||
{% if is_manager %}
|
||||
<div class="card bg-dark border-info text-white mb-4">
|
||||
<div class="card-body py-2 px-3 d-flex align-items-center flex-wrap gap-3">
|
||||
<div class="fw-semibold text-info small uppercase me-2">
|
||||
<i class="bi bi-gear-fill me-1 animate-spin"></i> Host Controls:
|
||||
</div>
|
||||
|
||||
<!-- Search Cases input -->
|
||||
<div class="position-relative flex-grow-1" style="max-width: 480px;">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text bg-secondary text-white border-secondary">
|
||||
<i class="bi bi-search"></i>
|
||||
</span>
|
||||
<input type="text" id="multiuserCaseSearch" class="form-control bg-dark text-white border-secondary"
|
||||
placeholder="Search cases to load..."
|
||||
hx-get="{% url 'atlas:multiuser_search_cases' session_id=session.id %}"
|
||||
hx-trigger="input delay:300ms, focus"
|
||||
hx-target="#caseSearchDropdown"
|
||||
hx-swap="innerHTML"
|
||||
onfocus="document.getElementById('caseSearchDropdown').style.display='block'"
|
||||
autocomplete="off">
|
||||
</div>
|
||||
<!-- Autocomplete Dropdown list -->
|
||||
<div id="caseSearchDropdown" class="position-absolute w-100 mt-1" style="display: none; z-index: 10000;">
|
||||
<!-- Results injected by HTMX -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="text-muted small">|</span>
|
||||
|
||||
<!-- Users Counter -->
|
||||
<div class="small text-light">
|
||||
<i class="bi bi-person-fill text-success"></i> Connected Users: <span id="connectedUsersCount" class="badge bg-secondary">1</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- Participant Status Bar -->
|
||||
<div class="card bg-dark border-secondary text-white mb-4">
|
||||
<div class="card-body py-2 px-3 d-flex align-items-center justify-content-between">
|
||||
<div class="small text-muted">
|
||||
<i class="bi bi-broadcast text-warning me-1"></i> You are viewing this session in real-time. Viewport controls are synchronized.
|
||||
</div>
|
||||
<div class="small text-light">
|
||||
<i class="bi bi-person-fill text-success"></i> Connected Users: <span id="connectedUsersCountParticipant" class="badge bg-secondary">1</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Main Workspace Area -->
|
||||
<div id="multiuser-case-container"
|
||||
hx-get="{% url 'atlas:multiuser_case_content_partial' session_id=session.id %}"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
<div class="d-flex justify-content-center py-5">
|
||||
<div class="spinner-border text-info" role="status">
|
||||
<span class="visually-hidden">Loading case workspace...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
<script type="text/javascript">
|
||||
// Set invite link input on load
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
const inviteInput = document.getElementById("inviteLinkInput");
|
||||
if (inviteInput) {
|
||||
inviteInput.value = window.location.href;
|
||||
}
|
||||
});
|
||||
|
||||
// Copy to clipboard helper
|
||||
function copyInviteLink() {
|
||||
const inviteInput = document.getElementById("inviteLinkInput");
|
||||
const copyBtn = document.getElementById("copyLinkBtn");
|
||||
|
||||
inviteInput.select();
|
||||
inviteInput.setSelectionRange(0, 99999); // For mobile devices
|
||||
|
||||
navigator.clipboard.writeText(inviteInput.value).then(function() {
|
||||
const origHTML = copyBtn.innerHTML;
|
||||
copyBtn.innerHTML = '<i class="bi bi-check-lg me-1"></i> Copied!';
|
||||
copyBtn.className = "btn btn-success";
|
||||
setTimeout(function() {
|
||||
copyBtn.innerHTML = origHTML;
|
||||
copyBtn.className = "btn btn-info";
|
||||
}, 2000);
|
||||
}).catch(function(err) {
|
||||
console.error('Could not copy link: ', err);
|
||||
});
|
||||
}
|
||||
|
||||
// Hide search autocomplete dropdown when clicking outside
|
||||
document.addEventListener("click", function(e) {
|
||||
const dropdown = document.getElementById("caseSearchDropdown");
|
||||
const searchInput = document.getElementById("multiuserCaseSearch");
|
||||
if (dropdown && searchInput && !dropdown.contains(e.target) && e.target !== searchInput) {
|
||||
dropdown.style.display = "none";
|
||||
}
|
||||
});
|
||||
|
||||
// Session-level WebSocket to listen for control signals
|
||||
(function() {
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const host = window.location.host || "localhost:8080";
|
||||
const wsUrl = `${protocol}//${host}/ws/multiuser/{{ session.id }}/`;
|
||||
|
||||
console.log("Connecting session WS to", wsUrl);
|
||||
const socket = new WebSocket(wsUrl);
|
||||
|
||||
socket.onmessage = function(event) {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log("Session WS event:", data.type, data);
|
||||
|
||||
if (data.type === 'change_case') {
|
||||
console.log("Session host changed case to", data.case_id);
|
||||
// Fetch new case content via HTMX
|
||||
htmx.ajax('GET', "{% url 'atlas:multiuser_case_content_partial' session_id=session.id %}", {
|
||||
target: "#multiuser-case-container",
|
||||
swap: "innerHTML"
|
||||
});
|
||||
} else if (data.type === 'room_status') {
|
||||
// Update user count
|
||||
const userCount = Object.keys(data.users || {}).length;
|
||||
const mgrCountBadge = document.getElementById("connectedUsersCount");
|
||||
const partCountBadge = document.getElementById("connectedUsersCountParticipant");
|
||||
|
||||
if (mgrCountBadge) mgrCountBadge.textContent = userCount;
|
||||
if (partCountBadge) partCountBadge.textContent = userCount;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error processing session WS message", err);
|
||||
}
|
||||
};
|
||||
|
||||
socket.onclose = function(e) {
|
||||
console.warn("Session WS closed. Reconnecting in 3s...", e);
|
||||
setTimeout(arguments.callee, 3000);
|
||||
};
|
||||
|
||||
socket.onerror = function(err) {
|
||||
console.error("Session WS error:", err);
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.animate-spin {
|
||||
animation: spin 8s linear infinite;
|
||||
display: inline-block;
|
||||
}
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,74 @@
|
||||
<div class="row">
|
||||
<!-- Left column: DICOM Viewer -->
|
||||
<div class="col-lg-9 mb-4">
|
||||
<div class="card bg-dark border-secondary">
|
||||
<div class="card-header bg-secondary text-white d-flex align-items-center justify-content-between py-1 px-3">
|
||||
<span class="fw-semibold"><i class="bi bi-display me-1"></i> Interactive DICOM Viewer</span>
|
||||
<span class="badge bg-info">Case: {{ case.title|default:"Untitled Case" }}</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div id="viewer-container" class="viewer-resizable-container" style="height: 70vh; min-height: 500px; max-height: 900px; position: relative; overflow: hidden; width: 100%;">
|
||||
<div id="main_viewer" class="dicom-viewer-root"
|
||||
data-auto-cache-stack="false"
|
||||
data-named-stacks='{{ named_stacks_json }}'
|
||||
data-multiuser-room="{{ session.id }}"
|
||||
data-username="{{ request.user.username }}"
|
||||
data-is-staff="{% if is_manager %}true{% else %}false{% endif %}"
|
||||
style="width: 100%; height: 100%;"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right column: Case Details -->
|
||||
<div class="col-lg-3">
|
||||
<div class="card bg-dark border-secondary text-white shadow-sm mb-4">
|
||||
<div class="card-header bg-secondary text-white fw-bold">
|
||||
<i class="bi bi-info-circle-fill me-1"></i> Case Information
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h5 class="text-info mb-2">{{ case.title|default:"Untitled Case" }}</h5>
|
||||
<hr class="border-secondary mt-0">
|
||||
|
||||
{% if case.description %}
|
||||
<div class="mb-3">
|
||||
<h6 class="text-muted mb-1 small uppercase fw-bold">Description</h6>
|
||||
<p class="mb-0 small">{{ case.description }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if case.history %}
|
||||
<div class="mb-3">
|
||||
<h6 class="text-muted mb-1 small uppercase fw-bold">Patient History</h6>
|
||||
<p class="mb-0 small">{{ case.history|linebreaksbr }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if is_manager %}
|
||||
{% if case.discussion %}
|
||||
<div class="mb-3">
|
||||
<h6 class="text-warning mb-1 small uppercase fw-bold">Discussion (Host Only)</h6>
|
||||
<p class="mb-0 small text-light-emphasis">{{ case.discussion|linebreaksbr }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if case.report %}
|
||||
<div class="mb-3">
|
||||
<h6 class="text-warning mb-1 small uppercase fw-bold">Model Report (Host Only)</h6>
|
||||
<p class="mb-0 small text-light-emphasis">{{ case.report|linebreaksbr }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
if (typeof window.remountDicomViewer === "function") {
|
||||
console.debug("Remounting DICOM viewer for case change");
|
||||
window.remountDicomViewer('main_viewer');
|
||||
} else {
|
||||
console.warn("window.remountDicomViewer is not defined!");
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,28 @@
|
||||
<ul class="list-group bg-dark border-secondary shadow-lg" style="max-height: 300px; overflow-y: auto;">
|
||||
{% for case in cases %}
|
||||
<li class="list-group-item list-group-item-action bg-dark text-white border-secondary d-flex justify-content-between align-items-center py-2 px-3">
|
||||
<div class="me-2 text-truncate">
|
||||
<strong class="text-info d-block text-truncate">{{ case.title|default:"Untitled Case" }}</strong>
|
||||
<span class="text-muted small">
|
||||
Authors:
|
||||
{% for author in case.author.all %}
|
||||
{{ author.username }}{% if not forloop.last %}, {% endif %}
|
||||
{% empty %}
|
||||
Unknown
|
||||
{% endfor %}
|
||||
</span>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm flex-shrink-0"
|
||||
hx-post="{% url 'atlas:multiuser_load_case' session_id=session.id %}"
|
||||
hx-vals='{"case_id": "{{ case.pk }}"}'
|
||||
hx-target="#multiuser-case-container"
|
||||
hx-swap="innerHTML"
|
||||
onclick="document.getElementById('caseSearchDropdown').style.display='none'; document.getElementById('multiuserCaseSearch').value='';"
|
||||
>
|
||||
Load Case
|
||||
</button>
|
||||
</li>
|
||||
{% empty %}
|
||||
<li class="list-group-item bg-dark text-muted border-secondary py-2 px-3">No matching cases found</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
@@ -0,0 +1,12 @@
|
||||
<div class="card bg-dark border-secondary text-white text-center py-5 shadow-sm">
|
||||
<div class="card-body py-5">
|
||||
<i class="bi bi-display fs-1 text-muted mb-3 d-block"></i>
|
||||
{% if is_manager %}
|
||||
<h4 class="text-info">Ready to start review!</h4>
|
||||
<p class="text-muted mb-4">Search and select a case above to load it into the session.</p>
|
||||
{% else %}
|
||||
<h4 class="text-info">Welcome to the session!</h4>
|
||||
<p class="text-muted">Waiting for the session host to load a case...</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user