502 lines
23 KiB
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 %}
|