feat: file explorer
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 50s

This commit is contained in:
domrichardson
2026-03-25 11:27:15 +00:00
parent b253bec9fc
commit 168f5eac83
16 changed files with 1297 additions and 20 deletions

View File

@@ -4,6 +4,16 @@
<button class="btn btn-sm btn-primary" @click="saveNote">Save</button>
<button v-if="canDelete" class="btn btn-sm btn-danger ms-2" @click="confirmDelete">Delete</button>
<button class="btn btn-sm btn-outline-secondary ms-2" @click="emit('cancel')">Cancel</button>
<button
v-if="fileExplorerEnabled"
class="btn btn-sm ms-2"
:class="showFileExplorer ? 'btn-secondary' : 'btn-outline-secondary'"
:title="showFileExplorer ? 'Hide file explorer' : 'Browse & insert files'"
@click="showFileExplorer = !showFileExplorer"
>
<i class="mdi mdi-folder-open-outline me-1" aria-hidden="true"></i>
Files
</button>
<span class="save-status ms-auto" :class="saveState">{{ saveStatusLabel }}</span>
</div>
@@ -16,15 +26,19 @@
</div>
<div class="row">
<div class="col-12 col-md-6">
<textarea v-model="editingNote.content" class="form-control editor-textarea" placeholder="Write your note in markdown..." @input="autoSave"></textarea>
<div :class="showFileExplorer ? 'col-12 col-md-5' : 'col-12 col-md-6'">
<textarea ref="contentTextareaRef" v-model="editingNote.content" class="form-control editor-textarea" placeholder="Write your note in markdown..." @input="autoSave"></textarea>
</div>
<div class="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 v-html="renderedMarkdown"></div>
</div>
</div>
<div v-if="showFileExplorer" class="col-12 col-md-3 mt-3 mt-md-0">
<FileExplorer v-model="fileExplorerPrefix" :space-id="spaceId" @insert="insertAtCursor" />
</div>
</div>
<div class="mt-3">
@@ -73,10 +87,13 @@
</template>
<script setup>
import { ref, computed, watch, onBeforeUnmount, onMounted } from "vue";
import { ref, computed, watch, onBeforeUnmount, onMounted, nextTick } from "vue";
import { marked } from "marked";
import DOMPurify from "dompurify";
import { useSettingsStore } from "../stores/settingsStore";
import { useAuthStore } from "../stores/authStore";
import { preprocessMarkdown } from "../utils/markdown.js";
import FileExplorer from "./FileExplorer.vue";
const props = defineProps({
note: {
@@ -91,13 +108,22 @@ const props = defineProps({
type: Boolean,
default: true,
},
spaceId: {
type: String,
default: "",
},
});
const emit = defineEmits(["save", "delete", "cancel"]);
const settingsStore = useSettingsStore();
const authStore = useAuthStore();
const publicSharingEnabled = ref(true);
const fileExplorerEnabled = computed(() => settingsStore.fileExplorerEnabled);
const editingNote = ref({ ...props.note });
const contentTextareaRef = ref(null);
const showFileExplorer = ref(false);
const fileExplorerPrefix = ref("");
const tagsInput = ref(props.note.tags?.join(", ") || "");
const passwordAction = ref("keep");
const notePassword = ref("");
@@ -106,8 +132,18 @@ const saveState = ref("saved");
const saveStateTimeout = ref(null);
const renderedMarkdown = computed(() => {
const html = marked.parse(editingNote.value.content || "");
return DOMPurify.sanitize(html);
const html = marked.parse(preprocessMarkdown(editingNote.value.content || ""));
let clean = DOMPurify.sanitize(html);
// Inject access token into space file API URLs so images render without a separate JS fetch
const token = authStore.accessToken;
if (token && props.spaceId) {
clean = clean.replace(/((?:src|href)=["'])([^"']*\/api\/v1\/spaces\/[^"']*\/files\/object[^"']*)(["'])/g, (_, attr, url, quote) => {
if (url.includes("token=")) return attr + url + quote;
const sep = url.includes("?") ? "&" : "?";
return `${attr}${url}${sep}token=${encodeURIComponent(token)}${quote}`;
});
}
return clean;
});
const saveStatusLabel = computed(() => {
@@ -197,6 +233,27 @@ const confirmDelete = () => {
}
};
/** Insert markdown snippet at the textarea cursor position. */
const insertAtCursor = (snippet) => {
const textarea = contentTextareaRef.value;
if (!textarea) {
editingNote.value.content = (editingNote.value.content || "") + snippet;
autoSave();
return;
}
const start = textarea.selectionStart ?? editingNote.value.content?.length ?? 0;
const end = textarea.selectionEnd ?? start;
const before = (editingNote.value.content || "").substring(0, start);
const after = (editingNote.value.content || "").substring(end);
editingNote.value.content = before + snippet + after;
autoSave();
nextTick(() => {
const newPos = start + snippet.length;
textarea.setSelectionRange(newPos, newPos);
textarea.focus();
});
};
onBeforeUnmount(() => {
clearTimeout(saveTimeout.value);
clearTimeout(saveStateTimeout.value);