Files
thoughtsync/frontend/src/stores/labels.ts
T
bvandeusenandClaude Opus 5 6f35e6e6d8
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 31s
Confirm irreversible deletes, which sync just made far more consequential
The trash model itself was already right and needed no change: notes soft-
delete (`trashed` locally, `deleted_at` server-side), Trash is a real view,
restore works, permanent deletion is a separate second step only offered on
an already-trashed note, `trash()` shows an Undo toast, and nothing auto-
purges — trash persists until someone acts. Sync carries all of it: a trashed
note syncs WITH its content, and only `purged_at` deletes a client's copy.

What was missing is the guard on the irreversible step. "Delete forever" and
label deletion were one click, silent, with no confirmation — and M10.7 has
changed what that costs. Before, a mis-click lost a note on one machine.
Now it pushes a tombstone that deletes it from every linked device, and the
local tombstone survives to make sure it gets there.

Both guards live in the STORE, not the call sites: NoteCard and NoteEditor
both offer delete-forever, and duplicating the copy is how two prompts drift
until one of them stops matching what actually happens.

The copy names the real consequence — "deleted from every device you sync
with" — because that's the part a user cannot infer from a button in a Trash
view. The label prompt also says the notes themselves are kept, since that's
what people actually worry about when deleting a label.

Labels deliberately get a confirmation but NOT a trash of their own. A label
is organization, not content; the reversible middle step notes get would be
ceremony around something that costs nothing to recreate.

Saved-filter deletion already confirmed (AppShell), so these two were the
outliers, not a new convention.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-26 15:40:33 -04:00

79 lines
2.9 KiB
TypeScript

import { defineStore } from "pinia";
import { ref } from "vue";
import { repo } from "../adapters";
export interface Label {
id: string;
name: string;
color: string;
// Number of notes carrying this label (from GET /api/labels; used in label management).
count?: number;
}
export const useLabelsStore = defineStore("labels", () => {
const items = ref<Label[]>([]);
const loaded = ref(false);
function sort() {
items.value.sort((a, b) => a.name.localeCompare(b.name));
}
async function load(): Promise<void> {
items.value = await repo.labels.list();
loaded.value = true;
}
async function create(name: string): Promise<Label> {
const label = await repo.labels.create(name);
if (!items.value.some((lb) => lb.id === label.id)) {
items.value.push(label);
sort();
}
return label;
}
async function rename(id: string, name: string): Promise<void> {
const updated = await repo.labels.rename(id, name);
const idx = items.value.findIndex((lb) => lb.id === id);
// The single-label PATCH doesn't recompute the count — keep the one we have.
if (idx >= 0) items.value[idx] = { ...updated, count: items.value[idx].count };
sort();
}
async function setColor(id: string, color: string): Promise<void> {
const updated = await repo.labels.setColor(id, color);
const idx = items.value.findIndex((lb) => lb.id === id);
if (idx >= 0) items.value[idx] = { ...updated, count: items.value[idx].count };
}
async function remove(id: string): Promise<void> {
// Labels have no trash of their own — a label is organization, not content, so
// the reversible middle step notes get would be ceremony. But deleting one is
// still irreversible and now reaches every synced device, so it asks first. The
// notes themselves survive; only the membership goes, which is the part people
// most need reassuring about.
const label = items.value.find((lb) => lb.id === id);
const subject = label ? `the label "${label.name}"` : "this label";
const confirmed = window.confirm(
`Delete ${subject}?\n\n` +
"It will be removed from every note that uses it, on every device you sync " +
"with. The notes themselves are kept.",
);
if (!confirmed) return;
await repo.labels.remove(id);
items.value = items.value.filter((lb) => lb.id !== id);
}
// Merge `sourceId` into `targetId`: the server moves the source's notes onto the
// target and deletes the source; the response carries the target's new count.
async function mergeInto(sourceId: string, targetId: string): Promise<void> {
const target = await repo.labels.merge(sourceId, targetId);
items.value = items.value.filter((lb) => lb.id !== sourceId);
const idx = items.value.findIndex((lb) => lb.id === targetId);
if (idx >= 0) items.value[idx] = target;
sort();
}
return { items, loaded, load, create, rename, setColor, remove, mergeInto };
});