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
+16 -76
View File
@@ -1,36 +1,28 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useSessionStore } from "../stores/session";
import { useConfigStore } from "../stores/config";
import { useRoute } from "vue-router";
import { useNotesStore, type Note, type NoteView } from "../stores/notes";
import AppShell from "../components/AppShell.vue";
import QuickAdd from "../components/QuickAdd.vue";
import NoteCard from "../components/NoteCard.vue";
import NoteEditor from "../components/NoteEditor.vue";
import BaseButton from "../components/BaseButton.vue";
import Icon from "../components/Icon.vue";
const session = useSessionStore();
const config = useConfigStore();
const notes = useNotesStore();
const route = useRoute();
const router = useRouter();
const editing = ref<Note | null>(null);
const navItems = [
{ name: "board", label: "Notes", to: "/" },
{ name: "archive", label: "Archive", to: "/archive" },
{ name: "trash", label: "Trash", to: "/trash" },
];
function viewForRoute(name: unknown): NoteView {
if (name === "archive") return "archived";
if (name === "trash") return "trash";
return "active";
return "active"; // board + label views
}
const currentView = computed<NoteView>(() => viewForRoute(route.name));
const currentLabel = computed<string | null>(() => (route.name === "label" ? String(route.params.id) : null));
// Quick-add + pinned/others split only on the main board (not archive/trash/label).
const isMainBoard = computed(() => route.name === "board");
const pinnedNotes = computed(() => notes.items.filter((n) => n.pinned));
const otherNotes = computed(() => notes.items.filter((n) => !n.pinned));
@@ -38,15 +30,16 @@ const emptyState = computed(() => {
if (currentView.value === "trash") return { title: "Trash is empty", subtitle: "Notes you delete land here first." };
if (currentView.value === "archived")
return { title: "Nothing archived", subtitle: "Archived notes are tucked away here." };
if (currentLabel.value) return { title: "No notes with this label", subtitle: "Tag a note to see it here." };
return { title: "No notes yet", subtitle: "Capture your first thought in the box above." };
});
async function reload() {
await notes.load(currentView.value);
await notes.load(currentView.value, currentLabel.value);
}
onMounted(reload);
watch(currentView, reload);
watch([currentView, currentLabel], reload);
function openEditor(note: Note) {
editing.value = note;
@@ -54,65 +47,12 @@ function openEditor(note: Note) {
function closeEditor() {
editing.value = null;
}
async function signOut() {
await session.logout();
await router.replace("/login");
}
</script>
<template>
<div class="flex min-h-full flex-col">
<header
class="sticky top-0 z-10 border-b border-neutral-200 bg-neutral-50/90 backdrop-blur dark:border-neutral-800 dark:bg-neutral-950/90"
>
<div class="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-3">
<div class="flex items-center gap-2">
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-brand text-sm font-black text-neutral-900">
TS
</div>
<span class="hidden font-semibold sm:inline">{{ config.siteName }}</span>
</div>
<nav class="flex items-center gap-1">
<RouterLink
v-for="item in navItems"
:key="item.name"
:to="item.to"
class="rounded-md px-3 py-1.5 text-sm font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
:class="
route.name === item.name
? 'bg-neutral-200 text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100'
: 'text-neutral-500 hover:bg-neutral-100 dark:text-neutral-400 dark:hover:bg-neutral-800'
"
>
{{ item.label }}
</RouterLink>
</nav>
<div class="flex items-center gap-3">
<RouterLink
v-if="session.user?.is_admin"
to="/settings"
class="icon-btn"
title="Settings"
aria-label="Settings"
>
<Icon name="settings" />
</RouterLink>
<span class="hidden text-sm text-neutral-500 sm:inline dark:text-neutral-400">{{
session.user?.display_name
}}</span>
<BaseButton variant="ghost" @click="signOut">
<Icon name="logout" />
<span class="hidden sm:inline">Sign out</span>
</BaseButton>
</div>
</div>
</header>
<main class="mx-auto w-full max-w-6xl flex-1 px-4 py-6">
<QuickAdd v-if="currentView === 'active'" autofocus class="mb-8" />
<AppShell>
<div class="mx-auto w-full max-w-6xl px-4 py-6">
<QuickAdd v-if="isMainBoard" autofocus class="mb-8" />
<div v-if="notes.loading" class="py-24 text-center text-sm text-neutral-400">Loading</div>
@@ -122,7 +62,7 @@ async function signOut() {
</div>
<template v-else>
<template v-if="currentView === 'active'">
<template v-if="isMainBoard">
<section v-if="pinnedNotes.length">
<h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-400">Pinned</h2>
<div class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4">
@@ -146,10 +86,10 @@ async function signOut() {
<NoteCard v-for="n in notes.items" :key="n.id" :note="n" @open="openEditor" />
</div>
</template>
</main>
</div>
<template v-if="editing">
<NoteEditor :note="editing" @close="closeEditor" />
</template>
</div>
</AppShell>
</template>