M6 1902b: facet bar + saved views UI (frontend)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 37s

The dead-simple facet bar over the board + saved views in the sidebar,
completing task 1902. Filter state lives in the URL query, so a filtered
board is a shareable lens and a saved view is just a link ("one space,
many lenses").

- notes/facets.ts: facetsFromQuery / facetsToQuery / facetCount helpers.
- FilterBar.vue (board only): a "Filters (N)" toggle expanding to text
  search + color swatches + label chips + has-reminder / has-attachment /
  Lists / Notes toggles + a created-date range; Clear + "Save view".
  Each control writes the URL query (router.replace).
- notes store: load(view, label, facets) builds the query; NoteFacets type
  + activeFacets; import reload preserves active facets.
- savedFilters store + sidebar "Views" section (each a query-link, delete
  on hover); loaded on mount.
- BoardView derives facets from the query, reloads on facet change (ignores
  ?open=), and shows a "no notes match these filters" empty state.
- Backend: saved-filter param whitelist keys on `label` (matches the
  repeatable ?label= query) so saved views keep their labels. New filter
  icon.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
2026-07-23 08:45:56 -04:00
co-authored by Claude Opus 4.8
parent 5fdc124c77
commit 5c045aed63
9 changed files with 382 additions and 11 deletions
+36
View File
@@ -4,11 +4,13 @@ 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 { useUiStore } from "../stores/ui";
import CommandPalette from "./CommandPalette.vue";
import Icon from "./Icon.vue";
import ImportNotes from "./ImportNotes.vue";
import LabelsModal from "./LabelsModal.vue";
import { facetsToQuery } from "../notes/facets";
import { NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors";
const route = useRoute();
@@ -16,8 +18,18 @@ const router = useRouter();
const session = useSessionStore();
const config = useConfigStore();
const labels = useLabelsStore();
const savedFilters = useSavedFiltersStore();
const ui = useUiStore();
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);
@@ -140,6 +152,7 @@ function onKeydown(e: KeyboardEvent) {
onMounted(() => {
if (!labels.loaded) void labels.load();
if (!savedFilters.loaded) void savedFilters.load();
window.addEventListener("keydown", onKeydown);
});
onBeforeUnmount(() => window.removeEventListener("keydown", onKeydown));
@@ -289,6 +302,29 @@ async function signOut() {
<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>