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
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
// The loading / error(+retry) wrapper every data view hand-rolled. While `loading`
// shows a spinner line; on `error` shows the message + a Retry button (emits
// `retry`); otherwise renders the default slot (the caller decides empty vs content).
defineProps<{ loading: boolean; error?: string; errorTitle?: string }>();
defineEmits<{ (e: "retry"): void }>();
</script>
<template>
<div v-if="loading" class="py-24 text-center text-sm text-neutral-400">Loading</div>
<div v-else-if="error" class="py-24 text-center">
<h2 class="text-lg font-semibold text-neutral-700 dark:text-neutral-200">
{{ errorTitle ?? "Something went wrong" }}
</h2>
<p class="mt-1 text-sm text-neutral-400">{{ error }}</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="$emit('retry')"
>
Retry
</button>
</div>
<slot v-else />
</template>
+13
View File
@@ -0,0 +1,13 @@
<script setup lang="ts">
// The centered "nothing here" block every data view renders. Title + optional
// subtitle; an optional default slot for a call-to-action beneath.
defineProps<{ title: string; subtitle?: string }>();
</script>
<template>
<div class="py-24 text-center">
<h2 class="text-lg font-semibold text-neutral-700 dark:text-neutral-200">{{ title }}</h2>
<p v-if="subtitle" class="mt-1 text-sm text-neutral-400">{{ subtitle }}</p>
<div v-if="$slots.default" class="mt-3"><slot /></div>
</div>
</template>
+33
View File
@@ -0,0 +1,33 @@
import { ref } from "vue";
import { useNotesStore, type Note } from "../stores/notes";
// Shared controller for hosting the NoteEditor modal. Every view that opens the
// editor used to re-declare the same editing/open/close/navigate glue; this is the
// single implementation.
//
// - `onClose` lets a host clean up its own state (e.g. a board's compose flag).
// - `list` is the host's local note array, tried first when resolving a navigated
// [[wiki-link]] target before falling back to the store, then a fetch — so views
// that keep their own list (reminders, timeline, search, graph) still resolve
// locally without duplicating the lookup.
export function useNoteEditor(options: { onClose?: () => void; list?: () => Note[] } = {}) {
const notes = useNotesStore();
const editing = ref<Note | null>(null);
function open(note: Note): void {
editing.value = note;
}
function close(): void {
editing.value = null;
options.onClose?.();
}
async function navigate(id: string): Promise<void> {
const local = options.list?.() ?? notes.items;
const found = local.find((n) => n.id === id) ?? notes.items.find((n) => n.id === id) ?? (await notes.fetchOne(id));
if (found) editing.value = found;
}
return { editing, open, close, navigate };
}
+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">