S1: frontend infra — AsyncState/EmptyState + useNoteEditor, adopted in BoardView
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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user