DRY refactoring pass: shared mixins, route helpers, composables, CSS

Backend:
- models/base.py: TimestampMixin + CreatedAtMixin; applied to all 10+ models
- routes/utils.py: not_found() + parse_iso_date() helpers; used across all route files
- routes/milestones.py: _milestone_dict() helper replaces 5 repeated to_dict + progress blocks

Frontend:
- 5 new composables: useAutoSave, useEditorGuards, useTagSuggestions,
  useFloatingAssist, useListKeyboardNavigation
- ConfirmDialog.vue: reusable confirm modal replacing inline <teleport> blocks
- editor-shared.css: sidebar CSS consolidated from both editor views
- viewer-shared.css: context-bar/breadcrumb CSS consolidated from both viewer views
- NoteEditorView + TaskEditorView: ~120 lines each replaced with composable calls;
  duplicate scoped sidebar CSS removed
- NotesListView + TasksListView: inline keyboard-nav replaced with composable
- NoteViewerView + TaskViewerView: duplicate context-bar CSS removed

No behaviour changes. Net: -634 lines, +237 lines across 31 files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 15:26:34 -05:00
parent 48f070f773
commit 16ecd6bbeb
32 changed files with 563 additions and 634 deletions
+64
View File
@@ -532,6 +532,70 @@
pointer-events: auto;
}
/* ── Sidebar shared styles ── */
.sidebar-toggle {
display: none;
width: 100%;
padding: 0.6rem 1rem;
background: var(--color-bg-secondary);
border: none;
border-bottom: 1px solid var(--color-border);
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-secondary);
cursor: pointer;
text-align: left;
font-family: inherit;
}
.sidebar-content {
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.sb-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.sb-label {
font-size: 0.78rem;
font-weight: 600;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.sb-select,
.sb-input {
width: 100%;
padding: 0.35rem 0.5rem;
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text);
font-size: 0.875rem;
font-family: inherit;
box-sizing: border-box;
}
.sb-select:focus,
.sb-input:focus {
outline: none;
border-color: var(--color-primary);
}
.sb-divider {
height: 1px;
background: var(--color-border);
margin: 0.15rem 0;
}
@media (max-width: 720px) {
.sidebar-toggle { display: block; }
.sidebar-content {
display: none;
padding: 0.75rem 1rem;
}
.sidebar-content.sidebar-open { display: flex; }
}
/* ── Mobile ── */
@media (max-width: 768px) {
.editor-body {
+41
View File
@@ -0,0 +1,41 @@
/* ── Context breadcrumb (shared by NoteViewerView and TaskViewerView) ── */
.context-bar {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
margin-bottom: 0.6rem;
}
.ctx-crumb {
display: inline-flex;
align-items: center;
font-size: 0.8rem;
padding: 0.15rem 0.55rem;
border-radius: 999px;
white-space: nowrap;
}
.ctx-crumb-parent {
color: var(--color-text-muted);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
text-decoration: none;
}
.ctx-crumb-parent:hover {
color: var(--color-primary);
border-color: var(--color-primary);
}
.ctx-crumb-project {
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
text-decoration: none;
font-weight: 500;
}
.ctx-crumb-project:hover {
background: color-mix(in srgb, var(--color-primary) 18%, transparent);
}
.ctx-crumb-milestone {
color: var(--color-text-secondary);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
}
+37
View File
@@ -0,0 +1,37 @@
<script setup lang="ts">
withDefaults(
defineProps<{
title: string;
message: string;
confirmLabel?: string;
danger?: boolean;
}>(),
{
confirmLabel: "Delete",
danger: true,
}
);
const emit = defineEmits<{
confirm: [];
cancel: [];
}>();
</script>
<template>
<teleport to="body">
<div class="modal-overlay" @click="emit('cancel')">
<div class="modal-card" @click.stop>
<h3 class="modal-title">{{ title }}</h3>
<p class="modal-message">{{ message }}</p>
<div class="modal-actions">
<button class="modal-btn" @click="emit('cancel')">Cancel</button>
<button
:class="['modal-btn', { 'modal-btn-danger': danger }]"
@click="emit('confirm')"
>{{ confirmLabel }}</button>
</div>
</div>
</div>
</teleport>
</template>
+22
View File
@@ -0,0 +1,22 @@
import { onMounted, onUnmounted } from "vue";
import type { Ref } from "vue";
export function useAutoSave(
dirty: Ref<boolean>,
saving: Ref<boolean>,
saveFn: () => Promise<void>,
intervalMs = 5 * 60 * 1000,
): void {
let timer: ReturnType<typeof setInterval> | null = null;
onMounted(() => {
timer = setInterval(async () => {
if (!dirty.value || saving.value) return;
await saveFn();
}, intervalMs);
});
onUnmounted(() => {
if (timer !== null) clearInterval(timer);
});
}
@@ -0,0 +1,33 @@
import { onMounted, onUnmounted } from "vue";
import { onBeforeRouteLeave } from "vue-router";
import type { Ref } from "vue";
export function useEditorGuards(
dirty: Ref<boolean>,
saveFn: () => void | Promise<void>,
): void {
function onKeydown(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
saveFn();
}
}
function onBeforeUnload(e: BeforeUnloadEvent) {
if (dirty.value) e.preventDefault();
}
onMounted(() => {
document.addEventListener("keydown", onKeydown);
window.addEventListener("beforeunload", onBeforeUnload);
});
onUnmounted(() => {
document.removeEventListener("keydown", onKeydown);
window.removeEventListener("beforeunload", onBeforeUnload);
});
onBeforeRouteLeave(() => {
if (dirty.value) return confirm("You have unsaved changes. Leave anyway?");
});
}
@@ -0,0 +1,34 @@
import { ref } from "vue";
type Selection = { text: string; start: number; end: number };
export function useFloatingAssist(onRequest: (sel: Selection) => void) {
const floatingAssist = ref({ show: false, top: 0, left: 0 });
const pendingSelection = ref<Selection | null>(null);
function onSelectionChange(payload: Selection) {
if (payload.text.trim().length > 0) {
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
const rect = sel.getRangeAt(0).getBoundingClientRect();
floatingAssist.value = {
show: true,
top: rect.top - 44,
left: rect.left + rect.width / 2,
};
pendingSelection.value = payload;
}
} else {
floatingAssist.value.show = false;
pendingSelection.value = null;
}
}
function handleInlineAssist() {
if (!pendingSelection.value) return;
floatingAssist.value.show = false;
onRequest(pendingSelection.value);
}
return { floatingAssist, onSelectionChange, handleInlineAssist };
}
@@ -0,0 +1,39 @@
import { ref, onMounted, onUnmounted } from "vue";
import type { Ref } from "vue";
export function useListKeyboardNavigation<T extends { id: number }>(
items: Ref<T[]>,
navigateTo: (item: T) => void,
rowSelector = ".kb-active-item",
enabled?: Ref<boolean>,
) {
const activeIndex = ref(-1);
function onKeydown(e: KeyboardEvent) {
if (enabled && !enabled.value) return;
const el = document.activeElement;
const tag = el ? (el as HTMLElement).tagName : "";
const isInput =
tag === "INPUT" || tag === "TEXTAREA" || (el as HTMLElement)?.isContentEditable;
if (isInput || e.ctrlKey || e.metaKey || e.altKey) return;
const count = items.value.length;
if (e.key === "j") {
e.preventDefault();
activeIndex.value = Math.min(activeIndex.value + 1, count - 1);
document.querySelector(rowSelector)?.scrollIntoView({ block: "nearest" });
} else if (e.key === "k") {
e.preventDefault();
activeIndex.value = Math.max(activeIndex.value - 1, 0);
document.querySelector(rowSelector)?.scrollIntoView({ block: "nearest" });
} else if (e.key === "Enter" && activeIndex.value >= 0) {
const item = items.value[activeIndex.value];
if (item) navigateTo(item);
}
}
onMounted(() => document.addEventListener("keydown", onKeydown));
onUnmounted(() => document.removeEventListener("keydown", onKeydown));
return { activeIndex };
}
@@ -0,0 +1,58 @@
import { ref } from "vue";
import type { Ref } from "vue";
import { apiPost } from "@/api/client";
import { useToastStore } from "@/stores/toast";
export function useTagSuggestions(
title: Ref<string>,
body: Ref<string>,
tags: Ref<string[]>,
markDirty: () => void,
) {
const suggestedTags = ref<string[]>([]);
const appliedTags = ref<Set<string>>(new Set());
const suggestingTags = ref(false);
async function fetchTagSuggestions() {
if (suggestingTags.value) return;
suggestingTags.value = true;
suggestedTags.value = [];
appliedTags.value = new Set();
const toast = useToastStore();
try {
const res = await apiPost<{ suggested_tags: string[] }>("/api/notes/suggest-tags", {
title: title.value,
body: body.value,
current_tags: tags.value,
});
suggestedTags.value = res.suggested_tags;
} catch {
toast.show("Failed to get tag suggestions", "error");
} finally {
suggestingTags.value = false;
}
}
function applyTagSuggestion(tag: string) {
if (appliedTags.value.has(tag)) return;
if (!tags.value.includes(tag)) {
tags.value = [...tags.value, tag];
}
appliedTags.value.add(tag);
markDirty();
}
function dismissTagSuggestions() {
suggestedTags.value = [];
appliedTags.value = new Set();
}
return {
suggestedTags,
appliedTags,
suggestingTags,
fetchTagSuggestions,
applyTagSuggestion,
dismissTagSuggestions,
};
}
+26 -161
View File
@@ -1,11 +1,15 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, nextTick, watch } from "vue";
import { useRoute, useRouter, onBeforeRouteLeave } from "vue-router";
import { useRoute, useRouter } from "vue-router";
import { useNotesStore } from "@/stores/notes";
import { useToastStore } from "@/stores/toast";
import { renderMarkdown } from "@/utils/markdown";
import { useAssist, type NoteDraft } from "@/composables/useAssist";
import { apiPost, apiGet } from "@/api/client";
import { useAutoSave } from "@/composables/useAutoSave";
import { useEditorGuards } from "@/composables/useEditorGuards";
import { useTagSuggestions } from "@/composables/useTagSuggestions";
import { useFloatingAssist } from "@/composables/useFloatingAssist";
import { apiGet, apiPost } from "@/api/client";
import type { Editor } from "@tiptap/vue-3";
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
import TiptapEditor from "@/components/TiptapEditor.vue";
@@ -14,6 +18,7 @@ import ProjectSelector from "@/components/ProjectSelector.vue";
import MilestoneSelector from "@/components/MilestoneSelector.vue";
import DiffView from "@/components/DiffView.vue";
import HistoryPanel from "@/components/HistoryPanel.vue";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
const route = useRoute();
const router = useRouter();
@@ -84,31 +89,14 @@ const scopeSelectValue = computed({
});
// Floating inline assist button
const floatingAssist = ref({ show: false, top: 0, left: 0 });
const pendingSelection = ref<{ start: number; end: number } | null>(null);
const instructionRef = ref<HTMLTextAreaElement | null>(null);
function onSelectionChange(payload: { text: string; start: number; end: number }) {
if (payload.text.trim().length > 0) {
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
const rect = sel.getRangeAt(0).getBoundingClientRect();
floatingAssist.value = { show: true, top: rect.top - 44, left: rect.left + rect.width / 2 };
pendingSelection.value = { start: payload.start, end: payload.end };
}
} else {
floatingAssist.value.show = false;
pendingSelection.value = null;
const { floatingAssist, onSelectionChange, handleInlineAssist } = useFloatingAssist(
({ start, end }) => {
assist.scopeMode.value = 'section';
assist.selectTextRange(start, end);
nextTick(() => instructionRef.value?.focus());
}
}
function handleInlineAssist() {
if (!pendingSelection.value) return;
assist.scopeMode.value = 'section';
assist.selectTextRange(pendingSelection.value.start, pendingSelection.value.end);
floatingAssist.value.show = false;
nextTick(() => instructionRef.value?.focus());
}
);
function handleAssistAccept() {
const newBody = assist.accept();
@@ -118,42 +106,8 @@ function handleAssistAccept() {
}
// Tag suggestions
const suggestedTags = ref<string[]>([]);
const appliedTags = ref<Set<string>>(new Set());
const suggestingTags = ref(false);
async function fetchTagSuggestions() {
if (suggestingTags.value) return;
suggestingTags.value = true;
suggestedTags.value = [];
appliedTags.value = new Set();
try {
const res = await apiPost<{ suggested_tags: string[] }>("/api/notes/suggest-tags", {
title: title.value,
body: body.value,
current_tags: tags.value,
});
suggestedTags.value = res.suggested_tags;
} catch {
toast.show("Failed to get tag suggestions", "error");
} finally {
suggestingTags.value = false;
}
}
function applyTagSuggestion(tag: string) {
if (appliedTags.value.has(tag)) return;
if (!tags.value.includes(tag)) {
tags.value = [...tags.value, tag];
}
appliedTags.value.add(tag);
markDirty();
}
function dismissTagSuggestions() {
suggestedTags.value = [];
appliedTags.value = new Set();
}
const { suggestedTags, appliedTags, suggestingTags, fetchTagSuggestions, applyTagSuggestion, dismissTagSuggestions } =
useTagSuggestions(title, body, tags, markDirty);
// ── Link Suggestions ────────────────────────────────────────────────────────
@@ -331,10 +285,8 @@ async function confirmDelete() {
}
// Auto-save every 5 minutes
let autoSaveTimer: ReturnType<typeof setInterval> | null = null;
async function autoSave() {
if (!isEditing.value || !dirty.value || saving.value) return;
async function doAutoSave() {
if (!isEditing.value || saving.value) return;
saving.value = true;
try {
await store.updateNote(noteId.value!, {
@@ -354,29 +306,8 @@ async function autoSave() {
saving.value = false;
}
}
onMounted(() => { autoSaveTimer = setInterval(autoSave, 5 * 60 * 1000); });
onUnmounted(() => { if (autoSaveTimer !== null) clearInterval(autoSaveTimer); });
function onKeydown(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
save();
}
}
onMounted(() => document.addEventListener("keydown", onKeydown));
onUnmounted(() => document.removeEventListener("keydown", onKeydown));
onBeforeRouteLeave(() => {
if (dirty.value) return confirm("You have unsaved changes. Leave anyway?");
});
function onBeforeUnload(e: BeforeUnloadEvent) {
if (dirty.value) e.preventDefault();
}
onMounted(() => window.addEventListener("beforeunload", onBeforeUnload));
onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
useAutoSave(dirty, saving, doAutoSave);
useEditorGuards(dirty, save);
// Close any in-flight assist SSE stream when navigating away
onUnmounted(() => assist.clearSelection());
@@ -595,18 +526,13 @@ onUnmounted(() => assist.clearSelection());
/>
<!-- Delete confirmation -->
<teleport to="body">
<div v-if="showDeleteConfirm" class="modal-overlay" @click="showDeleteConfirm = false">
<div class="modal-card" @click.stop>
<h3 class="modal-title">Delete Note</h3>
<p class="modal-message">Are you sure you want to delete this note? This cannot be undone.</p>
<div class="modal-actions">
<button class="modal-btn" @click="showDeleteConfirm = false">Cancel</button>
<button class="modal-btn modal-btn-danger" @click="confirmDelete">Delete</button>
</div>
</div>
</div>
</teleport>
<ConfirmDialog
v-if="showDeleteConfirm"
title="Delete Note"
message="Are you sure you want to delete this note? This cannot be undone."
@confirm="confirmDelete"
@cancel="showDeleteConfirm = false"
/>
</main>
</template>
@@ -675,61 +601,6 @@ onUnmounted(() => assist.clearSelection());
flex-direction: column;
}
/* Mobile accordion toggle (hidden on desktop) */
.sidebar-toggle {
display: none;
width: 100%;
padding: 0.6rem 1rem;
background: var(--color-bg-secondary);
border: none;
border-bottom: 1px solid var(--color-border);
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-secondary);
cursor: pointer;
text-align: left;
font-family: inherit;
}
.sidebar-content {
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.65rem;
}
/* Sidebar field layout */
.sb-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.sb-label {
font-size: 0.78rem;
font-weight: 600;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.sb-select {
width: 100%;
padding: 0.35rem 0.5rem;
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text);
font-size: 0.875rem;
font-family: inherit;
box-sizing: border-box;
}
.sb-select:focus { outline: none; border-color: var(--color-primary); }
.sb-divider {
height: 1px;
background: var(--color-border);
margin: 0.15rem 0;
}
/* Tag suggest row inside sidebar */
.tag-suggest-row {
display: flex;
@@ -841,11 +712,5 @@ onUnmounted(() => assist.clearSelection());
border-top: 1px solid var(--color-border);
overflow-y: visible;
}
.sidebar-toggle { display: block; }
.sidebar-content {
display: none;
padding: 0.75rem 1rem;
}
.sidebar-content.sidebar-open { display: flex; }
}
</style>
+1 -42
View File
@@ -229,6 +229,7 @@ async function convertToTask() {
</div>
</template>
<style src="@/assets/viewer-shared.css" />
<style scoped>
.viewer-layout {
display: flex;
@@ -294,48 +295,6 @@ async function convertToTask() {
cursor: default;
}
/* Context breadcrumb */
.context-bar {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
margin-bottom: 0.6rem;
}
.ctx-crumb {
display: inline-flex;
align-items: center;
font-size: 0.8rem;
padding: 0.15rem 0.55rem;
border-radius: 999px;
white-space: nowrap;
}
.ctx-crumb-parent {
color: var(--color-text-muted);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
text-decoration: none;
}
.ctx-crumb-parent:hover {
color: var(--color-primary);
border-color: var(--color-primary);
}
.ctx-crumb-project {
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
text-decoration: none;
font-weight: 500;
}
.ctx-crumb-project:hover {
background: color-mix(in srgb, var(--color-primary) 18%, transparent);
}
.ctx-crumb-milestone {
color: var(--color-text-secondary);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
}
.meta {
font-size: 0.85rem;
color: var(--color-text-muted);
+6 -28
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from "vue";
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useNotesStore } from "@/stores/notes";
import { useListKeyboardNavigation } from "@/composables/useListKeyboardNavigation";
import SearchBar from "@/components/SearchBar.vue";
import NoteCard from "@/components/NoteCard.vue";
import TagPill from "@/components/TagPill.vue";
@@ -14,36 +15,15 @@ const router = useRouter();
const store = useNotesStore();
const searchBarRef = ref<{ focus: () => void } | null>(null);
const activeIndex = ref(-1);
function onFocusSearch() {
searchBarRef.value?.focus();
}
function onKeydown(e: KeyboardEvent) {
const el = document.activeElement;
const tag = el ? (el as HTMLElement).tagName : "";
const isInput = tag === "INPUT" || tag === "TEXTAREA" || (el as HTMLElement)?.isContentEditable;
if (isInput || e.ctrlKey || e.metaKey || e.altKey) return;
const count = store.notes.length;
if (e.key === "j") {
e.preventDefault();
activeIndex.value = Math.min(activeIndex.value + 1, count - 1);
scrollActiveIntoView();
} else if (e.key === "k") {
e.preventDefault();
activeIndex.value = Math.max(activeIndex.value - 1, 0);
scrollActiveIntoView();
} else if (e.key === "Enter" && activeIndex.value >= 0) {
const note = store.notes[activeIndex.value];
if (note) router.push(`/notes/${note.id}`);
}
}
function scrollActiveIntoView() {
const el = document.querySelector(".kb-active-item");
if (el) el.scrollIntoView({ block: "nearest" });
}
const { activeIndex } = useListKeyboardNavigation(
computed(() => store.notes),
(note) => router.push(`/notes/${note.id}`),
);
const viewMode = ref<ViewMode>(
(localStorage.getItem("fabled-notes-view-mode") as ViewMode) ?? "grid"
@@ -63,12 +43,10 @@ onMounted(() => {
store.refresh();
}
document.addEventListener("shortcut:focus-search", onFocusSearch);
document.addEventListener("keydown", onKeydown);
});
onUnmounted(() => {
document.removeEventListener("shortcut:focus-search", onFocusSearch);
document.removeEventListener("keydown", onKeydown);
});
watch(
+27 -171
View File
@@ -1,11 +1,15 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, nextTick } from "vue";
import { useRoute, useRouter, onBeforeRouteLeave } from "vue-router";
import { ref, onMounted, computed, nextTick } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useTasksStore } from "@/stores/tasks";
import { useNotesStore } from "@/stores/notes";
import { useToastStore } from "@/stores/toast";
import { renderMarkdown } from "@/utils/markdown";
import { useAssist } from "@/composables/useAssist";
import { useAutoSave } from "@/composables/useAutoSave";
import { useEditorGuards } from "@/composables/useEditorGuards";
import { useTagSuggestions } from "@/composables/useTagSuggestions";
import { useFloatingAssist } from "@/composables/useFloatingAssist";
import { apiPost, apiGet, apiPatch } from "@/api/client";
import type { TaskStatus, TaskPriority } from "@/types/task";
import type { Editor } from "@tiptap/vue-3";
@@ -16,6 +20,7 @@ import ProjectSelector from "@/components/ProjectSelector.vue";
import MilestoneSelector from "@/components/MilestoneSelector.vue";
import TaskLogSection from "@/components/TaskLogSection.vue";
import InlineAssistPanel from "@/components/InlineAssistPanel.vue";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
const route = useRoute();
const router = useRouter();
@@ -126,32 +131,15 @@ function toggleAssist() {
}
// Floating inline assist button
const floatingAssist = ref({ show: false, top: 0, left: 0 });
const pendingSelection = ref<{ start: number; end: number } | null>(null);
const instructionRef = ref<HTMLTextAreaElement | null>(null);
function onSelectionChange(payload: { text: string; start: number; end: number }) {
if (payload.text.trim().length > 0) {
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
const rect = sel.getRangeAt(0).getBoundingClientRect();
floatingAssist.value = { show: true, top: rect.top - 44, left: rect.left + rect.width / 2 };
pendingSelection.value = { start: payload.start, end: payload.end };
}
} else {
floatingAssist.value.show = false;
pendingSelection.value = null;
const { floatingAssist, onSelectionChange, handleInlineAssist } = useFloatingAssist(
({ start, end }) => {
assist.selectTextRange(start, end);
assistOpen.value = true;
localStorage.setItem(ASSIST_KEY, 'true');
nextTick(() => instructionRef.value?.focus());
}
}
function handleInlineAssist() {
if (!pendingSelection.value) return;
assist.selectTextRange(pendingSelection.value.start, pendingSelection.value.end);
assistOpen.value = true;
localStorage.setItem(ASSIST_KEY, 'true');
floatingAssist.value.show = false;
nextTick(() => instructionRef.value?.focus());
}
);
function handleAssistAccept() {
const newBody = assist.accept();
@@ -168,42 +156,8 @@ function truncateTarget(text: string, max = 60): string {
}
// Tag suggestions
const suggestedTags = ref<string[]>([]);
const appliedTags = ref<Set<string>>(new Set());
const suggestingTags = ref(false);
async function fetchTagSuggestions() {
if (suggestingTags.value) return;
suggestingTags.value = true;
suggestedTags.value = [];
appliedTags.value = new Set();
try {
const res = await apiPost<{ suggested_tags: string[] }>("/api/notes/suggest-tags", {
title: title.value,
body: body.value,
current_tags: tags.value,
});
suggestedTags.value = res.suggested_tags;
} catch {
toast.show("Failed to get tag suggestions", "error");
} finally {
suggestingTags.value = false;
}
}
function applyTagSuggestion(tag: string) {
if (appliedTags.value.has(tag)) return;
if (!tags.value.includes(tag)) {
tags.value = [...tags.value, tag];
}
appliedTags.value.add(tag);
markDirty();
}
function dismissTagSuggestions() {
suggestedTags.value = [];
appliedTags.value = new Set();
}
const { suggestedTags, appliedTags, suggestingTags, fetchTagSuggestions, applyTagSuggestion, dismissTagSuggestions } =
useTagSuggestions(title, body, tags, markDirty);
let savedTitle = "";
let savedBody = "";
@@ -383,10 +337,8 @@ async function confirmDelete() {
}
// Auto-save every 5 minutes when editing an existing task
let autoSaveTimer: ReturnType<typeof setInterval> | null = null;
async function autoSave() {
if (!isEditing.value || !dirty.value || saving.value) return;
async function doAutoSave() {
if (!isEditing.value || saving.value) return;
saving.value = true;
try {
await store.updateTask(taskId.value!, {
@@ -417,33 +369,8 @@ async function autoSave() {
saving.value = false;
}
}
onMounted(() => { autoSaveTimer = setInterval(autoSave, 5 * 60 * 1000); });
onUnmounted(() => { if (autoSaveTimer !== null) clearInterval(autoSaveTimer); });
function onKeydown(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
save();
}
}
onMounted(() => document.addEventListener("keydown", onKeydown));
onUnmounted(() => document.removeEventListener("keydown", onKeydown));
onBeforeRouteLeave(() => {
if (dirty.value) {
return confirm("You have unsaved changes. Leave anyway?");
}
});
function onBeforeUnload(e: BeforeUnloadEvent) {
if (dirty.value) {
e.preventDefault();
}
}
onMounted(() => window.addEventListener("beforeunload", onBeforeUnload));
onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
useAutoSave(dirty, saving, doAutoSave);
useEditorGuards(dirty, save);
</script>
<template>
@@ -710,18 +637,13 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
</teleport>
<!-- Delete confirmation -->
<teleport to="body">
<div v-if="showDeleteConfirm" class="modal-overlay" @click="showDeleteConfirm = false">
<div class="modal-card" @click.stop>
<h3 class="modal-title">Delete Task</h3>
<p class="modal-message">Are you sure you want to delete this task? This cannot be undone.</p>
<div class="modal-actions">
<button class="modal-btn" @click="showDeleteConfirm = false">Cancel</button>
<button class="modal-btn modal-btn-danger" @click="confirmDelete">Delete</button>
</div>
</div>
</div>
</teleport>
<ConfirmDialog
v-if="showDeleteConfirm"
title="Delete Task"
message="Are you sure you want to delete this task? This cannot be undone."
@confirm="confirmDelete"
@cancel="showDeleteConfirm = false"
/>
</main>
</template>
@@ -777,66 +699,6 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
flex-direction: column;
}
/* Mobile accordion toggle (hidden on desktop) */
.sidebar-toggle {
display: none;
width: 100%;
padding: 0.6rem 1rem;
background: var(--color-bg-secondary);
border: none;
border-bottom: 1px solid var(--color-border);
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-secondary);
cursor: pointer;
text-align: left;
font-family: inherit;
}
.sidebar-content {
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.65rem;
}
/* Sidebar field layout */
.sb-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.sb-label {
font-size: 0.78rem;
font-weight: 600;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.sb-select,
.sb-input {
width: 100%;
padding: 0.35rem 0.5rem;
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text);
font-size: 0.875rem;
font-family: inherit;
box-sizing: border-box;
}
.sb-select:focus,
.sb-input:focus {
outline: none;
border-color: var(--color-primary);
}
.sb-divider {
height: 1px;
background: var(--color-border);
margin: 0.15rem 0;
}
/* Parent task search */
.parent-search-wrapper { position: relative; }
.parent-input-row { display: flex; align-items: center; gap: 0.25rem; }
@@ -985,11 +847,5 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
border-top: 1px solid var(--color-border);
overflow-y: visible;
}
.sidebar-toggle { display: block; }
.sidebar-content {
display: none;
padding: 0.75rem 1rem;
}
.sidebar-content.sidebar-open { display: flex; }
}
</style>
+1 -42
View File
@@ -315,6 +315,7 @@ const subTaskProgress = computed(() => {
</div>
</template>
<style src="@/assets/viewer-shared.css" />
<style scoped>
.viewer-layout {
display: flex;
@@ -380,48 +381,6 @@ const subTaskProgress = computed(() => {
cursor: default;
}
/* Context breadcrumb */
.context-bar {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
margin-bottom: 0.6rem;
}
.ctx-crumb {
display: inline-flex;
align-items: center;
font-size: 0.8rem;
padding: 0.15rem 0.55rem;
border-radius: 999px;
white-space: nowrap;
}
.ctx-crumb-parent {
color: var(--color-text-muted);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
text-decoration: none;
}
.ctx-crumb-parent:hover {
color: var(--color-primary);
border-color: var(--color-primary);
}
.ctx-crumb-project {
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
text-decoration: none;
font-weight: 500;
}
.ctx-crumb-project:hover {
background: color-mix(in srgb, var(--color-primary) 18%, transparent);
}
.ctx-crumb-milestone {
color: var(--color-text-secondary);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
}
.meta {
font-size: 0.85rem;
color: var(--color-text-muted);
+9 -24
View File
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from "vue-router";
import { useTasksStore } from "@/stores/tasks";
import type { Task, TaskStatus } from "@/types/task";
import { apiGet } from "@/api/client";
import { useListKeyboardNavigation } from "@/composables/useListKeyboardNavigation";
import SearchBar from "@/components/SearchBar.vue";
import TaskCard from "@/components/TaskCard.vue";
import TagPill from "@/components/TagPill.vue";
@@ -16,33 +17,11 @@ const router = useRouter();
const store = useTasksStore();
const searchBarRef = ref<{ focus: () => void } | null>(null);
const activeIndex = ref(-1);
function onFocusSearch() {
searchBarRef.value?.focus();
}
function onKeydown(e: KeyboardEvent) {
if (viewMode.value !== "flat") return;
const el = document.activeElement;
const tag = el ? (el as HTMLElement).tagName : "";
const isInput = tag === "INPUT" || tag === "TEXTAREA" || (el as HTMLElement)?.isContentEditable;
if (isInput || e.ctrlKey || e.metaKey || e.altKey) return;
const count = store.tasks.length;
if (e.key === "j") {
e.preventDefault();
activeIndex.value = Math.min(activeIndex.value + 1, count - 1);
document.querySelector(".kb-active-item")?.scrollIntoView({ block: "nearest" });
} else if (e.key === "k") {
e.preventDefault();
activeIndex.value = Math.max(activeIndex.value - 1, 0);
document.querySelector(".kb-active-item")?.scrollIntoView({ block: "nearest" });
} else if (e.key === "Enter" && activeIndex.value >= 0) {
const task = store.tasks[activeIndex.value];
if (task) router.push(`/tasks/${task.id}`);
}
}
const viewMode = ref<ViewMode>(
(localStorage.getItem("fabled-tasks-view-mode") as ViewMode) ?? "flat"
);
@@ -60,6 +39,14 @@ function setViewMode(mode: ViewMode) {
store.refresh();
}
const isFlat = computed(() => viewMode.value === "flat");
const { activeIndex } = useListKeyboardNavigation(
computed(() => store.tasks),
(task) => router.push(`/tasks/${task.id}`),
".kb-active-item",
isFlat,
);
// Project map for group labels and task breadcrumbs
const projectMap = ref<Map<number, string>>(new Map());
@@ -119,12 +106,10 @@ onMounted(async () => {
}
await Promise.all([store.refresh(), loadProjects()]);
document.addEventListener("shortcut:focus-search", onFocusSearch);
document.addEventListener("keydown", onKeydown);
});
onUnmounted(() => {
document.removeEventListener("shortcut:focus-search", onFocusSearch);
document.removeEventListener("keydown", onKeydown);
});
watch(