Files
thoughtsync/frontend/src/components/LabelsModal.vue
T
bvandeusenandClaude Opus 5 8c7553d619
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 16s
CI & Build / integration (push) Successful in 23s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m7s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m17s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m4s
copy: the product says "tags" now, and the schema keeps saying Label
Two words for one concept cost real comprehension: over a single exchange
the operator concluded that auto-tagging did not exist (it does, in
`derive.rs`) and that a tag-management view did not exist (it does,
`LabelsModal.vue`). The `#` is how most of these get made, so the `#` wins
the noun.

User-visible strings only, on all three surfaces plus the server's errors.
`Label`, `NoteLabel`, `via_tag`, `label_id`, the tables, `/api/labels` and
the FFI names are all untouched — renaming those touches migrations and the
wire format to buy nothing a reader can see.

Two of these were more than a find-and-replace:

  * Android's `label_from_tag` said "from #tag", sitting beside a chip that
    already renders as `#name`. Once every one of them IS a tag that hint is
    circular. What it actually tells you is that the note's BODY owns this
    one — which is why it alone has no remove cross — so it now says "from
    the text".

  * The web's empty state said "No labels yet — create one above" while
    Android's already mentioned the `#` route. The web now says it too. That
    is the exact fact the operator did not have.

The paired `aria-label`s went with their `title`s; a screen reader saying
"label" while the tooltip says "tag" is the same confusion with a smaller
audience.

Left alone deliberately: `json_error("invalid label")` and
`"label_ids must be a list"` in `notes/__init__.py` name the `?label=` query
parameter and the `label_ids` request field. Those are wire surface, not the
word a person reads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
2026-08-31 15:53:28 -04:00

168 lines
6.6 KiB
Vue

<script setup lang="ts">
import { computed, ref } from "vue";
import { useLabelsStore, type Label } from "../stores/labels";
import {
NOTE_COLOR_KEYS,
NOTE_COLOR_LABELS,
NOTE_SWATCH_CLASSES,
resolveLabelColor,
type NoteColor,
} from "../notes/colors";
import BaseModal from "./BaseModal.vue";
import Icon from "./Icon.vue";
const emit = defineEmits<{ (e: "close"): void }>();
const labels = useLabelsStore();
const newName = ref("");
const pickerFor = ref<string | null>(null);
const mergeFor = ref<string | null>(null);
const canMerge = computed(() => labels.items.length > 1);
async function add() {
const name = newName.value.trim();
if (!name) return;
await labels.create(name);
newName.value = "";
}
async function rename(id: string, value: string) {
const name = value.trim();
if (name) await labels.rename(id, name);
}
// Shows the colour the tag ACTUALLY wears, derived from its name when nobody has
// picked one — so this screen agrees with the chips everywhere else. The ring in the
// swatch grid below follows the same resolution, so opening the picker highlights
// what you can already see rather than nothing at all.
function labelDot(label: { name: string; color: string }): string {
return NOTE_SWATCH_CLASSES[resolveLabelColor(label)] ?? NOTE_SWATCH_CLASSES.default;
}
function openColor(id: string) {
mergeFor.value = null;
pickerFor.value = pickerFor.value === id ? null : id;
}
async function pickColor(id: string, color: NoteColor) {
pickerFor.value = null;
await labels.setColor(id, color);
}
function openMerge(id: string) {
pickerFor.value = null;
mergeFor.value = mergeFor.value === id ? null : id;
}
function otherLabels(id: string): Label[] {
return labels.items.filter((lb) => lb.id !== id);
}
async function doMerge(sourceId: string, targetId: string) {
mergeFor.value = null;
await labels.mergeInto(sourceId, targetId);
}
</script>
<template>
<BaseModal panel-class="w-full max-w-sm shadow-xl" @close="emit('close')">
<div class="flex items-center justify-between border-b border-neutral-100 px-4 py-3 dark:border-neutral-800">
<h2 class="text-sm font-semibold">Manage tags</h2>
<button type="button" class="icon-btn" aria-label="Close" @click="emit('close')"><Icon name="close" /></button>
</div>
<div class="flex flex-col gap-2 p-4">
<form class="flex items-center gap-2" @submit.prevent="add">
<Icon name="plus" />
<input
v-model="newName"
type="text"
placeholder="Create tag"
class="flex-1 rounded-md border border-neutral-300 bg-white px-2 py-1.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-800"
/>
</form>
<ul class="flex flex-col gap-1 pt-1">
<li v-for="lb in labels.items" :key="lb.id" class="relative flex items-center gap-1.5">
<button
type="button"
class="h-4 w-4 shrink-0 rounded-full border border-black/10 transition hover:scale-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-white/15"
:class="labelDot(lb)"
:title="`Color: ${NOTE_COLOR_LABELS[resolveLabelColor(lb)]}`"
aria-label="Change tag color"
@click="openColor(lb.id)"
/>
<input
:value="lb.name"
class="min-w-0 flex-1 rounded-md bg-transparent px-2 py-1.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-brand"
@change="rename(lb.id, ($event.target as HTMLInputElement).value)"
/>
<span
class="shrink-0 tabular-nums text-xs text-neutral-400"
:title="`${lb.count ?? 0} note${(lb.count ?? 0) === 1 ? '' : 's'}`"
>{{ lb.count ?? 0 }}</span
>
<button
v-if="canMerge"
type="button"
class="icon-btn"
title="Merge into another tag"
aria-label="Merge into another tag"
@click="openMerge(lb.id)"
>
<Icon name="merge" />
</button>
<button
type="button"
class="icon-btn"
title="Delete tag"
aria-label="Delete tag"
@click="labels.remove(lb.id)"
>
<Icon name="trash" />
</button>
<div
v-if="pickerFor === lb.id"
class="absolute left-0 top-full z-10 mt-1 flex max-w-[13rem] flex-wrap gap-1.5 rounded-lg border border-neutral-200 bg-white p-2 shadow-lg dark:border-neutral-700 dark:bg-neutral-800"
>
<button
v-for="key in NOTE_COLOR_KEYS"
:key="key"
type="button"
:title="NOTE_COLOR_LABELS[key]"
:aria-label="NOTE_COLOR_LABELS[key]"
class="h-6 w-6 rounded-full border border-black/10 transition hover:scale-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
:class="[NOTE_SWATCH_CLASSES[key], resolveLabelColor(lb) === key ? 'ring-2 ring-brand' : '']"
@click="pickColor(lb.id, key)"
/>
</div>
<div
v-if="mergeFor === lb.id"
class="absolute right-0 top-full z-10 mt-1 w-52 rounded-lg border border-neutral-200 bg-white p-1 shadow-lg dark:border-neutral-700 dark:bg-neutral-800"
>
<p class="px-2 py-1 text-xs text-neutral-400">
Merge {{ lb.name }} into <span class="text-neutral-300 dark:text-neutral-500">(keeps notes)</span>
</p>
<ul class="max-h-44 overflow-y-auto">
<li v-for="t in otherLabels(lb.id)" :key="t.id">
<button
type="button"
class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-neutral-100 dark:hover:bg-neutral-700"
@click="doMerge(lb.id, t.id)"
>
<span
class="h-2.5 w-2.5 shrink-0 rounded-full border border-black/10 dark:border-white/15"
:class="labelDot(t)"
></span>
<span class="truncate">{{ t.name }}</span>
</button>
</li>
</ul>
</div>
</li>
</ul>
<p v-if="!labels.items.length" class="py-2 text-center text-xs text-neutral-400">
No tags yet create one above, or write a #tag in a note and it becomes one.
</p>
</div>
</BaseModal>
</template>