M2 labels frontend: sidebar shell + label filter, chips, picker, manage
- 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:
@@ -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>
|
||||
Reference in New Issue
Block a user