diff --git a/frontend/src/components/AppShell.vue b/frontend/src/components/AppShell.vue index a38b5aa..f19e84d 100644 --- a/frontend/src/components/AppShell.vue +++ b/frontend/src/components/AppShell.vue @@ -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() { {{ lb.name }} + + Archive diff --git a/frontend/src/components/FilterBar.vue b/frontend/src/components/FilterBar.vue new file mode 100644 index 0000000..002a1d2 --- /dev/null +++ b/frontend/src/components/FilterBar.vue @@ -0,0 +1,197 @@ + + + diff --git a/frontend/src/components/Icon.vue b/frontend/src/components/Icon.vue index 0871e08..95d5111 100644 --- a/frontend/src/components/Icon.vue +++ b/frontend/src/components/Icon.vue @@ -28,6 +28,7 @@ const paths: Record = { device: '', paperclip: '', link: '', + filter: '', copy: '', }; diff --git a/frontend/src/notes/facets.ts b/frontend/src/notes/facets.ts new file mode 100644 index 0000000..3b33223 --- /dev/null +++ b/frontend/src/notes/facets.ts @@ -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; +} diff --git a/frontend/src/stores/notes.ts b/frontend/src/stores/notes.ts index 3d5971b..553c9a6 100644 --- a/frontend/src/stores/notes.ts +++ b/frontend/src/stores/notes.ts @@ -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("active"); const activeLabel = ref(null); + const activeFacets = ref({}); 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 { + async function load(v: NoteView, labelId: string | null = null, facets: NoteFacets = {}): Promise { 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, diff --git a/frontend/src/stores/savedFilters.ts b/frontend/src/stores/savedFilters.ts new file mode 100644 index 0000000..3050f21 --- /dev/null +++ b/frontend/src/stores/savedFilters.ts @@ -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([]); + const loaded = ref(false); + + async function load(): Promise { + 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 { + const sf = await api.post("/api/saved-filters", { name, params }); + items.value.push(sf); + return sf; + } + + async function remove(id: string): Promise { + await api.del(`/api/saved-filters/${id}`); + items.value = items.value.filter((f) => f.id !== id); + } + + async function rename(id: string, name: string): Promise { + const sf = await api.patch(`/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 }; +}); diff --git a/frontend/src/views/BoardView.vue b/frontend/src/views/BoardView.vue index 6c35dfc..34c05b3 100644 --- a/frontend/src/views/BoardView.vue +++ b/frontend/src/views/BoardView.vue @@ -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(() => viewForRoute(route.name)); const currentLabel = computed(() => (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) {