Files
thoughtsync/frontend/src/components/LabelsModal.vue
T
bvandeusenandClaude Opus 5 6fbee27f9c
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 15s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m52s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m10s
Desktop (Tauri) / Update manifest (push) Successful in 6s
Android / Kotlin + Rust (APK) (push) Successful in 7m43s
A tag with no colour of its own derives one from its name
Every #tag ever typed is `default`. `notes/tags.py` mints one as
`Label(owner_id=…, name=name)` with no colour, so it takes the column default —
which means tag-driven note colour, built on top, would have left the board
exactly as grey as it was. Four #todo notes in the operator's screenshot, four
different colours, because the tint is per-note-id and ignores tags entirely.

DERIVED RATHER THAN PERSISTED AT MINT TIME, reversing the plan in 2965. That plan
wanted a hashed colour written wherever a label is born, and named the risk in
its own body: `find_or_create_label` is "easy to miss, and it is the common one",
because most tags are born from typing `#grocery`, not from a management screen.
Deriving has no mint points to miss, needs no backfill for the tags that already
exist, and reuses the hash and the fixture the notes already have.

The cost is that renaming a tag recolours it. That is defensible — the name IS
the tag — and an explicitly picked colour is still stored and still wins, so tag
colours stay editable exactly as asked.

Lowercased before hashing: tags dedupe case-insensitively, so #Todo and #todo are
one tag and must not be two colours.

All five places a label's colour is drawn now resolve the same way — the card
chip, the editor chip, the drawer's tag list, and the management modal's dot and
swatch ring. The modal's ring follows the resolved colour rather than the stored
one, so opening the picker highlights what you can already see instead of
nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 12:57:59 -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 labels</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 label"
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 label 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 label"
aria-label="Merge into another label"
@click="openMerge(lb.id)"
>
<Icon name="merge" />
</button>
<button
type="button"
class="icon-btn"
title="Delete label"
aria-label="Delete label"
@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 labels yet create one above.
</p>
</div>
</BaseModal>
</template>