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 { useSessionStore } from "../stores/session";
import { useConfigStore } from "../stores/config"; import { useConfigStore } from "../stores/config";
import { useLabelsStore } from "../stores/labels"; import { useLabelsStore } from "../stores/labels";
import { useSavedFiltersStore, type SavedFilter } from "../stores/savedFilters";
import { useUiStore } from "../stores/ui"; import { useUiStore } from "../stores/ui";
import CommandPalette from "./CommandPalette.vue"; import CommandPalette from "./CommandPalette.vue";
import Icon from "./Icon.vue"; import Icon from "./Icon.vue";
import ImportNotes from "./ImportNotes.vue"; import ImportNotes from "./ImportNotes.vue";
import LabelsModal from "./LabelsModal.vue"; import LabelsModal from "./LabelsModal.vue";
import { facetsToQuery } from "../notes/facets";
import { NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors"; import { NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors";
const route = useRoute(); const route = useRoute();
@@ -16,8 +18,18 @@ const router = useRouter();
const session = useSessionStore(); const session = useSessionStore();
const config = useConfigStore(); const config = useConfigStore();
const labels = useLabelsStore(); const labels = useLabelsStore();
const savedFilters = useSavedFiltersStore();
const ui = useUiStore(); 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 managing = ref(false);
const showShortcuts = ref(false); const showShortcuts = ref(false);
const paletteOpen = ref(false); const paletteOpen = ref(false);
@@ -140,6 +152,7 @@ function onKeydown(e: KeyboardEvent) {
onMounted(() => { onMounted(() => {
if (!labels.loaded) void labels.load(); if (!labels.loaded) void labels.load();
if (!savedFilters.loaded) void savedFilters.load();
window.addEventListener("keydown", onKeydown); window.addEventListener("keydown", onKeydown);
}); });
onBeforeUnmount(() => window.removeEventListener("keydown", onKeydown)); onBeforeUnmount(() => window.removeEventListener("keydown", onKeydown));
@@ -289,6 +302,29 @@ async function signOut() {
<span class="truncate">{{ lb.name }}</span> <span class="truncate">{{ lb.name }}</span>
</RouterLink> </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' : ''"> <RouterLink to="/archive" class="nav-link mt-3" :class="route.name === 'archive' ? 'nav-link-active' : ''">
<Icon name="archive" /> Archive <Icon name="archive" /> Archive
</RouterLink> </RouterLink>
+197
View File
@@ -0,0 +1,197 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useLabelsStore } from "../stores/labels";
import { useSavedFiltersStore } from "../stores/savedFilters";
import { useUiStore } from "../stores/ui";
import type { NoteFacets } from "../stores/notes";
import { facetCount, facetsFromQuery, facetsToQuery } from "../notes/facets";
import { NOTE_COLOR_KEYS, NOTE_COLOR_LABELS, NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors";
import Icon from "./Icon.vue";
// A dead-simple facet bar over the board: text search + color + labels + has-reminder
// + has-attachment + kind + created-date range. The URL query IS the state, so a
// filtered board is a shareable lens and a saved view is just a link.
const route = useRoute();
const router = useRouter();
const labels = useLabelsStore();
const savedFilters = useSavedFiltersStore();
const ui = useUiStore();
const open = ref(false);
const facets = computed<NoteFacets>(() => facetsFromQuery(route.query));
const count = computed(() => facetCount(facets.value));
function apply(next: NoteFacets) {
void router.replace({ path: "/", query: facetsToQuery(next) });
}
function patch(p: Partial<NoteFacets>) {
apply({ ...facets.value, ...p });
}
function clearAll() {
void router.replace({ path: "/", query: {} });
}
function setColor(c: NoteColor) {
patch({ color: facets.value.color === c ? undefined : c });
}
function setKind(k: "text" | "list") {
patch({ kind: facets.value.kind === k ? undefined : k });
}
function toggleLabel(id: string) {
const cur = facets.value.label ?? [];
const next = cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id];
patch({ label: next.length ? next : undefined });
}
function toggleReminder() {
patch({ has_reminder: facets.value.has_reminder ? undefined : true });
}
function toggleAttachment() {
patch({ has_attachment: facets.value.has_attachment ? undefined : true });
}
let qTimer: ReturnType<typeof setTimeout> | undefined;
function onQ(e: Event) {
const v = (e.target as HTMLInputElement).value;
clearTimeout(qTimer);
qTimer = setTimeout(() => patch({ q: v.trim() || undefined }), 300);
}
function isoDay(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
function onFrom(e: Event) {
const v = (e.target as HTMLInputElement).value;
patch({ created_after: v ? `${v}T00:00:00` : undefined });
}
function onTo(e: Event) {
const v = (e.target as HTMLInputElement).value;
if (!v) {
patch({ created_before: undefined });
return;
}
// Half-open upper bound: the start of the day AFTER the chosen date (so it's inclusive).
const d = new Date(`${v}T00:00:00`);
d.setDate(d.getDate() + 1);
patch({ created_before: `${isoDay(d)}T00:00:00` });
}
const fromInput = computed(() => (facets.value.created_after ?? "").slice(0, 10));
const toInput = computed(() => {
if (!facets.value.created_before) return "";
const d = new Date(facets.value.created_before);
d.setDate(d.getDate() - 1);
return isoDay(d);
});
async function saveView() {
const name = window.prompt("Name this view:");
if (!name?.trim()) return;
try {
await savedFilters.create(name.trim(), facets.value);
ui.showToast(`Saved view "${name.trim()}".`);
} catch (e) {
ui.showToast((e as { error?: string }).error ?? "Couldn't save the view.");
}
}
const chipBase =
"rounded-full border px-2.5 py-0.5 text-xs transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand";
const chipOn = "border-brand bg-brand/10 text-brand-700 dark:text-brand";
const chipOff = "border-neutral-300 text-neutral-600 hover:bg-neutral-100 dark:border-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-800";
</script>
<template>
<div class="mb-6">
<div class="flex flex-wrap items-center gap-2">
<button
type="button"
class="inline-flex items-center gap-1.5 rounded-lg border border-neutral-300 px-3 py-1.5 text-sm hover:bg-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:hover:bg-neutral-800"
:class="count ? 'border-brand text-brand-700 dark:text-brand' : ''"
@click="open = !open"
>
<Icon name="filter" /> Filters
<span v-if="count" class="rounded-full bg-brand px-1.5 text-xs font-semibold text-black">{{ count }}</span>
</button>
<template v-if="count">
<button type="button" class="text-xs text-neutral-500 underline hover:text-neutral-700 dark:hover:text-neutral-300" @click="clearAll">
Clear
</button>
<button type="button" class="text-xs font-medium text-brand-700 underline dark:text-brand" @click="saveView">
Save view
</button>
</template>
</div>
<div
v-if="open"
class="mt-2 flex flex-col gap-3 rounded-xl border border-neutral-200 p-3 dark:border-neutral-800"
>
<input
type="search"
:value="facets.q ?? ''"
placeholder="Search text…"
class="w-full 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="onQ"
/>
<div class="flex flex-wrap items-center gap-1.5">
<span class="w-16 shrink-0 text-xs text-neutral-400">Color</span>
<button
v-for="c in NOTE_COLOR_KEYS"
:key="c"
type="button"
:title="NOTE_COLOR_LABELS[c]"
:aria-label="NOTE_COLOR_LABELS[c]"
class="h-6 w-6 rounded-full border border-black/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-white/10"
:class="[NOTE_SWATCH_CLASSES[c], facets.color === c ? 'ring-2 ring-brand ring-offset-1' : '']"
@click="setColor(c)"
/>
</div>
<div v-if="labels.items.length" class="flex flex-wrap items-center gap-1.5">
<span class="w-16 shrink-0 text-xs text-neutral-400">Labels</span>
<button
v-for="lb in labels.items"
:key="lb.id"
type="button"
:class="[chipBase, (facets.label ?? []).includes(lb.id) ? chipOn : chipOff]"
@click="toggleLabel(lb.id)"
>
{{ lb.name }}
</button>
</div>
<div class="flex flex-wrap items-center gap-1.5">
<span class="w-16 shrink-0 text-xs text-neutral-400">Only</span>
<button type="button" :class="[chipBase, facets.has_reminder ? chipOn : chipOff]" @click="toggleReminder">
Has reminder
</button>
<button type="button" :class="[chipBase, facets.has_attachment ? chipOn : chipOff]" @click="toggleAttachment">
Has attachment
</button>
<button type="button" :class="[chipBase, facets.kind === 'list' ? chipOn : chipOff]" @click="setKind('list')">
Lists
</button>
<button type="button" :class="[chipBase, facets.kind === 'text' ? chipOn : chipOff]" @click="setKind('text')">
Notes
</button>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="w-16 shrink-0 text-xs text-neutral-400">Created</span>
<input
type="date"
:value="fromInput"
class="rounded-lg border border-neutral-300 bg-white px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-900"
@change="onFrom"
/>
<span class="text-xs text-neutral-400">to</span>
<input
type="date"
:value="toInput"
class="rounded-lg border border-neutral-300 bg-white px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-900"
@change="onTo"
/>
</div>
</div>
</div>
</template>
+1
View File
@@ -28,6 +28,7 @@ const paths: Record<string, string> = {
device: '<rect width="14" height="20" x="5" y="2" rx="2" ry="2"/><path d="M12 18h.01"/>', device: '<rect width="14" height="20" x="5" y="2" rx="2" ry="2"/><path d="M12 18h.01"/>',
paperclip: '<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/>', paperclip: '<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/>',
link: '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>', link: '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
filter: '<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>',
copy: '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>', copy: '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
}; };
</script> </script>
+56
View File
@@ -0,0 +1,56 @@
// Convert between the board's URL query and a NoteFacets object. The URL query IS the
// filter state (so a lens is shareable/bookmarkable and a saved view is just a link).
import type { LocationQuery, LocationQueryRaw } from "vue-router";
import type { NoteFacets } from "../stores/notes";
function one(v: LocationQuery[string]): string | undefined {
return (Array.isArray(v) ? v[0] : v) ?? undefined;
}
export function facetsFromQuery(q: LocationQuery): NoteFacets {
const rawLabel = q.label;
const labels = (Array.isArray(rawLabel) ? rawLabel : [rawLabel]).filter(
(x): x is string => typeof x === "string" && x.length > 0,
);
const f: NoteFacets = {};
const text = one(q.q);
if (text) f.q = text;
const color = one(q.color);
if (color) f.color = color;
const kind = one(q.kind);
if (kind === "text" || kind === "list") f.kind = kind;
if (labels.length) f.label = labels;
if (one(q.has_reminder) === "true") f.has_reminder = true;
if (one(q.has_attachment) === "true") f.has_attachment = true;
const after = one(q.created_after);
if (after) f.created_after = after;
const before = one(q.created_before);
if (before) f.created_before = before;
return f;
}
export function facetsToQuery(f: NoteFacets): LocationQueryRaw {
const q: LocationQueryRaw = {};
if (f.q) q.q = f.q;
if (f.color) q.color = f.color;
if (f.kind) q.kind = f.kind;
if (f.label?.length) q.label = f.label;
if (f.has_reminder) q.has_reminder = "true";
if (f.has_attachment) q.has_attachment = "true";
if (f.created_after) q.created_after = f.created_after;
if (f.created_before) q.created_before = f.created_before;
return q;
}
// How many facets are active (labels counted individually) — drives the "Filters (N)" badge.
export function facetCount(f: NoteFacets): number {
let n = 0;
if (f.q) n++;
if (f.color) n++;
if (f.kind) n++;
n += f.label?.length ?? 0;
if (f.has_reminder) n++;
if (f.has_attachment) n++;
if (f.created_after || f.created_before) n++;
return n;
}
+30 -4
View File
@@ -7,6 +7,19 @@ import type { NoteColor } from "../notes/colors";
export type NoteView = "active" | "archived" | "trash"; export type NoteView = "active" | "archived" | "trash";
export type NoteKind = "text" | "list"; export type NoteKind = "text" | "list";
// Combinable facet filters for the board (mirrors the GET /api/notes query + a saved
// view's stored params). All optional; empty = the plain, unfiltered board.
export interface NoteFacets {
q?: string;
color?: string;
kind?: NoteKind;
label?: string[];
has_reminder?: boolean;
has_attachment?: boolean;
created_after?: string;
created_before?: string;
}
export interface NoteLabel { export interface NoteLabel {
id: string; id: string;
name: string; name: string;
@@ -77,6 +90,7 @@ export const useNotesStore = defineStore("notes", () => {
const loading = ref(false); const loading = ref(false);
const view = ref<NoteView>("active"); const view = ref<NoteView>("active");
const activeLabel = ref<string | null>(null); const activeLabel = ref<string | null>(null);
const activeFacets = ref<NoteFacets>({});
function sortItems(): void { function sortItems(): void {
items.value.sort((a, b) => { items.value.sort((a, b) => {
@@ -106,13 +120,24 @@ export const useNotesStore = defineStore("notes", () => {
} }
} }
async function load(v: NoteView, labelId: string | null = null): Promise<void> { async function load(v: NoteView, labelId: string | null = null, facets: NoteFacets = {}): Promise<void> {
view.value = v; view.value = v;
activeLabel.value = labelId; activeLabel.value = labelId;
activeFacets.value = facets;
loading.value = true; loading.value = true;
try { try {
const query = labelId ? `/api/notes?filter=${v}&label=${labelId}` : `/api/notes?filter=${v}`; const params = new URLSearchParams();
const res = await api.get<{ notes: Note[] }>(query); params.set("filter", v);
if (labelId) params.append("label", labelId);
for (const id of facets.label ?? []) if (id) params.append("label", id);
if (facets.q) params.set("q", facets.q);
if (facets.color) params.set("color", facets.color);
if (facets.kind) params.set("kind", facets.kind);
if (facets.has_reminder) params.set("has_reminder", "true");
if (facets.has_attachment) params.set("has_attachment", "true");
if (facets.created_after) params.set("created_after", facets.created_after);
if (facets.created_before) params.set("created_before", facets.created_before);
const res = await api.get<{ notes: Note[] }>(`/api/notes?${params.toString()}`);
items.value = res.notes; items.value = res.notes;
sortItems(); sortItems();
} finally { } finally {
@@ -206,7 +231,7 @@ export const useNotesStore = defineStore("notes", () => {
throw { error: message, status: resp.status }; throw { error: message, status: resp.status };
} }
// Refresh the current lens so imported notes appear (labels reloaded by caller). // Refresh the current lens so imported notes appear (labels reloaded by caller).
await load(view.value, activeLabel.value); await load(view.value, activeLabel.value, activeFacets.value);
return data as { source: string; imported: number; skipped: number }; return data as { source: string; imported: number; skipped: number };
} }
@@ -267,6 +292,7 @@ export const useNotesStore = defineStore("notes", () => {
loading, loading,
view, view,
activeLabel, activeLabel,
activeFacets,
load, load,
create, create,
setPinned, setPinned,
+46
View File
@@ -0,0 +1,46 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { api } from "../api/client";
import type { NoteFacets } from "./notes";
// A named, saved facet combination (a 'view'/lens). `params` mirrors NoteFacets.
export interface SavedFilter {
id: string;
name: string;
params: NoteFacets;
position: number;
}
export const useSavedFiltersStore = defineStore("savedFilters", () => {
const items = ref<SavedFilter[]>([]);
const loaded = ref(false);
async function load(): Promise<void> {
try {
items.value = (await api.get<{ filters: SavedFilter[] }>("/api/saved-filters")).filters;
} catch {
// leave whatever we have
} finally {
loaded.value = true;
}
}
async function create(name: string, params: NoteFacets): Promise<SavedFilter> {
const sf = await api.post<SavedFilter>("/api/saved-filters", { name, params });
items.value.push(sf);
return sf;
}
async function remove(id: string): Promise<void> {
await api.del(`/api/saved-filters/${id}`);
items.value = items.value.filter((f) => f.id !== id);
}
async function rename(id: string, name: string): Promise<void> {
const sf = await api.patch<SavedFilter>(`/api/saved-filters/${id}`, { name });
const idx = items.value.findIndex((f) => f.id === id);
if (idx >= 0) items.value[idx] = sf;
}
return { items, loaded, load, create, remove, rename };
});
+13 -4
View File
@@ -3,6 +3,8 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { useNotesStore, type Note, type NoteView } from "../stores/notes"; import { useNotesStore, type Note, type NoteView } from "../stores/notes";
import { useUiStore } from "../stores/ui"; import { useUiStore } from "../stores/ui";
import { facetCount, facetsFromQuery, facetsToQuery } from "../notes/facets";
import FilterBar from "../components/FilterBar.vue";
import NoteCard from "../components/NoteCard.vue"; import NoteCard from "../components/NoteCard.vue";
import NoteEditor from "../components/NoteEditor.vue"; import NoteEditor from "../components/NoteEditor.vue";
@@ -47,6 +49,11 @@ function viewForRoute(name: unknown): NoteView {
const currentView = computed<NoteView>(() => viewForRoute(route.name)); const currentView = computed<NoteView>(() => viewForRoute(route.name));
const currentLabel = computed<string | null>(() => (route.name === "label" ? String(route.params.id) : null)); const currentLabel = computed<string | null>(() => (route.name === "label" ? String(route.params.id) : null));
// Facet filters live in the URL query on the board route (a filtered board = a lens).
const facets = computed(() => facetsFromQuery(route.query));
const facetKey = computed(() => JSON.stringify(facetsToQuery(facets.value))); // stable; ignores ?open=
const filtered = computed(() => route.name === "board" && facetCount(facets.value) > 0);
// Quick-add + pinned/others split only on the main board (not archive/trash/label). // Quick-add + pinned/others split only on the main board (not archive/trash/label).
const isMainBoard = computed(() => route.name === "board"); const isMainBoard = computed(() => route.name === "board");
const pinnedNotes = computed(() => notes.items.filter((n) => n.pinned)); const pinnedNotes = computed(() => notes.items.filter((n) => n.pinned));
@@ -107,9 +114,10 @@ watch(
if (focusedIndex.value >= len) focusedIndex.value = len - 1; if (focusedIndex.value >= len) focusedIndex.value = len - 1;
}, },
); );
watch([currentView, currentLabel], () => (focusedIndex.value = -1)); watch([currentView, currentLabel, facetKey], () => (focusedIndex.value = -1));
const emptyState = computed(() => { const emptyState = computed(() => {
if (filtered.value) return { title: "No notes match these filters", subtitle: "Try clearing or loosening a facet." };
if (currentView.value === "trash") return { title: "Trash is empty", subtitle: "Notes you delete land here first." }; if (currentView.value === "trash") return { title: "Trash is empty", subtitle: "Notes you delete land here first." };
if (currentView.value === "archived") if (currentView.value === "archived")
return { title: "Nothing archived", subtitle: "Archived notes are tucked away here." }; return { title: "Nothing archived", subtitle: "Archived notes are tucked away here." };
@@ -120,7 +128,7 @@ const emptyState = computed(() => {
async function reload() { async function reload() {
loadError.value = ""; loadError.value = "";
try { try {
await notes.load(currentView.value, currentLabel.value); await notes.load(currentView.value, currentLabel.value, facets.value);
} catch (e) { } catch (e) {
loadError.value = (e as { error?: string }).error ?? "Couldn't load your notes."; loadError.value = (e as { error?: string }).error ?? "Couldn't load your notes.";
} }
@@ -131,7 +139,7 @@ onMounted(() => {
window.addEventListener("keydown", onBoardKey); window.addEventListener("keydown", onBoardKey);
}); });
onBeforeUnmount(() => window.removeEventListener("keydown", onBoardKey)); onBeforeUnmount(() => window.removeEventListener("keydown", onBoardKey));
watch([currentView, currentLabel], reload); watch([currentView, currentLabel, facetKey], reload);
function openEditor(note: Note) { function openEditor(note: Note) {
editing.value = note; editing.value = note;
@@ -174,7 +182,8 @@ async function onDrop(target: Note) {
<template> <template>
<div class="mx-auto w-full max-w-6xl px-4 py-6"> <div class="mx-auto w-full max-w-6xl px-4 py-6">
<NoteEditor v-if="isMainBoard" ref="composer" inline autofocus class="mb-8" /> <NoteEditor v-if="isMainBoard" ref="composer" inline autofocus class="mb-6" />
<FilterBar v-if="isMainBoard" />
<div v-if="notes.loading" class="py-24 text-center text-sm text-neutral-400">Loading</div> <div v-if="notes.loading" class="py-24 text-center text-sm text-neutral-400">Loading</div>
+1 -1
View File
@@ -20,7 +20,7 @@ _ALLOWED_PARAM_KEYS = {
"q", "q",
"color", "color",
"kind", "kind",
"labels", "label", # matches the repeatable ?label= query param (stored as an array)
"has_reminder", "has_reminder",
"has_attachment", "has_attachment",
"created_after", "created_after",
+2 -2
View File
@@ -13,12 +13,12 @@ def test_clean_params_whitelists_facet_keys():
raw = { raw = {
"q": "hi", "q": "hi",
"color": "yellow", "color": "yellow",
"labels": ["a"], "label": ["a"],
"has_reminder": True, "has_reminder": True,
"junk": 1, "junk": 1,
"__proto__": 2, "__proto__": 2,
} }
assert clean_params(raw) == {"q": "hi", "color": "yellow", "labels": ["a"], "has_reminder": True} assert clean_params(raw) == {"q": "hi", "color": "yellow", "label": ["a"], "has_reminder": True}
assert clean_params("nope") == {} assert clean_params("nope") == {}
assert clean_params(None) == {} assert clean_params(None) == {}