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
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>
168 lines
6.6 KiB
Vue
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>
|