From the audit (#5179, web half). - NoteActions: share, pin, archive and trash (restore and delete forever when trashed), as both the card and the editor offer them. The editor's history toggle goes in its slot, and it closes on `acted`. - ReminderActions: the Done / 1h / 1d chips on the card and in the editor, which are now the same chips. - PageHeader: the back-to-board header that Settings, Sync and Linked devices each wrote out, now with a `back` icon from the shared set. - notes/datetime: formatShortDateTime (was formatReminder and the editor's revLabel) and formatDateTime (the two `fmt` copies). - notes/colors: labelDotClasses (the sidebar's and the tag manager's labelDot). - Drawer links use exact-active-class instead of route.name ternaries. - BoardView binds its three grids from one gridBinds object. - Settings goes through repo.settings (rest, plus a local adapter that answers "needs a server") and shows load failures through AsyncState. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
378 lines
15 KiB
Vue
378 lines
15 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
|
import { useRoute, useRouter } from "vue-router";
|
|
import { useNotesStore, type Note, type NoteView } from "../stores/notes";
|
|
import { useConfigStore } from "../stores/config";
|
|
import { useUiStore } from "../stores/ui";
|
|
import { facetCount, facetsFromQuery, facetsToQuery } from "../notes/facets";
|
|
import { isOwnNote } from "../notes/sharing";
|
|
import { useNoteEditor } from "../composables/useNoteEditor";
|
|
import AsyncState from "../components/AsyncState.vue";
|
|
import EmptyState from "../components/EmptyState.vue";
|
|
import FilterBar from "../components/FilterBar.vue";
|
|
import NoteGrid from "../components/NoteGrid.vue";
|
|
import NoteEditor from "../components/NoteEditor.vue";
|
|
import { isDesktop, onCaptured, onSynced, sync as syncBridge } from "../desktop/bridge";
|
|
import { errorMessage } from "../api/errors";
|
|
|
|
const notes = useNotesStore();
|
|
const config = useConfigStore();
|
|
const route = useRoute();
|
|
const ui = useUiStore();
|
|
const router = useRouter();
|
|
|
|
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).
|
|
watch(
|
|
() => ui.composeTick,
|
|
() => (composing.value = true),
|
|
);
|
|
|
|
// The command palette opens a note by navigating here with ?open=<id>.
|
|
watch(
|
|
() => route.query.open,
|
|
(v) => {
|
|
if (typeof v === "string" && v) void openFromQuery(v);
|
|
},
|
|
{ immediate: true },
|
|
);
|
|
async function openFromQuery(id: string) {
|
|
const found = notes.items.find((n) => n.id === id) ?? (await notes.fetchOne(id));
|
|
if (found) openEditor(found);
|
|
const q = { ...route.query };
|
|
delete q.open;
|
|
void router.replace({ query: q });
|
|
}
|
|
|
|
function viewForRoute(name: unknown): NoteView {
|
|
if (name === "archive") return "archived";
|
|
if (name === "trash") return "trash";
|
|
return "active"; // board + label views
|
|
}
|
|
|
|
const currentView = computed<NoteView>(() => viewForRoute(route.name));
|
|
const currentLabel = computed<string | null>(() => (route.name === "label" ? String(route.params.id) : null));
|
|
|
|
// Facet filters live in the URL query on the board route (a filtered board = a lens).
|
|
const facets = computed(() => facetsFromQuery(route.query));
|
|
const facetKey = computed(() => JSON.stringify(facetsToQuery(facets.value))); // stable; ignores ?open=
|
|
const filtered = computed(() => route.name === "board" && facetCount(facets.value) > 0);
|
|
|
|
// Quick-add + pinned/others split only on the main board (not archive/trash/label).
|
|
const isMainBoard = computed(() => route.name === "board");
|
|
const pinnedNotes = computed(() => notes.items.filter((n) => n.pinned));
|
|
const otherNotes = computed(() => notes.items.filter((n) => !n.pinned));
|
|
|
|
// --- Keyboard card navigation: j/k or arrows move a selection; Enter opens;
|
|
// e archive, # pin, x trash the focused card. ---
|
|
const orderedNotes = computed<Note[]>(() =>
|
|
isMainBoard.value ? [...pinnedNotes.value, ...otherNotes.value] : notes.items,
|
|
);
|
|
const focusedIndex = ref(-1);
|
|
/** The focused note's id — what the grids compare against, so neither has to know
|
|
* its own offset within the combined keyboard-navigation order. */
|
|
const focusedId = computed<string | null>(() => orderedNotes.value[focusedIndex.value]?.id ?? null);
|
|
const inTrash = computed(() => currentView.value === "trash");
|
|
|
|
function moveFocus(delta: number) {
|
|
const count = orderedNotes.value.length;
|
|
if (count === 0) return;
|
|
focusedIndex.value =
|
|
focusedIndex.value < 0 ? 0 : Math.min(Math.max(focusedIndex.value + delta, 0), count - 1);
|
|
}
|
|
|
|
// Two-mode board keyboard. RESTING (no card focused): letters type-to-compose,
|
|
// handled globally in AppShell; here only the ARROWS enter browse mode. BROWSING (a
|
|
// card focused): j/k move, e/x/# act, Enter opens, Esc exits back to resting.
|
|
function onBoardKey(e: KeyboardEvent) {
|
|
if (editing.value || composing.value) return; // a modal owns the keyboard
|
|
const el = e.target as HTMLElement | null;
|
|
if (el && (["INPUT", "TEXTAREA", "BUTTON", "A", "SELECT"].includes(el.tagName) || el.isContentEditable)) return;
|
|
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
const key = e.key;
|
|
const browsing = focusedIndex.value >= 0;
|
|
if (key === "ArrowDown" || key === "ArrowRight" || (browsing && key === "j")) {
|
|
e.preventDefault();
|
|
moveFocus(1);
|
|
return;
|
|
}
|
|
if (key === "ArrowUp" || key === "ArrowLeft" || (browsing && key === "k")) {
|
|
e.preventDefault();
|
|
moveFocus(-1);
|
|
return;
|
|
}
|
|
if (!browsing) {
|
|
// Resting: Enter starts a new note; other keys fall to AppShell's type-to-compose.
|
|
if (key === "Enter") {
|
|
e.preventDefault();
|
|
ui.requestCompose();
|
|
}
|
|
return;
|
|
}
|
|
if (key === "Escape") {
|
|
e.preventDefault();
|
|
focusedIndex.value = -1; // leave browse mode, back to type-to-capture
|
|
return;
|
|
}
|
|
const note = orderedNotes.value[focusedIndex.value];
|
|
if (!note) return;
|
|
if (key === "Enter") {
|
|
e.preventDefault();
|
|
openEditor(note);
|
|
} else if (key === "x" && !inTrash.value && isOwnNote(note)) {
|
|
// Trash is the owner's; archive and pin below are everyone's own (#5176).
|
|
e.preventDefault();
|
|
void notes.trash(note.id);
|
|
} else if (key === "e" && !inTrash.value) {
|
|
e.preventDefault();
|
|
void notes.setArchived(note.id, !note.archived);
|
|
} else if (key === "#" && !inTrash.value) {
|
|
e.preventDefault();
|
|
void notes.setPinned(note.id, !note.pinned);
|
|
}
|
|
}
|
|
|
|
// Publish browse-mode state so AppShell's type-to-compose stands down while browsing.
|
|
watch(focusedIndex, (i) => (ui.boardCardFocused = i >= 0));
|
|
|
|
// Keep the selection valid as the list changes / the view switches.
|
|
watch(
|
|
() => orderedNotes.value.length,
|
|
(len) => {
|
|
if (focusedIndex.value >= len) focusedIndex.value = len - 1;
|
|
},
|
|
);
|
|
watch([currentView, currentLabel, facetKey], () => (focusedIndex.value = -1));
|
|
|
|
// The retention policy, said out loud at the top of Trash. Empty when retention is
|
|
// off — promising a deletion that never comes is its own kind of lie.
|
|
const retentionNotice = computed(() => {
|
|
if (currentView.value !== "trash") return "";
|
|
const days = config.trashRetentionDays;
|
|
if (days <= 0) return "Notes stay in Trash until you delete them.";
|
|
return days === 1
|
|
? "Notes here are permanently deleted 1 day after you trash them. Restore one to keep it."
|
|
: `Notes here are permanently deleted ${days} days after you trash them. Restore one to keep it.`;
|
|
});
|
|
|
|
/**
|
|
* Whether this desktop install has been linked to a server. Only consulted for the
|
|
* first-run copy below — offering to connect a server to someone who already did
|
|
* would be noise. Never set on the web build, which IS a server's UI.
|
|
*/
|
|
const linkedToServer = ref(false);
|
|
|
|
/**
|
|
* The desktop's start state: a fresh install, nothing written yet.
|
|
*
|
|
* There is no login and no onboarding gate — the app is usable the instant it
|
|
* opens, and interrupting that with a welcome modal would tax the one thing the
|
|
* product is for (dumping a thought in a second). So the empty board carries the
|
|
* message instead, and retires itself the moment a first note exists. That also
|
|
* means no "seen it" flag to persist, migrate, or get out of step with reality.
|
|
*/
|
|
const startState = computed(
|
|
() =>
|
|
isDesktop() &&
|
|
isMainBoard.value &&
|
|
!filtered.value &&
|
|
!linkedToServer.value &&
|
|
notes.items.length === 0,
|
|
);
|
|
|
|
const emptyState = computed(() => {
|
|
if (filtered.value) return { title: "No notes match these filters", subtitle: "Try clearing or loosening a facet." };
|
|
if (currentView.value === "trash") return { title: "Trash is empty", subtitle: "Notes you delete land here first." };
|
|
if (currentView.value === "archived")
|
|
return { title: "Nothing archived", subtitle: "Archived notes are tucked away here." };
|
|
if (currentLabel.value) return { title: "No notes with this tag", subtitle: "Tag a note to see it here." };
|
|
// Says what "no account" actually means for the notes about to be written here.
|
|
// A new user otherwise has no way to tell whether this thing is storing their
|
|
// thoughts locally, silently waiting for a login, or quietly sending them off.
|
|
if (startState.value)
|
|
return {
|
|
title: "Your notes live on this device",
|
|
subtitle: "Inkwell works fully offline — no account, nothing to sign in to.",
|
|
};
|
|
return { title: "No notes yet", subtitle: "Your captured thoughts will show up here." };
|
|
});
|
|
|
|
async function reload() {
|
|
loadError.value = "";
|
|
try {
|
|
await notes.load(currentView.value, currentLabel.value, facets.value);
|
|
} catch (e) {
|
|
loadError.value = errorMessage(e, "Couldn't load your notes.");
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
void reload();
|
|
window.addEventListener("keydown", onBoardKey);
|
|
// Best-effort and never awaited: the board must render at full speed whether or
|
|
// not this answers. A failure just leaves the start-state copy showing, which is
|
|
// the correct reading of "we don't know of a server".
|
|
if (isDesktop()) {
|
|
void syncBridge
|
|
.status()
|
|
.then((s) => {
|
|
linkedToServer.value = s.linked;
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
});
|
|
// A note written in the quick-capture window lands in the same SQLite file but a
|
|
// different Pinia store — this window has no way to know unless it is told.
|
|
// Registered as a promise because the listener is set up asynchronously, and
|
|
// unregistered on the way out so a board that has been navigated away from does
|
|
// not keep reloading itself.
|
|
let stopCaptureListener: (() => void) | null = null;
|
|
// The desktop syncs in the background (autosync.rs). A cycle that pulled something
|
|
// changed SQLite under this board, so it reloads — only then, so the cycles that
|
|
// change nothing (most of them) cost the board nothing.
|
|
let stopSyncListener: (() => void) | null = null;
|
|
onMounted(() => {
|
|
void onCaptured(() => void reload()).then((stop) => {
|
|
stopCaptureListener = stop;
|
|
});
|
|
void onSynced((cycle) => {
|
|
if (cycle.changed) void reload();
|
|
}).then((stop) => {
|
|
stopSyncListener = stop;
|
|
});
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
window.removeEventListener("keydown", onBoardKey);
|
|
stopCaptureListener?.();
|
|
stopSyncListener?.();
|
|
ui.boardCardFocused = false;
|
|
});
|
|
watch([currentView, currentLabel, facetKey], reload);
|
|
|
|
const draggingId = ref<string | null>(null);
|
|
function onDragStart(note: Note) {
|
|
draggingId.value = note.id;
|
|
}
|
|
function onDragEnd() {
|
|
// Clear the tracked source even when a drag is cancelled (dropped on nothing).
|
|
draggingId.value = null;
|
|
}
|
|
// Receives the TARGET'S ID, not the note: the dragged card finds its target by
|
|
// hit-testing the DOM, so an id is all it can know without a second lookup.
|
|
async function onDrop(targetId: string) {
|
|
const from = draggingId.value;
|
|
draggingId.value = null;
|
|
if (!from || from === targetId) return;
|
|
const order = notes.items.map((n) => n.id);
|
|
const fromIdx = order.indexOf(from);
|
|
const toIdx = order.indexOf(targetId);
|
|
if (fromIdx < 0 || toIdx < 0) return;
|
|
order.splice(fromIdx, 1);
|
|
order.splice(toIdx, 0, from);
|
|
await notes.reorder(order);
|
|
}
|
|
|
|
/** What every grid on this board takes besides its notes: the keyboard focus, and
|
|
* opening and reordering cards. */
|
|
const gridBinds = computed(() => ({
|
|
activeId: focusedId.value,
|
|
reorderable: true,
|
|
onOpen: openEditor,
|
|
onDragstart: onDragStart,
|
|
onDragend: onDragEnd,
|
|
onDrop,
|
|
}));
|
|
</script>
|
|
|
|
<template>
|
|
<!-- `pb-[…safe-area-inset-bottom]`: the board is the last thing on the page, so
|
|
it is what ends up underneath the gesture bar once `viewport-fit=cover` lets
|
|
the page draw there. Added to the existing py-6 rather than replacing it. -->
|
|
<div class="mx-auto w-full max-w-6xl px-4 pb-[calc(1.5rem+env(safe-area-inset-bottom,0px))] pt-6">
|
|
<button
|
|
v-if="isMainBoard"
|
|
type="button"
|
|
class="mx-auto mb-4 block w-full max-w-xl rounded-xl border border-dashed border-neutral-300 px-4 py-2.5 text-center text-sm text-neutral-400 transition hover:border-neutral-400 hover:text-neutral-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:hover:border-neutral-500"
|
|
@click="ui.requestCompose()"
|
|
>
|
|
<!-- Two ways of saying the same thing, because the keyboard half is a lie on a
|
|
phone: there is no Enter key until something is already focused, and the
|
|
box has always been tappable. `hidden sm:inline` / `sm:hidden` rather than
|
|
a JS pointer check — this is presentation, and it should be right in the
|
|
first paint rather than after one. -->
|
|
<span class="hidden sm:inline">
|
|
Press
|
|
<kbd class="mx-0.5 rounded border border-neutral-300 px-1 text-xs dark:border-neutral-600">Enter</kbd>
|
|
or start typing to add a note
|
|
</span>
|
|
<span class="sm:hidden">Tap to add a note</span>
|
|
</button>
|
|
<FilterBar v-if="isMainBoard" />
|
|
|
|
<p
|
|
v-if="retentionNotice"
|
|
class="mx-auto mb-4 max-w-xl rounded-xl bg-black/5 px-4 py-2.5 text-center text-sm text-neutral-600 dark:bg-white/10 dark:text-neutral-300"
|
|
>
|
|
{{ retentionNotice }}
|
|
</p>
|
|
|
|
<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">
|
|
<!-- v-if on the slot itself, so every OTHER empty state renders without a
|
|
stray call-to-action box beneath it. -->
|
|
<template v-if="startState" #default>
|
|
<RouterLink
|
|
to="/sync"
|
|
class="inline-block rounded-md border border-neutral-300 px-3 py-1.5 text-sm text-neutral-600 transition hover:bg-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-800"
|
|
>
|
|
Connect a sync server
|
|
</RouterLink>
|
|
<p class="mt-2 text-xs text-neutral-400">
|
|
Optional — only if you want these notes on your other devices.
|
|
</p>
|
|
</template>
|
|
</EmptyState>
|
|
<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>
|
|
<NoteGrid :notes="pinnedNotes" v-bind="gridBinds" />
|
|
</section>
|
|
<section v-if="otherNotes.length" :class="pinnedNotes.length ? 'mt-8' : ''">
|
|
<h2 v-if="pinnedNotes.length" class="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-400">
|
|
Others
|
|
</h2>
|
|
<NoteGrid :notes="otherNotes" v-bind="gridBinds" />
|
|
</section>
|
|
</template>
|
|
|
|
<NoteGrid v-else :notes="notes.items" v-bind="gridBinds" />
|
|
</template>
|
|
</AsyncState>
|
|
</div>
|
|
|
|
<template v-if="composing || editing">
|
|
<NoteEditor
|
|
:note="editing"
|
|
:initial-body="composing ? ui.composeSeed : ''"
|
|
@close="closeEditor"
|
|
@navigate="onNavigate"
|
|
/>
|
|
</template>
|
|
</template>
|