1 Commits

Author SHA1 Message Date
domrichardson
9cf71ab4a0 feat: added search bar and results page
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 1m34s
2026-03-26 12:52:09 +00:00
4 changed files with 290 additions and 12 deletions

View File

@@ -117,7 +117,7 @@
</h5> </h5>
</div> </div>
<div class="col-auto d-flex align-items-center"> <div class="col-auto d-flex align-items-center">
<div v-if="!selectedNote" class="btn-group me-2 d-none d-md-flex" role="group" aria-label="View mode"> <div v-if="!selectedNote || isSearchRoute" class="btn-group me-2 d-none d-md-flex" role="group" aria-label="View mode">
<button <button
type="button" type="button"
class="btn action-button" class="btn action-button"
@@ -140,7 +140,7 @@
</button> </button>
</div> </div>
<button <button
v-if="canEditNotes && selectedNote && !isEditingNote" v-if="canEditNotes && selectedNote && !isEditingNote && !isSearchRoute"
class="btn btn-outline-secondary me-2 action-button" class="btn btn-outline-secondary me-2 action-button"
aria-label="Edit note" aria-label="Edit note"
title="Edit note" title="Edit note"
@@ -150,7 +150,7 @@
<span class="action-label">Edit Note</span> <span class="action-label">Edit Note</span>
</button> </button>
<button <button
v-if="canShareSelectedNote && !isEditingNote" v-if="canShareSelectedNote && !isEditingNote && !isSearchRoute"
class="btn btn-outline-primary me-2 action-button" class="btn btn-outline-primary me-2 action-button"
:aria-label="shareCopied ? 'Link copied' : 'Share note'" :aria-label="shareCopied ? 'Link copied' : 'Share note'"
:title="shareCopied ? 'Link copied' : 'Share note'" :title="shareCopied ? 'Link copied' : 'Share note'"
@@ -169,8 +169,18 @@
<!-- Note Editor or Note List --> <!-- Note Editor or Note List -->
<div class="content p-4"> <div class="content p-4">
<SearchResultsPage
v-if="isSearchRoute"
:notes="searchResults"
:query="searchQuery"
:current-page="searchPage"
:page-size="searchPageSize"
:view-mode="noteViewMode"
@select-note="selectSearchResultNote"
@page-change="setSearchPage"
/>
<NoteEditor <NoteEditor
v-if="selectedNote && isEditingNote" v-else-if="selectedNote && isEditingNote"
:note="selectedNote" :note="selectedNote"
:category-options="categoryOptions" :category-options="categoryOptions"
:can-delete="canDeleteNotes" :can-delete="canDeleteNotes"
@@ -278,6 +288,7 @@ import CategoryTree from "./components/CategoryTree.vue";
import NoteEditor from "./components/NoteEditor.vue"; import NoteEditor from "./components/NoteEditor.vue";
import NoteViewer from "./components/NoteViewer.vue"; import NoteViewer from "./components/NoteViewer.vue";
import NoteList from "./components/NoteList.vue"; import NoteList from "./components/NoteList.vue";
import SearchResultsPage from "./components/SearchResultsPage.vue";
import CreateSpaceModal from "./components/CreateSpaceModal.vue"; import CreateSpaceModal from "./components/CreateSpaceModal.vue";
import CreateCategoryModal from "./components/CreateCategoryModal.vue"; import CreateCategoryModal from "./components/CreateCategoryModal.vue";
import CreateNoteModal from "./components/CreateNoteModal.vue"; import CreateNoteModal from "./components/CreateNoteModal.vue";
@@ -319,10 +330,20 @@ const unlockingNote = ref(false);
const currentUser = computed(() => authStore.user); const currentUser = computed(() => authStore.user);
const isAdminRoute = computed(() => route.path === "/admin"); const isAdminRoute = computed(() => route.path === "/admin");
const isSearchRoute = computed(() => route.path === "/search");
const isPublicRoute = computed(() => route.path.startsWith("/s/")); const isPublicRoute = computed(() => route.path.startsWith("/s/"));
const isAuthRoute = computed(() => route.path === "/login" || route.path === "/register"); const isAuthRoute = computed(() => route.path === "/login" || route.path === "/register");
const spaces = computed(() => spaceStore.spaces); const spaces = computed(() => spaceStore.spaces);
const currentSpace = computed(() => spaceStore.currentSpace); const currentSpace = computed(() => spaceStore.currentSpace);
const searchResults = computed(() => sortNotesByPriority(spaceStore.searchResults));
const searchPageSize = 12;
const searchPage = computed(() => {
const pageValue = Number.parseInt(route.query.page || "1", 10);
if (Number.isNaN(pageValue) || pageValue < 1) {
return 1;
}
return pageValue;
});
const categoryTree = computed(() => spaceStore.categoryTree); const categoryTree = computed(() => spaceStore.categoryTree);
const canCreateSpaces = computed(() => authStore.hasPermission("space.create")); const canCreateSpaces = computed(() => authStore.hasPermission("space.create"));
const canCreateCategories = computed(() => authStore.hasSpacePermission(currentSpace.value, "category.create")); const canCreateCategories = computed(() => authStore.hasSpacePermission(currentSpace.value, "category.create"));
@@ -382,7 +403,7 @@ const canLoadMoreMainNotes = computed(() => {
if (selectedCategory.value || selectedNote.value) { if (selectedCategory.value || selectedNote.value) {
return false; return false;
} }
if (searchQuery.value.trim()) { if (isSearchRoute.value) {
return false; return false;
} }
return spaceStore.notesHasMore; return spaceStore.notesHasMore;
@@ -411,6 +432,10 @@ const openSpaceHome = () => {
unlockPassword.value = ""; unlockPassword.value = "";
unlockError.value = ""; unlockError.value = "";
searchQuery.value = ""; searchQuery.value = "";
spaceStore.clearSearchResults();
if (route.path !== "/") {
router.push("/");
}
if (currentSpace.value?.id) { if (currentSpace.value?.id) {
spaceStore.fetchNotes(currentSpace.value.id, { reset: true }); spaceStore.fetchNotes(currentSpace.value.id, { reset: true });
} }
@@ -425,6 +450,21 @@ const breadcrumbItems = computed(() => {
return []; return [];
} }
if (isSearchRoute.value) {
return [
{
label: currentSpace.value.name,
clickable: true,
onClick: openSpaceHome,
},
{
label: searchQuery.value.trim() ? `Search: ${searchQuery.value.trim()}` : "Search",
clickable: false,
onClick: null,
},
];
}
const items = [ const items = [
{ {
label: currentSpace.value.name, label: currentSpace.value.name,
@@ -527,6 +567,30 @@ watch(
}, },
); );
watch(
[() => route.path, () => route.query.q, () => currentSpace.value?.id],
async ([path, routeQuery, spaceId]) => {
if (path !== "/search") {
return;
}
selectedNote.value = null;
selectedCategory.value = null;
isEditingNote.value = false;
const q = typeof routeQuery === "string" ? routeQuery.trim() : "";
searchQuery.value = q;
if (!spaceId || !q) {
spaceStore.clearSearchResults();
return;
}
await spaceStore.searchNotes(q);
},
{ immediate: true },
);
watch( watch(
() => selectedNote.value?.id, () => selectedNote.value?.id,
() => { () => {
@@ -683,10 +747,52 @@ const selectCategory = (category) => {
}; };
const performSearch = async () => { const performSearch = async () => {
if (searchQuery.value.trim()) { const q = searchQuery.value.trim();
await spaceStore.searchNotes(searchQuery.value); if (!q) {
} else if (currentSpace.value?.id) { spaceStore.clearSearchResults();
await spaceStore.fetchNotes(currentSpace.value.id, { reset: true }); if (route.path !== "/") {
await router.push("/");
}
if (currentSpace.value?.id) {
await spaceStore.fetchNotes(currentSpace.value.id, { reset: true });
}
return;
}
if (route.path !== "/search" || route.query.q !== q || route.query.page !== "1") {
await router.push({
path: "/search",
query: {
q,
page: "1",
},
});
} else {
await spaceStore.searchNotes(q);
}
};
const setSearchPage = async (page) => {
const q = typeof route.query.q === "string" ? route.query.q : "";
if (!q) {
return;
}
await router.push({
path: "/search",
query: {
q,
page: String(page),
},
});
};
const selectSearchResultNote = async (note) => {
if (!note) {
return;
}
await selectNote(note);
if (route.path === "/search") {
router.push("/");
} }
}; };

View File

@@ -0,0 +1,154 @@
<template>
<section class="search-results-page">
<header class="search-results-header">
<h2>Search Results</h2>
<p v-if="query" class="search-meta">{{ totalResults }} matches for "{{ query }}"</p>
<p v-else class="search-meta">Type in the top bar and press Enter to search notes.</p>
</header>
<div v-if="!query" class="empty-state">
<i class="mdi mdi-magnify empty-state-icon" aria-hidden="true"></i>
<h3>Start your search</h3>
<p>Use a title, content keyword, or tag to find matching notes in the selected space.</p>
</div>
<div v-else-if="totalResults === 0" class="empty-state">
<i class="mdi mdi-file-search-outline empty-state-icon" aria-hidden="true"></i>
<h3>No matching notes</h3>
<p>Try different keywords or a shorter phrase.</p>
</div>
<div v-else>
<NoteList :notes="paginatedNotes" :view-mode="viewMode" @select-note="emit('select-note', $event)" />
<nav v-if="totalPages > 1" class="pagination-bar" aria-label="Search result pages">
<button class="btn btn-outline-secondary" :disabled="currentPage <= 1" @click="goToPage(currentPage - 1)">Previous</button>
<span class="page-indicator">Page {{ currentPage }} of {{ totalPages }}</span>
<button class="btn btn-outline-secondary" :disabled="currentPage >= totalPages" @click="goToPage(currentPage + 1)">Next</button>
</nav>
</div>
</section>
</template>
<script setup>
import { computed } from "vue";
import NoteList from "./NoteList.vue";
const props = defineProps({
query: {
type: String,
default: "",
},
notes: {
type: Array,
default: () => [],
},
currentPage: {
type: Number,
default: 1,
},
pageSize: {
type: Number,
default: 12,
},
viewMode: {
type: String,
default: "grid",
},
});
const emit = defineEmits(["select-note", "page-change"]);
const totalResults = computed(() => props.notes.length);
const totalPages = computed(() => Math.max(1, Math.ceil(totalResults.value / props.pageSize)));
const normalizedPage = computed(() => {
if (!Number.isFinite(props.currentPage) || props.currentPage < 1) {
return 1;
}
return Math.min(props.currentPage, totalPages.value);
});
const paginatedNotes = computed(() => {
const start = (normalizedPage.value - 1) * props.pageSize;
return props.notes.slice(start, start + props.pageSize);
});
const goToPage = (page) => {
if (page < 1 || page > totalPages.value) {
return;
}
emit("page-change", page);
};
</script>
<style scoped>
.search-results-page {
max-width: 1200px;
margin: 0 auto;
}
.search-results-header {
margin-bottom: 1.5rem;
}
.search-results-header h2 {
margin: 0;
font-size: 1.5rem;
color: #223149;
}
.search-meta {
margin: 0.35rem 0 0;
color: #5b6f8b;
}
.pagination-bar {
margin-top: 1.25rem;
display: flex;
align-items: center;
justify-content: center;
gap: 0.85rem;
}
.page-indicator {
color: #4f637d;
font-weight: 600;
}
.empty-state {
min-height: 48vh;
border: 1px dashed #cfdae9;
border-radius: 14px;
background: radial-gradient(circle at 20% 20%, #f2f9ff 0%, #edf2ff 70%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 2rem 1rem;
}
.empty-state-icon {
font-size: 4.2rem;
color: #60789a;
margin-bottom: 0.6rem;
}
.empty-state h3 {
margin: 0;
color: #223149;
}
.empty-state p {
margin: 0.6rem 0 0;
color: #5b6f8b;
max-width: 500px;
}
@media (max-width: 768px) {
.pagination-bar {
flex-direction: column;
}
}
</style>

View File

@@ -21,6 +21,12 @@ const routes = [
component: () => import("../pages/Home.vue"), component: () => import("../pages/Home.vue"),
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
{
path: "/search",
name: "Search",
component: () => import("../pages/Home.vue"),
meta: { requiresAuth: true },
},
{ {
path: "/admin", path: "/admin",
name: "Admin", name: "Admin",

View File

@@ -6,6 +6,7 @@ export const useSpaceStore = defineStore("space", () => {
const spaces = ref([]); const spaces = ref([]);
const currentSpace = ref(null); const currentSpace = ref(null);
const notes = ref([]); const notes = ref([]);
const searchResults = ref([]);
const notesSkip = ref(0); const notesSkip = ref(0);
const notesLimit = ref(20); const notesLimit = ref(20);
const notesHasMore = ref(true); const notesHasMore = ref(true);
@@ -188,20 +189,30 @@ export const useSpaceStore = defineStore("space", () => {
}; };
const searchNotes = async (query) => { const searchNotes = async (query) => {
if (!currentSpace.value?.id) {
searchResults.value = [];
return [];
}
try { try {
const response = await apiClient.get(`/api/v1/spaces/${currentSpace.value.id}/notes/search`, { params: { q: query } }); const response = await apiClient.get(`/api/v1/spaces/${currentSpace.value.id}/notes/search`, { params: { q: query } });
notes.value = response.data || []; searchResults.value = response.data || [];
notesHasMore.value = false; return searchResults.value;
notesSkip.value = notes.value.length;
} catch (error) { } catch (error) {
console.error("Error searching notes:", error); console.error("Error searching notes:", error);
searchResults.value = [];
return [];
} }
}; };
const clearSearchResults = () => {
searchResults.value = [];
};
return { return {
spaces, spaces,
currentSpace, currentSpace,
notes, notes,
searchResults,
notesHasMore, notesHasMore,
notesLoading, notesLoading,
categories, categories,
@@ -220,5 +231,6 @@ export const useSpaceStore = defineStore("space", () => {
updateNote, updateNote,
deleteNote, deleteNote,
searchNotes, searchNotes,
clearSearchResults,
}; };
}); });