first commit

This commit is contained in:
domrichardson
2026-03-24 16:03:04 +00:00
commit df40cc57e1
80 changed files with 16766 additions and 0 deletions

View 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>