Compact interfaces and edit more things inline

This commit is contained in:
2026-02-15 22:20:14 +00:00
parent 981ee56de7
commit b23af9bc7f
8 changed files with 429 additions and 12 deletions

View File

@@ -0,0 +1,193 @@
{% include "mixins/partials/notify.html" %}
{% if page_title is not None %}
<h1 class="title is-4">{{ page_title }}</h1>
{% endif %}
{% if page_subtitle is not None %}
<h1 class="subtitle">{{ page_subtitle }}</h1>
{% endif %}
{% load crispy_forms_tags %}
<style>
.person-tab-pane {
display: none;
}
.person-tab-pane.is-active {
display: block;
}
.person-tab-pane-body {
max-height: min(52vh, 30rem);
overflow-y: auto;
padding-right: 0.25rem;
}
.person-content-count {
margin-left: 0.3rem;
min-width: 2.35rem;
text-align: center;
}
</style>
<form
id="person-form-tabs-{{ object.id }}"
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-post="{{ submit_url }}"
hx-target="#modals-here"
hx-swap="innerHTML">
{% csrf_token %}
{% for hidden in form.hidden_fields %}
{{ hidden }}
{% endfor %}
{% if form.non_field_errors %}
<article class="message is-danger">
<div class="message-body">{{ form.non_field_errors }}</div>
</article>
{% endif %}
{{ form.name|as_crispy_field }}
<div class="columns is-multiline">
<div class="column is-12-mobile is-4-tablet">{{ form.sentiment|as_crispy_field }}</div>
<div class="column is-12-mobile is-4-tablet">{{ form.timezone|as_crispy_field }}</div>
<div class="column is-12-mobile is-4-tablet">{{ form.last_interaction|as_crispy_field }}</div>
</div>
<div class="tabs is-small is-toggle is-toggle-rounded" style="margin-bottom: 0.75rem;">
<ul>
<li class="is-active">
<a href="#" data-person-tab-target="summary">
<span class="icon is-small"><i class="fa-solid fa-file-lines"></i></span>
<span>Summary</span>
<span id="person-count-summary-{{ object.id }}" class="tag is-light is-small person-content-count">0</span>
</a>
</li>
<li>
<a href="#" data-person-tab-target="profile">
<span class="icon is-small"><i class="fa-solid fa-id-card"></i></span>
<span>Profile</span>
<span id="person-count-profile-{{ object.id }}" class="tag is-light is-small person-content-count">0</span>
</a>
</li>
<li>
<a href="#" data-person-tab-target="revealed">
<span class="icon is-small"><i class="fa-solid fa-eye"></i></span>
<span>Revealed</span>
<span id="person-count-revealed-{{ object.id }}" class="tag is-light is-small person-content-count">0</span>
</a>
</li>
<li>
<a href="#" data-person-tab-target="likes">
<span class="icon is-small"><i class="fa-solid fa-thumbs-up"></i></span>
<span>Likes</span>
<span id="person-count-likes-{{ object.id }}" class="tag is-light is-small person-content-count">0</span>
</a>
</li>
<li>
<a href="#" data-person-tab-target="dislikes">
<span class="icon is-small"><i class="fa-solid fa-thumbs-down"></i></span>
<span>Dislikes</span>
<span id="person-count-dislikes-{{ object.id }}" class="tag is-light is-small person-content-count">0</span>
</a>
</li>
</ul>
</div>
<div class="person-tab-pane is-active" data-person-tab-pane="summary">
<div class="person-tab-pane-body">
{{ form.summary|as_crispy_field }}
</div>
</div>
<div class="person-tab-pane" data-person-tab-pane="profile">
<div class="person-tab-pane-body">
{{ form.profile|as_crispy_field }}
</div>
</div>
<div class="person-tab-pane" data-person-tab-pane="revealed">
<div class="person-tab-pane-body">
{{ form.revealed|as_crispy_field }}
</div>
</div>
<div class="person-tab-pane" data-person-tab-pane="likes">
<div class="person-tab-pane-body">
{{ form.likes|as_crispy_field }}
</div>
</div>
<div class="person-tab-pane" data-person-tab-pane="dislikes">
<div class="person-tab-pane-body">
{{ form.dislikes|as_crispy_field }}
</div>
</div>
<div class="buttons" style="margin-top: 1rem; margin-bottom: 0;">
{% if hide_cancel is not True %}
<button
type="button"
class="button is-light modal-close-button">
Cancel
</button>
{% endif %}
<button type="submit" class="button modal-close-button">Submit</button>
</div>
</form>
<script>
(function () {
const root = document.getElementById("person-form-tabs-{{ object.id }}");
if (!root || root.dataset.tabsBound === "1") {
return;
}
root.dataset.tabsBound = "1";
const tabs = Array.from(root.querySelectorAll("[data-person-tab-target]"));
const panes = Array.from(root.querySelectorAll("[data-person-tab-pane]"));
const contentFields = ["summary", "profile", "revealed", "likes", "dislikes"];
function setActive(key) {
tabs.forEach((tab) => {
const li = tab.closest("li");
if (!li) {
return;
}
li.classList.toggle("is-active", tab.dataset.personTabTarget === key);
});
panes.forEach((pane) => {
pane.classList.toggle("is-active", pane.dataset.personTabPane === key);
});
}
function updateCounts() {
contentFields.forEach((name) => {
const field = root.querySelector("[name='" + name + "']");
const counter = document.getElementById("person-count-" + name + "-{{ object.id }}");
if (!field || !counter) {
return;
}
const length = (field.value || "").trim().length;
counter.textContent = String(length);
});
}
tabs.forEach((tab) => {
tab.addEventListener("click", function (event) {
event.preventDefault();
setActive(tab.dataset.personTabTarget);
});
});
contentFields.forEach((name) => {
const field = root.querySelector("[name='" + name + "']");
if (field) {
field.addEventListener("input", updateCounts);
}
});
updateCounts();
if (tabs[0]) {
setActive(tabs[0].dataset.personTabTarget);
}
})();
</script>

View File

@@ -0,0 +1,44 @@
{% include "mixins/partials/notify.html" %}
{% if page_title is not None %}
<h1 class="title is-4">{{ page_title }}</h1>
{% endif %}
{% if page_subtitle is not None %}
<h1 class="subtitle">{{ page_subtitle }}</h1>
{% endif %}
{% load crispy_forms_tags %}
<form
id="queue-inline-form-{{ object.id }}"
data-inline-target="{{ submit_target|default:'#modals-here' }}"
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-post="{{ submit_url }}"
hx-target="{{ submit_target|default:'#modals-here' }}"
hx-swap="innerHTML">
{% csrf_token %}
{% for hidden in form.hidden_fields %}
{{ hidden }}
{% endfor %}
{% if form.non_field_errors %}
<article class="message is-danger">
<div class="message-body">{{ form.non_field_errors }}</div>
</article>
{% endif %}
{{ form|crispy }}
<div class="buttons are-small" style="margin-bottom: 0;">
{% if is_inline_edit %}
<button
type="button"
class="button is-light"
onclick="(function(){const f=document.getElementById('queue-inline-form-{{ object.id }}'); if(!f){return;} const target=f.dataset.inlineTarget; const host=target ? document.querySelector(target) : null; if(host){host.innerHTML=''; host.style.display='none';}})(); return false;">
Cancel
</button>
{% elif hide_cancel is not True %}
<button
type="button"
class="button is-light modal-close-button">
Cancel
</button>
{% endif %}
<button type="submit" class="button modal-close-button">Save</button>
</div>
</form>

View File

@@ -0,0 +1,77 @@
<div id="widget">
<div id="widget-{{ unique }}" class="grid-stack-item" {% block widget_options %}{% if widget_options is None %}gs-w="6" gs-h="1" gs-y="20" gs-x="0"{% else %}{% autoescape off %}{{ widget_options }}{% endautoescape %}{% endif %}{% endblock %}>
<div class="grid-stack-item-content">
<nav class="panel">
<p class="panel-heading" style="padding: .2em; line-height: .5em;">
<i class="{{ widget_icon|default:'fa-solid fa-arrows-up-down-left-right' }} has-text-grey-light"></i>
{% block close_button %}
{% include "mixins/partials/close-widget.html" %}
{% endblock %}
<i
class="fa-solid fa-arrows-minimize has-text-grey-light float-right"
onclick="grid.compact();"></i>
{% block heading %}
{{ title }}
{% endblock %}
</p>
<article class="panel-block is-active">
<div class="control">
{% block panel_content %}
{% include window_content %}
{% endblock %}
</div>
</article>
</nav>
</div>
</div>
</div>
<script>
{% block custom_script %}
{% endblock %}
var widget_event = new Event("load-widget");
document.dispatchEvent(widget_event);
(function () {
var widgetRoot = document.getElementById("widget-{{ unique }}");
var iconClass = "{{ widget_icon|default:'fa-solid fa-arrows-minimize'|escapejs }}";
function decorateHandle() {
if (!widgetRoot) {
return true;
}
var handles = widgetRoot.querySelectorAll(".ui-resizable-se");
if (!handles.length) {
handles = widgetRoot.querySelectorAll(".ui-resizable-handle");
}
if (!handles.length) {
return false;
}
handles.forEach(function (handle) {
if (handle.dataset.iconApplied === "1") {
return;
}
handle.dataset.iconApplied = "1";
handle.style.display = "flex";
handle.style.alignItems = "center";
handle.style.justifyContent = "center";
handle.style.overflow = "hidden";
var icon = document.createElement("i");
icon.className = iconClass + " has-text-grey-light";
icon.style.fontSize = "0.65rem";
icon.style.opacity = "0.65";
icon.style.pointerEvents = "none";
handle.appendChild(icon);
});
return true;
}
var attempts = 0;
var timer = window.setInterval(function () {
attempts += 1;
if (decorateHandle() || attempts > 10) {
window.clearInterval(timer);
}
}, 80);
})();
</script>
{% block custom_end %}
{% endblock %}

View File

@@ -199,10 +199,15 @@
</li> </li>
</ul> </ul>
</div> </div>
<div class="is-flex is-align-items-center" style="gap: 0.35rem;"> <div class="is-flex is-align-items-center ai-capsule-controls">
<span id="ai-cache-indicator-{{ person.id }}" class="tag is-warning is-light is-small" style="display: none;"> <span class="ai-cache-indicator-slot">
<span
id="ai-cache-indicator-{{ person.id }}"
class="tag is-warning is-light is-small"
aria-hidden="true">
Cached Cached
</span> </span>
</span>
<button <button
type="button" type="button"
class="button is-small is-ghost" class="button is-small is-ghost"
@@ -267,12 +272,38 @@
text-align: center; text-align: center;
white-space: nowrap; white-space: nowrap;
} }
.ai-person-widget .ai-capsule-controls {
gap: 0.35rem;
flex: 0 0 auto;
}
.ai-person-widget .ai-cache-indicator-slot {
width: 9.5rem;
display: inline-flex;
justify-content: flex-end;
align-items: center;
}
.ai-person-widget .ai-cache-indicator-slot .tag {
width: 100%;
display: inline-flex;
justify-content: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
visibility: hidden;
pointer-events: none;
}
.ai-person-widget .ai-cache-indicator-slot .tag.is-visible {
visibility: visible;
}
@media screen and (max-width: 768px) { @media screen and (max-width: 768px) {
.ai-person-widget .ai-top-tabs li a { .ai-person-widget .ai-top-tabs li a {
min-height: 2.1rem; min-height: 2.1rem;
min-width: 2.1rem; min-width: 2.1rem;
padding: 0 0.45rem; padding: 0 0.45rem;
} }
.ai-person-widget .ai-cache-indicator-slot {
width: 8.4rem;
}
} }
</style> </style>
@@ -407,8 +438,13 @@
} }
if (show) { if (show) {
indicator.textContent = formatCacheAge(ts); indicator.textContent = formatCacheAge(ts);
indicator.classList.add("is-visible");
indicator.setAttribute("aria-hidden", "false");
return;
} }
indicator.style.display = show ? "inline-flex" : "none"; indicator.textContent = "Cached";
indicator.classList.remove("is-visible");
indicator.setAttribute("aria-hidden", "true");
} }
const OPERATION_TABS = ["summarise", "draft_reply", "extract_patterns"]; const OPERATION_TABS = ["summarise", "draft_reply", "extract_patterns"];

View File

@@ -61,10 +61,11 @@
<div class="buttons are-small" style="margin: 0;"> <div class="buttons are-small" style="margin: 0;">
<button <button
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-get="{% url 'queue_update' type=type pk=item.id %}" hx-get="{% url 'queue_update' type='window' pk=item.id %}?hx_target=%23queue-inline-editor-{{ item.id }}"
hx-trigger="click" hx-trigger="click"
hx-target="#{{ type }}s-here" hx-target="#queue-inline-editor-{{ item.id }}"
hx-swap="innerHTML" hx-swap="innerHTML"
_="on htmx:afterRequest if event.detail.successful set #queue-inline-editor-{{ item.id }}.style.display to 'block' end"
class="button is-light"> class="button is-light">
<span class="icon is-small"><i class="fa-solid fa-pen"></i></span> <span class="icon is-small"><i class="fa-solid fa-pen"></i></span>
<span>Edit</span> <span>Edit</span>
@@ -82,6 +83,9 @@
</button> </button>
</div> </div>
</div> </div>
<div
id="queue-inline-editor-{{ item.id }}"
style="display: none; margin-top: 0.55rem; padding: 0.55rem; border-radius: 8px; border: 1px solid rgba(50, 115, 220, 0.25); background: rgba(255, 255, 255, 0.78);"></div>
</article> </article>
</div> </div>
{% endfor %} {% endfor %}

View File

@@ -53,6 +53,21 @@ def _column_field_name(column: "OsintColumn") -> str:
return str(column.key) return str(column.key)
def _safe_icon_class(raw: str | None, default: str) -> str:
icon_class = str(raw or "").strip()
if not icon_class:
return default
cleaned_parts = []
for part in icon_class.split():
if not part:
continue
if all(ch.isalnum() or ch in {"-", "_"} for ch in part):
cleaned_parts.append(part)
if not cleaned_parts:
return default
return " ".join(cleaned_parts)
def _url_with_query(base_url: str, query: dict[str, Any]) -> str: def _url_with_query(base_url: str, query: dict[str, Any]) -> str:
params = {} params = {}
for key, value in query.items(): for key, value in query.items():
@@ -378,6 +393,13 @@ OSINT_SCOPES: dict[str, OsintScopeConfig] = {
), ),
} }
OSINT_SCOPE_ICONS: dict[str, str] = {
"people": "fa-solid fa-user-group",
"groups": "fa-solid fa-users",
"personas": "fa-solid fa-masks-theater",
"manipulations": "fa-solid fa-sliders",
}
class OSINTListBase(ObjectList): class OSINTListBase(ObjectList):
list_template = "partials/osint/list-table.html" list_template = "partials/osint/list-table.html"
@@ -511,6 +533,10 @@ class OSINTListBase(ObjectList):
request_type: str, request_type: str,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
context_type = _context_type(request_type) context_type = _context_type(request_type)
update_type = "window" if request_type == "widget" else context_type
update_target = (
"#windows-here" if update_type == "window" else f"#{update_type}s-here"
)
rows = [] rows = []
for item in object_list: for item in object_list:
row = {"id": str(item.pk), "cells": [], "actions": []} row = {"id": str(item.pk), "cells": [], "actions": []}
@@ -524,7 +550,7 @@ class OSINTListBase(ObjectList):
update_url = reverse( update_url = reverse(
scope.update_url_name, scope.update_url_name,
kwargs={"type": context_type, "pk": item.pk}, kwargs={"type": update_type, "pk": item.pk},
) )
delete_url = reverse( delete_url = reverse(
scope.delete_url_name, scope.delete_url_name,
@@ -535,7 +561,7 @@ class OSINTListBase(ObjectList):
{ {
"mode": "hx-get", "mode": "hx-get",
"url": update_url, "url": update_url,
"target": f"#{context_type}s-here", "target": update_target,
"icon": "fa-solid fa-pencil", "icon": "fa-solid fa-pencil",
"title": "Edit", "title": "Edit",
} }
@@ -653,6 +679,10 @@ class OSINTListBase(ObjectList):
context["osint_show_actions"] = True context["osint_show_actions"] = True
context["osint_search_url"] = list_url context["osint_search_url"] = list_url
context["osint_result_count"] = context["osint_pagination"].get("count", 0) context["osint_result_count"] = context["osint_pagination"].get("count", 0)
context["widget_icon"] = _safe_icon_class(
self.request.GET.get("widget_icon"),
OSINT_SCOPE_ICONS.get(scope.key, "fa-solid fa-arrows-minimize"),
)
return context return context
@@ -1030,6 +1060,10 @@ class OSINTSearch(LoginRequiredMixin, View):
"unique": "osint-search-widget", "unique": "osint-search-widget",
"window_content": self.panel_template, "window_content": self.panel_template,
"widget_options": 'gs-w="8" gs-h="14" gs-x="0" gs-y="0" gs-min-w="5"', "widget_options": 'gs-w="8" gs-h="14" gs-x="0" gs-y="0" gs-min-w="5"',
"widget_icon": _safe_icon_class(
request.GET.get("widget_icon"),
"fa-solid fa-magnifying-glass",
),
**context, **context,
} }
return render(request, self.widget_template, widget_context) return render(request, self.widget_template, widget_context)
@@ -1054,25 +1088,37 @@ class OSINTWorkspaceTabsWidget(LoginRequiredMixin, View):
"key": "people", "key": "people",
"label": "People", "label": "People",
"icon": "fa-solid fa-user-group", "icon": "fa-solid fa-user-group",
"widget_url": reverse("people", kwargs={"type": "widget"}), "widget_url": _url_with_query(
reverse("people", kwargs={"type": "widget"}),
{"widget_icon": "fa-solid fa-user-group"},
),
}, },
{ {
"key": "groups", "key": "groups",
"label": "Groups", "label": "Groups",
"icon": "fa-solid fa-users", "icon": "fa-solid fa-users",
"widget_url": reverse("groups", kwargs={"type": "widget"}), "widget_url": _url_with_query(
reverse("groups", kwargs={"type": "widget"}),
{"widget_icon": "fa-solid fa-users"},
),
}, },
{ {
"key": "personas", "key": "personas",
"label": "Personas", "label": "Personas",
"icon": "fa-solid fa-masks-theater", "icon": "fa-solid fa-masks-theater",
"widget_url": reverse("personas", kwargs={"type": "widget"}), "widget_url": _url_with_query(
reverse("personas", kwargs={"type": "widget"}),
{"widget_icon": "fa-solid fa-masks-theater"},
),
}, },
{ {
"key": "manipulations", "key": "manipulations",
"label": "Manipulations", "label": "Manipulations",
"icon": "fa-solid fa-sliders", "icon": "fa-solid fa-sliders",
"widget_url": reverse("manipulations", kwargs={"type": "widget"}), "widget_url": _url_with_query(
reverse("manipulations", kwargs={"type": "widget"}),
{"widget_icon": "fa-solid fa-sliders"},
),
}, },
] ]
context = { context = {

View File

@@ -30,6 +30,7 @@ class PersonCreate(LoginRequiredMixin, ObjectCreate):
class PersonUpdate(LoginRequiredMixin, ObjectUpdate): class PersonUpdate(LoginRequiredMixin, ObjectUpdate):
model = Person model = Person
form_class = PersonForm form_class = PersonForm
window_content = "mixins/window-content/person-form.html"
submit_url_name = "person_update" submit_url_name = "person_update"

View File

@@ -1,3 +1,5 @@
import re
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.db import transaction from django.db import transaction
@@ -12,6 +14,7 @@ from core.models import Message, QueuedMessage
from core.util import logs from core.util import logs
log = logs.get_logger("queue") log = logs.get_logger("queue")
_INLINE_TARGET_RE = re.compile(r"^#queue-inline-editor-[A-Za-z0-9_-]+$")
class AcceptMessageAPI(LoginRequiredMixin, APIView): class AcceptMessageAPI(LoginRequiredMixin, APIView):
@@ -92,9 +95,22 @@ class QueueCreate(LoginRequiredMixin, ObjectCreate):
class QueueUpdate(LoginRequiredMixin, ObjectUpdate): class QueueUpdate(LoginRequiredMixin, ObjectUpdate):
model = QueuedMessage model = QueuedMessage
form_class = QueueForm form_class = QueueForm
window_content = "mixins/window-content/queue-form-inline.html"
submit_url_name = "queue_update" submit_url_name = "queue_update"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
raw_target = str(self.request.GET.get("hx_target") or "").strip()
if _INLINE_TARGET_RE.fullmatch(raw_target):
context["submit_target"] = raw_target
else:
context["submit_target"] = "#modals-here"
context["is_inline_edit"] = context["submit_target"].startswith(
"#queue-inline-editor-"
)
return context
class QueueDelete(LoginRequiredMixin, ObjectDelete): class QueueDelete(LoginRequiredMixin, ObjectDelete):
model = QueuedMessage model = QueuedMessage