Files
thoughtsync/frontend/src/components/LabelPicker.vue
T
bvandeusenandClaude Opus 4.8 c4914fe587
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 37s
m4.5: inline #tags — hashtags in a note become labels (two-way sync)
Fast, cross-device labelling: type #groceries in a note and it becomes
the "groceries" label. The body is the source of truth for tag-labels;
manual picker labels stay independent (rule 28 — additive).

- note_labels.via_tag (migration 0013) marks tag-sourced attachments.
- parse_tags(): #tag at start-of-body or after whitespace, needs a
  letter (so #2024, URL #frags, mid#word are ignored). unit-tested.
- _reconcile_tags() on create + body-update: attach labels for current
  #tags (find-or-create, case-insensitive), detach tag-labels whose tag
  was removed; never touches manual (via_tag=false) rows.
- label picker (set_note_labels + editor onLabelsChange) now preserves
  tag-labels on save, so a picker action can't strip a label the #tag
  still mandates.
- serialize via_tag; card/editor chips render tag-labels as "#name",
  and the editor hides the × on them (remove by editing the tag text).
- LabelPicker builds manual NoteLabels (via_tag:false).

Fourth and final item of M4.5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-22 08:39:20 -04:00

105 lines
3.9 KiB
Vue

<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useLabelsStore, type Label } from "../stores/labels";
import Icon from "./Icon.vue";
import type { NoteLabel } from "../stores/notes";
// A label attached via the picker is manual (via_tag: false).
const asManual = (lb: Label): NoteLabel => ({ id: lb.id, name: lb.name, color: lb.color, via_tag: false });
const props = defineProps<{ modelValue: NoteLabel[] }>();
const emit = defineEmits<{ (e: "update:modelValue", value: NoteLabel[]): void }>();
const labels = useLabelsStore();
const open = ref(false);
const filter = ref("");
const root = ref<HTMLElement | null>(null);
const selectedIds = computed(() => new Set(props.modelValue.map((lb) => lb.id)));
const shown = computed(() =>
labels.items.filter((lb) => lb.name.toLowerCase().includes(filter.value.trim().toLowerCase())),
);
const canCreate = computed(() => {
const name = filter.value.trim();
return name.length > 0 && !labels.items.some((lb) => lb.name.toLowerCase() === name.toLowerCase());
});
function toggle(lb: Label) {
const next = selectedIds.value.has(lb.id)
? props.modelValue.filter((x) => x.id !== lb.id)
: [...props.modelValue, asManual(lb)];
emit("update:modelValue", next);
}
async function createAndAdd() {
const created = await labels.create(filter.value.trim());
filter.value = "";
if (!selectedIds.value.has(created.id)) emit("update:modelValue", [...props.modelValue, asManual(created)]);
}
function onDocMousedown(e: MouseEvent) {
if (open.value && root.value && !root.value.contains(e.target as Node)) open.value = false;
}
onMounted(() => document.addEventListener("mousedown", onDocMousedown));
onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown));
</script>
<template>
<div ref="root" class="relative">
<button type="button" class="icon-btn" title="Labels" aria-label="Labels" @click="open = !open">
<Icon name="tag" />
</button>
<div
v-if="open"
class="absolute bottom-full right-0 z-10 mb-1 w-56 rounded-lg border border-neutral-200 bg-white p-2 shadow-lg dark:border-neutral-700 dark:bg-neutral-800"
>
<input
v-model="filter"
type="text"
placeholder="Label note…"
class="mb-1 w-full rounded-md border border-neutral-300 bg-white px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-600 dark:bg-neutral-900"
/>
<ul class="max-h-48 overflow-y-auto">
<li v-for="lb in shown" :key="lb.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="toggle(lb)"
>
<span
class="flex h-4 w-4 shrink-0 items-center justify-center rounded border"
:class="
selectedIds.has(lb.id)
? 'border-brand bg-brand text-neutral-900'
: 'border-neutral-400 dark:border-neutral-500'
"
>
<svg
v-if="selectedIds.has(lb.id)"
class="h-3 w-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 6 9 17l-5-5" />
</svg>
</span>
<span class="truncate">{{ lb.name }}</span>
</button>
</li>
</ul>
<button
v-if="canCreate"
type="button"
class="mt-1 flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-brand-700 hover:bg-neutral-100 dark:text-brand dark:hover:bg-neutral-700"
@click="createAndAdd"
>
<Icon name="plus" /> Create "{{ filter.trim() }}"
</button>
</div>
</div>
</template>