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:
@@ -532,6 +532,70 @@
|
|||||||
pointer-events: auto;
|
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 ── */
|
/* ── Mobile ── */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.editor-body {
|
.editor-body {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted, computed, nextTick, watch } from "vue";
|
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 { useNotesStore } from "@/stores/notes";
|
||||||
import { useToastStore } from "@/stores/toast";
|
import { useToastStore } from "@/stores/toast";
|
||||||
import { renderMarkdown } from "@/utils/markdown";
|
import { renderMarkdown } from "@/utils/markdown";
|
||||||
import { useAssist, type NoteDraft } from "@/composables/useAssist";
|
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 type { Editor } from "@tiptap/vue-3";
|
||||||
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
|
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
|
||||||
import TiptapEditor from "@/components/TiptapEditor.vue";
|
import TiptapEditor from "@/components/TiptapEditor.vue";
|
||||||
@@ -14,6 +18,7 @@ import ProjectSelector from "@/components/ProjectSelector.vue";
|
|||||||
import MilestoneSelector from "@/components/MilestoneSelector.vue";
|
import MilestoneSelector from "@/components/MilestoneSelector.vue";
|
||||||
import DiffView from "@/components/DiffView.vue";
|
import DiffView from "@/components/DiffView.vue";
|
||||||
import HistoryPanel from "@/components/HistoryPanel.vue";
|
import HistoryPanel from "@/components/HistoryPanel.vue";
|
||||||
|
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -84,31 +89,14 @@ const scopeSelectValue = computed({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Floating inline assist button
|
// 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);
|
const instructionRef = ref<HTMLTextAreaElement | null>(null);
|
||||||
|
const { floatingAssist, onSelectionChange, handleInlineAssist } = useFloatingAssist(
|
||||||
function onSelectionChange(payload: { text: string; start: number; end: number }) {
|
({ start, end }) => {
|
||||||
if (payload.text.trim().length > 0) {
|
assist.scopeMode.value = 'section';
|
||||||
const sel = window.getSelection();
|
assist.selectTextRange(start, end);
|
||||||
if (sel && sel.rangeCount > 0) {
|
nextTick(() => instructionRef.value?.focus());
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
);
|
||||||
|
|
||||||
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() {
|
function handleAssistAccept() {
|
||||||
const newBody = assist.accept();
|
const newBody = assist.accept();
|
||||||
@@ -118,42 +106,8 @@ function handleAssistAccept() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Tag suggestions
|
// Tag suggestions
|
||||||
const suggestedTags = ref<string[]>([]);
|
const { suggestedTags, appliedTags, suggestingTags, fetchTagSuggestions, applyTagSuggestion, dismissTagSuggestions } =
|
||||||
const appliedTags = ref<Set<string>>(new Set());
|
useTagSuggestions(title, body, tags, markDirty);
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Link Suggestions ────────────────────────────────────────────────────────
|
// ── Link Suggestions ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -331,10 +285,8 @@ async function confirmDelete() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Auto-save every 5 minutes
|
// Auto-save every 5 minutes
|
||||||
let autoSaveTimer: ReturnType<typeof setInterval> | null = null;
|
async function doAutoSave() {
|
||||||
|
if (!isEditing.value || saving.value) return;
|
||||||
async function autoSave() {
|
|
||||||
if (!isEditing.value || !dirty.value || saving.value) return;
|
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
try {
|
try {
|
||||||
await store.updateNote(noteId.value!, {
|
await store.updateNote(noteId.value!, {
|
||||||
@@ -354,29 +306,8 @@ async function autoSave() {
|
|||||||
saving.value = false;
|
saving.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
useAutoSave(dirty, saving, doAutoSave);
|
||||||
onMounted(() => { autoSaveTimer = setInterval(autoSave, 5 * 60 * 1000); });
|
useEditorGuards(dirty, save);
|
||||||
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));
|
|
||||||
|
|
||||||
// Close any in-flight assist SSE stream when navigating away
|
// Close any in-flight assist SSE stream when navigating away
|
||||||
onUnmounted(() => assist.clearSelection());
|
onUnmounted(() => assist.clearSelection());
|
||||||
@@ -595,18 +526,13 @@ onUnmounted(() => assist.clearSelection());
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Delete confirmation -->
|
<!-- Delete confirmation -->
|
||||||
<teleport to="body">
|
<ConfirmDialog
|
||||||
<div v-if="showDeleteConfirm" class="modal-overlay" @click="showDeleteConfirm = false">
|
v-if="showDeleteConfirm"
|
||||||
<div class="modal-card" @click.stop>
|
title="Delete Note"
|
||||||
<h3 class="modal-title">Delete Note</h3>
|
message="Are you sure you want to delete this note? This cannot be undone."
|
||||||
<p class="modal-message">Are you sure you want to delete this note? This cannot be undone.</p>
|
@confirm="confirmDelete"
|
||||||
<div class="modal-actions">
|
@cancel="showDeleteConfirm = false"
|
||||||
<button class="modal-btn" @click="showDeleteConfirm = false">Cancel</button>
|
/>
|
||||||
<button class="modal-btn modal-btn-danger" @click="confirmDelete">Delete</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</teleport>
|
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -675,61 +601,6 @@ onUnmounted(() => assist.clearSelection());
|
|||||||
flex-direction: column;
|
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 inside sidebar */
|
||||||
.tag-suggest-row {
|
.tag-suggest-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -841,11 +712,5 @@ onUnmounted(() => assist.clearSelection());
|
|||||||
border-top: 1px solid var(--color-border);
|
border-top: 1px solid var(--color-border);
|
||||||
overflow-y: visible;
|
overflow-y: visible;
|
||||||
}
|
}
|
||||||
.sidebar-toggle { display: block; }
|
|
||||||
.sidebar-content {
|
|
||||||
display: none;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
}
|
|
||||||
.sidebar-content.sidebar-open { display: flex; }
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -229,6 +229,7 @@ async function convertToTask() {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style src="@/assets/viewer-shared.css" />
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.viewer-layout {
|
.viewer-layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -294,48 +295,6 @@ async function convertToTask() {
|
|||||||
cursor: default;
|
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 {
|
.meta {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<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 { useRoute, useRouter } from "vue-router";
|
||||||
import { useNotesStore } from "@/stores/notes";
|
import { useNotesStore } from "@/stores/notes";
|
||||||
|
import { useListKeyboardNavigation } from "@/composables/useListKeyboardNavigation";
|
||||||
import SearchBar from "@/components/SearchBar.vue";
|
import SearchBar from "@/components/SearchBar.vue";
|
||||||
import NoteCard from "@/components/NoteCard.vue";
|
import NoteCard from "@/components/NoteCard.vue";
|
||||||
import TagPill from "@/components/TagPill.vue";
|
import TagPill from "@/components/TagPill.vue";
|
||||||
@@ -14,36 +15,15 @@ const router = useRouter();
|
|||||||
const store = useNotesStore();
|
const store = useNotesStore();
|
||||||
|
|
||||||
const searchBarRef = ref<{ focus: () => void } | null>(null);
|
const searchBarRef = ref<{ focus: () => void } | null>(null);
|
||||||
const activeIndex = ref(-1);
|
|
||||||
|
|
||||||
function onFocusSearch() {
|
function onFocusSearch() {
|
||||||
searchBarRef.value?.focus();
|
searchBarRef.value?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKeydown(e: KeyboardEvent) {
|
const { activeIndex } = useListKeyboardNavigation(
|
||||||
const el = document.activeElement;
|
computed(() => store.notes),
|
||||||
const tag = el ? (el as HTMLElement).tagName : "";
|
(note) => router.push(`/notes/${note.id}`),
|
||||||
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 viewMode = ref<ViewMode>(
|
const viewMode = ref<ViewMode>(
|
||||||
(localStorage.getItem("fabled-notes-view-mode") as ViewMode) ?? "grid"
|
(localStorage.getItem("fabled-notes-view-mode") as ViewMode) ?? "grid"
|
||||||
@@ -63,12 +43,10 @@ onMounted(() => {
|
|||||||
store.refresh();
|
store.refresh();
|
||||||
}
|
}
|
||||||
document.addEventListener("shortcut:focus-search", onFocusSearch);
|
document.addEventListener("shortcut:focus-search", onFocusSearch);
|
||||||
document.addEventListener("keydown", onKeydown);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
document.removeEventListener("shortcut:focus-search", onFocusSearch);
|
document.removeEventListener("shortcut:focus-search", onFocusSearch);
|
||||||
document.removeEventListener("keydown", onKeydown);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted, computed, nextTick } from "vue";
|
import { ref, onMounted, computed, nextTick } from "vue";
|
||||||
import { useRoute, useRouter, onBeforeRouteLeave } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { useTasksStore } from "@/stores/tasks";
|
import { useTasksStore } from "@/stores/tasks";
|
||||||
import { useNotesStore } from "@/stores/notes";
|
import { useNotesStore } from "@/stores/notes";
|
||||||
import { useToastStore } from "@/stores/toast";
|
import { useToastStore } from "@/stores/toast";
|
||||||
import { renderMarkdown } from "@/utils/markdown";
|
import { renderMarkdown } from "@/utils/markdown";
|
||||||
import { useAssist } from "@/composables/useAssist";
|
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 { apiPost, apiGet, apiPatch } from "@/api/client";
|
||||||
import type { TaskStatus, TaskPriority } from "@/types/task";
|
import type { TaskStatus, TaskPriority } from "@/types/task";
|
||||||
import type { Editor } from "@tiptap/vue-3";
|
import type { Editor } from "@tiptap/vue-3";
|
||||||
@@ -16,6 +20,7 @@ import ProjectSelector from "@/components/ProjectSelector.vue";
|
|||||||
import MilestoneSelector from "@/components/MilestoneSelector.vue";
|
import MilestoneSelector from "@/components/MilestoneSelector.vue";
|
||||||
import TaskLogSection from "@/components/TaskLogSection.vue";
|
import TaskLogSection from "@/components/TaskLogSection.vue";
|
||||||
import InlineAssistPanel from "@/components/InlineAssistPanel.vue";
|
import InlineAssistPanel from "@/components/InlineAssistPanel.vue";
|
||||||
|
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -126,32 +131,15 @@ function toggleAssist() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Floating inline assist button
|
// 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);
|
const instructionRef = ref<HTMLTextAreaElement | null>(null);
|
||||||
|
const { floatingAssist, onSelectionChange, handleInlineAssist } = useFloatingAssist(
|
||||||
function onSelectionChange(payload: { text: string; start: number; end: number }) {
|
({ start, end }) => {
|
||||||
if (payload.text.trim().length > 0) {
|
assist.selectTextRange(start, end);
|
||||||
const sel = window.getSelection();
|
assistOpen.value = true;
|
||||||
if (sel && sel.rangeCount > 0) {
|
localStorage.setItem(ASSIST_KEY, 'true');
|
||||||
const rect = sel.getRangeAt(0).getBoundingClientRect();
|
nextTick(() => instructionRef.value?.focus());
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
);
|
||||||
|
|
||||||
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() {
|
function handleAssistAccept() {
|
||||||
const newBody = assist.accept();
|
const newBody = assist.accept();
|
||||||
@@ -168,42 +156,8 @@ function truncateTarget(text: string, max = 60): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Tag suggestions
|
// Tag suggestions
|
||||||
const suggestedTags = ref<string[]>([]);
|
const { suggestedTags, appliedTags, suggestingTags, fetchTagSuggestions, applyTagSuggestion, dismissTagSuggestions } =
|
||||||
const appliedTags = ref<Set<string>>(new Set());
|
useTagSuggestions(title, body, tags, markDirty);
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
let savedTitle = "";
|
let savedTitle = "";
|
||||||
let savedBody = "";
|
let savedBody = "";
|
||||||
@@ -383,10 +337,8 @@ async function confirmDelete() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Auto-save every 5 minutes when editing an existing task
|
// Auto-save every 5 minutes when editing an existing task
|
||||||
let autoSaveTimer: ReturnType<typeof setInterval> | null = null;
|
async function doAutoSave() {
|
||||||
|
if (!isEditing.value || saving.value) return;
|
||||||
async function autoSave() {
|
|
||||||
if (!isEditing.value || !dirty.value || saving.value) return;
|
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
try {
|
try {
|
||||||
await store.updateTask(taskId.value!, {
|
await store.updateTask(taskId.value!, {
|
||||||
@@ -417,33 +369,8 @@ async function autoSave() {
|
|||||||
saving.value = false;
|
saving.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
useAutoSave(dirty, saving, doAutoSave);
|
||||||
onMounted(() => { autoSaveTimer = setInterval(autoSave, 5 * 60 * 1000); });
|
useEditorGuards(dirty, save);
|
||||||
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));
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -710,18 +637,13 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
|||||||
</teleport>
|
</teleport>
|
||||||
|
|
||||||
<!-- Delete confirmation -->
|
<!-- Delete confirmation -->
|
||||||
<teleport to="body">
|
<ConfirmDialog
|
||||||
<div v-if="showDeleteConfirm" class="modal-overlay" @click="showDeleteConfirm = false">
|
v-if="showDeleteConfirm"
|
||||||
<div class="modal-card" @click.stop>
|
title="Delete Task"
|
||||||
<h3 class="modal-title">Delete Task</h3>
|
message="Are you sure you want to delete this task? This cannot be undone."
|
||||||
<p class="modal-message">Are you sure you want to delete this task? This cannot be undone.</p>
|
@confirm="confirmDelete"
|
||||||
<div class="modal-actions">
|
@cancel="showDeleteConfirm = false"
|
||||||
<button class="modal-btn" @click="showDeleteConfirm = false">Cancel</button>
|
/>
|
||||||
<button class="modal-btn modal-btn-danger" @click="confirmDelete">Delete</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</teleport>
|
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -777,66 +699,6 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
|||||||
flex-direction: column;
|
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 task search */
|
||||||
.parent-search-wrapper { position: relative; }
|
.parent-search-wrapper { position: relative; }
|
||||||
.parent-input-row { display: flex; align-items: center; gap: 0.25rem; }
|
.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);
|
border-top: 1px solid var(--color-border);
|
||||||
overflow-y: visible;
|
overflow-y: visible;
|
||||||
}
|
}
|
||||||
.sidebar-toggle { display: block; }
|
|
||||||
.sidebar-content {
|
|
||||||
display: none;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
}
|
|
||||||
.sidebar-content.sidebar-open { display: flex; }
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -315,6 +315,7 @@ const subTaskProgress = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style src="@/assets/viewer-shared.css" />
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.viewer-layout {
|
.viewer-layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -380,48 +381,6 @@ const subTaskProgress = computed(() => {
|
|||||||
cursor: default;
|
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 {
|
.meta {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from "vue-router";
|
|||||||
import { useTasksStore } from "@/stores/tasks";
|
import { useTasksStore } from "@/stores/tasks";
|
||||||
import type { Task, TaskStatus } from "@/types/task";
|
import type { Task, TaskStatus } from "@/types/task";
|
||||||
import { apiGet } from "@/api/client";
|
import { apiGet } from "@/api/client";
|
||||||
|
import { useListKeyboardNavigation } from "@/composables/useListKeyboardNavigation";
|
||||||
import SearchBar from "@/components/SearchBar.vue";
|
import SearchBar from "@/components/SearchBar.vue";
|
||||||
import TaskCard from "@/components/TaskCard.vue";
|
import TaskCard from "@/components/TaskCard.vue";
|
||||||
import TagPill from "@/components/TagPill.vue";
|
import TagPill from "@/components/TagPill.vue";
|
||||||
@@ -16,33 +17,11 @@ const router = useRouter();
|
|||||||
const store = useTasksStore();
|
const store = useTasksStore();
|
||||||
|
|
||||||
const searchBarRef = ref<{ focus: () => void } | null>(null);
|
const searchBarRef = ref<{ focus: () => void } | null>(null);
|
||||||
const activeIndex = ref(-1);
|
|
||||||
|
|
||||||
function onFocusSearch() {
|
function onFocusSearch() {
|
||||||
searchBarRef.value?.focus();
|
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>(
|
const viewMode = ref<ViewMode>(
|
||||||
(localStorage.getItem("fabled-tasks-view-mode") as ViewMode) ?? "flat"
|
(localStorage.getItem("fabled-tasks-view-mode") as ViewMode) ?? "flat"
|
||||||
);
|
);
|
||||||
@@ -60,6 +39,14 @@ function setViewMode(mode: ViewMode) {
|
|||||||
store.refresh();
|
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
|
// Project map for group labels and task breadcrumbs
|
||||||
const projectMap = ref<Map<number, string>>(new Map());
|
const projectMap = ref<Map<number, string>>(new Map());
|
||||||
|
|
||||||
@@ -119,12 +106,10 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
await Promise.all([store.refresh(), loadProjects()]);
|
await Promise.all([store.refresh(), loadProjects()]);
|
||||||
document.addEventListener("shortcut:focus-search", onFocusSearch);
|
document.addEventListener("shortcut:focus-search", onFocusSearch);
|
||||||
document.addEventListener("keydown", onKeydown);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
document.removeEventListener("shortcut:focus-search", onFocusSearch);
|
document.removeEventListener("shortcut:focus-search", onFocusSearch);
|
||||||
document.removeEventListener("keydown", onKeydown);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ class Base(DeclarativeBase):
|
|||||||
pass
|
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.note import Note, TaskPriority, TaskStatus # noqa: E402, F401
|
||||||
from fabledassistant.models.conversation import Conversation, Message # noqa: E402, F401
|
from fabledassistant.models.conversation import Conversation, Message # noqa: E402, F401
|
||||||
from fabledassistant.models.setting import Setting # noqa: E402, F401
|
from fabledassistant.models.setting import Setting # noqa: E402, F401
|
||||||
|
|||||||
@@ -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)
|
||||||
|
)
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
from datetime import datetime, timezone
|
from sqlalchemy import ForeignKey, Index, Integer, Text
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text
|
|
||||||
from sqlalchemy import inspect
|
from sqlalchemy import inspect
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from fabledassistant.models import Base
|
from fabledassistant.models import Base
|
||||||
|
from fabledassistant.models.base import CreatedAtMixin, TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
class Conversation(Base):
|
class Conversation(Base, TimestampMixin):
|
||||||
__tablename__ = "conversations"
|
__tablename__ = "conversations"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
@@ -17,14 +16,6 @@ class Conversation(Base):
|
|||||||
)
|
)
|
||||||
title: Mapped[str] = mapped_column(Text, default="")
|
title: Mapped[str] = mapped_column(Text, default="")
|
||||||
model: 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(
|
messages: Mapped[list["Message"]] = relationship(
|
||||||
back_populates="conversation",
|
back_populates="conversation",
|
||||||
@@ -53,7 +44,7 @@ class Conversation(Base):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class Message(Base):
|
class Message(Base, CreatedAtMixin):
|
||||||
__tablename__ = "messages"
|
__tablename__ = "messages"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
@@ -67,9 +58,6 @@ class Message(Base):
|
|||||||
Integer, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
|
Integer, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
|
||||||
)
|
)
|
||||||
tool_calls: Mapped[list | None] = mapped_column(JSONB, 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")
|
conversation: Mapped["Conversation"] = relationship(back_populates="messages")
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
from datetime import datetime, timezone
|
from sqlalchemy import ForeignKey, Integer, Text
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, Integer, Text
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from fabledassistant.models import Base
|
from fabledassistant.models import Base
|
||||||
|
from fabledassistant.models.base import TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
class Milestone(Base):
|
class Milestone(Base, TimestampMixin):
|
||||||
__tablename__ = "milestones"
|
__tablename__ = "milestones"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
@@ -16,14 +15,6 @@ class Milestone(Base):
|
|||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
status: Mapped[str] = mapped_column(Text, default="active")
|
status: Mapped[str] = mapped_column(Text, default="active")
|
||||||
order_index: Mapped[int] = mapped_column(Integer, default=0)
|
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:
|
def to_dict(self) -> dict:
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import enum
|
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.dialects.postgresql import ARRAY
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from fabledassistant.models import Base
|
from fabledassistant.models import Base
|
||||||
|
from fabledassistant.models.base import TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
class TaskStatus(str, enum.Enum):
|
class TaskStatus(str, enum.Enum):
|
||||||
@@ -21,7 +22,7 @@ class TaskPriority(str, enum.Enum):
|
|||||||
high = "high"
|
high = "high"
|
||||||
|
|
||||||
|
|
||||||
class Note(Base):
|
class Note(Base, TimestampMixin):
|
||||||
__tablename__ = "notes"
|
__tablename__ = "notes"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
@@ -43,14 +44,6 @@ class Note(Base):
|
|||||||
status: Mapped[str | None] = mapped_column(Text, nullable=True)
|
status: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
priority: 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)
|
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__ = (
|
__table_args__ = (
|
||||||
Index("ix_notes_tags", "tags", postgresql_using="gin"),
|
Index("ix_notes_tags", "tags", postgresql_using="gin"),
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
from datetime import datetime, timezone
|
from sqlalchemy import ForeignKey, Integer, Text
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, Integer, Text
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from fabledassistant.models import Base
|
from fabledassistant.models import Base
|
||||||
|
from fabledassistant.models.base import TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
class NoteDraft(Base):
|
class NoteDraft(Base, TimestampMixin):
|
||||||
__tablename__ = "note_drafts"
|
__tablename__ = "note_drafts"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
@@ -16,14 +15,6 @@ class NoteDraft(Base):
|
|||||||
original_body: Mapped[str] = mapped_column(Text)
|
original_body: Mapped[str] = mapped_column(Text)
|
||||||
instruction: Mapped[str] = mapped_column(Text, default="")
|
instruction: Mapped[str] = mapped_column(Text, default="")
|
||||||
scope: Mapped[str] = mapped_column(Text, default="document")
|
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:
|
def to_dict(self) -> dict:
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
from datetime import datetime, timezone
|
from sqlalchemy import ARRAY, ForeignKey, Integer, Text
|
||||||
|
|
||||||
from sqlalchemy import ARRAY, DateTime, ForeignKey, Integer, Text
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from fabledassistant.models import Base
|
from fabledassistant.models import Base
|
||||||
|
from fabledassistant.models.base import CreatedAtMixin
|
||||||
|
|
||||||
|
|
||||||
class NoteVersion(Base):
|
class NoteVersion(Base, CreatedAtMixin):
|
||||||
__tablename__ = "note_versions"
|
__tablename__ = "note_versions"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
@@ -15,9 +14,6 @@ class NoteVersion(Base):
|
|||||||
body: Mapped[str] = mapped_column(Text)
|
body: Mapped[str] = mapped_column(Text)
|
||||||
title: Mapped[str] = mapped_column(Text, default="")
|
title: Mapped[str] = mapped_column(Text, default="")
|
||||||
tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list)
|
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:
|
def to_dict(self, include_body: bool = True) -> dict:
|
||||||
d: dict = {
|
d: dict = {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import enum
|
import enum
|
||||||
from datetime import datetime, timezone
|
from sqlalchemy import ForeignKey, Integer, Text
|
||||||
from sqlalchemy import DateTime, ForeignKey, Integer, Text
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
from fabledassistant.models import Base
|
from fabledassistant.models import Base
|
||||||
|
from fabledassistant.models.base import TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
class ProjectStatus(str, enum.Enum):
|
class ProjectStatus(str, enum.Enum):
|
||||||
@@ -11,7 +11,7 @@ class ProjectStatus(str, enum.Enum):
|
|||||||
archived = "archived"
|
archived = "archived"
|
||||||
|
|
||||||
|
|
||||||
class Project(Base):
|
class Project(Base, TimestampMixin):
|
||||||
__tablename__ = "projects"
|
__tablename__ = "projects"
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
user_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=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="")
|
goal: Mapped[str] = mapped_column(Text, default="")
|
||||||
status: Mapped[str] = mapped_column(Text, default="active")
|
status: Mapped[str] = mapped_column(Text, default="active")
|
||||||
color: Mapped[str | None] = mapped_column(Text, nullable=True) # hex color
|
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:
|
def to_dict(self) -> dict:
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime
|
||||||
from sqlalchemy import DateTime, ForeignKey, Integer, Text, UniqueConstraint
|
from sqlalchemy import DateTime, ForeignKey, Integer, Text, UniqueConstraint
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
from fabledassistant.models import Base
|
from fabledassistant.models import Base
|
||||||
|
from fabledassistant.models.base import CreatedAtMixin
|
||||||
|
|
||||||
|
|
||||||
class PushSubscription(Base):
|
class PushSubscription(Base, CreatedAtMixin):
|
||||||
__tablename__ = "push_subscriptions"
|
__tablename__ = "push_subscriptions"
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"))
|
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)
|
p256dh: Mapped[str] = mapped_column(Text)
|
||||||
auth: Mapped[str] = mapped_column(Text)
|
auth: Mapped[str] = mapped_column(Text)
|
||||||
user_agent: Mapped[str | None] = mapped_column(Text, nullable=True)
|
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)
|
last_used: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
from datetime import datetime, timezone
|
from sqlalchemy import ForeignKey, Integer, Text
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, Integer, Text
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from fabledassistant.models import Base
|
from fabledassistant.models import Base
|
||||||
|
from fabledassistant.models.base import TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
class TaskLog(Base):
|
class TaskLog(Base, TimestampMixin):
|
||||||
__tablename__ = "task_logs"
|
__tablename__ = "task_logs"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
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"))
|
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"))
|
||||||
content: Mapped[str] = mapped_column(Text)
|
content: Mapped[str] = mapped_column(Text)
|
||||||
duration_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
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:
|
def to_dict(self) -> dict:
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
from datetime import datetime, timezone
|
from sqlalchemy import Index, Text
|
||||||
|
|
||||||
from sqlalchemy import DateTime, Index, Text
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from fabledassistant.models import Base
|
from fabledassistant.models import Base
|
||||||
|
from fabledassistant.models.base import CreatedAtMixin
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
class User(Base, CreatedAtMixin):
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
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)
|
password_hash: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
oauth_sub: Mapped[str | None] = mapped_column(Text, unique=True, nullable=True)
|
oauth_sub: Mapped[str | None] = mapped_column(Text, unique=True, nullable=True)
|
||||||
role: Mapped[str] = mapped_column(Text, nullable=False, default="user")
|
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__ = (
|
__table_args__ = (
|
||||||
Index("ix_users_username", "username"),
|
Index("ix_users_username", "username"),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import httpx
|
|||||||
from quart import Blueprint, Response, jsonify, request
|
from quart import Blueprint, Response, jsonify, request
|
||||||
|
|
||||||
from fabledassistant.auth import login_required, get_current_user_id
|
from fabledassistant.auth import login_required, get_current_user_id
|
||||||
|
from fabledassistant.routes.utils import not_found
|
||||||
from fabledassistant.config import Config
|
from fabledassistant.config import Config
|
||||||
from fabledassistant.services.chat import (
|
from fabledassistant.services.chat import (
|
||||||
add_message,
|
add_message,
|
||||||
@@ -61,7 +62,7 @@ async def get_conversation_route(conv_id: int):
|
|||||||
uid = get_current_user_id()
|
uid = get_current_user_id()
|
||||||
conv = await get_conversation(uid, conv_id)
|
conv = await get_conversation(uid, conv_id)
|
||||||
if conv is None:
|
if conv is None:
|
||||||
return jsonify({"error": "Conversation not found"}), 404
|
return not_found("Conversation")
|
||||||
result = conv.to_dict()
|
result = conv.to_dict()
|
||||||
note_ids = [m.context_note_id for m in conv.messages if m.context_note_id]
|
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 {}
|
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()
|
uid = get_current_user_id()
|
||||||
deleted = await delete_conversation(uid, conv_id)
|
deleted = await delete_conversation(uid, conv_id)
|
||||||
if not deleted:
|
if not deleted:
|
||||||
return jsonify({"error": "Conversation not found"}), 404
|
return not_found("Conversation")
|
||||||
return "", 204
|
return "", 204
|
||||||
|
|
||||||
|
|
||||||
@@ -95,7 +96,7 @@ async def update_conversation_route(conv_id: int):
|
|||||||
return jsonify({"error": "title or model is required"}), 400
|
return jsonify({"error": "title or model is required"}), 400
|
||||||
conv = await update_conversation(uid, conv_id, title=title, model=model)
|
conv = await update_conversation(uid, conv_id, title=title, model=model)
|
||||||
if conv is None:
|
if conv is None:
|
||||||
return jsonify({"error": "Conversation not found"}), 404
|
return not_found("Conversation")
|
||||||
return jsonify(conv.to_dict())
|
return jsonify(conv.to_dict())
|
||||||
|
|
||||||
|
|
||||||
@@ -106,7 +107,7 @@ async def send_message_route(conv_id: int):
|
|||||||
uid = get_current_user_id()
|
uid = get_current_user_id()
|
||||||
conv = await get_conversation(uid, conv_id)
|
conv = await get_conversation(uid, conv_id)
|
||||||
if conv is None:
|
if conv is None:
|
||||||
return jsonify({"error": "Conversation not found"}), 404
|
return not_found("Conversation")
|
||||||
|
|
||||||
data = await request.get_json()
|
data = await request.get_json()
|
||||||
content = data.get("content", "").strip()
|
content = data.get("content", "").strip()
|
||||||
@@ -166,7 +167,7 @@ async def generation_stream_route(conv_id: int):
|
|||||||
uid = get_current_user_id()
|
uid = get_current_user_id()
|
||||||
conv = await get_conversation(uid, conv_id)
|
conv = await get_conversation(uid, conv_id)
|
||||||
if conv is None:
|
if conv is None:
|
||||||
return jsonify({"error": "Conversation not found"}), 404
|
return not_found("Conversation")
|
||||||
|
|
||||||
buf = get_buffer(conv_id)
|
buf = get_buffer(conv_id)
|
||||||
if buf is None:
|
if buf is None:
|
||||||
@@ -219,7 +220,7 @@ async def confirm_generation_route(conv_id: int):
|
|||||||
uid = get_current_user_id()
|
uid = get_current_user_id()
|
||||||
conv = await get_conversation(uid, conv_id)
|
conv = await get_conversation(uid, conv_id)
|
||||||
if conv is None:
|
if conv is None:
|
||||||
return jsonify({"error": "Conversation not found"}), 404
|
return not_found("Conversation")
|
||||||
|
|
||||||
buf = get_buffer(conv_id)
|
buf = get_buffer(conv_id)
|
||||||
if buf is None or buf.state != GenerationState.RUNNING:
|
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()
|
uid = get_current_user_id()
|
||||||
conv = await get_conversation(uid, conv_id)
|
conv = await get_conversation(uid, conv_id)
|
||||||
if conv is None:
|
if conv is None:
|
||||||
return jsonify({"error": "Conversation not found"}), 404
|
return not_found("Conversation")
|
||||||
|
|
||||||
buf = get_buffer(conv_id)
|
buf = get_buffer(conv_id)
|
||||||
if buf is None or buf.state != GenerationState.RUNNING:
|
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()
|
uid = get_current_user_id()
|
||||||
conv = await get_conversation(uid, conv_id)
|
conv = await get_conversation(uid, conv_id)
|
||||||
if conv is None:
|
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
|
model = await get_setting(uid, "default_model", Config.OLLAMA_MODEL) or Config.OLLAMA_MODEL
|
||||||
try:
|
try:
|
||||||
note = await summarize_conversation_as_note(uid, conv_id, model)
|
note = await summarize_conversation_as_note(uid, conv_id, model)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import logging
|
|||||||
from quart import Blueprint, jsonify, request
|
from quart import Blueprint, jsonify, request
|
||||||
|
|
||||||
from fabledassistant.auth import login_required, get_current_user_id
|
from fabledassistant.auth import login_required, get_current_user_id
|
||||||
|
from fabledassistant.routes.utils import not_found
|
||||||
from fabledassistant.services.milestones import (
|
from fabledassistant.services.milestones import (
|
||||||
create_milestone,
|
create_milestone,
|
||||||
delete_milestone,
|
delete_milestone,
|
||||||
@@ -20,21 +21,22 @@ logger = logging.getLogger(__name__)
|
|||||||
milestones_bp = Blueprint("milestones", __name__, url_prefix="/api/projects")
|
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"])
|
@milestones_bp.route("/<int:project_id>/milestones", methods=["GET"])
|
||||||
@login_required
|
@login_required
|
||||||
async def list_milestones_route(project_id: int):
|
async def list_milestones_route(project_id: int):
|
||||||
uid = get_current_user_id()
|
uid = get_current_user_id()
|
||||||
project = await get_project(uid, project_id)
|
project = await get_project(uid, project_id)
|
||||||
if project is None:
|
if project is None:
|
||||||
return jsonify({"error": "Project not found"}), 404
|
return not_found("Project")
|
||||||
status = request.args.get("status")
|
status = request.args.get("status")
|
||||||
milestones = await list_milestones(uid, project_id, status=status)
|
milestones = await list_milestones(uid, project_id, status=status)
|
||||||
result = []
|
result = [await _milestone_dict(m) for m in milestones]
|
||||||
for m in milestones:
|
|
||||||
entry = m.to_dict()
|
|
||||||
progress = await get_milestone_progress(m.id)
|
|
||||||
entry.update(progress)
|
|
||||||
result.append(entry)
|
|
||||||
return jsonify({"milestones": result})
|
return jsonify({"milestones": result})
|
||||||
|
|
||||||
|
|
||||||
@@ -44,7 +46,7 @@ async def create_milestone_route(project_id: int):
|
|||||||
uid = get_current_user_id()
|
uid = get_current_user_id()
|
||||||
project = await get_project(uid, project_id)
|
project = await get_project(uid, project_id)
|
||||||
if project is None:
|
if project is None:
|
||||||
return jsonify({"error": "Project not found"}), 404
|
return not_found("Project")
|
||||||
data = await request.get_json()
|
data = await request.get_json()
|
||||||
if not data.get("title"):
|
if not data.get("title"):
|
||||||
return jsonify({"error": "title is required"}), 400
|
return jsonify({"error": "title is required"}), 400
|
||||||
@@ -55,9 +57,7 @@ async def create_milestone_route(project_id: int):
|
|||||||
description=data.get("description"),
|
description=data.get("description"),
|
||||||
order_index=data.get("order_index", 0),
|
order_index=data.get("order_index", 0),
|
||||||
)
|
)
|
||||||
entry = milestone.to_dict()
|
return jsonify(await _milestone_dict(milestone)), 201
|
||||||
entry.update(await get_milestone_progress(milestone.id))
|
|
||||||
return jsonify(entry), 201
|
|
||||||
|
|
||||||
|
|
||||||
@milestones_bp.route("/<int:project_id>/milestones/<int:milestone_id>", methods=["GET"])
|
@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()
|
uid = get_current_user_id()
|
||||||
milestone = await get_milestone(uid, milestone_id)
|
milestone = await get_milestone(uid, milestone_id)
|
||||||
if milestone is None or milestone.project_id != project_id:
|
if milestone is None or milestone.project_id != project_id:
|
||||||
return jsonify({"error": "Milestone not found"}), 404
|
return not_found("Milestone")
|
||||||
entry = milestone.to_dict()
|
return jsonify(await _milestone_dict(milestone))
|
||||||
entry.update(await get_milestone_progress(milestone.id))
|
|
||||||
return jsonify(entry)
|
|
||||||
|
|
||||||
|
|
||||||
@milestones_bp.route("/<int:project_id>/milestones/<int:milestone_id>", methods=["PATCH"])
|
@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()
|
uid = get_current_user_id()
|
||||||
milestone = await get_milestone(uid, milestone_id)
|
milestone = await get_milestone(uid, milestone_id)
|
||||||
if milestone is None or milestone.project_id != project_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()
|
data = await request.get_json()
|
||||||
allowed = {"title", "description", "status", "order_index"}
|
allowed = {"title", "description", "status", "order_index"}
|
||||||
fields = {k: v for k, v in data.items() if k in allowed}
|
fields = {k: v for k, v in data.items() if k in allowed}
|
||||||
updated = await update_milestone(uid, milestone_id, **fields)
|
updated = await update_milestone(uid, milestone_id, **fields)
|
||||||
if updated is None:
|
if updated is None:
|
||||||
return jsonify({"error": "Milestone not found"}), 404
|
return not_found("Milestone")
|
||||||
entry = updated.to_dict()
|
return jsonify(await _milestone_dict(updated))
|
||||||
entry.update(await get_milestone_progress(updated.id))
|
|
||||||
return jsonify(entry)
|
|
||||||
|
|
||||||
|
|
||||||
@milestones_bp.route("/<int:project_id>/milestones/<int:milestone_id>", methods=["DELETE"])
|
@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()
|
uid = get_current_user_id()
|
||||||
milestone = await get_milestone(uid, milestone_id)
|
milestone = await get_milestone(uid, milestone_id)
|
||||||
if milestone is None or milestone.project_id != project_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)
|
deleted = await delete_milestone(uid, milestone_id)
|
||||||
if not deleted:
|
if not deleted:
|
||||||
return jsonify({"error": "Milestone not found"}), 404
|
return not_found("Milestone")
|
||||||
return "", 204
|
return "", 204
|
||||||
|
|
||||||
|
|
||||||
@@ -109,7 +105,7 @@ async def get_milestone_tasks_route(project_id: int, milestone_id: int):
|
|||||||
uid = get_current_user_id()
|
uid = get_current_user_id()
|
||||||
milestone = await get_milestone(uid, milestone_id)
|
milestone = await get_milestone(uid, milestone_id)
|
||||||
if milestone is None or milestone.project_id != project_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")
|
status_filter = request.args.get("status")
|
||||||
limit = min(request.args.get("limit", 100, type=int), 500)
|
limit = min(request.args.get("limit", 100, type=int), 500)
|
||||||
offset = request.args.get("offset", 0, type=int)
|
offset = request.args.get("offset", 0, type=int)
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from datetime import date
|
|
||||||
|
|
||||||
from fabledassistant.services.embeddings import upsert_note_embedding
|
from fabledassistant.services.embeddings import upsert_note_embedding
|
||||||
|
|
||||||
from quart import Blueprint, Response, jsonify, request
|
from quart import Blueprint, Response, jsonify, request
|
||||||
|
|
||||||
from fabledassistant.auth import login_required, get_current_user_id
|
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.config import Config
|
||||||
from fabledassistant.services.assist import build_assist_messages
|
from fabledassistant.services.assist import build_assist_messages
|
||||||
from fabledassistant.services.generation_buffer import (
|
from fabledassistant.services.generation_buffer import (
|
||||||
@@ -87,12 +87,9 @@ async def create_note_route():
|
|||||||
# Optional task fields
|
# Optional task fields
|
||||||
status = data.get("status")
|
status = data.get("status")
|
||||||
priority = data.get("priority")
|
priority = data.get("priority")
|
||||||
due_date = None
|
due_date = parse_iso_date(data.get("due_date"), "due_date")
|
||||||
if data.get("due_date"):
|
if isinstance(due_date, tuple):
|
||||||
try:
|
return due_date
|
||||||
due_date = date.fromisoformat(data["due_date"])
|
|
||||||
except ValueError:
|
|
||||||
return jsonify({"error": "Invalid date format. Use YYYY-MM-DD."}), 400
|
|
||||||
|
|
||||||
note = await create_note(
|
note = await create_note(
|
||||||
uid,
|
uid,
|
||||||
@@ -144,7 +141,7 @@ async def append_tag_route(note_id: int):
|
|||||||
|
|
||||||
note = await get_note(uid, note_id)
|
note = await get_note(uid, note_id)
|
||||||
if note is None:
|
if note is None:
|
||||||
return jsonify({"error": "Note not found"}), 404
|
return not_found("Note")
|
||||||
|
|
||||||
existing = list(note.tags or [])
|
existing = list(note.tags or [])
|
||||||
if tag not in existing:
|
if tag not in existing:
|
||||||
@@ -162,7 +159,7 @@ async def get_note_by_title_route():
|
|||||||
return jsonify({"error": "title parameter is required"}), 400
|
return jsonify({"error": "title parameter is required"}), 400
|
||||||
note = await get_note_by_title(uid, title)
|
note = await get_note_by_title(uid, title)
|
||||||
if note is None:
|
if note is None:
|
||||||
return jsonify({"error": "Note not found"}), 404
|
return not_found("Note")
|
||||||
return jsonify(note.to_dict())
|
return jsonify(note.to_dict())
|
||||||
|
|
||||||
|
|
||||||
@@ -184,7 +181,7 @@ async def get_note_route(note_id: int):
|
|||||||
uid = get_current_user_id()
|
uid = get_current_user_id()
|
||||||
note = await get_note(uid, note_id)
|
note = await get_note(uid, note_id)
|
||||||
if note is None:
|
if note is None:
|
||||||
return jsonify({"error": "Note not found"}), 404
|
return not_found("Note")
|
||||||
return jsonify(note.to_dict())
|
return jsonify(note.to_dict())
|
||||||
|
|
||||||
|
|
||||||
@@ -200,10 +197,10 @@ async def update_note_route(note_id: int):
|
|||||||
|
|
||||||
if "due_date" in data:
|
if "due_date" in data:
|
||||||
if data["due_date"]:
|
if data["due_date"]:
|
||||||
try:
|
result = parse_iso_date(data["due_date"], "due_date")
|
||||||
fields["due_date"] = date.fromisoformat(data["due_date"])
|
if isinstance(result, tuple):
|
||||||
except ValueError:
|
return result
|
||||||
return jsonify({"error": "Invalid date format. Use YYYY-MM-DD."}), 400
|
fields["due_date"] = result
|
||||||
else:
|
else:
|
||||||
fields["due_date"] = None
|
fields["due_date"] = None
|
||||||
|
|
||||||
@@ -211,7 +208,7 @@ async def update_note_route(note_id: int):
|
|||||||
fields["tags"] = data["tags"]
|
fields["tags"] = data["tags"]
|
||||||
note = await update_note(uid, note_id, **fields)
|
note = await update_note(uid, note_id, **fields)
|
||||||
if note is None:
|
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 "")
|
text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "")
|
||||||
if text:
|
if text:
|
||||||
asyncio.create_task(upsert_note_embedding(note.id, uid, 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:
|
if key in data:
|
||||||
fields[key] = data[key]
|
fields[key] = data[key]
|
||||||
if "due_date" in data:
|
if "due_date" in data:
|
||||||
try:
|
result = parse_iso_date(data.get("due_date"), "due_date")
|
||||||
fields["due_date"] = date.fromisoformat(data["due_date"]) if data["due_date"] else None
|
if isinstance(result, tuple):
|
||||||
except (ValueError, TypeError):
|
return result
|
||||||
return jsonify({"error": "Invalid due_date format, expected YYYY-MM-DD"}), 400
|
fields["due_date"] = result
|
||||||
if "tags" in data:
|
if "tags" in data:
|
||||||
fields["tags"] = data["tags"]
|
fields["tags"] = data["tags"]
|
||||||
note = await update_note(uid, note_id, **fields)
|
note = await update_note(uid, note_id, **fields)
|
||||||
if note is None:
|
if note is None:
|
||||||
return jsonify({"error": "Note not found"}), 404
|
return not_found("Note")
|
||||||
return jsonify(note.to_dict())
|
return jsonify(note.to_dict())
|
||||||
|
|
||||||
|
|
||||||
@@ -246,7 +243,7 @@ async def delete_note_route(note_id: int):
|
|||||||
uid = get_current_user_id()
|
uid = get_current_user_id()
|
||||||
deleted = await delete_note(uid, note_id)
|
deleted = await delete_note(uid, note_id)
|
||||||
if not deleted:
|
if not deleted:
|
||||||
return jsonify({"error": "Note not found"}), 404
|
return not_found("Note")
|
||||||
return "", 204
|
return "", 204
|
||||||
|
|
||||||
|
|
||||||
@@ -474,7 +471,7 @@ async def upsert_draft_route(note_id: int):
|
|||||||
# Verify note ownership
|
# Verify note ownership
|
||||||
note = await get_note(uid, note_id)
|
note = await get_note(uid, note_id)
|
||||||
if note is None:
|
if note is None:
|
||||||
return jsonify({"error": "Note not found"}), 404
|
return not_found("Note")
|
||||||
data = await request.get_json()
|
data = await request.get_json()
|
||||||
draft = await upsert_draft(
|
draft = await upsert_draft(
|
||||||
user_id=uid,
|
user_id=uid,
|
||||||
@@ -511,5 +508,5 @@ async def get_version_route(note_id: int, version_id: int):
|
|||||||
uid = get_current_user_id()
|
uid = get_current_user_id()
|
||||||
version = await get_version(uid, note_id, version_id)
|
version = await get_version(uid, note_id, version_id)
|
||||||
if version is None:
|
if version is None:
|
||||||
return jsonify({"error": "Version not found"}), 404
|
return not_found("Version")
|
||||||
return jsonify(version.to_dict(include_body=True))
|
return jsonify(version.to_dict(include_body=True))
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import logging
|
|||||||
from quart import Blueprint, jsonify, request
|
from quart import Blueprint, jsonify, request
|
||||||
|
|
||||||
from fabledassistant.auth import login_required, get_current_user_id
|
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.milestones import list_milestones
|
||||||
from fabledassistant.services.notes import list_notes
|
from fabledassistant.services.notes import list_notes
|
||||||
from fabledassistant.services.projects import (
|
from fabledassistant.services.projects import (
|
||||||
@@ -52,7 +53,7 @@ async def get_project_route(project_id: int):
|
|||||||
uid = get_current_user_id()
|
uid = get_current_user_id()
|
||||||
project = await get_project(uid, project_id)
|
project = await get_project(uid, project_id)
|
||||||
if project is None:
|
if project is None:
|
||||||
return jsonify({"error": "Project not found"}), 404
|
return not_found("Project")
|
||||||
summary = await get_project_summary(uid, project_id)
|
summary = await get_project_summary(uid, project_id)
|
||||||
data = project.to_dict()
|
data = project.to_dict()
|
||||||
data["summary"] = summary
|
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}
|
fields = {k: v for k, v in data.items() if k in allowed}
|
||||||
project = await update_project(uid, project_id, **fields)
|
project = await update_project(uid, project_id, **fields)
|
||||||
if project is None:
|
if project is None:
|
||||||
return jsonify({"error": "Project not found"}), 404
|
return not_found("Project")
|
||||||
return jsonify(project.to_dict())
|
return jsonify(project.to_dict())
|
||||||
|
|
||||||
|
|
||||||
@@ -78,7 +79,7 @@ async def delete_project_route(project_id: int):
|
|||||||
uid = get_current_user_id()
|
uid = get_current_user_id()
|
||||||
deleted = await delete_project(uid, project_id)
|
deleted = await delete_project(uid, project_id)
|
||||||
if not deleted:
|
if not deleted:
|
||||||
return jsonify({"error": "Project not found"}), 404
|
return not_found("Project")
|
||||||
return "", 204
|
return "", 204
|
||||||
|
|
||||||
|
|
||||||
@@ -88,7 +89,7 @@ async def get_project_notes_route(project_id: int):
|
|||||||
uid = get_current_user_id()
|
uid = get_current_user_id()
|
||||||
project = await get_project(uid, project_id)
|
project = await get_project(uid, project_id)
|
||||||
if project is None:
|
if project is None:
|
||||||
return jsonify({"error": "Project not found"}), 404
|
return not_found("Project")
|
||||||
|
|
||||||
# type filter: "note", "task", or None (both)
|
# type filter: "note", "task", or None (both)
|
||||||
type_filter = request.args.get("type")
|
type_filter = request.args.get("type")
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
from datetime import date
|
|
||||||
|
|
||||||
from quart import Blueprint, jsonify, request
|
from quart import Blueprint, jsonify, request
|
||||||
|
|
||||||
from fabledassistant.auth import login_required, get_current_user_id
|
from fabledassistant.auth import login_required, get_current_user_id
|
||||||
from fabledassistant.models.note import TaskPriority, TaskStatus
|
from fabledassistant.models.note import TaskPriority, TaskStatus
|
||||||
|
from fabledassistant.routes.utils import not_found, parse_iso_date
|
||||||
from fabledassistant.services.notes import (
|
from fabledassistant.services.notes import (
|
||||||
create_note,
|
create_note,
|
||||||
delete_note,
|
delete_note,
|
||||||
@@ -28,13 +27,12 @@ async def list_tasks_route():
|
|||||||
limit = min(request.args.get("limit", 50, type=int), 500)
|
limit = min(request.args.get("limit", 50, type=int), 500)
|
||||||
offset = request.args.get("offset", 0, type=int)
|
offset = request.args.get("offset", 0, type=int)
|
||||||
|
|
||||||
due_before_str = request.args.get("due_before")
|
due_before = parse_iso_date(request.args.get("due_before"), "due_before")
|
||||||
due_after_str = request.args.get("due_after")
|
if isinstance(due_before, tuple):
|
||||||
try:
|
return due_before
|
||||||
due_before = date.fromisoformat(due_before_str) if due_before_str else None
|
due_after = parse_iso_date(request.args.get("due_after"), "due_after")
|
||||||
due_after = date.fromisoformat(due_after_str) if due_after_str else None
|
if isinstance(due_after, tuple):
|
||||||
except ValueError:
|
return due_after
|
||||||
return jsonify({"error": "Invalid date format. Use YYYY-MM-DD."}), 400
|
|
||||||
|
|
||||||
tasks, total = await list_notes(
|
tasks, total = await list_notes(
|
||||||
uid,
|
uid,
|
||||||
@@ -61,12 +59,9 @@ async def create_task_route():
|
|||||||
body = data.get("body", "") or data.get("description", "")
|
body = data.get("body", "") or data.get("description", "")
|
||||||
tags = data.get("tags", [])
|
tags = data.get("tags", [])
|
||||||
|
|
||||||
due_date = None
|
due_date = parse_iso_date(data.get("due_date"), "due_date")
|
||||||
if data.get("due_date"):
|
if isinstance(due_date, tuple):
|
||||||
try:
|
return due_date
|
||||||
due_date = date.fromisoformat(data["due_date"])
|
|
||||||
except ValueError:
|
|
||||||
return jsonify({"error": "Invalid date format. Use YYYY-MM-DD."}), 400
|
|
||||||
|
|
||||||
status = TaskStatus(data["status"]).value if "status" in data else TaskStatus.todo.value
|
status = TaskStatus(data["status"]).value if "status" in data else TaskStatus.todo.value
|
||||||
priority = (
|
priority = (
|
||||||
@@ -94,7 +89,7 @@ async def get_task_route(task_id: int):
|
|||||||
uid = get_current_user_id()
|
uid = get_current_user_id()
|
||||||
task = await get_note(uid, task_id)
|
task = await get_note(uid, task_id)
|
||||||
if task is None:
|
if task is None:
|
||||||
return jsonify({"error": "Task not found"}), 404
|
return not_found("Task")
|
||||||
data = task.to_dict()
|
data = task.to_dict()
|
||||||
if task.parent_id:
|
if task.parent_id:
|
||||||
parent = await get_note(uid, 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 "due_date" in data:
|
||||||
if data["due_date"]:
|
if data["due_date"]:
|
||||||
try:
|
result = parse_iso_date(data["due_date"], "due_date")
|
||||||
fields["due_date"] = date.fromisoformat(data["due_date"])
|
if isinstance(result, tuple):
|
||||||
except ValueError:
|
return result
|
||||||
return jsonify({"error": "Invalid date format. Use YYYY-MM-DD."}), 400
|
fields["due_date"] = result
|
||||||
else:
|
else:
|
||||||
fields["due_date"] = None
|
fields["due_date"] = None
|
||||||
|
|
||||||
@@ -136,7 +131,7 @@ async def update_task_route(task_id: int):
|
|||||||
|
|
||||||
task = await update_note(uid, task_id, **fields)
|
task = await update_note(uid, task_id, **fields)
|
||||||
if task is None:
|
if task is None:
|
||||||
return jsonify({"error": "Task not found"}), 404
|
return not_found("Task")
|
||||||
return jsonify(task.to_dict())
|
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)
|
task = await update_note(uid, task_id, status=status_val)
|
||||||
if task is None:
|
if task is None:
|
||||||
return jsonify({"error": "Task not found"}), 404
|
return not_found("Task")
|
||||||
return jsonify(task.to_dict())
|
return jsonify(task.to_dict())
|
||||||
|
|
||||||
|
|
||||||
@@ -166,5 +161,5 @@ async def delete_task_route(task_id: int):
|
|||||||
uid = get_current_user_id()
|
uid = get_current_user_id()
|
||||||
deleted = await delete_note(uid, task_id)
|
deleted = await delete_note(uid, task_id)
|
||||||
if not deleted:
|
if not deleted:
|
||||||
return jsonify({"error": "Task not found"}), 404
|
return not_found("Task")
|
||||||
return "", 204
|
return "", 204
|
||||||
|
|||||||
@@ -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
@@ -12,6 +12,30 @@
|
|||||||
> Include file-level details in the commit body when the change is non-trivial.
|
> Include file-level details in the commit body when the change is non-trivial.
|
||||||
|
|
||||||
## Last Updated
|
## 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.
|
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.
|
**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.
|
||||||
|
|||||||
Reference in New Issue
Block a user