M6: label management — usage counts + merge
Extends the existing label manager (create/rename/color/delete) with the two missing maintenance tools (task 1904), so the label list stays clean — which matters more now that #tags mint labels automatically. Backend: GET /api/labels returns a per-label note count (one grouped query); new POST /api/labels/<id>/merge moves the source label's notes onto a target and deletes the source (repoint via delete+reinsert to avoid mutating the composite PK; preserves via_tag; dedupes notes already on the target). Body #tags are NOT rewritten, so a tag-sourced label re-mints on next edit if its #tag text remains — a documented nuance. Frontend: LabelsModal shows each label's note count and a 'merge into…' picker; also restores the previously-missing close (x) and merge icons. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
@@ -20,6 +20,8 @@ const paths: Record<string, string> = {
|
||||
bell: '<path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/>',
|
||||
grip: '<circle cx="9" cy="5" r="1" fill="currentColor"/><circle cx="9" cy="12" r="1" fill="currentColor"/><circle cx="9" cy="19" r="1" fill="currentColor"/><circle cx="15" cy="5" r="1" fill="currentColor"/><circle cx="15" cy="12" r="1" fill="currentColor"/><circle cx="15" cy="19" r="1" fill="currentColor"/>',
|
||||
calendar: '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/>',
|
||||
close: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
|
||||
merge: '<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 0 9 9"/>',
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { useLabelsStore } from "../stores/labels";
|
||||
import { computed, ref } from "vue";
|
||||
import { useLabelsStore, type Label } from "../stores/labels";
|
||||
import { NOTE_COLOR_KEYS, NOTE_COLOR_LABELS, NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors";
|
||||
import Icon from "./Icon.vue";
|
||||
|
||||
@@ -8,6 +8,9 @@ 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();
|
||||
@@ -25,10 +28,29 @@ function labelDot(color: string): string {
|
||||
return NOTE_SWATCH_CLASSES[color as NoteColor] ?? 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>
|
||||
@@ -43,7 +65,7 @@ async function pickColor(id: string, color: NoteColor) {
|
||||
@keydown.esc="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">Edit labels</h2>
|
||||
<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">
|
||||
@@ -57,20 +79,35 @@ async function pickColor(id: string, color: NoteColor) {
|
||||
/>
|
||||
</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-2">
|
||||
<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.color)"
|
||||
:title="`Color: ${NOTE_COLOR_LABELS[(lb.color as NoteColor)] ?? lb.color}`"
|
||||
aria-label="Change label color"
|
||||
@click="pickerFor = pickerFor === lb.id ? null : lb.id"
|
||||
@click="openColor(lb.id)"
|
||||
/>
|
||||
<input
|
||||
:value="lb.name"
|
||||
class="flex-1 rounded-md bg-transparent px-2 py-1.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-brand"
|
||||
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"
|
||||
@@ -95,6 +132,29 @@ async function pickColor(id: string, color: NoteColor) {
|
||||
@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.color)"
|
||||
></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">
|
||||
|
||||
@@ -6,6 +6,8 @@ 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", () => {
|
||||
@@ -34,14 +36,15 @@ export const useLabelsStore = defineStore("labels", () => {
|
||||
async function rename(id: string, name: string): Promise<void> {
|
||||
const updated = await api.patch<Label>(`/api/labels/${id}`, { name });
|
||||
const idx = items.value.findIndex((lb) => lb.id === id);
|
||||
if (idx >= 0) items.value[idx] = updated;
|
||||
// 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 api.patch<Label>(`/api/labels/${id}`, { color });
|
||||
const idx = items.value.findIndex((lb) => lb.id === id);
|
||||
if (idx >= 0) items.value[idx] = updated;
|
||||
if (idx >= 0) items.value[idx] = { ...updated, count: items.value[idx].count };
|
||||
}
|
||||
|
||||
async function remove(id: string): Promise<void> {
|
||||
@@ -49,5 +52,15 @@ export const useLabelsStore = defineStore("labels", () => {
|
||||
items.value = items.value.filter((lb) => lb.id !== id);
|
||||
}
|
||||
|
||||
return { items, loaded, load, create, rename, setColor, remove };
|
||||
// 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 api.post<Label>(`/api/labels/${sourceId}/merge`, { into: 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 };
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user