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 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 16s
CI & Build / integration (push) Successful in 23s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m7s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m17s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m4s
Two words for one concept cost real comprehension: over a single exchange
the operator concluded that auto-tagging did not exist (it does, in
`derive.rs`) and that a tag-management view did not exist (it does,
`LabelsModal.vue`). The `#` is how most of these get made, so the `#` wins
the noun.
User-visible strings only, on all three surfaces plus the server's errors.
`Label`, `NoteLabel`, `via_tag`, `label_id`, the tables, `/api/labels` and
the FFI names are all untouched — renaming those touches migrations and the
wire format to buy nothing a reader can see.
Two of these were more than a find-and-replace:
* Android's `label_from_tag` said "from #tag", sitting beside a chip that
already renders as `#name`. Once every one of them IS a tag that hint is
circular. What it actually tells you is that the note's BODY owns this
one — which is why it alone has no remove cross — so it now says "from
the text".
* The web's empty state said "No labels yet — create one above" while
Android's already mentioned the `#` route. The web now says it too. That
is the exact fact the operator did not have.
The paired `aria-label`s went with their `title`s; a screen reader saying
"label" while the tooltip says "tag" is the same confusion with a smaller
audience.
Left alone deliberately: `json_error("invalid label")` and
`"label_ids must be a list"` in `notes/__init__.py` name the `?label=` query
parameter and the `label_ids` request field. Those are wire surface, not the
word a person reads.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
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">Tags</span>
|
||
<button
|
||
type="button"
|
||
class="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-200"
|
||
title="Manage tags"
|
||
aria-label="Manage tags"
|
||
@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 tags 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>
|