965 lines
31 KiB
Vue
965 lines
31 KiB
Vue
<script setup lang="ts">
|
|
import { ref, onMounted, computed, nextTick, onUnmounted } 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 { Note } from "@/types/note";
|
|
import type { Editor } from "@tiptap/vue-3";
|
|
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
|
|
import TiptapEditor from "@/components/TiptapEditor.vue";
|
|
import WordCount from "@/components/WordCount.vue";
|
|
import TagInput from "@/components/TagInput.vue";
|
|
import ProjectSelector from "@/components/ProjectSelector.vue";
|
|
import MilestoneSelector from "@/components/MilestoneSelector.vue";
|
|
import TaskLogSection from "@/components/TaskLogSection.vue";
|
|
import DiffView from "@/components/DiffView.vue";
|
|
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
|
import VersionHistorySection from "@/components/VersionHistorySection.vue";
|
|
import RecurrenceEditor from "@/components/RecurrenceEditor.vue";
|
|
|
|
const route = useRoute();
|
|
const router = useRouter();
|
|
const store = useTasksStore();
|
|
const notesStore = useNotesStore();
|
|
const toast = useToastStore();
|
|
|
|
const title = ref("");
|
|
const body = ref("");
|
|
const tags = ref<string[]>([]);
|
|
const status = ref<TaskStatus>("todo");
|
|
const priority = ref<TaskPriority>("none");
|
|
const dueDate = ref("");
|
|
const projectId = ref<number | null>(null);
|
|
const milestoneId = ref<number | null>(null);
|
|
const parentId = ref<number | null>(null);
|
|
const parentTitle = ref("");
|
|
const startedAt = ref<string | null>(null);
|
|
const completedAt = ref<string | null>(null);
|
|
const recurrenceRule = ref<Record<string, unknown> | null>(null);
|
|
const parentSearchQuery = ref("");
|
|
const parentSearchResults = ref<{ id: number; title: string }[]>([]);
|
|
const parentSearchLoading = ref(false);
|
|
const showParentDropdown = ref(false);
|
|
let parentSearchTimer: ReturnType<typeof setTimeout> | null = null;
|
|
const dirty = ref(false);
|
|
const saving = ref(false);
|
|
|
|
// Sub-tasks
|
|
interface SubTask { id: number; title: string; status: string }
|
|
const subTasks = ref<SubTask[]>([]);
|
|
const subTasksLoading = ref(false);
|
|
const addingSubTask = ref(false);
|
|
const newSubTaskTitle = ref("");
|
|
|
|
async function loadSubTasks() {
|
|
if (!taskId.value) return;
|
|
subTasksLoading.value = true;
|
|
try {
|
|
const data = await apiGet<{ notes: SubTask[] }>(
|
|
`/api/notes?parent_id=${taskId.value}&is_task=true&limit=100`
|
|
);
|
|
subTasks.value = data.notes;
|
|
} catch {
|
|
// silent
|
|
} finally {
|
|
subTasksLoading.value = false;
|
|
}
|
|
}
|
|
|
|
async function createSubTask() {
|
|
const title = newSubTaskTitle.value.trim();
|
|
if (!title || !taskId.value) return;
|
|
try {
|
|
const created = await apiPost<SubTask>("/api/notes", {
|
|
title,
|
|
status: "todo",
|
|
priority: "none",
|
|
project_id: projectId.value,
|
|
milestone_id: milestoneId.value,
|
|
parent_id: taskId.value,
|
|
});
|
|
subTasks.value = [...subTasks.value, created];
|
|
newSubTaskTitle.value = "";
|
|
addingSubTask.value = false;
|
|
} catch {
|
|
toast.show("Failed to create sub-task", "error");
|
|
}
|
|
}
|
|
|
|
async function toggleSubTask(sub: SubTask) {
|
|
const newStatus = sub.status === "done" ? "todo" : "done";
|
|
try {
|
|
await apiPatch(`/api/notes/${sub.id}`, { status: newStatus });
|
|
sub.status = newStatus;
|
|
} catch {
|
|
toast.show("Failed to update sub-task", "error");
|
|
}
|
|
}
|
|
const showPreview = ref(false);
|
|
const sidebarOpen = ref(true);
|
|
const editorRef = ref<InstanceType<typeof TiptapEditor> | null>(null);
|
|
const titleRef = ref<HTMLInputElement | null>(null);
|
|
const tiptapEditor = computed<Editor | null>(() => {
|
|
return (editorRef.value?.editor as Editor | undefined) ?? null;
|
|
});
|
|
|
|
const taskId = computed(() =>
|
|
route.params.id ? Number(route.params.id) : null
|
|
);
|
|
const isEditing = computed(() => taskId.value !== null);
|
|
|
|
const renderedPreview = computed(() => renderMarkdown(body.value));
|
|
|
|
// AI Assist
|
|
const assist = useAssist(body, taskId, projectId);
|
|
|
|
// Scope selector — matches note editor pattern
|
|
const scopeOptions = computed(() => {
|
|
const opts: Array<{ value: string; label: string }> = [
|
|
{ value: "__document__", label: "Whole document" },
|
|
];
|
|
for (const section of assist.sections.value) {
|
|
opts.push({
|
|
value: String(assist.sections.value.indexOf(section)),
|
|
label: section.heading || "(preamble)",
|
|
});
|
|
}
|
|
return opts;
|
|
});
|
|
|
|
const scopeSelectValue = computed({
|
|
get() {
|
|
if (assist.scopeMode.value === "document") return "__document__";
|
|
if (assist.selectedSection.value) {
|
|
const idx = assist.sections.value.indexOf(assist.selectedSection.value);
|
|
return idx >= 0 ? String(idx) : "__document__";
|
|
}
|
|
return "__document__";
|
|
},
|
|
set(val: string) {
|
|
if (val === "__document__") {
|
|
assist.scopeMode.value = "document";
|
|
assist.selectedSection.value = null;
|
|
} else {
|
|
const idx = Number(val);
|
|
const section = assist.sections.value[idx];
|
|
if (section) {
|
|
assist.scopeMode.value = "section";
|
|
assist.selectSection(section);
|
|
}
|
|
}
|
|
},
|
|
});
|
|
|
|
// Floating inline assist button
|
|
const instructionRef = ref<HTMLTextAreaElement | null>(null);
|
|
const { floatingAssist, onSelectionChange, handleInlineAssist } = useFloatingAssist(
|
|
({ start, end }) => {
|
|
assist.scopeMode.value = "section";
|
|
assist.selectTextRange(start, end);
|
|
nextTick(() => instructionRef.value?.focus());
|
|
}
|
|
);
|
|
|
|
function handleAssistAccept() {
|
|
const newBody = assist.accept();
|
|
body.value = newBody;
|
|
markDirty();
|
|
toast.show("Task updated");
|
|
}
|
|
|
|
onUnmounted(() => assist.clearSelection());
|
|
|
|
// Tag suggestions
|
|
const { suggestedTags, appliedTags, suggestingTags, fetchTagSuggestions, applyTagSuggestion, dismissTagSuggestions } =
|
|
useTagSuggestions(title, body, tags, markDirty);
|
|
|
|
let savedTitle = "";
|
|
let savedBody = "";
|
|
let savedTags: string[] = [];
|
|
let savedStatus: TaskStatus = "todo";
|
|
let savedPriority: TaskPriority = "none";
|
|
let savedDueDate = "";
|
|
let savedProjectId: number | null = null;
|
|
let savedMilestoneId: number | null = null;
|
|
let savedParentId: number | null = null;
|
|
|
|
function markDirty() {
|
|
dirty.value =
|
|
title.value !== savedTitle ||
|
|
body.value !== savedBody ||
|
|
JSON.stringify(tags.value) !== JSON.stringify(savedTags) ||
|
|
status.value !== savedStatus ||
|
|
priority.value !== savedPriority ||
|
|
dueDate.value !== savedDueDate ||
|
|
projectId.value !== savedProjectId ||
|
|
milestoneId.value !== savedMilestoneId ||
|
|
parentId.value !== savedParentId;
|
|
}
|
|
|
|
function onBodyUpdate(newVal: string) {
|
|
body.value = newVal;
|
|
markDirty();
|
|
}
|
|
|
|
function onParentSearchInput() {
|
|
if (parentSearchTimer) clearTimeout(parentSearchTimer);
|
|
if (!parentSearchQuery.value.trim()) {
|
|
parentSearchResults.value = [];
|
|
showParentDropdown.value = false;
|
|
return;
|
|
}
|
|
parentSearchTimer = setTimeout(async () => {
|
|
const q = parentSearchQuery.value.trim();
|
|
if (!q) return;
|
|
parentSearchLoading.value = true;
|
|
showParentDropdown.value = true;
|
|
try {
|
|
const data = await apiGet<{ notes: Array<{ id: number; title: string }> }>(
|
|
`/api/notes?q=${encodeURIComponent(q)}&type=task&limit=8`
|
|
);
|
|
parentSearchResults.value = data.notes.filter((t) =>
|
|
!taskId.value || t.id !== taskId.value
|
|
);
|
|
} catch {
|
|
parentSearchResults.value = [];
|
|
} finally {
|
|
parentSearchLoading.value = false;
|
|
}
|
|
}, 250);
|
|
}
|
|
|
|
function selectParentTask(task: { id: number; title: string }) {
|
|
parentId.value = task.id;
|
|
parentTitle.value = task.title;
|
|
parentSearchQuery.value = task.title;
|
|
showParentDropdown.value = false;
|
|
markDirty();
|
|
}
|
|
|
|
function onParentFocus() {
|
|
if (parentSearchQuery.value) showParentDropdown.value = true;
|
|
}
|
|
|
|
function hideParentDropdown() {
|
|
setTimeout(() => { showParentDropdown.value = false; }, 200);
|
|
}
|
|
|
|
function clearParentTask() {
|
|
parentId.value = null;
|
|
parentTitle.value = "";
|
|
parentSearchQuery.value = "";
|
|
parentSearchResults.value = [];
|
|
showParentDropdown.value = false;
|
|
markDirty();
|
|
}
|
|
|
|
onMounted(async () => {
|
|
if (taskId.value) {
|
|
await store.fetchTask(taskId.value);
|
|
if (store.currentTask) {
|
|
title.value = store.currentTask.title;
|
|
body.value = store.currentTask.body;
|
|
tags.value = [...(store.currentTask.tags || [])];
|
|
status.value = store.currentTask.status as TaskStatus;
|
|
priority.value = store.currentTask.priority as TaskPriority;
|
|
dueDate.value = store.currentTask.due_date || "";
|
|
const taskRec = store.currentTask as Record<string, unknown>;
|
|
projectId.value = (taskRec.project_id as number | null) ?? null;
|
|
milestoneId.value = (taskRec.milestone_id as number | null) ?? null;
|
|
parentId.value = (taskRec.parent_id as number | null) ?? null;
|
|
parentTitle.value = (taskRec.parent_title as string | null) ?? "";
|
|
parentSearchQuery.value = parentTitle.value;
|
|
const noteTask = store.currentTask as unknown as Note;
|
|
startedAt.value = noteTask.started_at ?? null;
|
|
completedAt.value = noteTask.completed_at ?? null;
|
|
recurrenceRule.value = noteTask.recurrence_rule ?? null;
|
|
savedTitle = title.value;
|
|
savedBody = body.value;
|
|
savedTags = [...tags.value];
|
|
savedStatus = status.value;
|
|
savedPriority = priority.value;
|
|
savedDueDate = dueDate.value;
|
|
savedProjectId = projectId.value;
|
|
savedMilestoneId = milestoneId.value;
|
|
savedParentId = parentId.value;
|
|
// Start in preview mode only if the task already has body content
|
|
showPreview.value = body.value.trim().length > 0;
|
|
}
|
|
loadSubTasks();
|
|
} else {
|
|
// Pre-fill from query params when creating a new task
|
|
if (route.query.projectId) projectId.value = Number(route.query.projectId);
|
|
if (route.query.milestoneId) milestoneId.value = Number(route.query.milestoneId);
|
|
if (route.query.parentId) parentId.value = Number(route.query.parentId);
|
|
}
|
|
});
|
|
|
|
async function save() {
|
|
if (saving.value) return;
|
|
saving.value = true;
|
|
try {
|
|
const data = {
|
|
title: title.value,
|
|
body: body.value,
|
|
tags: tags.value,
|
|
status: status.value,
|
|
priority: priority.value,
|
|
due_date: dueDate.value || null,
|
|
project_id: projectId.value,
|
|
milestone_id: milestoneId.value,
|
|
parent_id: parentId.value,
|
|
recurrence_rule: recurrenceRule.value,
|
|
};
|
|
if (isEditing.value) {
|
|
await store.updateTask(taskId.value!, data);
|
|
savedTitle = title.value;
|
|
savedBody = body.value;
|
|
savedTags = [...tags.value];
|
|
savedStatus = status.value;
|
|
savedPriority = priority.value;
|
|
savedDueDate = dueDate.value;
|
|
savedProjectId = projectId.value;
|
|
savedMilestoneId = milestoneId.value;
|
|
savedParentId = parentId.value;
|
|
dirty.value = false;
|
|
toast.show("Task saved");
|
|
router.push(`/tasks/${taskId.value}`);
|
|
} else {
|
|
const task = await store.createTask(data);
|
|
dirty.value = false;
|
|
toast.show("Task created");
|
|
router.push(`/tasks/${task.id}`);
|
|
}
|
|
} catch {
|
|
toast.show("Failed to save task", "error");
|
|
} finally {
|
|
saving.value = false;
|
|
}
|
|
}
|
|
|
|
const showDeleteConfirm = ref(false);
|
|
|
|
function remove() {
|
|
if (taskId.value) {
|
|
showDeleteConfirm.value = true;
|
|
}
|
|
}
|
|
|
|
async function confirmDelete() {
|
|
showDeleteConfirm.value = false;
|
|
if (!taskId.value) return;
|
|
try {
|
|
await store.deleteTask(taskId.value);
|
|
dirty.value = false;
|
|
toast.show("Task deleted");
|
|
router.push(projectId.value ? `/projects/${projectId.value}` : "/");
|
|
} catch {
|
|
toast.show("Failed to delete task", "error");
|
|
}
|
|
}
|
|
|
|
// Auto-save every 5 minutes when editing an existing task
|
|
async function doAutoSave() {
|
|
if (!isEditing.value || saving.value) return;
|
|
saving.value = true;
|
|
try {
|
|
await store.updateTask(taskId.value!, {
|
|
title: title.value,
|
|
body: body.value,
|
|
tags: tags.value,
|
|
status: status.value,
|
|
priority: priority.value,
|
|
due_date: dueDate.value || null,
|
|
project_id: projectId.value,
|
|
milestone_id: milestoneId.value,
|
|
parent_id: parentId.value,
|
|
recurrence_rule: recurrenceRule.value,
|
|
} as Record<string, unknown>);
|
|
savedTitle = title.value;
|
|
savedBody = body.value;
|
|
savedTags = [...tags.value];
|
|
savedStatus = status.value;
|
|
savedPriority = priority.value;
|
|
savedDueDate = dueDate.value;
|
|
savedProjectId = projectId.value;
|
|
savedMilestoneId = milestoneId.value;
|
|
savedParentId = parentId.value;
|
|
dirty.value = false;
|
|
toast.show("Auto-saved");
|
|
} catch {
|
|
// Silent — user can still save manually
|
|
} finally {
|
|
saving.value = false;
|
|
}
|
|
}
|
|
useAutoSave(dirty, saving, doAutoSave);
|
|
useEditorGuards(dirty, save);
|
|
</script>
|
|
|
|
<template>
|
|
<main class="editor-page task-editor-page">
|
|
<div class="editor-header">
|
|
<div class="toolbar">
|
|
<router-link :to="projectId ? `/projects/${projectId}` : '/'" class="btn-back">← {{ projectId ? 'Project' : 'Knowledge' }}</router-link>
|
|
<button class="btn-save" @click="save" :disabled="saving">
|
|
{{ saving ? "Saving..." : "Save" }}
|
|
</button>
|
|
<button v-if="isEditing" class="btn-delete" @click="remove">Delete</button>
|
|
<WordCount :body="body" />
|
|
</div>
|
|
<input
|
|
v-model="title"
|
|
type="text"
|
|
placeholder="Task title"
|
|
ref="titleRef"
|
|
class="title-input"
|
|
@input="markDirty"
|
|
@keydown.ctrl.s.prevent="save"
|
|
@keydown.ctrl.e.prevent="tiptapEditor?.commands.focus()"
|
|
/>
|
|
|
|
</div><!-- /editor-header: title only -->
|
|
|
|
<!-- Two-column body: main (editor+log) | sidebar (metadata) -->
|
|
<div class="task-body">
|
|
|
|
<!-- ── Main column ─────────────────────────────────────────── -->
|
|
<div class="task-main" @keydown.ctrl.e.prevent="tiptapEditor?.commands.focus()">
|
|
|
|
<!-- Write / Preview tabs + toolbar sit above the editor -->
|
|
<div class="body-tabs-row">
|
|
<div class="editor-tabs">
|
|
<button :class="['tab', { active: !showPreview }]" @click="showPreview = false">Write</button>
|
|
<button :class="['tab', { active: showPreview }]" @click="showPreview = true">Preview</button>
|
|
</div>
|
|
<MarkdownToolbar v-show="!showPreview && assist.state.value === 'idle'" :editor="tiptapEditor" />
|
|
</div>
|
|
|
|
<!-- Streaming preview -->
|
|
<template v-if="assist.state.value === 'streaming'">
|
|
<div class="stream-label">Generating...</div>
|
|
<div class="stream-preview prose" v-html="renderMarkdown(assist.streamingText.value)" />
|
|
</template>
|
|
|
|
<!-- Review: full-document diff -->
|
|
<template v-else-if="assist.state.value === 'review'">
|
|
<DiffView :diff="assist.diff.value" class="main-diff" />
|
|
</template>
|
|
|
|
<!-- Normal: editor or preview -->
|
|
<template v-else>
|
|
<div v-show="!showPreview" class="body-editor-wrap">
|
|
<TiptapEditor
|
|
ref="editorRef"
|
|
:modelValue="body"
|
|
placeholder="Describe this task..."
|
|
@update:modelValue="onBodyUpdate"
|
|
@selectionChange="onSelectionChange"
|
|
@escape="titleRef?.focus()"
|
|
/>
|
|
</div>
|
|
<div v-show="showPreview" class="preview-pane prose" v-html="renderedPreview" />
|
|
</template>
|
|
|
|
<div v-if="assist.error.value" class="assist-error">{{ assist.error.value }}</div>
|
|
|
|
<!-- Work log -->
|
|
<TaskLogSection v-if="taskId" :task-id="taskId" class="body-log" />
|
|
</div>
|
|
|
|
<!-- ── Sidebar ──────────────────────────────────────────────── -->
|
|
<aside class="task-sidebar">
|
|
|
|
<!-- Mobile accordion toggle -->
|
|
<button class="sidebar-toggle" @click="sidebarOpen = !sidebarOpen">
|
|
Details {{ sidebarOpen ? '▴' : '▾' }}
|
|
</button>
|
|
|
|
<div :class="['sidebar-content', { 'sidebar-open': sidebarOpen }]">
|
|
|
|
<!-- Status / Priority / Due date -->
|
|
<div class="sb-field">
|
|
<label class="sb-label">Status</label>
|
|
<select v-model="status" @change="markDirty" class="sb-select">
|
|
<option value="todo">Todo</option>
|
|
<option value="in_progress">In Progress</option>
|
|
<option value="done">Done</option>
|
|
<option value="cancelled">Cancelled</option>
|
|
</select>
|
|
</div>
|
|
<div v-if="startedAt || completedAt" class="sb-timestamps">
|
|
<div v-if="startedAt" class="sb-timestamp">
|
|
<span class="sb-ts-label">Started</span>
|
|
<span class="sb-ts-value">{{ new Date(startedAt).toLocaleString() }}</span>
|
|
</div>
|
|
<div v-if="completedAt" class="sb-timestamp">
|
|
<span class="sb-ts-label">Completed</span>
|
|
<span class="sb-ts-value">{{ new Date(completedAt).toLocaleString() }}</span>
|
|
</div>
|
|
</div>
|
|
<div class="sb-field">
|
|
<label class="sb-label">Priority</label>
|
|
<select v-model="priority" @change="markDirty" class="sb-select">
|
|
<option value="none">None</option>
|
|
<option value="low">Low</option>
|
|
<option value="medium">Medium</option>
|
|
<option value="high">High</option>
|
|
</select>
|
|
</div>
|
|
<div class="sb-field">
|
|
<label class="sb-label">Due Date</label>
|
|
<input v-model="dueDate" type="date" class="sb-input" @input="markDirty" />
|
|
</div>
|
|
<div class="sb-field">
|
|
<label class="sb-label">Recurrence</label>
|
|
<RecurrenceEditor v-model="recurrenceRule" @update:modelValue="markDirty" />
|
|
</div>
|
|
<div class="sb-field">
|
|
<label class="sb-label">Project</label>
|
|
<ProjectSelector v-model="projectId" @update:modelValue="markDirty" />
|
|
</div>
|
|
<div class="sb-field">
|
|
<label class="sb-label">Milestone</label>
|
|
<MilestoneSelector :projectId="projectId" v-model="milestoneId" @update:modelValue="markDirty" />
|
|
</div>
|
|
|
|
<!-- Parent task -->
|
|
<div class="sb-field">
|
|
<label class="sb-label">Parent Task</label>
|
|
<div class="parent-search-wrapper">
|
|
<div class="parent-input-row">
|
|
<input
|
|
v-model="parentSearchQuery"
|
|
type="text"
|
|
class="sb-input"
|
|
placeholder="Search tasks..."
|
|
@input="onParentSearchInput"
|
|
@focus="onParentFocus"
|
|
@blur="hideParentDropdown"
|
|
/>
|
|
<button v-if="parentId" class="btn-clear-parent" @click="clearParentTask" title="Clear">×</button>
|
|
</div>
|
|
<div v-if="showParentDropdown" class="parent-dropdown">
|
|
<div v-if="parentSearchLoading" class="parent-dropdown-item parent-empty">Searching...</div>
|
|
<div
|
|
v-for="task in parentSearchResults"
|
|
:key="task.id"
|
|
class="parent-dropdown-item"
|
|
@mousedown.prevent="selectParentTask(task)"
|
|
>{{ task.title || "Untitled" }}</div>
|
|
<div v-if="!parentSearchLoading && parentSearchResults.length === 0" class="parent-dropdown-item parent-empty">No tasks found</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="sb-divider"></div>
|
|
|
|
<!-- Sub-tasks -->
|
|
<div v-if="isEditing" class="subtasks-section">
|
|
<div class="subtasks-header">
|
|
<span class="subtasks-label">Sub-tasks</span>
|
|
<button class="btn-add-subtask" @click="addingSubTask = !addingSubTask">+ Add</button>
|
|
</div>
|
|
<div v-if="subTasksLoading" class="subtasks-loading">Loading...</div>
|
|
<template v-else>
|
|
<div v-for="sub in subTasks" :key="sub.id" class="subtask-row">
|
|
<input type="checkbox" :checked="sub.status === 'done'" @change="toggleSubTask(sub)" class="subtask-checkbox" />
|
|
<router-link :to="`/tasks/${sub.id}`" :class="['subtask-title', { done: sub.status === 'done' }]">
|
|
{{ sub.title || "Untitled" }}
|
|
</router-link>
|
|
</div>
|
|
<p v-if="!subTasks.length && !addingSubTask" class="subtasks-empty">No sub-tasks yet.</p>
|
|
<div v-if="addingSubTask" class="subtask-add-row">
|
|
<input
|
|
v-model="newSubTaskTitle"
|
|
class="subtask-input"
|
|
placeholder="Sub-task title"
|
|
@keydown.enter="createSubTask"
|
|
@keydown.escape="addingSubTask = false; newSubTaskTitle = ''"
|
|
autofocus
|
|
/>
|
|
<button class="btn-subtask-confirm" @click="createSubTask" :disabled="!newSubTaskTitle.trim()">Add</button>
|
|
<button class="btn-subtask-cancel" @click="addingSubTask = false; newSubTaskTitle = ''">Cancel</button>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<div class="sb-divider"></div>
|
|
|
|
<!-- Tags -->
|
|
<div class="sb-field">
|
|
<label class="sb-label">Tags</label>
|
|
<TagInput
|
|
v-model="tags"
|
|
:fetchTags="(q: string) => notesStore.fetchAllTags(q)"
|
|
@update:modelValue="markDirty"
|
|
/>
|
|
</div>
|
|
<div class="tag-suggest-row">
|
|
<button class="btn-suggest-tags" @click="fetchTagSuggestions" :disabled="suggestingTags">
|
|
{{ suggestingTags ? "Suggesting..." : "Suggest tags" }}
|
|
</button>
|
|
<template v-if="suggestedTags.length > 0">
|
|
<button
|
|
v-for="tag in suggestedTags"
|
|
:key="tag"
|
|
:class="['tag-pill', { applied: appliedTags.has(tag) }]"
|
|
:disabled="appliedTags.has(tag)"
|
|
@click="applyTagSuggestion(tag)"
|
|
>
|
|
#{{ tag }}
|
|
<span v-if="appliedTags.has(tag)" class="tag-check">✓</span>
|
|
</button>
|
|
<button class="btn-dismiss-tags" aria-label="Dismiss tag suggestions" @click="dismissTagSuggestions">×</button>
|
|
</template>
|
|
</div>
|
|
|
|
<div class="sb-divider"></div>
|
|
|
|
<!-- Writing Assistant -->
|
|
<div class="assist-section">
|
|
<div class="assist-section-title">✨ Writing Assistant</div>
|
|
|
|
<div class="sb-field">
|
|
<label class="sb-label">Scope</label>
|
|
<select v-model="scopeSelectValue" class="sb-select" :disabled="assist.state.value === 'streaming'">
|
|
<option v-for="opt in scopeOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
|
|
</select>
|
|
</div>
|
|
|
|
<template v-if="assist.state.value === 'idle'">
|
|
<textarea
|
|
ref="instructionRef"
|
|
v-model="assist.instruction.value"
|
|
placeholder="What should I do?"
|
|
class="assist-instruction"
|
|
rows="3"
|
|
@keydown.enter.exact.prevent="assist.canSubmit.value && assist.submit()"
|
|
></textarea>
|
|
<div class="assist-input-actions">
|
|
<button class="btn-generate" @click="assist.submit()" :disabled="!assist.canSubmit.value">Generate</button>
|
|
<button class="btn-proofread" @click="assist.proofread()">Proofread</button>
|
|
</div>
|
|
</template>
|
|
|
|
<template v-else-if="assist.state.value === 'streaming'">
|
|
<div class="assist-active-hint">Generating… see main area</div>
|
|
<button class="btn-clear" @click="assist.clearSelection()">Cancel</button>
|
|
</template>
|
|
|
|
<template v-else-if="assist.state.value === 'review'">
|
|
<div class="assist-active-hint">Review the diff in the main area</div>
|
|
<div class="assist-actions">
|
|
<button class="btn-accept" @click="handleAssistAccept">Accept</button>
|
|
<button class="btn-reject" @click="assist.reject()">Reject</button>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<VersionHistorySection
|
|
v-if="taskId"
|
|
:note-id="taskId"
|
|
:current-body="body"
|
|
@restore="(b, t) => { body = b; tags = t; markDirty(); }"
|
|
/>
|
|
|
|
</div><!-- /sidebar-content -->
|
|
</aside><!-- /task-sidebar -->
|
|
|
|
</div><!-- /task-body -->
|
|
|
|
<!-- Floating inline assist button -->
|
|
<teleport to="body">
|
|
<button
|
|
v-if="floatingAssist.show"
|
|
class="inline-assist-btn"
|
|
:style="{ top: floatingAssist.top + 'px', left: floatingAssist.left + 'px' }"
|
|
@mousedown.prevent="handleInlineAssist"
|
|
>✨ Assist</button>
|
|
</teleport>
|
|
|
|
<!-- Delete confirmation -->
|
|
<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>
|
|
|
|
<style src="@/assets/editor-shared.css" />
|
|
<style scoped>
|
|
/* ── Two-column task layout ─────────────────────────────────── */
|
|
.task-editor-page {
|
|
/* override shared max-width; task editor can be wider */
|
|
max-width: 1600px;
|
|
}
|
|
|
|
/* Replace .editor-body for task editor */
|
|
.task-body {
|
|
flex: 1;
|
|
min-height: 0;
|
|
display: flex;
|
|
overflow: hidden;
|
|
position: relative;
|
|
}
|
|
|
|
/* Main column: editor + work log */
|
|
.task-main {
|
|
flex: 1;
|
|
min-width: 0;
|
|
overflow-y: auto;
|
|
padding: 0.75rem 1.25rem 2rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.body-tabs-row {
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
flex-wrap: wrap;
|
|
padding-bottom: 0.5rem;
|
|
border-bottom: 1px solid var(--color-border);
|
|
}
|
|
|
|
.body-editor-wrap {
|
|
min-height: 200px;
|
|
}
|
|
|
|
.body-log {
|
|
/* TaskLogSection already has its own border/bg */
|
|
}
|
|
|
|
/* Right sidebar: metadata fields */
|
|
.task-sidebar {
|
|
width: 280px;
|
|
flex-shrink: 0;
|
|
border-left: 1px solid var(--color-border);
|
|
overflow-y: auto;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
/* Parent task search */
|
|
.parent-search-wrapper { position: relative; }
|
|
.parent-input-row { display: flex; align-items: center; gap: 0.25rem; }
|
|
.parent-input-row .sb-input { flex: 1; }
|
|
.btn-clear-parent {
|
|
background: none;
|
|
border: none;
|
|
cursor: pointer;
|
|
color: var(--color-text-muted);
|
|
font-size: 1.1rem;
|
|
line-height: 1;
|
|
padding: 0 0.2rem;
|
|
flex-shrink: 0;
|
|
}
|
|
.btn-clear-parent:hover { color: var(--color-danger, #e74c3c); }
|
|
.parent-dropdown {
|
|
position: absolute;
|
|
top: calc(100% + 4px);
|
|
left: 0;
|
|
right: 0;
|
|
background: var(--color-bg-card);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-sm);
|
|
box-shadow: 0 4px 12px var(--color-shadow);
|
|
z-index: 50;
|
|
max-height: 200px;
|
|
overflow-y: auto;
|
|
}
|
|
.parent-dropdown-item {
|
|
padding: 0.4rem 0.65rem;
|
|
font-size: 0.85rem;
|
|
cursor: pointer;
|
|
color: var(--color-text);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.parent-dropdown-item:hover { background: var(--color-bg-secondary); }
|
|
.parent-empty { color: var(--color-text-muted); cursor: default; }
|
|
.parent-empty:hover { background: none; }
|
|
|
|
/* Sub-tasks */
|
|
.subtasks-section {
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-sm);
|
|
padding: 0.5rem 0.65rem;
|
|
background: var(--color-bg-secondary);
|
|
}
|
|
.subtasks-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 0.35rem;
|
|
}
|
|
.subtasks-label {
|
|
font-size: 0.75rem;
|
|
font-weight: 600;
|
|
color: var(--color-text-secondary);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
}
|
|
.btn-add-subtask {
|
|
background: none;
|
|
border: none;
|
|
cursor: pointer;
|
|
color: var(--color-primary);
|
|
font-size: 0.78rem;
|
|
font-family: inherit;
|
|
padding: 0.1rem 0.2rem;
|
|
}
|
|
.btn-add-subtask:hover { opacity: 0.8; }
|
|
.subtasks-loading { font-size: 0.78rem; color: var(--color-text-muted); }
|
|
.subtask-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
padding: 0.2rem 0;
|
|
}
|
|
.subtask-checkbox { flex-shrink: 0; cursor: pointer; }
|
|
.subtask-title {
|
|
font-size: 0.83rem;
|
|
color: var(--color-text);
|
|
text-decoration: none;
|
|
flex: 1;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.subtask-title:hover { color: var(--color-primary); }
|
|
.subtask-title.done { text-decoration: line-through; color: var(--color-text-muted); }
|
|
.subtasks-empty { font-size: 0.78rem; color: var(--color-text-muted); margin: 0; }
|
|
.subtask-add-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.3rem;
|
|
margin-top: 0.3rem;
|
|
}
|
|
.subtask-input {
|
|
flex: 1;
|
|
padding: 0.25rem 0.4rem;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-sm);
|
|
background: var(--color-bg);
|
|
color: var(--color-text);
|
|
font-size: 0.83rem;
|
|
font-family: inherit;
|
|
}
|
|
.subtask-input:focus { outline: none; border-color: var(--color-primary); }
|
|
.btn-subtask-confirm {
|
|
padding: 0.25rem 0.5rem;
|
|
background: var(--color-primary);
|
|
color: #fff;
|
|
border: none;
|
|
border-radius: var(--radius-sm);
|
|
cursor: pointer;
|
|
font-size: 0.78rem;
|
|
font-family: inherit;
|
|
}
|
|
.btn-subtask-confirm:disabled { opacity: 0.5; cursor: default; }
|
|
.btn-subtask-cancel {
|
|
padding: 0.25rem 0.5rem;
|
|
background: none;
|
|
border: 1px solid var(--color-border);
|
|
color: var(--color-text-secondary);
|
|
border-radius: var(--radius-sm);
|
|
cursor: pointer;
|
|
font-size: 0.78rem;
|
|
font-family: inherit;
|
|
}
|
|
|
|
/* Streaming preview */
|
|
.stream-label {
|
|
font-size: 0.8rem;
|
|
color: var(--color-text-muted);
|
|
font-style: italic;
|
|
}
|
|
.stream-preview {
|
|
border: 1px solid var(--color-input-border);
|
|
border-radius: var(--radius-sm);
|
|
padding: 0.75rem;
|
|
background: var(--color-bg-card);
|
|
min-height: 200px;
|
|
}
|
|
.main-diff {
|
|
flex: 1;
|
|
min-height: 0;
|
|
}
|
|
|
|
/* Writing Assistant section (in sidebar) */
|
|
.assist-section {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
.assist-section-title {
|
|
font-size: 0.78rem;
|
|
font-weight: 700;
|
|
color: var(--color-text-secondary);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
}
|
|
.assist-actions {
|
|
display: flex;
|
|
gap: 0.4rem;
|
|
}
|
|
|
|
/* Tag suggest row inside sidebar */
|
|
.tag-suggest-row {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.3rem;
|
|
align-items: center;
|
|
}
|
|
|
|
/* Lifecycle timestamps */
|
|
.sb-timestamps {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.2rem;
|
|
margin-top: 0.25rem;
|
|
}
|
|
.sb-timestamp {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 0.4rem;
|
|
font-size: 0.75rem;
|
|
}
|
|
.sb-ts-label {
|
|
color: var(--color-text-muted);
|
|
flex-shrink: 0;
|
|
}
|
|
.sb-ts-value {
|
|
color: var(--color-text-secondary);
|
|
text-align: right;
|
|
}
|
|
|
|
/* Narrow screen: sidebar collapses */
|
|
@media (max-width: 720px) {
|
|
.task-body { flex-direction: column; overflow-y: auto; overflow-x: hidden; }
|
|
.task-main { padding: 0.75rem 1rem 1rem; overflow-y: visible; }
|
|
.task-sidebar {
|
|
width: 100%;
|
|
border-left: none;
|
|
border-top: 1px solid var(--color-border);
|
|
overflow-y: visible;
|
|
}
|
|
}
|
|
</style> |