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 33s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 1m32s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m4s
The surface that turns the engine into a feature (rule 27). Desktop-only — the web build IS a server's UI, so a "connect a server" screen there would be nonsense; the route redirects to the board and the nav entry is hidden. UNLINKED IS THE RESTING STATE, not an incomplete setup. The empty case leads with "Working offline on this device — everything works without a server", because a screen that framed the default as a problem would push people into configuring something they may never need. The app is local-first; this is opt-in. Probe before credentials. "Check" shows who actually answered — site name, version, and the M10.6 verdict — before any password or token is typed. An incompatible server is shown in red and the sign-in fields never appear, so you cannot hand a credential to something that can't use it. `degraded` names the missing capabilities rather than staying quiet and letting a feature mysteriously do nothing. Both credential paths, matching the Rust side: email+password (a fresh install has no session to mint a token from) or a pasted device token (for anyone who'd rather not type a password into a desktop app). Secrets are cleared from component state the moment they're exchanged. Disconnect states plainly that the token stays valid server-side and points at Account -> Linked devices, rather than implying a remote revoke that didn't happen (issue 2110). Wording avoids "revoke" for exactly that reason. Push rejections are surfaced verbatim after a sync, never swallowed — a duplicate label name is the realistic case and only a person can resolve it. Adds schema v3: last_sync_at. The cursor can't answer "am I up to date?" — it's a revision watermark, not a time, and it doesn't move at all when a sync legitimately finds nothing new, so "synced a moment ago, nothing new" would be indistinguishable from "never synced". Stamped only after BOTH halves of the cycle succeed; a stamp after a partial cycle would claim currency the data doesn't have. Cleared on unlink so a new server can't inherit it. run_cycle now returns the post-cycle status, so the UI updates from one round-trip instead of chasing every sync with a status call. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
432 lines
15 KiB
Vue
432 lines
15 KiB
Vue
<script setup lang="ts">
|
||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||
import { useRoute, useRouter } from "vue-router";
|
||
import { useSessionStore } from "../stores/session";
|
||
import { useConfigStore } from "../stores/config";
|
||
import { useLabelsStore } from "../stores/labels";
|
||
import { useSavedFiltersStore, type SavedFilter } from "../stores/savedFilters";
|
||
import { useReminderStore } from "../stores/reminders";
|
||
import { useUiStore } from "../stores/ui";
|
||
import BaseModal from "./BaseModal.vue";
|
||
import CommandPalette from "./CommandPalette.vue";
|
||
import Icon from "./Icon.vue";
|
||
import ImportNotes from "./ImportNotes.vue";
|
||
import LabelsModal from "./LabelsModal.vue";
|
||
import { isDesktop } from "../desktop/bridge";
|
||
import { facetsToQuery } from "../notes/facets";
|
||
import { NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors";
|
||
|
||
const route = useRoute();
|
||
const router = useRouter();
|
||
const session = useSessionStore();
|
||
const config = useConfigStore();
|
||
const labels = useLabelsStore();
|
||
const savedFilters = useSavedFiltersStore();
|
||
const reminders = useReminderStore();
|
||
const ui = useUiStore();
|
||
// Sync is a desktop-app concern: the web build already IS the server's UI.
|
||
const desktopApp = isDesktop();
|
||
|
||
async function removeView(f: SavedFilter) {
|
||
if (!window.confirm(`Delete the "${f.name}" view?`)) return;
|
||
try {
|
||
await savedFilters.remove(f.id);
|
||
} catch {
|
||
ui.showToast("Couldn't delete that view.");
|
||
}
|
||
}
|
||
|
||
const managing = ref(false);
|
||
const showShortcuts = ref(false);
|
||
const paletteOpen = ref(false);
|
||
const drawer = ref(false);
|
||
const searchInput = ref<HTMLInputElement | null>(null);
|
||
const searchText = ref(typeof route.query.q === "string" ? route.query.q : "");
|
||
let searchTimer: ReturnType<typeof setTimeout> | undefined;
|
||
|
||
const shortcuts = [
|
||
{ label: "Command palette", keys: ["⌘/Ctrl", "K"] },
|
||
{ label: "New note (or just start typing)", keys: ["Enter", "c"] },
|
||
{ label: "Search", keys: ["/"] },
|
||
{ label: "Go to Board", keys: ["g", "b"] },
|
||
{ label: "Go to Graph", keys: ["g", "g"] },
|
||
{ label: "Go to Reminders", keys: ["g", "r"] },
|
||
{ label: "Go to Timeline", keys: ["g", "t"] },
|
||
{ label: "Browse cards", keys: ["↑", "↓", "←", "→"] },
|
||
{ label: "Move / open focused card", keys: ["j", "k", "Enter"] },
|
||
{ label: "Pin / archive / trash card", keys: ["#", "e", "x"] },
|
||
{ label: "Finish & close note", keys: ["⌘/Ctrl", "Enter"] },
|
||
{ label: "Save & new (composing)", keys: ["Shift", "Enter"] },
|
||
{ label: "Stop browsing / back to Notes", keys: ["Esc"] },
|
||
{ label: "This help", keys: ["?"] },
|
||
];
|
||
|
||
let gPending = false;
|
||
let gTimer: ReturnType<typeof setTimeout> | undefined;
|
||
|
||
function isTyping(e: KeyboardEvent): boolean {
|
||
const el = e.target as HTMLElement | null;
|
||
return !!el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable);
|
||
}
|
||
|
||
function focusSearch() {
|
||
searchInput.value?.focus();
|
||
searchInput.value?.select();
|
||
}
|
||
|
||
// New note (the "+ New" button and the `c` shortcut): ensure we're on the board,
|
||
// then open the compose modal. Type-to-compose seeds it instead (see onKeydown).
|
||
async function newNote() {
|
||
if (route.name !== "board") {
|
||
await router.push("/");
|
||
await nextTick();
|
||
}
|
||
ui.requestCompose();
|
||
}
|
||
|
||
// Global keyboard shortcuts, active only inside the authed shell. Ignored while
|
||
// typing in a field (except Esc, which blurs it). `g` starts a two-key jump
|
||
// (g b / g g / g r) with a short timeout.
|
||
function onKeydown(e: KeyboardEvent) {
|
||
// Cmd/Ctrl+K toggles the command palette — works even while typing.
|
||
if ((e.metaKey || e.ctrlKey) && (e.key === "k" || e.key === "K")) {
|
||
e.preventDefault();
|
||
paletteOpen.value = !paletteOpen.value;
|
||
return;
|
||
}
|
||
if (isTyping(e)) {
|
||
if (e.key === "Escape") (e.target as HTMLElement).blur();
|
||
return;
|
||
}
|
||
if (e.key === "Escape" && showShortcuts.value) {
|
||
showShortcuts.value = false;
|
||
return;
|
||
}
|
||
if (e.key === "Escape" && drawer.value) {
|
||
drawer.value = false;
|
||
return;
|
||
}
|
||
// Esc with nothing open = back to the base Notes board. (An open editor gets Esc
|
||
// first via its own handler, which stops propagation so it doesn't also navigate.)
|
||
if (e.key === "Escape") {
|
||
if (route.name !== "board") void router.push("/");
|
||
return;
|
||
}
|
||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||
if (gPending) {
|
||
gPending = false;
|
||
clearTimeout(gTimer);
|
||
if (e.key === "b") {
|
||
e.preventDefault();
|
||
void router.push("/");
|
||
return;
|
||
}
|
||
if (e.key === "g") {
|
||
e.preventDefault();
|
||
void router.push("/graph");
|
||
return;
|
||
}
|
||
if (e.key === "r") {
|
||
e.preventDefault();
|
||
void router.push("/reminders");
|
||
return;
|
||
}
|
||
if (e.key === "t") {
|
||
e.preventDefault();
|
||
void router.push("/timeline");
|
||
return;
|
||
}
|
||
}
|
||
if (e.key === "/") {
|
||
e.preventDefault();
|
||
focusSearch();
|
||
return;
|
||
}
|
||
if (e.key === "c") {
|
||
e.preventDefault();
|
||
void newNote();
|
||
return;
|
||
}
|
||
if (e.key === "?") {
|
||
e.preventDefault();
|
||
showShortcuts.value = true;
|
||
return;
|
||
}
|
||
if (e.key === "g") {
|
||
gPending = true;
|
||
clearTimeout(gTimer);
|
||
gTimer = setTimeout(() => (gPending = false), 800);
|
||
return;
|
||
}
|
||
// Type-to-compose: any OTHER single printable key, on the board with no card
|
||
// focused (browse mode owns j/k/e/x/#), opens a new note seeded with that key.
|
||
if (route.name === "board" && !ui.boardCardFocused && e.key.length === 1 && /\S/.test(e.key)) {
|
||
e.preventDefault();
|
||
ui.requestCompose(e.key);
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
if (!labels.loaded) void labels.load();
|
||
if (!savedFilters.loaded) void savedFilters.load();
|
||
reminders.start(); // foreground reminder delivery while the app is open
|
||
window.addEventListener("keydown", onKeydown);
|
||
});
|
||
onBeforeUnmount(() => {
|
||
reminders.stop();
|
||
window.removeEventListener("keydown", onKeydown);
|
||
});
|
||
|
||
const currentLabelId = computed(() => (route.name === "label" ? String(route.params.id) : null));
|
||
|
||
function labelDot(color: string): string {
|
||
return NOTE_SWATCH_CLASSES[color as NoteColor] ?? NOTE_SWATCH_CLASSES.default;
|
||
}
|
||
|
||
function onSearch(value: string) {
|
||
searchText.value = value;
|
||
clearTimeout(searchTimer);
|
||
searchTimer = setTimeout(() => {
|
||
const q = searchText.value.trim();
|
||
if (q) router.push({ name: "search", query: { q } });
|
||
else if (route.name === "search") router.push("/");
|
||
}, 250);
|
||
}
|
||
|
||
// Clear the search box when navigating to a non-search view.
|
||
watch(
|
||
() => route.name,
|
||
(name) => {
|
||
if (name !== "search") searchText.value = "";
|
||
drawer.value = false;
|
||
},
|
||
);
|
||
|
||
async function signOut() {
|
||
await session.logout();
|
||
await router.replace("/login");
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="flex min-h-full flex-col">
|
||
<header
|
||
class="sticky top-0 z-20 border-b border-neutral-200 bg-neutral-50/90 backdrop-blur dark:border-neutral-800 dark:bg-neutral-950/90"
|
||
>
|
||
<div class="flex items-center gap-3 px-4 py-3">
|
||
<button
|
||
type="button"
|
||
class="icon-btn sm:hidden"
|
||
title="Menu"
|
||
aria-label="Open menu"
|
||
@click="drawer = true"
|
||
>
|
||
<svg
|
||
class="h-5 w-5"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
stroke-linecap="round"
|
||
>
|
||
<line x1="3" y1="6" x2="21" y2="6" />
|
||
<line x1="3" y1="12" x2="21" y2="12" />
|
||
<line x1="3" y1="18" x2="21" y2="18" />
|
||
</svg>
|
||
</button>
|
||
<RouterLink to="/" class="flex shrink-0 items-center gap-2" title="Home" aria-label="Home">
|
||
<img src="/icon.svg" alt="" class="h-8 w-8 rounded-lg" width="32" height="32" />
|
||
<span class="hidden font-semibold sm:inline">{{ config.siteName }}</span>
|
||
</RouterLink>
|
||
|
||
<div class="flex flex-1 justify-center">
|
||
<input
|
||
ref="searchInput"
|
||
:value="searchText"
|
||
type="search"
|
||
placeholder="Search notes…"
|
||
aria-label="Search notes"
|
||
class="w-full max-w-md rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-900"
|
||
@input="onSearch(($event.target as HTMLInputElement).value)"
|
||
/>
|
||
</div>
|
||
|
||
<div class="flex shrink-0 items-center gap-3">
|
||
<button
|
||
type="button"
|
||
class="inline-flex items-center gap-1 rounded-lg bg-brand px-2.5 py-1.5 text-sm font-semibold text-neutral-900 hover:brightness-95 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
|
||
title="New note (or just start typing)"
|
||
@click="newNote"
|
||
>
|
||
<Icon name="plus" /> <span class="hidden sm:inline">New</span>
|
||
</button>
|
||
<span class="hidden text-sm text-neutral-500 md:inline dark:text-neutral-400">{{
|
||
session.user?.display_name
|
||
}}</span>
|
||
<RouterLink
|
||
v-if="desktopApp"
|
||
to="/sync"
|
||
class="icon-btn"
|
||
title="Sync"
|
||
aria-label="Sync"
|
||
>
|
||
<Icon name="sync" />
|
||
</RouterLink>
|
||
<RouterLink to="/account" class="icon-btn" title="Linked devices" aria-label="Linked devices">
|
||
<Icon name="device" />
|
||
</RouterLink>
|
||
<RouterLink
|
||
v-if="session.user?.is_admin"
|
||
to="/settings"
|
||
class="icon-btn"
|
||
title="Settings"
|
||
aria-label="Settings"
|
||
>
|
||
<Icon name="settings" />
|
||
</RouterLink>
|
||
<button type="button" class="icon-btn" title="Sign out" aria-label="Sign out" @click="signOut">
|
||
<Icon name="logout" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="flex flex-1">
|
||
<div
|
||
v-if="drawer"
|
||
class="fixed inset-0 z-30 bg-black/40 sm:hidden"
|
||
aria-hidden="true"
|
||
@click="drawer = false"
|
||
></div>
|
||
<aside
|
||
class="fixed inset-y-0 left-0 z-40 w-64 -translate-x-full overflow-y-auto border-r border-neutral-200 bg-neutral-50 p-3 transition-transform duration-200 sm:static sm:z-auto sm:w-56 sm:translate-x-0 dark:border-neutral-800 dark:bg-neutral-950"
|
||
:class="drawer ? 'translate-x-0' : ''"
|
||
>
|
||
<nav class="flex flex-col gap-0.5 text-sm" @click="drawer = false">
|
||
<RouterLink to="/" class="nav-link" :class="route.name === 'board' ? 'nav-link-active' : ''">
|
||
<Icon name="note" /> Notes
|
||
</RouterLink>
|
||
<RouterLink to="/graph" class="nav-link" :class="route.name === 'graph' ? 'nav-link-active' : ''">
|
||
<Icon name="graph" /> Graph
|
||
</RouterLink>
|
||
|
||
<div class="mt-3 flex items-center justify-between px-3 pb-1">
|
||
<span class="text-xs font-semibold uppercase tracking-wide text-neutral-400">Labels</span>
|
||
<button
|
||
type="button"
|
||
class="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-200"
|
||
title="Edit labels"
|
||
aria-label="Edit labels"
|
||
@click="managing = true"
|
||
>
|
||
<Icon name="pencil" />
|
||
</button>
|
||
</div>
|
||
<p v-if="!labels.items.length" class="px-3 py-1 text-xs text-neutral-400">No labels yet</p>
|
||
<RouterLink
|
||
v-for="lb in labels.items"
|
||
:key="lb.id"
|
||
:to="`/label/${lb.id}`"
|
||
class="nav-link"
|
||
:class="currentLabelId === lb.id ? 'nav-link-active' : ''"
|
||
>
|
||
<span
|
||
class="h-2.5 w-2.5 shrink-0 rounded-full border border-black/10 dark:border-white/15"
|
||
:class="labelDot(lb.color)"
|
||
></span>
|
||
<span class="truncate">{{ lb.name }}</span>
|
||
</RouterLink>
|
||
|
||
<template v-if="savedFilters.items.length">
|
||
<div class="mt-3 px-3 pb-1">
|
||
<span class="text-xs font-semibold uppercase tracking-wide text-neutral-400">Views</span>
|
||
</div>
|
||
<RouterLink
|
||
v-for="f in savedFilters.items"
|
||
:key="f.id"
|
||
:to="{ path: '/', query: facetsToQuery(f.params) }"
|
||
class="nav-link group/view"
|
||
>
|
||
<Icon name="filter" />
|
||
<span class="flex-1 truncate">{{ f.name }}</span>
|
||
<button
|
||
type="button"
|
||
class="text-neutral-400 opacity-0 hover:text-red-500 group-hover/view:opacity-100"
|
||
:aria-label="`Delete view ${f.name}`"
|
||
@click.prevent.stop="removeView(f)"
|
||
>
|
||
×
|
||
</button>
|
||
</RouterLink>
|
||
</template>
|
||
|
||
<RouterLink to="/archive" class="nav-link mt-3" :class="route.name === 'archive' ? 'nav-link-active' : ''">
|
||
<Icon name="archive" /> Archive
|
||
</RouterLink>
|
||
<RouterLink to="/trash" class="nav-link" :class="route.name === 'trash' ? 'nav-link-active' : ''">
|
||
<Icon name="trash" /> Trash
|
||
</RouterLink>
|
||
<RouterLink
|
||
to="/reminders"
|
||
class="nav-link"
|
||
:class="route.name === 'reminders' ? 'nav-link-active' : ''"
|
||
>
|
||
<Icon name="bell" /> Reminders
|
||
</RouterLink>
|
||
<RouterLink
|
||
to="/timeline"
|
||
class="nav-link"
|
||
:class="route.name === 'timeline' ? 'nav-link-active' : ''"
|
||
>
|
||
<Icon name="calendar" /> Timeline
|
||
</RouterLink>
|
||
<!-- Direct download (same-origin GET, session cookie sent); not a route. -->
|
||
<a href="/api/notes/export" download class="nav-link" title="Download all your notes as a zip">
|
||
<Icon name="download" /> Export
|
||
</a>
|
||
<ImportNotes />
|
||
</nav>
|
||
</aside>
|
||
|
||
<main class="min-w-0 flex-1"><RouterView /></main>
|
||
</div>
|
||
|
||
<LabelsModal v-if="managing" @close="managing = false" />
|
||
|
||
<CommandPalette v-if="paletteOpen" @close="paletteOpen = false" />
|
||
|
||
<BaseModal
|
||
v-if="showShortcuts"
|
||
panel-class="w-full max-w-sm p-5 shadow-xl"
|
||
align="center"
|
||
aria-label="Keyboard shortcuts"
|
||
@close="showShortcuts = false"
|
||
>
|
||
<div class="mb-3 flex items-center justify-between">
|
||
<h2 class="text-sm font-semibold">Keyboard shortcuts</h2>
|
||
<button
|
||
type="button"
|
||
class="text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-100"
|
||
aria-label="Close"
|
||
@click="showShortcuts = false"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
<dl class="flex flex-col gap-2 text-sm">
|
||
<div v-for="s in shortcuts" :key="s.label" class="flex items-center justify-between gap-4">
|
||
<dt class="text-neutral-600 dark:text-neutral-300">{{ s.label }}</dt>
|
||
<dd class="flex items-center gap-1">
|
||
<kbd
|
||
v-for="(k, i) in s.keys"
|
||
:key="i"
|
||
class="rounded border border-neutral-300 bg-neutral-100 px-1.5 py-0.5 font-mono text-xs text-neutral-700 dark:border-neutral-600 dark:bg-neutral-800 dark:text-neutral-200"
|
||
>{{ k }}</kbd
|
||
>
|
||
</dd>
|
||
</div>
|
||
</dl>
|
||
</BaseModal>
|
||
</div>
|
||
</template>
|