diff --git a/frontend/src/assets/editor-shared.css b/frontend/src/assets/editor-shared.css index 769f498..80ce3e8 100644 --- a/frontend/src/assets/editor-shared.css +++ b/frontend/src/assets/editor-shared.css @@ -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 { diff --git a/frontend/src/assets/viewer-shared.css b/frontend/src/assets/viewer-shared.css new file mode 100644 index 0000000..d643d08 --- /dev/null +++ b/frontend/src/assets/viewer-shared.css @@ -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); +} diff --git a/frontend/src/components/ConfirmDialog.vue b/frontend/src/components/ConfirmDialog.vue new file mode 100644 index 0000000..aa8400d --- /dev/null +++ b/frontend/src/components/ConfirmDialog.vue @@ -0,0 +1,37 @@ + + + + + + + {{ title }} + {{ message }} + + Cancel + {{ confirmLabel }} + + + + + diff --git a/frontend/src/composables/useAutoSave.ts b/frontend/src/composables/useAutoSave.ts new file mode 100644 index 0000000..1b78bd7 --- /dev/null +++ b/frontend/src/composables/useAutoSave.ts @@ -0,0 +1,22 @@ +import { onMounted, onUnmounted } from "vue"; +import type { Ref } from "vue"; + +export function useAutoSave( + dirty: Ref, + saving: Ref, + saveFn: () => Promise, + intervalMs = 5 * 60 * 1000, +): void { + let timer: ReturnType | null = null; + + onMounted(() => { + timer = setInterval(async () => { + if (!dirty.value || saving.value) return; + await saveFn(); + }, intervalMs); + }); + + onUnmounted(() => { + if (timer !== null) clearInterval(timer); + }); +} diff --git a/frontend/src/composables/useEditorGuards.ts b/frontend/src/composables/useEditorGuards.ts new file mode 100644 index 0000000..caff83a --- /dev/null +++ b/frontend/src/composables/useEditorGuards.ts @@ -0,0 +1,33 @@ +import { onMounted, onUnmounted } from "vue"; +import { onBeforeRouteLeave } from "vue-router"; +import type { Ref } from "vue"; + +export function useEditorGuards( + dirty: Ref, + saveFn: () => void | Promise, +): 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?"); + }); +} diff --git a/frontend/src/composables/useFloatingAssist.ts b/frontend/src/composables/useFloatingAssist.ts new file mode 100644 index 0000000..781bc80 --- /dev/null +++ b/frontend/src/composables/useFloatingAssist.ts @@ -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(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 }; +} diff --git a/frontend/src/composables/useListKeyboardNavigation.ts b/frontend/src/composables/useListKeyboardNavigation.ts new file mode 100644 index 0000000..9d4e841 --- /dev/null +++ b/frontend/src/composables/useListKeyboardNavigation.ts @@ -0,0 +1,39 @@ +import { ref, onMounted, onUnmounted } from "vue"; +import type { Ref } from "vue"; + +export function useListKeyboardNavigation( + items: Ref, + navigateTo: (item: T) => void, + rowSelector = ".kb-active-item", + enabled?: Ref, +) { + 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 }; +} diff --git a/frontend/src/composables/useTagSuggestions.ts b/frontend/src/composables/useTagSuggestions.ts new file mode 100644 index 0000000..9c48c64 --- /dev/null +++ b/frontend/src/composables/useTagSuggestions.ts @@ -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, + body: Ref, + tags: Ref, + markDirty: () => void, +) { + const suggestedTags = ref([]); + const appliedTags = ref>(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, + }; +} diff --git a/frontend/src/views/NoteEditorView.vue b/frontend/src/views/NoteEditorView.vue index 3b7cd09..6103275 100644 --- a/frontend/src/views/NoteEditorView.vue +++ b/frontend/src/views/NoteEditorView.vue @@ -1,11 +1,15 @@ @@ -710,18 +637,13 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload)); - - - - Delete Task - Are you sure you want to delete this task? This cannot be undone. - - Cancel - Delete - - - - + @@ -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; } } \ No newline at end of file diff --git a/frontend/src/views/TaskViewerView.vue b/frontend/src/views/TaskViewerView.vue index 4494218..4810b5e 100644 --- a/frontend/src/views/TaskViewerView.vue +++ b/frontend/src/views/TaskViewerView.vue @@ -315,6 +315,7 @@ const subTaskProgress = computed(() => { +
{{ message }}
Are you sure you want to delete this task? This cannot be undone.