S1: frontend infra — AsyncState/EmptyState + useNoteEditor, adopted in BoardView
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 8s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 30s

M9 section S1, commit 4 (frontend). Introduce the shared UI primitives the 7 data
views + 5 editor hosts were hand-rolling:

- components/AsyncState.vue: the loading / error(+Retry) wrapper (emits `retry`).
- components/EmptyState.vue: the centered title/subtitle "nothing here" block.
- composables/useNoteEditor.ts: the editing/open/close/navigate glue every editor
  host duplicated, as one controller (onClose hook + local-list resolution for
  [[wiki-link]] navigation).

BoardView adopts all three: its three hand-rolled loading/error/empty blocks
collapse into <AsyncState> + <EmptyState>, and its editor glue into useNoteEditor.
The other views + editor hosts adopt these in the Organize (S3) and Auth (S5)
sections. Behavior-preserving.

Frontend has no local typecheck (rule 10); CI's vue-tsc is the gate.

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-23 19:40:37 -04:00
co-authored by Claude Opus 4.8
parent 61f7c40db7
commit 18ca4d4db4
4 changed files with 93 additions and 41 deletions
+20 -41
View File
@@ -4,6 +4,9 @@ import { useRoute, useRouter } from "vue-router";
import { useNotesStore, type Note, type NoteView } from "../stores/notes";
import { useUiStore } from "../stores/ui";
import { facetCount, facetsFromQuery, facetsToQuery } from "../notes/facets";
import { useNoteEditor } from "../composables/useNoteEditor";
import AsyncState from "../components/AsyncState.vue";
import EmptyState from "../components/EmptyState.vue";
import FilterBar from "../components/FilterBar.vue";
import NoteCard from "../components/NoteCard.vue";
import NoteEditor from "../components/NoteEditor.vue";
@@ -13,9 +16,14 @@ const route = useRoute();
const ui = useUiStore();
const router = useRouter();
const editing = ref<Note | null>(null);
const composing = ref(false); // compose modal open (a new, empty note)
const loadError = ref("");
// Shared editor controller (open/close/navigate glue lives in the composable).
const { editing, open: openEditor, close: closeEditor, navigate: onNavigate } = useNoteEditor({
onClose: () => {
composing.value = false;
},
});
// Compose and edit are ONE surface: the "Take a note…" bar and the global `c`
// shortcut both open the same modal editor with an empty note (editing = null).
@@ -34,7 +42,7 @@ watch(
);
async function openFromQuery(id: string) {
const found = notes.items.find((n) => n.id === id) ?? (await notes.fetchOne(id));
if (found) editing.value = found;
if (found) openEditor(found);
const q = { ...route.query };
delete q.open;
void router.replace({ query: q });
@@ -164,24 +172,6 @@ onBeforeUnmount(() => {
});
watch([currentView, currentLabel, facetKey], reload);
function openEditor(note: Note) {
editing.value = note;
}
function closeEditor() {
editing.value = null;
composing.value = false;
}
async function onNavigate(id: string) {
const found = notes.items.find((n) => n.id === id);
if (found) {
editing.value = found;
return;
}
const fetched = await notes.fetchOne(id);
if (fetched) editing.value = fetched;
}
const draggingId = ref<string | null>(null);
function onDragStart(note: Note) {
draggingId.value = note.id;
@@ -218,26 +208,14 @@ async function onDrop(target: Note) {
</button>
<FilterBar v-if="isMainBoard" />
<div v-if="notes.loading" class="py-24 text-center text-sm text-neutral-400">Loading</div>
<div v-else-if="loadError" class="py-24 text-center">
<h2 class="text-lg font-semibold text-neutral-700 dark:text-neutral-200">Couldn't load your notes</h2>
<p class="mt-1 text-sm text-neutral-400">{{ loadError }}</p>
<button
type="button"
class="mt-3 rounded-md border border-neutral-300 px-3 py-1.5 text-sm hover:bg-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:hover:bg-neutral-800"
@click="reload"
>
Retry
</button>
</div>
<div v-else-if="notes.items.length === 0" class="py-24 text-center">
<h2 class="text-lg font-semibold text-neutral-700 dark:text-neutral-200">{{ emptyState.title }}</h2>
<p class="mt-1 text-sm text-neutral-400">{{ emptyState.subtitle }}</p>
</div>
<template v-else>
<AsyncState
:loading="notes.loading"
:error="loadError || undefined"
error-title="Couldn't load your notes"
@retry="reload"
>
<EmptyState v-if="notes.items.length === 0" :title="emptyState.title" :subtitle="emptyState.subtitle" />
<template v-else>
<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>
@@ -288,7 +266,8 @@ async function onDrop(target: Note) {
@drop="onDrop"
/>
</div>
</template>
</template>
</AsyncState>
</div>
<template v-if="composing || editing">