Files
inkwell/frontend/src/stores/notes.ts
T
bvandeusenandClaude Opus 4.8 80324fba3b
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 9s
CI & Build / Build & push image (push) Successful in 34s
m4.5: lists first-class — create a checklist straight from quick-add
Checklist notes existed (M2) but could only be made by creating a text
note and toggling it in the editor — so they were undiscoverable. Now
the board's quick-add can make one in one shot.

- create endpoint accepts kind + items: POST /api/notes with
  kind:"list" and items:[...] creates a checklist note and its items
  atomically. A list note is non-empty when it has a title or ≥1 item.
- quick-add gets a checklist toggle (checkbox icon): flip it and each
  body line becomes an item on save; placeholder switches to
  "One item per line"; resets to a plain note after close.
- notes store create() accepts kind + items; parse_list_items() helper
  (trims, drops blanks) with a unit test.
- title placeholders now read "Title (optional)" in quick-add too.

Third item of M4.5. Display/editing of checklists was already built in
M2; this closes the creation gap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-22 08:26:19 -04:00

229 lines
6.8 KiB
TypeScript

import { defineStore } from "pinia";
import { ref } from "vue";
import { api } from "../api/client";
import { useUiStore } from "./ui";
import type { NoteColor } from "../notes/colors";
export type NoteView = "active" | "archived" | "trash";
export type NoteKind = "text" | "list";
export interface NoteLabel {
id: string;
name: string;
color: string;
}
export interface ChecklistItem {
id: string;
text: string;
checked: boolean;
position: number;
}
export interface Attachment {
id: string;
url: string;
mime: string;
}
export interface Note {
id: string;
title: string | null;
// The note's display NAME: explicit title, else its first body line (server-derived).
// Every note has one, so body-only notes are still nameable + [[link]]-able.
display_title: string;
body: string;
color: NoteColor;
kind: NoteKind;
position: number;
pinned: boolean;
archived: boolean;
trashed: boolean;
remind_at: string | null;
labels: NoteLabel[];
items: ChecklistItem[];
attachments: Attachment[];
created_at: string | null;
updated_at: string | null;
}
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 {
items.value.sort((a, b) => {
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
if (a.position !== b.position) return b.position - a.position;
return (b.updated_at ?? "").localeCompare(a.updated_at ?? "");
});
}
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 (belongsHere(note)) {
if (idx >= 0) items.value[idx] = note;
else items.value.push(note);
sortItems();
} else if (idx >= 0) {
items.value.splice(idx, 1);
}
}
async function load(v: NoteView, labelId: string | null = null): Promise<void> {
view.value = v;
activeLabel.value = labelId;
loading.value = true;
try {
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 {
loading.value = false;
}
}
async function create(input: {
title: string;
body: string;
color: NoteColor;
kind?: NoteKind;
items?: string[];
}): Promise<void> {
reconcile(await api.post<Note>("/api/notes", input));
}
async function mutate(
id: string,
changes: Partial<Pick<Note, "title" | "body" | "color" | "kind" | "pinned" | "archived" | "remind_at">>,
): Promise<void> {
reconcile(await api.patch<Note>(`/api/notes/${id}`, changes));
}
const setPinned = (id: string, pinned: boolean) => mutate(id, { pinned });
const setArchived = async (id: string, archived: boolean): Promise<void> => {
await mutate(id, { archived });
if (archived)
useUiStore().showToast("Note archived", { label: "Undo", run: () => void setArchived(id, false) });
};
const setColor = (id: string, color: NoteColor) => mutate(id, { color });
const setKind = (id: string, kind: NoteKind) => mutate(id, { kind });
const setReminder = (id: string, remindAt: string | null) => mutate(id, { remind_at: remindAt });
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 uploadAttachment(id: string, file: File): Promise<void> {
const form = new FormData();
form.append("file", file);
const resp = await fetch(`/api/notes/${id}/attachments`, { method: "POST", credentials: "include", body: form });
const data: unknown = await resp.json().catch(() => ({}));
if (!resp.ok) {
const message =
typeof data === "object" && data !== null && "error" in data
? String((data as { error: unknown }).error)
: "Upload failed.";
throw { error: message, status: resp.status };
}
reconcile(data as Note);
}
async function deleteAttachment(id: string, attId: string): Promise<void> {
reconcile(await api.del<Note>(`/api/notes/${id}/attachments/${attId}`));
}
async function fetchOne(id: string): Promise<Note | null> {
try {
return await api.get<Note>(`/api/notes/${id}`);
} catch {
return null;
}
}
async function createTitled(title: string): Promise<Note> {
const created = await api.post<Note>("/api/notes", { title, body: "" });
reconcile(created);
return created;
}
async function reorder(orderedIds: string[]): Promise<void> {
// Optimistically assign positions matching the backend (total - index), sort,
// then persist.
const total = orderedIds.length;
orderedIds.forEach((id, index) => {
const n = items.value.find((x) => x.id === id);
if (n) n.position = total - index;
});
sortItems();
await api.post("/api/notes/reorder", { ids: orderedIds });
}
async function trash(id: string): Promise<void> {
reconcile(await api.post<Note>(`/api/notes/${id}/trash`));
useUiStore().showToast("Note moved to trash", { label: "Undo", run: () => void restore(id) });
}
async function restore(id: string): Promise<void> {
reconcile(await api.post<Note>(`/api/notes/${id}/restore`));
}
async function deleteForever(id: string): Promise<void> {
await api.del(`/api/notes/${id}`);
const idx = items.value.findIndex((n) => n.id === id);
if (idx >= 0) items.value.splice(idx, 1);
}
return {
items,
loading,
view,
activeLabel,
load,
create,
setPinned,
setArchived,
setColor,
setKind,
setReminder,
saveEdit,
setLabels,
addItem,
updateItem,
deleteItem,
uploadAttachment,
deleteAttachment,
fetchOne,
createTitled,
reorder,
trash,
restore,
deleteForever,
};
});