M6 1902b: facet bar + saved views UI (frontend)
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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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"/>',
|
||||
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"/>',
|
||||
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"/>',
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -7,6 +7,19 @@ import type { NoteColor } from "../notes/colors";
|
||||
export type NoteView = "active" | "archived" | "trash";
|
||||
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 {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -77,6 +90,7 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
const loading = ref(false);
|
||||
const view = ref<NoteView>("active");
|
||||
const activeLabel = ref<string | null>(null);
|
||||
const activeFacets = ref<NoteFacets>({});
|
||||
|
||||
function sortItems(): void {
|
||||
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;
|
||||
activeLabel.value = labelId;
|
||||
activeFacets.value = facets;
|
||||
loading.value = true;
|
||||
try {
|
||||
const query = labelId ? `/api/notes?filter=${v}&label=${labelId}` : `/api/notes?filter=${v}`;
|
||||
const res = await api.get<{ notes: Note[] }>(query);
|
||||
const params = new URLSearchParams();
|
||||
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;
|
||||
sortItems();
|
||||
} finally {
|
||||
@@ -206,7 +231,7 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
throw { error: message, status: resp.status };
|
||||
}
|
||||
// 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 };
|
||||
}
|
||||
|
||||
@@ -267,6 +292,7 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
loading,
|
||||
view,
|
||||
activeLabel,
|
||||
activeFacets,
|
||||
load,
|
||||
create,
|
||||
setPinned,
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -3,6 +3,8 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useNotesStore, type Note, type NoteView } from "../stores/notes";
|
||||
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 NoteEditor from "../components/NoteEditor.vue";
|
||||
|
||||
@@ -47,6 +49,11 @@ function viewForRoute(name: unknown): NoteView {
|
||||
const currentView = computed<NoteView>(() => viewForRoute(route.name));
|
||||
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).
|
||||
const isMainBoard = computed(() => route.name === "board");
|
||||
const pinnedNotes = computed(() => notes.items.filter((n) => n.pinned));
|
||||
@@ -107,9 +114,10 @@ watch(
|
||||
if (focusedIndex.value >= len) focusedIndex.value = len - 1;
|
||||
},
|
||||
);
|
||||
watch([currentView, currentLabel], () => (focusedIndex.value = -1));
|
||||
watch([currentView, currentLabel, facetKey], () => (focusedIndex.value = -1));
|
||||
|
||||
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 === "archived")
|
||||
return { title: "Nothing archived", subtitle: "Archived notes are tucked away here." };
|
||||
@@ -120,7 +128,7 @@ const emptyState = computed(() => {
|
||||
async function reload() {
|
||||
loadError.value = "";
|
||||
try {
|
||||
await notes.load(currentView.value, currentLabel.value);
|
||||
await notes.load(currentView.value, currentLabel.value, facets.value);
|
||||
} catch (e) {
|
||||
loadError.value = (e as { error?: string }).error ?? "Couldn't load your notes.";
|
||||
}
|
||||
@@ -131,7 +139,7 @@ onMounted(() => {
|
||||
window.addEventListener("keydown", onBoardKey);
|
||||
});
|
||||
onBeforeUnmount(() => window.removeEventListener("keydown", onBoardKey));
|
||||
watch([currentView, currentLabel], reload);
|
||||
watch([currentView, currentLabel, facetKey], reload);
|
||||
|
||||
function openEditor(note: Note) {
|
||||
editing.value = note;
|
||||
@@ -174,7 +182,8 @@ async function onDrop(target: Note) {
|
||||
|
||||
<template>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ _ALLOWED_PARAM_KEYS = {
|
||||
"q",
|
||||
"color",
|
||||
"kind",
|
||||
"labels",
|
||||
"label", # matches the repeatable ?label= query param (stored as an array)
|
||||
"has_reminder",
|
||||
"has_attachment",
|
||||
"created_after",
|
||||
|
||||
@@ -13,12 +13,12 @@ def test_clean_params_whitelists_facet_keys():
|
||||
raw = {
|
||||
"q": "hi",
|
||||
"color": "yellow",
|
||||
"labels": ["a"],
|
||||
"label": ["a"],
|
||||
"has_reminder": True,
|
||||
"junk": 1,
|
||||
"__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(None) == {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user