M2 labels frontend: sidebar shell + label filter, chips, picker, manage
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 13s
CI & Build / Build & push image (push) Successful in 34s

- AppShell: persistent left sidebar (Notes · labels · Archive · Trash) + top bar
  (site name, admin Settings, sign out); BoardView now renders inside it.
- labels store (list/create/rename/delete); Note gains labels[]; notes store
  gains setLabels + label-aware reconcile + /api/notes?label= loading.
- /label/:id route → label-filtered board.
- LabelPicker (tag a note, create-on-the-fly) in the editor; label chips shown
  on cards and in the editor; LabelsModal to create/rename/delete labels.
- api client PUT; new icons (note/tag/pencil/plus/check); nav-link styles.

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:
2026-07-19 21:56:14 -04:00
co-authored by Claude Opus 4.8
parent 4d1fc1bdf9
commit 2046600a95
12 changed files with 432 additions and 89 deletions
+101
View File
@@ -0,0 +1,101 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useLabelsStore } from "../stores/labels";
import Icon from "./Icon.vue";
import type { NoteLabel } from "../stores/notes";
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: NoteLabel) {
const next = selectedIds.value.has(lb.id)
? props.modelValue.filter((x) => x.id !== lb.id)
: [...props.modelValue, 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, 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>