M1: masonry board UI — quick-add, note cards, editor, views
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 30s

- notes Pinia store (load/create/pin/archive/color/trash/restore/delete) with
  view-aware reconcile; api client patch/del methods.
- colors.ts palette (10 keys → light/dark card + swatch tints).
- QuickAdd (collapsed → expand, title/body/color, click-outside/Esc to save),
  NoteCard (color tint, click-to-edit, hover action bar), NoteEditor modal,
  ColorPicker, inline Icon set (no icon dep).
- BoardView rewrite: Notes/Archive/Trash routes, CSS-columns masonry, Pinned +
  Others sections, per-view empty states, sign-out.

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 16:08:53 -04:00
co-authored by Claude Opus 4.8
parent df59a30cca
commit 0f604f9a26
11 changed files with 649 additions and 38 deletions
+108
View File
@@ -0,0 +1,108 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { api } from "../api/client";
import type { NoteColor } from "../notes/colors";
export type NoteView = "active" | "archived" | "trash";
export interface Note {
id: string;
title: string | null;
body: string;
color: NoteColor;
pinned: boolean;
archived: boolean;
trashed: boolean;
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");
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 reconcile(note: Note): void {
const idx = items.value.findIndex((n) => n.id === note.id);
if (belongsToView(note, view.value)) {
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): Promise<void> {
view.value = v;
loading.value = true;
try {
const res = await api.get<{ notes: Note[] }>(`/api/notes?filter=${v}`);
items.value = res.notes;
sortItems();
} finally {
loading.value = false;
}
}
async function create(input: { title: string; body: string; color: NoteColor }): Promise<void> {
reconcile(await api.post<Note>("/api/notes", input));
}
async function mutate(
id: string,
changes: Partial<Pick<Note, "title" | "body" | "color" | "pinned" | "archived">>,
): Promise<void> {
reconcile(await api.patch<Note>(`/api/notes/${id}`, changes));
}
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 saveEdit = (id: string, changes: { title: string; body: string; color: NoteColor }) => mutate(id, changes);
async function trash(id: string): Promise<void> {
reconcile(await api.post<Note>(`/api/notes/${id}/trash`));
}
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,
load,
create,
setPinned,
setArchived,
setColor,
saveEdit,
trash,
restore,
deleteForever,
};
});