Files
inkwell/frontend/src/components/LabelsModal.vue
T
bvandeusenandClaude Opus 5.5 3dd47fa178 DRY pass #2, batch 5, F17: the web's smaller pairs (#5372)
- desktop/bridge.ts listen<T>(): the no-op-off-desktop event listener
  that onSynced, onCaptured and onReminderDue each wrote.
- size.ts roughSize(): one decimal below 10, none above, for the client
  download sizes (MB) and the storage line (GB).
- BaseModal's title prop draws the heading row and close button that
  LabelsModal and ShareDialog each built.
- SyncView: switchChannel and switchSource share recheck(); the four
  update-card radios render from two option lists.

Kept: the board's and the shell's is-typing guards (they ignore different
elements), the drawer's nav beside the palette's commands (one is a laid-
out list with tags between its entries, the other a command list), the
status pills (each colours differently), and plurals (the copy is the
operator's call, raised in #5371).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:48:31 -04:00

157 lines
5.9 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,
labelDotClasses,
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);
}
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" title="Manage tags" @close="emit('close')">
<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="labelDotClasses(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="labelDotClasses(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>