DRY refactoring pass: shared mixins, route helpers, composables, CSS
Backend: - models/base.py: TimestampMixin + CreatedAtMixin; applied to all 10+ models - routes/utils.py: not_found() + parse_iso_date() helpers; used across all route files - routes/milestones.py: _milestone_dict() helper replaces 5 repeated to_dict + progress blocks Frontend: - 5 new composables: useAutoSave, useEditorGuards, useTagSuggestions, useFloatingAssist, useListKeyboardNavigation - ConfirmDialog.vue: reusable confirm modal replacing inline <teleport> blocks - editor-shared.css: sidebar CSS consolidated from both editor views - viewer-shared.css: context-bar/breadcrumb CSS consolidated from both viewer views - NoteEditorView + TaskEditorView: ~120 lines each replaced with composable calls; duplicate scoped sidebar CSS removed - NotesListView + TasksListView: inline keyboard-nav replaced with composable - NoteViewerView + TaskViewerView: duplicate context-bar CSS removed No behaviour changes. Net: -634 lines, +237 lines across 31 files. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed, nextTick, watch } from "vue";
|
||||
import { useRoute, useRouter, onBeforeRouteLeave } from "vue-router";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useNotesStore } from "@/stores/notes";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { renderMarkdown } from "@/utils/markdown";
|
||||
import { useAssist, type NoteDraft } from "@/composables/useAssist";
|
||||
import { apiPost, apiGet } from "@/api/client";
|
||||
import { useAutoSave } from "@/composables/useAutoSave";
|
||||
import { useEditorGuards } from "@/composables/useEditorGuards";
|
||||
import { useTagSuggestions } from "@/composables/useTagSuggestions";
|
||||
import { useFloatingAssist } from "@/composables/useFloatingAssist";
|
||||
import { apiGet, apiPost } from "@/api/client";
|
||||
import type { Editor } from "@tiptap/vue-3";
|
||||
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
|
||||
import TiptapEditor from "@/components/TiptapEditor.vue";
|
||||
@@ -14,6 +18,7 @@ import ProjectSelector from "@/components/ProjectSelector.vue";
|
||||
import MilestoneSelector from "@/components/MilestoneSelector.vue";
|
||||
import DiffView from "@/components/DiffView.vue";
|
||||
import HistoryPanel from "@/components/HistoryPanel.vue";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -84,31 +89,14 @@ const scopeSelectValue = computed({
|
||||
});
|
||||
|
||||
// Floating inline assist button
|
||||
const floatingAssist = ref({ show: false, top: 0, left: 0 });
|
||||
const pendingSelection = ref<{ start: number; end: number } | null>(null);
|
||||
const instructionRef = ref<HTMLTextAreaElement | null>(null);
|
||||
|
||||
function onSelectionChange(payload: { text: string; start: number; end: number }) {
|
||||
if (payload.text.trim().length > 0) {
|
||||
const sel = window.getSelection();
|
||||
if (sel && sel.rangeCount > 0) {
|
||||
const rect = sel.getRangeAt(0).getBoundingClientRect();
|
||||
floatingAssist.value = { show: true, top: rect.top - 44, left: rect.left + rect.width / 2 };
|
||||
pendingSelection.value = { start: payload.start, end: payload.end };
|
||||
}
|
||||
} else {
|
||||
floatingAssist.value.show = false;
|
||||
pendingSelection.value = null;
|
||||
const { floatingAssist, onSelectionChange, handleInlineAssist } = useFloatingAssist(
|
||||
({ start, end }) => {
|
||||
assist.scopeMode.value = 'section';
|
||||
assist.selectTextRange(start, end);
|
||||
nextTick(() => instructionRef.value?.focus());
|
||||
}
|
||||
}
|
||||
|
||||
function handleInlineAssist() {
|
||||
if (!pendingSelection.value) return;
|
||||
assist.scopeMode.value = 'section';
|
||||
assist.selectTextRange(pendingSelection.value.start, pendingSelection.value.end);
|
||||
floatingAssist.value.show = false;
|
||||
nextTick(() => instructionRef.value?.focus());
|
||||
}
|
||||
);
|
||||
|
||||
function handleAssistAccept() {
|
||||
const newBody = assist.accept();
|
||||
@@ -118,42 +106,8 @@ function handleAssistAccept() {
|
||||
}
|
||||
|
||||
// Tag suggestions
|
||||
const suggestedTags = ref<string[]>([]);
|
||||
const appliedTags = ref<Set<string>>(new Set());
|
||||
const suggestingTags = ref(false);
|
||||
|
||||
async function fetchTagSuggestions() {
|
||||
if (suggestingTags.value) return;
|
||||
suggestingTags.value = true;
|
||||
suggestedTags.value = [];
|
||||
appliedTags.value = new Set();
|
||||
try {
|
||||
const res = await apiPost<{ suggested_tags: string[] }>("/api/notes/suggest-tags", {
|
||||
title: title.value,
|
||||
body: body.value,
|
||||
current_tags: tags.value,
|
||||
});
|
||||
suggestedTags.value = res.suggested_tags;
|
||||
} catch {
|
||||
toast.show("Failed to get tag suggestions", "error");
|
||||
} finally {
|
||||
suggestingTags.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function applyTagSuggestion(tag: string) {
|
||||
if (appliedTags.value.has(tag)) return;
|
||||
if (!tags.value.includes(tag)) {
|
||||
tags.value = [...tags.value, tag];
|
||||
}
|
||||
appliedTags.value.add(tag);
|
||||
markDirty();
|
||||
}
|
||||
|
||||
function dismissTagSuggestions() {
|
||||
suggestedTags.value = [];
|
||||
appliedTags.value = new Set();
|
||||
}
|
||||
const { suggestedTags, appliedTags, suggestingTags, fetchTagSuggestions, applyTagSuggestion, dismissTagSuggestions } =
|
||||
useTagSuggestions(title, body, tags, markDirty);
|
||||
|
||||
// ── Link Suggestions ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -331,10 +285,8 @@ async function confirmDelete() {
|
||||
}
|
||||
|
||||
// Auto-save every 5 minutes
|
||||
let autoSaveTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
async function autoSave() {
|
||||
if (!isEditing.value || !dirty.value || saving.value) return;
|
||||
async function doAutoSave() {
|
||||
if (!isEditing.value || saving.value) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
await store.updateNote(noteId.value!, {
|
||||
@@ -354,29 +306,8 @@ async function autoSave() {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { autoSaveTimer = setInterval(autoSave, 5 * 60 * 1000); });
|
||||
onUnmounted(() => { if (autoSaveTimer !== null) clearInterval(autoSaveTimer); });
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
||||
e.preventDefault();
|
||||
save();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener("keydown", onKeydown));
|
||||
onUnmounted(() => document.removeEventListener("keydown", onKeydown));
|
||||
|
||||
onBeforeRouteLeave(() => {
|
||||
if (dirty.value) return confirm("You have unsaved changes. Leave anyway?");
|
||||
});
|
||||
|
||||
function onBeforeUnload(e: BeforeUnloadEvent) {
|
||||
if (dirty.value) e.preventDefault();
|
||||
}
|
||||
onMounted(() => window.addEventListener("beforeunload", onBeforeUnload));
|
||||
onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
||||
useAutoSave(dirty, saving, doAutoSave);
|
||||
useEditorGuards(dirty, save);
|
||||
|
||||
// Close any in-flight assist SSE stream when navigating away
|
||||
onUnmounted(() => assist.clearSelection());
|
||||
@@ -595,18 +526,13 @@ onUnmounted(() => assist.clearSelection());
|
||||
/>
|
||||
|
||||
<!-- Delete confirmation -->
|
||||
<teleport to="body">
|
||||
<div v-if="showDeleteConfirm" class="modal-overlay" @click="showDeleteConfirm = false">
|
||||
<div class="modal-card" @click.stop>
|
||||
<h3 class="modal-title">Delete Note</h3>
|
||||
<p class="modal-message">Are you sure you want to delete this note? This cannot be undone.</p>
|
||||
<div class="modal-actions">
|
||||
<button class="modal-btn" @click="showDeleteConfirm = false">Cancel</button>
|
||||
<button class="modal-btn modal-btn-danger" @click="confirmDelete">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
<ConfirmDialog
|
||||
v-if="showDeleteConfirm"
|
||||
title="Delete Note"
|
||||
message="Are you sure you want to delete this note? This cannot be undone."
|
||||
@confirm="confirmDelete"
|
||||
@cancel="showDeleteConfirm = false"
|
||||
/>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -675,61 +601,6 @@ onUnmounted(() => assist.clearSelection());
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Mobile accordion toggle (hidden on desktop) */
|
||||
.sidebar-toggle {
|
||||
display: none;
|
||||
width: 100%;
|
||||
padding: 0.6rem 1rem;
|
||||
background: var(--color-bg-secondary);
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
/* Sidebar field layout */
|
||||
.sb-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.sb-label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.sb-select {
|
||||
width: 100%;
|
||||
padding: 0.35rem 0.5rem;
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.sb-select:focus { outline: none; border-color: var(--color-primary); }
|
||||
|
||||
.sb-divider {
|
||||
height: 1px;
|
||||
background: var(--color-border);
|
||||
margin: 0.15rem 0;
|
||||
}
|
||||
|
||||
/* Tag suggest row inside sidebar */
|
||||
.tag-suggest-row {
|
||||
display: flex;
|
||||
@@ -841,11 +712,5 @@ onUnmounted(() => assist.clearSelection());
|
||||
border-top: 1px solid var(--color-border);
|
||||
overflow-y: visible;
|
||||
}
|
||||
.sidebar-toggle { display: block; }
|
||||
.sidebar-content {
|
||||
display: none;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
.sidebar-content.sidebar-open { display: flex; }
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user