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:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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