v0.4.0: live feed updates (auto-insert new posts)
New artifacts appear at the top of the feed without a manual refresh. - GET /feed/updates (session-authed): new artifacts since a timestamp marker, owner-scoped and filter-aware (source/label/kind/q), returned as row HTML. - Feed JS polls every 5s, paused while the tab is hidden (immediate poll on refocus). Prepends new rows into today's group without touching the selected row or the preview iframe — what you're reading stays put and selected. - Fresh rows show "now", aging into their real time after ~3 min; brief highlight on arrival. - Extracted _artifact_rows.html partial, shared by the day-group render and the updates endpoint, so live rows match exactly. - Polling chosen over SSE/WebSockets: zero infra on sync-WSGI + SQLite (see planning/next_steps for the SSE upgrade path). 142 tests pass; feed page + endpoint verified. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
f6f004c689
commit
cae414c307
9 changed files with 289 additions and 40 deletions
34
templates/artifacts/_artifact_rows.html
Normal file
34
templates/artifacts/_artifact_rows.html
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
{% for artifact in artifacts %}
|
||||
<a class="artifact-row {% if selected_artifact.id == artifact.id %}selected{% endif %} {% if artifact.is_unread %}unread{% endif %} {% if artifact.is_kept %}kept{% endif %} {% if artifact.is_shared %}shared{% endif %}"
|
||||
href="?{% if selected_source %}source={{ selected_source|urlencode }}&{% endif %}{% if selected_label %}label={{ selected_label|urlencode }}&{% endif %}{% if selected_kind %}kind={{ selected_kind|urlencode }}&{% endif %}{% if title_query %}q={{ title_query|urlencode }}&{% endif %}artifact={{ artifact.id }}&view={{ selected_view }}"
|
||||
data-artifact-id="{{ artifact.id }}"
|
||||
data-title="{{ artifact.title }}"
|
||||
data-kind="{{ artifact.kind }}"
|
||||
data-source="{{ artifact.source }}"
|
||||
data-source-type="{{ artifact.source_type }}"
|
||||
data-source-name="{{ artifact.source_name }}"
|
||||
data-created="{{ artifact.created_at|date:'M j Y, H:i' }}"
|
||||
data-size="{{ artifact.size_bytes|filesizeformat }}"
|
||||
data-kept="{{ artifact.is_kept|yesno:'1,0' }}"
|
||||
data-shared="{{ artifact.is_shared|yesno:'1,0' }}"
|
||||
data-share-url="{% if artifact.is_shared %}{{ request.scheme }}://{{ request.get_host }}{% url 'artifact-shared' artifact.share_token %}{% endif %}"
|
||||
data-tags="{{ artifact.tags|join:', ' }}"
|
||||
data-rendered-url="{% url 'artifact' artifact.id %}"
|
||||
data-raw-url="{% url 'artifact' artifact.id %}?raw=1">
|
||||
<time class="artifact-time" datetime="{{ artifact.created_at|date:'c' }}">{{ artifact.created_at|date:"H:i" }}</time>
|
||||
<span class="artifact-row-main">
|
||||
<span class="artifact-title">{{ artifact.title }}</span>
|
||||
<span class="artifact-kind-text">{% if artifact.kind == "image" %}image{% elif artifact.kind == "pdf" %}pdf{% elif artifact.kind == "markdown" %}markdown{% elif artifact.kind == "deck" %}deck{% elif artifact.kind == "binary" %}binary{% else %}html{% endif %}</span>
|
||||
<span class="kept-badge" data-kept-badge title="Kept — never auto-deleted"{% if not artifact.is_kept %} hidden{% endif %}>K</span>
|
||||
<span class="shared-badge" data-shared-badge title="Shared — anyone with the link can view"{% if not artifact.is_shared %} hidden{% endif %}>🔗</span>
|
||||
{% if artifact.label_chips %}
|
||||
<span class="tag-chips">
|
||||
{% for label in artifact.label_chips %}
|
||||
<span class="tag-chip" style="background:{{ label.color }}">{{ label.name }}</span>
|
||||
{% endfor %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
<span class="artifact-source">{{ artifact.source }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
|
@ -1,43 +1,10 @@
|
|||
{% for group in day_groups %}
|
||||
<div class="day-group">
|
||||
<div class="day-group"{% if group.day == today %} data-today="1"{% endif %}>
|
||||
<div class="day-separator">
|
||||
<span>{{ group.day|date:"D, M j Y" }}{% if group.day == today %} <em class="today-badge">Today</em>{% endif %}</span>
|
||||
<span class="day-separator-count">{{ group.artifacts|length }}</span>
|
||||
</div>
|
||||
{% for artifact in group.artifacts %}
|
||||
<a class="artifact-row {% if selected_artifact.id == artifact.id %}selected{% endif %} {% if artifact.is_unread %}unread{% endif %} {% if artifact.is_kept %}kept{% endif %} {% if artifact.is_shared %}shared{% endif %}"
|
||||
href="?{% if selected_source %}source={{ selected_source|urlencode }}&{% endif %}{% if selected_label %}label={{ selected_label|urlencode }}&{% endif %}{% if selected_kind %}kind={{ selected_kind|urlencode }}&{% endif %}{% if title_query %}q={{ title_query|urlencode }}&{% endif %}artifact={{ artifact.id }}&view={{ selected_view }}"
|
||||
data-artifact-id="{{ artifact.id }}"
|
||||
data-title="{{ artifact.title }}"
|
||||
data-kind="{{ artifact.kind }}"
|
||||
data-source="{{ artifact.source }}"
|
||||
data-source-type="{{ artifact.source_type }}"
|
||||
data-source-name="{{ artifact.source_name }}"
|
||||
data-created="{{ artifact.created_at|date:'M j Y, H:i' }}"
|
||||
data-size="{{ artifact.size_bytes|filesizeformat }}"
|
||||
data-kept="{{ artifact.is_kept|yesno:'1,0' }}"
|
||||
data-shared="{{ artifact.is_shared|yesno:'1,0' }}"
|
||||
data-share-url="{% if artifact.is_shared %}{{ request.scheme }}://{{ request.get_host }}{% url 'artifact-shared' artifact.share_token %}{% endif %}"
|
||||
data-tags="{{ artifact.tags|join:', ' }}"
|
||||
data-rendered-url="{% url 'artifact' artifact.id %}"
|
||||
data-raw-url="{% url 'artifact' artifact.id %}?raw=1">
|
||||
<time class="artifact-time" datetime="{{ artifact.created_at|date:'c' }}">{{ artifact.created_at|date:"H:i" }}</time>
|
||||
<span class="artifact-row-main">
|
||||
<span class="artifact-title">{{ artifact.title }}</span>
|
||||
<span class="artifact-kind-text">{% if artifact.kind == "image" %}image{% elif artifact.kind == "pdf" %}pdf{% elif artifact.kind == "markdown" %}markdown{% elif artifact.kind == "deck" %}deck{% elif artifact.kind == "binary" %}binary{% else %}html{% endif %}</span>
|
||||
<span class="kept-badge" data-kept-badge title="Kept — never auto-deleted"{% if not artifact.is_kept %} hidden{% endif %}>K</span>
|
||||
<span class="shared-badge" data-shared-badge title="Shared — anyone with the link can view"{% if not artifact.is_shared %} hidden{% endif %}>🔗</span>
|
||||
{% if artifact.label_chips %}
|
||||
<span class="tag-chips">
|
||||
{% for label in artifact.label_chips %}
|
||||
<span class="tag-chip" style="background:{{ label.color }}">{{ label.name }}</span>
|
||||
{% endfor %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
<span class="artifact-source">{{ artifact.source }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% include "artifacts/_artifact_rows.html" with artifacts=group.artifacts %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if has_more %}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@
|
|||
<span>{{ artifacts|length }} artifact{{ artifacts|length|pluralize }}</span>
|
||||
</div>
|
||||
|
||||
<div class="artifact-list{% if selected_source %} no-source-col{% endif %}" data-artifact-list>
|
||||
<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 %}
|
||||
|
|
@ -552,6 +552,95 @@
|
|||
|
||||
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 () => {
|
||||
if (livePolling || document.hidden || !liveSince) 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);
|
||||
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} artifact${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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue