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:
@@ -0,0 +1,22 @@
|
|||||||
|
"""labels.color
|
||||||
|
|
||||||
|
Revision ID: 0011
|
||||||
|
Revises: 0010
|
||||||
|
Create Date: 2026-07-20
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0011"
|
||||||
|
down_revision = "0010"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# server_default backfills existing labels to the neutral "default" chip.
|
||||||
|
op.add_column("labels", sa.Column("color", sa.Text(), nullable=False, server_default="default"))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("labels", "color")
|
||||||
@@ -8,6 +8,7 @@ import { useUiStore } from "../stores/ui";
|
|||||||
import CommandPalette from "./CommandPalette.vue";
|
import CommandPalette from "./CommandPalette.vue";
|
||||||
import Icon from "./Icon.vue";
|
import Icon from "./Icon.vue";
|
||||||
import LabelsModal from "./LabelsModal.vue";
|
import LabelsModal from "./LabelsModal.vue";
|
||||||
|
import { NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -127,6 +128,10 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKeydown));
|
|||||||
|
|
||||||
const currentLabelId = computed(() => (route.name === "label" ? String(route.params.id) : null));
|
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) {
|
function onSearch(value: string) {
|
||||||
searchText.value = value;
|
searchText.value = value;
|
||||||
clearTimeout(searchTimer);
|
clearTimeout(searchTimer);
|
||||||
@@ -226,7 +231,11 @@ async function signOut() {
|
|||||||
class="nav-link"
|
class="nav-link"
|
||||||
:class="currentLabelId === lb.id ? 'nav-link-active' : ''"
|
: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>
|
||||||
|
|
||||||
<RouterLink to="/archive" class="nav-link mt-3" :class="route.name === 'archive' ? 'nav-link-active' : ''">
|
<RouterLink to="/archive" class="nav-link mt-3" :class="route.name === 'archive' ? 'nav-link-active' : ''">
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
import { useLabelsStore } from "../stores/labels";
|
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";
|
import Icon from "./Icon.vue";
|
||||||
|
|
||||||
const emit = defineEmits<{ (e: "close"): void }>();
|
const emit = defineEmits<{ (e: "close"): void }>();
|
||||||
const labels = useLabelsStore();
|
const labels = useLabelsStore();
|
||||||
const newName = ref("");
|
const newName = ref("");
|
||||||
|
const pickerFor = ref<string | null>(null);
|
||||||
|
|
||||||
async function add() {
|
async function add() {
|
||||||
const name = newName.value.trim();
|
const name = newName.value.trim();
|
||||||
@@ -18,6 +20,15 @@ async function rename(id: string, value: string) {
|
|||||||
const name = value.trim();
|
const name = value.trim();
|
||||||
if (name) await labels.rename(id, name);
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -46,8 +57,15 @@ async function rename(id: string, value: string) {
|
|||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
<ul class="flex flex-col gap-1 pt-1">
|
<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">
|
<li v-for="lb in labels.items" :key="lb.id" class="relative flex items-center gap-2">
|
||||||
<Icon name="tag" />
|
<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
|
<input
|
||||||
:value="lb.name"
|
: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"
|
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" />
|
<Icon name="trash" />
|
||||||
</button>
|
</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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p v-if="!labels.items.length" class="py-2 text-center text-xs text-neutral-400">
|
<p v-if="!labels.items.length" class="py-2 text-center text-xs text-neutral-400">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from "vue";
|
import { ref, watch } from "vue";
|
||||||
import { useNotesStore } from "../stores/notes";
|
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 type { Note } from "../stores/notes";
|
||||||
import Icon from "./Icon.vue";
|
import Icon from "./Icon.vue";
|
||||||
import LinkedText from "./LinkedText.vue";
|
import LinkedText from "./LinkedText.vue";
|
||||||
@@ -28,6 +28,10 @@ watch(
|
|||||||
function cardClass(color: NoteColor): string {
|
function cardClass(color: NoteColor): string {
|
||||||
return NOTE_CARD_CLASSES[color] ?? NOTE_CARD_CLASSES.default;
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -86,7 +90,8 @@ function cardClass(color: NoteColor): string {
|
|||||||
<span
|
<span
|
||||||
v-for="lb in note.labels"
|
v-for="lb in note.labels"
|
||||||
:key="lb.id"
|
: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
|
>{{ lb.name }}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import LabelPicker from "./LabelPicker.vue";
|
|||||||
import NoteChecklist from "./NoteChecklist.vue";
|
import NoteChecklist from "./NoteChecklist.vue";
|
||||||
import { fromLocalInput, toLocalInput } from "../notes/datetime";
|
import { fromLocalInput, toLocalInput } from "../notes/datetime";
|
||||||
import type { Note, NoteLabel } from "../stores/notes";
|
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 props = defineProps<{ note: Note }>();
|
||||||
const emit = defineEmits<{ (e: "close"): void; (e: "navigate", id: string): void }>();
|
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));
|
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() {
|
async function toggleKind() {
|
||||||
if (liveNote.value.kind === "list") {
|
if (liveNote.value.kind === "list") {
|
||||||
await notes.setKind(props.note.id, "text");
|
await notes.setKind(props.note.id, "text");
|
||||||
@@ -315,7 +319,8 @@ async function act(fn: () => Promise<void>) {
|
|||||||
<span
|
<span
|
||||||
v-for="lb in labelList"
|
v-for="lb in labelList"
|
||||||
:key="lb.id"
|
: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 }}
|
{{ lb.name }}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -43,6 +43,20 @@ export const NOTE_SWATCH_CLASSES: Record<NoteColor, string> = {
|
|||||||
gray: "bg-neutral-400 dark:bg-neutral-500",
|
gray: "bg-neutral-400 dark:bg-neutral-500",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Label chip tints (bg + readable text), keyed by the same color vocabulary.
|
||||||
|
export const LABEL_CHIP_CLASSES: Record<NoteColor, string> = {
|
||||||
|
default: "bg-black/5 text-neutral-600 dark:bg-white/10 dark:text-neutral-300",
|
||||||
|
red: "bg-red-100 text-red-700 dark:bg-red-950/50 dark:text-red-300",
|
||||||
|
orange: "bg-orange-100 text-orange-700 dark:bg-orange-950/50 dark:text-orange-300",
|
||||||
|
yellow: "bg-amber-100 text-amber-800 dark:bg-amber-950/50 dark:text-amber-300",
|
||||||
|
green: "bg-green-100 text-green-700 dark:bg-green-950/50 dark:text-green-300",
|
||||||
|
teal: "bg-teal-100 text-teal-700 dark:bg-teal-950/50 dark:text-teal-300",
|
||||||
|
blue: "bg-blue-100 text-blue-700 dark:bg-blue-950/50 dark:text-blue-300",
|
||||||
|
purple: "bg-purple-100 text-purple-700 dark:bg-purple-950/50 dark:text-purple-300",
|
||||||
|
pink: "bg-pink-100 text-pink-700 dark:bg-pink-950/50 dark:text-pink-300",
|
||||||
|
gray: "bg-neutral-200 text-neutral-700 dark:bg-neutral-700 dark:text-neutral-200",
|
||||||
|
};
|
||||||
|
|
||||||
export const NOTE_COLOR_LABELS: Record<NoteColor, string> = {
|
export const NOTE_COLOR_LABELS: Record<NoteColor, string> = {
|
||||||
default: "Default",
|
default: "Default",
|
||||||
red: "Red",
|
red: "Red",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { api } from "../api/client";
|
|||||||
export interface Label {
|
export interface Label {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
color: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useLabelsStore = defineStore("labels", () => {
|
export const useLabelsStore = defineStore("labels", () => {
|
||||||
@@ -37,10 +38,16 @@ export const useLabelsStore = defineStore("labels", () => {
|
|||||||
sort();
|
sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function setColor(id: string, color: string): Promise<void> {
|
||||||
|
const updated = await api.patch<Label>(`/api/labels/${id}`, { color });
|
||||||
|
const idx = items.value.findIndex((lb) => lb.id === id);
|
||||||
|
if (idx >= 0) items.value[idx] = updated;
|
||||||
|
}
|
||||||
|
|
||||||
async function remove(id: string): Promise<void> {
|
async function remove(id: string): Promise<void> {
|
||||||
await api.del(`/api/labels/${id}`);
|
await api.del(`/api/labels/${id}`);
|
||||||
items.value = items.value.filter((lb) => lb.id !== id);
|
items.value = items.value.filter((lb) => lb.id !== id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { items, loaded, load, create, rename, remove };
|
return { items, loaded, load, create, rename, setColor, remove };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type NoteKind = "text" | "list";
|
|||||||
export interface NoteLabel {
|
export interface NoteLabel {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
color: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChecklistItem {
|
export interface ChecklistItem {
|
||||||
|
|||||||
+25
-11
@@ -12,8 +12,15 @@ from .models.label import Label
|
|||||||
bp = Blueprint("labels", __name__, url_prefix="/api/labels")
|
bp = Blueprint("labels", __name__, url_prefix="/api/labels")
|
||||||
|
|
||||||
|
|
||||||
|
LABEL_COLORS = {"default", "red", "orange", "yellow", "green", "teal", "blue", "purple", "pink", "gray"}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_label_color(color: object) -> str:
|
||||||
|
return color if color in LABEL_COLORS else "default"
|
||||||
|
|
||||||
|
|
||||||
def _serialize_label(label: Label) -> dict:
|
def _serialize_label(label: Label) -> dict:
|
||||||
return {"id": str(label.id), "name": label.name}
|
return {"id": str(label.id), "name": label.name, "color": label.color}
|
||||||
|
|
||||||
|
|
||||||
async def _get_owned_label(db, label_id: str) -> Label | None:
|
async def _get_owned_label(db, label_id: str) -> Label | None:
|
||||||
@@ -44,7 +51,7 @@ async def create_label():
|
|||||||
existing = await db.scalar(select(Label).where(Label.owner_id == g.user_id, Label.name == name))
|
existing = await db.scalar(select(Label).where(Label.owner_id == g.user_id, Label.name == name))
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
return jsonify(_serialize_label(existing)), 200
|
return jsonify(_serialize_label(existing)), 200
|
||||||
label = Label(owner_id=g.user_id, name=name)
|
label = Label(owner_id=g.user_id, name=name, color=_normalize_label_color(data.get("color")))
|
||||||
db.add(label)
|
db.add(label)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(label)
|
await db.refresh(label)
|
||||||
@@ -53,21 +60,28 @@ async def create_label():
|
|||||||
|
|
||||||
@bp.patch("/<label_id>")
|
@bp.patch("/<label_id>")
|
||||||
@login_required
|
@login_required
|
||||||
async def rename_label(label_id: str):
|
async def update_label(label_id: str):
|
||||||
data = await request.get_json(silent=True) or {}
|
data = await request.get_json(silent=True) or {}
|
||||||
name = (data.get("name") or "").strip()
|
has_name = "name" in data
|
||||||
if not name:
|
has_color = "color" in data
|
||||||
|
if not has_name and not has_color:
|
||||||
|
return jsonify({"error": "nothing to update"}), 400
|
||||||
|
name = (data.get("name") or "").strip() if has_name else None
|
||||||
|
if has_name and not name:
|
||||||
return jsonify({"error": "label name is required"}), 400
|
return jsonify({"error": "label name is required"}), 400
|
||||||
async with session_scope() as db:
|
async with session_scope() as db:
|
||||||
label = await _get_owned_label(db, label_id)
|
label = await _get_owned_label(db, label_id)
|
||||||
if label is None:
|
if label is None:
|
||||||
return jsonify({"error": "not found"}), 404
|
return jsonify({"error": "not found"}), 404
|
||||||
clash = await db.scalar(
|
if has_name:
|
||||||
select(Label).where(Label.owner_id == g.user_id, Label.name == name, Label.id != label.id)
|
clash = await db.scalar(
|
||||||
)
|
select(Label).where(Label.owner_id == g.user_id, Label.name == name, Label.id != label.id)
|
||||||
if clash is not None:
|
)
|
||||||
return jsonify({"error": "a label with that name already exists"}), 409
|
if clash is not None:
|
||||||
label.name = name
|
return jsonify({"error": "a label with that name already exists"}), 409
|
||||||
|
label.name = name
|
||||||
|
if has_color:
|
||||||
|
label.color = _normalize_label_color(data.get("color"))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(label)
|
await db.refresh(label)
|
||||||
return jsonify(_serialize_label(label))
|
return jsonify(_serialize_label(label))
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class Label(Base):
|
|||||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||||
)
|
)
|
||||||
name: Mapped[str] = mapped_column(Text(), nullable=False)
|
name: Mapped[str] = mapped_column(Text(), nullable=False)
|
||||||
|
color: Mapped[str] = mapped_column(Text(), nullable=False, default="default", server_default="default")
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -62,13 +62,13 @@ async def _labels_for_notes(db, note_ids: list) -> dict:
|
|||||||
if not note_ids:
|
if not note_ids:
|
||||||
return result
|
return result
|
||||||
rows = await db.execute(
|
rows = await db.execute(
|
||||||
select(NoteLabel.note_id, Label.id, Label.name)
|
select(NoteLabel.note_id, Label.id, Label.name, Label.color)
|
||||||
.join(Label, Label.id == NoteLabel.label_id)
|
.join(Label, Label.id == NoteLabel.label_id)
|
||||||
.where(NoteLabel.note_id.in_(note_ids))
|
.where(NoteLabel.note_id.in_(note_ids))
|
||||||
.order_by(Label.name)
|
.order_by(Label.name)
|
||||||
)
|
)
|
||||||
for note_id, label_id, name in rows.all():
|
for note_id, label_id, name, color in rows.all():
|
||||||
result.setdefault(note_id, []).append({"id": str(label_id), "name": name})
|
result.setdefault(note_id, []).append({"id": str(label_id), "name": name, "color": color})
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user