v0.5.0: Trash (soft-delete) + Keep folder
Delete now moves an artifact to a recoverable Trash instead of hard-removing.
- Model: Artifact.deleted_at (nullable, indexed) = in Trash.
- Soft-delete everywhere: DELETE /api/artifacts/{id} sets deleted_at
(idempotent 204); session POST /a/{id}/delete for the feed's hover Delete
icon; POST /a/{id}/undelete restores; POST /feed/empty-trash purges.
- Trashed artifacts are off-quota (deleting frees quota) and eviction-protected
(never evicted early); hidden from feed, sources, counts, and all API reads
(list/detail/raw).
- Sidebar: "Deleted" and "Keep" system folders at the bottom (counts, hidden
when empty); ?folder=deleted / ?folder=keep views reuse the row rendering.
Deleted view has hover Undelete + an Empty Trash button. Keep = view over
is_kept. Live-poll disabled in folder views.
- Docs (api.md delete section, CLAUDE.md), 10 new/updated tests. 150 passing;
API soft-delete lifecycle verified live.
Purge is manual (Empty Trash) this release; auto purge-on-delete (background,
~3-day retention) is the planned fast-follow.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
6c1823e65e
commit
4254164bef
14 changed files with 438 additions and 43 deletions
|
|
@ -30,5 +30,10 @@
|
|||
{% endif %}
|
||||
</span>
|
||||
<span class="artifact-source">{{ artifact.source }}</span>
|
||||
{% if in_trash %}
|
||||
<span class="row-action row-undelete" role="button" tabindex="0" data-undelete="{{ artifact.id }}" title="Restore from Trash" aria-label="Restore {{ artifact.title }}">↩</span>
|
||||
{% else %}
|
||||
<span class="row-action row-delete" role="button" tabindex="0" data-delete="{{ artifact.id }}" title="Delete" aria-label="Delete {{ artifact.title }}">🗑</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@
|
|||
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">
|
||||
|
|
@ -34,16 +35,33 @@
|
|||
<h2>Sources</h2>
|
||||
</div>
|
||||
<nav class="source-list">
|
||||
<a class="source-link {% if not selected_source %}selected{% endif %}" href="?{% if selected_label %}label={{ selected_label|urlencode }}&{% endif %}{% if selected_kind %}kind={{ selected_kind|urlencode }}&{% endif %}{% if title_query %}q={{ title_query|urlencode }}&{% endif %}view={{ selected_view }}&preview=none">
|
||||
<a class="source-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">{{ 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">
|
||||
<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">
|
||||
<span class="source-name">{{ entry.source }}</span>
|
||||
{% if entry.count %}<span class="source-count">{{ entry.count }}</span>{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
||||
{% if keep_count or trash_count %}
|
||||
<div class="source-folders">
|
||||
{% if keep_count %}
|
||||
<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>
|
||||
<span class="source-count">{{ keep_count }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if trash_count %}
|
||||
<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>
|
||||
<span class="source-count">{{ trash_count }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
|
|
@ -51,8 +69,12 @@
|
|||
|
||||
<aside class="artifact-sidebar" aria-label="Artifact feed">
|
||||
<div class="pane-bar pane-heading">
|
||||
<h2>{% if selected_source %}{{ selected_source }}{% else %}All sources{% endif %}</h2>
|
||||
<span>{{ artifacts|length }} artifact{{ artifacts|length|pluralize }}</span>
|
||||
<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 }} artifact{{ artifacts|length|pluralize }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="artifact-list{% if selected_source %} no-source-col{% endif %}" data-artifact-list data-since="{{ feed_since }}">
|
||||
|
|
@ -444,6 +466,68 @@
|
|||
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} artifact${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
|
||||
});
|
||||
}
|
||||
|
||||
const renderPreview = (row, pushState = true) => {
|
||||
navigationMode = "artifacts";
|
||||
markRead(row);
|
||||
|
|
@ -479,6 +563,15 @@
|
|||
|
||||
// Delegated so rows appended by infinite scroll work without rebinding.
|
||||
artifactList.addEventListener("click", (event) => {
|
||||
// Row action icons (delete/undelete) intercept before selection.
|
||||
const action = event.target.closest("[data-delete], [data-undelete]");
|
||||
if (action) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
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();
|
||||
|
|
@ -667,7 +760,9 @@
|
|||
};
|
||||
|
||||
const pollUpdates = async () => {
|
||||
if (livePolling || document.hidden || !liveSince) return;
|
||||
// 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"}});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue