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:
2026-03-06 15:26:34 -05:00
parent 48f070f773
commit 16ecd6bbeb
32 changed files with 563 additions and 634 deletions
+22
View File
@@ -0,0 +1,22 @@
import { onMounted, onUnmounted } from "vue";
import type { Ref } from "vue";
export function useAutoSave(
dirty: Ref<boolean>,
saving: Ref<boolean>,
saveFn: () => Promise<void>,
intervalMs = 5 * 60 * 1000,
): void {
let timer: ReturnType<typeof setInterval> | null = null;
onMounted(() => {
timer = setInterval(async () => {
if (!dirty.value || saving.value) return;
await saveFn();
}, intervalMs);
});
onUnmounted(() => {
if (timer !== null) clearInterval(timer);
});
}
@@ -0,0 +1,33 @@
import { onMounted, onUnmounted } from "vue";
import { onBeforeRouteLeave } from "vue-router";
import type { Ref } from "vue";
export function useEditorGuards(
dirty: Ref<boolean>,
saveFn: () => void | Promise<void>,
): void {
function onKeydown(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
saveFn();
}
}
function onBeforeUnload(e: BeforeUnloadEvent) {
if (dirty.value) e.preventDefault();
}
onMounted(() => {
document.addEventListener("keydown", onKeydown);
window.addEventListener("beforeunload", onBeforeUnload);
});
onUnmounted(() => {
document.removeEventListener("keydown", onKeydown);
window.removeEventListener("beforeunload", onBeforeUnload);
});
onBeforeRouteLeave(() => {
if (dirty.value) return confirm("You have unsaved changes. Leave anyway?");
});
}
@@ -0,0 +1,34 @@
import { ref } from "vue";
type Selection = { text: string; start: number; end: number };
export function useFloatingAssist(onRequest: (sel: Selection) => void) {
const floatingAssist = ref({ show: false, top: 0, left: 0 });
const pendingSelection = ref<Selection | null>(null);
function onSelectionChange(payload: Selection) {
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 = payload;
}
} else {
floatingAssist.value.show = false;
pendingSelection.value = null;
}
}
function handleInlineAssist() {
if (!pendingSelection.value) return;
floatingAssist.value.show = false;
onRequest(pendingSelection.value);
}
return { floatingAssist, onSelectionChange, handleInlineAssist };
}
@@ -0,0 +1,39 @@
import { ref, onMounted, onUnmounted } from "vue";
import type { Ref } from "vue";
export function useListKeyboardNavigation<T extends { id: number }>(
items: Ref<T[]>,
navigateTo: (item: T) => void,
rowSelector = ".kb-active-item",
enabled?: Ref<boolean>,
) {
const activeIndex = ref(-1);
function onKeydown(e: KeyboardEvent) {
if (enabled && !enabled.value) return;
const el = document.activeElement;
const tag = el ? (el as HTMLElement).tagName : "";
const isInput =
tag === "INPUT" || tag === "TEXTAREA" || (el as HTMLElement)?.isContentEditable;
if (isInput || e.ctrlKey || e.metaKey || e.altKey) return;
const count = items.value.length;
if (e.key === "j") {
e.preventDefault();
activeIndex.value = Math.min(activeIndex.value + 1, count - 1);
document.querySelector(rowSelector)?.scrollIntoView({ block: "nearest" });
} else if (e.key === "k") {
e.preventDefault();
activeIndex.value = Math.max(activeIndex.value - 1, 0);
document.querySelector(rowSelector)?.scrollIntoView({ block: "nearest" });
} else if (e.key === "Enter" && activeIndex.value >= 0) {
const item = items.value[activeIndex.value];
if (item) navigateTo(item);
}
}
onMounted(() => document.addEventListener("keydown", onKeydown));
onUnmounted(() => document.removeEventListener("keydown", onKeydown));
return { activeIndex };
}
@@ -0,0 +1,58 @@
import { ref } from "vue";
import type { Ref } from "vue";
import { apiPost } from "@/api/client";
import { useToastStore } from "@/stores/toast";
export function useTagSuggestions(
title: Ref<string>,
body: Ref<string>,
tags: Ref<string[]>,
markDirty: () => void,
) {
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();
const toast = useToastStore();
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();
}
return {
suggestedTags,
appliedTags,
suggestingTags,
fetchTagSuggestions,
applyTagSuggestion,
dismissTagSuggestions,
};
}