3 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
domrichardson
cf94697d07 feat: Added better md styling
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 58s
2026-03-26 11:41:16 +00:00
domrichardson
94f11be77c fix: Fixed redis user
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 1m48s
2026-03-26 10:10:07 +00:00
12 changed files with 404 additions and 19 deletions

View File

@@ -54,6 +54,7 @@ func main() {
redisAddr = "localhost:6379" redisAddr = "localhost:6379"
} }
redisUser := os.Getenv("REDIS_USER")
redisPassword := os.Getenv("REDIS_PASSWORD") redisPassword := os.Getenv("REDIS_PASSWORD")
redisDB := 0 redisDB := 0
if redisDBText := os.Getenv("REDIS_DB"); redisDBText != "" { if redisDBText := os.Getenv("REDIS_DB"); redisDBText != "" {
@@ -85,6 +86,7 @@ func main() {
redisClient := redis.NewClient(&redis.Options{ redisClient := redis.NewClient(&redis.Options{
Addr: redisAddr, Addr: redisAddr,
Username: redisUser,
Password: redisPassword, Password: redisPassword,
DB: redisDB, DB: redisDB,
}) })

View File

@@ -13,7 +13,9 @@
"axios": "^1.4.0", "axios": "^1.4.0",
"bootstrap": "^5.3.0", "bootstrap": "^5.3.0",
"dompurify": "^3.0.0", "dompurify": "^3.0.0",
"highlight.js": "^11.11.1",
"marked": "^9.0.0", "marked": "^9.0.0",
"marked-highlight": "^2.2.3",
"pinia": "^2.1.0", "pinia": "^2.1.0",
"vue": "^3.3.0", "vue": "^3.3.0",
"vue-router": "^4.2.0" "vue-router": "^4.2.0"
@@ -1433,6 +1435,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/highlight.js": {
"version": "11.11.1",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/ini": { "node_modules/ini": {
"version": "1.3.8", "version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
@@ -1556,6 +1567,15 @@
"node": ">= 16" "node": ">= 16"
} }
}, },
"node_modules/marked-highlight": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/marked-highlight/-/marked-highlight-2.2.3.tgz",
"integrity": "sha512-FCfZRxW/msZAiasCML4isYpxyQWKEEx44vOgdn5Kloae+Qc3q4XR7WjpKKf8oMLk7JP9ZCRd2vhtclJFdwxlWQ==",
"license": "MIT",
"peerDependencies": {
"marked": ">=4 <18"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",

View File

@@ -15,7 +15,9 @@
"axios": "^1.4.0", "axios": "^1.4.0",
"bootstrap": "^5.3.0", "bootstrap": "^5.3.0",
"dompurify": "^3.0.0", "dompurify": "^3.0.0",
"highlight.js": "^11.11.1",
"marked": "^9.0.0", "marked": "^9.0.0",
"marked-highlight": "^2.2.3",
"pinia": "^2.1.0", "pinia": "^2.1.0",
"vue": "^3.3.0", "vue": "^3.3.0",
"vue-router": "^4.2.0" "vue-router": "^4.2.0"

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

@@ -25,6 +25,70 @@ body,
width: 100%; width: 100%;
} }
.markdown-body table {
width: 100%;
margin: 1rem 0;
border-collapse: collapse;
border-spacing: 0;
background: #fff;
}
.markdown-body th,
.markdown-body td {
padding: 0.7rem 0.9rem;
border: 1px solid var(--border-color);
text-align: left;
vertical-align: top;
}
.markdown-body th {
font-weight: 600;
background: #f3f6fb;
}
.markdown-body tr:nth-child(even) td {
background: #fbfcfe;
}
.markdown-body table code {
white-space: nowrap;
}
.markdown-body blockquote {
margin: 1rem 0;
padding: 0.75rem 1rem;
border-left: 4px solid #748ffc;
background: #f8f9ff;
color: #334155;
}
.markdown-body blockquote > :last-child {
margin-bottom: 0;
}
.markdown-body pre {
margin: 1rem 0;
padding: 1rem;
border-radius: 0.75rem;
background: #111827;
color: #f9fafb;
overflow-x: auto;
}
.markdown-body pre code {
background: transparent;
color: inherit;
padding: 0;
}
.markdown-body code {
font-family: "Courier New", monospace;
font-size: 0.95em;
padding: 0.1rem 0.3rem;
border-radius: 0.35rem;
background: #f1f3f5;
}
/* Scrollbar styling */ /* Scrollbar styling */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 8px; width: 8px;

View File

@@ -31,7 +31,7 @@
<div :class="showFileExplorer ? 'col-12 col-md-4 mt-3 mt-md-0' : 'col-12 col-md-6 mt-3 mt-md-0'"> <div :class="showFileExplorer ? 'col-12 col-md-4 mt-3 mt-md-0' : 'col-12 col-md-6 mt-3 mt-md-0'">
<div class="preview-pane border rounded p-3"> <div class="preview-pane border rounded p-3">
<div v-html="renderedMarkdown"></div> <div class="markdown-body" v-html="renderedMarkdown"></div>
</div> </div>
</div> </div>
@@ -96,10 +96,9 @@
<script setup> <script setup>
import { ref, computed, watch, onBeforeUnmount, onMounted, nextTick } from "vue"; import { ref, computed, watch, onBeforeUnmount, onMounted, nextTick } from "vue";
import { marked } from "marked";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { useSettingsStore } from "../stores/settingsStore"; import { useSettingsStore } from "../stores/settingsStore";
import { preprocessMarkdown } from "../utils/markdown.js"; import { renderMarkdown } from "../utils/markdown.js";
import FileExplorer from "./FileExplorer.vue"; import FileExplorer from "./FileExplorer.vue";
const props = defineProps({ const props = defineProps({
@@ -138,7 +137,7 @@ const saveState = ref("saved");
const saveStateTimeout = ref(null); const saveStateTimeout = ref(null);
const renderedMarkdown = computed(() => { const renderedMarkdown = computed(() => {
const html = marked.parse(preprocessMarkdown(editingNote.value.content || "")); const html = renderMarkdown(editingNote.value.content || "");
return DOMPurify.sanitize(html); return DOMPurify.sanitize(html);
}); });

View File

@@ -30,9 +30,8 @@
<script setup> <script setup>
import { computed } from "vue"; import { computed } from "vue";
import { marked } from "marked";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { preprocessMarkdown } from "../utils/markdown.js"; import { renderMarkdown } from "../utils/markdown.js";
const props = defineProps({ const props = defineProps({
note: { note: {
@@ -50,7 +49,7 @@ const props = defineProps({
}); });
const renderedMarkdown = computed(() => { const renderedMarkdown = computed(() => {
const html = marked.parse(preprocessMarkdown(props.note.content || "")); const html = renderMarkdown(props.note.content || "");
return DOMPurify.sanitize(html); return DOMPurify.sanitize(html);
}); });

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

@@ -4,6 +4,7 @@ import router from "./router";
import App from "./App.vue"; import App from "./App.vue";
import "bootstrap/dist/css/bootstrap.min.css"; import "bootstrap/dist/css/bootstrap.min.css";
import "@mdi/font/css/materialdesignicons.min.css"; import "@mdi/font/css/materialdesignicons.min.css";
import "highlight.js/styles/github-dark.min.css";
import "./assets/styles/main.css"; import "./assets/styles/main.css";
const app = createApp(App); const app = createApp(App);

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,
}; };
}); });

View File

@@ -1,3 +1,19 @@
import { marked } from "marked";
import { markedHighlight } from "marked-highlight";
import hljs from "highlight.js/lib/common";
marked.use(
markedHighlight({
langPrefix: "hljs language-",
highlight(code, lang) {
if (lang && hljs.getLanguage(lang)) {
return hljs.highlight(code, { language: lang }).value;
}
return hljs.highlightAuto(code).value;
},
}),
);
/** /**
* Preprocesses markdown content to support extended image size syntax: * Preprocesses markdown content to support extended image size syntax:
* *
@@ -24,3 +40,7 @@ export function preprocessMarkdown(content) {
return `<img ${attrs}>`; return `<img ${attrs}>`;
}); });
} }
export function renderMarkdown(content) {
return marked.parse(preprocessMarkdown(content || ""), { gfm: true });
}