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
+46
View File
@@ -0,0 +1,46 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { api } from "../api/client";
export interface Label {
id: string;
name: string;
}
export const useLabelsStore = defineStore("labels", () => {
const items = ref<Label[]>([]);
const loaded = ref(false);
function sort() {
items.value.sort((a, b) => a.name.localeCompare(b.name));
}
async function load(): Promise<void> {
const res = await api.get<{ labels: Label[] }>("/api/labels");
items.value = res.labels;
loaded.value = true;
}
async function create(name: string): Promise<Label> {
const label = await api.post<Label>("/api/labels", { name });
if (!items.value.some((lb) => lb.id === label.id)) {
items.value.push(label);
sort();
}
return label;
}
async function rename(id: string, name: string): Promise<void> {
const updated = await api.patch<Label>(`/api/labels/${id}`, { name });
const idx = items.value.findIndex((lb) => lb.id === id);
if (idx >= 0) items.value[idx] = updated;
sort();
}
async function remove(id: string): Promise<void> {
await api.del(`/api/labels/${id}`);
items.value = items.value.filter((lb) => lb.id !== id);
}
return { items, loaded, load, create, rename, remove };
});
+27 -12
View File
@@ -5,6 +5,11 @@ import type { NoteColor } from "../notes/colors";
export type NoteView = "active" | "archived" | "trash";
export interface NoteLabel {
id: string;
name: string;
}
export interface Note {
id: string;
title: string | null;
@@ -13,34 +18,36 @@ export interface Note {
pinned: boolean;
archived: boolean;
trashed: boolean;
labels: NoteLabel[];
created_at: string | null;
updated_at: string | null;
}
function belongsToView(n: Note, v: NoteView): boolean {
if (v === "trash") return n.trashed;
if (v === "archived") return !n.trashed && n.archived;
return !n.trashed && !n.archived;
}
export const useNotesStore = defineStore("notes", () => {
const items = ref<Note[]>([]);
const loading = ref(false);
const view = ref<NoteView>("active");
const activeLabel = ref<string | null>(null);
function sortItems(): void {
// Pinned first, then most-recently-updated.
items.value.sort((a, b) => {
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
return (b.updated_at ?? "").localeCompare(a.updated_at ?? "");
});
}
// Put the server's version of a note where it belongs for the current view, or
// remove it if it no longer belongs (e.g. archived while viewing the board).
function belongsHere(n: Note): boolean {
const v = view.value;
const inView =
v === "trash" ? n.trashed : v === "archived" ? !n.trashed && n.archived : !n.trashed && !n.archived;
if (!inView) return false;
if (activeLabel.value) return n.labels.some((lb) => lb.id === activeLabel.value);
return true;
}
function reconcile(note: Note): void {
const idx = items.value.findIndex((n) => n.id === note.id);
if (belongsToView(note, view.value)) {
if (belongsHere(note)) {
if (idx >= 0) items.value[idx] = note;
else items.value.push(note);
sortItems();
@@ -49,11 +56,13 @@ export const useNotesStore = defineStore("notes", () => {
}
}
async function load(v: NoteView): Promise<void> {
async function load(v: NoteView, labelId: string | null = null): Promise<void> {
view.value = v;
activeLabel.value = labelId;
loading.value = true;
try {
const res = await api.get<{ notes: Note[] }>(`/api/notes?filter=${v}`);
const query = labelId ? `/api/notes?filter=${v}&label=${labelId}` : `/api/notes?filter=${v}`;
const res = await api.get<{ notes: Note[] }>(query);
items.value = res.notes;
sortItems();
} finally {
@@ -77,6 +86,10 @@ export const useNotesStore = defineStore("notes", () => {
const setColor = (id: string, color: NoteColor) => mutate(id, { color });
const saveEdit = (id: string, changes: { title: string; body: string; color: NoteColor }) => mutate(id, changes);
async function setLabels(id: string, labelIds: string[]): Promise<void> {
reconcile(await api.put<Note>(`/api/notes/${id}/labels`, { label_ids: labelIds }));
}
async function trash(id: string): Promise<void> {
reconcile(await api.post<Note>(`/api/notes/${id}/trash`));
}
@@ -95,12 +108,14 @@ export const useNotesStore = defineStore("notes", () => {
items,
loading,
view,
activeLabel,
load,
create,
setPinned,
setArchived,
setColor,
saveEdit,
setLabels,
trash,
restore,
deleteForever,