M2 checklists: note kind + items backend + editor/card UI
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 27s

- Migration 0006: notes.kind ('text'|'list') + note_items (text, checked,
  position). Item API: add/update(toggle)/delete/reorder; PATCH note kind; note
  responses include kind + items[] (merged in one query alongside labels).
- notes store: kind/items on Note, setKind/addItem/updateItem/deleteItem.
- NoteChecklist component (toggle/add/edit/delete items); rendered read-only-ish
  on cards (checkboxes toggle) and editable in the editor.
- Editor: convert text<->checklist (body lines become items on convert to list).

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 22:13:25 -04:00
co-authored by Claude Opus 4.8
parent ffc008bf4d
commit 31be66ac60
11 changed files with 353 additions and 18 deletions
+1
View File
@@ -14,6 +14,7 @@ const paths: Record<string, string> = {
pencil: '<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/>',
plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
check: '<path d="M20 6 9 17l-5-5"/>',
checkbox: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="m9 12 2 2 4-4"/>',
};
</script>
+20 -1
View File
@@ -3,6 +3,7 @@ import { useNotesStore } from "../stores/notes";
import { NOTE_CARD_CLASSES, type NoteColor } from "../notes/colors";
import type { Note } from "../stores/notes";
import Icon from "./Icon.vue";
import NoteChecklist from "./NoteChecklist.vue";
defineProps<{ note: Note }>();
const emit = defineEmits<{ (e: "open", note: Note): void }>();
@@ -18,9 +19,27 @@ function cardClass(color: NoteColor): string {
class="group relative mb-4 break-inside-avoid rounded-xl border p-3 shadow-sm transition hover:shadow-md"
:class="cardClass(note.color)"
>
<!-- Checklist notes can't nest interactive controls in a <button>, so use a
focusable div; text notes keep a semantic button. -->
<template v-if="note.kind === 'list'">
<div
role="button"
tabindex="0"
class="rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
@click="emit('open', note)"
@keydown.enter="emit('open', note)"
>
<h3 v-if="note.title" class="mb-1 break-words text-sm font-semibold text-neutral-900 dark:text-neutral-100">
{{ note.title }}
</h3>
</div>
<NoteChecklist class="mt-1" :note-id="note.id" :items="note.items" @click="emit('open', note)" />
</template>
<button
v-else
type="button"
class="block w-full cursor-text text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-transparent rounded"
class="block w-full cursor-text rounded text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-transparent"
@click="emit('open', note)"
>
<h3 v-if="note.title" class="mb-1 break-words text-sm font-semibold text-neutral-900 dark:text-neutral-100">
+75
View File
@@ -0,0 +1,75 @@
<script setup lang="ts">
import { ref } from "vue";
import { useNotesStore, type ChecklistItem } from "../stores/notes";
const props = defineProps<{ noteId: string; items: ChecklistItem[]; editable?: boolean }>();
const notes = useNotesStore();
const newItem = ref("");
async function addItem() {
const text = newItem.value.trim();
if (!text) return;
await notes.addItem(props.noteId, text);
newItem.value = "";
}
function toggle(item: ChecklistItem) {
void notes.updateItem(props.noteId, item.id, { checked: !item.checked });
}
function editText(item: ChecklistItem, value: string) {
if (value !== item.text) void notes.updateItem(props.noteId, item.id, { text: value });
}
function remove(item: ChecklistItem) {
void notes.deleteItem(props.noteId, item.id);
}
</script>
<template>
<div class="flex flex-col gap-1">
<div v-for="item in items" :key="item.id" class="group/item flex items-center gap-2">
<input
type="checkbox"
class="h-4 w-4 shrink-0 accent-brand"
:checked="item.checked"
@change="toggle(item)"
@click.stop
/>
<input
v-if="editable"
:value="item.text"
class="min-w-0 flex-1 bg-transparent text-sm outline-none"
:class="item.checked ? 'text-neutral-400 line-through' : ''"
@change="editText(item, ($event.target as HTMLInputElement).value)"
/>
<span
v-else
class="min-w-0 flex-1 truncate text-sm"
:class="item.checked ? 'text-neutral-400 line-through' : 'text-neutral-700 dark:text-neutral-300'"
>{{ item.text }}</span
>
<button
v-if="editable"
type="button"
class="text-neutral-300 opacity-0 hover:text-neutral-600 group-hover/item:opacity-100 dark:hover:text-neutral-200"
aria-label="Delete item"
@click="remove(item)"
>
×
</button>
</div>
<form v-if="editable" class="mt-1 flex items-center gap-2" @submit.prevent="addItem">
<span class="h-4 w-4 shrink-0" />
<input
v-model="newItem"
type="text"
placeholder="+ List item"
class="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-neutral-400"
/>
</form>
<p v-if="!editable && items.length === 0" class="text-sm italic text-neutral-400">Empty checklist</p>
</div>
</template>
+38 -1
View File
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { nextTick, onMounted, ref, watch } from "vue";
import { computed, nextTick, onMounted, ref, watch } from "vue";
import { useNotesStore } from "../stores/notes";
import ColorPicker from "./ColorPicker.vue";
import Icon from "./Icon.vue";
import LabelPicker from "./LabelPicker.vue";
import NoteChecklist from "./NoteChecklist.vue";
import type { Note, NoteLabel } from "../stores/notes";
import type { NoteColor } from "../notes/colors";
@@ -11,6 +12,10 @@ const props = defineProps<{ note: Note }>();
const emit = defineEmits<{ (e: "close"): void }>();
const notes = useNotesStore();
// Read the note reactively from the store so checklist item add/toggle/delete
// (which reconcile a fresh note object) reflect live while the editor is open.
const liveNote = computed(() => notes.items.find((n) => n.id === props.note.id) ?? props.note);
const title = ref(props.note.title ?? "");
const body = ref(props.note.body);
const color = ref<NoteColor>(props.note.color);
@@ -44,6 +49,24 @@ async function removeLabel(id: string) {
await onLabelsChange(labelList.value.filter((lb) => lb.id !== id));
}
async function toggleKind() {
if (liveNote.value.kind === "list") {
await notes.setKind(props.note.id, "text");
return;
}
// Convert existing body lines into checklist items, then switch to a list.
const lines = body.value
.split("\n")
.map((s) => s.trim())
.filter((s) => s.length > 0);
for (const line of lines) await notes.addItem(props.note.id, line);
if (lines.length > 0) {
body.value = "";
await notes.saveEdit(props.note.id, { title: title.value, body: "", color: color.value });
}
await notes.setKind(props.note.id, "list");
}
async function close() {
const changed =
(title.value.trim() || null) !== (props.note.title ?? null) ||
@@ -80,12 +103,15 @@ async function act(fn: () => Promise<void>) {
class="w-full bg-transparent text-base font-semibold outline-none placeholder:text-neutral-400"
/>
<textarea
v-if="liveNote.kind === 'text'"
ref="bodyInput"
v-model="body"
rows="8"
placeholder="Take a note…"
class="w-full resize-none bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400"
/>
<NoteChecklist v-else class="py-1" :note-id="liveNote.id" :items="liveNote.items" editable />
<div v-if="labelList.length" class="flex flex-wrap gap-1.5 pt-1">
<span
v-for="lb in labelList"
@@ -104,9 +130,20 @@ async function act(fn: () => Promise<void>) {
</span>
</div>
</div>
<div class="flex items-center justify-between gap-2 border-t border-neutral-100 px-3 py-2 dark:border-neutral-800">
<ColorPicker v-model="color" />
<div class="flex items-center gap-0.5">
<button
v-if="!note.trashed"
type="button"
class="icon-btn"
:class="liveNote.kind === 'list' ? 'text-brand-700 dark:text-brand' : ''"
:title="liveNote.kind === 'list' ? 'Convert to text note' : 'Convert to checklist'"
@click="toggleKind"
>
<Icon name="checkbox" />
</button>
<LabelPicker v-if="!note.trashed" :model-value="labelList" @update:model-value="onLabelsChange" />
<template v-if="!note.trashed">
<button
+28 -1
View File
@@ -4,21 +4,31 @@ import { api } from "../api/client";
import type { NoteColor } from "../notes/colors";
export type NoteView = "active" | "archived" | "trash";
export type NoteKind = "text" | "list";
export interface NoteLabel {
id: string;
name: string;
}
export interface ChecklistItem {
id: string;
text: string;
checked: boolean;
position: number;
}
export interface Note {
id: string;
title: string | null;
body: string;
color: NoteColor;
kind: NoteKind;
pinned: boolean;
archived: boolean;
trashed: boolean;
labels: NoteLabel[];
items: ChecklistItem[];
created_at: string | null;
updated_at: string | null;
}
@@ -76,7 +86,7 @@ export const useNotesStore = defineStore("notes", () => {
async function mutate(
id: string,
changes: Partial<Pick<Note, "title" | "body" | "color" | "pinned" | "archived">>,
changes: Partial<Pick<Note, "title" | "body" | "color" | "kind" | "pinned" | "archived">>,
): Promise<void> {
reconcile(await api.patch<Note>(`/api/notes/${id}`, changes));
}
@@ -84,12 +94,25 @@ export const useNotesStore = defineStore("notes", () => {
const setPinned = (id: string, pinned: boolean) => mutate(id, { pinned });
const setArchived = (id: string, archived: boolean) => mutate(id, { archived });
const setColor = (id: string, color: NoteColor) => mutate(id, { color });
const setKind = (id: string, kind: NoteKind) => mutate(id, { kind });
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 addItem(id: string, text: string): Promise<void> {
reconcile(await api.post<Note>(`/api/notes/${id}/items`, { text }));
}
async function updateItem(id: string, itemId: string, changes: { text?: string; checked?: boolean }): Promise<void> {
reconcile(await api.patch<Note>(`/api/notes/${id}/items/${itemId}`, changes));
}
async function deleteItem(id: string, itemId: string): Promise<void> {
reconcile(await api.del<Note>(`/api/notes/${id}/items/${itemId}`));
}
async function trash(id: string): Promise<void> {
reconcile(await api.post<Note>(`/api/notes/${id}/trash`));
}
@@ -114,8 +137,12 @@ export const useNotesStore = defineStore("notes", () => {
setPinned,
setArchived,
setColor,
setKind,
saveEdit,
setLabels,
addItem,
updateItem,
deleteItem,
trash,
restore,
deleteForever,