Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cf71ab4a0 | ||
|
|
cf94697d07 | ||
|
|
94f11be77c |
@@ -54,6 +54,7 @@ func main() {
|
||||
redisAddr = "localhost:6379"
|
||||
}
|
||||
|
||||
redisUser := os.Getenv("REDIS_USER")
|
||||
redisPassword := os.Getenv("REDIS_PASSWORD")
|
||||
redisDB := 0
|
||||
if redisDBText := os.Getenv("REDIS_DB"); redisDBText != "" {
|
||||
@@ -85,6 +86,7 @@ func main() {
|
||||
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: redisAddr,
|
||||
Username: redisUser,
|
||||
Password: redisPassword,
|
||||
DB: redisDB,
|
||||
})
|
||||
|
||||
20
frontend/package-lock.json
generated
20
frontend/package-lock.json
generated
@@ -13,7 +13,9 @@
|
||||
"axios": "^1.4.0",
|
||||
"bootstrap": "^5.3.0",
|
||||
"dompurify": "^3.0.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"marked": "^9.0.0",
|
||||
"marked-highlight": "^2.2.3",
|
||||
"pinia": "^2.1.0",
|
||||
"vue": "^3.3.0",
|
||||
"vue-router": "^4.2.0"
|
||||
@@ -1433,6 +1435,15 @@
|
||||
"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": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
|
||||
@@ -1556,6 +1567,15 @@
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
"axios": "^1.4.0",
|
||||
"bootstrap": "^5.3.0",
|
||||
"dompurify": "^3.0.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"marked": "^9.0.0",
|
||||
"marked-highlight": "^2.2.3",
|
||||
"pinia": "^2.1.0",
|
||||
"vue": "^3.3.0",
|
||||
"vue-router": "^4.2.0"
|
||||
|
||||
@@ -117,7 +117,7 @@
|
||||
</h5>
|
||||
</div>
|
||||
<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
|
||||
type="button"
|
||||
class="btn action-button"
|
||||
@@ -140,7 +140,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-if="canEditNotes && selectedNote && !isEditingNote"
|
||||
v-if="canEditNotes && selectedNote && !isEditingNote && !isSearchRoute"
|
||||
class="btn btn-outline-secondary me-2 action-button"
|
||||
aria-label="Edit note"
|
||||
title="Edit note"
|
||||
@@ -150,7 +150,7 @@
|
||||
<span class="action-label">Edit Note</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="canShareSelectedNote && !isEditingNote"
|
||||
v-if="canShareSelectedNote && !isEditingNote && !isSearchRoute"
|
||||
class="btn btn-outline-primary me-2 action-button"
|
||||
:aria-label="shareCopied ? 'Link copied' : 'Share note'"
|
||||
:title="shareCopied ? 'Link copied' : 'Share note'"
|
||||
@@ -169,8 +169,18 @@
|
||||
|
||||
<!-- Note Editor or Note List -->
|
||||
<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
|
||||
v-if="selectedNote && isEditingNote"
|
||||
v-else-if="selectedNote && isEditingNote"
|
||||
:note="selectedNote"
|
||||
:category-options="categoryOptions"
|
||||
:can-delete="canDeleteNotes"
|
||||
@@ -278,6 +288,7 @@ import CategoryTree from "./components/CategoryTree.vue";
|
||||
import NoteEditor from "./components/NoteEditor.vue";
|
||||
import NoteViewer from "./components/NoteViewer.vue";
|
||||
import NoteList from "./components/NoteList.vue";
|
||||
import SearchResultsPage from "./components/SearchResultsPage.vue";
|
||||
import CreateSpaceModal from "./components/CreateSpaceModal.vue";
|
||||
import CreateCategoryModal from "./components/CreateCategoryModal.vue";
|
||||
import CreateNoteModal from "./components/CreateNoteModal.vue";
|
||||
@@ -319,10 +330,20 @@ const unlockingNote = ref(false);
|
||||
|
||||
const currentUser = computed(() => authStore.user);
|
||||
const isAdminRoute = computed(() => route.path === "/admin");
|
||||
const isSearchRoute = computed(() => route.path === "/search");
|
||||
const isPublicRoute = computed(() => route.path.startsWith("/s/"));
|
||||
const isAuthRoute = computed(() => route.path === "/login" || route.path === "/register");
|
||||
const spaces = computed(() => spaceStore.spaces);
|
||||
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 canCreateSpaces = computed(() => authStore.hasPermission("space.create"));
|
||||
const canCreateCategories = computed(() => authStore.hasSpacePermission(currentSpace.value, "category.create"));
|
||||
@@ -382,7 +403,7 @@ const canLoadMoreMainNotes = computed(() => {
|
||||
if (selectedCategory.value || selectedNote.value) {
|
||||
return false;
|
||||
}
|
||||
if (searchQuery.value.trim()) {
|
||||
if (isSearchRoute.value) {
|
||||
return false;
|
||||
}
|
||||
return spaceStore.notesHasMore;
|
||||
@@ -411,6 +432,10 @@ const openSpaceHome = () => {
|
||||
unlockPassword.value = "";
|
||||
unlockError.value = "";
|
||||
searchQuery.value = "";
|
||||
spaceStore.clearSearchResults();
|
||||
if (route.path !== "/") {
|
||||
router.push("/");
|
||||
}
|
||||
if (currentSpace.value?.id) {
|
||||
spaceStore.fetchNotes(currentSpace.value.id, { reset: true });
|
||||
}
|
||||
@@ -425,6 +450,21 @@ const breadcrumbItems = computed(() => {
|
||||
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 = [
|
||||
{
|
||||
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(
|
||||
() => selectedNote.value?.id,
|
||||
() => {
|
||||
@@ -683,11 +747,53 @@ const selectCategory = (category) => {
|
||||
};
|
||||
|
||||
const performSearch = async () => {
|
||||
if (searchQuery.value.trim()) {
|
||||
await spaceStore.searchNotes(searchQuery.value);
|
||||
} else if (currentSpace.value?.id) {
|
||||
const q = searchQuery.value.trim();
|
||||
if (!q) {
|
||||
spaceStore.clearSearchResults();
|
||||
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("/");
|
||||
}
|
||||
};
|
||||
|
||||
const loadMoreMainNotes = async () => {
|
||||
|
||||
@@ -25,6 +25,70 @@ body,
|
||||
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 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
|
||||
@@ -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="preview-pane border rounded p-3">
|
||||
<div v-html="renderedMarkdown"></div>
|
||||
<div class="markdown-body" v-html="renderedMarkdown"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -96,10 +96,9 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onBeforeUnmount, onMounted, nextTick } from "vue";
|
||||
import { marked } from "marked";
|
||||
import DOMPurify from "dompurify";
|
||||
import { useSettingsStore } from "../stores/settingsStore";
|
||||
import { preprocessMarkdown } from "../utils/markdown.js";
|
||||
import { renderMarkdown } from "../utils/markdown.js";
|
||||
import FileExplorer from "./FileExplorer.vue";
|
||||
|
||||
const props = defineProps({
|
||||
@@ -138,7 +137,7 @@ const saveState = ref("saved");
|
||||
const saveStateTimeout = ref(null);
|
||||
|
||||
const renderedMarkdown = computed(() => {
|
||||
const html = marked.parse(preprocessMarkdown(editingNote.value.content || ""));
|
||||
const html = renderMarkdown(editingNote.value.content || "");
|
||||
return DOMPurify.sanitize(html);
|
||||
});
|
||||
|
||||
|
||||
@@ -30,9 +30,8 @@
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { marked } from "marked";
|
||||
import DOMPurify from "dompurify";
|
||||
import { preprocessMarkdown } from "../utils/markdown.js";
|
||||
import { renderMarkdown } from "../utils/markdown.js";
|
||||
|
||||
const props = defineProps({
|
||||
note: {
|
||||
@@ -50,7 +49,7 @@ const props = defineProps({
|
||||
});
|
||||
|
||||
const renderedMarkdown = computed(() => {
|
||||
const html = marked.parse(preprocessMarkdown(props.note.content || ""));
|
||||
const html = renderMarkdown(props.note.content || "");
|
||||
return DOMPurify.sanitize(html);
|
||||
});
|
||||
|
||||
|
||||
154
frontend/src/components/SearchResultsPage.vue
Normal file
154
frontend/src/components/SearchResultsPage.vue
Normal 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>
|
||||
@@ -4,6 +4,7 @@ import router from "./router";
|
||||
import App from "./App.vue";
|
||||
import "bootstrap/dist/css/bootstrap.min.css";
|
||||
import "@mdi/font/css/materialdesignicons.min.css";
|
||||
import "highlight.js/styles/github-dark.min.css";
|
||||
import "./assets/styles/main.css";
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
@@ -21,6 +21,12 @@ const routes = [
|
||||
component: () => import("../pages/Home.vue"),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/search",
|
||||
name: "Search",
|
||||
component: () => import("../pages/Home.vue"),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/admin",
|
||||
name: "Admin",
|
||||
|
||||
@@ -6,6 +6,7 @@ export const useSpaceStore = defineStore("space", () => {
|
||||
const spaces = ref([]);
|
||||
const currentSpace = ref(null);
|
||||
const notes = ref([]);
|
||||
const searchResults = ref([]);
|
||||
const notesSkip = ref(0);
|
||||
const notesLimit = ref(20);
|
||||
const notesHasMore = ref(true);
|
||||
@@ -188,20 +189,30 @@ export const useSpaceStore = defineStore("space", () => {
|
||||
};
|
||||
|
||||
const searchNotes = async (query) => {
|
||||
if (!currentSpace.value?.id) {
|
||||
searchResults.value = [];
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const response = await apiClient.get(`/api/v1/spaces/${currentSpace.value.id}/notes/search`, { params: { q: query } });
|
||||
notes.value = response.data || [];
|
||||
notesHasMore.value = false;
|
||||
notesSkip.value = notes.value.length;
|
||||
searchResults.value = response.data || [];
|
||||
return searchResults.value;
|
||||
} catch (error) {
|
||||
console.error("Error searching notes:", error);
|
||||
searchResults.value = [];
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const clearSearchResults = () => {
|
||||
searchResults.value = [];
|
||||
};
|
||||
|
||||
return {
|
||||
spaces,
|
||||
currentSpace,
|
||||
notes,
|
||||
searchResults,
|
||||
notesHasMore,
|
||||
notesLoading,
|
||||
categories,
|
||||
@@ -220,5 +231,6 @@ export const useSpaceStore = defineStore("space", () => {
|
||||
updateNote,
|
||||
deleteNote,
|
||||
searchNotes,
|
||||
clearSearchResults,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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:
|
||||
*
|
||||
@@ -24,3 +40,7 @@ export function preprocessMarkdown(content) {
|
||||
return `<img ${attrs}>`;
|
||||
});
|
||||
}
|
||||
|
||||
export function renderMarkdown(content) {
|
||||
return marked.parse(preprocessMarkdown(content || ""), { gfm: true });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user