labels: per-label color
Give labels a color (migration 0011 adds labels.color, server_default 'default' so existing labels keep the neutral chip). The PATCH endpoint now updates name and/or color; note serialization carries each label's color. Frontend: a swatch picker per label in the Edit-labels modal, colored chips on cards + in the editor (LABEL_CHIP_CLASSES), and a color dot on each sidebar label. Reuses the note color vocabulary. (Graph node coloring rides this in the graph-liveliness task.) 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:
@@ -8,6 +8,7 @@ import { useUiStore } from "../stores/ui";
|
||||
import CommandPalette from "./CommandPalette.vue";
|
||||
import Icon from "./Icon.vue";
|
||||
import LabelsModal from "./LabelsModal.vue";
|
||||
import { NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -127,6 +128,10 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKeydown));
|
||||
|
||||
const currentLabelId = computed(() => (route.name === "label" ? String(route.params.id) : null));
|
||||
|
||||
function labelDot(color: string): string {
|
||||
return NOTE_SWATCH_CLASSES[color as NoteColor] ?? NOTE_SWATCH_CLASSES.default;
|
||||
}
|
||||
|
||||
function onSearch(value: string) {
|
||||
searchText.value = value;
|
||||
clearTimeout(searchTimer);
|
||||
@@ -226,7 +231,11 @@ async function signOut() {
|
||||
class="nav-link"
|
||||
:class="currentLabelId === lb.id ? 'nav-link-active' : ''"
|
||||
>
|
||||
<Icon name="tag" /> <span class="truncate">{{ lb.name }}</span>
|
||||
<span
|
||||
class="h-2.5 w-2.5 shrink-0 rounded-full border border-black/10 dark:border-white/15"
|
||||
:class="labelDot(lb.color)"
|
||||
></span>
|
||||
<span class="truncate">{{ lb.name }}</span>
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink to="/archive" class="nav-link mt-3" :class="route.name === 'archive' ? 'nav-link-active' : ''">
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { useLabelsStore } from "../stores/labels";
|
||||
import { NOTE_COLOR_KEYS, NOTE_COLOR_LABELS, NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors";
|
||||
import Icon from "./Icon.vue";
|
||||
|
||||
const emit = defineEmits<{ (e: "close"): void }>();
|
||||
const labels = useLabelsStore();
|
||||
const newName = ref("");
|
||||
const pickerFor = ref<string | null>(null);
|
||||
|
||||
async function add() {
|
||||
const name = newName.value.trim();
|
||||
@@ -18,6 +20,15 @@ async function rename(id: string, value: string) {
|
||||
const name = value.trim();
|
||||
if (name) await labels.rename(id, name);
|
||||
}
|
||||
|
||||
function labelDot(color: string): string {
|
||||
return NOTE_SWATCH_CLASSES[color as NoteColor] ?? NOTE_SWATCH_CLASSES.default;
|
||||
}
|
||||
|
||||
async function pickColor(id: string, color: NoteColor) {
|
||||
pickerFor.value = null;
|
||||
await labels.setColor(id, color);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -46,8 +57,15 @@ async function rename(id: string, value: string) {
|
||||
/>
|
||||
</form>
|
||||
<ul class="flex flex-col gap-1 pt-1">
|
||||
<li v-for="lb in labels.items" :key="lb.id" class="flex items-center gap-2">
|
||||
<Icon name="tag" />
|
||||
<li v-for="lb in labels.items" :key="lb.id" class="relative flex items-center gap-2">
|
||||
<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"
|
||||
/>
|
||||
<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"
|
||||
@@ -62,6 +80,21 @@ async function rename(id: string, value: string) {
|
||||
>
|
||||
<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], lb.color === key ? 'ring-2 ring-brand' : '']"
|
||||
@click="pickColor(lb.id, key)"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="!labels.items.length" class="py-2 text-center text-xs text-neutral-400">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
import { useNotesStore } from "../stores/notes";
|
||||
import { NOTE_CARD_CLASSES, type NoteColor } from "../notes/colors";
|
||||
import { LABEL_CHIP_CLASSES, NOTE_CARD_CLASSES, type NoteColor } from "../notes/colors";
|
||||
import type { Note } from "../stores/notes";
|
||||
import Icon from "./Icon.vue";
|
||||
import LinkedText from "./LinkedText.vue";
|
||||
@@ -28,6 +28,10 @@ watch(
|
||||
function cardClass(color: NoteColor): string {
|
||||
return NOTE_CARD_CLASSES[color] ?? NOTE_CARD_CLASSES.default;
|
||||
}
|
||||
|
||||
function labelChip(color: string): string {
|
||||
return LABEL_CHIP_CLASSES[color as NoteColor] ?? LABEL_CHIP_CLASSES.default;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -86,7 +90,8 @@ function cardClass(color: NoteColor): string {
|
||||
<span
|
||||
v-for="lb in note.labels"
|
||||
:key="lb.id"
|
||||
class="rounded-full bg-black/5 px-2 py-0.5 text-xs text-neutral-600 dark:bg-white/10 dark:text-neutral-300"
|
||||
class="rounded-full px-2 py-0.5 text-xs"
|
||||
:class="labelChip(lb.color)"
|
||||
>{{ lb.name }}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ import LabelPicker from "./LabelPicker.vue";
|
||||
import NoteChecklist from "./NoteChecklist.vue";
|
||||
import { fromLocalInput, toLocalInput } from "../notes/datetime";
|
||||
import type { Note, NoteLabel } from "../stores/notes";
|
||||
import type { NoteColor } from "../notes/colors";
|
||||
import { LABEL_CHIP_CLASSES, type NoteColor } from "../notes/colors";
|
||||
|
||||
const props = defineProps<{ note: Note }>();
|
||||
const emit = defineEmits<{ (e: "close"): void; (e: "navigate", id: string): void }>();
|
||||
@@ -176,6 +176,10 @@ async function removeLabel(id: string) {
|
||||
await onLabelsChange(labelList.value.filter((lb) => lb.id !== id));
|
||||
}
|
||||
|
||||
function labelChip(color: string): string {
|
||||
return LABEL_CHIP_CLASSES[color as NoteColor] ?? LABEL_CHIP_CLASSES.default;
|
||||
}
|
||||
|
||||
async function toggleKind() {
|
||||
if (liveNote.value.kind === "list") {
|
||||
await notes.setKind(props.note.id, "text");
|
||||
@@ -315,7 +319,8 @@ async function act(fn: () => Promise<void>) {
|
||||
<span
|
||||
v-for="lb in labelList"
|
||||
:key="lb.id"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-black/5 px-2 py-0.5 text-xs text-neutral-600 dark:bg-white/10 dark:text-neutral-300"
|
||||
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs"
|
||||
:class="labelChip(lb.color)"
|
||||
>
|
||||
{{ lb.name }}
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user