first commit
This commit is contained in:
287
frontend/src/components/NoteEditor.vue
Normal file
287
frontend/src/components/NoteEditor.vue
Normal file
@@ -0,0 +1,287 @@
|
||||
<template>
|
||||
<div class="note-editor">
|
||||
<div class="editor-toolbar mb-3">
|
||||
<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 class="btn btn-sm btn-secondary ms-2" @click="togglePreview">
|
||||
{{ showPreview ? "Edit" : "Preview" }}
|
||||
</button>
|
||||
<span class="save-status ms-auto" :class="saveState">{{ saveStatusLabel }}</span>
|
||||
</div>
|
||||
|
||||
<div class="editor-container">
|
||||
<input v-model="editingNote.title" type="text" class="form-control form-control-lg mb-3" placeholder="Note title..." @input="autoSave" />
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea v-model="editingNote.description" class="form-control" rows="2" maxlength="500" placeholder="Short summary shown in note lists..." @input="autoSave"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div :class="{ 'col-md-6': showPreview, 'col-12': !showPreview }">
|
||||
<textarea v-model="editingNote.content" class="form-control editor-textarea" placeholder="Write your note in markdown..." @input="autoSave"></textarea>
|
||||
</div>
|
||||
|
||||
<div v-if="showPreview" class="col-md-6">
|
||||
<div class="preview-pane border rounded p-3">
|
||||
<div v-html="renderedMarkdown"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<label class="form-label">Tags</label>
|
||||
<input v-model="tagsInput" type="text" class="form-control" placeholder="Add tags separated by commas" />
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<label class="form-label">Category</label>
|
||||
<select v-model="editingNote.category_id" class="form-select" @change="autoSave">
|
||||
<option :value="null">Uncategorized</option>
|
||||
<option v-for="category in categoryOptions" :key="category.id" :value="category.id">
|
||||
{{ category.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="note-flags mt-3">
|
||||
<label class="flag-check">
|
||||
<input v-model="editingNote.is_pinned" class="flag-check-input" type="checkbox" @change="autoSave" />
|
||||
<span class="flag-check-label">Pinned</span>
|
||||
</label>
|
||||
|
||||
<label class="flag-check">
|
||||
<input v-model="editingNote.is_favorite" class="flag-check-input" type="checkbox" @change="autoSave" />
|
||||
<span class="flag-check-label">Featured</span>
|
||||
</label>
|
||||
|
||||
<label v-if="publicSharingEnabled" class="flag-check">
|
||||
<input v-model="editingNote.is_public" class="flag-check-input" type="checkbox" @change="autoSave" />
|
||||
<span class="flag-check-label">Public</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<label class="form-label">Password Protection</label>
|
||||
<select v-model="passwordAction" class="form-select">
|
||||
<option value="keep">Keep current setting</option>
|
||||
<option value="set">Set or change password</option>
|
||||
<option value="remove">Remove password protection</option>
|
||||
</select>
|
||||
<input v-if="passwordAction === 'set'" v-model="notePassword" type="password" class="form-control mt-2" minlength="4" maxlength="128" placeholder="Enter a note password" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onBeforeUnmount, onMounted } from "vue";
|
||||
import { marked } from "marked";
|
||||
import DOMPurify from "dompurify";
|
||||
import { useSettingsStore } from "../stores/settingsStore";
|
||||
|
||||
const props = defineProps({
|
||||
note: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
categoryOptions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
canDelete: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["save", "delete", "cancel"]);
|
||||
const settingsStore = useSettingsStore();
|
||||
const publicSharingEnabled = ref(true);
|
||||
|
||||
const editingNote = ref({ ...props.note });
|
||||
const showPreview = ref(false);
|
||||
const tagsInput = ref(props.note.tags?.join(", ") || "");
|
||||
const passwordAction = ref("keep");
|
||||
const notePassword = ref("");
|
||||
const saveTimeout = ref(null);
|
||||
const saveState = ref("saved");
|
||||
const saveStateTimeout = ref(null);
|
||||
|
||||
const renderedMarkdown = computed(() => {
|
||||
const html = marked.parse(editingNote.value.content || "");
|
||||
return DOMPurify.sanitize(html);
|
||||
});
|
||||
|
||||
const saveStatusLabel = computed(() => {
|
||||
switch (saveState.value) {
|
||||
case "dirty":
|
||||
return "Unsaved changes";
|
||||
case "saving":
|
||||
return "Saving...";
|
||||
case "saved":
|
||||
default:
|
||||
return "Saved";
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.note,
|
||||
(newNote) => {
|
||||
editingNote.value = { ...newNote };
|
||||
tagsInput.value = newNote.tags?.join(", ") || "";
|
||||
passwordAction.value = "keep";
|
||||
notePassword.value = "";
|
||||
saveState.value = "saved";
|
||||
},
|
||||
);
|
||||
|
||||
watch(tagsInput, () => {
|
||||
if (editingNote.value.id) {
|
||||
autoSave();
|
||||
}
|
||||
});
|
||||
|
||||
const markSavedSoon = () => {
|
||||
clearTimeout(saveStateTimeout.value);
|
||||
saveStateTimeout.value = setTimeout(() => {
|
||||
saveState.value = "saved";
|
||||
}, 250);
|
||||
};
|
||||
|
||||
const saveNote = () => {
|
||||
if (passwordAction.value === "set") {
|
||||
if (!notePassword.value.trim()) {
|
||||
alert("Please enter a note password.");
|
||||
return;
|
||||
}
|
||||
if (notePassword.value.trim().length < 4) {
|
||||
alert("Note password must be at least 4 characters.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
saveState.value = "saving";
|
||||
const note = {
|
||||
...editingNote.value,
|
||||
category_id: editingNote.value.category_id || null,
|
||||
tags: tagsInput.value
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t),
|
||||
};
|
||||
|
||||
if (passwordAction.value === "set") {
|
||||
note.note_password = notePassword.value;
|
||||
} else if (passwordAction.value === "remove") {
|
||||
note.note_password = "";
|
||||
}
|
||||
|
||||
emit("save", note);
|
||||
if (passwordAction.value !== "keep") {
|
||||
passwordAction.value = "keep";
|
||||
notePassword.value = "";
|
||||
}
|
||||
markSavedSoon();
|
||||
};
|
||||
|
||||
const autoSave = () => {
|
||||
saveState.value = "dirty";
|
||||
clearTimeout(saveTimeout.value);
|
||||
saveTimeout.value = setTimeout(saveNote, 3000);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (!props.canDelete) {
|
||||
return;
|
||||
}
|
||||
if (confirm("Are you sure you want to delete this note?")) {
|
||||
emit("delete", editingNote.value.id);
|
||||
}
|
||||
};
|
||||
|
||||
const togglePreview = () => {
|
||||
showPreview.value = !showPreview.value;
|
||||
};
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearTimeout(saveTimeout.value);
|
||||
clearTimeout(saveStateTimeout.value);
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await settingsStore.loadFeatureFlags();
|
||||
publicSharingEnabled.value = settingsStore.publicSharingEnabled;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.save-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 0.85rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.save-status.dirty {
|
||||
color: #b26a00;
|
||||
}
|
||||
|
||||
.save-status.saving {
|
||||
color: #0d6efd;
|
||||
}
|
||||
|
||||
.save-status.saved {
|
||||
color: #2b8a3e;
|
||||
}
|
||||
|
||||
.editor-textarea {
|
||||
font-family: "Courier New", monospace;
|
||||
min-height: 400px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.note-flags {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.flag-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.45rem 0.75rem;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 999px;
|
||||
background: #f8f9fa;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.flag-check-input {
|
||||
margin: 0;
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.flag-check-label {
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.preview-pane {
|
||||
background-color: #f8f9fa;
|
||||
overflow-y: auto;
|
||||
max-height: 600px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user