mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-21 18:55:36 +02:00
feat(ui): rewrite frontend in React with Vite
- Scaffold Vite+React project in frontend/ (bun, react 18, @vitejs/plugin-react) - vite.config.js outputs to crates/archivr-server/static/ directly - src/utils.js: formatBytes, valueText, formatTimestamp, SOURCE_ICONS, sourceIconSvg - src/api.js: typed fetch wrappers for all API endpoints - App.jsx: full state management, archive switching, debounced search, tag filter, view routing, capture dialog orchestration - components/Topbar.jsx: archive switcher, nav, capture button - components/CaptureDialog.jsx: native <dialog> ref with showModal/close, Escape key support, locator validation - components/EntriesView.jsx + EntryRow.jsx: flex table with source icons, type pills, keyboard selection - components/ContextRail.jsx: parallel detail+tags fetch, stale-race guard via selectSeqRef, inline tag assign/remove - components/RunsView.jsx: runs kept as <table> (matches original) - components/AdminView.jsx: mounted archives list - components/TagsView.jsx: recursive tag tree with active state - Remove old app.js; styles.css moved to frontend/src/ (Vite bundles it)
This commit is contained in:
parent
b895d6331c
commit
4458f17b13
22 changed files with 1035 additions and 591 deletions
|
|
@ -1,505 +0,0 @@
|
|||
const state = {
|
||||
archives: [],
|
||||
archiveId: null,
|
||||
entries: [],
|
||||
selectedEntryUid: null,
|
||||
selectedEntry: null,
|
||||
tagFilter: null,
|
||||
};
|
||||
let selectSeq = 0;
|
||||
|
||||
const archiveSwitcher = document.querySelector("#archive-switcher");
|
||||
const entriesBody = document.querySelector("#entries-body");
|
||||
const runsBody = document.querySelector("#runs-body");
|
||||
const contextBody = document.querySelector("#context-body");
|
||||
const navButtons = document.querySelectorAll(".nav-link");
|
||||
const searchInput = document.querySelector("#search");
|
||||
const resultCount = document.querySelector("#result-count");
|
||||
const adminArchives = document.querySelector("#admin-archives");
|
||||
const tagTree = document.querySelector("#tag-tree");
|
||||
const entryTagsEl = document.querySelector("#entry-tags");
|
||||
const assignTagForm = document.querySelector("#assign-tag-form");
|
||||
const assignTagInput = document.querySelector("#assign-tag-input");
|
||||
const assignTagBtn = document.querySelector("#assign-tag-btn");
|
||||
const captureButton = document.querySelector('.capture-button');
|
||||
const captureDialog = document.querySelector('#capture-dialog');
|
||||
const captureLocatorInput = document.querySelector('#capture-locator');
|
||||
const captureSubmitBtn = document.querySelector('#capture-submit-btn');
|
||||
const captureCancelBtn = document.querySelector('#capture-cancel-btn');
|
||||
const captureError = document.querySelector('#capture-error');
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (!bytes) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let size = bytes;
|
||||
let unit = 0;
|
||||
while (size >= 1024 && unit < units.length - 1) {
|
||||
size /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
return `${size.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
function valueText(value) {
|
||||
return value ?? "";
|
||||
}
|
||||
|
||||
const SOURCE_ICONS = {
|
||||
youtube: `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path fill="#FF0000" d="M23.5 6.2a3 3 0 0 0-2.1-2.1C19.5 3.6 12 3.6 12 3.6s-7.5 0-9.4.5A3 3 0 0 0 .5 6.2C0 8.1 0 12 0 12s0 3.9.5 5.8a3 3 0 0 0 2.1 2.1c1.9.5 9.4.5 9.4.5s7.5 0 9.4-.5a3 3 0 0 0 2.1-2.1C24 15.9 24 12 24 12s0-3.9-.5-5.8z"/><polygon fill="#fff" points="9.6,15.6 15.8,12 9.6,8.4"/></svg>`,
|
||||
x: `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M18.2 2h3.3l-7.2 8.2L23 22h-6.6l-5.2-6.8L5 22H1.7l7.7-8.8L1 2h6.8l4.7 6.2zm-1.1 18h1.8L6.9 3.9H5z"/></svg>`,
|
||||
instagram: `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="ig" x1="0%" y1="100%" x2="100%" y2="0%"><stop offset="0%" stop-color="#f09433"/><stop offset="25%" stop-color="#e6683c"/><stop offset="50%" stop-color="#dc2743"/><stop offset="75%" stop-color="#cc2366"/><stop offset="100%" stop-color="#bc1888"/></linearGradient></defs><rect width="24" height="24" rx="5" fill="url(#ig)"/><rect x="2.5" y="2.5" width="19" height="19" rx="4" fill="none" stroke="#fff" stroke-width="1.5"/><circle cx="12" cy="12" r="4" fill="none" stroke="#fff" stroke-width="1.5"/><circle cx="17.5" cy="6.5" r="1" fill="#fff"/></svg>`,
|
||||
facebook: `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><rect width="24" height="24" rx="4" fill="#1877F2"/><path fill="#fff" d="M16 8h-2c-.6 0-1 .4-1 1v2h3l-.4 3H13v8h-3v-8H8v-3h2V9a4 4 0 0 1 4-4h2v3z"/></svg>`,
|
||||
tiktok: `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M19.6 5.4a4.8 4.8 0 0 1-4.8-4.8h-3v14.7a2 2 0 1 1-2-2l.1-3a5 5 0 1 0 4.9 5V8.4a8 8 0 0 0 4.8 1.6V6.7a4.8 4.8 0 0 1-0-.2l0 0 .1.9z" fill="#000"/><path d="M18.6 4.4a4.8 4.8 0 0 1-4.8-4.8h-3v14.7a2 2 0 1 1-2-2l.1-3a5 5 0 1 0 4.9 5V7.4a8 8 0 0 0 4.8 1.6V5.7" fill="none" stroke="#69C9D0" stroke-width="1.5"/></svg>`,
|
||||
reddit: `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><circle cx="12" cy="12" r="12" fill="#FF4500"/><path fill="#fff" d="M20 12a2 2 0 0 0-2-2 2 2 0 0 0-1.3.5c-1.3-.9-3-.9-4.6-.9l.8-3.7 2.6.5a1.4 1.4 0 1 0 1.4-1.3 1.4 1.4 0 0 0-1.3.8l-2.9-.5a.3.3 0 0 0-.4.3l-.9 4.1c-1.6 0-3.1.1-4.3.9A2 2 0 0 0 4 12a2 2 0 0 0 1 1.7 3.3 3.3 0 0 0 0 .5c0 2.6 3 4.8 6.8 4.8s6.8-2.1 6.8-4.8a3.3 3.3 0 0 0 0-.5A2 2 0 0 0 20 12zm-13.6 1a1.1 1.1 0 1 1 1.1 1.1A1.1 1.1 0 0 1 6.4 13zm6.2 3.1a3.5 3.5 0 0 1-2.3.7 3.5 3.5 0 0 1-2.3-.7.3.3 0 0 1 .4-.4 3 3 0 0 0 1.9.5 3 3 0 0 0 1.9-.5.3.3 0 0 1 .4.4zm-.3-2a1.1 1.1 0 1 1 1.1-1.1A1.1 1.1 0 0 1 12.3 14.1z"/></svg>`,
|
||||
snapchat: `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><rect width="24" height="24" rx="4" fill="#FFFC00"/><path d="M12 3.5c-2.4 0-4.4 2-4.4 4.4v1.3l-.8.1c-.3 0-.5.2-.5.5s.3.5.6.6c.3.1.5.3.5.8 0 .3-.1.5-.3.7-.6.8-1.5 1.3-2.2 1.4-.1.7.7 1 1.7 1.2.1.6.3.8.8.8.6 0 1.1.4 2.7.4 1.5 0 2.1-.4 2.7-.4.5 0 .7-.2.8-.8 1-.2 1.8-.5 1.7-1.2-.7-.1-1.6-.6-2.2-1.4-.2-.2-.3-.4-.3-.7 0-.5.2-.7.5-.8.3-.1.6-.3.6-.6s-.2-.5-.5-.5l-.8-.1V7.9C16.4 5.5 14.4 3.5 12 3.5z"/></svg>`,
|
||||
local: `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path fill="none" stroke="#666" stroke-width="1.5" stroke-linejoin="round" d="M4 4h7l2 2h7v14H4z"/></svg>`,
|
||||
web: `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><circle cx="12" cy="12" r="10" fill="none" stroke="#555" stroke-width="1.5"/><ellipse cx="12" cy="12" rx="4" ry="10" fill="none" stroke="#555" stroke-width="1.5"/><line x1="2" y1="9" x2="22" y2="9" stroke="#555" stroke-width="1.5"/><line x1="2" y1="15" x2="22" y2="15" stroke="#555" stroke-width="1.5"/></svg>`,
|
||||
other: `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><circle cx="12" cy="12" r="10" fill="none" stroke="#999" stroke-width="1.5"/><text x="12" y="16" text-anchor="middle" font-size="12" fill="#999">?</text></svg>`,
|
||||
};
|
||||
|
||||
function sourceIcon(kind) {
|
||||
const svg = SOURCE_ICONS[kind] || SOURCE_ICONS.other;
|
||||
const el = document.createElement("span");
|
||||
el.className = "source-icon";
|
||||
el.innerHTML = svg;
|
||||
return el;
|
||||
}
|
||||
|
||||
function formatTimestamp(value) {
|
||||
if (!value) return "";
|
||||
const d = new Date(value);
|
||||
if (isNaN(d)) return value;
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
|
||||
}
|
||||
|
||||
async function getJson(url) {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.status} ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function appendCell(row, text, className) {
|
||||
const cell = document.createElement("div");
|
||||
if (className) cell.className = className;
|
||||
cell.textContent = text;
|
||||
row.append(cell);
|
||||
return cell;
|
||||
}
|
||||
|
||||
function renderArchives() {
|
||||
archiveSwitcher.innerHTML = "";
|
||||
for (const archive of state.archives) {
|
||||
const option = document.createElement("option");
|
||||
option.value = archive.id;
|
||||
option.textContent = archive.label;
|
||||
archiveSwitcher.append(option);
|
||||
}
|
||||
archiveSwitcher.value = state.archiveId ?? "";
|
||||
|
||||
adminArchives.innerHTML = "";
|
||||
for (const archive of state.archives) {
|
||||
const item = document.createElement("div");
|
||||
item.className = "admin-archive";
|
||||
const label = document.createElement("strong");
|
||||
label.textContent = archive.label;
|
||||
const path = document.createElement("div");
|
||||
path.className = "muted";
|
||||
path.textContent = archive.archive_path;
|
||||
item.append(label, path);
|
||||
adminArchives.append(item);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function renderEntries() {
|
||||
entriesBody.innerHTML = "";
|
||||
if (state.entries.length === 0 && searchInput.value.trim()) {
|
||||
resultCount.textContent = "No results.";
|
||||
} else {
|
||||
resultCount.textContent = `${state.entries.length} entries`;
|
||||
}
|
||||
if (state.tagFilter) {
|
||||
const badge = document.createElement("button");
|
||||
badge.className = "tag-filter-badge";
|
||||
badge.textContent = `× ${state.tagFilter}`;
|
||||
badge.addEventListener("click", () => {
|
||||
state.tagFilter = null;
|
||||
if (state.archiveId) loadEntries(searchInput.value);
|
||||
});
|
||||
resultCount.appendChild(badge);
|
||||
}
|
||||
|
||||
for (const entry of state.entries) {
|
||||
const row = document.createElement("div");
|
||||
row.tabIndex = 0;
|
||||
row.dataset.entryUid = entry.entry_uid;
|
||||
if (entry.entry_uid === state.selectedEntryUid) {
|
||||
row.classList.add("is-selected");
|
||||
}
|
||||
|
||||
appendCell(row, formatTimestamp(entry.archived_at), "col-added");
|
||||
|
||||
const titleCell = appendCell(row, "", "col-title");
|
||||
titleCell.append(sourceIcon(entry.source_kind));
|
||||
const title = document.createElement("span");
|
||||
title.className = "entry-title";
|
||||
title.textContent = valueText(entry.title) || valueText(entry.entry_uid);
|
||||
titleCell.append(title);
|
||||
|
||||
const typeCell = appendCell(row, "", "col-type");
|
||||
const type = document.createElement("span");
|
||||
type.className = "type-pill";
|
||||
type.textContent = valueText(entry.entity_kind);
|
||||
typeCell.append(type);
|
||||
|
||||
appendCell(row, formatBytes(entry.total_artifact_bytes), "col-size");
|
||||
appendCell(row, valueText(entry.original_url), "url-cell col-url");
|
||||
|
||||
row.addEventListener("click", () => selectEntry(entry));
|
||||
row.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") selectEntry(entry);
|
||||
});
|
||||
entriesBody.append(row);
|
||||
}
|
||||
}
|
||||
|
||||
function renderContextDetail(detail) {
|
||||
contextBody.innerHTML = "";
|
||||
|
||||
// Title
|
||||
const titleEl = document.createElement("strong");
|
||||
titleEl.className = "rail-entry-title";
|
||||
titleEl.textContent =
|
||||
valueText(detail.summary.title) || valueText(detail.summary.entry_uid);
|
||||
contextBody.append(titleEl);
|
||||
|
||||
// Metadata section
|
||||
const metaSection = document.createElement("div");
|
||||
metaSection.className = "rail-section";
|
||||
|
||||
if (detail.summary.original_url) {
|
||||
const urlRow = document.createElement("div");
|
||||
urlRow.className = "rail-item";
|
||||
const urlLabel = document.createElement("span");
|
||||
urlLabel.className = "rail-label";
|
||||
urlLabel.textContent = "Original URL";
|
||||
const urlLink = document.createElement("a");
|
||||
urlLink.href = detail.summary.original_url;
|
||||
urlLink.target = "_blank";
|
||||
urlLink.rel = "noopener noreferrer";
|
||||
urlLink.className = "rail-url-link";
|
||||
urlLink.textContent = detail.summary.original_url;
|
||||
urlRow.append(urlLabel, document.createTextNode(": "), urlLink);
|
||||
metaSection.append(urlRow);
|
||||
}
|
||||
|
||||
const metaFields = [
|
||||
["Added", formatTimestamp(detail.summary.archived_at)],
|
||||
["Source", detail.summary.source_kind],
|
||||
["Type", detail.summary.entity_kind],
|
||||
["Visibility", detail.summary.visibility],
|
||||
["Structured root", detail.structured_root_relpath],
|
||||
];
|
||||
for (const [label, value] of metaFields) {
|
||||
const item = document.createElement("div");
|
||||
item.className = "rail-item";
|
||||
const labelEl = document.createElement("span");
|
||||
labelEl.className = "rail-label";
|
||||
labelEl.textContent = label;
|
||||
item.append(labelEl, document.createTextNode(`: ${valueText(value)}`));
|
||||
metaSection.append(item);
|
||||
}
|
||||
contextBody.append(metaSection);
|
||||
|
||||
// Artifacts section
|
||||
if (detail.artifacts.length > 0) {
|
||||
const artifactsSection = document.createElement("div");
|
||||
artifactsSection.className = "rail-section";
|
||||
const artifactsHeading = document.createElement("div");
|
||||
artifactsHeading.className = "rail-section-heading";
|
||||
artifactsHeading.textContent = `Artifacts (${detail.artifacts.length})`;
|
||||
artifactsSection.append(artifactsHeading);
|
||||
const list = document.createElement("ul");
|
||||
list.className = "artifact-list";
|
||||
detail.artifacts.forEach((artifact, index) => {
|
||||
const li = document.createElement("li");
|
||||
const a = document.createElement("a");
|
||||
a.href = `/api/archives/${state.archiveId}/entries/${detail.summary.entry_uid}/artifacts/${index}`;
|
||||
a.target = "_blank";
|
||||
a.rel = "noopener noreferrer";
|
||||
a.className = "artifact-link";
|
||||
const roleName = artifact.artifact_role.replace(/_/g, " ");
|
||||
const size =
|
||||
artifact.byte_size != null ? ` (${formatBytes(artifact.byte_size)})` : "";
|
||||
a.textContent = `${roleName}${size}`;
|
||||
li.append(a);
|
||||
list.append(li);
|
||||
});
|
||||
artifactsSection.append(list);
|
||||
contextBody.append(artifactsSection);
|
||||
} else {
|
||||
const noArtifacts = document.createElement("div");
|
||||
noArtifacts.className = "rail-item muted";
|
||||
noArtifacts.textContent = "No artifacts.";
|
||||
contextBody.append(noArtifacts);
|
||||
}
|
||||
}
|
||||
|
||||
function renderEntryTags(tags, entryUid) {
|
||||
entryTagsEl.innerHTML = "";
|
||||
if (!tags.length) {
|
||||
entryTagsEl.textContent = "No tags.";
|
||||
return;
|
||||
}
|
||||
for (const tag of tags) {
|
||||
const pill = document.createElement("span");
|
||||
pill.className = "tag-pill";
|
||||
pill.textContent = tag.name;
|
||||
pill.title = tag.full_path;
|
||||
const removeBtn = document.createElement("button");
|
||||
removeBtn.className = "remove-tag";
|
||||
removeBtn.textContent = "×";
|
||||
removeBtn.title = `Remove tag ${tag.full_path}`;
|
||||
removeBtn.addEventListener("click", async () => {
|
||||
const resp = await fetch(
|
||||
`/api/archives/${state.archiveId}/entries/${entryUid}/tags/${tag.tag_uid}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
if (!resp.ok) {
|
||||
removeBtn.title = `Remove failed (${resp.status})`;
|
||||
return;
|
||||
}
|
||||
const updated = await getJson(
|
||||
`/api/archives/${state.archiveId}/entries/${entryUid}/tags`
|
||||
);
|
||||
renderEntryTags(updated, entryUid);
|
||||
loadTagTree();
|
||||
});
|
||||
pill.appendChild(removeBtn);
|
||||
entryTagsEl.appendChild(pill);
|
||||
}
|
||||
}
|
||||
|
||||
async function selectEntry(entry) {
|
||||
const seq = ++selectSeq;
|
||||
state.selectedEntryUid = entry.entry_uid;
|
||||
state.selectedEntry = entry;
|
||||
renderEntries();
|
||||
const detail = await getJson(
|
||||
`/api/archives/${state.archiveId}/entries/${entry.entry_uid}`
|
||||
);
|
||||
if (seq !== selectSeq) return;
|
||||
renderContextDetail(detail);
|
||||
entryTagsEl.hidden = false;
|
||||
assignTagForm.hidden = false;
|
||||
entryTagsEl.innerHTML = "";
|
||||
const tags = await getJson(
|
||||
`/api/archives/${state.archiveId}/entries/${entry.entry_uid}/tags`
|
||||
);
|
||||
if (seq !== selectSeq) return;
|
||||
renderEntryTags(tags, entry.entry_uid);
|
||||
}
|
||||
|
||||
async function loadRuns() {
|
||||
const runs = await getJson(`/api/archives/${state.archiveId}/runs`);
|
||||
runsBody.innerHTML = "";
|
||||
for (const run of runs) {
|
||||
const row = document.createElement("tr");
|
||||
for (const val of [run.started_at, run.status, run.requested_count, run.completed_count, run.failed_count]) {
|
||||
const td = document.createElement("td");
|
||||
td.textContent = valueText(String(val ?? ""));
|
||||
row.append(td);
|
||||
}
|
||||
runsBody.append(row);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEntries(q = "") {
|
||||
const trimmed = q.trim();
|
||||
const params = new URLSearchParams();
|
||||
if (trimmed) params.set("q", trimmed);
|
||||
if (state.tagFilter) params.set("tag", state.tagFilter);
|
||||
const url =
|
||||
trimmed || state.tagFilter
|
||||
? `/api/archives/${state.archiveId}/entries/search?${params}`
|
||||
: `/api/archives/${state.archiveId}/entries`;
|
||||
searchInput.setAttribute("aria-busy", "true");
|
||||
try {
|
||||
state.entries = await getJson(url);
|
||||
} catch (err) {
|
||||
resultCount.textContent = "Search failed. Try again.";
|
||||
state.entries = [];
|
||||
} finally {
|
||||
searchInput.removeAttribute("aria-busy");
|
||||
}
|
||||
renderEntries();
|
||||
}
|
||||
|
||||
async function loadTagTree() {
|
||||
if (!state.archiveId) return;
|
||||
const nodes = await getJson(`/api/archives/${state.archiveId}/tags`);
|
||||
tagTree.innerHTML = "";
|
||||
renderTagTree(nodes, tagTree);
|
||||
}
|
||||
|
||||
function renderTagTree(nodes, container) {
|
||||
if (!nodes.length) {
|
||||
container.textContent = "No tags yet.";
|
||||
return;
|
||||
}
|
||||
const ul = document.createElement("ul");
|
||||
ul.className = "tag-tree-list";
|
||||
for (const node of nodes) {
|
||||
const li = document.createElement("li");
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "tag-node-btn";
|
||||
if (state.tagFilter === node.tag.full_path) btn.classList.add("is-active");
|
||||
btn.textContent = node.tag.name;
|
||||
btn.title = node.tag.full_path;
|
||||
btn.addEventListener("click", () => {
|
||||
if (state.tagFilter === node.tag.full_path) {
|
||||
state.tagFilter = null;
|
||||
} else {
|
||||
state.tagFilter = node.tag.full_path;
|
||||
}
|
||||
// Switch to archive view and reload
|
||||
switchView("archive");
|
||||
if (state.archiveId) loadEntries(searchInput.value);
|
||||
});
|
||||
li.appendChild(btn);
|
||||
if (node.children?.length) {
|
||||
const childContainer = document.createElement("div");
|
||||
childContainer.className = "tag-children";
|
||||
renderTagTree(node.children, childContainer);
|
||||
li.appendChild(childContainer);
|
||||
}
|
||||
ul.appendChild(li);
|
||||
}
|
||||
container.appendChild(ul);
|
||||
}
|
||||
|
||||
async function loadArchives() {
|
||||
state.archives = await getJson("/api/archives");
|
||||
state.archiveId = state.archives[0]?.id ?? null;
|
||||
renderArchives();
|
||||
if (state.archiveId) {
|
||||
await loadEntries();
|
||||
await loadRuns();
|
||||
loadTagTree();
|
||||
} else {
|
||||
contextBody.textContent = "No archives mounted.";
|
||||
resultCount.textContent = "0 entries";
|
||||
}
|
||||
}
|
||||
|
||||
function debounce(fn, ms) {
|
||||
let timer;
|
||||
return (...args) => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => fn(...args), ms);
|
||||
};
|
||||
}
|
||||
|
||||
archiveSwitcher.addEventListener("change", async () => {
|
||||
state.tagFilter = null;
|
||||
state.selectedEntry = null;
|
||||
state.selectedEntryUid = null;
|
||||
entryTagsEl.hidden = true;
|
||||
assignTagForm.hidden = true;
|
||||
state.archiveId = archiveSwitcher.value;
|
||||
await loadEntries();
|
||||
await loadRuns();
|
||||
loadTagTree();
|
||||
});
|
||||
|
||||
const debouncedSearch = debounce((q) => {
|
||||
if (state.archiveId) loadEntries(q);
|
||||
}, 300);
|
||||
|
||||
searchInput.addEventListener("input", () => {
|
||||
debouncedSearch(searchInput.value);
|
||||
});
|
||||
|
||||
function switchView(name) {
|
||||
navButtons.forEach(b => b.classList.toggle("is-active", b.dataset.view === name));
|
||||
document.querySelectorAll(".view").forEach(v => v.classList.remove("is-active"));
|
||||
document.querySelector(`#${name}-view`)?.classList.add("is-active");
|
||||
}
|
||||
|
||||
navButtons.forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
switchView(button.dataset.view);
|
||||
if (button.dataset.view === "tags") loadTagTree();
|
||||
});
|
||||
});
|
||||
|
||||
assignTagBtn.addEventListener("click", async () => {
|
||||
const path = assignTagInput.value.trim();
|
||||
if (!path || !state.selectedEntry) return;
|
||||
const resp = await fetch(
|
||||
`/api/archives/${state.archiveId}/entries/${state.selectedEntry.entry_uid}/tags`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ tag_path: path }),
|
||||
}
|
||||
);
|
||||
if (resp.ok) {
|
||||
assignTagInput.setCustomValidity("");
|
||||
assignTagInput.value = "";
|
||||
const tags = await getJson(
|
||||
`/api/archives/${state.archiveId}/entries/${state.selectedEntry.entry_uid}/tags`
|
||||
);
|
||||
renderEntryTags(tags, state.selectedEntry.entry_uid);
|
||||
loadTagTree();
|
||||
} else {
|
||||
assignTagInput.setCustomValidity(`Failed to add tag (${resp.status})`);
|
||||
assignTagInput.reportValidity();
|
||||
}
|
||||
});
|
||||
|
||||
captureButton.addEventListener('click', () => {
|
||||
captureLocatorInput.value = '';
|
||||
captureError.hidden = true;
|
||||
captureDialog.showModal();
|
||||
});
|
||||
|
||||
captureCancelBtn.addEventListener('click', () => captureDialog.close());
|
||||
|
||||
captureSubmitBtn.addEventListener('click', async () => {
|
||||
const locator = captureLocatorInput.value.trim();
|
||||
if (!locator) {
|
||||
captureError.textContent = 'Enter a locator.';
|
||||
captureError.hidden = false;
|
||||
return;
|
||||
}
|
||||
captureSubmitBtn.disabled = true;
|
||||
captureSubmitBtn.textContent = 'Capturing\u2026';
|
||||
captureError.hidden = true;
|
||||
try {
|
||||
const res = await fetch(`/api/archives/${state.archiveId}/captures`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ locator }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const msg = await res.text();
|
||||
throw new Error(msg || `HTTP ${res.status}`);
|
||||
}
|
||||
captureDialog.close();
|
||||
await Promise.all([loadEntries(searchInput.value), loadRuns()]);
|
||||
} catch (e) {
|
||||
captureError.textContent = e.message;
|
||||
captureError.hidden = false;
|
||||
} finally {
|
||||
captureSubmitBtn.disabled = false;
|
||||
captureSubmitBtn.textContent = 'Capture';
|
||||
}
|
||||
});
|
||||
|
||||
loadArchives().catch((error) => {
|
||||
contextBody.textContent = `Failed to load archives: ${error.message}`;
|
||||
});
|
||||
1
crates/archivr-server/static/assets/index-DJpQthbx.css
Normal file
1
crates/archivr-server/static/assets/index-DJpQthbx.css
Normal file
File diff suppressed because one or more lines are too long
40
crates/archivr-server/static/assets/index-XKDv7X2o.js
Normal file
40
crates/archivr-server/static/assets/index-XKDv7X2o.js
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -1,94 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Archivr</title>
|
||||
<link rel="stylesheet" href="/assets/styles.css">
|
||||
<script type="module" crossorigin src="/assets/index-XKDv7X2o.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DJpQthbx.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">Archivr</div>
|
||||
<select id="archive-switcher" class="archive-switcher" aria-label="Select archive"></select>
|
||||
<nav class="nav" aria-label="Primary">
|
||||
<button class="nav-link is-active" data-view="archive">Archive</button>
|
||||
<button class="nav-link" data-view="runs">Runs</button>
|
||||
<button class="nav-link" data-view="admin">Admin</button>
|
||||
<button class="nav-link" data-view="tags">Tags</button>
|
||||
</nav>
|
||||
<button class="capture-button">+ Capture</button>
|
||||
</header>
|
||||
|
||||
<main class="app-shell">
|
||||
<section class="workspace">
|
||||
<div class="search-row">
|
||||
<input id="search" class="search-input" type="search" aria-label="Search archive" autocomplete="off">
|
||||
<div id="result-count" class="result-count"></div>
|
||||
</div>
|
||||
|
||||
<section id="archive-view" class="view is-active">
|
||||
<div class="entry-table">
|
||||
<div class="entry-header-row">
|
||||
<div class="col-added">Added</div>
|
||||
<div class="col-title">Title</div>
|
||||
<div class="col-type">Type</div>
|
||||
<div class="col-size">Size</div>
|
||||
<div class="col-url">Original URL</div>
|
||||
</div>
|
||||
<div id="entries-body"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="runs-view" class="view">
|
||||
<table class="entry-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Started</th>
|
||||
<th>Status</th>
|
||||
<th>Requested</th>
|
||||
<th>Completed</th>
|
||||
<th>Failed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="runs-body"></tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section id="admin-view" class="view admin-view">
|
||||
<h1>Mounted Archives</h1>
|
||||
<div id="admin-archives" class="admin-list"></div>
|
||||
</section>
|
||||
|
||||
<section id="tags-view" class="view">
|
||||
<div id="tag-tree" class="tag-tree"></div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<aside class="context-rail">
|
||||
<div class="rail-title">Context</div>
|
||||
<div id="context-body" class="rail-body">Select an entry.</div>
|
||||
<div id="entry-tags" class="entry-tags" hidden></div>
|
||||
<div id="assign-tag-form" class="assign-tag-form" hidden>
|
||||
<input id="assign-tag-input" class="assign-tag-input" type="text" placeholder="/science/cs" autocomplete="off">
|
||||
<button id="assign-tag-btn" class="assign-tag-btn">Add tag</button>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<dialog id="capture-dialog" class="capture-dialog">
|
||||
<div class="capture-dialog-inner">
|
||||
<h2 class="capture-dialog-title">Capture</h2>
|
||||
<label for="capture-locator" class="capture-label">Locator</label>
|
||||
<input id="capture-locator" class="capture-input" type="text"
|
||||
placeholder="tweet:1234567890 or https://..." autocomplete="off">
|
||||
<div id="capture-error" class="capture-error" hidden></div>
|
||||
<div class="capture-actions">
|
||||
<button type="button" id="capture-cancel-btn" class="capture-cancel">Cancel</button>
|
||||
<button type="button" id="capture-submit-btn" class="capture-submit">Capture</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script type="module" src="/assets/app.js"></script>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,642 +0,0 @@
|
|||
:root {
|
||||
color-scheme: light;
|
||||
--ink: #20251f;
|
||||
--muted: #666a61;
|
||||
--paper: #f5f0e7;
|
||||
--paper-2: #e9e1d2;
|
||||
--paper-3: #fffaf0;
|
||||
--line: #d2c6b5;
|
||||
--line-soft: #e5dccd;
|
||||
--accent: #8d3f30;
|
||||
--accent-2: #b78342;
|
||||
--link: #245f72;
|
||||
--top: #141d18;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
height: 56px;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(180px, 250px) 1fr auto;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
padding: 0 18px;
|
||||
background: var(--top);
|
||||
color: #f7eedf;
|
||||
border-bottom: 3px solid var(--accent);
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.archive-switcher {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 1px solid rgba(247, 238, 223, 0.35);
|
||||
background: #202b25;
|
||||
color: #f7eedf;
|
||||
padding: 7px 9px;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: flex-end;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #d7cdbf;
|
||||
cursor: pointer;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.nav-link.is-active {
|
||||
color: #fffaf0;
|
||||
border-bottom: 1px solid #fffaf0;
|
||||
}
|
||||
|
||||
.capture-button {
|
||||
border: 0;
|
||||
background: #f7eedf;
|
||||
color: var(--top);
|
||||
padding: 9px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
height: calc(100vh - 56px);
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.search-row {
|
||||
min-height: 76px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) max-content;
|
||||
gap: 18px;
|
||||
align-items: center;
|
||||
padding: 14px 16px;
|
||||
background: var(--paper-2);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
height: 46px;
|
||||
border: 2px solid var(--ink);
|
||||
background: var(--paper-3);
|
||||
color: var(--ink);
|
||||
padding: 0 14px;
|
||||
font-size: 16px;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.result-count {
|
||||
min-width: 76px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.view {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.view.is-active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.entry-table {
|
||||
width: 100%;
|
||||
font-size: 13px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.entry-header-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: #ded5c7;
|
||||
border-bottom: 1px solid #cec4b5;
|
||||
color: #5d625e;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.entry-header-row > div {
|
||||
padding: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#entries-body > div {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
cursor: default;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
}
|
||||
|
||||
#entries-body > div > div {
|
||||
padding: 10px;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#entries-body > div:nth-child(even) {
|
||||
background: #f2ede5;
|
||||
}
|
||||
|
||||
#entries-body > div:nth-child(odd) {
|
||||
background: var(--paper-3);
|
||||
}
|
||||
|
||||
#entries-body > div.is-selected {
|
||||
background: #eee2d2;
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.col-added {
|
||||
width: 168px;
|
||||
}
|
||||
|
||||
.col-title {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.col-type {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.col-size {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.col-url {
|
||||
flex: 0 0 30%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Runs table (still a <table>) */
|
||||
#runs-view .entry-table {
|
||||
border-collapse: collapse;
|
||||
table-layout: auto;
|
||||
}
|
||||
|
||||
#runs-view .entry-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
text-align: left;
|
||||
padding: 10px;
|
||||
background: #ded5c7;
|
||||
color: #5d625e;
|
||||
border-bottom: 1px solid #cec4b5;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
#runs-view .entry-table td {
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
#runs-view .entry-table tr:nth-child(even) td { background: #f2ede5; }
|
||||
#runs-view .entry-table tr:nth-child(odd) td { background: var(--paper-3); }
|
||||
|
||||
.entry-title {
|
||||
color: var(--link);
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.source-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
margin-right: 0.35em;
|
||||
vertical-align: middle;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.source-icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.url-cell {
|
||||
color: #555b55;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.type-pill {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
background: #d8e3df;
|
||||
color: #275a5f;
|
||||
border: 1px solid #bfd0ca;
|
||||
}
|
||||
|
||||
.context-rail {
|
||||
border-left: 1px solid var(--line);
|
||||
background: #efe7dc;
|
||||
padding: 18px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.rail-title {
|
||||
color: var(--accent);
|
||||
font-weight: 800;
|
||||
margin-bottom: 10px;
|
||||
text-transform: uppercase;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.rail-body {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.rail-body strong {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.rail-item {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.admin-view {
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.admin-view h1 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.admin-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
max-width: 780px;
|
||||
}
|
||||
|
||||
.admin-archive {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--paper-3);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.topbar {
|
||||
grid-template-columns: 1fr auto;
|
||||
height: auto;
|
||||
min-height: 56px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.archive-switcher,
|
||||
.nav {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.nav {
|
||||
justify-content: flex-start;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
height: auto;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.search-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.result-count {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.context-rail {
|
||||
border-left: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.entry-table {
|
||||
min-width: 860px;
|
||||
}
|
||||
}
|
||||
|
||||
.rail-entry-title {
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.rail-section {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.rail-section-heading {
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--accent);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.rail-label {
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.rail-url-link {
|
||||
color: var(--accent);
|
||||
word-break: break-all;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.artifact-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.artifact-link {
|
||||
display: block;
|
||||
padding: 6px 8px;
|
||||
background: var(--paper-3);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.artifact-link:hover {
|
||||
background: var(--line);
|
||||
text-decoration: underline;
|
||||
}
|
||||
/* Search loading state */
|
||||
.search-input[aria-busy="true"] {
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
/* Tag tree */
|
||||
.tag-tree {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.tag-tree-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tag-children {
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.tag-node-btn {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--link);
|
||||
cursor: pointer;
|
||||
padding: 3px 0;
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tag-node-btn:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.tag-node-btn.is-active {
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* Tag pills in context rail */
|
||||
.entry-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.tag-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: var(--paper-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.remove-tag {
|
||||
border: 0;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.remove-tag:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Assign tag form */
|
||||
.assign-tag-form {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 6px 0 8px;
|
||||
border-top: 1px solid var(--line-soft);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.assign-tag-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--paper-3);
|
||||
color: var(--ink);
|
||||
padding: 4px 7px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.assign-tag-btn {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--paper-2);
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.assign-tag-btn:hover {
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
/* Tag filter badge in search row */
|
||||
.tag-filter-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
cursor: pointer;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.tag-filter-badge:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Capture dialog */
|
||||
.capture-dialog {
|
||||
border: 2px solid var(--ink);
|
||||
background: var(--paper);
|
||||
padding: 0;
|
||||
min-width: 360px;
|
||||
max-width: 520px;
|
||||
box-shadow: 4px 4px 0 var(--ink);
|
||||
}
|
||||
|
||||
.capture-dialog::backdrop {
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.capture-dialog-inner {
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.capture-dialog-title {
|
||||
margin: 0 0 16px;
|
||||
font-size: 18px;
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
}
|
||||
|
||||
.capture-label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.capture-input {
|
||||
width: 100%;
|
||||
height: 42px;
|
||||
border: 2px solid var(--ink);
|
||||
background: var(--paper-3);
|
||||
color: var(--ink);
|
||||
padding: 0 12px;
|
||||
font-size: 15px;
|
||||
margin-bottom: 10px;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.capture-error {
|
||||
color: var(--accent);
|
||||
font-size: 13px;
|
||||
margin-bottom: 10px;
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
.capture-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.capture-cancel {
|
||||
border: 1px solid var(--line);
|
||||
background: none;
|
||||
color: var(--ink);
|
||||
padding: 8px 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.capture-cancel:hover {
|
||||
background: var(--paper-2);
|
||||
}
|
||||
|
||||
.capture-submit {
|
||||
border: 0;
|
||||
background: var(--ink);
|
||||
color: var(--paper);
|
||||
padding: 8px 20px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.capture-submit:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.capture-submit:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue