- Settings -> Account: change your own password (current + new), session
preserved via update_session_auth_hash.
- Custom labels: per-user palette in Settings -> Labels; apply/remove from a
message's ... menu (menu stays open, checkmark = applied). Labels are unified
with tags; chip colours sync to the global TagColor. New Label model +
migration 0017; POST /a/{id}/label (session, owner-scoped, palette-validated).
- Sidebar Keep/Deleted folders are now always shown; count badge only when
non-empty. Updates the stale 'hide when empty' note in CLAUDE.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1128 lines
51 KiB
HTML
1128 lines
51 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="icon" type="image/png" href="/static/favicon.png">
|
|
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png">
|
|
<link rel="stylesheet" href="/static/agentfeed.css">
|
|
<script>
|
|
// Set the theme before first paint so dark-mode users never see a light flash.
|
|
(function () {
|
|
try {
|
|
if (localStorage.getItem("agentfeed.theme") === "dark") {
|
|
document.documentElement.setAttribute("data-theme", "dark");
|
|
}
|
|
} catch (e) {}
|
|
})();
|
|
</script>
|
|
</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-folder="{{ selected_folder }}"
|
|
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 all-sources-link {% if not selected_source and not selected_folder %}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" data-mark-read role="button" tabindex="0" title="Mark all as read">{{ total_count }}</span>{% endif %}
|
|
</a>
|
|
{% for entry in source_counts %}
|
|
<a class="source-link {% if entry.source == selected_source and not selected_folder %}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" data-source="{{ entry.source }}">
|
|
<span class="source-name">{{ entry.source }}</span>
|
|
{% if entry.count %}<span class="source-count" data-mark-read role="button" tabindex="0" title="Mark all as read">{{ entry.count }}</span>{% endif %}
|
|
</a>
|
|
{% endfor %}
|
|
</nav>
|
|
|
|
<nav class="system-folders" aria-label="Folders">
|
|
<a class="source-link folder-link {% if selected_folder == 'keep' %}selected{% endif %}" href="?folder=keep&view={{ selected_view }}&preview=none" data-folder-link="keep">
|
|
<span class="source-name">★ Keep</span>
|
|
{% if keep_count %}<span class="source-count">{{ keep_count }}</span>{% endif %}
|
|
</a>
|
|
<a class="source-link folder-link {% if selected_folder == 'deleted' %}selected{% endif %}" href="?folder=deleted&view={{ selected_view }}&preview=none" data-folder-link="deleted">
|
|
<span class="source-name">🗑 Deleted</span>
|
|
{% if trash_count %}<span class="source-count">{{ trash_count }}</span>{% endif %}
|
|
</a>
|
|
</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_folder == 'deleted' %}Deleted{% elif selected_folder == 'keep' %}Keep{% elif selected_source %}{{ selected_source }}{% else %}All sources{% endif %}</h2>
|
|
{% if selected_folder == 'deleted' %}
|
|
<button type="button" class="empty-trash-button" data-empty-trash>Empty Trash</button>
|
|
{% else %}
|
|
<span>{{ artifacts|length }} message{{ artifacts|length|pluralize }}</span>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<div class="artifact-list{% if selected_source %} no-source-col{% endif %}" data-artifact-list data-since="{{ feed_since }}">
|
|
{% 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 messages</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 %}title="{{ selected_artifact.title }}"{% endif %}>
|
|
{% if selected_artifact %}{{ selected_artifact.title }}{% else %}No message 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" class="share-button {% if selected_artifact.is_shared %}shared{% endif %}" data-share-button data-artifact-id="{% if selected_artifact %}{{ selected_artifact.id }}{% endif %}" {% if not selected_artifact %}disabled{% endif %}>{% if selected_artifact.is_shared %}🔗 Shared{% else %}Share{% 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>
|
|
<div class="share-link-box" data-share-link-box {% if not selected_artifact.is_shared %}hidden{% endif %}>
|
|
<input type="text" readonly data-share-link-input value="{% if selected_artifact.is_shared %}{{ request.scheme }}://{{ request.get_host }}{% url 'artifact-shared' selected_artifact.share_token %}{% endif %}">
|
|
<button type="button" data-copy-url="{% if selected_artifact.is_shared %}{{ request.scheme }}://{{ request.get_host }}{% url 'artifact-shared' selected_artifact.share_token %}{% endif %}">Copy</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 Message</h2>
|
|
<p>This message 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 message loaded</h2>
|
|
<p>Press <kbd>Right Arrow</kbd> or select a message from the middle column to preview it.</p>
|
|
{% else %}
|
|
<h2>No messages for this day</h2>
|
|
<p>This day is ready for new agent output.</p>
|
|
{% endif %}
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</section>
|
|
</main>
|
|
|
|
<div class="row-menu" hidden data-row-menu>
|
|
<button type="button" data-menu-action="keep">Keep</button>
|
|
<button type="button" data-menu-action="open">Open</button>
|
|
<button type="button" data-menu-action="copy">Copy link</button>
|
|
<button type="button" data-menu-action="delete" class="danger">Delete</button>
|
|
{% if label_palette %}
|
|
<div class="row-menu-divider" role="separator"></div>
|
|
<div class="row-menu-labels">
|
|
{% for label in label_palette %}
|
|
<button type="button" class="row-menu-label" data-label-name="{{ label.name }}" data-label-color="{{ label.color }}">
|
|
<span class="row-menu-dot" style="background:{{ label.color }}"></span>
|
|
<span class="row-menu-label-name">{{ label.name }}</span>
|
|
<span class="row-menu-check" aria-hidden="true">✓</span>
|
|
</button>
|
|
{% endfor %}
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<script>
|
|
(() => {
|
|
const feed = document.querySelector(".feed");
|
|
if (!feed) return;
|
|
let selectedView = "{{ selected_view }}";
|
|
|
|
// When a single source is selected, the row's source column is dropped
|
|
// (see .no-source-col), so the artifact sidebar needs ~7rem less width.
|
|
// The stored width is the "all sources" (wide) width; a selected source
|
|
// renders it minus this fixed offset. Dragging the splitter in either
|
|
// mode adjusts the stored width directly, so the width is settable in
|
|
// whichever mode you're in and the other mode tracks it by the offset.
|
|
const SOURCE_COL_WIDTH = 112; // 7rem, matches the dropped source column
|
|
const sourceSelected = Boolean(feed.dataset.selectedSource);
|
|
|
|
const sizes = {
|
|
days: Number(localStorage.getItem("agentfeed.daysWidth")) || 144,
|
|
artifacts: Number(localStorage.getItem("agentfeed.artifactsWidth")) || 280,
|
|
};
|
|
|
|
const applySizes = () => {
|
|
feed.style.setProperty("--days-width", `${sizes.days}px`);
|
|
const artifactsWidth = sourceSelected
|
|
? Math.max(150, sizes.artifacts - SOURCE_COL_WIDTH)
|
|
: sizes.artifacts;
|
|
feed.style.setProperty("--artifacts-width", `${artifactsWidth}px`);
|
|
};
|
|
|
|
applySizes();
|
|
|
|
// Per-pane resize bounds. The artifact list may grow much wider than the
|
|
// sources sidebar, but is still capped so it can't swallow the preview.
|
|
const sizeBounds = {
|
|
days: {min: 96, max: 320},
|
|
artifacts: {min: 200, max: 720},
|
|
};
|
|
|
|
document.querySelectorAll(".splitter").forEach((splitter) => {
|
|
splitter.addEventListener("pointerdown", (event) => {
|
|
const target = splitter.dataset.splitter;
|
|
const startX = event.clientX;
|
|
const startWidth = sizes[target];
|
|
const bounds = sizeBounds[target];
|
|
splitter.setPointerCapture(event.pointerId);
|
|
document.body.classList.add("is-resizing");
|
|
|
|
const move = (moveEvent) => {
|
|
sizes[target] = Math.max(bounds.min, Math.min(bounds.max, startWidth + moveEvent.clientX - startX));
|
|
applySizes();
|
|
};
|
|
|
|
const up = () => {
|
|
localStorage.setItem(`agentfeed.${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"));
|
|
// Live-queried so sources added by live updates are keyboard-navigable too.
|
|
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 message 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;
|
|
previewTitle.title = 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;
|
|
syncShareButton(row.dataset.shared === "1", row.dataset.shareUrl || "");
|
|
const shareButton = actions.querySelector("[data-share-button]");
|
|
if (shareButton) {
|
|
shareButton.disabled = false;
|
|
shareButton.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 syncShareButton = (isShared, shareUrl) => {
|
|
const shareButton = document.querySelector("[data-share-button]");
|
|
if (shareButton) {
|
|
shareButton.classList.toggle("shared", isShared);
|
|
shareButton.textContent = isShared ? "🔗 Shared" : "Share";
|
|
}
|
|
const box = document.querySelector("[data-share-link-box]");
|
|
if (!box) return;
|
|
box.hidden = !isShared;
|
|
const input = box.querySelector("[data-share-link-input]");
|
|
if (input) input.value = isShared ? shareUrl : "";
|
|
const copyButton = box.querySelector("[data-copy-url]");
|
|
if (copyButton) copyButton.dataset.copyUrl = isShared ? shareUrl : "";
|
|
};
|
|
|
|
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 message loaded";
|
|
previewTitle.removeAttribute("title");
|
|
}
|
|
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";
|
|
}
|
|
const shareButton = actions.querySelector("[data-share-button]");
|
|
if (shareButton) {
|
|
shareButton.disabled = true;
|
|
shareButton.classList.remove("shared");
|
|
shareButton.textContent = "Share";
|
|
}
|
|
syncShareButton(false, "");
|
|
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 findSourceLink = (name) =>
|
|
sourceLinks().find((link) => link.querySelector(".source-name")?.textContent.trim() === name);
|
|
|
|
const bumpSourceBadge = (link, delta) => {
|
|
let badge = link.querySelector(".source-count");
|
|
const next = (badge ? Number(badge.textContent) || 0 : 0) + delta;
|
|
if (next <= 0) {
|
|
if (badge) badge.remove(); // empty folder/source shows no count badge
|
|
return;
|
|
}
|
|
if (!badge) {
|
|
badge = document.createElement("span");
|
|
badge.className = "source-count";
|
|
link.appendChild(badge);
|
|
}
|
|
badge.textContent = String(next);
|
|
};
|
|
|
|
const addSourceLink = (name, count) => {
|
|
const params = new URLSearchParams();
|
|
params.set("source", name);
|
|
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);
|
|
params.set("view", feed.dataset.selectedView || "rendered");
|
|
params.set("preview", "none");
|
|
|
|
const link = document.createElement("a");
|
|
link.className = "source-link";
|
|
link.href = `?${params.toString()}`;
|
|
link.innerHTML =
|
|
`<span class="source-name">${escapeHtml(name)}</span>` +
|
|
`<span class="source-count">${count}</span>`;
|
|
|
|
// Newest activity goes to the top of the per-source list (just under
|
|
// the pinned "All sources"), matching the recency ordering.
|
|
const list = document.querySelector(".source-list");
|
|
const firstSource = list.querySelector(".source-link:not(.all-sources-link)");
|
|
list.insertBefore(link, firstSource);
|
|
};
|
|
|
|
const moveSourceToTop = (link) => {
|
|
const list = document.querySelector(".source-list");
|
|
const firstSource = list.querySelector(".source-link:not(.all-sources-link)");
|
|
if (firstSource !== link) list.insertBefore(link, firstSource);
|
|
};
|
|
|
|
// From the newly-arrived rows: bump each source's unread count (adding a
|
|
// sidebar entry for a source seen for the first time) plus the total.
|
|
const registerNewSources = (rows) => {
|
|
const counts = {};
|
|
rows.forEach((row) => {
|
|
const source = row.dataset.source;
|
|
if (source) counts[source] = (counts[source] || 0) + 1;
|
|
});
|
|
let total = 0;
|
|
Object.entries(counts).forEach(([source, n]) => {
|
|
total += n;
|
|
const existing = findSourceLink(source);
|
|
if (existing) {
|
|
bumpSourceBadge(existing, n);
|
|
moveSourceToTop(existing); // most-recent activity -> top (recency order)
|
|
} else {
|
|
addSourceLink(source, n);
|
|
}
|
|
});
|
|
const allLink = findSourceLink("All sources");
|
|
if (allLink && total) bumpSourceBadge(allLink, total);
|
|
};
|
|
|
|
// --- Delete / undelete / empty trash ---
|
|
const updateArtifactCount = (delta) => {
|
|
const header = document.querySelector(".artifact-sidebar .pane-heading span");
|
|
if (!header) return;
|
|
const total = Math.max(0, (parseInt(header.textContent, 10) || 0) + delta);
|
|
header.textContent = `${total} message${total === 1 ? "" : "s"}`;
|
|
};
|
|
|
|
const bumpFolderCount = (folder, delta) => {
|
|
const link = document.querySelector(`[data-folder-link="${folder}"]`);
|
|
if (link) bumpSourceBadge(link, delta);
|
|
};
|
|
|
|
const removeRowFromList = (row) => {
|
|
const group = row.closest(".day-group");
|
|
row.remove();
|
|
if (group) {
|
|
const remaining = group.querySelectorAll(".artifact-row").length;
|
|
const count = group.querySelector(".day-separator-count");
|
|
if (count) count.textContent = String(remaining);
|
|
if (remaining === 0) group.remove();
|
|
}
|
|
};
|
|
|
|
const softDeleteRow = async (id) => {
|
|
const row = artifactList.querySelector(`.artifact-row[data-artifact-id="${id}"]`);
|
|
try {
|
|
const response = await fetch(`/a/${id}/delete`, {method: "POST", headers: {"X-CSRFToken": csrfToken}});
|
|
if (!response.ok) return;
|
|
} catch { return; }
|
|
if (!row) return;
|
|
if (row.classList.contains("selected")) clearPreview();
|
|
if (row.classList.contains("unread")) decrementSourceCount(row.dataset.source);
|
|
removeRowFromList(row);
|
|
updateArtifactCount(-1);
|
|
bumpFolderCount("deleted", 1); // best-effort; the folder appears on next nav if it's the first delete
|
|
};
|
|
|
|
const undeleteRow = async (id) => {
|
|
const row = artifactList.querySelector(`.artifact-row[data-artifact-id="${id}"]`);
|
|
try {
|
|
const response = await fetch(`/a/${id}/undelete`, {method: "POST", headers: {"X-CSRFToken": csrfToken}});
|
|
if (!response.ok) return;
|
|
} catch { return; }
|
|
if (!row) return;
|
|
removeRowFromList(row);
|
|
bumpFolderCount("deleted", -1);
|
|
// The restored item reappears in the feed on the next navigation (full render).
|
|
};
|
|
|
|
const emptyTrashButton = document.querySelector("[data-empty-trash]");
|
|
if (emptyTrashButton) {
|
|
emptyTrashButton.addEventListener("click", async () => {
|
|
if (!window.confirm("Permanently delete everything in Trash? This can't be undone.")) return;
|
|
try {
|
|
const response = await fetch("{% url 'feed-empty-trash' %}", {method: "POST", headers: {"X-CSRFToken": csrfToken}});
|
|
if (!response.ok) return;
|
|
} catch { return; }
|
|
window.location.href = window.location.pathname; // trash empty; folder gone
|
|
});
|
|
}
|
|
|
|
// --- Per-row actions menu (⋯ trigger + right-click) ---
|
|
const rowMenu = document.querySelector("[data-row-menu]");
|
|
let menuRowId = null;
|
|
|
|
const currentMenuRow = () =>
|
|
menuRowId ? artifactList.querySelector(`.artifact-row[data-artifact-id="${menuRowId}"]`) : null;
|
|
|
|
const closeRowMenu = () => {
|
|
if (rowMenu) rowMenu.hidden = true;
|
|
menuRowId = null;
|
|
};
|
|
|
|
const parseRowTags = (row) =>
|
|
(row.dataset.tags || "").split(",").map((t) => t.trim()).filter(Boolean);
|
|
|
|
const refreshMenuLabelState = (row) => {
|
|
const applied = new Set(parseRowTags(row));
|
|
rowMenu.querySelectorAll(".row-menu-label").forEach((btn) => {
|
|
btn.classList.toggle("applied", applied.has(btn.dataset.labelName));
|
|
});
|
|
};
|
|
|
|
const openRowMenu = (row, x, y) => {
|
|
if (!rowMenu || !row) return;
|
|
menuRowId = row.dataset.artifactId;
|
|
const keepBtn = rowMenu.querySelector('[data-menu-action="keep"]');
|
|
if (keepBtn) keepBtn.textContent = row.dataset.kept === "1" ? "Remove from Keep" : "Keep";
|
|
refreshMenuLabelState(row);
|
|
rowMenu.hidden = false;
|
|
const rect = rowMenu.getBoundingClientRect();
|
|
const left = Math.max(8, Math.min(x, window.innerWidth - rect.width - 8));
|
|
const top = Math.max(8, Math.min(y, window.innerHeight - rect.height - 8));
|
|
rowMenu.style.left = `${left}px`;
|
|
rowMenu.style.top = `${top}px`;
|
|
};
|
|
|
|
const copyText = async (text) => {
|
|
try { await navigator.clipboard.writeText(text); } catch (e) { /* clipboard unavailable */ }
|
|
};
|
|
|
|
const keepFromMenu = async (row, id) => {
|
|
try {
|
|
const response = await fetch(`/a/${id}/keep`, {method: "POST", headers: {"X-CSRFToken": csrfToken}});
|
|
if (!response.ok) return;
|
|
const data = await response.json();
|
|
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;
|
|
bumpFolderCount("keep", data.is_kept ? 1 : -1);
|
|
if (feed.dataset.folder === "keep" && !data.is_kept) removeRowFromList(row);
|
|
const keepButton = document.querySelector("[data-keep-button]");
|
|
if (keepButton && keepButton.dataset.artifactId === id) {
|
|
keepButton.classList.toggle("kept", data.is_kept);
|
|
keepButton.textContent = data.is_kept ? "✓ Kept" : "Keep";
|
|
}
|
|
} catch (e) { /* leave as-is */ }
|
|
};
|
|
|
|
const addRowChip = (row, name, color) => {
|
|
const main = row.querySelector(".artifact-row-main");
|
|
if (!main) return;
|
|
let chips = main.querySelector(".tag-chips");
|
|
if (!chips) {
|
|
chips = document.createElement("span");
|
|
chips.className = "tag-chips";
|
|
main.appendChild(chips);
|
|
}
|
|
if ([...chips.querySelectorAll(".tag-chip")].some((c) => c.textContent === name)) return;
|
|
const chip = document.createElement("span");
|
|
chip.className = "tag-chip";
|
|
chip.style.background = color;
|
|
chip.textContent = name;
|
|
chips.appendChild(chip);
|
|
};
|
|
|
|
const removeRowChip = (row, name) => {
|
|
const chips = row.querySelector(".tag-chips");
|
|
if (!chips) return;
|
|
chips.querySelectorAll(".tag-chip").forEach((c) => { if (c.textContent === name) c.remove(); });
|
|
if (!chips.querySelector(".tag-chip")) chips.remove();
|
|
};
|
|
|
|
// Toggle a palette label on the current menu row. Leaves the menu open so
|
|
// several labels can be applied in one go.
|
|
const toggleLabelFromMenu = async (btn) => {
|
|
const row = currentMenuRow();
|
|
const id = menuRowId;
|
|
if (!row || !id) return;
|
|
const name = btn.dataset.labelName;
|
|
const body = new URLSearchParams();
|
|
body.set("name", name);
|
|
let data;
|
|
try {
|
|
const response = await fetch(`/a/${id}/label`, {
|
|
method: "POST",
|
|
headers: {"X-CSRFToken": csrfToken, "Content-Type": "application/x-www-form-urlencoded"},
|
|
body: body.toString(),
|
|
});
|
|
if (!response.ok) return;
|
|
data = await response.json();
|
|
} catch (e) { return; }
|
|
row.dataset.tags = data.tags.join(", ");
|
|
btn.classList.toggle("applied", data.applied);
|
|
if (data.applied) addRowChip(row, data.name, data.color);
|
|
else removeRowChip(row, data.name);
|
|
};
|
|
|
|
if (rowMenu) {
|
|
rowMenu.addEventListener("click", (event) => {
|
|
const labelBtn = event.target.closest(".row-menu-label");
|
|
if (labelBtn) {
|
|
toggleLabelFromMenu(labelBtn);
|
|
return; // keep the menu open for applying multiple labels
|
|
}
|
|
const btn = event.target.closest("[data-menu-action]");
|
|
if (!btn) return;
|
|
const row = currentMenuRow();
|
|
const id = menuRowId;
|
|
closeRowMenu();
|
|
if (!row || !id) return;
|
|
const action = btn.dataset.menuAction;
|
|
if (action === "delete") softDeleteRow(id);
|
|
else if (action === "keep") keepFromMenu(row, id);
|
|
else if (action === "open") window.open(row.dataset.renderedUrl, "_blank", "noopener");
|
|
else if (action === "copy") copyText(window.location.origin + row.dataset.renderedUrl);
|
|
});
|
|
}
|
|
|
|
document.addEventListener("click", (event) => {
|
|
if (rowMenu && !rowMenu.hidden && !rowMenu.contains(event.target)) closeRowMenu();
|
|
});
|
|
document.addEventListener("keydown", (event) => {
|
|
if (event.key === "Escape") closeRowMenu();
|
|
});
|
|
|
|
// Right-click a feed row opens the same menu at the cursor.
|
|
artifactList.addEventListener("contextmenu", (event) => {
|
|
const row = event.target.closest(".artifact-row");
|
|
if (!row || feed.dataset.folder) return; // normal feed only
|
|
event.preventDefault();
|
|
openRowMenu(row, event.clientX, event.clientY);
|
|
});
|
|
|
|
// Click a source's unread badge to mark all of that source's messages read.
|
|
const markSourceRead = async (badge) => {
|
|
const link = badge.closest(".source-link");
|
|
if (!link) return;
|
|
const isAll = link.classList.contains("all-sources-link");
|
|
const source = link.dataset.source || "";
|
|
const body = new URLSearchParams();
|
|
if (!isAll && source) body.set("source", source);
|
|
try {
|
|
const response = await fetch("{% url 'feed-mark-read' %}", {
|
|
method: "POST",
|
|
headers: {"X-CSRFToken": csrfToken, "Content-Type": "application/x-www-form-urlencoded"},
|
|
body: body.toString(),
|
|
});
|
|
if (!response.ok) return;
|
|
} catch (e) { return; }
|
|
if (isAll) {
|
|
document.querySelectorAll(".source-list .source-count").forEach((b) => b.remove());
|
|
artifactList.querySelectorAll(".artifact-row.unread").forEach((r) => r.classList.remove("unread"));
|
|
} else {
|
|
const n = parseInt(badge.textContent, 10) || 0;
|
|
badge.remove();
|
|
const allBadge = document.querySelector(".all-sources-link .source-count");
|
|
if (allBadge) {
|
|
const t = (parseInt(allBadge.textContent, 10) || 0) - n;
|
|
if (t > 0) allBadge.textContent = String(t);
|
|
else allBadge.remove();
|
|
}
|
|
artifactList
|
|
.querySelectorAll(`.artifact-row.unread[data-source="${CSS.escape(source)}"]`)
|
|
.forEach((r) => r.classList.remove("unread"));
|
|
}
|
|
};
|
|
|
|
const sourceListEl = document.querySelector(".source-list");
|
|
if (sourceListEl) {
|
|
const badgeFromEvent = (event) => event.target.closest("[data-mark-read]");
|
|
sourceListEl.addEventListener("click", (event) => {
|
|
const badge = badgeFromEvent(event);
|
|
if (!badge) return;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
markSourceRead(badge);
|
|
});
|
|
sourceListEl.addEventListener("keydown", (event) => {
|
|
if (event.key !== "Enter" && event.key !== " ") return;
|
|
const badge = badgeFromEvent(event);
|
|
if (!badge) return;
|
|
event.preventDefault();
|
|
markSourceRead(badge);
|
|
});
|
|
}
|
|
|
|
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 Message</h2><p>This message 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 message loaded</h2><p>Press <kbd>Right Arrow</kbd> or select a message 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) => {
|
|
// Row action icons (delete/undelete/menu) intercept before selection.
|
|
const action = event.target.closest("[data-delete], [data-undelete], [data-menu-trigger]");
|
|
if (action) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
if (action.hasAttribute("data-menu-trigger")) {
|
|
const rect = action.getBoundingClientRect();
|
|
openRowMenu(action.closest(".artifact-row"), rect.right, rect.bottom);
|
|
} else if (action.hasAttribute("data-delete")) {
|
|
softDeleteRow(action.getAttribute("data-delete"));
|
|
} else {
|
|
undeleteRow(action.getAttribute("data-undelete"));
|
|
}
|
|
return;
|
|
}
|
|
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) => {
|
|
const links = sourceLinks();
|
|
if (!links.length) return;
|
|
navigationMode = "sources";
|
|
const current = document.querySelector(".source-link.selected");
|
|
const currentIndex = current ? links.indexOf(current) : 0;
|
|
const nextIndex = Math.max(0, Math.min(links.length - 1, currentIndex + direction));
|
|
const nextSource = links[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();
|
|
|
|
// --- Live updates: poll for new artifacts and prepend them at the top. ---
|
|
// Cheap: a small "anything since X?" fetch every few seconds, paused while
|
|
// the tab is hidden. New rows are inserted without touching the selected
|
|
// row or the preview, so what you're reading stays put. Respects the
|
|
// active source/label/kind/q filters (passed through below).
|
|
const LIVE_POLL_MS = 5000;
|
|
const NOW_TTL_MS = 3 * 60 * 1000; // show "now", then age into the real time
|
|
let liveSince = artifactList.dataset.since || null;
|
|
let livePolling = false;
|
|
|
|
const buildUpdatesUrl = () => {
|
|
const url = new URL("{% url 'feed-updates' %}", window.location.origin);
|
|
const p = url.searchParams;
|
|
if (liveSince) p.set("after", liveSince);
|
|
p.set("view", feed.dataset.selectedView || "rendered");
|
|
if (feed.dataset.selectedSource) p.set("source", feed.dataset.selectedSource);
|
|
if (feed.dataset.selectedLabel) p.set("label", feed.dataset.selectedLabel);
|
|
if (feed.dataset.selectedKind) p.set("kind", feed.dataset.selectedKind);
|
|
if (feed.dataset.titleQuery) p.set("q", feed.dataset.titleQuery);
|
|
return url;
|
|
};
|
|
|
|
const ensureTodayGroup = (todayLabel) => {
|
|
let group = artifactList.querySelector(".day-group[data-today]");
|
|
if (group) return group;
|
|
const empty = artifactList.querySelector(".empty");
|
|
if (empty) empty.remove();
|
|
group = document.createElement("div");
|
|
group.className = "day-group";
|
|
group.setAttribute("data-today", "1");
|
|
group.innerHTML =
|
|
`<div class="day-separator"><span>${escapeHtml(todayLabel)} <em class="today-badge">Today</em></span>` +
|
|
`<span class="day-separator-count">0</span></div>`;
|
|
artifactList.insertBefore(group, artifactList.firstChild);
|
|
return group;
|
|
};
|
|
|
|
const markArrived = (rows) => {
|
|
rows.forEach((row) => {
|
|
const timeEl = row.querySelector(".artifact-time");
|
|
if (timeEl) {
|
|
timeEl.dataset.realtime = timeEl.textContent;
|
|
timeEl.textContent = "now";
|
|
setTimeout(() => {
|
|
if (timeEl.dataset.realtime) timeEl.textContent = timeEl.dataset.realtime;
|
|
}, NOW_TTL_MS);
|
|
}
|
|
row.classList.add("just-arrived");
|
|
setTimeout(() => row.classList.remove("just-arrived"), 1500);
|
|
});
|
|
};
|
|
|
|
const pollUpdates = async () => {
|
|
// Don't poll while viewing a system folder (Deleted/Keep) — new live
|
|
// posts don't belong in those views.
|
|
if (livePolling || document.hidden || !liveSince || feed.dataset.folder) return;
|
|
livePolling = true;
|
|
try {
|
|
const response = await fetch(buildUpdatesUrl(), {headers: {"X-Requested-With": "fetch"}});
|
|
if (!response.ok) return;
|
|
const data = await response.json();
|
|
if (!data.count) return;
|
|
const group = ensureTodayGroup(data.today_label);
|
|
const separator = group.querySelector(".day-separator");
|
|
// Newest-first batch goes immediately below the separator, above prior rows.
|
|
separator.insertAdjacentHTML("afterend", data.html);
|
|
const inserted = [];
|
|
let node = separator.nextElementSibling;
|
|
while (node && inserted.length < data.count) {
|
|
if (node.classList.contains("artifact-row")) inserted.push(node);
|
|
node = node.nextElementSibling;
|
|
}
|
|
markArrived(inserted);
|
|
registerNewSources(inserted); // update sidebar: counts + any new source
|
|
liveSince = data.newest;
|
|
const dayCount = group.querySelector(".day-separator-count");
|
|
if (dayCount) dayCount.textContent = String((parseInt(dayCount.textContent, 10) || 0) + data.count);
|
|
const header = document.querySelector(".artifact-sidebar .pane-heading span");
|
|
if (header) {
|
|
const total = (parseInt(header.textContent, 10) || 0) + data.count;
|
|
header.textContent = `${total} message${total === 1 ? "" : "s"}`;
|
|
}
|
|
} catch {
|
|
// ignore; retry next tick
|
|
} finally {
|
|
livePolling = false;
|
|
}
|
|
};
|
|
|
|
setInterval(pollUpdates, LIVE_POLL_MS);
|
|
document.addEventListener("visibilitychange", () => { if (!document.hidden) pollUpdates(); });
|
|
|
|
// 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;
|
|
}
|
|
};
|
|
|
|
const toggleShare = async (button) => {
|
|
const artifactId = button.dataset.artifactId;
|
|
if (!artifactId) return;
|
|
button.disabled = true;
|
|
try {
|
|
const response = await fetch(`/a/${artifactId}/share`, {
|
|
method: "POST",
|
|
headers: {"X-CSRFToken": csrfToken},
|
|
});
|
|
const data = await response.json();
|
|
syncShareButton(data.is_shared, data.share_url || "");
|
|
const row = document.querySelector(`.artifact-row[data-artifact-id="${artifactId}"]`);
|
|
if (row) {
|
|
row.dataset.shared = data.is_shared ? "1" : "0";
|
|
row.dataset.shareUrl = data.share_url || "";
|
|
row.classList.toggle("shared", data.is_shared);
|
|
const badge = row.querySelector("[data-shared-badge]");
|
|
if (badge) badge.hidden = !data.is_shared;
|
|
}
|
|
} finally {
|
|
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 shareButton = event.target.closest("[data-share-button]");
|
|
if (shareButton && !shareButton.disabled) {
|
|
event.preventDefault();
|
|
toggleShare(shareButton);
|
|
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>
|