464 lines
21 KiB
HTML
464 lines
21 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>
|
|
<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>
|
|
|
|
<main class="feed"
|
|
data-selected-day="{{ selected_day|date:'Y-m-d' }}"
|
|
data-selected-persona="{{ selected_persona }}"
|
|
data-selected-kind="{{ selected_kind }}"
|
|
data-title-query="{{ title_query }}">
|
|
<aside class="day-sidebar" aria-label="Days">
|
|
<div class="pane-bar">
|
|
<h2>Days</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>
|
|
</a>
|
|
{% endfor %}
|
|
</nav>
|
|
</aside>
|
|
|
|
<div class="splitter" data-splitter="days" role="separator" aria-label="Resize days sidebar" aria-orientation="vertical"></div>
|
|
|
|
<aside class="artifact-sidebar" aria-label="Artifacts for selected day">
|
|
<div class="pane-bar pane-heading">
|
|
<h2>{{ selected_day|date:"M j Y" }}</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="empty compact">
|
|
<h3>No artifacts</h3>
|
|
<p>No artifacts exist for this day yet.</p>
|
|
</div>
|
|
{% endfor %}
|
|
</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">
|
|
<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>
|
|
<button type="button" disabled>Send</button>
|
|
</div>
|
|
</header>
|
|
<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 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 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" : "days";
|
|
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;
|
|
}
|
|
};
|
|
|
|
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 renderPreview = (row, pushState = true) => {
|
|
navigationMode = "artifacts";
|
|
rows.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 = "days";
|
|
if (document.activeElement && document.activeElement.classList.contains("artifact-row")) {
|
|
document.activeElement.blur();
|
|
}
|
|
rows.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});
|
|
};
|
|
|
|
rows.forEach((row) => {
|
|
row.addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
renderPreview(row);
|
|
});
|
|
});
|
|
|
|
const moveSelection = (direction) => {
|
|
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 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;
|
|
};
|
|
|
|
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();
|
|
moveDay(1);
|
|
} else if (event.altKey && event.key === "ArrowUp") {
|
|
event.preventDefault();
|
|
moveDay(-1);
|
|
} else if (event.altKey) {
|
|
return;
|
|
} else if (event.key === "ArrowDown") {
|
|
event.preventDefault();
|
|
if (navigationMode === "days") {
|
|
moveDay(1);
|
|
} else {
|
|
moveSelection(1);
|
|
}
|
|
} else if (event.key === "ArrowUp") {
|
|
event.preventDefault();
|
|
if (navigationMode === "days") {
|
|
moveDay(-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") {
|
|
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 = "days";
|
|
const currentDay = document.querySelector(".day-link.selected");
|
|
if (currentDay) currentDay.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 () => {
|
|
try {
|
|
const response = await fetch(artifactListUrl(), {headers: {Accept: "application/json"}});
|
|
if (!response.ok) return;
|
|
const artifacts = await response.json();
|
|
const unseenCount = artifacts.filter((artifact) => !initialArtifactIds.has(artifact.id)).length;
|
|
showNewArtifactsBanner(unseenCount);
|
|
} catch {
|
|
// Polling is opportunistic; leave the current feed untouched on errors.
|
|
}
|
|
};
|
|
|
|
if (newArtifactsBanner) {
|
|
newArtifactsBanner.addEventListener("click", () => window.location.reload());
|
|
window.setInterval(checkForNewArtifacts, 5000);
|
|
}
|
|
|
|
previewPane.addEventListener("click", (event) => {
|
|
const copyButton = event.target.closest("[data-copy-url]");
|
|
if (copyButton) {
|
|
event.preventDefault();
|
|
copyLink(copyButton);
|
|
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>
|