Tightly integrate WhatsApp selectors into existing UIs
This commit is contained in:
@@ -576,6 +576,67 @@
|
||||
window.giaWindowAnchor = null;
|
||||
};
|
||||
|
||||
window.giaEnableFloatingWindowInteractions = function (windowEl) {
|
||||
if (!windowEl || windowEl.dataset.giaWindowInteractive === "1") {
|
||||
return;
|
||||
}
|
||||
windowEl.dataset.giaWindowInteractive = "1";
|
||||
|
||||
// Disable magnet-block global drag so text inputs remain editable.
|
||||
windowEl.setAttribute("unmovable", "");
|
||||
|
||||
const heading = windowEl.querySelector(".panel-heading");
|
||||
if (!heading) {
|
||||
return;
|
||||
}
|
||||
|
||||
let dragging = false;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let startLeft = 0;
|
||||
let startTop = 0;
|
||||
|
||||
const onMove = function (event) {
|
||||
if (!dragging) {
|
||||
return;
|
||||
}
|
||||
const deltaX = event.clientX - startX;
|
||||
const deltaY = event.clientY - startY;
|
||||
windowEl.style.left = (startLeft + deltaX) + "px";
|
||||
windowEl.style.top = (startTop + deltaY) + "px";
|
||||
windowEl.style.right = "auto";
|
||||
windowEl.style.bottom = "auto";
|
||||
};
|
||||
|
||||
const stopDrag = function () {
|
||||
dragging = false;
|
||||
document.removeEventListener("pointermove", onMove);
|
||||
document.removeEventListener("pointerup", stopDrag);
|
||||
};
|
||||
|
||||
heading.addEventListener("pointerdown", function (event) {
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
const interactive = event.target.closest(
|
||||
"button, a, input, textarea, select, label, .delete, .icon"
|
||||
);
|
||||
if (interactive) {
|
||||
return;
|
||||
}
|
||||
const rect = windowEl.getBoundingClientRect();
|
||||
windowEl.style.position = "fixed";
|
||||
startLeft = rect.left;
|
||||
startTop = rect.top;
|
||||
startX = event.clientX;
|
||||
startY = event.clientY;
|
||||
dragging = true;
|
||||
document.addEventListener("pointermove", onMove);
|
||||
document.addEventListener("pointerup", stopDrag);
|
||||
event.preventDefault();
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener("click", function (event) {
|
||||
const trigger = event.target.closest(".js-widget-spawn-trigger");
|
||||
if (!trigger) {
|
||||
@@ -593,12 +654,13 @@
|
||||
window.giaEnableWidgetSpawnButtons(target);
|
||||
const targetId = (target && target.id) || "";
|
||||
if (targetId === "windows-here") {
|
||||
const floatingWindow = target.querySelector(".floating-window");
|
||||
if (floatingWindow) {
|
||||
const floatingWindows = target.querySelectorAll(".floating-window");
|
||||
floatingWindows.forEach(function (floatingWindow) {
|
||||
window.setTimeout(function () {
|
||||
window.giaPositionFloatingWindow(floatingWindow);
|
||||
window.giaEnableFloatingWindowInteractions(floatingWindow);
|
||||
}, 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
{% block close_button %}
|
||||
{% include "mixins/partials/close-widget.html" %}
|
||||
{% endblock %}
|
||||
<span class="icon is-small mr-1">
|
||||
<i class="{{ widget_icon|default:'fa-solid fa-window-maximize' }}"></i>
|
||||
</span>
|
||||
<i
|
||||
class="fa-solid fa-arrows-minimize has-text-grey-light float-right"
|
||||
onclick="grid.compact();"></i>
|
||||
|
||||
@@ -30,12 +30,12 @@
|
||||
<div class="column is-5">
|
||||
<article class="box">
|
||||
<h2 class="title is-6">Create Or Link Identifier</h2>
|
||||
<form method="post">
|
||||
<form id="compose-contact-link-form" method="post">
|
||||
{% csrf_token %}
|
||||
<div class="field">
|
||||
<label class="label is-small">Service</label>
|
||||
<div class="select is-fullwidth">
|
||||
<select name="service" required>
|
||||
<select id="link-service" name="service" required>
|
||||
{% for key, label in service_choices %}
|
||||
<option value="{{ key }}" {% if key == prefill_service %}selected{% endif %}>
|
||||
{{ label }}
|
||||
@@ -47,13 +47,13 @@
|
||||
<div class="field">
|
||||
<label class="label is-small">Identifier</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="identifier" value="{{ prefill_identifier }}" required>
|
||||
<input id="link-identifier" class="input" type="text" name="identifier" value="{{ prefill_identifier }}" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label is-small">Existing Person</label>
|
||||
<div class="select is-fullwidth">
|
||||
<select name="person_id">
|
||||
<select id="link-person-id" name="person_id">
|
||||
<option value="">- Select person -</option>
|
||||
{% for person in people %}
|
||||
<option value="{{ person.id }}">{{ person.name }}</option>
|
||||
@@ -64,7 +64,7 @@
|
||||
<div class="field">
|
||||
<label class="label is-small">Or Create Person</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="person_name" placeholder="New person name">
|
||||
<input id="link-person-name" class="input" type="text" name="person_name" placeholder="New person name">
|
||||
</div>
|
||||
</div>
|
||||
<button class="button is-link" type="submit">
|
||||
@@ -79,29 +79,114 @@
|
||||
<article class="box">
|
||||
<h2 class="title is-6">Discovered Contacts</h2>
|
||||
{% if candidates %}
|
||||
<div class="table-container">
|
||||
<table class="table is-fullwidth is-hoverable is-striped">
|
||||
<div id="discovered-contacts-shell" class="osint-table-shell">
|
||||
<div class="field has-addons is-flex-wrap-wrap" style="margin-bottom: 0.6rem;">
|
||||
<div class="control is-expanded" style="min-width: 14rem;">
|
||||
<input
|
||||
id="discovered-search"
|
||||
class="input"
|
||||
type="text"
|
||||
placeholder="Search discovered contacts...">
|
||||
</div>
|
||||
<div class="control">
|
||||
<div class="select">
|
||||
<select id="discovered-source">
|
||||
<option value="">All Platforms</option>
|
||||
{% for key, label in service_choices %}
|
||||
<option value="{{ key }}">{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button id="discovered-search-reset" class="button is-light" type="button">
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="osint-results-meta">
|
||||
<div class="osint-results-meta-left">
|
||||
<div class="dropdown is-hoverable" id="discovered-columns-dropdown">
|
||||
<div class="dropdown-trigger">
|
||||
<button class="button is-small is-light" aria-haspopup="true" aria-controls="discovered-columns-menu">
|
||||
<span>Show/Hide Fields</span>
|
||||
<span class="icon is-small"><i class="fa-solid fa-angle-down"></i></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="dropdown-menu" id="discovered-columns-menu" role="menu">
|
||||
<div class="dropdown-content">
|
||||
<a class="dropdown-item discovered-col-toggle" data-col-index="0" href="#" onclick="return false;">
|
||||
<span class="icon is-small"><i class="fa-solid fa-check"></i></span>
|
||||
<span>Person</span>
|
||||
<span class="is-size-7 has-text-grey ml-2">(linked_person)</span>
|
||||
</a>
|
||||
<a class="dropdown-item discovered-col-toggle" data-col-index="1" href="#" onclick="return false;">
|
||||
<span class="icon is-small"><i class="fa-solid fa-check"></i></span>
|
||||
<span>Name</span>
|
||||
<span class="is-size-7 has-text-grey ml-2">(detected_name)</span>
|
||||
</a>
|
||||
<a class="dropdown-item discovered-col-toggle" data-col-index="2" href="#" onclick="return false;">
|
||||
<span class="icon is-small"><i class="fa-solid fa-check"></i></span>
|
||||
<span>Service</span>
|
||||
<span class="is-size-7 has-text-grey ml-2">(service)</span>
|
||||
</a>
|
||||
<a class="dropdown-item discovered-col-toggle" data-col-index="3" href="#" onclick="return false;">
|
||||
<span class="icon is-small"><i class="fa-solid fa-check"></i></span>
|
||||
<span>Identifier</span>
|
||||
<span class="is-size-7 has-text-grey ml-2">(identifier)</span>
|
||||
</a>
|
||||
<a class="dropdown-item discovered-col-toggle" data-col-index="4" href="#" onclick="return false;">
|
||||
<span class="icon is-small"><i class="fa-solid fa-check"></i></span>
|
||||
<span>Suggest</span>
|
||||
<span class="is-size-7 has-text-grey ml-2">(suggestions)</span>
|
||||
</a>
|
||||
<a class="dropdown-item discovered-col-toggle" data-col-index="5" href="#" onclick="return false;">
|
||||
<span class="icon is-small"><i class="fa-solid fa-check"></i></span>
|
||||
<span>Status</span>
|
||||
<span class="is-size-7 has-text-grey ml-2">(linked)</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="button is-small is-light" type="button" disabled>
|
||||
<span class="icon is-small"><i class="fa-solid fa-database"></i></span>
|
||||
<span>Static</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="osint-results-count">
|
||||
fetched {{ candidates|length }} result{% if candidates|length != 1 %}s{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table id="discovered-contacts-table" class="table is-fullwidth is-hoverable is-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Person</th>
|
||||
<th>Detected Name</th>
|
||||
<th>Service</th>
|
||||
<th>Identifier</th>
|
||||
<th>Suggested Match</th>
|
||||
<th>Status</th>
|
||||
<th data-discovered-col="0" class="discovered-col-0">Person</th>
|
||||
<th data-discovered-col="1" class="discovered-col-1">Name</th>
|
||||
<th data-discovered-col="2" class="discovered-col-2">Service</th>
|
||||
<th data-discovered-col="3" class="discovered-col-3">Identifier</th>
|
||||
<th data-discovered-col="4" class="discovered-col-4">Suggest</th>
|
||||
<th data-discovered-col="5" class="discovered-col-5">Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in candidates %}
|
||||
<tr>
|
||||
<td>{{ row.linked_person_name|default:"-" }}</td>
|
||||
<td>{{ row.detected_name|default:"-" }}</td>
|
||||
<td>
|
||||
<tr
|
||||
data-service="{{ row.service }}"
|
||||
data-person="{{ row.linked_person_name|default:'-'|lower }}"
|
||||
data-detected="{{ row.detected_name|default:'-'|lower }}"
|
||||
data-identifier="{{ row.identifier|lower }}"
|
||||
data-search="{{ row.linked_person_name|default:'-'|lower }} {{ row.detected_name|default:'-'|lower }} {{ row.service|lower }} {{ row.identifier|lower }}">
|
||||
<td data-discovered-col="0" class="discovered-col-0">{{ row.linked_person_name|default:"-" }}</td>
|
||||
<td data-discovered-col="1" class="discovered-col-1">{{ row.detected_name|default:"-" }}</td>
|
||||
<td data-discovered-col="2" class="discovered-col-2">
|
||||
{{ row.service|title }}
|
||||
</td>
|
||||
<td><code>{{ row.identifier }}</code></td>
|
||||
<td>
|
||||
<td data-discovered-col="3" class="discovered-col-3"><code>{{ row.identifier }}</code></td>
|
||||
<td data-discovered-col="4" class="discovered-col-4">
|
||||
{% if not row.linked_person and row.suggestions %}
|
||||
<div class="buttons are-small">
|
||||
{% for suggestion in row.suggestions %}
|
||||
@@ -124,23 +209,41 @@
|
||||
<span class="has-text-grey">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<td data-discovered-col="5" class="discovered-col-5">
|
||||
{% if row.linked_person %}
|
||||
<span class="tag is-success is-light">linked</span>
|
||||
{% else %}
|
||||
<span class="tag is-warning is-light">unlinked</span>
|
||||
<button
|
||||
type="button"
|
||||
class="tag is-warning is-light js-unlinked-link"
|
||||
data-service="{{ row.service }}"
|
||||
data-identifier="{{ row.identifier }}"
|
||||
title="Click to prefill link form">
|
||||
unlinked
|
||||
</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
class="button is-small is-light js-contact-info"
|
||||
data-service="{{ row.service|title }}"
|
||||
data-identifier="{{ row.identifier }}"
|
||||
data-person="{{ row.linked_person_name|default:'-' }}"
|
||||
data-detected="{{ row.detected_name|default:'-' }}"
|
||||
data-status="{% if row.linked_person %}linked{% else %}unlinked{% endif %}"
|
||||
data-suggested="{% if row.suggestions %}{% for suggestion in row.suggestions %}{{ suggestion.person.name }}{% if not forloop.last %}, {% endif %}{% endfor %}{% else %}-{% endif %}">
|
||||
<span class="icon is-small"><i class="fa-solid fa-circle-info"></i></span>
|
||||
</button>
|
||||
<a class="button is-small is-light" href="{{ row.compose_url }}">
|
||||
<span class="icon is-small"><i class="fa-solid fa-paper-plane"></i></span>
|
||||
<span>Message</span>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="has-text-grey">No contacts discovered yet.</p>
|
||||
@@ -151,4 +254,248 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="discovered-contact-popover" class="compose-ai-popover is-hidden" aria-hidden="true">
|
||||
<div class="compose-ai-card is-active" style="min-width: 18rem;">
|
||||
<p class="compose-ai-title">Contact Details</p>
|
||||
<div class="compose-ai-content">
|
||||
<table class="table is-fullwidth is-striped is-size-7">
|
||||
<tbody>
|
||||
<tr><th>Service</th><td id="modal-service">-</td></tr>
|
||||
<tr><th>Identifier</th><td><code id="modal-identifier">-</code></td></tr>
|
||||
<tr><th>Name</th><td id="modal-detected">-</td></tr>
|
||||
<tr><th>Linked Person</th><td id="modal-person">-</td></tr>
|
||||
<tr><th>Status</th><td id="modal-status">-</td></tr>
|
||||
<tr><th>Suggested</th><td id="modal-suggested">-</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const shell = document.getElementById("discovered-contacts-shell");
|
||||
const table = document.getElementById("discovered-contacts-table");
|
||||
if (!table || !shell) return;
|
||||
const rows = Array.from(table.querySelectorAll("tbody tr"));
|
||||
const searchInput = document.getElementById("discovered-search");
|
||||
const sourceSelect = document.getElementById("discovered-source");
|
||||
const resetBtn = document.getElementById("discovered-search-reset");
|
||||
const linkForm = document.getElementById("compose-contact-link-form");
|
||||
const linkService = document.getElementById("link-service");
|
||||
const linkIdentifier = document.getElementById("link-identifier");
|
||||
const linkPerson = document.getElementById("link-person-id");
|
||||
const linkPersonName = document.getElementById("link-person-name");
|
||||
const toggles = Array.from(shell.querySelectorAll(".discovered-col-toggle"));
|
||||
const popover = document.getElementById("discovered-contact-popover");
|
||||
let activeInfoBtn = null;
|
||||
const storageKey = "gia_discovered_contacts_hidden_cols_v1";
|
||||
let hidden = [];
|
||||
try {
|
||||
hidden = JSON.parse(localStorage.getItem(storageKey) || "[]");
|
||||
} catch (e) {
|
||||
hidden = [];
|
||||
}
|
||||
if (!Array.isArray(hidden)) hidden = [];
|
||||
hidden = hidden.map(String);
|
||||
|
||||
function applyFilters() {
|
||||
const query = String(searchInput?.value || "").trim().toLowerCase();
|
||||
const source = String(sourceSelect?.value || "").trim().toLowerCase();
|
||||
rows.forEach((row) => {
|
||||
const hay = String(row.dataset.search || "").toLowerCase();
|
||||
const rowSource = String(row.dataset.service || "").toLowerCase();
|
||||
const searchPass = !query || hay.includes(query);
|
||||
const sourcePass = !source || rowSource === source;
|
||||
row.style.display = searchPass && sourcePass ? "" : "none";
|
||||
});
|
||||
}
|
||||
|
||||
function applyColumns() {
|
||||
const hiddenSet = new Set(hidden);
|
||||
shell.querySelectorAll("[data-discovered-col]").forEach((node) => {
|
||||
const idx = String(node.getAttribute("data-discovered-col") || "");
|
||||
node.style.display = hiddenSet.has(idx) ? "none" : "";
|
||||
});
|
||||
toggles.forEach((toggle) => {
|
||||
const idx = String(toggle.getAttribute("data-col-index") || "");
|
||||
const isHidden = hiddenSet.has(idx);
|
||||
toggle.classList.toggle("is-hidden-col", isHidden);
|
||||
const icon = toggle.querySelector("i");
|
||||
if (icon) {
|
||||
icon.className = isHidden ? "fa-solid fa-xmark" : "fa-solid fa-check";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function persistColumns() {
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify(hidden));
|
||||
} catch (e) {
|
||||
// Ignore storage failures.
|
||||
}
|
||||
}
|
||||
|
||||
function hidePopover() {
|
||||
if (!popover) return;
|
||||
popover.classList.add("is-hidden");
|
||||
popover.setAttribute("aria-hidden", "true");
|
||||
activeInfoBtn = null;
|
||||
}
|
||||
|
||||
function positionPopover(btn) {
|
||||
if (!popover || !btn) return;
|
||||
const rect = btn.getBoundingClientRect();
|
||||
const width = Math.min(360, Math.max(280, Math.floor(window.innerWidth * 0.32)));
|
||||
const left = Math.min(
|
||||
window.innerWidth - width - 16,
|
||||
Math.max(12, rect.left - width + rect.width)
|
||||
);
|
||||
const top = Math.min(window.innerHeight - 24, rect.bottom + 8);
|
||||
popover.style.left = left + "px";
|
||||
popover.style.top = top + "px";
|
||||
popover.style.width = width + "px";
|
||||
}
|
||||
|
||||
function openPopoverFromButton(btn) {
|
||||
document.getElementById("modal-service").textContent = btn.dataset.service || "-";
|
||||
document.getElementById("modal-identifier").textContent = btn.dataset.identifier || "-";
|
||||
document.getElementById("modal-detected").textContent = btn.dataset.detected || "-";
|
||||
document.getElementById("modal-person").textContent = btn.dataset.person || "-";
|
||||
document.getElementById("modal-status").textContent = btn.dataset.status || "-";
|
||||
document.getElementById("modal-suggested").textContent = btn.dataset.suggested || "-";
|
||||
positionPopover(btn);
|
||||
popover?.classList.remove("is-hidden");
|
||||
popover?.setAttribute("aria-hidden", "false");
|
||||
}
|
||||
|
||||
toggles.forEach((toggle) => {
|
||||
toggle.addEventListener("click", function () {
|
||||
const idx = String(toggle.getAttribute("data-col-index") || "");
|
||||
if (!idx) return;
|
||||
if (hidden.includes(idx)) {
|
||||
hidden = hidden.filter((item) => item !== idx);
|
||||
} else {
|
||||
hidden.push(idx);
|
||||
}
|
||||
persistColumns();
|
||||
applyColumns();
|
||||
});
|
||||
});
|
||||
|
||||
if (resetBtn) {
|
||||
resetBtn.addEventListener("click", function () {
|
||||
if (searchInput) searchInput.value = "";
|
||||
if (sourceSelect) sourceSelect.value = "";
|
||||
applyFilters();
|
||||
});
|
||||
}
|
||||
|
||||
if (searchInput) searchInput.addEventListener("input", applyFilters);
|
||||
if (sourceSelect) sourceSelect.addEventListener("change", applyFilters);
|
||||
|
||||
table.querySelectorAll(".js-contact-info").forEach((btn) => {
|
||||
btn.addEventListener("click", function () {
|
||||
if (activeInfoBtn === this && popover && !popover.classList.contains("is-hidden")) {
|
||||
hidePopover();
|
||||
return;
|
||||
}
|
||||
activeInfoBtn = this;
|
||||
openPopoverFromButton(this);
|
||||
});
|
||||
});
|
||||
|
||||
table.querySelectorAll(".js-unlinked-link").forEach((btn) => {
|
||||
btn.addEventListener("click", function () {
|
||||
if (linkService) linkService.value = String(this.dataset.service || "").toLowerCase();
|
||||
if (linkIdentifier) linkIdentifier.value = this.dataset.identifier || "";
|
||||
if (linkPerson) linkPerson.value = "";
|
||||
if (linkPersonName) linkPersonName.value = "";
|
||||
if (linkForm) {
|
||||
linkForm.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
if (linkPerson) {
|
||||
linkPerson.focus();
|
||||
} else if (linkIdentifier) {
|
||||
linkIdentifier.focus();
|
||||
}
|
||||
});
|
||||
});
|
||||
document.addEventListener("click", function (ev) {
|
||||
if (!popover || popover.classList.contains("is-hidden")) return;
|
||||
if (popover.contains(ev.target)) return;
|
||||
if (activeInfoBtn && activeInfoBtn.contains(ev.target)) return;
|
||||
hidePopover();
|
||||
});
|
||||
window.addEventListener("resize", function () {
|
||||
if (activeInfoBtn && popover && !popover.classList.contains("is-hidden")) {
|
||||
positionPopover(activeInfoBtn);
|
||||
}
|
||||
});
|
||||
document.addEventListener("keydown", function (ev) {
|
||||
if (ev.key === "Escape") hidePopover();
|
||||
});
|
||||
|
||||
applyFilters();
|
||||
applyColumns();
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#discovered-contacts-shell .osint-results-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
#discovered-contacts-shell .osint-results-meta-left {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.42rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
#discovered-contacts-shell .osint-results-count {
|
||||
margin: 0;
|
||||
color: #6c757d;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
#discovered-contacts-shell .discovered-col-toggle .icon {
|
||||
color: #3273dc;
|
||||
}
|
||||
#discovered-contacts-shell .discovered-col-toggle.is-hidden-col .icon {
|
||||
color: #b5b5b5;
|
||||
}
|
||||
#discovered-contact-popover {
|
||||
position: fixed;
|
||||
z-index: 2000;
|
||||
transition: opacity 170ms ease, transform 170ms ease;
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
max-height: min(70vh, 30rem);
|
||||
overflow: auto;
|
||||
}
|
||||
#discovered-contact-popover.is-hidden {
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: translateY(-6px) scale(0.985);
|
||||
}
|
||||
#discovered-contact-popover .compose-ai-card {
|
||||
border: 1px solid rgba(0, 0, 0, 0.16);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
box-shadow: 0 18px 34px rgba(15, 23, 42, 0.16);
|
||||
padding: 0.55rem 0.65rem;
|
||||
}
|
||||
#discovered-contact-popover .compose-ai-title {
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #4a4a4a;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -112,6 +112,7 @@
|
||||
hx-swap="innerHTML">
|
||||
<input type="hidden" id="draft-send-input-{{ person.id }}-{{ operation }}" name="draft_text" value="">
|
||||
<input type="hidden" id="draft-send-force-{{ person.id }}-{{ operation }}" name="force_send" value="0">
|
||||
<input type="hidden" name="target_identifier_id" value="{{ send_target_bundle.selected_id }}">
|
||||
<div class="field">
|
||||
<label class="label is-small">Draft Preview</label>
|
||||
<div class="control">
|
||||
|
||||
@@ -388,7 +388,23 @@
|
||||
<input type="hidden" name="active_tab" value="{{ active_tab|default:'engage' }}">
|
||||
<input type="hidden" id="engage-action-input-{{ person.id }}" name="action" value="preview">
|
||||
<input type="hidden" id="engage-force-send-{{ person.id }}" name="force_send" value="0">
|
||||
<input type="hidden" id="engage-target-input-{{ person.id }}" name="target_identifier_id" value="{{ engage_form.target_identifier_id }}">
|
||||
<div class="columns is-multiline" style="margin: 0 -0.3rem;">
|
||||
{% if send_target_bundle.options %}
|
||||
<div class="column is-12-mobile is-6-tablet" style="padding: 0.3rem;">
|
||||
<label class="label is-small" style="margin-bottom: 0.25rem;">Target Platform</label>
|
||||
<div class="select is-small is-fullwidth">
|
||||
<select id="engage-target-select-{{ person.id }}" onchange="giaEngageSetTarget('{{ person.id }}', this.value);">
|
||||
{% for option in send_target_bundle.options %}
|
||||
<option value="{{ option.id }}" {% if option.id == engage_form.target_identifier_id %}selected{% endif %}>
|
||||
{{ option.service_label }} · {{ option.identifier }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="column is-12-mobile is-6-tablet" style="padding: 0.3rem;">
|
||||
<label class="label is-small" style="margin-bottom: 0.25rem;">Source</label>
|
||||
<div class="select is-small is-fullwidth">
|
||||
@@ -897,6 +913,19 @@
|
||||
form.requestSubmit();
|
||||
};
|
||||
|
||||
window.giaEngageSetTarget = function(pid, targetId) {
|
||||
if (pid !== personId) return;
|
||||
const normalized = String(targetId || "").trim();
|
||||
const input = document.getElementById("engage-target-input-" + pid);
|
||||
if (input) {
|
||||
input.value = normalized;
|
||||
}
|
||||
const parentSelect = document.getElementById("ai-target-select-" + pid);
|
||||
if (parentSelect && normalized) {
|
||||
parentSelect.value = normalized;
|
||||
}
|
||||
};
|
||||
|
||||
window.giaEngageSelect = function(pid, kind, value, node) {
|
||||
if (pid !== personId) return;
|
||||
let inputId = "";
|
||||
@@ -922,6 +951,14 @@
|
||||
|
||||
window.giaMitigationShowTab(personId, "{{ active_tab|default:'plan_board' }}");
|
||||
resizeEditableTextareas(document.getElementById("mitigation-shell-" + personId));
|
||||
const parentTarget = document.getElementById("ai-target-select-" + personId);
|
||||
if (parentTarget) {
|
||||
window.giaEngageSetTarget(personId, parentTarget.value);
|
||||
}
|
||||
const localTarget = document.getElementById("engage-target-select-" + personId);
|
||||
if (localTarget) {
|
||||
window.giaEngageSetTarget(personId, localTarget.value);
|
||||
}
|
||||
window.giaEngageSyncSendOverride(personId);
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -72,12 +72,13 @@
|
||||
{% endif %}
|
||||
{% if compose_page_url %}
|
||||
<div class="buttons are-small" style="margin-top: 0.45rem; margin-bottom: 0;">
|
||||
<a class="button is-light" href="{{ compose_page_url }}">
|
||||
<a id="ai-manual-link-{{ person.id }}" class="button is-light" href="{{ compose_page_url }}">
|
||||
<span class="icon is-small"><i class="{{ manual_icon_class }}"></i></span>
|
||||
<span>Manual Text Mode</span>
|
||||
</a>
|
||||
{% if compose_widget_url %}
|
||||
<button
|
||||
id="ai-manual-widget-btn-{{ person.id }}"
|
||||
type="button"
|
||||
class="button is-light is-small js-widget-spawn-trigger is-hidden"
|
||||
data-widget-url="{{ compose_widget_url }}"
|
||||
@@ -111,6 +112,26 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if send_target_bundle.options %}
|
||||
<div class="field" style="margin-top: 0.55rem; margin-bottom: 0;">
|
||||
<label class="label is-small" style="margin-bottom: 0.25rem;">Target Platform</label>
|
||||
<div class="control">
|
||||
<div class="select is-small is-fullwidth">
|
||||
<select id="ai-target-select-{{ person.id }}" aria-label="Target platform and identifier">
|
||||
{% for option in send_target_bundle.options %}
|
||||
<option
|
||||
value="{{ option.id }}"
|
||||
data-service="{{ option.service }}"
|
||||
data-identifier="{{ option.identifier }}"
|
||||
{% if option.id == send_target_bundle.selected_id %}selected{% endif %}>
|
||||
{{ option.service_label }} · {{ option.identifier }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div id="draft-top-status-{{ person.id }}" style="margin-top: 0.5rem;"></div>
|
||||
</div>
|
||||
|
||||
@@ -719,6 +740,10 @@
|
||||
}
|
||||
const payload = new URLSearchParams();
|
||||
payload.append("draft_text", text);
|
||||
const targetId = getSelectedTargetId();
|
||||
if (targetId) {
|
||||
payload.append("target_identifier_id", targetId);
|
||||
}
|
||||
fetch(queueUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -740,6 +765,74 @@
|
||||
});
|
||||
};
|
||||
|
||||
function getSelectedTargetId() {
|
||||
const select = document.getElementById("ai-target-select-" + personId);
|
||||
if (!select) {
|
||||
return "";
|
||||
}
|
||||
return String(select.value || "").trim();
|
||||
}
|
||||
|
||||
function syncTargetInputs() {
|
||||
const targetId = getSelectedTargetId();
|
||||
widget.querySelectorAll('input[name="target_identifier_id"]').forEach(function(input) {
|
||||
input.value = targetId;
|
||||
});
|
||||
const engageSelect = document.getElementById("engage-target-select-" + personId);
|
||||
if (engageSelect && targetId) {
|
||||
engageSelect.value = targetId;
|
||||
}
|
||||
syncComposeTargets();
|
||||
}
|
||||
|
||||
function syncComposeTargets() {
|
||||
const targetSelectNode = document.getElementById("ai-target-select-" + personId);
|
||||
if (!targetSelectNode) {
|
||||
return;
|
||||
}
|
||||
const selected = targetSelectNode.options[targetSelectNode.selectedIndex];
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
const service = String(selected.dataset.service || "").trim();
|
||||
const identifier = String(selected.dataset.identifier || "").trim();
|
||||
if (!service || !identifier) {
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams({
|
||||
service: service,
|
||||
identifier: identifier,
|
||||
person: "{{ person.id }}",
|
||||
});
|
||||
const manualLink = document.getElementById("ai-manual-link-" + personId);
|
||||
if (manualLink) {
|
||||
manualLink.href = "{{ compose_page_base_url }}?" + params.toString();
|
||||
}
|
||||
const widgetBtn = document.getElementById("ai-manual-widget-btn-" + personId);
|
||||
if (widgetBtn) {
|
||||
const widgetParams = new URLSearchParams({
|
||||
service: service,
|
||||
identifier: identifier,
|
||||
person: "{{ person.id }}",
|
||||
limit: "{{ limit }}",
|
||||
});
|
||||
const widgetUrl = "{{ compose_widget_base_url }}?" + widgetParams.toString();
|
||||
widgetBtn.dataset.widgetUrl = widgetUrl;
|
||||
widgetBtn.setAttribute("hx-get", widgetUrl);
|
||||
}
|
||||
}
|
||||
|
||||
const targetSelect = document.getElementById("ai-target-select-" + personId);
|
||||
if (targetSelect) {
|
||||
targetSelect.addEventListener("change", function() {
|
||||
syncTargetInputs();
|
||||
});
|
||||
}
|
||||
|
||||
widget.addEventListener("htmx:afterSwap", function() {
|
||||
syncTargetInputs();
|
||||
});
|
||||
|
||||
if (typeof window.giaMitigationShowTab !== "function") {
|
||||
window.giaMitigationShowTab = function(pid, tabName) {
|
||||
const names = ["plan_board", "corrections", "engage", "fundamentals", "ask_ai"];
|
||||
@@ -839,5 +932,6 @@
|
||||
}
|
||||
|
||||
window.giaWorkspaceOpenTab(personId, "plan_board", false);
|
||||
syncTargetInputs();
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -2,18 +2,62 @@
|
||||
<div class="is-flex is-justify-content-space-between is-align-items-center" style="gap: 0.5rem; flex-wrap: wrap;">
|
||||
<div>
|
||||
<p class="is-size-7 has-text-weight-semibold" style="margin-bottom: 0.2rem;">Manual Text Mode</p>
|
||||
<p class="is-size-6" style="margin-bottom: 0;">
|
||||
{% if person %}
|
||||
{{ person.name }}
|
||||
{% else %}
|
||||
{{ identifier }}
|
||||
{% endif %}
|
||||
</p>
|
||||
<p class="is-size-7 compose-meta-line" style="margin-bottom: 0;">
|
||||
{% if recent_contacts %}
|
||||
<div class="compose-contact-switch">
|
||||
<div class="select is-small">
|
||||
<select id="{{ panel_id }}-contact-select" class="compose-contact-select">
|
||||
{% for option in recent_contacts %}
|
||||
<option
|
||||
value="{{ option.identifier }}"
|
||||
data-service="{{ option.service }}"
|
||||
data-identifier="{{ option.identifier }}"
|
||||
data-person="{{ option.person_id }}"
|
||||
data-page-url="{{ option.compose_url }}"
|
||||
data-widget-url="{{ option.compose_widget_url }}"
|
||||
{% if option.is_active %}selected{% endif %}>
|
||||
{{ option.person_name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="is-size-6" style="margin-bottom: 0;">
|
||||
{% if person %}
|
||||
{{ person.name }}
|
||||
{% else %}
|
||||
{{ identifier }}
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
<p id="{{ panel_id }}-meta-line" class="is-size-7 compose-meta-line" style="margin-bottom: 0;">
|
||||
{{ service|title }} · {{ identifier }}
|
||||
</p>
|
||||
{% if platform_options %}
|
||||
<div class="compose-platform-switch">
|
||||
<div class="select is-small">
|
||||
<select id="{{ panel_id }}-platform-select" class="compose-platform-select">
|
||||
{% for option in platform_options %}
|
||||
<option
|
||||
value="{{ option.service }}"
|
||||
data-identifier="{{ option.identifier }}"
|
||||
data-person="{{ option.person_id }}"
|
||||
data-page-url="{{ option.page_url }}"
|
||||
data-widget-url="{{ option.widget_url }}"
|
||||
{% if option.is_active %}selected{% endif %}>
|
||||
{{ option.service_label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="buttons are-small compose-top-actions" style="margin: 0;">
|
||||
<button type="button" class="button is-light is-rounded compose-history-sync-btn">
|
||||
<span class="icon is-small"><i class="fa-solid fa-rotate-right"></i></span>
|
||||
<span>Force Sync</span>
|
||||
</button>
|
||||
<button type="button" class="button is-light is-rounded js-ai-trigger" data-kind="drafts">
|
||||
<span class="icon is-small"><i class="fa-solid fa-pen"></i></span>
|
||||
<span>Drafts</span>
|
||||
@@ -182,6 +226,7 @@
|
||||
data-drafts-url="{{ compose_drafts_url }}"
|
||||
data-summary-url="{{ compose_summary_url }}"
|
||||
data-quick-insights-url="{{ compose_quick_insights_url }}"
|
||||
data-history-sync-url="{{ compose_history_sync_url }}"
|
||||
data-engage-preview-url="{{ compose_engage_preview_url }}"
|
||||
data-engage-send-url="{{ compose_engage_send_url }}">
|
||||
{% for msg in serialized_messages %}
|
||||
@@ -231,7 +276,13 @@
|
||||
</article>
|
||||
</div>
|
||||
{% empty %}
|
||||
<p class="compose-empty">No stored messages for this contact yet.</p>
|
||||
<div class="compose-empty-wrap">
|
||||
<p class="compose-empty">No stored messages for this contact yet.</p>
|
||||
<button type="button" class="button is-light is-small compose-history-sync-btn">
|
||||
<span class="icon is-small"><i class="fa-solid fa-rotate-right"></i></span>
|
||||
<span>Force History Sync</span>
|
||||
</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p id="{{ panel_id }}-typing" class="compose-typing is-hidden">
|
||||
@@ -245,16 +296,14 @@
|
||||
hx-post="{% url 'compose_send' %}"
|
||||
hx-target="#{{ panel_id }}-status"
|
||||
hx-swap="innerHTML">
|
||||
<input type="hidden" name="service" value="{{ service }}">
|
||||
<input type="hidden" name="identifier" value="{{ identifier }}">
|
||||
<input id="{{ panel_id }}-input-service" type="hidden" name="service" value="{{ service }}">
|
||||
<input id="{{ panel_id }}-input-identifier" type="hidden" name="identifier" value="{{ identifier }}">
|
||||
<input id="{{ panel_id }}-input-person" type="hidden" name="person" value="{% if person %}{{ person.id }}{% endif %}">
|
||||
<input type="hidden" name="render_mode" value="{{ render_mode }}">
|
||||
<input type="hidden" name="limit" value="{{ limit }}">
|
||||
<input type="hidden" name="panel_id" value="{{ panel_id }}">
|
||||
<input type="hidden" name="failsafe_arm" value="0">
|
||||
<input type="hidden" name="failsafe_confirm" value="0">
|
||||
{% if person %}
|
||||
<input type="hidden" name="person" value="{{ person.id }}">
|
||||
{% endif %}
|
||||
<div class="compose-send-safety">
|
||||
<label class="checkbox is-size-7">
|
||||
<input type="checkbox" class="manual-confirm"> Confirm Send
|
||||
@@ -477,6 +526,19 @@
|
||||
#{{ panel_id }} .compose-msg-meta {
|
||||
margin: 0;
|
||||
}
|
||||
#{{ panel_id }} .compose-platform-switch {
|
||||
margin-top: 0.32rem;
|
||||
}
|
||||
#{{ panel_id }} .compose-contact-switch {
|
||||
margin-bottom: 0.08rem;
|
||||
}
|
||||
#{{ panel_id }} .compose-contact-select {
|
||||
min-width: 15rem;
|
||||
max-width: min(80vw, 30rem);
|
||||
}
|
||||
#{{ panel_id }} .compose-platform-select {
|
||||
min-width: 11rem;
|
||||
}
|
||||
#{{ panel_id }} .compose-gap-artifacts {
|
||||
align-self: center;
|
||||
width: min(92%, 34rem);
|
||||
@@ -554,6 +616,16 @@
|
||||
color: #6f6f6f;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
#{{ panel_id }} .compose-empty-wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
#{{ panel_id }} .compose-history-sync-btn.is-loading {
|
||||
pointer-events: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
#{{ panel_id }} .compose-typing {
|
||||
margin: 0 0 0.5rem 0.2rem;
|
||||
font-size: 0.78rem;
|
||||
@@ -1062,6 +1134,13 @@
|
||||
const thread = document.getElementById(panelId + "-thread");
|
||||
const form = document.getElementById(panelId + "-form");
|
||||
const textarea = document.getElementById(panelId + "-textarea");
|
||||
const platformSelect = document.getElementById(panelId + "-platform-select");
|
||||
const contactSelect = document.getElementById(panelId + "-contact-select");
|
||||
const metaLine = document.getElementById(panelId + "-meta-line");
|
||||
const hiddenService = document.getElementById(panelId + "-input-service");
|
||||
const hiddenIdentifier = document.getElementById(panelId + "-input-identifier");
|
||||
const hiddenPerson = document.getElementById(panelId + "-input-person");
|
||||
const renderMode = "{{ render_mode }}";
|
||||
if (!thread || !form || !textarea) {
|
||||
return;
|
||||
}
|
||||
@@ -1240,9 +1319,9 @@
|
||||
gap: null,
|
||||
metrics: [],
|
||||
};
|
||||
const personId = String(thread.dataset.person || "").trim();
|
||||
const insightUrlForMetric = function (metricSlug) {
|
||||
const slug = String(metricSlug || "").trim();
|
||||
const personId = String(thread.dataset.person || "").trim();
|
||||
if (!personId || !slug) {
|
||||
return "";
|
||||
}
|
||||
@@ -1273,6 +1352,109 @@
|
||||
}
|
||||
};
|
||||
|
||||
const bindHistorySyncButtons = function (rootNode) {
|
||||
const scope = rootNode || panel;
|
||||
if (!scope) {
|
||||
return;
|
||||
}
|
||||
scope.querySelectorAll(".compose-history-sync-btn").forEach(function (button) {
|
||||
if (button.dataset.bound === "1") {
|
||||
return;
|
||||
}
|
||||
button.dataset.bound = "1";
|
||||
button.addEventListener("click", async function () {
|
||||
const historySyncUrl = String(thread.dataset.historySyncUrl || "").trim();
|
||||
if (!historySyncUrl) {
|
||||
setStatus("History sync endpoint is unavailable.", "warning");
|
||||
return;
|
||||
}
|
||||
button.classList.add("is-loading");
|
||||
setStatus("Requesting history sync…", "info");
|
||||
try {
|
||||
const payload = new URLSearchParams();
|
||||
payload.set("service", thread.dataset.service || "");
|
||||
payload.set("identifier", thread.dataset.identifier || "");
|
||||
if (thread.dataset.person) {
|
||||
payload.set("person", thread.dataset.person);
|
||||
}
|
||||
payload.set("limit", thread.dataset.limit || "60");
|
||||
const response = await fetch(historySyncUrl, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"X-CSRFToken": csrfToken,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json"
|
||||
},
|
||||
body: payload.toString(),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!result.ok) {
|
||||
setStatus(
|
||||
String(result.message || result.error || "History sync failed."),
|
||||
String(result.level || "danger")
|
||||
);
|
||||
} else {
|
||||
setStatus(
|
||||
String(result.message || "History sync requested."),
|
||||
String(result.level || "success")
|
||||
);
|
||||
}
|
||||
await poll(true);
|
||||
if (result.ok) {
|
||||
window.setTimeout(function () {
|
||||
poll(false);
|
||||
}, 1800);
|
||||
window.setTimeout(function () {
|
||||
poll(false);
|
||||
}, 4200);
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus("History sync request failed.", "danger");
|
||||
} finally {
|
||||
button.classList.remove("is-loading");
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const ensureEmptyState = function (messageText) {
|
||||
if (!thread) {
|
||||
return;
|
||||
}
|
||||
if (thread.querySelector(".compose-row")) {
|
||||
return;
|
||||
}
|
||||
thread.querySelectorAll(".compose-empty").forEach(function (node) {
|
||||
if (!node.closest(".compose-empty-wrap")) {
|
||||
node.remove();
|
||||
}
|
||||
});
|
||||
let wrap = thread.querySelector(".compose-empty-wrap");
|
||||
if (!wrap) {
|
||||
wrap = document.createElement("div");
|
||||
wrap.className = "compose-empty-wrap";
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "compose-empty";
|
||||
empty.textContent = String(
|
||||
messageText || "No stored messages for this contact yet."
|
||||
);
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "button is-light is-small compose-history-sync-btn";
|
||||
button.innerHTML = '<span class="icon is-small"><i class="fa-solid fa-rotate-right"></i></span><span>Force History Sync</span>';
|
||||
wrap.appendChild(empty);
|
||||
wrap.appendChild(button);
|
||||
thread.appendChild(wrap);
|
||||
} else {
|
||||
const empty = wrap.querySelector(".compose-empty");
|
||||
if (empty && messageText) {
|
||||
empty.textContent = String(messageText);
|
||||
}
|
||||
}
|
||||
bindHistorySyncButtons(wrap);
|
||||
};
|
||||
|
||||
const extractUrlCandidates = function (value) {
|
||||
const raw = String(value || "");
|
||||
const matches = raw.match(/https?:\/\/[^\s<>'"\\]+/g) || [];
|
||||
@@ -1550,6 +1732,10 @@
|
||||
if (empty) {
|
||||
empty.remove();
|
||||
}
|
||||
const emptyWrap = thread.querySelector(".compose-empty-wrap");
|
||||
if (emptyWrap) {
|
||||
emptyWrap.remove();
|
||||
}
|
||||
thread.appendChild(row);
|
||||
wireImageFallbacks(row);
|
||||
updateGlanceFromMessage(msg);
|
||||
@@ -1651,6 +1837,7 @@
|
||||
lastTs = Math.max(lastTs, toInt(payload.last_ts));
|
||||
thread.dataset.lastTs = String(lastTs);
|
||||
}
|
||||
ensureEmptyState();
|
||||
} catch (err) {
|
||||
console.debug("compose poll error", err);
|
||||
} finally {
|
||||
@@ -1729,6 +1916,7 @@
|
||||
// Ignore invalid initial typing state payload.
|
||||
}
|
||||
applyMinuteGrouping();
|
||||
bindHistorySyncButtons(panel);
|
||||
|
||||
const setStatus = function (message, level) {
|
||||
if (!statusBox) {
|
||||
@@ -1815,6 +2003,78 @@
|
||||
return params;
|
||||
};
|
||||
|
||||
const titleCase = function (value) {
|
||||
const raw = String(value || "").trim().toLowerCase();
|
||||
if (!raw) {
|
||||
return "";
|
||||
}
|
||||
if (raw === "whatsapp") {
|
||||
return "WhatsApp";
|
||||
}
|
||||
if (raw === "xmpp") {
|
||||
return "XMPP";
|
||||
}
|
||||
return raw.charAt(0).toUpperCase() + raw.slice(1);
|
||||
};
|
||||
|
||||
const switchThreadContext = function (nextService, nextIdentifier, nextPersonId, pageUrl) {
|
||||
const service = String(nextService || "").trim().toLowerCase();
|
||||
const identifier = String(nextIdentifier || "").trim();
|
||||
const personId = String(nextPersonId || "").trim();
|
||||
if (!service || !identifier) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
String(thread.dataset.service || "").toLowerCase() === service
|
||||
&& String(thread.dataset.identifier || "") === identifier
|
||||
&& String(thread.dataset.person || "") === personId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
thread.dataset.service = service;
|
||||
thread.dataset.identifier = identifier;
|
||||
if (personId) {
|
||||
thread.dataset.person = personId;
|
||||
} else {
|
||||
delete thread.dataset.person;
|
||||
}
|
||||
if (hiddenService) {
|
||||
hiddenService.value = service;
|
||||
}
|
||||
if (hiddenIdentifier) {
|
||||
hiddenIdentifier.value = identifier;
|
||||
}
|
||||
if (hiddenPerson) {
|
||||
hiddenPerson.value = personId;
|
||||
}
|
||||
if (metaLine) {
|
||||
metaLine.textContent = titleCase(service) + " · " + identifier;
|
||||
}
|
||||
if (panelState.socket) {
|
||||
try {
|
||||
panelState.socket.close();
|
||||
} catch (err) {
|
||||
// Ignore socket close errors.
|
||||
}
|
||||
panelState.socket = null;
|
||||
}
|
||||
panelState.websocketReady = false;
|
||||
hideAllCards();
|
||||
thread.innerHTML = '<p class="compose-empty">Loading messages...</p>';
|
||||
lastTs = 0;
|
||||
thread.dataset.lastTs = "0";
|
||||
glanceState = { gap: null, metrics: [] };
|
||||
renderGlanceItems([]);
|
||||
if (renderMode === "page" && pageUrl) {
|
||||
try {
|
||||
window.history.replaceState({}, "", String(pageUrl));
|
||||
} catch (err) {
|
||||
// Ignore history API failures.
|
||||
}
|
||||
}
|
||||
poll(true);
|
||||
};
|
||||
|
||||
const setCardLoading = function (card, loading) {
|
||||
const loadingNode = card.querySelector(".compose-ai-loading");
|
||||
const contentNode = card.querySelector(".compose-ai-content");
|
||||
@@ -2397,6 +2657,50 @@
|
||||
}
|
||||
});
|
||||
});
|
||||
if (platformSelect) {
|
||||
platformSelect.addEventListener("change", function () {
|
||||
const selected = platformSelect.options[platformSelect.selectedIndex];
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
const selectedService = selected.value || "";
|
||||
const selectedIdentifier = selected.dataset.identifier || "";
|
||||
const selectedPerson = selected.dataset.person || thread.dataset.person || "";
|
||||
const selectedPageUrl = (
|
||||
renderMode === "page"
|
||||
? selected.dataset.pageUrl
|
||||
: selected.dataset.widgetUrl
|
||||
) || "";
|
||||
switchThreadContext(
|
||||
selectedService,
|
||||
selectedIdentifier,
|
||||
selectedPerson,
|
||||
selectedPageUrl
|
||||
);
|
||||
});
|
||||
}
|
||||
if (contactSelect) {
|
||||
contactSelect.addEventListener("change", function () {
|
||||
const selected = contactSelect.options[contactSelect.selectedIndex];
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
const selectedService = selected.dataset.service || "";
|
||||
const selectedIdentifier = selected.dataset.identifier || "";
|
||||
const selectedPerson = selected.dataset.person || "";
|
||||
const selectedPageUrl = (
|
||||
renderMode === "page"
|
||||
? selected.dataset.pageUrl
|
||||
: selected.dataset.widgetUrl
|
||||
) || "";
|
||||
switchThreadContext(
|
||||
selectedService,
|
||||
selectedIdentifier,
|
||||
selectedPerson,
|
||||
selectedPageUrl
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
panelState.docClickHandler = function (event) {
|
||||
if (!panel.contains(event.target)) {
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
</a>
|
||||
{% else %}
|
||||
<button
|
||||
type="button"
|
||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||
hx-get="{% url contacts_url_name type=type pk=item %}"
|
||||
hx-trigger="click"
|
||||
@@ -75,6 +76,7 @@
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||
hx-get="{% url chats_url_name type=type pk=item %}"
|
||||
hx-trigger="click"
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
</a>
|
||||
{% else %}
|
||||
<button
|
||||
type="button"
|
||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||
hx-get="{{ item.compose_widget_url }}"
|
||||
hx-trigger="click"
|
||||
|
||||
@@ -1,53 +1,231 @@
|
||||
{% include 'mixins/partials/notify.html' %}
|
||||
<table
|
||||
class="table is-fullwidth is-hoverable"
|
||||
hx-target="#{{ context_object_name }}-table"
|
||||
id="{{ context_object_name }}-table"
|
||||
<div
|
||||
id="whatsapp-contacts-shell"
|
||||
hx-target="#whatsapp-contacts-shell"
|
||||
hx-swap="outerHTML"
|
||||
hx-trigger="{{ context_object_name_singular }}Event from:body"
|
||||
hx-get="{{ list_url }}">
|
||||
<thead>
|
||||
<th>name</th>
|
||||
<th>identifier</th>
|
||||
<th>jid</th>
|
||||
<th>person</th>
|
||||
<th>actions</th>
|
||||
</thead>
|
||||
{% for item in object_list %}
|
||||
<tr>
|
||||
<td>{{ item.name|default:"-" }}</td>
|
||||
<td>
|
||||
<code>{{ item.identifier }}</code>
|
||||
</td>
|
||||
<td>{{ item.jid|default:"-" }}</td>
|
||||
<td>{{ item.person_name|default:"-" }}</td>
|
||||
<td>
|
||||
<div class="buttons">
|
||||
{% if type == 'page' %}
|
||||
<a href="{{ item.compose_page_url }}" class="button" title="Open manual chat">
|
||||
<span class="icon"><i class="fa-solid fa-paper-plane"></i></span>
|
||||
</a>
|
||||
{% else %}
|
||||
<button
|
||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||
hx-get="{{ item.compose_widget_url }}"
|
||||
hx-trigger="click"
|
||||
hx-target="#widgets-here"
|
||||
hx-swap="afterend"
|
||||
class="button"
|
||||
title="Open manual chat widget">
|
||||
<span class="icon"><i class="fa-solid fa-paper-plane"></i></span>
|
||||
</button>
|
||||
{% endif %}
|
||||
<a href="{{ item.match_url }}" class="button" title="Match identifier">
|
||||
<span class="icon"><i class="fa-solid fa-link"></i></span>
|
||||
</a>
|
||||
|
||||
<div class="field has-addons is-flex-wrap-wrap" style="margin-bottom: 0.6rem;">
|
||||
<div class="control is-expanded" style="min-width: 14rem;">
|
||||
<input
|
||||
id="whatsapp-contacts-search"
|
||||
class="input"
|
||||
type="text"
|
||||
placeholder="Search WhatsApp contacts...">
|
||||
</div>
|
||||
<div class="control">
|
||||
<button id="whatsapp-contacts-reset" class="button is-light" type="button">
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="osint-results-meta">
|
||||
<div class="osint-results-meta-left">
|
||||
<div class="dropdown is-hoverable" id="whatsapp-contacts-columns-dropdown">
|
||||
<div class="dropdown-trigger">
|
||||
<button class="button is-small is-light" aria-haspopup="true" aria-controls="whatsapp-contacts-columns-menu">
|
||||
<span>Show/Hide Fields</span>
|
||||
<span class="icon is-small"><i class="fa-solid fa-angle-down"></i></span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="5" class="has-text-grey">No WhatsApp contacts discovered yet.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
<div class="dropdown-menu" id="whatsapp-contacts-columns-menu" role="menu">
|
||||
<div class="dropdown-content">
|
||||
<a class="dropdown-item whatsapp-col-toggle" data-col-index="0" href="#" onclick="return false;">
|
||||
<span class="icon is-small"><i class="fa-solid fa-check"></i></span>
|
||||
<span>Name</span>
|
||||
<span class="is-size-7 has-text-grey ml-2">(name)</span>
|
||||
</a>
|
||||
<a class="dropdown-item whatsapp-col-toggle" data-col-index="1" href="#" onclick="return false;">
|
||||
<span class="icon is-small"><i class="fa-solid fa-check"></i></span>
|
||||
<span>Identifier</span>
|
||||
<span class="is-size-7 has-text-grey ml-2">(identifier)</span>
|
||||
</a>
|
||||
<a class="dropdown-item whatsapp-col-toggle" data-col-index="2" href="#" onclick="return false;">
|
||||
<span class="icon is-small"><i class="fa-solid fa-check"></i></span>
|
||||
<span>JID</span>
|
||||
<span class="is-size-7 has-text-grey ml-2">(jid)</span>
|
||||
</a>
|
||||
<a class="dropdown-item whatsapp-col-toggle" data-col-index="3" href="#" onclick="return false;">
|
||||
<span class="icon is-small"><i class="fa-solid fa-check"></i></span>
|
||||
<span>Person</span>
|
||||
<span class="is-size-7 has-text-grey ml-2">(person)</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="button is-small is-light" type="button" disabled>
|
||||
<span class="icon is-small"><i class="fa-solid fa-database"></i></span>
|
||||
<span>Static</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="osint-results-count">
|
||||
fetched {{ object_list|length }} result{% if object_list|length != 1 %}s{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table
|
||||
class="table is-fullwidth is-hoverable"
|
||||
id="whatsapp-contacts-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-whatsapp-col="0" class="whatsapp-col-0">name</th>
|
||||
<th data-whatsapp-col="1" class="whatsapp-col-1">identifier</th>
|
||||
<th data-whatsapp-col="2" class="whatsapp-col-2">jid</th>
|
||||
<th data-whatsapp-col="3" class="whatsapp-col-3">person</th>
|
||||
<th>actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in object_list %}
|
||||
<tr data-search="{{ item.name|default:'-'|lower }} {{ item.identifier|lower }} {{ item.jid|default:'-'|lower }} {{ item.person_name|default:'-'|lower }}">
|
||||
<td data-whatsapp-col="0" class="whatsapp-col-0">{{ item.name|default:"-" }}</td>
|
||||
<td data-whatsapp-col="1" class="whatsapp-col-1">
|
||||
<code>{{ item.identifier }}</code>
|
||||
</td>
|
||||
<td data-whatsapp-col="2" class="whatsapp-col-2">{{ item.jid|default:"-" }}</td>
|
||||
<td data-whatsapp-col="3" class="whatsapp-col-3">{{ item.person_name|default:"-" }}</td>
|
||||
<td>
|
||||
<div class="buttons">
|
||||
{% if type == 'page' %}
|
||||
<a href="{{ item.compose_page_url }}" class="button" title="Open manual chat">
|
||||
<span class="icon"><i class="fa-solid fa-paper-plane"></i></span>
|
||||
</a>
|
||||
{% else %}
|
||||
<button
|
||||
type="button"
|
||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||
hx-get="{{ item.compose_widget_url }}"
|
||||
hx-trigger="click"
|
||||
hx-target="#widgets-here"
|
||||
hx-swap="afterend"
|
||||
class="button"
|
||||
title="Open manual chat widget">
|
||||
<span class="icon"><i class="fa-solid fa-paper-plane"></i></span>
|
||||
</button>
|
||||
{% endif %}
|
||||
<a href="{{ item.match_url }}" class="button" title="Match identifier">
|
||||
<span class="icon"><i class="fa-solid fa-link"></i></span>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="5" class="has-text-grey">No WhatsApp contacts discovered yet.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#whatsapp-contacts-shell .osint-results-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
#whatsapp-contacts-shell .osint-results-meta-left {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.42rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
#whatsapp-contacts-shell .osint-results-count {
|
||||
margin: 0;
|
||||
color: #6c757d;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
#whatsapp-contacts-shell .whatsapp-col-toggle .icon {
|
||||
color: #3273dc;
|
||||
}
|
||||
#whatsapp-contacts-shell .whatsapp-col-toggle.is-hidden-col .icon {
|
||||
color: #b5b5b5;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const shell = document.getElementById("whatsapp-contacts-shell");
|
||||
if (!shell) return;
|
||||
const table = document.getElementById("whatsapp-contacts-table");
|
||||
if (!table) return;
|
||||
const rows = Array.from(table.querySelectorAll("tbody tr[data-search]"));
|
||||
const searchInput = document.getElementById("whatsapp-contacts-search");
|
||||
const resetBtn = document.getElementById("whatsapp-contacts-reset");
|
||||
const toggles = Array.from(shell.querySelectorAll(".whatsapp-col-toggle"));
|
||||
const storageKey = "gia_whatsapp_contacts_hidden_cols_v1";
|
||||
let hidden = [];
|
||||
try {
|
||||
hidden = JSON.parse(localStorage.getItem(storageKey) || "[]");
|
||||
} catch (e) {
|
||||
hidden = [];
|
||||
}
|
||||
if (!Array.isArray(hidden)) hidden = [];
|
||||
hidden = hidden.map(String);
|
||||
|
||||
function applyFilters() {
|
||||
const query = String(searchInput?.value || "").trim().toLowerCase();
|
||||
rows.forEach((row) => {
|
||||
const hay = String(row.dataset.search || "").toLowerCase();
|
||||
row.style.display = !query || hay.includes(query) ? "" : "none";
|
||||
});
|
||||
}
|
||||
|
||||
function applyColumns() {
|
||||
const hiddenSet = new Set(hidden);
|
||||
shell.querySelectorAll("[data-whatsapp-col]").forEach((node) => {
|
||||
const idx = String(node.getAttribute("data-whatsapp-col") || "");
|
||||
node.style.display = hiddenSet.has(idx) ? "none" : "";
|
||||
});
|
||||
toggles.forEach((toggle) => {
|
||||
const idx = String(toggle.getAttribute("data-col-index") || "");
|
||||
const isHidden = hiddenSet.has(idx);
|
||||
toggle.classList.toggle("is-hidden-col", isHidden);
|
||||
const icon = toggle.querySelector("i");
|
||||
if (icon) {
|
||||
icon.className = isHidden ? "fa-solid fa-xmark" : "fa-solid fa-check";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function persistColumns() {
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify(hidden));
|
||||
} catch (e) {
|
||||
// Ignore storage failures.
|
||||
}
|
||||
}
|
||||
|
||||
toggles.forEach((toggle) => {
|
||||
toggle.addEventListener("click", function () {
|
||||
const idx = String(toggle.getAttribute("data-col-index") || "");
|
||||
if (!idx) return;
|
||||
if (hidden.includes(idx)) {
|
||||
hidden = hidden.filter((item) => item !== idx);
|
||||
} else {
|
||||
hidden.push(idx);
|
||||
}
|
||||
persistColumns();
|
||||
applyColumns();
|
||||
});
|
||||
});
|
||||
|
||||
if (searchInput) searchInput.addEventListener("input", applyFilters);
|
||||
if (resetBtn) {
|
||||
resetBtn.addEventListener("click", function () {
|
||||
if (searchInput) searchInput.value = "";
|
||||
applyFilters();
|
||||
});
|
||||
}
|
||||
|
||||
applyFilters();
|
||||
applyColumns();
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user