Tightly integrate WhatsApp selectors into existing UIs
This commit is contained in:
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user