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>
|
||||
|
||||
Reference in New Issue
Block a user