fb18d2c41d
Improve chat context: build_context() now returns metadata about auto-found notes, emitted as an SSE event so the frontend can display context pills showing which notes influenced the response. Users can promote notes for deeper context (+) or exclude irrelevant ones (x). A note picker lets users manually attach notes. Multi-word search uses per-term AND matching, and auto-search iterates keywords individually for broader OR-style coverage. Standardize styling: introduce CSS design tokens (--radius-sm/md/lg/pill, --color-success/warning/overlay, --focus-ring) and migrate all components to use them. Fix header alignment to full-width, add active nav link state, replace hardcoded colors with CSS variables, and normalize button padding. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
505 lines
12 KiB
Vue
505 lines
12 KiB
Vue
<script setup lang="ts">
|
|
import { ref, onMounted, onUnmounted, computed, nextTick } from "vue";
|
|
import { useRoute, useRouter, onBeforeRouteLeave } from "vue-router";
|
|
import { useTasksStore } from "@/stores/tasks";
|
|
import { useNotesStore } from "@/stores/notes";
|
|
import { useToastStore } from "@/stores/toast";
|
|
import { renderMarkdown } from "@/utils/markdown";
|
|
import { useAutocomplete } from "@/composables/useAutocomplete";
|
|
import type { TaskStatus, TaskPriority } from "@/types/task";
|
|
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
|
|
|
|
const route = useRoute();
|
|
const router = useRouter();
|
|
const store = useTasksStore();
|
|
const notesStore = useNotesStore();
|
|
const toast = useToastStore();
|
|
|
|
const title = ref("");
|
|
const body = ref("");
|
|
const status = ref<TaskStatus>("todo");
|
|
const priority = ref<TaskPriority>("none");
|
|
const dueDate = ref("");
|
|
const dirty = ref(false);
|
|
const saving = ref(false);
|
|
const showPreview = ref(false);
|
|
const textareaRef = ref<HTMLTextAreaElement | null>(null);
|
|
|
|
const taskId = computed(() =>
|
|
route.params.id ? Number(route.params.id) : null
|
|
);
|
|
const isEditing = computed(() => taskId.value !== null);
|
|
|
|
const renderedPreview = computed(() => renderMarkdown(body.value));
|
|
|
|
// Autocomplete
|
|
const {
|
|
acItems,
|
|
acVisible,
|
|
acIndex,
|
|
acTop,
|
|
acLeft,
|
|
detectTrigger,
|
|
accept: acAccept,
|
|
dismiss: acDismiss,
|
|
onKeydown: acOnKeydown,
|
|
} = useAutocomplete(textareaRef, body, (q) => notesStore.fetchAllTags(q));
|
|
|
|
let savedTitle = "";
|
|
let savedBody = "";
|
|
let savedStatus: TaskStatus = "todo";
|
|
let savedPriority: TaskPriority = "none";
|
|
let savedDueDate = "";
|
|
|
|
function markDirty() {
|
|
dirty.value =
|
|
title.value !== savedTitle ||
|
|
body.value !== savedBody ||
|
|
status.value !== savedStatus ||
|
|
priority.value !== savedPriority ||
|
|
dueDate.value !== savedDueDate;
|
|
}
|
|
|
|
function autoGrow() {
|
|
const el = textareaRef.value;
|
|
if (!el) return;
|
|
el.style.height = "auto";
|
|
el.style.height = Math.max(el.scrollHeight, 200) + "px";
|
|
}
|
|
|
|
function onBodyInput() {
|
|
markDirty();
|
|
autoGrow();
|
|
detectTrigger();
|
|
}
|
|
|
|
function onTextareaKeydown(e: KeyboardEvent) {
|
|
acOnKeydown(e);
|
|
}
|
|
|
|
function onTextareaBlur() {
|
|
setTimeout(acDismiss, 150);
|
|
}
|
|
|
|
function insertAtCursor(before: string, after: string, placeholder: string) {
|
|
const el = textareaRef.value;
|
|
if (!el) return;
|
|
showPreview.value = false;
|
|
nextTick(() => {
|
|
el.focus();
|
|
const start = el.selectionStart;
|
|
const end = el.selectionEnd;
|
|
const selected = body.value.slice(start, end);
|
|
const insert = selected || placeholder;
|
|
body.value =
|
|
body.value.slice(0, start) + before + insert + after + body.value.slice(end);
|
|
markDirty();
|
|
nextTick(() => {
|
|
const newStart = start + before.length;
|
|
const newEnd = newStart + insert.length;
|
|
el.setSelectionRange(newStart, newEnd);
|
|
el.focus();
|
|
autoGrow();
|
|
});
|
|
});
|
|
}
|
|
|
|
onMounted(async () => {
|
|
if (taskId.value) {
|
|
await store.fetchTask(taskId.value);
|
|
if (store.currentTask) {
|
|
title.value = store.currentTask.title;
|
|
body.value = store.currentTask.body;
|
|
status.value = store.currentTask.status as TaskStatus;
|
|
priority.value = store.currentTask.priority as TaskPriority;
|
|
dueDate.value = store.currentTask.due_date || "";
|
|
savedTitle = title.value;
|
|
savedBody = body.value;
|
|
savedStatus = status.value;
|
|
savedPriority = priority.value;
|
|
savedDueDate = dueDate.value;
|
|
}
|
|
}
|
|
nextTick(autoGrow);
|
|
});
|
|
|
|
async function save() {
|
|
if (saving.value) return;
|
|
saving.value = true;
|
|
try {
|
|
const data = {
|
|
title: title.value,
|
|
body: body.value,
|
|
status: status.value,
|
|
priority: priority.value,
|
|
due_date: dueDate.value || null,
|
|
};
|
|
if (isEditing.value) {
|
|
await store.updateTask(taskId.value!, data);
|
|
savedTitle = title.value;
|
|
savedBody = body.value;
|
|
savedStatus = status.value;
|
|
savedPriority = priority.value;
|
|
savedDueDate = dueDate.value;
|
|
dirty.value = false;
|
|
toast.show("Task saved");
|
|
} 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("/tasks");
|
|
} catch {
|
|
toast.show("Failed to delete task", "error");
|
|
}
|
|
}
|
|
|
|
function onKeydown(e: KeyboardEvent) {
|
|
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
|
e.preventDefault();
|
|
save();
|
|
}
|
|
}
|
|
|
|
onMounted(() => document.addEventListener("keydown", onKeydown));
|
|
onUnmounted(() => document.removeEventListener("keydown", onKeydown));
|
|
|
|
onBeforeRouteLeave(() => {
|
|
if (dirty.value) {
|
|
return confirm("You have unsaved changes. Leave anyway?");
|
|
}
|
|
});
|
|
|
|
function onBeforeUnload(e: BeforeUnloadEvent) {
|
|
if (dirty.value) {
|
|
e.preventDefault();
|
|
}
|
|
}
|
|
onMounted(() => window.addEventListener("beforeunload", onBeforeUnload));
|
|
onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
|
</script>
|
|
|
|
<template>
|
|
<main class="editor">
|
|
<div class="toolbar">
|
|
<router-link to="/tasks" class="btn-back">Back</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>
|
|
</div>
|
|
<input
|
|
v-model="title"
|
|
type="text"
|
|
placeholder="Task title"
|
|
class="title-input"
|
|
@input="markDirty"
|
|
/>
|
|
|
|
<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" @insert="insertAtCursor" />
|
|
|
|
<div class="textarea-wrapper" v-show="!showPreview">
|
|
<textarea
|
|
ref="textareaRef"
|
|
v-model="body"
|
|
placeholder="Describe this task... Use #tags inline"
|
|
class="body-input"
|
|
@input="onBodyInput"
|
|
@keydown="onTextareaKeydown"
|
|
@blur="onTextareaBlur"
|
|
></textarea>
|
|
|
|
<!-- Autocomplete dropdown -->
|
|
<ul
|
|
v-if="acVisible"
|
|
class="ac-dropdown"
|
|
:style="{ top: acTop + 'px', left: acLeft + 'px' }"
|
|
>
|
|
<li
|
|
v-for="(item, i) in acItems"
|
|
:key="item.value"
|
|
:class="['ac-item', { active: i === acIndex }]"
|
|
@mousedown.prevent="acAccept(i)"
|
|
>
|
|
{{ item.label }}
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div
|
|
v-show="showPreview"
|
|
class="preview-pane prose"
|
|
v-html="renderedPreview"
|
|
></div>
|
|
|
|
<div class="field-row">
|
|
<div class="field">
|
|
<label>Status</label>
|
|
<select v-model="status" @change="markDirty" class="field-select">
|
|
<option value="todo">Todo</option>
|
|
<option value="in_progress">In Progress</option>
|
|
<option value="done">Done</option>
|
|
</select>
|
|
</div>
|
|
<div class="field">
|
|
<label>Priority</label>
|
|
<select v-model="priority" @change="markDirty" class="field-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="field">
|
|
<label>Due Date</label>
|
|
<input
|
|
v-model="dueDate"
|
|
type="date"
|
|
class="field-input"
|
|
@input="markDirty"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 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>
|
|
</main>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.editor {
|
|
max-width: 720px;
|
|
margin: 2rem auto;
|
|
padding: 0 1rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.75rem;
|
|
}
|
|
.toolbar {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
align-items: center;
|
|
}
|
|
.btn-back {
|
|
text-decoration: none;
|
|
color: var(--color-primary);
|
|
}
|
|
.btn-save {
|
|
padding: 0.45rem 1rem;
|
|
background: var(--color-primary);
|
|
color: #fff;
|
|
border: none;
|
|
border-radius: var(--radius-sm);
|
|
cursor: pointer;
|
|
}
|
|
.btn-save:disabled {
|
|
opacity: 0.6;
|
|
cursor: default;
|
|
}
|
|
.btn-delete {
|
|
padding: 0.45rem 1rem;
|
|
background: var(--color-danger);
|
|
color: #fff;
|
|
border: none;
|
|
border-radius: var(--radius-sm);
|
|
cursor: pointer;
|
|
margin-left: auto;
|
|
}
|
|
.title-input {
|
|
padding: 0.5rem 0.75rem;
|
|
border: 1px solid var(--color-input-border);
|
|
border-radius: var(--radius-sm);
|
|
font-size: 1.25rem;
|
|
font-weight: 600;
|
|
background: var(--color-bg-card);
|
|
color: var(--color-text);
|
|
}
|
|
.editor-tabs {
|
|
display: flex;
|
|
gap: 0;
|
|
border-bottom: 1px solid var(--color-border);
|
|
}
|
|
.tab {
|
|
padding: 0.45rem 1rem;
|
|
border: none;
|
|
background: none;
|
|
color: var(--color-text-secondary);
|
|
cursor: pointer;
|
|
font-size: 0.9rem;
|
|
border-bottom: 2px solid transparent;
|
|
}
|
|
.tab.active {
|
|
color: var(--color-primary);
|
|
border-bottom-color: var(--color-primary);
|
|
}
|
|
.textarea-wrapper {
|
|
position: relative;
|
|
}
|
|
.body-input {
|
|
width: 100%;
|
|
padding: 0.75rem;
|
|
border: 1px solid var(--color-input-border);
|
|
border-radius: var(--radius-sm);
|
|
font-size: 1rem;
|
|
font-family: monospace;
|
|
min-height: 200px;
|
|
resize: none;
|
|
overflow: hidden;
|
|
background: var(--color-bg-card);
|
|
color: var(--color-text);
|
|
box-sizing: border-box;
|
|
}
|
|
.preview-pane {
|
|
padding: 0.75rem;
|
|
border: 1px solid var(--color-input-border);
|
|
border-radius: var(--radius-sm);
|
|
min-height: 200px;
|
|
background: var(--color-bg-card);
|
|
}
|
|
.ac-dropdown {
|
|
position: fixed;
|
|
z-index: 100;
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: 0;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-sm);
|
|
background: var(--color-bg-card);
|
|
box-shadow: 0 4px 12px var(--color-shadow);
|
|
max-height: 200px;
|
|
overflow-y: auto;
|
|
min-width: 160px;
|
|
}
|
|
.ac-item {
|
|
padding: 0.4rem 0.75rem;
|
|
cursor: pointer;
|
|
font-size: 0.9rem;
|
|
}
|
|
.ac-item:hover,
|
|
.ac-item.active {
|
|
background: var(--color-bg-secondary);
|
|
}
|
|
.field-row {
|
|
display: flex;
|
|
gap: 1rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
.field {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.25rem;
|
|
min-width: 0;
|
|
flex: 1;
|
|
}
|
|
.field label {
|
|
font-size: 0.85rem;
|
|
color: var(--color-text-secondary);
|
|
font-weight: 500;
|
|
}
|
|
.field-select,
|
|
.field-input {
|
|
padding: 0.4rem 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.9rem;
|
|
}
|
|
.modal-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: var(--color-overlay);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 200;
|
|
}
|
|
.modal-card {
|
|
background: var(--color-bg-card);
|
|
border-radius: var(--radius-md);
|
|
padding: 1.5rem;
|
|
max-width: 400px;
|
|
width: 90%;
|
|
box-shadow: 0 8px 32px var(--color-shadow);
|
|
}
|
|
.modal-title {
|
|
margin: 0 0 0.5rem;
|
|
font-size: 1.1rem;
|
|
}
|
|
.modal-message {
|
|
margin: 0 0 1.25rem;
|
|
color: var(--color-text-secondary);
|
|
font-size: 0.95rem;
|
|
}
|
|
.modal-actions {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
justify-content: flex-end;
|
|
}
|
|
.modal-btn {
|
|
padding: 0.45rem 1rem;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-sm);
|
|
background: var(--color-bg-card);
|
|
color: var(--color-text);
|
|
cursor: pointer;
|
|
font-size: 0.9rem;
|
|
}
|
|
.modal-btn-danger {
|
|
background: var(--color-danger);
|
|
color: #fff;
|
|
border-color: var(--color-danger);
|
|
}
|
|
</style>
|