WIP: checkpoint working tree before rebasing onto dev

This commit is contained in:
Nick Sweeting
2026-03-22 20:23:45 -07:00
parent a6548df8d0
commit f400a2cd67
87 changed files with 12607 additions and 1808 deletions

View File

@@ -1083,8 +1083,11 @@
width: 100% !important;
}
body.filters-collapsed.change-list #changelist .changelist-form-container > div {
body.filters-collapsed.change-list #changelist .changelist-form-container > div,
body.filters-collapsed.change-list #changelist .changelist-form-container > form {
max-width: 100% !important;
width: 100% !important;
flex: 1 1 100% !important;
}
/* Actions bar */
@@ -1372,7 +1375,8 @@
order: 2;
align-self: flex-start;
}
body.change-list #changelist .changelist-form-container > div {
body.change-list #changelist .changelist-form-container > div,
body.change-list #changelist .changelist-form-container > form {
flex: 1 1 auto;
min-width: 0;
order: 1;

View File

@@ -0,0 +1,268 @@
{% extends "admin/change_form.html" %}
{% block bodyclass %}{{ block.super }} app-core model-tag tag-form-page{% endblock %}
{% block extrastyle %}
{{ block.super }}
<style>
.tag-form-hero {
margin: 0 0 20px;
padding: 22px 24px;
border-radius: 20px;
border: 1px solid #dbe4ee;
background:
radial-gradient(circle at top right, rgba(245, 158, 11, 0.12), transparent 30%),
linear-gradient(135deg, #fff7ed 0%, #ffffff 48%, #eff6ff 100%);
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.06);
display: grid;
gap: 16px;
grid-template-columns: minmax(0, 1.7fr) minmax(260px, 1fr);
}
.tag-form-hero h2 {
margin: 0 0 8px;
font-size: 28px;
line-height: 1.05;
color: #111827;
}
.tag-form-hero p {
margin: 0;
color: #475569;
font-size: 14px;
max-width: 70ch;
}
.tag-form-hero__meta {
display: grid;
gap: 10px;
}
.tag-form-hero__meta div {
padding: 14px 16px;
border-radius: 14px;
border: 1px solid rgba(203, 213, 225, 0.85);
background: rgba(255, 255, 255, 0.88);
}
.tag-form-hero__meta span {
display: block;
margin-bottom: 8px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #64748b;
}
.tag-similar-panel {
margin-top: 18px;
padding: 18px;
border-radius: 18px;
border: 1px solid #dbe4ee;
background: #fff;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.05);
}
.tag-similar-panel h3 {
margin: 0 0 6px;
font-size: 16px;
color: #111827;
}
.tag-similar-panel p {
margin: 0 0 14px;
font-size: 13px;
color: #64748b;
}
.tag-similar-list {
display: grid;
gap: 10px;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}
.tag-similar-card {
display: grid;
gap: 8px;
padding: 14px 16px;
border-radius: 16px;
border: 1px solid #dbe4ee;
background: #f8fafc;
text-decoration: none;
color: #0f172a;
}
.tag-similar-card strong {
font-size: 15px;
line-height: 1.1;
}
.tag-similar-card span {
font-size: 12px;
color: #64748b;
}
.tag-similar-card__snapshots {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tag-similar-snapshot {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
max-width: 100%;
padding: 6px 8px;
border-radius: 999px;
background: #fff;
border: 1px solid #dbe4ee;
font-size: 11px;
color: #334155;
}
.tag-similar-snapshot img {
width: 14px;
height: 14px;
border-radius: 4px;
flex: 0 0 auto;
}
.tag-similar-empty {
padding: 16px;
border-radius: 16px;
border: 1px dashed #cbd5e1;
background: #f8fafc;
color: #64748b;
font-size: 13px;
}
@media (max-width: 920px) {
.tag-form-hero {
grid-template-columns: 1fr;
}
}
</style>
{% endblock %}
{% block form_top %}
<section class="tag-form-hero">
<div>
<h2>{% if add %}New Tag{% else %}Edit Tag{% endif %}</h2>
<p>Similar tags are shown below while typing.</p>
</div>
<div class="tag-form-hero__meta">
<div>
<span>Matches</span>
<strong>Current tags</strong>
</div>
<div>
<span>Links</span>
<strong>Open filtered snapshots</strong>
</div>
</div>
</section>
{{ block.super }}
{% endblock %}
{% block after_field_sets %}
{{ block.super }}
<section
id="tag-similar-panel"
class="tag-similar-panel"
data-search-url="{{ tag_search_api_url }}"
>
<h3>Similar Tags</h3>
<p>Updates while typing.</p>
<div id="tag-similar-list" class="tag-similar-list"></div>
</section>
{{ tag_similar_cards|json_script:"abx-tag-similar-data" }}
<script>
document.addEventListener('DOMContentLoaded', function () {
const panel = document.getElementById('tag-similar-panel');
const list = document.getElementById('tag-similar-list');
const nameInput = document.querySelector('input[data-tag-name-input="1"]');
if (!panel || !list || !nameInput) return;
const searchUrl = panel.dataset.searchUrl;
let similarCards = JSON.parse(document.getElementById('abx-tag-similar-data').textContent || '[]');
let timeoutId = null;
function escapeHtml(value) {
const div = document.createElement('div');
div.textContent = value == null ? '' : String(value);
return div.innerHTML;
}
function getApiKey() {
return (window.ARCHIVEBOX_API_KEY || '').trim();
}
function withApiKey(url) {
const apiKey = getApiKey();
if (!apiKey) return url;
const separator = url.includes('?') ? '&' : '?';
return url + separator + 'api_key=' + encodeURIComponent(apiKey);
}
function buildHeaders() {
const headers = {};
const apiKey = getApiKey();
if (apiKey) headers['X-ArchiveBox-API-Key'] = apiKey;
return headers;
}
function render(cards) {
const filtered = (cards || []).filter(function (card) {
return (card.name || '').toLowerCase() !== (nameInput.value || '').trim().toLowerCase();
});
if (!filtered.length) {
list.innerHTML = '<div class="tag-similar-empty">No similar tags.</div>';
return;
}
list.innerHTML = filtered.map(function (card) {
const snapshots = (card.snapshots || []).slice(0, 3).map(function (snapshot) {
return '' +
'<span class="tag-similar-snapshot">' +
'<img src="' + escapeHtml(snapshot.favicon_url) + '" alt="" onerror="this.style.display=\\'none\\'">' +
'<span>' + escapeHtml(snapshot.title) + '</span>' +
'</span>';
}).join('');
return '' +
'<a class="tag-similar-card" href="' + escapeHtml(card.filter_url) + '">' +
'<strong>' + escapeHtml(card.name) + '</strong>' +
'<span>' + escapeHtml(card.num_snapshots) + ' snapshots · slug: ' + escapeHtml(card.slug) + '</span>' +
'<div class="tag-similar-card__snapshots">' + (snapshots || '<span class="tag-similar-snapshot">No snapshots</span>') + '</div>' +
'</a>';
}).join('');
}
async function fetchSimilar(query) {
const response = await fetch(withApiKey(searchUrl + '?q=' + encodeURIComponent(query || '')), {
headers: buildHeaders(),
credentials: 'same-origin',
});
if (!response.ok) return [];
const payload = await response.json();
return payload.tags || [];
}
nameInput.addEventListener('input', function () {
window.clearTimeout(timeoutId);
timeoutId = window.setTimeout(async function () {
similarCards = await fetchSimilar((nameInput.value || '').trim());
render(similarCards);
}, 140);
});
render(similarCards);
});
</script>
{% endblock %}

View File

@@ -0,0 +1,997 @@
{% extends "admin/change_list.html" %}
{% block bodyclass %}{{ block.super }} app-core model-tag change-list tag-admin-page{% endblock %}
{% block object-tools %}{% endblock %}
{% block extrastyle %}
{{ block.super }}
<style>
.tag-admin-shell {
display: grid;
gap: 12px;
}
.tag-admin-toolbar {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: start;
}
.tag-admin-panel {
flex: 1 1 320px;
padding: 12px;
border-radius: 16px;
border: 1px solid #dbe4ee;
background: #fff;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.05);
}
.tag-admin-panel--search {
flex: 3 1 360px;
}
.tag-admin-panel--filters {
flex: 3 1 440px;
}
.tag-admin-panel--create {
flex: 1 1 280px;
}
.tag-admin-panel h2 {
margin: 0 0 12px;
font-size: 16px;
color: #0f172a;
}
.tag-create-form,
.tag-search-form {
display: grid;
gap: 10px;
}
.tag-input-row {
display: flex;
gap: 10px;
align-items: center;
}
.tag-create-form .tag-input-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
}
.tag-input-row input {
flex: 1 1 auto;
min-width: 0;
height: 40px;
box-sizing: border-box;
padding: 0 12px;
line-height: 1.2;
border-radius: 10px;
border: 1px solid #cbd5e1;
background: #f8fafc;
font-size: 13px;
color: #0f172a;
}
.tag-input-row input:focus {
outline: none;
border-color: #0ea5e9;
box-shadow: 0 0 0 4px rgba(14, 165, 233, 0.14);
background: #fff;
}
.tag-button,
.tag-chip-button {
border: 0;
border-radius: 10px;
cursor: pointer;
font-weight: 700;
transition: transform 0.12s ease, box-shadow 0.12s ease, opacity 0.12s ease;
}
.tag-button:hover,
.tag-chip-button:hover {
transform: translateY(-1px);
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.08);
}
.tag-button:disabled,
.tag-chip-button:disabled {
cursor: wait;
opacity: 0.6;
transform: none;
box-shadow: none;
}
.tag-button {
flex: 0 0 auto;
height: 40px;
padding: 0 12px;
background: linear-gradient(135deg, #0f766e 0%, #0ea5e9 100%);
color: #fff;
white-space: nowrap;
font-size: 12px;
}
.tag-toolbar-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
font-size: 12px;
color: #64748b;
}
.tag-toolbar-meta strong {
color: #0f172a;
}
.tag-help {
margin: 0;
font-size: 12px;
color: #64748b;
}
.tag-filter-grid {
display: grid;
gap: 10px;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.tag-select-field {
display: grid;
gap: 4px;
min-width: 0;
font-size: 11px;
font-weight: 700;
color: #475569;
}
.tag-select-field select {
width: 100%;
min-width: 0;
height: 40px;
box-sizing: border-box;
padding: 0 10px;
line-height: 1.2;
border-radius: 10px;
border: 1px solid #cbd5e1;
background: #f8fafc;
color: #0f172a;
font-size: 12px;
vertical-align: middle;
}
.tag-select-field select:focus {
outline: none;
border-color: #0ea5e9;
box-shadow: 0 0 0 4px rgba(14, 165, 233, 0.14);
background: #fff;
}
.tag-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}
.tag-card {
position: relative;
display: grid;
gap: 10px;
padding: 10px;
border-radius: 16px;
border: 1px solid #dbe4ee;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.96) 0%, rgba(248, 250, 252, 0.94) 100%);
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.05);
transition: transform 0.14s ease, border-color 0.14s ease, box-shadow 0.14s ease;
cursor: pointer;
}
.tag-card:hover {
transform: translateY(-2px);
border-color: #93c5fd;
box-shadow: 0 14px 26px rgba(15, 23, 42, 0.08);
}
.tag-card__header {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: flex-start;
}
.tag-card__title {
flex: 1 1 auto;
min-width: 0;
display: grid;
gap: 4px;
}
.tag-card__title strong,
.tag-card__rename strong {
display: block;
font-size: 17px;
line-height: 1.1;
color: #111827;
word-break: break-word;
}
.tag-card__count {
display: inline-flex;
align-items: center;
white-space: nowrap;
padding: 3px 8px;
border-radius: 999px;
background: #e0f2fe;
color: #075985;
font-size: 11px;
font-weight: 700;
}
.tag-card__actions {
flex: 0 0 auto;
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
align-items: center;
gap: 6px;
}
.tag-chip-button {
height: 30px;
padding: 0 8px;
background: #fff;
border: 1px solid #dbe4ee;
color: #334155;
font-size: 11px;
}
.tag-chip-button.is-danger {
background: #fff1f2;
border-color: #fecdd3;
color: #be123c;
}
.tag-card__rename {
display: none;
gap: 6px;
align-items: center;
flex-wrap: wrap;
margin-top: 2px;
}
.tag-card.is-editing .tag-card__display {
display: none;
}
.tag-card.is-editing .tag-card__rename {
display: flex;
}
.tag-card.is-editing .tag-card__header {
display: grid;
grid-template-columns: minmax(0, 1fr);
}
.tag-card.is-editing .tag-card__actions {
justify-content: flex-start;
}
.tag-card__rename input {
flex: 1 1 220px;
min-width: 0;
height: 34px;
padding: 0 10px;
border-radius: 10px;
border: 1px solid #cbd5e1;
background: #fff;
font-size: 12px;
}
.tag-card__snapshots {
display: grid;
gap: 8px;
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
}
.tag-snapshot-badge {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
padding: 6px 8px;
border-radius: 12px;
border: 1px solid #dbe4ee;
background: rgba(255, 255, 255, 0.86);
text-decoration: none;
color: #0f172a;
}
.tag-snapshot-badge img {
width: 16px;
height: 16px;
border-radius: 4px;
flex: 0 0 auto;
background: #f8fafc;
}
.tag-snapshot-badge span {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 11px;
font-weight: 600;
}
.tag-card__empty {
padding: 14px;
border-radius: 14px;
border: 1px dashed #cbd5e1;
background: #f8fafc;
color: #64748b;
font-size: 13px;
}
.tag-toast {
position: sticky;
top: 12px;
z-index: 30;
display: none;
width: fit-content;
max-width: min(100%, 420px);
padding: 12px 14px;
border-radius: 14px;
font-size: 13px;
font-weight: 700;
box-shadow: 0 14px 30px rgba(15, 23, 42, 0.12);
}
.tag-toast.is-visible {
display: block;
}
.tag-toast.is-success {
background: #dcfce7;
color: #166534;
}
.tag-toast.is-error {
background: #fee2e2;
color: #991b1b;
}
.tag-empty-state {
padding: 24px 18px;
border-radius: 16px;
border: 1px dashed #cbd5e1;
background: #fff;
text-align: center;
color: #64748b;
font-size: 13px;
}
</style>
{% endblock %}
{% block content %}
<div id="content-main">
<div
id="abx-tag-admin"
class="tag-admin-shell"
data-search-url="{{ tag_search_api_url }}"
data-create-url="{{ tag_create_api_url }}"
>
<section class="tag-admin-toolbar">
<div class="tag-admin-panel tag-admin-panel--search">
<div class="tag-search-form">
<div class="tag-input-row">
<input
id="tag-live-search"
type="search"
placeholder="Search by tag name"
value="{{ initial_query }}"
autocomplete="off"
>
</div>
<div class="tag-toolbar-meta">
<span id="tag-query-label">{% if initial_query %}“{{ initial_query }}”{% else %}All tags{% endif %}</span>
</div>
</div>
</div>
<div class="tag-admin-panel tag-admin-panel--filters">
<div class="tag-filter-grid">
<label class="tag-select-field" for="tag-sort-select">
<span>Sort</span>
<select id="tag-sort-select">
{% for value, label in tag_sort_choices %}
<option value="{{ value }}"{% if value == initial_sort %} selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label class="tag-select-field" for="tag-created-by-select">
<span>Created By</span>
<select id="tag-created-by-select">
<option value="">All users</option>
{% for value, label in tag_created_by_choices %}
<option value="{{ value }}"{% if value == initial_created_by %} selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label class="tag-select-field" for="tag-year-select">
<span>Year</span>
<select id="tag-year-select">
<option value="">All years</option>
{% for value in tag_year_choices %}
<option value="{{ value }}"{% if value == initial_year %} selected{% endif %}>{{ value }}</option>
{% endfor %}
</select>
</label>
</div>
</div>
<div class="tag-admin-panel tag-admin-panel--create">
<form id="tag-create-form" class="tag-create-form">
{% csrf_token %}
<div class="tag-input-row">
<input
id="tag-create-name"
type="text"
name="name"
placeholder="New tag name"
autocomplete="off"
value=""
>
<button class="tag-button" type="submit">Create</button>
</div>
</form>
</div>
</section>
<div id="tag-toast" class="tag-toast" aria-live="polite"></div>
<div id="tag-card-grid" class="tag-grid">
{% if initial_tag_cards %}
{% for card in initial_tag_cards %}
<article
class="tag-card"
data-id="{{ card.id }}"
data-filter-url="{{ card.filter_url }}"
data-rename-url="{{ card.rename_url }}"
data-delete-url="{{ card.delete_url }}"
data-export-urls-url="{{ card.export_urls_url }}"
data-export-jsonl-url="{{ card.export_jsonl_url }}"
>
<div class="tag-card__header">
<div class="tag-card__title">
<div class="tag-card__display">
<strong><a href="{{ card.filter_url }}" style="color:inherit;text-decoration:none;">{{ card.name }}</a></strong>
</div>
<div class="tag-card__rename">
<input type="text" value="{{ card.name }}" aria-label="Rename tag {{ card.name }}">
<button type="button" class="tag-chip-button" data-action="save-edit">Save</button>
<button type="button" class="tag-chip-button" data-action="cancel-edit">Cancel</button>
</div>
</div>
<div class="tag-card__actions">
<button type="button" class="tag-chip-button" data-action="edit" aria-label="Rename tag" title="Rename tag"></button>
<button type="button" class="tag-chip-button" data-action="copy-urls">Copy URLs</button>
<button type="button" class="tag-chip-button" data-action="download-jsonl">JSONL</button>
<button type="button" class="tag-chip-button is-danger" data-action="delete">Delete</button>
<span class="tag-card__count">{{ card.num_snapshots }}</span>
</div>
</div>
<div class="tag-card__snapshots">
{% if card.snapshots %}
{% for snapshot in card.snapshots %}
<a class="tag-snapshot-badge" href="{{ snapshot.admin_url }}" title="{{ snapshot.url }}">
<img src="{{ snapshot.favicon_url }}" alt="" onerror="this.style.display='none'">
<span>{{ snapshot.title }}</span>
</a>
{% endfor %}
{% else %}
<div class="tag-card__empty">No snapshots attached yet.</div>
{% endif %}
</div>
</article>
{% endfor %}
{% else %}
<div class="tag-empty-state">No tags.</div>
{% endif %}
</div>
</div>
</div>
{{ initial_tag_cards|json_script:"abx-tag-cards-data" }}
<script>
document.addEventListener('DOMContentLoaded', function () {
const shell = document.getElementById('abx-tag-admin');
if (!shell) return;
const initialCards = JSON.parse(document.getElementById('abx-tag-cards-data').textContent || '[]');
const searchUrl = shell.dataset.searchUrl;
const createUrl = shell.dataset.createUrl;
const searchInput = document.getElementById('tag-live-search');
const sortSelect = document.getElementById('tag-sort-select');
const createdBySelect = document.getElementById('tag-created-by-select');
const yearSelect = document.getElementById('tag-year-select');
const createForm = document.getElementById('tag-create-form');
const createInput = document.getElementById('tag-create-name');
const grid = document.getElementById('tag-card-grid');
const queryLabel = document.getElementById('tag-query-label');
const toast = document.getElementById('tag-toast');
let cards = initialCards;
let searchTimeout = null;
let activeQuery = (searchInput?.value || '').trim();
function escapeHtml(value) {
const div = document.createElement('div');
div.textContent = value == null ? '' : String(value);
return div.innerHTML;
}
function slugify(value) {
return String(value || '')
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'tag';
}
function getCSRFToken() {
const input = document.querySelector('input[name="csrfmiddlewaretoken"]');
if (input) return input.value;
const cookies = document.cookie.split(';');
for (const cookieRaw of cookies) {
const cookie = cookieRaw.trim();
if (cookie.startsWith('csrftoken=')) return cookie.slice('csrftoken='.length);
}
return '';
}
function getApiKey() {
return (window.ARCHIVEBOX_API_KEY || '').trim();
}
function withApiKey(url) {
const apiKey = getApiKey();
if (!apiKey) return url;
const separator = url.includes('?') ? '&' : '?';
return url + separator + 'api_key=' + encodeURIComponent(apiKey);
}
function buildHeaders(isJsonBody) {
const headers = {};
if (isJsonBody) headers['Content-Type'] = 'application/json';
const csrfToken = getCSRFToken();
if (csrfToken) headers['X-CSRFToken'] = csrfToken;
const apiKey = getApiKey();
if (apiKey) headers['X-ArchiveBox-API-Key'] = apiKey;
return headers;
}
function setToast(message, tone) {
toast.textContent = message;
toast.className = 'tag-toast is-visible ' + (tone === 'error' ? 'is-error' : 'is-success');
window.clearTimeout(setToast._timer);
setToast._timer = window.setTimeout(function () {
toast.className = 'tag-toast';
toast.textContent = '';
}, 2600);
}
function getCurrentState(overrides) {
const next = overrides || {};
return {
query: typeof next.query === 'string' ? next.query.trim() : (searchInput?.value || '').trim(),
sort: typeof next.sort === 'string' ? next.sort : (sortSelect?.value || 'created_desc'),
created_by: typeof next.created_by === 'string' ? next.created_by : (createdBySelect?.value || ''),
year: typeof next.year === 'string' ? next.year : (yearSelect?.value || ''),
};
}
function syncSearchState(state) {
if (searchInput) searchInput.value = state.query;
if (sortSelect) sortSelect.value = state.sort;
if (createdBySelect) createdBySelect.value = state.created_by;
if (yearSelect) yearSelect.value = state.year;
}
function syncLocation(state) {
const url = new URL(window.location.href);
if (state.query) {
url.searchParams.set('q', state.query);
} else {
url.searchParams.delete('q');
}
if (state.sort && state.sort !== 'created_desc') {
url.searchParams.set('sort', state.sort);
} else {
url.searchParams.delete('sort');
}
if (state.created_by) {
url.searchParams.set('created_by', state.created_by);
} else {
url.searchParams.delete('created_by');
}
if (state.year) {
url.searchParams.set('year', state.year);
} else {
url.searchParams.delete('year');
}
window.history.replaceState({}, '', url.toString());
}
function setMeta(state, count) {
const baseLabel = state.query ? '"' + state.query + '"' : 'All tags';
queryLabel.textContent = baseLabel + ' · ' + count + ' shown';
activeQuery = state.query;
}
function renderCards(nextCards, state) {
cards = Array.isArray(nextCards) ? nextCards : [];
setMeta(state || getCurrentState(), cards.length);
if (!cards.length) {
grid.innerHTML = '<div class="tag-empty-state">No tags.</div>';
return;
}
grid.innerHTML = cards.map(function (card) {
const snapshotHtml = (card.snapshots || []).length
? card.snapshots.map(function (snapshot) {
return '' +
'<a class="tag-snapshot-badge" href="' + escapeHtml(snapshot.admin_url) + '" title="' + escapeHtml(snapshot.url) + '">' +
'<img src="' + escapeHtml(snapshot.favicon_url) + '" alt="" onerror="this.hidden=true">' +
'<span>' + escapeHtml(snapshot.title) + '</span>' +
'</a>';
}).join('')
: '<div class="tag-card__empty">No snapshots attached yet.</div>';
return '' +
'<article class="tag-card" data-id="' + escapeHtml(card.id) + '" data-filter-url="' + escapeHtml(card.filter_url) + '" data-rename-url="' + escapeHtml(card.rename_url) + '" data-delete-url="' + escapeHtml(card.delete_url) + '" data-export-urls-url="' + escapeHtml(card.export_urls_url) + '" data-export-jsonl-url="' + escapeHtml(card.export_jsonl_url) + '">' +
'<div class="tag-card__header">' +
'<div class="tag-card__title">' +
'<div class="tag-card__display">' +
'<strong>' + escapeHtml(card.name) + '</strong>' +
'</div>' +
'<div class="tag-card__rename">' +
'<input type="text" value="' + escapeHtml(card.name) + '" aria-label="Rename tag ' + escapeHtml(card.name) + '">' +
'<button type="button" class="tag-chip-button" data-action="save-edit">Save</button>' +
'<button type="button" class="tag-chip-button" data-action="cancel-edit">Cancel</button>' +
'</div>' +
'</div>' +
'<div class="tag-card__actions">' +
'<button type="button" class="tag-chip-button" data-action="edit" aria-label="Rename tag" title="Rename tag">✎</button>' +
'<button type="button" class="tag-chip-button" data-action="copy-urls">Copy URLs</button>' +
'<button type="button" class="tag-chip-button" data-action="download-jsonl">JSONL</button>' +
'<button type="button" class="tag-chip-button is-danger" data-action="delete">Delete</button>' +
'<span class="tag-card__count">' + escapeHtml(card.num_snapshots) + '</span>' +
'</div>' +
'</div>' +
'<div class="tag-card__snapshots">' + snapshotHtml + '</div>' +
'</article>';
}).join('');
}
async function fetchCards(state) {
const params = new URLSearchParams();
if (state.query) params.set('q', state.query);
if (state.sort) params.set('sort', state.sort);
if (state.created_by) params.set('created_by', state.created_by);
if (state.year) params.set('year', state.year);
const url = withApiKey(searchUrl + '?' + params.toString());
const response = await fetch(url, {
headers: buildHeaders(false),
credentials: 'same-origin',
});
if (!response.ok) {
const message = await response.text();
throw new Error(message || 'Failed to load matching tags');
}
const payload = await response.json();
return {
tags: payload.tags || [],
state: {
query: state.query,
sort: payload.sort || state.sort,
created_by: payload.created_by || '',
year: payload.year || '',
},
};
}
async function refreshCards(overrides) {
const requestedState = getCurrentState(overrides);
const result = await fetchCards(requestedState);
syncSearchState(result.state);
renderCards(result.tags, result.state);
syncLocation(result.state);
return result.tags;
}
async function submitJson(url, method, payload) {
const response = await fetch(withApiKey(url), {
method: method,
headers: buildHeaders(true),
credentials: 'same-origin',
body: JSON.stringify(payload || {}),
});
if (!response.ok) {
let message = 'Request failed';
try {
const data = await response.json();
message = data.detail || data.message || message;
} catch (_err) {
message = await response.text() || message;
}
throw new Error(message);
}
if (response.status === 204) return {};
return response.json();
}
async function copyTextFromUrl(url) {
const response = await fetch(withApiKey(url), {
headers: buildHeaders(false),
credentials: 'same-origin',
});
if (!response.ok) throw new Error('Failed to export URLs');
const text = await response.text();
await copyTextToClipboard(text);
return text;
}
async function copyTextToClipboard(text) {
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text);
return;
} catch (_error) {
}
}
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.top = '-9999px';
textarea.style.left = '-9999px';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
const copied = document.execCommand('copy');
document.body.removeChild(textarea);
if (!copied) {
throw new Error('Clipboard write failed');
}
}
function getDownloadFilename(response, fallbackFilename) {
const disposition = response.headers.get('Content-Disposition') || '';
const utf8Match = disposition.match(/filename\\*=UTF-8''([^;]+)/i);
if (utf8Match && utf8Match[1]) {
return decodeURIComponent(utf8Match[1]);
}
const filenameMatch = disposition.match(/filename="?([^";]+)"?/i);
if (filenameMatch && filenameMatch[1]) {
return filenameMatch[1];
}
return fallbackFilename;
}
async function downloadFileFromUrl(url, fallbackFilename) {
const response = await fetch(withApiKey(url), {
headers: buildHeaders(false),
credentials: 'same-origin',
});
if (!response.ok) {
let message = 'Download failed';
try {
const data = await response.json();
message = data.detail || data.message || message;
} catch (_err) {
message = await response.text() || message;
}
throw new Error(message);
}
const blob = await response.blob();
const downloadUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = downloadUrl;
link.download = getDownloadFilename(response, fallbackFilename);
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(function () {
URL.revokeObjectURL(downloadUrl);
}, 1000);
}
createForm?.addEventListener('submit', async function (event) {
event.preventDefault();
const name = (createInput.value || '').trim();
if (!name) {
setToast('Enter a tag name first.', 'error');
createInput.focus();
return;
}
const button = createForm.querySelector('button[type="submit"]');
button.disabled = true;
try {
const result = await submitJson(createUrl, 'POST', { name: name });
createInput.value = '';
await refreshCards({ query: result.tag_name || name });
setToast(result.created ? 'Tag created.' : 'Existing tag loaded.', 'success');
} catch (error) {
setToast(error.message || 'Failed to create tag.', 'error');
} finally {
button.disabled = false;
}
});
searchInput?.addEventListener('input', function () {
window.clearTimeout(searchTimeout);
searchTimeout = window.setTimeout(async function () {
try {
await refreshCards();
} catch (error) {
setToast(error.message || 'Failed to search tags.', 'error');
}
}, 150);
});
[sortSelect, createdBySelect, yearSelect].forEach(function (field) {
field?.addEventListener('change', async function () {
try {
await refreshCards();
} catch (error) {
setToast(error.message || 'Failed to update tag filters.', 'error');
}
});
});
grid.addEventListener('click', async function (event) {
const actionButton = event.target.closest('[data-action]');
const snapshotLink = event.target.closest('.tag-snapshot-badge');
if (snapshotLink) return;
const cardEl = event.target.closest('.tag-card');
if (!cardEl) return;
if (!actionButton) {
window.location.href = cardEl.dataset.filterUrl;
return;
}
event.preventDefault();
event.stopPropagation();
const action = actionButton.dataset.action;
if (action === 'edit') {
cardEl.classList.add('is-editing');
const input = cardEl.querySelector('.tag-card__rename input');
if (input) {
input.focus();
input.select();
}
return;
}
if (action === 'cancel-edit') {
cardEl.classList.remove('is-editing');
return;
}
if (action === 'save-edit') {
const input = cardEl.querySelector('.tag-card__rename input');
const nextName = (input?.value || '').trim();
if (!nextName) {
setToast('Tag name is required.', 'error');
input?.focus();
return;
}
actionButton.disabled = true;
try {
await submitJson(cardEl.dataset.renameUrl, 'POST', { name: nextName });
await refreshCards();
setToast('Tag renamed.', 'success');
} catch (error) {
setToast(error.message || 'Rename failed.', 'error');
} finally {
actionButton.disabled = false;
}
return;
}
if (action === 'delete') {
const tagName = cardEl.querySelector('.tag-card__display strong')?.textContent || 'this tag';
if (!window.confirm('Delete "' + tagName + '"? This only removes the tag and its tag links.')) return;
actionButton.disabled = true;
try {
await fetch(withApiKey(cardEl.dataset.deleteUrl), {
method: 'DELETE',
headers: buildHeaders(false),
credentials: 'same-origin',
}).then(async function (response) {
if (!response.ok) {
let message = 'Delete failed';
try {
const payload = await response.json();
message = payload.detail || message;
} catch (_err) {
message = await response.text() || message;
}
throw new Error(message);
}
});
await refreshCards();
setToast('Tag deleted.', 'success');
} catch (error) {
setToast(error.message || 'Delete failed.', 'error');
} finally {
actionButton.disabled = false;
}
return;
}
if (action === 'copy-urls') {
actionButton.disabled = true;
try {
await copyTextFromUrl(cardEl.dataset.exportUrlsUrl);
} catch (error) {
setToast(error.message || 'Failed to copy URLs.', 'error');
} finally {
actionButton.disabled = false;
}
return;
}
if (action === 'download-jsonl') {
actionButton.disabled = true;
try {
const tagName = cardEl.querySelector('.tag-card__display strong')?.textContent || 'tag';
await downloadFileFromUrl(cardEl.dataset.exportJsonlUrl, 'tag-' + slugify(tagName) + '-snapshots.jsonl');
} catch (error) {
setToast(error.message || 'Failed to download JSONL.', 'error');
} finally {
actionButton.disabled = false;
}
}
});
grid.addEventListener('keydown', function (event) {
if (event.key !== 'Enter') return;
const input = event.target.closest('.tag-card__rename input');
if (!input) return;
event.preventDefault();
const saveButton = input.closest('.tag-card__rename')?.querySelector('[data-action="save-edit"]');
saveButton?.click();
});
const initialState = getCurrentState();
renderCards(cards, initialState);
syncLocation(initialState);
});
</script>
{% endblock %}

View File

@@ -0,0 +1,249 @@
{% extends "admin/change_form.html" %}
{% block bodyclass %}{{ block.super }} app-personas model-persona{% endblock %}
{% block extrastyle %}
{{ block.super }}
<style>
.persona-import-hero {
margin: 0 0 22px;
padding: 22px 24px;
border-radius: 18px;
border: 1px solid #d8dee9;
background:
radial-gradient(circle at top right, rgba(67, 97, 238, 0.10), transparent 32%),
linear-gradient(135deg, #fff7ed 0%, #ffffff 45%, #ecfeff 100%);
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
display: grid;
gap: 18px;
grid-template-columns: minmax(0, 1.8fr) minmax(280px, 1fr);
align-items: start;
}
.persona-import-hero h2 {
margin: 0 0 8px;
font-size: 28px;
line-height: 1.1;
color: #111827;
}
.persona-import-hero p {
margin: 0;
color: #475569;
max-width: 70ch;
font-size: 14px;
}
.persona-import-hero__meta {
display: grid;
gap: 10px;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
}
.persona-import-hero__stat {
padding: 14px 16px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.86);
border: 1px solid rgba(203, 213, 225, 0.85);
}
.persona-import-hero__stat span {
display: block;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
font-weight: 700;
color: #64748b;
margin-bottom: 8px;
}
.persona-import-hero__stat strong,
.persona-import-hero__stat code {
font-size: 18px;
color: #0f172a;
}
.field-import_mode ul,
.field-import_discovered_profile ul {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 12px;
}
.field-import_mode ul {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.field-import_discovered_profile ul {
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
max-height: 460px;
overflow: auto;
padding-right: 4px;
}
.field-import_mode li,
.field-import_discovered_profile li {
margin: 0;
}
.field-import_mode label,
.field-import_discovered_profile label {
display: flex;
gap: 12px;
align-items: flex-start;
min-height: 100%;
padding: 14px 16px;
border-radius: 14px;
border: 1px solid #dbe4ee;
background: #fff;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
cursor: pointer;
transition: transform 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
}
.field-import_mode label:hover,
.field-import_discovered_profile label:hover {
transform: translateY(-1px);
border-color: #7c3aed;
box-shadow: 0 8px 20px rgba(124, 58, 237, 0.10);
}
.field-import_mode input[type="radio"],
.field-import_discovered_profile input[type="radio"] {
margin-top: 3px;
flex: 0 0 auto;
}
.abx-import-mode-option,
.abx-profile-option {
display: grid;
gap: 6px;
}
.abx-import-mode-option strong,
.abx-profile-option strong {
color: #0f172a;
font-size: 15px;
}
.abx-import-mode-option span:last-child,
.abx-profile-option__meta {
color: #64748b;
font-size: 12px;
line-height: 1.5;
}
.abx-profile-option code {
font-size: 11px;
line-height: 1.5;
white-space: normal;
overflow-wrap: anywhere;
color: #334155;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 8px 10px;
}
.abx-persona-path-list,
.abx-persona-artifacts {
display: grid;
gap: 10px;
}
.abx-persona-path-list div,
.abx-persona-artifact {
display: grid;
gap: 6px;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid #e2e8f0;
background: #f8fafc;
}
.abx-persona-path-list code,
.abx-persona-artifact code {
white-space: normal;
overflow-wrap: anywhere;
font-size: 12px;
}
.abx-artifact-state {
display: inline-flex;
width: fit-content;
align-items: center;
border-radius: 999px;
padding: 2px 10px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.abx-artifact-state--yes {
background: #dcfce7;
color: #166534;
}
.abx-artifact-state--no {
background: #fee2e2;
color: #991b1b;
}
@media (max-width: 960px) {
.persona-import-hero {
grid-template-columns: 1fr;
}
}
</style>
{% endblock %}
{% block extrahead %}
{{ block.super }}
<script>
document.addEventListener('DOMContentLoaded', function () {
const modeInputs = Array.from(document.querySelectorAll('input[name="import_mode"]'));
const discoveredRow = document.querySelector('.form-row.field-import_discovered_profile');
const sourceRow = document.querySelector('.form-row.field-import_source');
const profileRow = document.querySelector('.form-row.field-import_profile_name');
const updateVisibility = () => {
const selected = modeInputs.find((input) => input.checked)?.value || 'none';
if (discoveredRow) discoveredRow.style.display = selected === 'discovered' ? '' : 'none';
if (sourceRow) sourceRow.style.display = selected === 'custom' ? '' : 'none';
if (profileRow) profileRow.style.display = selected === 'custom' ? '' : 'none';
};
modeInputs.forEach((input) => input.addEventListener('change', updateVisibility));
updateVisibility();
});
</script>
{% endblock %}
{% block form_top %}
<section class="persona-import-hero">
<div>
<h2>Bootstrap a persona from a real browser session</h2>
<p>
Pick a local Chromium profile, paste an absolute profile path, or attach to a live CDP endpoint.
The form saves the Persona normally, then imports profile files, cookies, and optional tab storage into
the Persona's own directories.
</p>
</div>
<div class="persona-import-hero__meta">
<div class="persona-import-hero__stat">
<span>Detected profiles</span>
<strong>{{ detected_profile_count }}</strong>
</div>
<div class="persona-import-hero__stat">
<span>Persona artifacts</span>
<code>chrome_user_data</code>
<code>cookies.txt</code>
<code>auth.json</code>
</div>
</div>
</section>
{{ block.super }}
{% endblock %}

View File

@@ -706,14 +706,14 @@
? Math.max(0, Math.min(100, extractor.progress))
: null;
const progressStyle = progress !== null ? ` style="width: ${progress}%;"` : '';
const pidHtml = extractor.pid ? `<span class="pid-label compact">pid ${extractor.pid}</span>` : '';
const pidHtml = extractor.status === 'started' && extractor.pid ? `<span class="pid-label compact">pid ${extractor.pid}</span>` : '';
return `
<span class="extractor-badge ${extractor.status || 'queued'}">
<span class="progress-fill"${progressStyle}></span>
<span class="badge-content">
<span class="badge-icon">${icon}</span>
<span>${extractor.plugin || 'unknown'}</span>
<span>${extractor.label || extractor.plugin || 'unknown'}</span>
${pidHtml}
</span>
</span>
@@ -742,6 +742,23 @@
`;
}
const hasProcessEntries = (snapshot.all_plugins || []).some(extractor => extractor.source === 'process');
const hasArchiveResults = (snapshot.all_plugins || []).some(extractor => extractor.source === 'archiveresult');
const processOnly = hasProcessEntries && !hasArchiveResults;
const runningProcessCount = (snapshot.all_plugins || []).filter(extractor => extractor.source === 'process' && extractor.status === 'started').length;
const failedProcessCount = (snapshot.all_plugins || []).filter(extractor => extractor.source === 'process' && extractor.status === 'failed').length;
const snapshotMeta = (snapshot.total_plugins || 0) > 0
? processOnly
? runningProcessCount > 0
? `Running ${runningProcessCount}/${snapshot.total_plugins || 0} setup hooks`
: failedProcessCount > 0
? `${failedProcessCount} setup hook${failedProcessCount === 1 ? '' : 's'} failed`
: `${snapshot.completed_plugins || 0}/${snapshot.total_plugins || 0} setup hooks`
: hasProcessEntries
? `${snapshot.completed_plugins || 0}/${snapshot.total_plugins || 0} tasks${(snapshot.failed_plugins || 0) > 0 ? ` <span style="color:#f85149">(${snapshot.failed_plugins} failed)</span>` : ''}${runningProcessCount > 0 ? ` <span style="color:#d29922">(${runningProcessCount} hooks running)</span>` : ''}`
: `${snapshot.completed_plugins || 0}/${snapshot.total_plugins || 0} extractors${(snapshot.failed_plugins || 0) > 0 ? ` <span style="color:#f85149">(${snapshot.failed_plugins} failed)</span>` : ''}`
: 'Waiting for extractors...';
return `
<div class="snapshot-item">
<div class="snapshot-header">
@@ -750,9 +767,7 @@
<div class="snapshot-info">
<div class="snapshot-url">${formatUrl(snapshot.url)}</div>
<div class="snapshot-meta">
${(snapshot.total_plugins || 0) > 0
? `${snapshot.completed_plugins || 0}/${snapshot.total_plugins || 0} extractors${(snapshot.failed_plugins || 0) > 0 ? ` <span style="color:#f85149">(${snapshot.failed_plugins} failed)</span>` : ''}`
: 'Waiting for extractors...'}
${snapshotMeta}
</div>
</div>
${snapshotPidHtml}
@@ -762,7 +777,7 @@
</div>
<div class="snapshot-progress">
<div class="progress-bar-container">
<div class="progress-bar snapshot ${snapshot.status === 'started' && (snapshot.progress || 0) === 0 ? 'indeterminate' : ''}"
<div class="progress-bar snapshot ${((processOnly && runningProcessCount > 0) || (snapshot.status === 'started' && (snapshot.progress || 0) === 0)) ? 'indeterminate' : ''}"
style="width: ${snapshot.progress || 0}%"></div>
</div>
</div>
@@ -784,6 +799,29 @@
if (crawl.active_snapshots && crawl.active_snapshots.length > 0) {
snapshotsHtml = crawl.active_snapshots.map(s => renderSnapshot(s, crawl.id)).join('');
}
let setupHtml = '';
if (crawl.setup_plugins && crawl.setup_plugins.length > 0) {
const setupSummary = `${crawl.setup_completed_plugins || 0}/${crawl.setup_total_plugins || 0} setup tasks${(crawl.setup_failed_plugins || 0) > 0 ? ` <span style="color:#f85149">(${crawl.setup_failed_plugins} failed)</span>` : ''}`;
const sortedSetup = [...crawl.setup_plugins].sort((a, b) =>
(a.plugin || '').localeCompare(b.plugin || '')
);
setupHtml = `
<div class="snapshot-item">
<div class="snapshot-header">
<div class="snapshot-header-link">
<span class="snapshot-icon">&#9881;</span>
<div class="snapshot-info">
<div class="snapshot-url">Crawl Setup</div>
<div class="snapshot-meta">${setupSummary}</div>
</div>
</div>
</div>
<div class="extractor-list">
${sortedSetup.map(e => renderExtractor(e)).join('')}
</div>
</div>
`;
}
// Show warning if crawl is stuck (queued but can't start)
let warningHtml = '';
@@ -847,6 +885,7 @@
${warningHtml}
<div class="crawl-body">
<div class="snapshot-list">
${setupHtml}
${snapshotsHtml}
</div>
</div>