CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 15s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m52s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m10s
Desktop (Tauri) / Update manifest (push) Successful in 6s
Android / Kotlin + Rust (APK) (push) Successful in 7m43s
Every #tag ever typed is `default`. `notes/tags.py` mints one as `Label(owner_id=…, name=name)` with no colour, so it takes the column default — which means tag-driven note colour, built on top, would have left the board exactly as grey as it was. Four #todo notes in the operator's screenshot, four different colours, because the tint is per-note-id and ignores tags entirely. DERIVED RATHER THAN PERSISTED AT MINT TIME, reversing the plan in 2965. That plan wanted a hashed colour written wherever a label is born, and named the risk in its own body: `find_or_create_label` is "easy to miss, and it is the common one", because most tags are born from typing `#grocery`, not from a management screen. Deriving has no mint points to miss, needs no backfill for the tags that already exist, and reuses the hash and the fixture the notes already have. The cost is that renaming a tag recolours it. That is defensible — the name IS the tag — and an explicitly picked colour is still stored and still wins, so tag colours stay editable exactly as asked. Lowercased before hashing: tags dedupe case-insensitively, so #Todo and #todo are one tag and must not be two colours. All five places a label's colour is drawn now resolve the same way — the card chip, the editor chip, the drawer's tag list, and the management modal's dot and swatch ring. The modal's ring follows the resolved colour rather than the stored one, so opening the picker highlights what you can already see instead of nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
590 lines
24 KiB
Vue
590 lines
24 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();
|
||
|
||
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 w-64 -translate-x-full 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>
|
||
</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>
|