CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 59s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m33s
Android (Tauri) / Android APK (debug) (push) Successful in 4m24s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m32s
Desktop (Tauri) / Update manifest (push) Successful in 5s
"The same board, re-filtered" has to be literally true to read as true. The column classes were copy-pasted into five places — the board's pinned and other sections, its non-board branch, search, and timeline — so a lens could drift from home by a single edit. One already had: the FLIP reflow from 1914 landed on the board's three grids and left search and timeline popping. NoteGrid is now the only file that knows how the masonry is laid out or how it moves, and search and timeline gained the motion by adopting it. It takes activeId rather than an index. The board splits its notes across two grids, so index-based focus made the call site do offset arithmetic (focusedIndex === pinnedNotes.length + i) against a list the grid didn't own. The lens cross-fade is deliberately UNKEYED, which is the whole trick. Board, archive, trash and label all render the same BoardView; keying the transition on the route would remount it, blanking the board and refetching — exactly the page-change feeling this is meant to remove. Unkeyed, Vue transitions only when the component TYPE changes (board to search to timeline to graph), and moving between the board's own lenses stays an in-place reflow that NoteGrid animates. The two behaviours fall out of one rule rather than needing to be special-cased. Out is quicker than in because mode="out-in" makes the durations additive. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
355 lines
13 KiB
Vue
355 lines
13 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 { 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, sync as syncBridge } from "../desktop/bridge";
|
|
|
|
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) {
|
|
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 label", 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: "ThoughtSync 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 = (e as { error?: string }).error ?? "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(() => {});
|
|
}
|
|
});
|
|
onBeforeUnmount(() => {
|
|
window.removeEventListener("keydown", onBoardKey);
|
|
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);
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="mx-auto w-full max-w-6xl px-4 py-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()"
|
|
>
|
|
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
|
|
</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"
|
|
:active-id="focusedId"
|
|
reorderable
|
|
@open="openEditor"
|
|
@dragstart="onDragStart"
|
|
@dragend="onDragEnd"
|
|
@drop="onDrop"
|
|
/>
|
|
</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"
|
|
:active-id="focusedId"
|
|
reorderable
|
|
@open="openEditor"
|
|
@dragstart="onDragStart"
|
|
@dragend="onDragEnd"
|
|
@drop="onDrop"
|
|
/>
|
|
</section>
|
|
</template>
|
|
|
|
<NoteGrid
|
|
v-else
|
|
:notes="notes.items"
|
|
:active-id="focusedId"
|
|
reorderable
|
|
@open="openEditor"
|
|
@dragstart="onDragStart"
|
|
@dragend="onDragEnd"
|
|
@drop="onDrop"
|
|
/>
|
|
</template>
|
|
</AsyncState>
|
|
</div>
|
|
|
|
<template v-if="composing || editing">
|
|
<NoteEditor
|
|
:note="editing"
|
|
:initial-body="composing ? ui.composeSeed : ''"
|
|
@close="closeEditor"
|
|
@navigate="onNavigate"
|
|
/>
|
|
</template>
|
|
</template>
|