Add multi-user auth, settings area, and unified label feed
Turns Daybook into a multi-user tool and reworks the feed UI: - Multi-user: Django session login (username/password + remember-me), per-user personal API tokens (DRF authtoken), whole app gated behind login. Each artifact has an owner; feeds are private per user. - Rename persona -> source (free-text label for whatever posted an artifact: agent-profile, script, or person). source + tags form one unified label system; single label filter matches tags, source has its own filter/column/sidebar. - Settings area (gear/user menu, top-right): Account (view/regenerate token) and Demo Data (in-app button seeding one artifact per kind). User account management via Django admin (superusers only). - Feed rework: rolling 7-day window with sticky inline date separators and infinite scroll; left sidebar is now a Sources list with per-source unread counts; artifact type shown as text after title; source in its own column. - Unread tracking: read_at on Artifact, marked read when previewed, bold titles for unread, sidebar unread badges, old banner removed. - Tag colours: global shared registry, hash-based default with optional per-tag override via tag_colors on POST/PATCH. - Ingest: token resolves owner; same source name under two users' tokens stays isolated. daybook_post.py renamed persona->source, requires a real token, --check accepts a bearer token. Docs updated to match. Migrations 0004-0008 rename persona, add owner (nullable -> backfill to first superuser -> required), and add read_at. Fresh installs no-op the backfill. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
203f9b578e
commit
37cd96fa7a
33 changed files with 1614 additions and 367 deletions
34
templates/artifacts/_day_groups.html
Normal file
34
templates/artifacts/_day_groups.html
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
{% for group in day_groups %}
|
||||
<div class="day-group">
|
||||
<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 %}"
|
||||
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-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>
|
||||
{% 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 %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if has_more %}
|
||||
<div class="feed-more" data-feed-more data-next-before="{{ next_before }}"></div>
|
||||
{% endif %}
|
||||
28
templates/artifacts/_feed_toolbar.html
Normal file
28
templates/artifacts/_feed_toolbar.html
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<form method="get" class="filter">
|
||||
<input type="hidden" name="view" value="{{ selected_view }}">
|
||||
{% if selected_source %}<input type="hidden" name="source" value="{{ selected_source }}">{% endif %}
|
||||
{% if selected_artifact %}<input type="hidden" name="artifact" value="{{ selected_artifact.id }}">{% endif %}
|
||||
<label>
|
||||
<span>Label</span>
|
||||
<select name="label" onchange="this.form.submit()">
|
||||
<option value="">All labels</option>
|
||||
{% for label in all_labels %}
|
||||
<option value="{{ label.name }}" {% if label.name == selected_label %}selected{% endif %}>{{ label.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Type</span>
|
||||
<select name="kind" onchange="this.form.submit()">
|
||||
<option value="">All types</option>
|
||||
{% for value, label in kind_choices %}
|
||||
<option value="{{ value }}" {% if value == selected_kind %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Name</span>
|
||||
<input type="search" name="q" value="{{ title_query }}" placeholder="Title contains" onsearch="this.form.submit()">
|
||||
</label>
|
||||
<button type="submit">Filter</button>
|
||||
</form>
|
||||
37
templates/artifacts/_topbar.html
Normal file
37
templates/artifacts/_topbar.html
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<header class="topbar">
|
||||
<div class="brandline">
|
||||
<a class="brand-link" href="{% url 'feed' %}"><h1>AgentFeed</h1></a>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
{% if topbar_toolbar %}{% include topbar_toolbar %}{% endif %}
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<button class="icon-button" type="button" data-user-menu-toggle aria-haspopup="true" aria-expanded="false" title="{{ request.user.username }}">
|
||||
⚙ {{ request.user.username }}
|
||||
</button>
|
||||
<div class="user-menu-dropdown" hidden data-user-menu>
|
||||
<a href="{% url 'settings-account' %}">Settings</a>
|
||||
<a href="{% url 'settings-demo-data' %}">Demo Data</a>
|
||||
<form method="post" action="{% url 'logout' %}">{% csrf_token %}<button type="submit">Log out</button></form>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<script>
|
||||
(() => {
|
||||
const toggle = document.querySelector("[data-user-menu-toggle]");
|
||||
const menu = document.querySelector("[data-user-menu]");
|
||||
if (!toggle || !menu) return;
|
||||
toggle.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
const isHidden = menu.hidden;
|
||||
menu.hidden = !isHidden;
|
||||
toggle.setAttribute("aria-expanded", String(isHidden));
|
||||
});
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!menu.hidden && !menu.contains(event.target) && event.target !== toggle) {
|
||||
menu.hidden = true;
|
||||
toggle.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
|
@ -7,94 +7,51 @@
|
|||
<link rel="stylesheet" href="/static/daybook.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brandline">
|
||||
<h1>AgentFeed</h1>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<form method="get" class="filter">
|
||||
<input type="hidden" name="day" value="{{ selected_day|date:'Y-m-d' }}">
|
||||
<input type="hidden" name="view" value="{{ selected_view }}">
|
||||
{% if selected_artifact %}<input type="hidden" name="artifact" value="{{ selected_artifact.id }}">{% endif %}
|
||||
<label>
|
||||
<span>Persona</span>
|
||||
<select name="persona" onchange="this.form.submit()">
|
||||
<option value="">All personas</option>
|
||||
{% for persona in personas %}
|
||||
<option value="{{ persona }}" {% if persona == selected_persona %}selected{% endif %}>{{ persona }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Type</span>
|
||||
<select name="kind" onchange="this.form.submit()">
|
||||
<option value="">All types</option>
|
||||
{% for value, label in kind_choices %}
|
||||
<option value="{{ value }}" {% if value == selected_kind %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Name</span>
|
||||
<input type="search" name="q" value="{{ title_query }}" placeholder="Title contains" onsearch="this.form.submit()">
|
||||
</label>
|
||||
<button type="submit">Filter</button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
{% include "artifacts/_topbar.html" with topbar_toolbar="artifacts/_feed_toolbar.html" %}
|
||||
|
||||
<main class="feed"
|
||||
data-selected-day="{{ selected_day|date:'Y-m-d' }}"
|
||||
data-selected-persona="{{ selected_persona }}"
|
||||
data-selected-source="{{ selected_source }}"
|
||||
data-selected-label="{{ selected_label }}"
|
||||
data-selected-kind="{{ selected_kind }}"
|
||||
data-title-query="{{ title_query }}">
|
||||
<aside class="day-sidebar" aria-label="Days">
|
||||
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>Days</h2>
|
||||
<h2>Sources</h2>
|
||||
</div>
|
||||
<nav class="day-list">
|
||||
{% for day in days %}
|
||||
<a class="day-link {% if day == selected_day %}selected{% endif %}" href="?day={{ day|date:'Y-m-d' }}{% if selected_persona %}&persona={{ selected_persona|urlencode }}{% endif %}{% if selected_kind %}&kind={{ selected_kind|urlencode }}{% endif %}{% if title_query %}&q={{ title_query|urlencode }}{% endif %}&view={{ selected_view }}&preview=none">
|
||||
<span>{{ day|date:"D" }}{% if day == today %}<em class="today-badge">Today</em>{% endif %}</span>
|
||||
<strong>{{ day|date:"Y-m-d" }}</strong>
|
||||
<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 days sidebar" aria-orientation="vertical"></div>
|
||||
<div class="splitter" data-splitter="days" role="separator" aria-label="Resize sources sidebar" aria-orientation="vertical"></div>
|
||||
|
||||
<aside class="artifact-sidebar" aria-label="Artifacts for selected day">
|
||||
<aside class="artifact-sidebar" aria-label="Artifact feed">
|
||||
<div class="pane-bar pane-heading">
|
||||
<h2>{{ selected_day|date:"M j Y" }}</h2>
|
||||
<h2>{% if selected_source %}{{ selected_source }}{% else %}All sources{% endif %}</h2>
|
||||
<span>{{ artifacts|length }} artifact{{ artifacts|length|pluralize }}</span>
|
||||
</div>
|
||||
|
||||
<button class="new-artifacts-banner" type="button" hidden data-new-artifacts-banner>
|
||||
<strong data-new-artifacts-count>0</strong> new artifact<span data-new-artifacts-plural>s</span> available
|
||||
</button>
|
||||
|
||||
<div class="artifact-list">
|
||||
{% for artifact in artifacts %}
|
||||
<a class="artifact-row {% if selected_artifact.id == artifact.id %}selected{% endif %}"
|
||||
href="?day={{ selected_day|date:'Y-m-d' }}{% if selected_persona %}&persona={{ selected_persona|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-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>{{ artifact.title }}</span>
|
||||
</span>
|
||||
<span class="kind-icon kind-{{ artifact.kind }}" aria-hidden="true">{% if artifact.kind == "image" %}IMG{% elif artifact.kind == "pdf" %}PDF{% elif artifact.kind == "markdown" %}MD{% elif artifact.kind == "deck" %}DECK{% elif artifact.kind == "binary" %}BIN{% else %}HTML{% endif %}</span>
|
||||
</a>
|
||||
{% empty %}
|
||||
<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>No artifacts exist for this day yet.</p>
|
||||
<p>Nothing in the last {{ 7 }} days for these filters.</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
|
|
@ -195,12 +152,11 @@
|
|||
|
||||
const previewPane = document.querySelector(".preview-pane");
|
||||
const previewBody = document.querySelector(".preview-body");
|
||||
const rows = Array.from(document.querySelectorAll(".artifact-row"));
|
||||
const dayLinks = Array.from(document.querySelectorAll(".day-link"));
|
||||
const initialArtifactIds = new Set(rows.map((row) => row.dataset.artifactId));
|
||||
const newArtifactsBanner = document.querySelector("[data-new-artifacts-banner]");
|
||||
const newArtifactsCount = document.querySelector("[data-new-artifacts-count]");
|
||||
const newArtifactsPlural = document.querySelector("[data-new-artifacts-plural]");
|
||||
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) => ({
|
||||
"&": "&",
|
||||
|
|
@ -222,7 +178,7 @@
|
|||
}, 1200);
|
||||
};
|
||||
const selectedRow = () => document.querySelector(".artifact-row.selected");
|
||||
let navigationMode = selectedRow() ? "artifacts" : "days";
|
||||
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);
|
||||
|
|
@ -286,9 +242,39 @@
|
|||
}
|
||||
};
|
||||
|
||||
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";
|
||||
rows.forEach((candidate) => candidate.classList.toggle("selected", candidate === row));
|
||||
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;
|
||||
|
|
@ -306,26 +292,28 @@
|
|||
};
|
||||
|
||||
const clearPreview = () => {
|
||||
navigationMode = "days";
|
||||
navigationMode = "sources";
|
||||
if (document.activeElement && document.activeElement.classList.contains("artifact-row")) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
rows.forEach((candidate) => candidate.classList.remove("selected"));
|
||||
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 currentDay = document.querySelector(".day-link.selected");
|
||||
if (currentDay) currentDay.focus({preventScroll: true});
|
||||
const currentSource = document.querySelector(".source-link.selected");
|
||||
if (currentSource) currentSource.focus({preventScroll: true});
|
||||
};
|
||||
|
||||
rows.forEach((row) => {
|
||||
row.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
renderPreview(row);
|
||||
});
|
||||
// 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();
|
||||
|
|
@ -337,14 +325,14 @@
|
|||
nextRow.scrollIntoView({block: "nearest"});
|
||||
};
|
||||
|
||||
const moveDay = (direction) => {
|
||||
if (!dayLinks.length) return;
|
||||
navigationMode = "days";
|
||||
const current = document.querySelector(".day-link.selected");
|
||||
const currentIndex = current ? dayLinks.indexOf(current) : 0;
|
||||
const nextIndex = Math.max(0, Math.min(dayLinks.length - 1, currentIndex + direction));
|
||||
const nextDay = dayLinks[nextIndex];
|
||||
if (nextDay && nextDay !== current) window.location.href = nextDay.href;
|
||||
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) => {
|
||||
|
|
@ -353,23 +341,23 @@
|
|||
|
||||
if (event.altKey && event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
moveDay(1);
|
||||
moveSource(1);
|
||||
} else if (event.altKey && event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
moveDay(-1);
|
||||
moveSource(-1);
|
||||
} else if (event.altKey) {
|
||||
return;
|
||||
} else if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
if (navigationMode === "days") {
|
||||
moveDay(1);
|
||||
if (navigationMode === "sources") {
|
||||
moveSource(1);
|
||||
} else {
|
||||
moveSelection(1);
|
||||
}
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
if (navigationMode === "days") {
|
||||
moveDay(-1);
|
||||
if (navigationMode === "sources") {
|
||||
moveSource(-1);
|
||||
} else {
|
||||
moveSelection(-1);
|
||||
}
|
||||
|
|
@ -384,6 +372,7 @@
|
|||
moveSelection(-5);
|
||||
}
|
||||
} else if (event.key === "ArrowRight") {
|
||||
const rows = allRows();
|
||||
if (!selectedRow() && rows.length) {
|
||||
event.preventDefault();
|
||||
renderPreview(rows[0]);
|
||||
|
|
@ -397,46 +386,59 @@
|
|||
event.preventDefault();
|
||||
clearPreview();
|
||||
} else {
|
||||
navigationMode = "days";
|
||||
const currentDay = document.querySelector(".day-link.selected");
|
||||
if (currentDay) currentDay.focus({preventScroll: true});
|
||||
navigationMode = "sources";
|
||||
const currentSource = document.querySelector(".source-link.selected");
|
||||
if (currentSource) currentSource.focus({preventScroll: true});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const artifactListUrl = () => {
|
||||
const url = new URL("/api/artifacts", window.location.origin);
|
||||
const params = url.searchParams;
|
||||
if (feed.dataset.selectedDay) params.set("day", feed.dataset.selectedDay);
|
||||
if (feed.dataset.selectedPersona) params.set("persona", feed.dataset.selectedPersona);
|
||||
if (feed.dataset.selectedKind) params.set("kind", feed.dataset.selectedKind);
|
||||
if (feed.dataset.titleQuery) params.set("q", feed.dataset.titleQuery);
|
||||
return url;
|
||||
};
|
||||
|
||||
const showNewArtifactsBanner = (count) => {
|
||||
if (!newArtifactsBanner || count < 1) return;
|
||||
newArtifactsCount.textContent = String(count);
|
||||
newArtifactsPlural.hidden = count === 1;
|
||||
newArtifactsBanner.hidden = false;
|
||||
};
|
||||
|
||||
const checkForNewArtifacts = async () => {
|
||||
// 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 response = await fetch(artifactListUrl(), {headers: {Accept: "application/json"}});
|
||||
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 artifacts = await response.json();
|
||||
const unseenCount = artifacts.filter((artifact) => !initialArtifactIds.has(artifact.id)).length;
|
||||
showNewArtifactsBanner(unseenCount);
|
||||
const markup = (await response.text()).trim();
|
||||
sentinel.remove();
|
||||
if (markup) {
|
||||
artifactList.insertAdjacentHTML("beforeend", markup);
|
||||
observeSentinel();
|
||||
}
|
||||
} catch {
|
||||
// Polling is opportunistic; leave the current feed untouched on errors.
|
||||
// Leave the list as-is; the user can scroll again to retry.
|
||||
} finally {
|
||||
loadingOlder = false;
|
||||
}
|
||||
};
|
||||
|
||||
if (newArtifactsBanner) {
|
||||
newArtifactsBanner.addEventListener("click", () => window.location.reload());
|
||||
window.setInterval(checkForNewArtifacts, 5000);
|
||||
}
|
||||
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();
|
||||
|
||||
previewPane.addEventListener("click", (event) => {
|
||||
const copyButton = event.target.closest("[data-copy-url]");
|
||||
|
|
|
|||
34
templates/artifacts/login.html
Normal file
34
templates/artifacts/login.html
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Log in — AgentFeed</title>
|
||||
<link rel="stylesheet" href="/static/daybook.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="login-page">
|
||||
<form method="post" class="login-card">
|
||||
{% csrf_token %}
|
||||
<h1>AgentFeed</h1>
|
||||
{% if form.non_field_errors %}
|
||||
<p class="login-error">{{ form.non_field_errors|join:" " }}</p>
|
||||
{% endif %}
|
||||
<label>
|
||||
<span>Username</span>
|
||||
{{ form.username }}
|
||||
</label>
|
||||
<label>
|
||||
<span>Password</span>
|
||||
{{ form.password }}
|
||||
</label>
|
||||
<label class="login-remember">
|
||||
{{ form.remember_me }}
|
||||
<span>Remember me</span>
|
||||
</label>
|
||||
<button type="submit">Log in</button>
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
15
templates/artifacts/settings_account.html
Normal file
15
templates/artifacts/settings_account.html
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{% extends "artifacts/settings_base.html" %}
|
||||
|
||||
{% block settings_content %}
|
||||
<h2>Account</h2>
|
||||
<p><strong>Username:</strong> {{ request.user.username }}</p>
|
||||
|
||||
<h3>API Token</h3>
|
||||
<p>Use this token as <code>Authorization: Bearer <token></code> when posting artifacts.</p>
|
||||
<p><code class="token-value">{{ token.key }}</code></p>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="regenerate">
|
||||
<button type="submit">Regenerate Token</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
28
templates/artifacts/settings_base.html
Normal file
28
templates/artifacts/settings_base.html
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Settings — AgentFeed</title>
|
||||
<link rel="stylesheet" href="/static/daybook.css">
|
||||
</head>
|
||||
<body>
|
||||
{% include "artifacts/_topbar.html" %}
|
||||
|
||||
<main class="settings-layout">
|
||||
<nav class="settings-nav">
|
||||
<a class="settings-nav-back" href="{% url 'feed' %}">← Back to Feed</a>
|
||||
<a class="{% if active_section == 'account' %}selected{% endif %}" href="{% url 'settings-account' %}">Account</a>
|
||||
<a class="{% if active_section == 'demo-data' %}selected{% endif %}" href="{% url 'settings-demo-data' %}">Demo Data</a>
|
||||
</nav>
|
||||
<section class="settings-panel">
|
||||
{% if messages %}
|
||||
<ul class="settings-messages">
|
||||
{% for message in messages %}<li>{{ message }}</li>{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% block settings_content %}{% endblock %}
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
10
templates/artifacts/settings_demo_data.html
Normal file
10
templates/artifacts/settings_demo_data.html
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{% extends "artifacts/settings_base.html" %}
|
||||
|
||||
{% block settings_content %}
|
||||
<h2>Demo Data</h2>
|
||||
<p>Generate one sample artifact of every supported kind (markdown, HTML fragment, full HTML, deck, image, PDF, binary), tagged "demo" and dated today.</p>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<button type="submit">Generate Demo Data</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
Loading…
Add table
Add a link
Reference in a new issue