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;
|
||||
}
|
||||
|
||||
/* ── 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 {
|
||||
|
||||
@@ -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">
|
||||
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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user