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
+64
View File
@@ -532,6 +532,70 @@
pointer-events: auto;
}
/* ── Sidebar shared styles ── */
.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;
}
.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,
.sb-input {
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,
.sb-input:focus {
outline: none;
border-color: var(--color-primary);
}
.sb-divider {
height: 1px;
background: var(--color-border);
margin: 0.15rem 0;
}
@media (max-width: 720px) {
.sidebar-toggle { display: block; }
.sidebar-content {
display: none;
padding: 0.75rem 1rem;
}
.sidebar-content.sidebar-open { display: flex; }
}
/* ── Mobile ── */
@media (max-width: 768px) {
.editor-body {
+41
View File
@@ -0,0 +1,41 @@
/* ── Context breadcrumb (shared by NoteViewerView and TaskViewerView) ── */
.context-bar {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
margin-bottom: 0.6rem;
}
.ctx-crumb {
display: inline-flex;
align-items: center;
font-size: 0.8rem;
padding: 0.15rem 0.55rem;
border-radius: 999px;
white-space: nowrap;
}
.ctx-crumb-parent {
color: var(--color-text-muted);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
text-decoration: none;
}
.ctx-crumb-parent:hover {
color: var(--color-primary);
border-color: var(--color-primary);
}
.ctx-crumb-project {
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
text-decoration: none;
font-weight: 500;
}
.ctx-crumb-project:hover {
background: color-mix(in srgb, var(--color-primary) 18%, transparent);
}
.ctx-crumb-milestone {
color: var(--color-text-secondary);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
}
+37
View File
@@ -0,0 +1,37 @@
<script setup lang="ts">
withDefaults(
defineProps<{
title: string;
message: string;
confirmLabel?: string;
danger?: boolean;
}>(),
{
confirmLabel: "Delete",
danger: true,
}
);
const emit = defineEmits<{
confirm: [];
cancel: [];
}>();
</script>
<template>
<teleport to="body">
<div class="modal-overlay" @click="emit('cancel')">
<div class="modal-card" @click.stop>
<h3 class="modal-title">{{ title }}</h3>
<p class="modal-message">{{ message }}</p>
<div class="modal-actions">
<button class="modal-btn" @click="emit('cancel')">Cancel</button>
<button
:class="['modal-btn', { 'modal-btn-danger': danger }]"
@click="emit('confirm')"
>{{ confirmLabel }}</button>
</div>
</div>
</div>
</teleport>
</template>
+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,
};
}
+26 -161
View File
@@ -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>
+1 -42
View File
@@ -229,6 +229,7 @@ async function convertToTask() {
</div>
</template>
<style src="@/assets/viewer-shared.css" />
<style scoped>
.viewer-layout {
display: flex;
@@ -294,48 +295,6 @@ async function convertToTask() {
cursor: default;
}
/* Context breadcrumb */
.context-bar {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
margin-bottom: 0.6rem;
}
.ctx-crumb {
display: inline-flex;
align-items: center;
font-size: 0.8rem;
padding: 0.15rem 0.55rem;
border-radius: 999px;
white-space: nowrap;
}
.ctx-crumb-parent {
color: var(--color-text-muted);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
text-decoration: none;
}
.ctx-crumb-parent:hover {
color: var(--color-primary);
border-color: var(--color-primary);
}
.ctx-crumb-project {
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
text-decoration: none;
font-weight: 500;
}
.ctx-crumb-project:hover {
background: color-mix(in srgb, var(--color-primary) 18%, transparent);
}
.ctx-crumb-milestone {
color: var(--color-text-secondary);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
}
.meta {
font-size: 0.85rem;
color: var(--color-text-muted);
+6 -28
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from "vue";
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useNotesStore } from "@/stores/notes";
import { useListKeyboardNavigation } from "@/composables/useListKeyboardNavigation";
import SearchBar from "@/components/SearchBar.vue";
import NoteCard from "@/components/NoteCard.vue";
import TagPill from "@/components/TagPill.vue";
@@ -14,36 +15,15 @@ const router = useRouter();
const store = useNotesStore();
const searchBarRef = ref<{ focus: () => void } | null>(null);
const activeIndex = ref(-1);
function onFocusSearch() {
searchBarRef.value?.focus();
}
function onKeydown(e: KeyboardEvent) {
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 = store.notes.length;
if (e.key === "j") {
e.preventDefault();
activeIndex.value = Math.min(activeIndex.value + 1, count - 1);
scrollActiveIntoView();
} else if (e.key === "k") {
e.preventDefault();
activeIndex.value = Math.max(activeIndex.value - 1, 0);
scrollActiveIntoView();
} else if (e.key === "Enter" && activeIndex.value >= 0) {
const note = store.notes[activeIndex.value];
if (note) router.push(`/notes/${note.id}`);
}
}
function scrollActiveIntoView() {
const el = document.querySelector(".kb-active-item");
if (el) el.scrollIntoView({ block: "nearest" });
}
const { activeIndex } = useListKeyboardNavigation(
computed(() => store.notes),
(note) => router.push(`/notes/${note.id}`),
);
const viewMode = ref<ViewMode>(
(localStorage.getItem("fabled-notes-view-mode") as ViewMode) ?? "grid"
@@ -63,12 +43,10 @@ onMounted(() => {
store.refresh();
}
document.addEventListener("shortcut:focus-search", onFocusSearch);
document.addEventListener("keydown", onKeydown);
});
onUnmounted(() => {
document.removeEventListener("shortcut:focus-search", onFocusSearch);
document.removeEventListener("keydown", onKeydown);
});
watch(
+27 -171
View File
@@ -1,11 +1,15 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, nextTick } from "vue";
import { useRoute, useRouter, onBeforeRouteLeave } from "vue-router";
import { ref, onMounted, computed, nextTick } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useTasksStore } from "@/stores/tasks";
import { useNotesStore } from "@/stores/notes";
import { useToastStore } from "@/stores/toast";
import { renderMarkdown } from "@/utils/markdown";
import { useAssist } from "@/composables/useAssist";
import { useAutoSave } from "@/composables/useAutoSave";
import { useEditorGuards } from "@/composables/useEditorGuards";
import { useTagSuggestions } from "@/composables/useTagSuggestions";
import { useFloatingAssist } from "@/composables/useFloatingAssist";
import { apiPost, apiGet, apiPatch } from "@/api/client";
import type { TaskStatus, TaskPriority } from "@/types/task";
import type { Editor } from "@tiptap/vue-3";
@@ -16,6 +20,7 @@ import ProjectSelector from "@/components/ProjectSelector.vue";
import MilestoneSelector from "@/components/MilestoneSelector.vue";
import TaskLogSection from "@/components/TaskLogSection.vue";
import InlineAssistPanel from "@/components/InlineAssistPanel.vue";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
const route = useRoute();
const router = useRouter();
@@ -126,32 +131,15 @@ function toggleAssist() {
}
// 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.selectTextRange(start, end);
assistOpen.value = true;
localStorage.setItem(ASSIST_KEY, 'true');
nextTick(() => instructionRef.value?.focus());
}
}
function handleInlineAssist() {
if (!pendingSelection.value) return;
assist.selectTextRange(pendingSelection.value.start, pendingSelection.value.end);
assistOpen.value = true;
localStorage.setItem(ASSIST_KEY, 'true');
floatingAssist.value.show = false;
nextTick(() => instructionRef.value?.focus());
}
);
function handleAssistAccept() {
const newBody = assist.accept();
@@ -168,42 +156,8 @@ function truncateTarget(text: string, max = 60): string {
}
// 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);
let savedTitle = "";
let savedBody = "";
@@ -383,10 +337,8 @@ async function confirmDelete() {
}
// Auto-save every 5 minutes when editing an existing task
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.updateTask(taskId.value!, {
@@ -417,33 +369,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);
</script>
<template>
@@ -710,18 +637,13 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
</teleport>
<!-- 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 Task</h3>
<p class="modal-message">Are you sure you want to delete this task? 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 Task"
message="Are you sure you want to delete this task? This cannot be undone."
@confirm="confirmDelete"
@cancel="showDeleteConfirm = false"
/>
</main>
</template>
@@ -777,66 +699,6 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
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,
.sb-input {
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,
.sb-input:focus {
outline: none;
border-color: var(--color-primary);
}
.sb-divider {
height: 1px;
background: var(--color-border);
margin: 0.15rem 0;
}
/* Parent task search */
.parent-search-wrapper { position: relative; }
.parent-input-row { display: flex; align-items: center; gap: 0.25rem; }
@@ -985,11 +847,5 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
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>
+1 -42
View File
@@ -315,6 +315,7 @@ const subTaskProgress = computed(() => {
</div>
</template>
<style src="@/assets/viewer-shared.css" />
<style scoped>
.viewer-layout {
display: flex;
@@ -380,48 +381,6 @@ const subTaskProgress = computed(() => {
cursor: default;
}
/* Context breadcrumb */
.context-bar {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
margin-bottom: 0.6rem;
}
.ctx-crumb {
display: inline-flex;
align-items: center;
font-size: 0.8rem;
padding: 0.15rem 0.55rem;
border-radius: 999px;
white-space: nowrap;
}
.ctx-crumb-parent {
color: var(--color-text-muted);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
text-decoration: none;
}
.ctx-crumb-parent:hover {
color: var(--color-primary);
border-color: var(--color-primary);
}
.ctx-crumb-project {
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
text-decoration: none;
font-weight: 500;
}
.ctx-crumb-project:hover {
background: color-mix(in srgb, var(--color-primary) 18%, transparent);
}
.ctx-crumb-milestone {
color: var(--color-text-secondary);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
}
.meta {
font-size: 0.85rem;
color: var(--color-text-muted);
+9 -24
View File
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from "vue-router";
import { useTasksStore } from "@/stores/tasks";
import type { Task, TaskStatus } from "@/types/task";
import { apiGet } from "@/api/client";
import { useListKeyboardNavigation } from "@/composables/useListKeyboardNavigation";
import SearchBar from "@/components/SearchBar.vue";
import TaskCard from "@/components/TaskCard.vue";
import TagPill from "@/components/TagPill.vue";
@@ -16,33 +17,11 @@ const router = useRouter();
const store = useTasksStore();
const searchBarRef = ref<{ focus: () => void } | null>(null);
const activeIndex = ref(-1);
function onFocusSearch() {
searchBarRef.value?.focus();
}
function onKeydown(e: KeyboardEvent) {
if (viewMode.value !== "flat") 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 = store.tasks.length;
if (e.key === "j") {
e.preventDefault();
activeIndex.value = Math.min(activeIndex.value + 1, count - 1);
document.querySelector(".kb-active-item")?.scrollIntoView({ block: "nearest" });
} else if (e.key === "k") {
e.preventDefault();
activeIndex.value = Math.max(activeIndex.value - 1, 0);
document.querySelector(".kb-active-item")?.scrollIntoView({ block: "nearest" });
} else if (e.key === "Enter" && activeIndex.value >= 0) {
const task = store.tasks[activeIndex.value];
if (task) router.push(`/tasks/${task.id}`);
}
}
const viewMode = ref<ViewMode>(
(localStorage.getItem("fabled-tasks-view-mode") as ViewMode) ?? "flat"
);
@@ -60,6 +39,14 @@ function setViewMode(mode: ViewMode) {
store.refresh();
}
const isFlat = computed(() => viewMode.value === "flat");
const { activeIndex } = useListKeyboardNavigation(
computed(() => store.tasks),
(task) => router.push(`/tasks/${task.id}`),
".kb-active-item",
isFlat,
);
// Project map for group labels and task breadcrumbs
const projectMap = ref<Map<number, string>>(new Map());
@@ -119,12 +106,10 @@ onMounted(async () => {
}
await Promise.all([store.refresh(), loadProjects()]);
document.addEventListener("shortcut:focus-search", onFocusSearch);
document.addEventListener("keydown", onKeydown);
});
onUnmounted(() => {
document.removeEventListener("shortcut:focus-search", onFocusSearch);
document.removeEventListener("keydown", onKeydown);
});
watch(
+3
View File
@@ -16,6 +16,9 @@ class Base(DeclarativeBase):
pass
from fabledassistant.models.base import CreatedAtMixin, TimestampMixin # noqa: E402, F401
from fabledassistant.models.note import Note, TaskPriority, TaskStatus # noqa: E402, F401
from fabledassistant.models.conversation import Conversation, Message # noqa: E402, F401
from fabledassistant.models.setting import Setting # noqa: E402, F401
+21
View File
@@ -0,0 +1,21 @@
from datetime import datetime, timezone
from sqlalchemy import DateTime
from sqlalchemy.orm import Mapped, mapped_column
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
class CreatedAtMixin:
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
+4 -16
View File
@@ -1,14 +1,13 @@
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text
from sqlalchemy import ForeignKey, Index, Integer, Text
from sqlalchemy import inspect
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from fabledassistant.models import Base
from fabledassistant.models.base import CreatedAtMixin, TimestampMixin
class Conversation(Base):
class Conversation(Base, TimestampMixin):
__tablename__ = "conversations"
id: Mapped[int] = mapped_column(primary_key=True)
@@ -17,14 +16,6 @@ class Conversation(Base):
)
title: Mapped[str] = mapped_column(Text, default="")
model: Mapped[str] = mapped_column(Text, default="")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
messages: Mapped[list["Message"]] = relationship(
back_populates="conversation",
@@ -53,7 +44,7 @@ class Conversation(Base):
}
class Message(Base):
class Message(Base, CreatedAtMixin):
__tablename__ = "messages"
id: Mapped[int] = mapped_column(primary_key=True)
@@ -67,9 +58,6 @@ class Message(Base):
Integer, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
)
tool_calls: Mapped[list | None] = mapped_column(JSONB, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
conversation: Mapped["Conversation"] = relationship(back_populates="messages")
+3 -12
View File
@@ -1,12 +1,11 @@
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Integer, Text
from sqlalchemy import ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base
from fabledassistant.models.base import TimestampMixin
class Milestone(Base):
class Milestone(Base, TimestampMixin):
__tablename__ = "milestones"
id: Mapped[int] = mapped_column(primary_key=True)
@@ -16,14 +15,6 @@ class Milestone(Base):
description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(Text, default="active")
order_index: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
def to_dict(self) -> dict:
return {
+4 -11
View File
@@ -1,11 +1,12 @@
import enum
from datetime import date, datetime, timezone
from datetime import date
from sqlalchemy import Date, DateTime, ForeignKey, Index, Integer, Text
from sqlalchemy import Date, ForeignKey, Index, Integer, Text
from sqlalchemy.dialects.postgresql import ARRAY
from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base
from fabledassistant.models.base import TimestampMixin
class TaskStatus(str, enum.Enum):
@@ -21,7 +22,7 @@ class TaskPriority(str, enum.Enum):
high = "high"
class Note(Base):
class Note(Base, TimestampMixin):
__tablename__ = "notes"
id: Mapped[int] = mapped_column(primary_key=True)
@@ -43,14 +44,6 @@ class Note(Base):
status: Mapped[str | None] = mapped_column(Text, nullable=True)
priority: Mapped[str | None] = mapped_column(Text, nullable=True)
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
__table_args__ = (
Index("ix_notes_tags", "tags", postgresql_using="gin"),
+3 -12
View File
@@ -1,12 +1,11 @@
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Integer, Text
from sqlalchemy import ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base
from fabledassistant.models.base import TimestampMixin
class NoteDraft(Base):
class NoteDraft(Base, TimestampMixin):
__tablename__ = "note_drafts"
id: Mapped[int] = mapped_column(primary_key=True)
@@ -16,14 +15,6 @@ class NoteDraft(Base):
original_body: Mapped[str] = mapped_column(Text)
instruction: Mapped[str] = mapped_column(Text, default="")
scope: Mapped[str] = mapped_column(Text, default="document")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
def to_dict(self) -> dict:
return {
+3 -7
View File
@@ -1,12 +1,11 @@
from datetime import datetime, timezone
from sqlalchemy import ARRAY, DateTime, ForeignKey, Integer, Text
from sqlalchemy import ARRAY, ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base
from fabledassistant.models.base import CreatedAtMixin
class NoteVersion(Base):
class NoteVersion(Base, CreatedAtMixin):
__tablename__ = "note_versions"
id: Mapped[int] = mapped_column(primary_key=True)
@@ -15,9 +14,6 @@ class NoteVersion(Base):
body: Mapped[str] = mapped_column(Text)
title: Mapped[str] = mapped_column(Text, default="")
tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
def to_dict(self, include_body: bool = True) -> dict:
d: dict = {
+3 -5
View File
@@ -1,8 +1,8 @@
import enum
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Integer, Text
from sqlalchemy import ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base
from fabledassistant.models.base import TimestampMixin
class ProjectStatus(str, enum.Enum):
@@ -11,7 +11,7 @@ class ProjectStatus(str, enum.Enum):
archived = "archived"
class Project(Base):
class Project(Base, TimestampMixin):
__tablename__ = "projects"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True)
@@ -20,8 +20,6 @@ class Project(Base):
goal: Mapped[str] = mapped_column(Text, default="")
status: Mapped[str] = mapped_column(Text, default="active")
color: Mapped[str | None] = mapped_column(Text, nullable=True) # hex color
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
def to_dict(self) -> dict:
return {
@@ -1,10 +1,11 @@
from datetime import datetime, timezone
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base
from fabledassistant.models.base import CreatedAtMixin
class PushSubscription(Base):
class PushSubscription(Base, CreatedAtMixin):
__tablename__ = "push_subscriptions"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"))
@@ -12,7 +13,6 @@ class PushSubscription(Base):
p256dh: Mapped[str] = mapped_column(Text)
auth: Mapped[str] = mapped_column(Text)
user_agent: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
last_used: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
__table_args__ = (
+3 -12
View File
@@ -1,12 +1,11 @@
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Integer, Text
from sqlalchemy import ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base
from fabledassistant.models.base import TimestampMixin
class TaskLog(Base):
class TaskLog(Base, TimestampMixin):
__tablename__ = "task_logs"
id: Mapped[int] = mapped_column(primary_key=True)
@@ -14,14 +13,6 @@ class TaskLog(Base):
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"))
content: Mapped[str] = mapped_column(Text)
duration_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
def to_dict(self) -> dict:
return {
+3 -7
View File
@@ -1,12 +1,11 @@
from datetime import datetime, timezone
from sqlalchemy import DateTime, Index, Text
from sqlalchemy import Index, Text
from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base
from fabledassistant.models.base import CreatedAtMixin
class User(Base):
class User(Base, CreatedAtMixin):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
@@ -15,9 +14,6 @@ class User(Base):
password_hash: Mapped[str | None] = mapped_column(Text, nullable=True)
oauth_sub: Mapped[str | None] = mapped_column(Text, unique=True, nullable=True)
role: Mapped[str] = mapped_column(Text, nullable=False, default="user")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
__table_args__ = (
Index("ix_users_username", "username"),
+9 -8
View File
@@ -6,6 +6,7 @@ import httpx
from quart import Blueprint, Response, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.routes.utils import not_found
from fabledassistant.config import Config
from fabledassistant.services.chat import (
add_message,
@@ -61,7 +62,7 @@ async def get_conversation_route(conv_id: int):
uid = get_current_user_id()
conv = await get_conversation(uid, conv_id)
if conv is None:
return jsonify({"error": "Conversation not found"}), 404
return not_found("Conversation")
result = conv.to_dict()
note_ids = [m.context_note_id for m in conv.messages if m.context_note_id]
note_map = await get_notes_by_ids(uid, note_ids) if note_ids else {}
@@ -80,7 +81,7 @@ async def delete_conversation_route(conv_id: int):
uid = get_current_user_id()
deleted = await delete_conversation(uid, conv_id)
if not deleted:
return jsonify({"error": "Conversation not found"}), 404
return not_found("Conversation")
return "", 204
@@ -95,7 +96,7 @@ async def update_conversation_route(conv_id: int):
return jsonify({"error": "title or model is required"}), 400
conv = await update_conversation(uid, conv_id, title=title, model=model)
if conv is None:
return jsonify({"error": "Conversation not found"}), 404
return not_found("Conversation")
return jsonify(conv.to_dict())
@@ -106,7 +107,7 @@ async def send_message_route(conv_id: int):
uid = get_current_user_id()
conv = await get_conversation(uid, conv_id)
if conv is None:
return jsonify({"error": "Conversation not found"}), 404
return not_found("Conversation")
data = await request.get_json()
content = data.get("content", "").strip()
@@ -166,7 +167,7 @@ async def generation_stream_route(conv_id: int):
uid = get_current_user_id()
conv = await get_conversation(uid, conv_id)
if conv is None:
return jsonify({"error": "Conversation not found"}), 404
return not_found("Conversation")
buf = get_buffer(conv_id)
if buf is None:
@@ -219,7 +220,7 @@ async def confirm_generation_route(conv_id: int):
uid = get_current_user_id()
conv = await get_conversation(uid, conv_id)
if conv is None:
return jsonify({"error": "Conversation not found"}), 404
return not_found("Conversation")
buf = get_buffer(conv_id)
if buf is None or buf.state != GenerationState.RUNNING:
@@ -241,7 +242,7 @@ async def cancel_generation_route(conv_id: int):
uid = get_current_user_id()
conv = await get_conversation(uid, conv_id)
if conv is None:
return jsonify({"error": "Conversation not found"}), 404
return not_found("Conversation")
buf = get_buffer(conv_id)
if buf is None or buf.state != GenerationState.RUNNING:
@@ -268,7 +269,7 @@ async def summarize_conversation_route(conv_id: int):
uid = get_current_user_id()
conv = await get_conversation(uid, conv_id)
if conv is None:
return jsonify({"error": "Conversation not found"}), 404
return not_found("Conversation")
model = await get_setting(uid, "default_model", Config.OLLAMA_MODEL) or Config.OLLAMA_MODEL
try:
note = await summarize_conversation_as_note(uid, conv_id, model)
+19 -23
View File
@@ -4,6 +4,7 @@ import logging
from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.routes.utils import not_found
from fabledassistant.services.milestones import (
create_milestone,
delete_milestone,
@@ -20,21 +21,22 @@ logger = logging.getLogger(__name__)
milestones_bp = Blueprint("milestones", __name__, url_prefix="/api/projects")
async def _milestone_dict(m) -> dict:
d = m.to_dict()
d.update(await get_milestone_progress(m.id))
return d
@milestones_bp.route("/<int:project_id>/milestones", methods=["GET"])
@login_required
async def list_milestones_route(project_id: int):
uid = get_current_user_id()
project = await get_project(uid, project_id)
if project is None:
return jsonify({"error": "Project not found"}), 404
return not_found("Project")
status = request.args.get("status")
milestones = await list_milestones(uid, project_id, status=status)
result = []
for m in milestones:
entry = m.to_dict()
progress = await get_milestone_progress(m.id)
entry.update(progress)
result.append(entry)
result = [await _milestone_dict(m) for m in milestones]
return jsonify({"milestones": result})
@@ -44,7 +46,7 @@ async def create_milestone_route(project_id: int):
uid = get_current_user_id()
project = await get_project(uid, project_id)
if project is None:
return jsonify({"error": "Project not found"}), 404
return not_found("Project")
data = await request.get_json()
if not data.get("title"):
return jsonify({"error": "title is required"}), 400
@@ -55,9 +57,7 @@ async def create_milestone_route(project_id: int):
description=data.get("description"),
order_index=data.get("order_index", 0),
)
entry = milestone.to_dict()
entry.update(await get_milestone_progress(milestone.id))
return jsonify(entry), 201
return jsonify(await _milestone_dict(milestone)), 201
@milestones_bp.route("/<int:project_id>/milestones/<int:milestone_id>", methods=["GET"])
@@ -66,10 +66,8 @@ async def get_milestone_route(project_id: int, milestone_id: int):
uid = get_current_user_id()
milestone = await get_milestone(uid, milestone_id)
if milestone is None or milestone.project_id != project_id:
return jsonify({"error": "Milestone not found"}), 404
entry = milestone.to_dict()
entry.update(await get_milestone_progress(milestone.id))
return jsonify(entry)
return not_found("Milestone")
return jsonify(await _milestone_dict(milestone))
@milestones_bp.route("/<int:project_id>/milestones/<int:milestone_id>", methods=["PATCH"])
@@ -78,16 +76,14 @@ async def update_milestone_route(project_id: int, milestone_id: int):
uid = get_current_user_id()
milestone = await get_milestone(uid, milestone_id)
if milestone is None or milestone.project_id != project_id:
return jsonify({"error": "Milestone not found"}), 404
return not_found("Milestone")
data = await request.get_json()
allowed = {"title", "description", "status", "order_index"}
fields = {k: v for k, v in data.items() if k in allowed}
updated = await update_milestone(uid, milestone_id, **fields)
if updated is None:
return jsonify({"error": "Milestone not found"}), 404
entry = updated.to_dict()
entry.update(await get_milestone_progress(updated.id))
return jsonify(entry)
return not_found("Milestone")
return jsonify(await _milestone_dict(updated))
@milestones_bp.route("/<int:project_id>/milestones/<int:milestone_id>", methods=["DELETE"])
@@ -96,10 +92,10 @@ async def delete_milestone_route(project_id: int, milestone_id: int):
uid = get_current_user_id()
milestone = await get_milestone(uid, milestone_id)
if milestone is None or milestone.project_id != project_id:
return jsonify({"error": "Milestone not found"}), 404
return not_found("Milestone")
deleted = await delete_milestone(uid, milestone_id)
if not deleted:
return jsonify({"error": "Milestone not found"}), 404
return not_found("Milestone")
return "", 204
@@ -109,7 +105,7 @@ async def get_milestone_tasks_route(project_id: int, milestone_id: int):
uid = get_current_user_id()
milestone = await get_milestone(uid, milestone_id)
if milestone is None or milestone.project_id != project_id:
return jsonify({"error": "Milestone not found"}), 404
return not_found("Milestone")
status_filter = request.args.get("status")
limit = min(request.args.get("limit", 100, type=int), 500)
offset = request.args.get("offset", 0, type=int)
+20 -23
View File
@@ -1,13 +1,13 @@
import asyncio
import logging
import re
from datetime import date
from fabledassistant.services.embeddings import upsert_note_embedding
from quart import Blueprint, Response, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.routes.utils import not_found, parse_iso_date
from fabledassistant.config import Config
from fabledassistant.services.assist import build_assist_messages
from fabledassistant.services.generation_buffer import (
@@ -87,12 +87,9 @@ async def create_note_route():
# Optional task fields
status = data.get("status")
priority = data.get("priority")
due_date = None
if data.get("due_date"):
try:
due_date = date.fromisoformat(data["due_date"])
except ValueError:
return jsonify({"error": "Invalid date format. Use YYYY-MM-DD."}), 400
due_date = parse_iso_date(data.get("due_date"), "due_date")
if isinstance(due_date, tuple):
return due_date
note = await create_note(
uid,
@@ -144,7 +141,7 @@ async def append_tag_route(note_id: int):
note = await get_note(uid, note_id)
if note is None:
return jsonify({"error": "Note not found"}), 404
return not_found("Note")
existing = list(note.tags or [])
if tag not in existing:
@@ -162,7 +159,7 @@ async def get_note_by_title_route():
return jsonify({"error": "title parameter is required"}), 400
note = await get_note_by_title(uid, title)
if note is None:
return jsonify({"error": "Note not found"}), 404
return not_found("Note")
return jsonify(note.to_dict())
@@ -184,7 +181,7 @@ async def get_note_route(note_id: int):
uid = get_current_user_id()
note = await get_note(uid, note_id)
if note is None:
return jsonify({"error": "Note not found"}), 404
return not_found("Note")
return jsonify(note.to_dict())
@@ -200,10 +197,10 @@ async def update_note_route(note_id: int):
if "due_date" in data:
if data["due_date"]:
try:
fields["due_date"] = date.fromisoformat(data["due_date"])
except ValueError:
return jsonify({"error": "Invalid date format. Use YYYY-MM-DD."}), 400
result = parse_iso_date(data["due_date"], "due_date")
if isinstance(result, tuple):
return result
fields["due_date"] = result
else:
fields["due_date"] = None
@@ -211,7 +208,7 @@ async def update_note_route(note_id: int):
fields["tags"] = data["tags"]
note = await update_note(uid, note_id, **fields)
if note is None:
return jsonify({"error": "Note not found"}), 404
return not_found("Note")
text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "")
if text:
asyncio.create_task(upsert_note_embedding(note.id, uid, text))
@@ -228,15 +225,15 @@ async def patch_note_route(note_id: int):
if key in data:
fields[key] = data[key]
if "due_date" in data:
try:
fields["due_date"] = date.fromisoformat(data["due_date"]) if data["due_date"] else None
except (ValueError, TypeError):
return jsonify({"error": "Invalid due_date format, expected YYYY-MM-DD"}), 400
result = parse_iso_date(data.get("due_date"), "due_date")
if isinstance(result, tuple):
return result
fields["due_date"] = result
if "tags" in data:
fields["tags"] = data["tags"]
note = await update_note(uid, note_id, **fields)
if note is None:
return jsonify({"error": "Note not found"}), 404
return not_found("Note")
return jsonify(note.to_dict())
@@ -246,7 +243,7 @@ async def delete_note_route(note_id: int):
uid = get_current_user_id()
deleted = await delete_note(uid, note_id)
if not deleted:
return jsonify({"error": "Note not found"}), 404
return not_found("Note")
return "", 204
@@ -474,7 +471,7 @@ async def upsert_draft_route(note_id: int):
# Verify note ownership
note = await get_note(uid, note_id)
if note is None:
return jsonify({"error": "Note not found"}), 404
return not_found("Note")
data = await request.get_json()
draft = await upsert_draft(
user_id=uid,
@@ -511,5 +508,5 @@ async def get_version_route(note_id: int, version_id: int):
uid = get_current_user_id()
version = await get_version(uid, note_id, version_id)
if version is None:
return jsonify({"error": "Version not found"}), 404
return not_found("Version")
return jsonify(version.to_dict(include_body=True))
+5 -4
View File
@@ -4,6 +4,7 @@ import logging
from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.routes.utils import not_found
from fabledassistant.services.milestones import list_milestones
from fabledassistant.services.notes import list_notes
from fabledassistant.services.projects import (
@@ -52,7 +53,7 @@ async def get_project_route(project_id: int):
uid = get_current_user_id()
project = await get_project(uid, project_id)
if project is None:
return jsonify({"error": "Project not found"}), 404
return not_found("Project")
summary = await get_project_summary(uid, project_id)
data = project.to_dict()
data["summary"] = summary
@@ -68,7 +69,7 @@ async def update_project_route(project_id: int):
fields = {k: v for k, v in data.items() if k in allowed}
project = await update_project(uid, project_id, **fields)
if project is None:
return jsonify({"error": "Project not found"}), 404
return not_found("Project")
return jsonify(project.to_dict())
@@ -78,7 +79,7 @@ async def delete_project_route(project_id: int):
uid = get_current_user_id()
deleted = await delete_project(uid, project_id)
if not deleted:
return jsonify({"error": "Project not found"}), 404
return not_found("Project")
return "", 204
@@ -88,7 +89,7 @@ async def get_project_notes_route(project_id: int):
uid = get_current_user_id()
project = await get_project(uid, project_id)
if project is None:
return jsonify({"error": "Project not found"}), 404
return not_found("Project")
# type filter: "note", "task", or None (both)
type_filter = request.args.get("type")
+18 -23
View File
@@ -1,9 +1,8 @@
from datetime import date
from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.models.note import TaskPriority, TaskStatus
from fabledassistant.routes.utils import not_found, parse_iso_date
from fabledassistant.services.notes import (
create_note,
delete_note,
@@ -28,13 +27,12 @@ async def list_tasks_route():
limit = min(request.args.get("limit", 50, type=int), 500)
offset = request.args.get("offset", 0, type=int)
due_before_str = request.args.get("due_before")
due_after_str = request.args.get("due_after")
try:
due_before = date.fromisoformat(due_before_str) if due_before_str else None
due_after = date.fromisoformat(due_after_str) if due_after_str else None
except ValueError:
return jsonify({"error": "Invalid date format. Use YYYY-MM-DD."}), 400
due_before = parse_iso_date(request.args.get("due_before"), "due_before")
if isinstance(due_before, tuple):
return due_before
due_after = parse_iso_date(request.args.get("due_after"), "due_after")
if isinstance(due_after, tuple):
return due_after
tasks, total = await list_notes(
uid,
@@ -61,12 +59,9 @@ async def create_task_route():
body = data.get("body", "") or data.get("description", "")
tags = data.get("tags", [])
due_date = None
if data.get("due_date"):
try:
due_date = date.fromisoformat(data["due_date"])
except ValueError:
return jsonify({"error": "Invalid date format. Use YYYY-MM-DD."}), 400
due_date = parse_iso_date(data.get("due_date"), "due_date")
if isinstance(due_date, tuple):
return due_date
status = TaskStatus(data["status"]).value if "status" in data else TaskStatus.todo.value
priority = (
@@ -94,7 +89,7 @@ async def get_task_route(task_id: int):
uid = get_current_user_id()
task = await get_note(uid, task_id)
if task is None:
return jsonify({"error": "Task not found"}), 404
return not_found("Task")
data = task.to_dict()
if task.parent_id:
parent = await get_note(uid, task.parent_id)
@@ -120,10 +115,10 @@ async def update_task_route(task_id: int):
if "due_date" in data:
if data["due_date"]:
try:
fields["due_date"] = date.fromisoformat(data["due_date"])
except ValueError:
return jsonify({"error": "Invalid date format. Use YYYY-MM-DD."}), 400
result = parse_iso_date(data["due_date"], "due_date")
if isinstance(result, tuple):
return result
fields["due_date"] = result
else:
fields["due_date"] = None
@@ -136,7 +131,7 @@ async def update_task_route(task_id: int):
task = await update_note(uid, task_id, **fields)
if task is None:
return jsonify({"error": "Task not found"}), 404
return not_found("Task")
return jsonify(task.to_dict())
@@ -156,7 +151,7 @@ async def patch_task_status(task_id: int):
task = await update_note(uid, task_id, status=status_val)
if task is None:
return jsonify({"error": "Task not found"}), 404
return not_found("Task")
return jsonify(task.to_dict())
@@ -166,5 +161,5 @@ async def delete_task_route(task_id: int):
uid = get_current_user_id()
deleted = await delete_note(uid, task_id)
if not deleted:
return jsonify({"error": "Task not found"}), 404
return not_found("Task")
return "", 204
+17
View File
@@ -0,0 +1,17 @@
from datetime import date
from quart import jsonify
def not_found(resource: str = "Item"):
return jsonify({"error": f"{resource} not found"}), 404
def parse_iso_date(value: str | None, field: str = "date"):
"""Parse an ISO date string. Returns a date, None, or a (response, 400) tuple."""
if not value:
return None
try:
return date.fromisoformat(value)
except ValueError:
return jsonify({"error": f"Invalid {field} format. Use YYYY-MM-DD."}), 400
+24
View File
@@ -12,6 +12,30 @@
> Include file-level details in the commit body when the change is non-trivial.
## Last Updated
2026-03-06 — DRY refactoring pass: TimestampMixin, route helpers, shared composables, ConfirmDialog, shared CSS.
**DRY refactoring pass (backend):**
- `src/fabledassistant/models/base.py` (new): `TimestampMixin` (`created_at` + `updated_at`) and `CreatedAtMixin` (`created_at` only) — SQLAlchemy 2.0 `Mapped[]` mixins. Applied to all 10+ models (`Note`, `Project`, `Milestone`, `Conversation`, `Message`, `User`, `PushSubscription`, `TaskLog`, `NoteDraft`, `NoteVersion`), removing ~60 lines of duplicated column definitions.
- `src/fabledassistant/routes/utils.py` (new): `not_found(resource)``(Response, 404)` and `parse_iso_date(value, field)``date | None | (Response, 400)`. Used across `notes.py`, `tasks.py`, `projects.py`, `milestones.py`, `chat.py`, replacing ~18 inline 404 blocks and ~4 duplicate date-parsing try/except blocks.
- `routes/milestones.py`: `_milestone_dict(m)` local async helper replaces 5 repetitions of `entry = m.to_dict(); entry.update(await get_milestone_progress(m.id))`.
**DRY refactoring pass (frontend):**
- `frontend/src/composables/useAutoSave.ts` (new): `useAutoSave(dirty, saving, saveFn, intervalMs?)` — interval-based autosave with `onMounted`/`onUnmounted` lifecycle, guards on dirty+saving state.
- `frontend/src/composables/useEditorGuards.ts` (new): `useEditorGuards(dirty, saveFn)` — registers Ctrl+S handler, `beforeunload` warning, and `onBeforeRouteLeave` confirm guard.
- `frontend/src/composables/useTagSuggestions.ts` (new): `useTagSuggestions(title, body, tags, markDirty)` — encapsulates `/api/notes/suggest-tags` call and suggestion state.
- `frontend/src/composables/useFloatingAssist.ts` (new): `useFloatingAssist(onRequest)` — floating selection-based assist button positioning.
- `frontend/src/composables/useListKeyboardNavigation.ts` (new): `useListKeyboardNavigation<T>(items, navigateTo, rowSelector?, enabled?)` — j/k/Enter navigation with focus-in-input guard and optional `enabled` ref.
- `frontend/src/components/ConfirmDialog.vue` (new): reusable confirm modal. Props: `title`, `message`, `confirmLabel` (default "Delete"), `danger` (default true). Emits `confirm`/`cancel`. Uses `<teleport to="body">` internally.
- `frontend/src/assets/editor-shared.css`: added shared sidebar CSS (`.sidebar-toggle`, `.sidebar-content`, `.sb-field`, `.sb-label`, `.sb-select`, `.sb-input`, `.sb-divider` + mobile collapse rules) used by both editor views.
- `frontend/src/assets/viewer-shared.css` (new): shared context-breadcrumb CSS (`.context-bar`, `.ctx-crumb*`) imported by `NoteViewerView` and `TaskViewerView`.
- `NoteEditorView.vue`, `TaskEditorView.vue`: replaced ~120 lines each of duplicated floating-assist, tag-suggestion, autosave, and route-guard logic with composable calls; replaced inline `<teleport>` delete modal with `<ConfirmDialog>`; removed ~80 lines of duplicated scoped sidebar CSS.
- `NotesListView.vue`, `TasksListView.vue`: replaced inline `activeIndex`/`onKeydown`/`scrollActiveIntoView` keyboard-nav (~25 lines each) with `useListKeyboardNavigation`. TasksListView passes `isFlat` computed ref as `enabled` guard.
- `NoteViewerView.vue`, `TaskViewerView.vue`: removed ~40 lines of duplicate `.context-bar`/`.ctx-crumb*` scoped CSS; added `<style src="@/assets/viewer-shared.css" />`.
- No behaviour changes. Net: 634 lines, +237 lines across 31 files.
---
## Previous Session
2026-03-06 — Project-aware writing assist, link suggestions, project-scoped RAG, semantic note search tool, SSE done-event race fix, RAG snippet increase, sidebar include full body, Enter-to-submit assist.
**SSE done-event race fix:** `routes/notes.py` and `routes/chat.py` — The SSE stream generator had a race condition where `yield buf.format_sse(event)` hands control back to the event loop, allowing the generation task to set `buf.state = COMPLETED` and append the `done` event between the last yield and the state check. The loop would then break without delivering the `done` event (containing `full_text`). Fixed by adding a final drain loop after the `while` exits in both stream generators. Added INFO-level logging to `run_assist_generation` showing `input_chars`, `output_chars`, event count, and done-event index.