Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 15s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m50s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m19s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 7m59s
Note 3127 §5 removed version tags, so an artifact's self-report is now the only
answer to "which build is this?" — and nothing exists to contradict it when it
is wrong. Three surfaces gain a dim build line: the foot of the web rail, the
login screen, and the foot of Sync on Android.
The login screen because "I can't sign in" is a bug report like any other, and
requiring an account to read a build number withholds it from exactly the people
who can't get past that page. `/api/config` is already public.
Two of the values it was going to show were wrong, which is the part worth
knowing about.
The DESKTOP reported `env!("CARGO_PKG_VERSION")` from `config_get` and from the
startup log. `cargo tauri build --config '{"version": ...}'` overrides
tauri.conf.json, not Cargo's own metadata — so both read the literal `0.2.0` in
Cargo.toml, on every build ever shipped. They now read a display version baked in
by the lane through `option_env!`, hoisted to the crate root because two readers
of one fact is how this repo keeps producing 2181-2183. Not the ordering key
either: `1.0.<minutes>` is the opaque value Tauri's updater compares and must
never be shown to a person, and `update.rs` still reads it because a comparator
is exactly what it is (rule 149).
The SERVER fell back to `__version__` when APP_VERSION was absent, so a server
run from a checkout reported `0.2.0` — a real-looking version naming no build
anybody could obtain. `__init__.py` already asserted the honest answer was
"APP_VERSION being missing, which app.py already handles"; it did not, and a
comment claiming a behaviour two files away is how that stayed true-sounding.
Now an explicit "unknown", with the packaging version left where "unknown" is
not a legal value.
Android reads the INSTALLED package's versionName rather than BuildConfig, so it
reports what is actually on the phone.
Everything renders "unknown" rather than blank when it cannot say. A blank looks
like a layout bug; a plausible default cannot be caught by anything.
build.rs gets `rerun-if-env-changed` for the baked value: cargo does not track an
`option_env!` variable on its own, and the desktop lane having no cache today is
what makes that easy to forget the day one is added.
618 lines
25 KiB
Vue
618 lines
25 KiB
Vue
<script setup lang="ts">
|
||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||
import { useRoute, useRouter, type LocationQueryRaw } 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, resolveLabelColor } 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();
|
||
|
||
// The build, for the dim line at the foot of the rail (#3181).
|
||
//
|
||
// NEVER BLANK. "unknown" is the honest answer when the value is missing, and an
|
||
// empty space is a bug that reads as a design choice. Note 3127 §5: with version
|
||
// tags gone this is the only answer to "which build is this?", so it has to be
|
||
// either right or visibly absent.
|
||
//
|
||
// One slot, two artifacts, and that is deliberate rather than sloppy. In the
|
||
// browser `repo` is `rest`, so this is the SERVER's version; in the desktop shell
|
||
// `repo` is `local` and `config_get` returns the desktop build's own. Each surface
|
||
// names the thing the person is actually looking at. A linked server's version is
|
||
// a different question and Sync answers it separately.
|
||
const buildVersion = computed(() => config.version || "unknown");
|
||
const buildLabel = computed(
|
||
() => `ThoughtSync ${desktopApp ? "desktop" : "server"} build ${buildVersion.value}`,
|
||
);
|
||
|
||
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 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 === "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));
|
||
|
||
// The drawer's tag list. Same resolution as every other chip and dot — a tag that is
|
||
// green on a card must be green here, or the sidebar stops being a way to find it.
|
||
function labelDot(label: { name: string; color: string }): string {
|
||
return NOTE_SWATCH_CLASSES[resolveLabelColor(label)] ?? NOTE_SWATCH_CLASSES.default;
|
||
}
|
||
|
||
// The board lenses — the routes a search can happen *within*. Searching while looking
|
||
// at Trash should search Trash, not silently move you.
|
||
const BOARD_ROUTES = new Set(["board", "archive", "trash", "label"]);
|
||
|
||
/**
|
||
* Search is a FACET, not a destination.
|
||
*
|
||
* It used to navigate to a `/search` view backed by a different endpoint with no
|
||
* facets at all — so the one screen you landed on when you searched was the one
|
||
* screen where you could not also narrow by tag, which is precisely what tags are
|
||
* for (note 2930). Now it writes `?q=` into the board's URL, beside any labels
|
||
* already there, and the same AND-ed query serves both.
|
||
*
|
||
* Existing facets are preserved, so "filter by #grocery, then search" and the reverse
|
||
* both work.
|
||
*/
|
||
function onSearch(value: string) {
|
||
searchText.value = value;
|
||
clearTimeout(searchTimer);
|
||
searchTimer = setTimeout(() => {
|
||
const q = searchText.value.trim();
|
||
const onBoard = BOARD_ROUTES.has(String(route.name));
|
||
const query: LocationQueryRaw = onBoard ? { ...route.query } : {};
|
||
if (q) query.q = q;
|
||
else delete query.q;
|
||
void router.push({ path: onBoard ? route.path : "/", query });
|
||
}, 250);
|
||
}
|
||
|
||
// The URL is the filter state (see notes/facets.ts), so the box READS from it rather
|
||
// than holding its own copy — which is also what keeps it in step with the Filters
|
||
// panel's Clear button and with a saved view opened from the sidebar.
|
||
watch(
|
||
() => route.query.q,
|
||
(q) => {
|
||
searchText.value = typeof q === "string" ? q : "";
|
||
},
|
||
{ immediate: true },
|
||
);
|
||
watch(
|
||
() => route.name,
|
||
() => {
|
||
drawer.value = false;
|
||
},
|
||
);
|
||
|
||
/**
|
||
* What to call the lens currently in view.
|
||
*
|
||
* Keyed off the route name rather than each view declaring its own title, so the
|
||
* label sits in one place and can't go missing (the board never had one)
|
||
* or drift in styling (timeline and reminders each had their own h1).
|
||
*
|
||
* A label lens is named by the label itself — "Groceries" is what the user came
|
||
* looking for; "Label" would tell them nothing they didn't already know.
|
||
*/
|
||
const lensName = computed<string>(() => {
|
||
switch (route.name) {
|
||
case "archive":
|
||
return "Archive";
|
||
case "trash":
|
||
return "Trash";
|
||
case "timeline":
|
||
return "Timeline";
|
||
case "reminders":
|
||
return "Reminders";
|
||
case "label":
|
||
// The store may not have loaded yet on a deep link; fall back rather than
|
||
// flashing an empty slot.
|
||
return labels.items.find((l) => l.id === String(route.params.id))?.name ?? "Label";
|
||
default:
|
||
return "Notes";
|
||
}
|
||
});
|
||
|
||
async function signOut() {
|
||
await session.logout();
|
||
await router.replace("/login");
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="flex min-h-full flex-col">
|
||
<!-- First tab stop on every page. The header and sidebar are a dozen-odd tab
|
||
stops that repeat on every navigation; without this a keyboard user walks
|
||
all of them again to reach their own notes. Hidden until focused. -->
|
||
<a
|
||
href="#main"
|
||
class="sr-only focus:not-sr-only focus:absolute focus:left-3 focus:top-3 focus:z-50 focus:rounded-md focus:bg-white focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:shadow-lg focus:outline-none focus:ring-2 focus:ring-brand dark:focus:bg-neutral-900"
|
||
>
|
||
Skip to notes
|
||
</a>
|
||
<!-- `pt-[env(safe-area-inset-top)]`: with `viewport-fit=cover` the page draws
|
||
under the status bar / notch, and this is the one box that sits there — a
|
||
sticky header pinned to y=0. Resolves to 0 wherever there is no cutout, so
|
||
it costs nothing on a desktop or a flat-topped phone. -->
|
||
<header
|
||
class="sticky top-0 z-20 border-b border-neutral-200 bg-neutral-50/90 pt-[env(safe-area-inset-top,0px)] backdrop-blur dark:border-neutral-800 dark:bg-neutral-950/90"
|
||
>
|
||
<!-- Wraps, so the search field can take a line of its own on a narrow screen.
|
||
In one row it was sharing ~360px with a menu button, the logo, the lens
|
||
name and four icons, which left every one of them truncated — the lens read
|
||
as "N…" and the search box as an empty pill. -->
|
||
<div class="flex flex-wrap items-center gap-x-3 gap-y-2 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>
|
||
|
||
<!-- The active lens, named in the persistent chrome rather than inside each
|
||
view. Which lens you're looking at is a property of the SPACE, not of a
|
||
page you navigated to — so it sits in the bar that never moves, beside
|
||
the app name, and stays in one place while everything beneath it
|
||
re-filters. Replaces the per-view <h1>s, which sat in a different spot
|
||
in each view and were absent entirely on the board. -->
|
||
<span aria-live="polite" class="flex min-w-0 shrink items-center gap-2 text-sm text-neutral-400">
|
||
<!-- The separator only makes sense next to the app name, which is itself
|
||
hidden on narrow screens. There, the lens name simply takes the space
|
||
the app name vacated — you already know which app you're in. -->
|
||
<span aria-hidden="true" class="hidden sm:inline">/</span>
|
||
<span class="truncate font-medium text-neutral-600 dark:text-neutral-300">{{ lensName }}</span>
|
||
</span>
|
||
|
||
<!-- `order-last w-full` drops this onto its own line below sm (a full-width
|
||
flex item forces the wrap); from sm it returns to the middle of the row.
|
||
ONE input either way, moved by CSS rather than duplicated — the `/`
|
||
shortcut focuses `searchInput`, and two of those would be one ref too
|
||
many. -->
|
||
<div class="order-last flex w-full justify-center sm:order-none sm:w-auto sm:flex-1">
|
||
<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-2 text-base outline-none focus-visible:ring-2 focus-visible:ring-brand sm:py-1.5 sm:text-sm dark:border-neutral-700 dark:bg-neutral-900"
|
||
@input="onSearch(($event.target as HTMLInputElement).value)"
|
||
/>
|
||
</div>
|
||
|
||
<div class="ml-auto flex shrink-0 items-center gap-1 sm:ml-0 sm: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>
|
||
<!-- Whose account this is only means something when there IS an account.
|
||
The desktop signs in as a synthetic local user so the shared router's
|
||
auth guard resolves; naming it would invent a login the app doesn't
|
||
have. -->
|
||
<span
|
||
v-if="!desktopApp"
|
||
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>
|
||
<!-- Server-side concept: it lists the tokens a server has issued to native
|
||
clients. The desktop IS one of those clients, so here the list is always
|
||
empty and issuing a token rejects — its own relationship with a server
|
||
lives under /sync instead. -->
|
||
<RouterLink
|
||
v-if="!desktopApp"
|
||
to="/account"
|
||
class="icon-btn hidden sm:inline-flex"
|
||
title="Linked devices"
|
||
aria-label="Linked devices"
|
||
>
|
||
<Icon name="device" />
|
||
</RouterLink>
|
||
<RouterLink
|
||
v-if="session.user?.is_admin"
|
||
to="/settings"
|
||
class="icon-btn hidden sm:inline-flex"
|
||
title="Settings"
|
||
aria-label="Settings"
|
||
>
|
||
<Icon name="settings" />
|
||
</RouterLink>
|
||
<!-- Hidden on the desktop, where it was a trap rather than an action:
|
||
logout nulls the synthetic local user and redirects to /login, but the
|
||
offline adapter rejects every sign-in ("there's no account to sign in
|
||
to"), leaving no way back in short of restarting the app. There is
|
||
nothing to sign out OF — the notes are on this machine either way. -->
|
||
<button
|
||
v-if="!desktopApp"
|
||
type="button"
|
||
class="icon-btn hidden sm:inline-flex"
|
||
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 flex w-64 -translate-x-full flex-col overflow-y-auto border-r border-neutral-200 bg-neutral-50 p-3 pb-[calc(0.75rem+env(safe-area-inset-bottom,0px))] pt-[calc(0.75rem+env(safe-area-inset-top,0px))] transition-transform duration-200 sm:static sm:z-auto sm:w-56 sm:translate-x-0 sm:pb-3 sm:pt-3 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>
|
||
|
||
<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)"
|
||
></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="hover-reveal 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 />
|
||
|
||
<!-- Account, settings and sign-out, for the screens where they are NOT in
|
||
the header. Four icons plus a search field never fit one phone-width
|
||
row, and the header is the wrong place to lose: it holds the only way
|
||
back to the board. Here they get room to be named instead of guessed
|
||
at from a glyph.
|
||
Hidden from sm up, where the header carries them again — so they are
|
||
in exactly one place at any width, never both. -->
|
||
<div
|
||
v-if="!desktopApp"
|
||
class="mt-3 flex flex-col gap-0.5 border-t border-neutral-200 pt-3 sm:hidden dark:border-neutral-800"
|
||
>
|
||
<p class="truncate px-3 pb-1 text-xs text-neutral-400">{{ session.user?.display_name }}</p>
|
||
<RouterLink to="/account" class="nav-link" :class="route.name === 'account' ? 'nav-link-active' : ''">
|
||
<Icon name="device" /> Linked devices
|
||
</RouterLink>
|
||
<RouterLink
|
||
v-if="session.user?.is_admin"
|
||
to="/settings"
|
||
class="nav-link"
|
||
:class="route.name === 'settings' ? 'nav-link-active' : ''"
|
||
>
|
||
<Icon name="settings" /> Settings
|
||
</RouterLink>
|
||
<button type="button" class="nav-link w-full text-left" @click="signOut">
|
||
<Icon name="logout" /> Sign out
|
||
</button>
|
||
</div>
|
||
</nav>
|
||
|
||
<!-- The build. `mt-auto` puts it at the foot of the rail when the nav is
|
||
short and lets it simply follow when the nav has scrolled.
|
||
`select-all` because the one thing anybody does with this is copy it
|
||
into a bug report. -->
|
||
<p
|
||
class="mt-auto select-all px-3 pt-6 text-[11px] text-neutral-400 dark:text-neutral-500"
|
||
:title="buildLabel"
|
||
>
|
||
{{ buildVersion }}
|
||
</p>
|
||
</aside>
|
||
|
||
<!-- tabindex="-1" so the skip link above actually moves FOCUS here, not just
|
||
the viewport — several browsers scroll to a plain anchor without focusing
|
||
it, which leaves the next Tab back at the top of the page. -->
|
||
<!-- Cross-fade between lenses so switching reads as the same space
|
||
re-framing rather than a page swap.
|
||
Deliberately UNKEYED: board / archive / trash / label all render the same
|
||
BoardView, so keying on the route would remount it — blanking the board
|
||
and refetching, which is precisely the page-change feeling this is meant
|
||
to remove. Unkeyed, Vue only transitions when the component TYPE changes
|
||
(board ↔ timeline ↔ reminders), and moving between the board's own
|
||
lenses stays an in-place reflow that NoteGrid animates. -->
|
||
<main id="main" tabindex="-1" class="min-w-0 flex-1 focus:outline-none">
|
||
<RouterView v-slot="{ Component }">
|
||
<Transition name="lens" mode="out-in">
|
||
<component :is="Component" />
|
||
</Transition>
|
||
</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>
|