Bounds each user's disk footprint and makes throwaway artifacts self-clean:
- Per-user quota (default 1 GB) stored on a new UserProfile model,
adjustable per user in Django admin. Artifact.size_bytes records each
artifact's on-disk footprint (primary file + markdown sidecar) at
ingest so usage is a cheap SUM.
- Automatic eviction: on each post, oldest unkept artifacts are deleted
(files and rows) until the new one fits under quota. If the quota is
full of kept artifacts, the post is rejected with HTTP 413 rather than
evicting anything protected.
- Keep flag (Artifact.is_kept): a Keep button in the preview control bar
toggles protection via POST /a/{id}/keep; kept items are never evicted.
Kept rows show a green "K" badge in the listing.
- Details panel: a Details toggle in the control bar expands a panel
under it with full source, type, created time, size, and tags.
- Settings -> Storage: usage bar (green/amber/red), used/quota summary,
artifact and kept counts; superusers also see total allocated quota vs
free disk with an oversubscription warning.
- Type indicator in the listing is now a small grey pill instead of
plain text.
Storage accounting/eviction logic lives in artifacts/storage.py.
Migrations 0009 (fields + UserProfile) and 0010 (backfill sizes and
create profiles for existing users). Fresh installs no-op the backfill.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
553 lines
24 KiB
HTML
553 lines
24 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>AgentFeed</title>
|
|
<link rel="stylesheet" href="/static/daybook.css">
|
|
</head>
|
|
<body>
|
|
{% include "artifacts/_topbar.html" with topbar_toolbar="artifacts/_feed_toolbar.html" %}
|
|
|
|
<main class="feed"
|
|
data-selected-source="{{ selected_source }}"
|
|
data-selected-label="{{ selected_label }}"
|
|
data-selected-kind="{{ selected_kind }}"
|
|
data-title-query="{{ title_query }}"
|
|
data-selected-view="{{ selected_view }}"
|
|
data-window-start="{{ window_start|date:'Y-m-d' }}"
|
|
data-older-count="{{ older_count }}">
|
|
<aside class="source-sidebar" aria-label="Sources">
|
|
<div class="pane-bar">
|
|
<h2>Sources</h2>
|
|
</div>
|
|
<nav class="source-list">
|
|
<a class="source-link {% if not selected_source %}selected{% endif %}" href="?{% if selected_label %}label={{ selected_label|urlencode }}&{% endif %}{% if selected_kind %}kind={{ selected_kind|urlencode }}&{% endif %}{% if title_query %}q={{ title_query|urlencode }}&{% endif %}view={{ selected_view }}&preview=none">
|
|
<span class="source-name">All sources</span>
|
|
{% if total_count %}<span class="source-count">{{ total_count }}</span>{% endif %}
|
|
</a>
|
|
{% for entry in source_counts %}
|
|
<a class="source-link {% if entry.source == selected_source %}selected{% endif %}" href="?source={{ entry.source|urlencode }}{% if selected_label %}&label={{ selected_label|urlencode }}{% endif %}{% if selected_kind %}&kind={{ selected_kind|urlencode }}{% endif %}{% if title_query %}&q={{ title_query|urlencode }}{% endif %}&view={{ selected_view }}&preview=none">
|
|
<span class="source-name">{{ entry.source }}</span>
|
|
{% if entry.count %}<span class="source-count">{{ entry.count }}</span>{% endif %}
|
|
</a>
|
|
{% endfor %}
|
|
</nav>
|
|
</aside>
|
|
|
|
<div class="splitter" data-splitter="days" role="separator" aria-label="Resize sources sidebar" aria-orientation="vertical"></div>
|
|
|
|
<aside class="artifact-sidebar" aria-label="Artifact feed">
|
|
<div class="pane-bar pane-heading">
|
|
<h2>{% if selected_source %}{{ selected_source }}{% else %}All sources{% endif %}</h2>
|
|
<span>{{ artifacts|length }} artifact{{ artifacts|length|pluralize }}</span>
|
|
</div>
|
|
|
|
<div class="artifact-list" data-artifact-list>
|
|
{% if day_groups %}
|
|
{% include "artifacts/_day_groups.html" with has_more=older_count next_before=window_start|date:"Y-m-d" %}
|
|
{% else %}
|
|
<div class="empty compact">
|
|
<h3>No artifacts</h3>
|
|
<p>Nothing in the last {{ 7 }} days for these filters.</p>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</aside>
|
|
|
|
<div class="splitter" data-splitter="artifacts" role="separator" aria-label="Resize artifacts sidebar" aria-orientation="vertical"></div>
|
|
|
|
<section class="preview-pane">
|
|
<header class="pane-bar preview-actions" data-actions>
|
|
<div class="preview-title" data-preview-title>
|
|
{% if selected_artifact %}{{ selected_artifact.title }}{% else %}No artifact loaded{% endif %}
|
|
</div>
|
|
<nav class="view-toggle" aria-label="Preview mode">
|
|
<a class="{% if selected_view == 'rendered' %}selected{% endif %}{% if not selected_artifact %} disabled{% endif %}" href="{% if selected_artifact %}{{ rendered_url }}{% else %}#{% endif %}" data-view="rendered" {% if not selected_artifact %}aria-disabled="true"{% endif %}>Rendered</a>
|
|
<a class="{% if selected_view == 'raw' %}selected{% endif %}{% if not selected_artifact %} disabled{% endif %}" href="{% if selected_artifact %}{{ raw_url }}{% else %}#{% endif %}" data-view="raw" {% if not selected_artifact %}aria-disabled="true"{% endif %}>Raw</a>
|
|
</nav>
|
|
<div class="artifact-action-links">
|
|
<button type="button" class="keep-button {% if selected_artifact.is_kept %}kept{% endif %}" data-keep-button data-artifact-id="{% if selected_artifact %}{{ selected_artifact.id }}{% endif %}" {% if not selected_artifact %}disabled{% endif %}>{% if selected_artifact.is_kept %}✓ Kept{% else %}Keep{% endif %}</button>
|
|
<button type="button" data-details-toggle {% if not selected_artifact %}disabled{% endif %}>Details</button>
|
|
<a class="{% if not selected_artifact %}disabled{% endif %}" href="{% if selected_artifact %}{% url 'artifact' selected_artifact.id %}{% else %}#{% endif %}" target="_blank" rel="noopener" {% if not selected_artifact %}aria-disabled="true"{% endif %}>Open</a>
|
|
<a class="{% if not selected_artifact %}disabled{% endif %}" href="{% if selected_artifact %}{% url 'artifact' selected_artifact.id %}{% else %}#{% endif %}" download {% if not selected_artifact %}aria-disabled="true"{% endif %}>Download</a>
|
|
<button type="button" data-copy-url="{% if selected_artifact %}{% url 'artifact' selected_artifact.id %}{% endif %}" {% if not selected_artifact %}disabled{% endif %}>Copy Link</button>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="details-panel" hidden data-details-panel></div>
|
|
<div class="preview-body">
|
|
{% if selected_artifact %}
|
|
<div class="preview-frame-wrap">
|
|
{% if selected_view == "raw" %}
|
|
<iframe class="preview-frame" src="{% url 'artifact' selected_artifact.id %}?raw=1" title="{{ selected_artifact.title }} raw source"></iframe>
|
|
{% elif selected_artifact.kind == "image" %}
|
|
<img class="preview-image" src="{% url 'artifact' selected_artifact.id %}" alt="{{ selected_artifact.title }}">
|
|
{% elif selected_artifact.kind == "binary" %}
|
|
<div class="empty preview-empty">
|
|
<h2>Binary Artifact</h2>
|
|
<p>This artifact type cannot be previewed inline yet.</p>
|
|
<p><a href="{% url 'artifact' selected_artifact.id %}">Open or download it</a>.</p>
|
|
</div>
|
|
{% else %}
|
|
<iframe class="preview-frame" src="{% url 'artifact' selected_artifact.id %}{% if selected_view == 'raw' %}?raw=1{% endif %}" title="{{ selected_artifact.title }}"></iframe>
|
|
{% endif %}
|
|
</div>
|
|
{% else %}
|
|
<div class="empty preview-empty">
|
|
{% if artifacts %}
|
|
<h2>No artifact loaded</h2>
|
|
<p>Press <kbd>Right Arrow</kbd> or select an artifact from the middle column to preview it.</p>
|
|
{% else %}
|
|
<h2>No artifacts for this day</h2>
|
|
<p>This day is ready for new agent output.</p>
|
|
{% endif %}
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</section>
|
|
</main>
|
|
|
|
<script>
|
|
(() => {
|
|
const feed = document.querySelector(".feed");
|
|
if (!feed) return;
|
|
let selectedView = "{{ selected_view }}";
|
|
|
|
const sizes = {
|
|
days: Number(localStorage.getItem("daybook.daysWidth")) || 144,
|
|
artifacts: Number(localStorage.getItem("daybook.artifactsWidth")) || 280,
|
|
};
|
|
|
|
const applySizes = () => {
|
|
feed.style.setProperty("--days-width", `${sizes.days}px`);
|
|
feed.style.setProperty("--artifacts-width", `${sizes.artifacts}px`);
|
|
};
|
|
|
|
applySizes();
|
|
|
|
document.querySelectorAll(".splitter").forEach((splitter) => {
|
|
splitter.addEventListener("pointerdown", (event) => {
|
|
const target = splitter.dataset.splitter;
|
|
const startX = event.clientX;
|
|
const startWidth = sizes[target];
|
|
splitter.setPointerCapture(event.pointerId);
|
|
document.body.classList.add("is-resizing");
|
|
|
|
const move = (moveEvent) => {
|
|
sizes[target] = Math.max(96, Math.min(420, startWidth + moveEvent.clientX - startX));
|
|
applySizes();
|
|
};
|
|
|
|
const up = () => {
|
|
localStorage.setItem(`daybook.${target}Width`, String(sizes[target]));
|
|
document.body.classList.remove("is-resizing");
|
|
splitter.removeEventListener("pointermove", move);
|
|
splitter.removeEventListener("pointerup", up);
|
|
splitter.removeEventListener("pointercancel", up);
|
|
};
|
|
|
|
splitter.addEventListener("pointermove", move);
|
|
splitter.addEventListener("pointerup", up);
|
|
splitter.addEventListener("pointercancel", up);
|
|
});
|
|
});
|
|
|
|
const previewPane = document.querySelector(".preview-pane");
|
|
const previewBody = document.querySelector(".preview-body");
|
|
const artifactList = document.querySelector("[data-artifact-list]");
|
|
// Rows grow as older pages load, so always read them live rather than caching an array.
|
|
const allRows = () => Array.from(document.querySelectorAll(".artifact-row"));
|
|
const sourceLinks = Array.from(document.querySelectorAll(".source-link"));
|
|
const csrfToken = document.querySelector("[name=csrfmiddlewaretoken]")?.value || "";
|
|
|
|
const escapeHtml = (value) => value.replace(/[&<>"]/g, (character) => ({
|
|
"&": "&",
|
|
"<": "<",
|
|
">": ">",
|
|
"\"": """,
|
|
}[character]));
|
|
const copyLink = async (button) => {
|
|
const absoluteUrl = new URL(button.dataset.copyUrl, window.location.origin).toString();
|
|
try {
|
|
await navigator.clipboard.writeText(absoluteUrl);
|
|
} catch {
|
|
window.prompt("Copy artifact link", absoluteUrl);
|
|
}
|
|
const original = button.textContent;
|
|
button.textContent = "Copied";
|
|
setTimeout(() => {
|
|
button.textContent = original;
|
|
}, 1200);
|
|
};
|
|
const selectedRow = () => document.querySelector(".artifact-row.selected");
|
|
let navigationMode = selectedRow() ? "artifacts" : "sources";
|
|
const urlFor = (row, view) => view === "raw" ? row.dataset.rawUrl : row.dataset.renderedUrl;
|
|
const pageUrlFor = (row, view) => {
|
|
const url = new URL(row.href, window.location.origin);
|
|
url.searchParams.set("artifact", row.dataset.artifactId);
|
|
url.searchParams.set("view", view);
|
|
url.searchParams.delete("preview");
|
|
return url;
|
|
};
|
|
|
|
const noPreviewUrl = () => {
|
|
const url = new URL(window.location.href);
|
|
url.searchParams.delete("artifact");
|
|
url.searchParams.set("preview", "none");
|
|
url.searchParams.set("view", selectedView);
|
|
return url;
|
|
};
|
|
|
|
const updateActions = (row) => {
|
|
const actions = document.querySelector("[data-actions]");
|
|
if (!actions) return;
|
|
const previewTitle = actions.querySelector("[data-preview-title]");
|
|
if (previewTitle) previewTitle.textContent = row.dataset.title;
|
|
actions.querySelectorAll("[data-view]").forEach((link) => {
|
|
const view = link.dataset.view;
|
|
link.classList.toggle("selected", view === selectedView);
|
|
link.classList.remove("disabled");
|
|
link.removeAttribute("aria-disabled");
|
|
link.href = pageUrlFor(row, view).toString();
|
|
});
|
|
actions.querySelectorAll(".artifact-action-links a").forEach((link) => {
|
|
link.classList.remove("disabled");
|
|
link.removeAttribute("aria-disabled");
|
|
link.href = row.dataset.renderedUrl;
|
|
});
|
|
const copyButton = actions.querySelector("[data-copy-url]");
|
|
if (copyButton) {
|
|
copyButton.disabled = false;
|
|
copyButton.dataset.copyUrl = row.dataset.renderedUrl;
|
|
}
|
|
syncKeepButton(row.dataset.kept === "1");
|
|
const keepButton = actions.querySelector("[data-keep-button]");
|
|
if (keepButton) keepButton.dataset.artifactId = row.dataset.artifactId;
|
|
actions.querySelector("[data-details-toggle]").disabled = false;
|
|
renderDetails(row);
|
|
};
|
|
|
|
const syncKeepButton = (isKept) => {
|
|
const keepButton = document.querySelector("[data-keep-button]");
|
|
if (!keepButton) return;
|
|
keepButton.disabled = false;
|
|
keepButton.classList.toggle("kept", isKept);
|
|
keepButton.textContent = isKept ? "✓ Kept" : "Keep";
|
|
};
|
|
|
|
const renderDetails = (row) => {
|
|
const panel = document.querySelector("[data-details-panel]");
|
|
if (!panel) return;
|
|
const d = row.dataset;
|
|
const tags = d.tags ? d.tags.split(",").map((t) => t.trim()).filter(Boolean) : [];
|
|
const rows = [
|
|
["Source", `${escapeHtml(d.source)} (${escapeHtml(d.sourceType)})`],
|
|
["Source name", escapeHtml(d.sourceName || "—")],
|
|
["Type", escapeHtml(d.kind)],
|
|
["Created", escapeHtml(d.created)],
|
|
["Size", escapeHtml(d.size)],
|
|
["Tags", tags.length ? tags.map(escapeHtml).join(", ") : "—"],
|
|
];
|
|
panel.innerHTML = `<dl class="details-grid">${rows.map(([k, v]) => `<dt>${k}</dt><dd>${v}</dd>`).join("")}</dl>`;
|
|
};
|
|
|
|
const disableActions = () => {
|
|
const actions = document.querySelector("[data-actions]");
|
|
if (!actions) return;
|
|
const previewTitle = actions.querySelector("[data-preview-title]");
|
|
if (previewTitle) previewTitle.textContent = "No artifact loaded";
|
|
actions.querySelectorAll("[data-view]").forEach((link) => {
|
|
link.classList.add("disabled");
|
|
link.setAttribute("aria-disabled", "true");
|
|
link.href = "#";
|
|
});
|
|
actions.querySelectorAll(".artifact-action-links a").forEach((link) => {
|
|
link.classList.add("disabled");
|
|
link.setAttribute("aria-disabled", "true");
|
|
link.href = "#";
|
|
});
|
|
const copyButton = actions.querySelector("[data-copy-url]");
|
|
if (copyButton) {
|
|
copyButton.disabled = true;
|
|
copyButton.dataset.copyUrl = "";
|
|
}
|
|
const keepButton = actions.querySelector("[data-keep-button]");
|
|
if (keepButton) {
|
|
keepButton.disabled = true;
|
|
keepButton.classList.remove("kept");
|
|
keepButton.textContent = "Keep";
|
|
}
|
|
actions.querySelector("[data-details-toggle]").disabled = true;
|
|
const panel = document.querySelector("[data-details-panel]");
|
|
if (panel) panel.hidden = true;
|
|
};
|
|
|
|
const markRead = async (row) => {
|
|
if (!row.classList.contains("unread")) return;
|
|
row.classList.remove("unread");
|
|
decrementSourceCount(row.querySelector(".artifact-source")?.textContent.trim());
|
|
try {
|
|
await fetch(`/a/${row.dataset.artifactId}/read`, {
|
|
method: "POST",
|
|
headers: {"X-CSRFToken": csrfToken},
|
|
});
|
|
} catch {
|
|
// Non-critical: the row stays visually read until the next page load.
|
|
}
|
|
};
|
|
|
|
const decrementSourceCount = (sourceName) => {
|
|
document.querySelectorAll(".source-link").forEach((link) => {
|
|
const name = link.querySelector(".source-name")?.textContent.trim();
|
|
if (name !== sourceName && name !== "All sources") return;
|
|
const badge = link.querySelector(".source-count");
|
|
if (!badge) return;
|
|
const next = Number(badge.textContent) - 1;
|
|
if (next > 0) {
|
|
badge.textContent = String(next);
|
|
} else {
|
|
badge.remove();
|
|
}
|
|
});
|
|
};
|
|
|
|
const renderPreview = (row, pushState = true) => {
|
|
navigationMode = "artifacts";
|
|
markRead(row);
|
|
allRows().forEach((candidate) => candidate.classList.toggle("selected", candidate === row));
|
|
const source = urlFor(row, selectedView);
|
|
const title = escapeHtml(row.dataset.title);
|
|
const kind = row.dataset.kind;
|
|
const content = selectedView === "raw"
|
|
? `<iframe class="preview-frame" src="${source}" title="${title} raw source"></iframe>`
|
|
: kind === "image"
|
|
? `<img class="preview-image" src="${source}" alt="${title}">`
|
|
: kind === "binary"
|
|
? `<div class="empty preview-empty"><h2>Binary Artifact</h2><p>This artifact type cannot be previewed inline yet.</p><p><a href="${source}">Open or download it</a>.</p></div>`
|
|
: `<iframe class="preview-frame" src="${source}" title="${title}"></iframe>`;
|
|
|
|
previewBody.innerHTML = `<div class="preview-frame-wrap">${content}</div>`;
|
|
updateActions(row);
|
|
if (pushState) window.history.pushState({}, "", pageUrlFor(row, selectedView));
|
|
};
|
|
|
|
const clearPreview = () => {
|
|
navigationMode = "sources";
|
|
if (document.activeElement && document.activeElement.classList.contains("artifact-row")) {
|
|
document.activeElement.blur();
|
|
}
|
|
allRows().forEach((candidate) => candidate.classList.remove("selected"));
|
|
disableActions();
|
|
previewBody.innerHTML = `<div class="empty preview-empty"><h2>No artifact loaded</h2><p>Press <kbd>Right Arrow</kbd> or select an artifact from the middle column to preview it.</p></div>`;
|
|
window.history.pushState({}, "", noPreviewUrl());
|
|
const currentSource = document.querySelector(".source-link.selected");
|
|
if (currentSource) currentSource.focus({preventScroll: true});
|
|
};
|
|
|
|
// Delegated so rows appended by infinite scroll work without rebinding.
|
|
artifactList.addEventListener("click", (event) => {
|
|
const row = event.target.closest(".artifact-row");
|
|
if (!row) return;
|
|
event.preventDefault();
|
|
renderPreview(row);
|
|
});
|
|
|
|
const moveSelection = (direction) => {
|
|
const rows = allRows();
|
|
if (!rows.length) return;
|
|
navigationMode = "artifacts";
|
|
const current = selectedRow();
|
|
const currentIndex = current ? rows.indexOf(current) : -1;
|
|
const nextIndex = Math.max(0, Math.min(rows.length - 1, currentIndex + direction));
|
|
const nextRow = rows[nextIndex === -1 ? 0 : nextIndex];
|
|
renderPreview(nextRow);
|
|
nextRow.focus({preventScroll: true});
|
|
nextRow.scrollIntoView({block: "nearest"});
|
|
};
|
|
|
|
const moveSource = (direction) => {
|
|
if (!sourceLinks.length) return;
|
|
navigationMode = "sources";
|
|
const current = document.querySelector(".source-link.selected");
|
|
const currentIndex = current ? sourceLinks.indexOf(current) : 0;
|
|
const nextIndex = Math.max(0, Math.min(sourceLinks.length - 1, currentIndex + direction));
|
|
const nextSource = sourceLinks[nextIndex];
|
|
if (nextSource && nextSource !== current) window.location.href = nextSource.href;
|
|
};
|
|
|
|
document.addEventListener("keydown", (event) => {
|
|
if (event.ctrlKey || event.metaKey || event.shiftKey) return;
|
|
if (["INPUT", "SELECT", "TEXTAREA", "BUTTON"].includes(event.target.tagName)) return;
|
|
|
|
if (event.altKey && event.key === "ArrowDown") {
|
|
event.preventDefault();
|
|
moveSource(1);
|
|
} else if (event.altKey && event.key === "ArrowUp") {
|
|
event.preventDefault();
|
|
moveSource(-1);
|
|
} else if (event.altKey) {
|
|
return;
|
|
} else if (event.key === "ArrowDown") {
|
|
event.preventDefault();
|
|
if (navigationMode === "sources") {
|
|
moveSource(1);
|
|
} else {
|
|
moveSelection(1);
|
|
}
|
|
} else if (event.key === "ArrowUp") {
|
|
event.preventDefault();
|
|
if (navigationMode === "sources") {
|
|
moveSource(-1);
|
|
} else {
|
|
moveSelection(-1);
|
|
}
|
|
} else if (event.key === "PageDown") {
|
|
if (navigationMode === "artifacts") {
|
|
event.preventDefault();
|
|
moveSelection(5);
|
|
}
|
|
} else if (event.key === "PageUp") {
|
|
if (navigationMode === "artifacts") {
|
|
event.preventDefault();
|
|
moveSelection(-5);
|
|
}
|
|
} else if (event.key === "ArrowRight") {
|
|
const rows = allRows();
|
|
if (!selectedRow() && rows.length) {
|
|
event.preventDefault();
|
|
renderPreview(rows[0]);
|
|
rows[0].focus({preventScroll: true});
|
|
rows[0].scrollIntoView({block: "nearest"});
|
|
} else if (selectedRow()) {
|
|
navigationMode = "artifacts";
|
|
}
|
|
} else if (event.key === "ArrowLeft") {
|
|
if (selectedRow()) {
|
|
event.preventDefault();
|
|
clearPreview();
|
|
} else {
|
|
navigationMode = "sources";
|
|
const currentSource = document.querySelector(".source-link.selected");
|
|
if (currentSource) currentSource.focus({preventScroll: true});
|
|
}
|
|
}
|
|
});
|
|
|
|
// Infinite scroll: when the sentinel at the end of the list scrolls into
|
|
// view, fetch the next page of older day-groups and append them.
|
|
let loadingOlder = false;
|
|
const loadOlder = async (sentinel) => {
|
|
if (loadingOlder) return;
|
|
loadingOlder = true;
|
|
sentinel.removeAttribute("data-feed-more");
|
|
try {
|
|
const url = new URL("{% url 'feed-older' %}", window.location.origin);
|
|
const params = url.searchParams;
|
|
params.set("before", sentinel.dataset.nextBefore);
|
|
params.set("view", feed.dataset.selectedView || "rendered");
|
|
if (feed.dataset.selectedSource) params.set("source", feed.dataset.selectedSource);
|
|
if (feed.dataset.selectedLabel) params.set("label", feed.dataset.selectedLabel);
|
|
if (feed.dataset.selectedKind) params.set("kind", feed.dataset.selectedKind);
|
|
if (feed.dataset.titleQuery) params.set("q", feed.dataset.titleQuery);
|
|
const response = await fetch(url, {headers: {"X-Requested-With": "fetch"}});
|
|
if (!response.ok) return;
|
|
const markup = (await response.text()).trim();
|
|
sentinel.remove();
|
|
if (markup) {
|
|
artifactList.insertAdjacentHTML("beforeend", markup);
|
|
observeSentinel();
|
|
}
|
|
} catch {
|
|
// Leave the list as-is; the user can scroll again to retry.
|
|
} finally {
|
|
loadingOlder = false;
|
|
}
|
|
};
|
|
|
|
const olderObserver = new IntersectionObserver((entries) => {
|
|
entries.forEach((entry) => {
|
|
if (entry.isIntersecting) {
|
|
olderObserver.unobserve(entry.target);
|
|
loadOlder(entry.target);
|
|
}
|
|
});
|
|
}, {root: artifactList, rootMargin: "200px"});
|
|
|
|
const observeSentinel = () => {
|
|
const sentinel = artifactList.querySelector("[data-feed-more]");
|
|
if (sentinel) olderObserver.observe(sentinel);
|
|
};
|
|
|
|
observeSentinel();
|
|
|
|
// Populate the details panel for a server-preselected artifact so the first
|
|
// "Details" click shows content immediately.
|
|
const preselected = selectedRow();
|
|
if (preselected) renderDetails(preselected);
|
|
|
|
const toggleKeep = async (button) => {
|
|
const artifactId = button.dataset.artifactId;
|
|
if (!artifactId) return;
|
|
button.disabled = true;
|
|
try {
|
|
const response = await fetch(`/a/${artifactId}/keep`, {
|
|
method: "POST",
|
|
headers: {"X-CSRFToken": csrfToken},
|
|
});
|
|
const data = await response.json();
|
|
syncKeepButton(data.is_kept);
|
|
const row = document.querySelector(`.artifact-row[data-artifact-id="${artifactId}"]`);
|
|
if (row) {
|
|
row.dataset.kept = data.is_kept ? "1" : "0";
|
|
row.classList.toggle("kept", data.is_kept);
|
|
const badge = row.querySelector("[data-kept-badge]");
|
|
if (badge) badge.hidden = !data.is_kept;
|
|
}
|
|
} catch {
|
|
button.disabled = false;
|
|
}
|
|
};
|
|
|
|
previewPane.addEventListener("click", (event) => {
|
|
const copyButton = event.target.closest("[data-copy-url]");
|
|
if (copyButton) {
|
|
event.preventDefault();
|
|
copyLink(copyButton);
|
|
return;
|
|
}
|
|
|
|
const keepButton = event.target.closest("[data-keep-button]");
|
|
if (keepButton && !keepButton.disabled) {
|
|
event.preventDefault();
|
|
toggleKeep(keepButton);
|
|
return;
|
|
}
|
|
|
|
const detailsToggle = event.target.closest("[data-details-toggle]");
|
|
if (detailsToggle && !detailsToggle.disabled) {
|
|
event.preventDefault();
|
|
const panel = document.querySelector("[data-details-panel]");
|
|
if (panel) {
|
|
panel.hidden = !panel.hidden;
|
|
detailsToggle.classList.toggle("selected", !panel.hidden);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const toggle = event.target.closest("[data-view]");
|
|
if (!toggle) return;
|
|
if (toggle.classList.contains("disabled")) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
const row = selectedRow();
|
|
if (!row) return;
|
|
event.preventDefault();
|
|
selectedView = toggle.dataset.view;
|
|
renderPreview(row);
|
|
});
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|