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>

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
{% load static tz admin_urls %}
{% load static tz admin_urls core_tags %}
<!DOCTYPE html>
<html lang="en">
@@ -9,6 +9,10 @@
<link rel="stylesheet" href="{% static 'admin/css/base.css' %}">
<link rel="stylesheet" href="{% static 'admin.css' %}">
<link rel="stylesheet" href="{% static 'bootstrap.min.css' %}">
{% api_token as api_token %}
<script>
window.ARCHIVEBOX_API_KEY = "{{ api_token|escapejs }}";
</script>
<script src="{% static 'jquery.min.js' %}"></script>
{% block extra_head %}

View File

@@ -6,7 +6,7 @@
<a href="/admin/core/tag/">Tags</a> |
<a href="/admin/core/archiveresult/?o=-1">Log</a> &nbsp; &nbsp;
<a href="{% url 'Docs' %}" target="_blank" rel="noopener noreferrer">Docs</a> |
<a href="/api">API</a> |
<a href="/api/v1/docs">API</a> |
<a href="{% url 'public-index' %}">Public</a> |
<a href="/admin/">Admin</a>
&nbsp; &nbsp;

View File

@@ -456,6 +456,9 @@
text-overflow: ellipsis;
white-space: nowrap;
}
.thumb-card:has([data-compact]) .card-text {
display: none;
}
.thumb-card:has([data-compact]) .thumbnail-text-header,
.thumb-card:has([data-compact]) .thumbnail-compact-icon,
.thumb-card:has([data-compact]) .thumbnail-compact-label {
@@ -620,8 +623,9 @@
<div class="header-top container-fluid">
<div class="row nav">
<div class="col-lg-2" style="line-height: 50px; vertical-align: middle">
<a href="../../index.html" class="header-archivebox" title="Go to Main Index...">
<img src="/static/archive.png" alt="Archive Icon">
{% public_base_url as public_base %}
<a href="{% if public_base %}{{ public_base }}/public/{% else %}/{% endif %}" class="header-archivebox" title="Go to Public Index...">
<img src="{% if public_base %}{{ public_base }}/static/archive.png{% else %}/static/archive.png{% endif %}" alt="Archive Icon">
ArchiveBox
</a>
</div>
@@ -683,12 +687,10 @@
<div class="info-chunk">
<h5>🗃&nbsp; Snapshot: <a href="{% admin_base_url %}/admin/core/snapshot/{{snapshot_id|default:id}}/change/"><code style="color: rgba(255,255,255,0.6); font-weight: 200; font-size: 12px; background-color: #1a1a1a"><b>[{{timestamp}}]</b> <small>{{snapshot_id|default:id|truncatechars:24}}</small></code></a></h5>
<a href="{% snapshot_url snapshot 'index.json' %}" title="JSON summary of archived link.">JSON</a> |
<a href="{% snapshot_url snapshot 'warc/' %}" title="Any WARC archives for the page">WARC</a> |
<a href="{% snapshot_url snapshot 'media/' %}" title="Audio, Video, and Subtitle files.">Media</a> |
<a href="{% snapshot_url snapshot 'git/' %}" title="Any git repos at the url">Git</a> |
<a href="{% snapshot_base_url snapshot %}/?files=1" title="Browse the full SNAP_DIR for this snapshot">See all files...</a> |
<a href="{% admin_base_url %}/admin/core/snapshot/?q={{snapshot_id|default:id}}" title="Go to the Snapshot admin to update, overwrite, or delete this Snapshot">Actions</a> |
<a href="{% admin_base_url %}/admin/core/snapshot/{{snapshot_id|default:id}}/change/" title="Edit this snapshot in the Admin UI">Admin</a> |
<a href="{% snapshot_base_url snapshot %}/?files=1" title="Webserver-provided index of files directory.">See all files...</a><br/>
<a href="https://web.archive.org/web/{{url}}" title="Search for a copy of the URL saved in Archive.org" target="_blank" rel="noreferrer">Archive.org</a><br/>
</div>
</div>
</div>
@@ -713,12 +715,12 @@
<a href="{{display_url}}" data-no-preview="1" title="Download output file" download>⬇️</a>
{% endif %}
</div>
<a href="{{ display_url }}" target="preview">
<h4 class="card-title">{% plugin_icon result_info.name %} {{ result_info.name|plugin_name|truncatechars:20 }}</h4>
</a>
<a href="{{ display_url }}" title="Open in new tab..." target="_blank" rel="noopener">
<p class="card-text"><code>{{ result_info.path }}</code></p>
</a>
<a href="{{ display_url }}" target="preview">
<h4 class="card-title">{{ result_info.name|title }}</h4>
</a>
{% if result_info.result %}
{% with plugin_base=result_info.name|plugin_name %}
{% if plugin_base == 'ytdlp' or plugin_base == 'yt-dlp' or plugin_base == 'youtube-dl' %}

View File

@@ -902,9 +902,9 @@
<div class="header-top">
<div class="header-nav">
<div class="header-col header-left" style="line-height: 58px; vertical-align: middle">
<a href="/" class="header-archivebox" title="Go to Main Index...">
{% web_base_url as web_base %}
<img src="{% if web_base %}//{{ web_base|cut:'http://'|cut:'https://' }}/static/archive.png{% else %}{% static 'archive.png' %}{% endif %}" alt="Archive Icon">
{% public_base_url as public_base %}
<a href="{% if public_base %}{{ public_base }}/public/{% else %}/{% endif %}" class="header-archivebox" title="Go to Public Index...">
<img src="{% if public_base %}{{ public_base }}/static/archive.png{% else %}{% static 'archive.png' %}{% endif %}" alt="Archive Icon">
ArchiveBox
</a>
</div>
@@ -996,8 +996,7 @@
<br/>
<div class="external-links">
📁 &nbsp;
<a href="{% snapshot_base_url snapshot %}/?files=1" title="Browse files for this snapshot" target="_blank">FILES</a> &nbsp;|&nbsp; 🗃️
<a href="{% snapshot_url snapshot warc_path %}" title="Download the ArchiveBox-generated WARC file" target="_blank">WARC</a> &nbsp;|&nbsp;
<a href="{% snapshot_base_url snapshot %}/?files=1" title="Browse the full SNAP_DIR for this snapshot" target="_blank">See all files...</a> &nbsp;|&nbsp;
<a href="https://web.archive.org/web/{{url}}" title="Search for a copy of the URL saved in Archive.org" target="_blank" rel="noreferrer">🏛️ Archive.org</a>
<!--<a href="https://archive.md/{{url}}" title="Search for a copy of the URL saved in Archive.today" target="_blank" rel="noreferrer">Archive.today</a> &nbsp;|&nbsp; -->
<!--<a href="https://ghostarchive.org/search?term={{url}}" title="Search for a copy of the URL saved in GhostArchive.org" target="_blank" rel="noreferrer">More...</a>-->
@@ -1010,7 +1009,7 @@
{% for result in archiveresults %}
{% with display_path=result.path|default:result.result.embed_path display_url='' %}
{% with display_path=result.path display_url='' %}
{% if display_path %}{% snapshot_url snapshot display_path as display_url %}{% endif %}
<div class="thumb-card{% if forloop.first %} selected-card{% endif %}"{% if display_url %} data-preview-url="{{display_url}}"{% endif %}>
<div class="thumb-body">

View File

@@ -78,6 +78,7 @@ textarea, select, input[type="text"] {
box-shadow: 4px 4px 4px rgba(0,0,0,0.02);
width: 100%;
padding: 8px 12px;
font-family: inherit;
font-size: 14px;
}
@@ -85,6 +86,10 @@ textarea {
min-height: 300px;
}
input[type="text"] {
min-height: 42px;
}
textarea[rows="3"] {
min-height: 80px;
}
@@ -153,6 +158,13 @@ select {
margin-bottom: 20px;
}
.settings-row {
display: grid;
grid-template-columns: minmax(260px, 340px) minmax(420px, 1fr);
gap: 18px;
align-items: start;
}
.form-field label {
display: block;
font-size: 16px;
@@ -160,6 +172,234 @@ select {
margin-bottom: 8px;
}
.field-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.field-header label {
margin-bottom: 0;
}
.url-workbench {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
gap: 18px;
align-items: start;
}
.url-editor-column {
min-width: 0;
}
.url-editor-shell {
position: relative;
}
.url-editor-shell textarea[name="url"] {
position: relative;
z-index: 2;
background: transparent;
color: #1f2937;
-webkit-text-fill-color: #1f2937;
caret-color: #1f2937;
min-height: 240px;
height: 240px;
line-height: 1.5;
resize: vertical;
}
.url-editor-shell textarea[name="url"]::selection {
background: rgba(0, 72, 130, 0.18);
}
.url-highlight-layer {
position: absolute;
inset: 2px;
z-index: 1;
margin: 0;
padding: 8px 12px;
overflow: auto;
pointer-events: none;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
font-family: inherit;
font-size: 14px;
line-height: 1.5;
color: transparent;
background: transparent;
border-radius: 2px;
scrollbar-width: none;
}
.url-highlight-layer::-webkit-scrollbar {
display: none;
}
.url-highlight-segment {
border-radius: 3px;
}
.detected-urls-panel {
display: flex;
flex-direction: column;
min-height: 240px;
padding: 12px 14px;
background: linear-gradient(180deg, #fff 0%, #f6f8fb 100%);
border: 1px solid #d7e2eb;
border-radius: 8px;
overflow: hidden;
}
.detected-urls-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.detected-urls-summary {
font-size: 12px;
color: #5f6c78;
}
.detected-urls-list {
flex: 1;
min-height: 0;
display: grid;
align-content: start;
gap: 8px;
overflow: auto;
padding-right: 4px;
}
.detected-urls-empty {
padding: 8px 0;
color: #6b7280;
font-size: 13px;
line-height: 1.5;
}
.detected-url-item {
display: grid;
gap: 8px;
padding: 10px 12px;
border-left: 4px solid var(--detected-url-border, #d0d7de);
border-radius: 6px;
background: linear-gradient(90deg, var(--detected-url-bg, rgba(0, 0, 0, 0.03)), rgba(255, 255, 255, 0.96) 28%);
}
.detected-url-topline {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.detected-url-controls {
display: flex;
flex-wrap: nowrap;
gap: 6px;
min-width: 0;
}
.detected-url-number {
width: 20px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
background: rgba(15, 23, 42, 0.08);
color: #24303b;
font-size: 10px;
font-weight: 700;
}
.detected-url-body {
min-width: 0;
}
.detected-url-value {
display: block;
font-size: 12px;
line-height: 1.45;
color: #1f2937;
overflow-wrap: anywhere;
}
.detected-url-toggle-btn {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px 8px;
min-height: 24px;
border: 1px solid rgba(148, 163, 184, 0.4);
border-radius: 999px;
background: rgba(148, 163, 184, 0.12);
color: #64748b;
font-size: 11px;
font-weight: 700;
line-height: 1;
white-space: nowrap;
transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease;
cursor: pointer;
}
.detected-url-toggle-btn:hover {
background: rgba(15, 23, 42, 0.08);
}
.detected-url-toggle-btn-inactive:hover {
border-color: rgba(180, 35, 24, 0.28);
background: rgba(180, 35, 24, 0.10);
color: #b42318;
}
.detected-url-toggle-btn-active:hover {
border-color: rgba(22, 101, 52, 0.28);
background: rgba(22, 101, 52, 0.10);
color: #166534;
}
.detected-url-toggle-btn-disabled,
.detected-url-toggle-btn-disabled:hover {
border-color: rgba(203, 213, 225, 0.55);
background: rgba(226, 232, 240, 0.45);
color: #94a3b8;
cursor: not-allowed;
}
.detected-url-message {
margin-top: 4px;
font-size: 11px;
color: #617080;
line-height: 1.45;
}
.detected-url-allowlisted .detected-url-value {
color: #166534;
}
.detected-url-denied .detected-url-value {
color: #b42318;
text-decoration: line-through;
text-decoration-thickness: 1.5px;
}
.detected-url-denied .detected-url-message {
color: #b42318;
}
.detected-url-filtered .detected-url-value {
color: #6b7280;
}
.form-field .help-text {
font-size: 12px;
color: #666;
@@ -173,7 +413,137 @@ select {
margin-top: 4px;
}
/* Checkbox fields (for overwrite, update, index_only) */
.tag-editor-container {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
padding: 8px 12px;
min-height: 44px;
background: #fff;
border: 2px solid #004882;
border-radius: 4px;
box-shadow: 4px 4px 4px rgba(0,0,0,0.02);
cursor: text;
}
.tag-editor-container:focus-within {
border-color: #2c7ec1;
}
.tag-pills {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.tag-pill {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px 4px 10px;
background: var(--tag-bg, #e2e8f0);
color: var(--tag-fg, #1e293b);
border-radius: 16px;
border: 1px solid var(--tag-border, #cbd5e1);
font-size: 13px;
font-weight: 500;
}
.tag-remove-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
padding: 0;
margin: 0;
border: 1px solid rgba(15, 23, 42, 0.12);
border-radius: 50%;
background: rgba(15, 23, 42, 0.08);
color: inherit;
font-size: 14px;
line-height: 1;
cursor: pointer;
}
.tag-inline-input {
flex: 1;
min-width: 120px;
padding: 4px 0;
border: none !important;
box-shadow: none !important;
outline: none;
background: transparent;
}
.tag-inline-input::placeholder {
color: #7c8b98;
}
.url-filters-widget textarea {
min-height: 58px;
font-family: monospace;
font-size: 13px;
}
.url-filters-field > label {
display: none;
}
.url-filters-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.url-filter-label-row {
display: flex;
align-items: baseline;
flex-wrap: nowrap;
gap: 10px;
width: 100%;
margin-bottom: 6px;
}
.url-filters-column .url-filter-label {
display: block;
font-size: 14px;
margin-bottom: 0;
}
.url-filter-label-main {
font-weight: 600;
white-space: nowrap;
}
.url-filter-label-note {
display: inline-block;
flex: 0 0 auto;
margin-left: auto;
font-size: 12px;
color: #7a7a7a;
font-weight: 400;
font-style: italic;
text-align: right;
white-space: nowrap;
}
.url-filters-toggle {
display: inline-flex !important;
align-items: center;
gap: 8px;
margin-top: 10px;
font-size: 14px !important;
font-weight: 600;
}
.url-filters-toggle input[type="checkbox"] {
width: auto;
margin: 0;
}
.checkbox-field {
display: flex;
align-items: center;
@@ -193,7 +563,6 @@ select {
/* URL Counter */
.url-counter {
display: inline-block;
margin-top: 8px;
padding: 4px 10px;
font-size: 13px;
font-weight: 600;
@@ -209,13 +578,27 @@ select {
border-color: #c3e6cb;
}
@media (max-width: 1020px) {
.settings-row {
grid-template-columns: 1fr;
}
.url-workbench {
grid-template-columns: 1fr;
}
.url-filters-grid {
grid-template-columns: 1fr;
}
}
/* Plugin Presets */
.plugin-presets {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 20px;
margin-bottom: 18px;
padding: 15px;
background-color: #f8f9fa;
border: 1px solid #dee2e6;
@@ -254,11 +637,18 @@ select {
/* Plugin groups */
.plugin-group {
margin-bottom: 20px;
padding: 15px;
padding: 14px 16px;
background-color: white;
border: 1px solid #ddd;
border-radius: 6px;
min-width: 0;
}
.plugin-groups-grid {
display: grid;
grid-template-columns: repeat(2, minmax(280px, 1fr));
gap: 16px;
align-items: start;
}
.plugin-group-header {
@@ -268,6 +658,7 @@ select {
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 2px solid #004882;
gap: 12px;
}
.plugin-group-header label {
@@ -277,6 +668,12 @@ select {
margin: 0;
}
.plugin-group-note {
font-size: 12px;
color: #7a7a7a;
white-space: nowrap;
}
.select-all-btn {
padding: 4px 12px;
font-size: 12px;
@@ -293,42 +690,105 @@ select {
.plugin-checkboxes {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 8px;
grid-template-columns: 1fr;
gap: 6px;
}
.plugin-checkboxes ul {
list-style-type: none;
padding: 0;
margin: 0;
display: contents;
.plugin-checkboxes > div {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px 10px;
}
.plugin-checkboxes li {
.plugin-checkboxes > div > div {
display: flex;
align-items: center;
gap: 8px;
padding: 6px;
padding: 6px 8px;
border: 1px solid #e3e8ef;
background-color: #fff;
border-radius: 4px;
transition: background-color 0.2s;
}
.plugin-checkboxes li:hover {
.plugin-checkboxes > div > div:hover {
background-color: #f5f5f5;
}
.plugin-checkboxes input[type="checkbox"] {
grid-column: 2;
grid-row: 1 / span 2;
margin: 0;
margin-top: 2px;
width: auto;
flex: 0 0 auto;
}
.plugin-checkboxes label {
#add-form .plugin-checkboxes label {
display: grid !important;
grid-template-columns: 18px 16px minmax(0, 1fr);
column-gap: 8px;
row-gap: 3px;
align-items: start;
width: 100%;
margin: 0;
font-size: 14px;
font-weight: normal;
cursor: pointer;
}
.plugin-choice-name {
grid-column: 3;
grid-row: 1;
font-weight: 500;
color: #1f2937;
}
#add-form .plugin-choice-icon {
grid-column: 1;
grid-row: 1 / span 2;
display: inline-flex;
align-items: center;
justify-content: center;
color: #7a7a7a;
flex: 0 0 auto;
}
#add-form .plugin-choice-icon .abx-output-icon {
display: inline-flex;
align-items: center;
justify-content: center;
}
#add-form .plugin-choice-icon svg {
width: 18px;
height: 18px;
}
#add-form .plugin-choice-description {
grid-column: 3;
grid-row: 2;
margin-left: 0;
display: inline-block;
font-size: 12px;
color: #7a7a7a !important;
text-decoration: none !important;
text-align: left;
}
#add-form .plugin-checkboxes label a.plugin-choice-description:link,
#add-form .plugin-checkboxes label a.plugin-choice-description:visited,
#add-form .plugin-checkboxes label a.plugin-choice-description:active {
color: #7a7a7a !important;
text-decoration: none !important;
}
#add-form .plugin-checkboxes label a.plugin-choice-description:hover,
#add-form .plugin-checkboxes label a.plugin-choice-description:focus {
color: #4b5563 !important;
text-decoration: underline !important;
}
/* Advanced section (collapsible) */
.advanced-section {
background-color: white;
@@ -388,6 +848,14 @@ input:focus, select:focus, textarea:focus, button:focus {
grid-template-columns: 1fr;
}
.plugin-groups-grid {
grid-template-columns: 1fr;
}
.plugin-checkboxes > div {
grid-template-columns: 1fr;
}
.plugin-group-header {
flex-direction: column;
align-items: flex-start;

View File

@@ -477,6 +477,10 @@ body.model-snapshot.change-list #content .object-tools {
max-width: 220px;
}
#content td.field-tags_inline .tag-editor-inline.readonly {
padding-right: 0;
}
#content th.field-tags_inline,
#content td.field-tags_inline {
max-width: 220px;
@@ -610,6 +614,56 @@ body.model-snapshot.change-list #content .object-tools {
border-radius: 4px;
}
body.model-archiveresult.change-list #result_list td.field-cmd_str {
width: 300px !important;
max-width: 300px !important;
min-width: 300px !important;
}
body.model-archiveresult.change-list #result_list td.field-cmd_str > div,
body.model-archiveresult.change-list #result_list td.field-cmd_str code {
max-width: 300px !important;
}
body.model-archiveresult.change-list #result_list {
table-layout: fixed;
width: 100%;
}
body.model-archiveresult.change-list #result_list th.column-cmd_str,
body.model-archiveresult.change-list #result_list td.field-cmd_str {
width: 300px !important;
max-width: 300px !important;
min-width: 300px !important;
overflow: hidden !important;
box-sizing: border-box;
}
body.model-archiveresult.change-list #result_list th.column-process_link,
body.model-archiveresult.change-list #result_list td.field-process_link {
width: 72px;
white-space: nowrap;
}
body.model-archiveresult.change-list #result_list th.column-machine_link,
body.model-archiveresult.change-list #result_list td.field-machine_link {
width: 180px;
}
body.model-archiveresult.change-list #result_list td.field-snapshot_info a {
display: block;
overflow: hidden;
text-overflow: ellipsis;
}
body.model-archiveresult.change-list #result_list td.field-cmd_str > div,
body.model-archiveresult.change-list #result_list td.field-cmd_str code {
width: 300px !important;
min-width: 300px !important;
max-width: 300px !important;
box-sizing: border-box;
}
body.filters-collapsed #content #changelist-filter {
display: none !important;
}
@@ -637,10 +691,49 @@ body.filters-collapsed .filtered div.xfull {
font-variant: small-caps;
}
#result_list tbody td.field-status {
#result_list tbody td.field-status,
#result_list tbody td.field-status_badge {
font-variant: small-caps;
}
body.model-archiveresult.filters-collapsed.change-list #changelist .changelist-form-container {
gap: 0 !important;
}
body.model-archiveresult.filters-collapsed.change-list #changelist .changelist-form-container > div,
body.model-archiveresult.filters-collapsed.change-list #changelist .results,
body.model-archiveresult.filters-collapsed.change-list #changelist .paginator,
body.model-archiveresult.filters-collapsed.change-list #changelist #toolbar,
body.model-archiveresult.filters-collapsed.change-list #changelist #changelist-form,
body.model-archiveresult.filters-collapsed.change-list #changelist #result_list {
width: 100% !important;
max-width: 100% !important;
margin-right: 0 !important;
}
body.model-archiveresult.change-list #result_list tbody tr {
transition: background-color 0.15s ease, opacity 0.15s ease;
}
body.model-archiveresult.change-list #result_list tbody tr:has(td.field-status_badge .status-badge.started),
body.model-archiveresult.change-list #result_list tbody tr:has(td.field-status_badge .status-badge.backoff) {
background: rgba(251, 191, 36, 0.14);
}
body.model-archiveresult.change-list #result_list tbody tr:has(td.field-status_badge .status-badge.failed) {
background: rgba(239, 68, 68, 0.12);
}
body.model-archiveresult.change-list #result_list tbody tr:has(td.field-status_badge .status-badge.succeeded) {
background: rgba(34, 197, 94, 0.11);
}
body.model-archiveresult.change-list #result_list tbody tr:has(td.field-status_badge .status-badge.skipped),
body.model-archiveresult.change-list #result_list tbody tr:has(td.field-status_badge .status-badge.noresults) {
background: rgba(148, 163, 184, 0.10);
opacity: 0.82;
}
.inline-group .tabular td.original p {
margin-top: -28px;
}
@@ -697,6 +790,7 @@ tbody .output-link:hover {opacity: 1;}
.status-badge.failed { background: #fee2e2; color: #ef4444; }
.status-badge.backoff { background: #fef3c7; color: #f59e0b; }
.status-badge.skipped { background: #f3f4f6; color: #6b7280; }
.status-badge.noresults { background: #f1f5f9; color: #64748b; }
/* Progress Bar */
.snapshot-progress-bar {