Files
GIA/core/templates/pages/compose-contact-match.html

502 lines
23 KiB
HTML

{% extends "index.html" %}
{% block content %}
<section class="section">
<div class="container">
<div class="level" style="margin-bottom: 0.75rem;">
<div class="level-left">
<div>
<h1 class="title is-4" style="margin-bottom: 0.2rem;">Contact Match</h1>
<p class="is-size-7 has-text-grey">
Manually link Signal, WhatsApp, Instagram, and XMPP identifiers to people.
</p>
</div>
</div>
<div class="level-right">
<a class="button is-light" href="{% url 'compose_workspace' %}">
<span class="icon is-small"><i class="fa-solid fa-table-cells-large"></i></span>
<span>Manual Workspace</span>
</a>
</div>
</div>
{% if notice_message %}
<article class="notification is-{{ notice_level|default:'info' }} is-light">
{{ notice_message }}
</article>
{% endif %}
<div class="columns is-variable is-4">
<div class="column is-5">
<article class="box">
<h2 class="title is-6">Create Or Link Identifier</h2>
<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 id="link-service" name="service" required>
{% for key, label in service_choices %}
<option value="{{ key }}" {% if key == prefill_service %}selected{% endif %}>
{{ label }}
</option>
{% endfor %}
</select>
</div>
</div>
<div class="field">
<label class="label is-small">Identifier</label>
<div class="control">
<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 id="link-person-id" name="person_id">
<option value="">- Select person -</option>
{% for person in people %}
<option value="{{ person.id }}">{{ person.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="field">
<label class="label is-small">Or Create Person</label>
<div class="control">
<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">
<span class="icon is-small"><i class="fa-solid fa-link"></i></span>
<span>Save Match</span>
</button>
</form>
</article>
</div>
<div class="column is-7">
<article class="box">
<h2 class="title is-6">Discovered Contacts</h2>
{% if candidates %}
<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 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
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 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 %}
<form method="post" style="display: inline-flex;">
{% csrf_token %}
<input type="hidden" name="service" value="{{ row.service }}">
<input type="hidden" name="identifier" value="{{ row.identifier }}">
<input type="hidden" name="person_id" value="{{ suggestion.person.id }}">
<button
type="submit"
class="button is-small is-success is-light is-rounded"
title="Accept suggested match">
<span class="icon is-small"><i class="fa-solid fa-check"></i></span>
<span>{{ suggestion.person.name }}</span>
</button>
</form>
{% endfor %}
</div>
{% else %}
<span class="has-text-grey">-</span>
{% endif %}
</td>
<td data-discovered-col="5" class="discovered-col-5">
{% if row.linked_person %}
<span class="tag is-success is-light">linked</span>
{% else %}
<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>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<p class="has-text-grey">No contacts discovered yet.</p>
{% endif %}
</article>
</div>
</div>
</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 %}