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:
parent
fcd7da853f
commit
c2a354809a
14 changed files with 562 additions and 23 deletions
|
|
@ -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&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 %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
<nav class="system-folders" aria-label="Folders">
|
||||
<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>
|
||||
{% 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&view={{ selected_view }}&preview=none" data-folder-link="deleted">
|
||||
<span class="source-name">🗑 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">✓</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();
|
||||
|
|
|
|||
|
|
@ -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 →</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 %}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
54
templates/artifacts/settings_labels.html
Normal file
54
templates/artifacts/settings_labels.html
Normal 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 ⋯ 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 "{{ label.name|escapejs }}" 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 ⋯ menu.</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
Loading…
Add table
Add a link
Reference in a new issue