v0.7.1: in-app password change, custom labels, always-on folders

- 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>
This commit is contained in:
peter 2026-09-08 15:19:54 +01:00
parent fcd7da853f
commit c2a354809a
14 changed files with 562 additions and 23 deletions

View file

@ -47,22 +47,16 @@
{% endfor %}
</nav>
{% if keep_count or trash_count %}
<nav class="system-folders" aria-label="Folders">
{% if keep_count %}
<a class="source-link folder-link {% if selected_folder == 'keep' %}selected{% endif %}" href="?folder=keep&amp;view={{ selected_view }}&amp;preview=none" data-folder-link="keep">
<span class="source-name">&#9733; 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&amp;view={{ selected_view }}&amp;preview=none" data-folder-link="deleted">
<span class="source-name">&#128465; Deleted</span>
<span class="source-count">{{ trash_count }}</span>
</a>
{% endif %}
</nav>
{% endif %}
<nav class="system-folders" aria-label="Folders">
<a class="source-link folder-link {% if selected_folder == 'keep' %}selected{% endif %}" href="?folder=keep&amp;view={{ selected_view }}&amp;preview=none" data-folder-link="keep">
<span class="source-name">&#9733; 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&amp;view={{ selected_view }}&amp;preview=none" data-folder-link="deleted">
<span class="source-name">&#128465; 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>
@ -152,6 +146,18 @@
<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">&#10003;</span>
</button>
{% endfor %}
</div>
{% endif %}
</div>
<script>
@ -418,13 +424,17 @@
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";
badge.textContent = "0";
link.appendChild(badge);
}
badge.textContent = String((Number(badge.textContent) || 0) + delta);
badge.textContent = String(next);
};
const addSourceLink = (name, count) => {
@ -553,11 +563,22 @@
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));
@ -589,8 +610,62 @@
} 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();

View file

@ -10,4 +10,27 @@
revocable on its own. You have {{ key_count }} key{{ key_count|pluralize }}.
</p>
<p><a href="{% url 'settings-api-keys' %}">Manage API keys &rarr;</a></p>
<h3>Change password</h3>
<p>Enter your current password, then choose a new one.</p>
<form method="post" class="password-change">
{% csrf_token %}
{% if password_form.non_field_errors %}
<ul class="form-errors">
{% for error in password_form.non_field_errors %}<li>{{ error }}</li>{% endfor %}
</ul>
{% endif %}
{% for field in password_form %}
<label class="form-field">
<span>{{ field.label }}</span>
{{ field }}
{% if field.errors %}
<ul class="form-errors">
{% for error in field.errors %}<li>{{ error }}</li>{% endfor %}
</ul>
{% endif %}
</label>
{% endfor %}
<button type="submit">Change password</button>
</form>
{% endblock %}

View file

@ -29,6 +29,7 @@
<span class="settings-nav-heading">You</span>
<a class="{% if active_section == 'account' %}selected{% endif %}" href="{% url 'settings-account' %}">Account</a>
<a class="{% if active_section == 'api-keys' %}selected{% endif %}" href="{% url 'settings-api-keys' %}">API Keys</a>
<a class="{% if active_section == 'labels' %}selected{% endif %}" href="{% url 'settings-labels' %}">Labels</a>
<a class="{% if active_section == 'storage' %}selected{% endif %}" href="{% url 'settings-storage' %}">Storage</a>
<a class="{% if active_section == 'demo-data' %}selected{% endif %}" href="{% url 'settings-demo-data' %}">Demo Data</a>
</div>

View file

@ -0,0 +1,54 @@
{% extends "artifacts/settings_base.html" %}
{% block settings_content %}
<h2>Labels</h2>
<p>
Build a palette of labels you can apply to any message from its &#8943; menu
in the feed. A label is just a coloured tag — applying one adds it to the
message and it shows as a chip next to the title. Deleting a label here only
removes it from this palette; messages that already carry it keep the chip.
</p>
<h3>Add a label</h3>
<form method="post" class="label-create">
{% csrf_token %}
<input type="hidden" name="action" value="create">
<label>Name <input type="text" name="name" placeholder="e.g. follow-up" required autocomplete="off" maxlength="40"></label>
<label>Colour <input type="color" name="color" value="#4a6ea8"></label>
<button type="submit">Add label</button>
</form>
<h3>Your labels</h3>
{% if labels %}
<table class="label-table">
<thead>
<tr><th>Label</th><th>Colour</th><th></th></tr>
</thead>
<tbody>
{% for label in labels %}
<tr>
<td><span class="tag-chip" style="background:{{ label.color }}">{{ label.name }}</span></td>
<td>
<form method="post" class="label-recolor">
{% csrf_token %}
<input type="hidden" name="action" value="update">
<input type="hidden" name="label_id" value="{{ label.pk }}">
<input type="color" name="color" value="{{ label.color }}" onchange="this.form.submit()" title="Change colour">
</form>
</td>
<td>
<form method="post" onsubmit="return confirm('Remove &quot;{{ label.name|escapejs }}&quot; from your palette? Messages already labelled keep the chip.');">
{% csrf_token %}
<input type="hidden" name="action" value="delete">
<input type="hidden" name="label_id" value="{{ label.pk }}">
<button type="submit" class="label-delete">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="muted">No labels yet. Add one above, then pick it from a message's &#8943; menu.</p>
{% endif %}
{% endblock %}