feat: task system
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 1m55s

This commit is contained in:
domrichardson
2026-03-27 16:33:11 +00:00
parent d793b5ccf2
commit 1b336299ee
15 changed files with 3876 additions and 17 deletions

View File

@@ -128,7 +128,11 @@
</h5>
</div>
<div class="col-auto d-flex align-items-center">
<div v-if="!selectedNote || isSearchRoute" class="btn-group me-2 d-none d-md-flex" role="group" aria-label="View mode">
<div class="btn-group me-2" role="group" aria-label="Workspace mode">
<button type="button" class="btn action-button" :class="activeView === 'notes' ? 'btn-secondary' : 'btn-outline-secondary'" @click="activeView = 'notes'">Notes</button>
<button type="button" class="btn action-button" :class="activeView === 'tasks' ? 'btn-secondary' : 'btn-outline-secondary'" @click="activeView = 'tasks'">Tasks</button>
</div>
<div v-if="activeView === 'notes' && (!selectedNote || isSearchRoute)" class="btn-group me-2 d-none d-md-flex" role="group" aria-label="View mode">
<button
type="button"
class="btn action-button"
@@ -151,7 +155,7 @@
</button>
</div>
<button
v-if="canEditNotes && selectedNote && !isEditingNote && !isSearchRoute"
v-if="activeView === 'notes' && canEditNotes && selectedNote && !isEditingNote && !isSearchRoute"
class="btn btn-outline-secondary me-2 action-button"
aria-label="Edit note"
title="Edit note"
@@ -161,7 +165,7 @@
<span class="action-label">Edit Note</span>
</button>
<button
v-if="canShareSelectedNote && !isEditingNote && !isSearchRoute"
v-if="activeView === 'notes' && canShareSelectedNote && !isEditingNote && !isSearchRoute"
class="btn btn-outline-primary me-2 action-button"
:aria-label="shareCopied ? 'Link copied' : 'Share note'"
:title="shareCopied ? 'Link copied' : 'Share note'"
@@ -170,18 +174,36 @@
<i class="mdi mdi-share-variant-outline me-1" aria-hidden="true"></i>
<span class="action-label">{{ shareCopied ? "Copied" : "Share" }}</span>
</button>
<button v-if="canCreateNotes" class="btn btn-primary action-button" aria-label="New note" title="New note" @click="showCreateNoteModal = true">
<button v-if="activeView === 'notes' && canCreateNotes" class="btn btn-primary action-button" aria-label="New note" title="New note" @click="showCreateNoteModal = true">
<i class="mdi mdi-note-plus-outline me-1" aria-hidden="true"></i>
<span class="action-label">New Note</span>
</button>
<button v-if="activeView === 'tasks' && canCreateTasks" class="btn btn-primary action-button" aria-label="New task" title="New task" @click="openTaskCreateModal">
<i class="mdi mdi-checkbox-marked-circle-plus-outline me-1" aria-hidden="true"></i>
<span class="action-label">New Task</span>
</button>
</div>
</div>
</div>
<!-- Note Editor or Note List -->
<div class="content p-4">
<TaskBoard
v-if="activeView === 'tasks'"
:tasks="tasks"
:statuses="taskStatuses"
:category-options="categoryOptions"
@create-task="openTaskCreateModal"
@select-task="openTaskDetail"
@filter-change="applyTaskFilters"
@reorder-status="reorderTaskStatuses"
@create-status="createTaskStatus"
@rename-status="renameTaskStatus"
@delete-status="deleteTaskStatus"
@update-task-status="updateTaskStatusFromBoard"
/>
<SearchResultsPage
v-if="isSearchRoute"
v-else-if="isSearchRoute"
:notes="searchResults"
:query="searchQuery"
:current-page="searchPage"
@@ -199,8 +221,16 @@
@save="updateNote"
@delete="deleteNote"
@cancel="cancelEditingNote"
@open-linked-task="openLinkedTaskFromNote"
/>
<NoteViewer
v-else-if="selectedNote"
:note="selectedNote"
:category-options="categoryOptions"
:space-id="currentSpace?.id"
:linked-tasks="linkedTasksForSelectedNote"
@open-linked-task="openLinkedTaskFromNote"
/>
<NoteViewer v-else-if="selectedNote" :note="selectedNote" :category-options="categoryOptions" :space-id="currentSpace?.id" />
<NoteList
v-else
:notes="displayedNotes"
@@ -256,6 +286,20 @@
@saved="applyUpdatedSpace"
@deleted="handleSpaceDeleted"
/>
<TaskDetailModal
v-if="showTaskModal"
:task="taskModalDraft || {}"
:statuses="taskStatuses"
:category-options="categoryOptions"
:parent-task-options="taskParentOptions"
:subtasks="taskDetail?.subtasks || []"
@close="showTaskModal = false"
@save-task="saveTask"
@delete-task="removeTask"
@transition="transitionTaskStatus"
@create-subtask="createSubtask"
@open-task="openTaskDetail"
/>
<teleport to="body">
<div v-if="showUnlockModal" class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="closeUnlockModal">
@@ -304,6 +348,8 @@ import CreateSpaceModal from "./components/CreateSpaceModal.vue";
import CreateCategoryModal from "./components/CreateCategoryModal.vue";
import CreateNoteModal from "./components/CreateNoteModal.vue";
import SpaceSettingsModal from "./components/SpaceSettingsModal.vue";
import TaskBoard from "./components/TaskBoard.vue";
import TaskDetailModal from "./components/TaskDetailModal.vue";
import { sortNotesByPriority } from "./utils/noteSort";
import apiClient from "./services/apiClient";
@@ -345,6 +391,16 @@ const unlockTargetNote = ref(null);
const unlockPassword = ref("");
const unlockError = ref("");
const unlockingNote = ref(false);
const activeView = ref("notes");
const taskFilters = ref({
categoryId: null,
statusId: null,
parentTaskId: null,
});
const showTaskModal = ref(false);
const taskDetail = ref(null);
const taskModalDraft = ref(null);
const linkedTasksForSelectedNote = ref([]);
const currentUser = computed(() => authStore.user);
const isAdminRoute = computed(() => route.path === "/admin");
@@ -370,6 +426,9 @@ const canDeleteCategories = computed(() => authStore.hasSpacePermission(currentS
const canCreateNotes = computed(() => authStore.hasSpacePermission(currentSpace.value, "note.create"));
const canEditNotes = computed(() => authStore.hasSpacePermission(currentSpace.value, "note.edit"));
const canDeleteNotes = computed(() => authStore.hasSpacePermission(currentSpace.value, "note.delete"));
const canCreateTasks = computed(() => authStore.hasSpacePermission(currentSpace.value, "tasks.create"));
const canEditTasks = computed(() => authStore.hasSpacePermission(currentSpace.value, "tasks.edit"));
const canDeleteTasks = computed(() => authStore.hasSpacePermission(currentSpace.value, "tasks.delete"));
const canShareSelectedNote = computed(() => !!selectedNote.value?.is_public && !!currentSpace.value?.id && !!selectedNote.value?.id);
const offcanvasOffsetStyle = computed(() => ({
top: `${navbarHeight.value}px`,
@@ -416,6 +475,15 @@ const displayedNotes = computed(() => {
}
return sortNotesByPriority(collectNotesFromCategory(selectedCategory.value, []));
});
const tasks = computed(() => spaceStore.tasks || []);
const taskStatuses = computed(() => spaceStore.taskStatuses || []);
const initialTaskStatusId = computed(() => {
if (!taskStatuses.value.length) {
return null;
}
return [...taskStatuses.value].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))[0]?.id || null;
});
const taskParentOptions = computed(() => tasks.value.filter((task) => task.depth < 2));
const canLoadMoreMainNotes = computed(() => {
if (selectedCategory.value || selectedNote.value) {
@@ -611,9 +679,20 @@ watch(
watch(
() => selectedNote.value?.id,
() => {
async (noteId) => {
shareCopied.value = false;
clearTimeout(shareCopyTimeout.value);
if (!noteId || !currentSpace.value?.id) {
linkedTasksForSelectedNote.value = [];
return;
}
try {
linkedTasksForSelectedNote.value = await spaceStore.fetchTasksForNote(currentSpace.value.id, noteId);
} catch {
linkedTasksForSelectedNote.value = [];
}
},
);
@@ -659,6 +738,8 @@ const selectSpace = async (space) => {
selectedNote.value = null;
selectedCategory.value = null;
isEditingNote.value = false;
linkedTasksForSelectedNote.value = [];
await applyTaskFilters(taskFilters.value);
};
const copyShareLink = async () => {
@@ -821,6 +902,210 @@ const loadMoreMainNotes = async () => {
await spaceStore.loadMoreNotes(currentSpace.value.id);
};
const applyTaskFilters = async (filters) => {
taskFilters.value = filters;
if (!currentSpace.value?.id) {
return;
}
await spaceStore.fetchTasks(currentSpace.value.id, filters);
};
const openTaskCreateModal = () => {
if (!canCreateTasks.value) {
return;
}
taskDetail.value = null;
taskModalDraft.value = {
title: "",
description: "",
category_id: selectedCategory.value?.id || null,
status_id: initialTaskStatusId.value,
parent_task_id: null,
note_links: selectedNote.value?.id ? [selectedNote.value.id] : [],
depth: 0,
};
activeView.value = "tasks";
showTaskModal.value = true;
};
const openTaskDetail = async (task) => {
if (!currentSpace.value?.id || !task?.id) {
return;
}
try {
taskDetail.value = await spaceStore.getTask(currentSpace.value.id, task.id);
taskModalDraft.value = taskDetail.value;
showTaskModal.value = true;
} catch {
alert("Unable to open task details.");
}
};
const saveTask = async (payload) => {
if (!currentSpace.value?.id) {
return;
}
try {
if (payload.id) {
if (!canEditTasks.value) {
return;
}
const updated = await spaceStore.updateTask(currentSpace.value.id, payload.id, payload);
await openTaskDetail(updated);
} else {
if (!canCreateTasks.value) {
return;
}
const created = await spaceStore.createTask(currentSpace.value.id, payload);
await openTaskDetail(created);
}
if (selectedNote.value?.id) {
linkedTasksForSelectedNote.value = await spaceStore.fetchTasksForNote(currentSpace.value.id, selectedNote.value.id);
}
} catch (error) {
alert(error?.response?.data || "Failed to save task.");
}
};
const removeTask = async (task) => {
if (!currentSpace.value?.id || !task?.id || !canDeleteTasks.value) {
return;
}
if (!confirm(`Delete task "${task.title}"?`)) {
return;
}
try {
await spaceStore.deleteTask(currentSpace.value.id, task.id);
showTaskModal.value = false;
taskDetail.value = null;
if (selectedNote.value?.id) {
linkedTasksForSelectedNote.value = await spaceStore.fetchTasksForNote(currentSpace.value.id, selectedNote.value.id);
}
} catch (error) {
alert(error?.response?.data || "Unable to delete task.");
}
};
const transitionTaskStatus = async ({ taskId, direction }) => {
if (!currentSpace.value?.id || !taskId) {
return;
}
try {
const updated = await spaceStore.transitionTask(currentSpace.value.id, taskId, direction);
await openTaskDetail(updated);
if (selectedNote.value?.id) {
linkedTasksForSelectedNote.value = await spaceStore.fetchTasksForNote(currentSpace.value.id, selectedNote.value.id);
}
} catch (error) {
alert(error?.response?.data || "Unable to change task status.");
}
};
const createSubtask = (parentTask) => {
if (!parentTask || (parentTask.depth ?? 0) >= 2) {
alert("Maximum sub-task depth reached.");
return;
}
taskDetail.value = null;
taskModalDraft.value = {
title: "",
description: "",
category_id: parentTask.category_id || null,
status_id: initialTaskStatusId.value,
parent_task_id: parentTask.id,
note_links: selectedNote.value?.id ? [selectedNote.value.id] : [],
depth: Math.min((parentTask.depth ?? 0) + 1, 2),
};
showTaskModal.value = true;
};
const createTaskStatus = async (payload) => {
if (!currentSpace.value?.id) {
return;
}
try {
await spaceStore.createTaskStatus(currentSpace.value.id, payload);
} catch (error) {
alert(error?.response?.data || "Unable to create status.");
}
};
const renameTaskStatus = async (status) => {
if (!currentSpace.value?.id || !status?.id) {
return;
}
try {
await spaceStore.updateTaskStatus(currentSpace.value.id, status.id, {
name: status.name,
color: status.color,
});
} catch (error) {
alert(error?.response?.data || "Unable to update status.");
}
};
const deleteTaskStatus = async (status) => {
if (!currentSpace.value?.id || !status?.id) {
return;
}
if (!confirm(`Delete status "${status.name}"?`)) {
return;
}
try {
await spaceStore.deleteTaskStatus(currentSpace.value.id, status.id);
} catch (error) {
alert(error?.response?.data || "Unable to delete status.");
}
};
const reorderTaskStatuses = async (orderedIds) => {
if (!currentSpace.value?.id) {
return;
}
try {
await spaceStore.reorderTaskStatuses(currentSpace.value.id, orderedIds);
} catch (error) {
alert(error?.response?.data || "Unable to reorder statuses.");
}
};
const updateTaskStatusFromBoard = async ({ taskId, currentStatusId, targetStatusId }) => {
if (!currentSpace.value?.id || !taskId || !targetStatusId || currentStatusId === targetStatusId) {
return;
}
const orderedStatuses = [...taskStatuses.value].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
const currentIndex = orderedStatuses.findIndex((item) => item.id === currentStatusId);
const targetIndex = orderedStatuses.findIndex((item) => item.id === targetStatusId);
try {
let updatedTask = null;
if (currentIndex >= 0 && targetIndex >= 0) {
const direction = targetIndex > currentIndex ? "forward" : "backward";
const steps = Math.abs(targetIndex - currentIndex);
for (let i = 0; i < steps; i++) {
updatedTask = await spaceStore.transitionTask(currentSpace.value.id, taskId, direction);
}
} else {
updatedTask = await spaceStore.updateTask(currentSpace.value.id, taskId, { status_id: targetStatusId });
}
if (updatedTask && taskDetail.value?.id === updatedTask.id) {
await openTaskDetail(updatedTask);
}
if (selectedNote.value?.id) {
linkedTasksForSelectedNote.value = await spaceStore.fetchTasksForNote(currentSpace.value.id, selectedNote.value.id);
}
} catch (error) {
alert(error?.response?.data || "Unable to update task status.");
}
};
const openLinkedTaskFromNote = async (task) => {
activeView.value = "tasks";
await openTaskDetail(task);
};
const createSpace = async (spaceData) => {
showCreateSpaceModal.value = false;
await spaceStore.createSpace(spaceData);

View File

@@ -13,6 +13,16 @@
<i class="mdi mdi-folder-open-outline me-1" aria-hidden="true"></i>
Files
</button>
<button
v-if="spaceId"
class="btn btn-sm"
:class="showTaskPicker ? 'btn-secondary' : 'btn-outline-secondary'"
:title="showTaskPicker ? 'Hide task picker' : 'Browse & insert task mentions'"
@click="toggleTaskPicker"
>
<i class="mdi mdi-checkbox-marked-circle-outline me-1" aria-hidden="true"></i>
Tasks
</button>
<span class="save-status ms-auto" :class="saveState">{{ saveStatusLabel }}</span>
</div>
@@ -25,19 +35,52 @@
</div>
<div class="row">
<div :class="showFileExplorer ? 'col-12 col-md-5' : 'col-12 col-md-6'">
<div :class="editorColumnClass">
<textarea ref="contentTextareaRef" v-model="editingNote.content" class="form-control editor-textarea" placeholder="Write your note in markdown..." @input="autoSave"></textarea>
<div v-if="showTaskMention" class="task-mention-panel">
<div class="small text-muted mb-1">Link task for "{{ taskMentionQuery }}"</div>
<button v-for="task in taskMentionResults" :key="task.id" class="task-mention-option" @click="selectMentionTask(task)">
<span>{{ task.title }}</span>
<small>Depth {{ task.depth + 1 }}</small>
</button>
</div>
</div>
<div :class="showFileExplorer ? 'col-12 col-md-4 mt-3 mt-md-0' : 'col-12 col-md-6 mt-3 mt-md-0'">
<div class="preview-pane border rounded p-3">
<div :class="previewColumnClass">
<div class="preview-pane border rounded p-3" @click="onPreviewClick">
<div class="markdown-body" v-html="renderedMarkdown"></div>
</div>
</div>
<div v-if="showFileExplorer" class="col-12 col-md-3 mt-3 mt-md-0">
<div v-if="showFileExplorer" :class="fileExplorerColumnClass">
<FileExplorer v-model="fileExplorerPrefix" :space-id="spaceId" @insert="insertAtCursor" />
</div>
<div v-if="showTaskPicker" :class="taskPickerColumnClass">
<div class="task-picker border rounded">
<div class="task-picker-header px-2 py-1 border-bottom d-flex align-items-center gap-2">
<i class="mdi mdi-checkbox-marked-circle-outline text-muted" aria-hidden="true"></i>
<span class="small fw-semibold">Space Tasks</span>
<button class="btn btn-link btn-sm p-0 text-muted ms-auto" title="Refresh" @click="refreshTaskPicker">
<i class="mdi mdi-refresh" aria-hidden="true"></i>
</button>
</div>
<div class="task-picker-search p-2 border-bottom">
<input v-model="taskPickerQuery" type="text" class="form-control form-control-sm" placeholder="Search tasks by title..." />
</div>
<div v-if="taskPickerLoading" class="task-picker-empty text-muted small">
<i class="mdi mdi-loading mdi-spin me-1" aria-hidden="true"></i>
Loading tasks...
</div>
<div v-else-if="!taskPickerItems.length" class="task-picker-empty text-muted small">No tasks found.</div>
<div v-else class="task-picker-list">
<button v-for="task in taskPickerItems" :key="task.id" class="task-picker-item" @click="insertTaskMention(task)">
<span class="task-picker-title">{{ task.title }}</span>
<small>{{ task.picker_status_name }}</small>
</button>
</div>
</div>
</div>
</div>
<div class="mt-3">
@@ -98,6 +141,7 @@
import { ref, computed, watch, onBeforeUnmount, onMounted, nextTick } from "vue";
import DOMPurify from "dompurify";
import { useSettingsStore } from "../stores/settingsStore";
import { useSpaceStore } from "../stores/spaceStore";
import { renderMarkdown } from "../utils/markdown.js";
import FileExplorer from "./FileExplorer.vue";
@@ -120,8 +164,9 @@ const props = defineProps({
},
});
const emit = defineEmits(["save", "delete", "cancel"]);
const emit = defineEmits(["save", "delete", "cancel", "open-linked-task"]);
const settingsStore = useSettingsStore();
const spaceStore = useSpaceStore();
const publicSharingEnabled = ref(true);
const fileExplorerEnabled = computed(() => settingsStore.fileExplorerEnabled);
@@ -135,12 +180,110 @@ const notePassword = ref("");
const saveTimeout = ref(null);
const saveState = ref("saved");
const saveStateTimeout = ref(null);
const taskMentionQuery = ref("");
const taskMentionResults = ref([]);
const showTaskMention = ref(false);
const linkedTasks = ref([]);
const showTaskPicker = ref(false);
const taskPickerQuery = ref("");
const taskPickerLoading = ref(false);
const hasAuxPanels = computed(() => showFileExplorer.value || showTaskPicker.value);
const hasTwoAuxPanels = computed(() => showFileExplorer.value && showTaskPicker.value);
const editorColumnClass = computed(() => {
if (hasTwoAuxPanels.value) {
return "col-12 col-xl-4";
}
return hasAuxPanels.value ? "col-12 col-md-5" : "col-12 col-md-6";
});
const previewColumnClass = computed(() => {
if (hasTwoAuxPanels.value) {
return "col-12 col-xl-4 mt-3 mt-xl-0";
}
return hasAuxPanels.value ? "col-12 col-md-4 mt-3 mt-md-0" : "col-12 col-md-6 mt-3 mt-md-0";
});
const fileExplorerColumnClass = computed(() => {
return hasTwoAuxPanels.value ? "col-12 col-md-6 col-xl-2 mt-3 mt-xl-0" : "col-12 col-md-3 mt-3 mt-md-0";
});
const taskPickerColumnClass = computed(() => {
return hasTwoAuxPanels.value ? "col-12 col-md-6 col-xl-2 mt-3 mt-xl-0" : "col-12 col-md-3 mt-3 mt-md-0";
});
const taskStatusNameById = computed(() => {
const map = new Map();
for (const status of spaceStore.taskStatuses || []) {
if (status?.id) {
map.set(status.id, status.name || "");
}
}
return map;
});
const taskPickerItems = computed(() => {
const query = taskPickerQuery.value.trim().toLowerCase();
const allTasks = [...(spaceStore.tasks || [])]
.map((task) => ({
...task,
picker_status_name: task.status_name || task.status?.name || taskStatusNameById.value.get(task.status_id) || "Unknown",
}))
.sort((a, b) => (a.title || "").localeCompare(b.title || ""));
if (!query) {
return allTasks;
}
return allTasks.filter((task) => (task.title || "").toLowerCase().includes(query));
});
const renderedMarkdown = computed(() => {
const html = renderMarkdown(editingNote.value.content || "");
const html = renderMarkdown(enrichTaskMentions(editingNote.value.content || ""));
return DOMPurify.sanitize(html);
});
const normalizeTaskTitle = (value) => (value || "").trim().toLowerCase();
const taskByTitle = computed(() => {
const map = new Map();
for (const task of linkedTasks.value || []) {
const key = normalizeTaskTitle(task.title);
if (!key || map.has(key)) {
continue;
}
map.set(key, task);
}
return map;
});
const enrichTaskMentions = (content) => {
if (!content) {
return "";
}
return content.replace(/@task\(([^)]+)\)/gi, (full, rawTitle) => {
const title = (rawTitle || "").trim();
if (!title) {
return full;
}
const linkedTask = taskByTitle.value.get(normalizeTaskTitle(title));
if (!linkedTask?.id) {
return full;
}
const statusName = (linkedTask.status_name || "Unknown").trim();
const safeTitle = title.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
const safeStatusName = statusName.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
const statusColor = (linkedTask.status_color || "").trim();
const safeStatusColor = statusColor.replace(/"/g, "").replace(/</g, "").replace(/>/g, "");
const styleAttr = safeStatusColor ? ` style="--task-status-color:${safeStatusColor}"` : "";
return `<a href="#task:${linkedTask.id}" class="task-inline-link"${styleAttr}><i class="mdi mdi-checkbox-marked-circle-outline" aria-hidden="true"></i><span class="task-inline-title">${safeTitle}</span><span class="task-inline-status">${safeStatusName}</span></a>`;
});
};
const saveStatusLabel = computed(() => {
switch (saveState.value) {
case "dirty":
@@ -155,12 +298,21 @@ const saveStatusLabel = computed(() => {
watch(
() => props.note,
(newNote) => {
async (newNote) => {
editingNote.value = { ...newNote };
tagsInput.value = newNote.tags?.join(", ") || "";
passwordAction.value = "keep";
notePassword.value = "";
saveState.value = "saved";
if (props.spaceId && newNote?.id) {
try {
linkedTasks.value = await spaceStore.fetchTasksForNote(props.spaceId, newNote.id);
} catch {
linkedTasks.value = [];
}
} else {
linkedTasks.value = [];
}
},
);
@@ -211,12 +363,15 @@ const saveNote = () => {
notePassword.value = "";
}
markSavedSoon();
// Auto-link any @task(Title) mentions present in the saved content
syncTaskMentionLinks(note.content || "");
};
const autoSave = () => {
saveState.value = "dirty";
clearTimeout(saveTimeout.value);
saveTimeout.value = setTimeout(saveNote, 3000);
detectTaskMention();
};
const confirmDelete = () => {
@@ -249,6 +404,152 @@ const insertAtCursor = (snippet) => {
});
};
const detectTaskMention = async () => {
const content = editingNote.value.content || "";
const match = content.match(/@task\s+([^\n]{1,40})$/i);
if (!match || !props.spaceId) {
showTaskMention.value = false;
taskMentionResults.value = [];
taskMentionQuery.value = "";
return;
}
const query = match[1].trim();
taskMentionQuery.value = query;
if (!query) {
showTaskMention.value = false;
taskMentionResults.value = [];
return;
}
try {
taskMentionResults.value = await spaceStore.searchTasks(props.spaceId, query);
showTaskMention.value = taskMentionResults.value.length > 0;
} catch {
taskMentionResults.value = [];
showTaskMention.value = false;
}
};
const replaceTaskMentionText = (title) => {
editingNote.value.content = (editingNote.value.content || "").replace(/@task\s+([^\n]{1,40})$/i, `@task(${title})`);
};
const selectMentionTask = async (task) => {
replaceTaskMentionText(task.title);
showTaskMention.value = false;
taskMentionResults.value = [];
if (!props.spaceId || !editingNote.value.id) {
return;
}
try {
await spaceStore.linkTaskToNote(props.spaceId, task.id, editingNote.value.id);
linkedTasks.value = await spaceStore.fetchTasksForNote(props.spaceId, editingNote.value.id);
} catch {
alert("Unable to link task to this note.");
}
autoSave();
};
const insertTaskMention = (task) => {
if (!task?.title) {
return;
}
insertAtCursor(`@task(${task.title})`);
};
const refreshTaskPicker = async () => {
if (!props.spaceId) {
return;
}
taskPickerLoading.value = true;
try {
await Promise.all([spaceStore.fetchTasks(props.spaceId), spaceStore.fetchTaskStatuses(props.spaceId)]);
} finally {
taskPickerLoading.value = false;
}
};
const toggleTaskPicker = async () => {
showTaskPicker.value = !showTaskPicker.value;
if (showTaskPicker.value && !spaceStore.tasks.length) {
await refreshTaskPicker();
}
};
/**
* Parse all @task(Title) mentions in content and ensure each is linked.
* Called after every real save so new mentions are linked automatically.
*/
const syncTaskMentionLinks = async (content) => {
if (!props.spaceId || !editingNote.value.id) {
return;
}
const mentionTitles = new Set();
const rx = /@task\(([^)]+)\)/gi;
let m;
while ((m = rx.exec(content)) !== null) {
const title = (m[1] || "").trim();
if (title) {
mentionTitles.add(title.toLowerCase());
}
}
if (!mentionTitles.size) {
return;
}
let current;
try {
current = await spaceStore.fetchTasksForNote(props.spaceId, editingNote.value.id);
} catch {
return;
}
const linkedTitles = new Set((current || []).map((t) => (t.title || "").toLowerCase()));
const toLink = [...mentionTitles].filter((title) => !linkedTitles.has(title));
if (!toLink.length) {
linkedTasks.value = current;
return;
}
await Promise.all(
toLink.map(async (title) => {
try {
const results = await spaceStore.searchTasks(props.spaceId, title);
const exact = results.find((t) => (t.title || "").toLowerCase() === title);
if (exact) {
await spaceStore.linkTaskToNote(props.spaceId, exact.id, editingNote.value.id);
}
} catch {
// best-effort — skip silently
}
}),
);
try {
linkedTasks.value = await spaceStore.fetchTasksForNote(props.spaceId, editingNote.value.id);
} catch {
// ignore
}
};
const onPreviewClick = (event) => {
const anchor = event.target?.closest?.("a");
if (!anchor) {
return;
}
const href = anchor.getAttribute("href") || "";
if (!href.startsWith("#task:")) {
return;
}
event.preventDefault();
const taskId = href.slice("#task:".length);
if (!taskId) {
return;
}
const matchedTask = (linkedTasks.value || []).find((task) => task.id === taskId);
if (matchedTask) {
emit("open-linked-task", matchedTask);
}
};
onBeforeUnmount(() => {
clearTimeout(saveTimeout.value);
clearTimeout(saveStateTimeout.value);
@@ -257,6 +558,16 @@ onBeforeUnmount(() => {
onMounted(async () => {
await settingsStore.loadFeatureFlags();
publicSharingEnabled.value = settingsStore.publicSharingEnabled;
if (props.spaceId && editingNote.value?.id) {
try {
linkedTasks.value = await spaceStore.fetchTasksForNote(props.spaceId, editingNote.value.id);
} catch {
linkedTasks.value = [];
}
}
if (props.spaceId && !spaceStore.tasks.length) {
await refreshTaskPicker();
}
});
</script>
@@ -347,6 +658,133 @@ onMounted(async () => {
font-size: 0.9rem;
}
.task-mention-panel {
margin-top: 0.45rem;
border: 1px solid #dbe4f0;
border-radius: 10px;
background: #fbfdff;
padding: 0.5rem;
max-height: 220px;
overflow-y: auto;
}
.task-mention-option {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
border: 0;
background: transparent;
padding: 0.35rem 0.45rem;
border-radius: 6px;
text-align: left;
}
.task-mention-option:hover {
background: #eef3ff;
}
.task-picker {
background: #fff;
min-height: 300px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.task-picker-header {
background: #f8f9fa;
min-height: 34px;
}
.task-picker-search {
background: #fff;
}
.task-picker-list {
overflow-y: auto;
max-height: 520px;
padding: 0.25rem;
}
.task-picker-item {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
border: 0;
background: transparent;
border-radius: 8px;
padding: 0.35rem 0.45rem;
text-align: left;
gap: 0.4rem;
color: #333;
}
.task-picker-item:hover {
background: #eef3ff;
}
.task-picker-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-picker-empty {
padding: 0.7rem;
}
.task-picker-item small {
font-size: 0.7rem;
color: #6b7280;
}
.task-picker .btn-link {
text-decoration: none;
}
.task-picker-item {
flex-wrap: wrap;
}
.markdown-body :deep(.task-inline-link) {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.18rem 0.5rem;
border-radius: 999px;
border: 1px solid #c7d8ff;
background: #eef4ff;
color: #2c4ea3;
font-weight: 600;
text-decoration: none;
}
.markdown-body :deep(.task-inline-title) {
line-height: 1;
}
.markdown-body :deep(.task-inline-status) {
line-height: 1;
font-size: 0.72rem;
font-weight: 700;
border-radius: 999px;
padding: 0.16rem 0.42rem;
border: 1px solid color-mix(in srgb, var(--task-status-color, #5c7bd9) 60%, #ffffff 40%);
background: color-mix(in srgb, var(--task-status-color, #5c7bd9) 18%, #ffffff 82%);
color: color-mix(in srgb, var(--task-status-color, #5c7bd9) 72%, #0f172a 28%);
}
.markdown-body :deep(.task-inline-link:hover) {
background: #dfeaff;
border-color: #aac4ff;
}
.markdown-body :deep(.task-inline-link i) {
font-size: 0.9rem;
}
/* Dark mode overrides */
:root[data-bs-theme="dark"] .editor-toolbar {
border-bottom-color: #3a3f4b;
@@ -373,4 +811,63 @@ onMounted(async () => {
:root[data-bs-theme="dark"] .danger-zone-copy {
color: #fca5a5;
}
:root[data-bs-theme="dark"] .task-mention-panel {
border-color: #3a4558;
background: #1f2733;
}
:root[data-bs-theme="dark"] .task-mention-option:hover {
background: #2b3646;
}
:root[data-bs-theme="dark"] .task-picker {
border-color: #3a3f4b !important;
background: #21252e;
}
:root[data-bs-theme="dark"] .task-picker-header {
background: #21252e;
border-bottom-color: #3a3f4b !important;
}
:root[data-bs-theme="dark"] .task-picker-search {
background: #21252e;
border-bottom-color: #3a3f4b !important;
}
:root[data-bs-theme="dark"] .task-picker-search .form-control {
background: #1f2430;
border-color: #3a3f4b;
color: #e2e8f0;
}
:root[data-bs-theme="dark"] .task-picker-item {
color: #e2e8f0;
}
:root[data-bs-theme="dark"] .task-picker-item:hover {
background: #2d3748;
}
:root[data-bs-theme="dark"] .task-picker-item small {
color: #a8b4c7;
}
:root[data-bs-theme="dark"] .markdown-body :deep(.task-inline-link) {
border-color: #35508b;
background: #1b2a4a;
color: #9ec0ff;
}
:root[data-bs-theme="dark"] .markdown-body :deep(.task-inline-link:hover) {
background: #22345c;
border-color: #4566ad;
}
:root[data-bs-theme="dark"] .markdown-body :deep(.task-inline-status) {
border-color: color-mix(in srgb, var(--task-status-color, #7aa2f7) 65%, #1e293b 35%);
background: color-mix(in srgb, var(--task-status-color, #7aa2f7) 26%, #111827 74%);
color: color-mix(in srgb, var(--task-status-color, #7aa2f7) 70%, #dbeafe 30%);
}
</style>

View File

@@ -24,7 +24,7 @@
</div>
</header>
<div class="markdown-body" v-html="renderedMarkdown"></div>
<div class="markdown-body" v-html="renderedMarkdown" @click="onMarkdownClick"></div>
</article>
</template>
@@ -46,13 +46,61 @@ const props = defineProps({
type: String,
default: "",
},
linkedTasks: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(["open-linked-task"]);
const renderedMarkdown = computed(() => {
const html = renderMarkdown(props.note.content || "");
const html = renderMarkdown(enrichTaskMentions(props.note.content || ""));
return DOMPurify.sanitize(html);
});
const normalizeTaskTitle = (value) => (value || "").trim().toLowerCase();
const taskByTitle = computed(() => {
const map = new Map();
for (const task of props.linkedTasks || []) {
const key = normalizeTaskTitle(task.title);
if (!key || map.has(key)) {
continue;
}
map.set(key, task);
}
return map;
});
const enrichTaskMentions = (content) => {
if (!content) {
return "";
}
return content.replace(/@task\(([^)]+)\)/gi, (full, rawTitle) => {
const title = (rawTitle || "").trim();
if (!title) {
return full;
}
const linkedTask = taskByTitle.value.get(normalizeTaskTitle(title));
if (!linkedTask?.id) {
return full;
}
const statusName = (linkedTask.status_name || "Unknown").trim();
const safeTitle = title.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
const safeStatusName = statusName.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
const statusColor = (linkedTask.status_color || "").trim();
const safeStatusColor = statusColor.replace(/"/g, "").replace(/</g, "").replace(/>/g, "");
const styleAttr = safeStatusColor ? ` style="--task-status-color:${safeStatusColor}"` : "";
return `<a href="#task:${linkedTask.id}" class="task-inline-link"${styleAttr}><i class="mdi mdi-checkbox-marked-circle-outline" aria-hidden="true"></i><span class="task-inline-title">${safeTitle}</span><span class="task-inline-status">${safeStatusName}</span></a>`;
});
};
const categoryLabel = computed(() => {
const categoryId = props.note.category_id;
if (!categoryId) {
@@ -79,6 +127,29 @@ const categoryLabel = computed(() => {
});
const formatDateTime = (dateString) => new Date(dateString).toLocaleString();
const onMarkdownClick = (event) => {
const anchor = event.target?.closest?.("a");
if (!anchor) {
return;
}
const href = anchor.getAttribute("href") || "";
if (!href.startsWith("#task:")) {
return;
}
event.preventDefault();
const taskId = href.slice("#task:".length);
if (!taskId) {
return;
}
const matchedTask = (props.linkedTasks || []).find((task) => task.id === taskId);
if (matchedTask) {
emit("open-linked-task", matchedTask);
}
};
</script>
<style scoped>
@@ -146,6 +217,43 @@ const formatDateTime = (dateString) => new Date(dateString).toLocaleString();
background: #fff4e6;
}
.markdown-body :deep(.task-inline-link) {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.18rem 0.5rem;
border-radius: 999px;
border: 1px solid #c7d8ff;
background: #eef4ff;
color: #2c4ea3;
font-weight: 600;
text-decoration: none;
}
.markdown-body :deep(.task-inline-title) {
line-height: 1;
}
.markdown-body :deep(.task-inline-status) {
line-height: 1;
font-size: 0.72rem;
font-weight: 700;
border-radius: 999px;
padding: 0.16rem 0.42rem;
border: 1px solid color-mix(in srgb, var(--task-status-color, #5c7bd9) 60%, #ffffff 40%);
background: color-mix(in srgb, var(--task-status-color, #5c7bd9) 18%, #ffffff 82%);
color: color-mix(in srgb, var(--task-status-color, #5c7bd9) 72%, #0f172a 28%);
}
.markdown-body :deep(.task-inline-link:hover) {
background: #dfeaff;
border-color: #aac4ff;
}
.markdown-body :deep(.task-inline-link i) {
font-size: 0.9rem;
}
.markdown-body :deep(h1),
.markdown-body :deep(h2),
.markdown-body :deep(h3) {
@@ -219,4 +327,21 @@ const formatDateTime = (dateString) => new Date(dateString).toLocaleString();
background: #1e2430;
color: #94a3b8;
}
:root[data-bs-theme="dark"] .markdown-body :deep(.task-inline-link) {
border-color: #35508b;
background: #1b2a4a;
color: #9ec0ff;
}
:root[data-bs-theme="dark"] .markdown-body :deep(.task-inline-link:hover) {
background: #22345c;
border-color: #4566ad;
}
:root[data-bs-theme="dark"] .markdown-body :deep(.task-inline-status) {
border-color: color-mix(in srgb, var(--task-status-color, #7aa2f7) 65%, #1e293b 35%);
background: color-mix(in srgb, var(--task-status-color, #7aa2f7) 26%, #111827 74%);
color: color-mix(in srgb, var(--task-status-color, #7aa2f7) 70%, #dbeafe 30%);
}
</style>

View File

@@ -0,0 +1,892 @@
<template>
<section class="task-board">
<div class="task-board-header">
<div class="task-title-wrap">
<h4 class="mb-0">Tasks</h4>
<p class="text-muted small mb-0">Track work with ordered statuses.</p>
</div>
<button class="btn btn-primary" @click="emit('create-task')">
<i class="mdi mdi-checkbox-marked-circle-plus-outline me-1" aria-hidden="true"></i>
New Task
</button>
</div>
<div class="task-filters">
<select v-model="filterCategory" class="form-select" @change="emitFilters">
<option value="">All categories</option>
<option v-for="category in categoryOptions" :key="category.id" :value="category.id">
{{ category.label }}
</option>
</select>
<select v-model="filterStatus" class="form-select" @change="emitFilters">
<option value="">All statuses</option>
<option v-for="status in statuses" :key="status.id" :value="status.id">
{{ status.name }}
</option>
</select>
<select v-model="filterParent" class="form-select" @change="emitFilters">
<option value="">Any parent</option>
<option value="root">Top-level only</option>
<option v-for="task in parentTaskOptions" :key="task.id" :value="task.id">
{{ task.title }}
</option>
</select>
</div>
<div class="status-lane">
<div class="lane-header">
<strong>Status Progression</strong>
<button class="btn btn-sm btn-outline-primary" @click="openCreateStatusModal">Add Status</button>
</div>
<div class="status-list">
<div
v-for="status in statuses"
:key="status.id"
class="status-item"
:class="{ 'is-drag-over': dragOverStatusId === status.id }"
draggable="true"
@dragstart="onStatusDragStart(status.id)"
@dragover.prevent="onStatusDragOver(status.id)"
@dragleave="onStatusDragLeave(status.id)"
@drop.prevent="onStatusDrop(status.id)"
@dragend="onStatusDragEnd"
>
<span class="drag-handle" aria-hidden="true">
<i class="mdi mdi-drag-horizontal-variant"></i>
</span>
<span class="status-dot" :style="{ backgroundColor: status.color || '#7c8596' }"></span>
<span class="status-name">{{ status.name }}</span>
<div class="status-actions">
<button class="btn btn-sm btn-outline-secondary" @click="openEditStatusModal(status)">Edit</button>
</div>
</div>
</div>
</div>
<div class="task-status-groups">
<div v-if="!tasks.length" class="empty-state">No tasks matched these filters.</div>
<section v-for="section in statusSections" :key="section.status.id" class="status-group">
<header class="status-group-header" :style="statusHeaderStyle(section.status)">
<div class="status-group-title-wrap">
<span class="status-group-dot" :style="{ backgroundColor: section.status.color || '#7c8596' }"></span>
<span class="status-group-title">{{ section.status.name }}</span>
</div>
<span class="status-group-count">{{ section.parentTasks.length }}</span>
</header>
<div v-if="!section.parentTasks.length" class="status-empty">No tasks in this status.</div>
<div v-for="parentTask in section.parentTasks" :key="parentTask.id" class="task-tree-row level-0">
<div
class="task-row"
role="button"
tabindex="0"
@click="emit('select-task', parentTask)"
@keydown.enter="emit('select-task', parentTask)"
@keydown.space.prevent="emit('select-task', parentTask)"
>
<span class="tree-toggle" @click.stop="toggleExpanded(parentTask)">
<i v-if="hasChildren(parentTask)" :class="isExpanded(parentTask.id) ? 'mdi mdi-chevron-down' : 'mdi mdi-chevron-right'"></i>
</span>
<span class="task-main">
<strong>{{ parentTask.title }}</strong>
<small class="text-muted">{{ parentTask.description || "No description" }}</small>
</span>
<div class="task-status-menu" @click.stop>
<button type="button" class="status-trigger" :title="`Status: ${statusName(parentTask.status_id)}`" @click="toggleStatusMenu(parentTask.id)">
<span class="status-trigger-dot" :style="statusDotStyle(parentTask.status_id)"></span>
</button>
<div v-if="isStatusMenuOpen(parentTask.id)" class="status-popup">
<button
v-for="status in statuses"
:key="status.id"
type="button"
class="status-option"
:class="{ selected: parentTask.status_id === status.id }"
@click="onTaskStatusChange(parentTask, status.id)"
>
<span class="status-option-dot" :style="{ borderColor: status.color || '#7c8596' }"></span>
<span class="status-option-label">{{ status.name }}</span>
<i v-if="parentTask.status_id === status.id" class="mdi mdi-check status-option-check" aria-hidden="true"></i>
</button>
</div>
</div>
</div>
<div v-if="isExpanded(parentTask.id)">
<div v-for="childTask in childrenFor(parentTask.id)" :key="childTask.id" class="task-tree-row level-1">
<div
class="task-row"
role="button"
tabindex="0"
@click="emit('select-task', childTask)"
@keydown.enter="emit('select-task', childTask)"
@keydown.space.prevent="emit('select-task', childTask)"
>
<span class="tree-toggle" @click.stop="toggleExpanded(childTask)">
<i v-if="hasChildren(childTask)" :class="isExpanded(childTask.id) ? 'mdi mdi-chevron-down' : 'mdi mdi-chevron-right'"></i>
</span>
<span class="task-main">
<strong>{{ childTask.title }}</strong>
<small class="text-muted">{{ childTask.description || "No description" }}</small>
</span>
<div class="task-status-menu" @click.stop>
<button type="button" class="status-trigger" :title="`Status: ${statusName(childTask.status_id)}`" @click="toggleStatusMenu(childTask.id)">
<span class="status-trigger-dot" :style="statusDotStyle(childTask.status_id)"></span>
</button>
<div v-if="isStatusMenuOpen(childTask.id)" class="status-popup">
<button
v-for="status in statuses"
:key="status.id"
type="button"
class="status-option"
:class="{ selected: childTask.status_id === status.id }"
@click="onTaskStatusChange(childTask, status.id)"
>
<span class="status-option-dot" :style="{ borderColor: status.color || '#7c8596' }"></span>
<span class="status-option-label">{{ status.name }}</span>
<i v-if="childTask.status_id === status.id" class="mdi mdi-check status-option-check" aria-hidden="true"></i>
</button>
</div>
</div>
</div>
<div v-if="isExpanded(childTask.id)">
<div v-for="grandchildTask in childrenFor(childTask.id)" :key="grandchildTask.id" class="task-tree-row level-2">
<div
class="task-row"
role="button"
tabindex="0"
@click="emit('select-task', grandchildTask)"
@keydown.enter="emit('select-task', grandchildTask)"
@keydown.space.prevent="emit('select-task', grandchildTask)"
>
<span class="tree-toggle"></span>
<span class="task-main">
<strong>{{ grandchildTask.title }}</strong>
<small class="text-muted">{{ grandchildTask.description || "No description" }}</small>
</span>
<div class="task-status-menu" @click.stop>
<button type="button" class="status-trigger" :title="`Status: ${statusName(grandchildTask.status_id)}`" @click="toggleStatusMenu(grandchildTask.id)">
<span class="status-trigger-dot" :style="statusDotStyle(grandchildTask.status_id)"></span>
</button>
<div v-if="isStatusMenuOpen(grandchildTask.id)" class="status-popup">
<button
v-for="status in statuses"
:key="status.id"
type="button"
class="status-option"
:class="{ selected: grandchildTask.status_id === status.id }"
@click="onTaskStatusChange(grandchildTask, status.id)"
>
<span class="status-option-dot" :style="{ borderColor: status.color || '#7c8596' }"></span>
<span class="status-option-label">{{ status.name }}</span>
<i v-if="grandchildTask.status_id === status.id" class="mdi mdi-check status-option-check" aria-hidden="true"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
<teleport to="body">
<div v-if="showStatusModal" class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="closeStatusModal">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ statusMode === "create" ? "Create Task Status" : "Edit Task Status" }}</h5>
<button type="button" class="btn-close" aria-label="Close" @click="closeStatusModal"></button>
</div>
<div class="modal-body">
<label class="form-label" for="taskStatusName">Status Name</label>
<input id="taskStatusName" v-model="statusForm.name" type="text" class="form-control" maxlength="100" placeholder="e.g. Blocked" />
<label class="form-label mt-3" for="taskStatusColor">Status Color</label>
<div class="status-color-row">
<input id="taskStatusColor" v-model="statusForm.color" type="color" class="form-control form-control-color" title="Choose status color" />
<input v-model="statusForm.color" type="text" class="form-control" placeholder="#7c8596" maxlength="20" />
</div>
<section v-if="statusMode === 'edit'" class="danger-zone mt-4" aria-labelledby="status-danger-zone-title">
<h6 id="status-danger-zone-title" class="danger-zone-title">Danger Zone</h6>
<p class="danger-zone-copy mb-2">Deleting this status is permanent and cannot be undone.</p>
<button type="button" class="btn btn-outline-danger" @click="deleteStatusFromModal">Delete Status</button>
</section>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @click="closeStatusModal">Cancel</button>
<button type="button" class="btn btn-primary" @click="submitStatusForm">
{{ statusMode === "create" ? "Create" : "Save" }}
</button>
</div>
</div>
</div>
</div>
<div v-if="showStatusModal" class="modal-backdrop fade show"></div>
</teleport>
</section>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
const props = defineProps({
tasks: {
type: Array,
default: () => [],
},
statuses: {
type: Array,
default: () => [],
},
categoryOptions: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(["create-task", "select-task", "filter-change", "reorder-status", "create-status", "rename-status", "delete-status", "update-task-status"]);
const filterCategory = ref("");
const filterStatus = ref("");
const filterParent = ref("");
const showStatusModal = ref(false);
const statusMode = ref("create");
const editingStatusId = ref("");
const draggedStatusId = ref("");
const dragOverStatusId = ref("");
const expandedTaskIds = ref({});
const openStatusMenuTaskId = ref("");
const statusForm = ref({
name: "",
color: "#7c8596",
});
const parentTaskOptions = computed(() => props.tasks.filter((task) => task.depth < 2));
const tasksById = computed(() => {
const map = new Map();
for (const task of props.tasks) {
map.set(task.id, task);
}
return map;
});
const tasksByParentId = computed(() => {
const map = new Map();
for (const task of props.tasks) {
if (!task.parent_task_id) {
continue;
}
const existing = map.get(task.parent_task_id) || [];
existing.push(task);
map.set(task.parent_task_id, existing);
}
for (const [key, children] of map) {
map.set(
key,
[...children].sort((a, b) => (a.title || "").localeCompare(b.title || "")),
);
}
return map;
});
const parentTasks = computed(() => props.tasks.filter((task) => !task.parent_task_id || !tasksById.value.has(task.parent_task_id)));
const statusSections = computed(() =>
props.statuses.map((status) => ({
status,
parentTasks: parentTasks.value.filter((task) => task.status_id === status.id),
})),
);
const emitFilters = () => {
emit("filter-change", {
categoryId: filterCategory.value || null,
statusId: filterStatus.value || null,
parentTaskId: filterParent.value || null,
});
};
const statusHeaderStyle = (status) => {
const color = status.color || "#7c8596";
return {
borderColor: color,
};
};
const statusColor = (statusId) => props.statuses.find((status) => status.id === statusId)?.color || "#7c8596";
const statusName = (statusId) => props.statuses.find((status) => status.id === statusId)?.name || "Unknown";
const statusDotStyle = (statusId) => ({
backgroundColor: statusColor(statusId),
});
const isStatusMenuOpen = (taskId) => openStatusMenuTaskId.value === taskId;
const toggleStatusMenu = (taskId) => {
openStatusMenuTaskId.value = openStatusMenuTaskId.value === taskId ? "" : taskId;
};
const closeStatusMenu = () => {
openStatusMenuTaskId.value = "";
};
const onDocumentClick = () => {
closeStatusMenu();
};
const childrenFor = (parentId) => tasksByParentId.value.get(parentId) || [];
const hasChildren = (task) => childrenFor(task.id).length > 0;
const isExpanded = (taskId) => !!expandedTaskIds.value[taskId];
const toggleExpanded = (task) => {
if (!hasChildren(task)) {
return;
}
expandedTaskIds.value = {
...expandedTaskIds.value,
[task.id]: !expandedTaskIds.value[task.id],
};
};
const onTaskStatusChange = (task, statusId) => {
if (!task?.id || !statusId || task.status_id === statusId) {
closeStatusMenu();
return;
}
emit("update-task-status", {
taskId: task.id,
currentStatusId: task.status_id,
targetStatusId: statusId,
});
closeStatusMenu();
};
onMounted(() => {
document.addEventListener("click", onDocumentClick);
});
onBeforeUnmount(() => {
document.removeEventListener("click", onDocumentClick);
});
const onStatusDragStart = (statusId) => {
draggedStatusId.value = statusId;
};
const onStatusDragOver = (statusId) => {
dragOverStatusId.value = statusId;
};
const onStatusDragLeave = (statusId) => {
if (dragOverStatusId.value === statusId) {
dragOverStatusId.value = "";
}
};
const onStatusDrop = (targetStatusId) => {
if (!draggedStatusId.value || draggedStatusId.value === targetStatusId) {
onStatusDragEnd();
return;
}
const ordered = props.statuses.map((item) => item.id);
const fromIndex = ordered.indexOf(draggedStatusId.value);
const targetIndex = ordered.indexOf(targetStatusId);
if (fromIndex < 0 || targetIndex < 0) {
onStatusDragEnd();
return;
}
ordered.splice(fromIndex, 1);
const insertIndex = ordered.indexOf(targetStatusId);
ordered.splice(insertIndex, 0, draggedStatusId.value);
emit("reorder-status", ordered);
onStatusDragEnd();
};
const onStatusDragEnd = () => {
draggedStatusId.value = "";
dragOverStatusId.value = "";
};
const closeStatusModal = () => {
showStatusModal.value = false;
statusMode.value = "create";
editingStatusId.value = "";
statusForm.value = {
name: "",
color: "#7c8596",
};
};
const openCreateStatusModal = () => {
statusMode.value = "create";
editingStatusId.value = "";
statusForm.value = {
name: "",
color: "#7c8596",
};
showStatusModal.value = true;
};
const openEditStatusModal = (status) => {
statusMode.value = "edit";
editingStatusId.value = status.id;
statusForm.value = {
name: status.name || "",
color: status.color || "#7c8596",
};
showStatusModal.value = true;
};
const submitStatusForm = () => {
const name = statusForm.value.name?.trim();
if (!name) {
return;
}
const color = statusForm.value.color?.trim() || "";
if (statusMode.value === "create") {
emit("create-status", { name, color });
} else {
if (!editingStatusId.value) {
return;
}
emit("rename-status", {
id: editingStatusId.value,
name,
color,
});
}
closeStatusModal();
};
const deleteStatusFromModal = () => {
if (statusMode.value !== "edit" || !editingStatusId.value) {
return;
}
emit("delete-status", {
id: editingStatusId.value,
name: statusForm.value.name?.trim() || "",
color: statusForm.value.color?.trim() || "",
});
closeStatusModal();
};
</script>
<style scoped>
.task-board {
display: flex;
flex-direction: column;
gap: 1rem;
}
.task-board-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.task-filters {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
}
.status-lane {
border: 1px solid #d9e2ec;
border-radius: 12px;
padding: 0.75rem;
background: linear-gradient(180deg, #fcfdff 0%, #f5f8fc 100%);
}
.lane-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.status-list {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.status-item {
display: flex;
align-items: center;
gap: 0.55rem;
padding: 0.4rem 0.45rem;
border-radius: 8px;
background: #ffffff;
border: 1px solid #e4e9f0;
cursor: grab;
}
.status-item.is-drag-over {
border-color: #7aa2f7;
background: #eef3ff;
}
.drag-handle {
color: #74839a;
display: inline-flex;
align-items: center;
justify-content: center;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 999px;
}
.status-name {
flex: 1;
font-weight: 600;
}
.status-actions {
display: inline-flex;
gap: 0.35rem;
}
.task-status-groups {
display: flex;
flex-direction: column;
gap: 1rem;
}
.status-group {
border: 1px solid #dbe4f0;
border-radius: 12px;
overflow: visible;
background: #fff;
}
.status-group-header {
display: flex;
align-items: center;
justify-content: space-between;
border-left: 6px solid transparent;
background: #f8fbff;
border-bottom: 1px solid #edf2f8;
padding: 0.65rem 0.85rem;
}
.status-group-title-wrap {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.status-group-title {
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.status-group-dot {
width: 10px;
height: 10px;
border-radius: 999px;
}
.status-group-count {
color: #5f6f87;
font-weight: 600;
}
.status-empty {
padding: 0.75rem 0.85rem;
color: #7a8799;
font-size: 0.9rem;
}
.task-tree-row {
border-bottom: 1px solid #edf2f8;
}
.task-tree-row:last-child {
border-bottom: 0;
}
.task-tree-row.level-1 .task-row {
padding-left: 2.1rem;
}
.task-tree-row.level-2 .task-row {
padding-left: 3.5rem;
}
.task-row {
width: 100%;
display: grid;
grid-template-columns: 28px 1fr auto;
gap: 0.65rem;
align-items: center;
border: 0;
background: #fff;
text-align: left;
padding: 0.7rem 0.85rem;
}
.task-row:hover {
background: #f4f8ff;
}
.status-group-header {
border-top-left-radius: 12px;
border-top-right-radius: 12px;
}
.status-group > .task-tree-row:last-child .task-row,
.status-group > .task-tree-row:last-child > div > .task-tree-row:last-child .task-row,
.status-group > .task-tree-row:last-child > div > .task-tree-row:last-child > div > .task-tree-row:last-child .task-row {
border-bottom-left-radius: 12px;
border-bottom-right-radius: 12px;
}
.tree-toggle {
width: 1.25rem;
color: #5f6f87;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
background: transparent;
padding: 0;
}
.task-main {
display: flex;
flex-direction: column;
min-width: 0;
}
.task-main strong,
.task-main small {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.task-status-menu {
position: relative;
display: inline-flex;
}
.status-trigger {
width: 28px;
height: 28px;
border: 0;
background: transparent;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.status-trigger:hover {
background: #eef3f9;
}
.status-trigger-dot {
width: 14px;
height: 14px;
border: 2px solid #fff;
box-shadow: 0 0 0 1px rgba(67, 81, 98, 0.25);
border-radius: 999px;
}
.status-popup {
position: absolute;
right: 0;
top: calc(100% + 0.3rem);
min-width: 190px;
background: #151a22;
border: 1px solid #2a3343;
border-radius: 10px;
box-shadow: 0 12px 28px rgba(5, 9, 15, 0.35);
padding: 0.35rem;
z-index: 40;
}
.status-option {
width: 100%;
border: 0;
border-radius: 8px;
background: transparent;
color: #e8edf5;
display: grid;
grid-template-columns: 14px 1fr auto;
align-items: center;
gap: 0.55rem;
padding: 0.45rem 0.5rem;
text-align: left;
}
.status-option:hover,
.status-option.selected {
background: rgba(255, 255, 255, 0.09);
}
.status-option-dot {
width: 14px;
height: 14px;
border-radius: 999px;
border: 2px solid;
background: transparent;
}
.status-option-label {
font-size: 0.86rem;
letter-spacing: 0.02em;
text-transform: uppercase;
font-weight: 600;
}
.status-option-check {
color: #e8edf5;
font-size: 0.95rem;
}
.empty-state {
padding: 1rem;
color: #6c757d;
}
.status-color-row {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.5rem;
align-items: center;
}
.danger-zone {
border: 1px solid #f3b5b5;
border-radius: 0.75rem;
background: #fff5f5;
padding: 0.75rem;
}
.danger-zone-title {
color: #9f1c1c;
margin: 0;
font-weight: 700;
}
.danger-zone-copy {
color: #7a2727;
font-size: 0.9rem;
}
@media (max-width: 900px) {
.task-filters {
grid-template-columns: 1fr;
}
.task-row {
grid-template-columns: 24px 1fr;
}
.status-popup {
right: -0.2rem;
min-width: 170px;
}
}
/* ── Dark mode ── */
:root[data-bs-theme="dark"] .status-lane {
background: linear-gradient(180deg, #1e2330 0%, #1a1d27 100%);
border-color: #3a3f4b;
}
:root[data-bs-theme="dark"] .status-item {
background: #252b38;
border-color: #3a3f4b;
color: #c8d3e6;
}
:root[data-bs-theme="dark"] .status-item.is-drag-over {
border-color: #7aa2f7;
background: #1e2d4a;
}
:root[data-bs-theme="dark"] .drag-handle {
color: #5f6f87;
}
:root[data-bs-theme="dark"] .status-group {
background: #1e2230;
border-color: #3a3f4b;
}
:root[data-bs-theme="dark"] .status-group-header {
background: #232840;
border-bottom-color: #3a3f4b;
}
:root[data-bs-theme="dark"] .status-group-title {
color: #c8d3e6;
}
:root[data-bs-theme="dark"] .status-group-count {
color: #7a8fa8;
}
:root[data-bs-theme="dark"] .status-empty {
color: #5f6f87;
}
:root[data-bs-theme="dark"] .task-tree-row {
border-bottom-color: #2e3444;
}
:root[data-bs-theme="dark"] .task-row {
background: #1e2230;
color: #c8d3e6;
}
:root[data-bs-theme="dark"] .task-row:hover {
background: #252d40;
}
:root[data-bs-theme="dark"] .tree-toggle {
color: #7a8fa8;
}
:root[data-bs-theme="dark"] .task-main small {
color: #7a8fa8;
}
:root[data-bs-theme="dark"] .status-trigger:hover {
background: #2e3448;
}
:root[data-bs-theme="dark"] .status-trigger-dot {
border-color: #1e2230;
box-shadow: 0 0 0 1px rgba(180, 195, 220, 0.2);
}
:root[data-bs-theme="dark"] .empty-state {
color: #7a8fa8;
}
</style>

View File

@@ -0,0 +1,207 @@
<template>
<teleport to="body">
<div class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')">
<div class="modal-dialog modal-xl modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ localTask.id ? "Task Detail" : "Create Task" }}</h5>
<button type="button" class="btn-close" aria-label="Close" @click="emit('close')"></button>
</div>
<div class="modal-body">
<div class="row g-3">
<div class="col-12 col-lg-7">
<label class="form-label">Title</label>
<input v-model="localTask.title" class="form-control" type="text" maxlength="255" />
<label class="form-label mt-3">Description</label>
<textarea v-model="localTask.description" class="form-control" rows="5" maxlength="2000"></textarea>
<label class="form-label mt-3">Category</label>
<select v-model="localTask.category_id" class="form-select">
<option value="">Uncategorized</option>
<option v-for="category in categoryOptions" :key="category.id" :value="category.id">{{ category.label }}</option>
</select>
<label class="form-label mt-3">Parent Task</label>
<select v-model="localTask.parent_task_id" class="form-select">
<option value="">No parent (top level)</option>
<option v-for="option in parentTaskOptions" :key="option.id" :value="option.id">{{ option.title }}</option>
</select>
</div>
<div class="col-12 col-lg-5">
<label class="form-label">Status</label>
<select v-model="localTask.status_id" class="form-select">
<option v-for="status in statuses" :key="status.id" :value="status.id">{{ status.name }}</option>
</select>
<div class="status-progress mt-3">
<div v-for="status in statuses" :key="status.id" class="progress-step" :class="stepClass(status)">
<span class="dot" :style="{ borderColor: status.color || '#7c8596', backgroundColor: isReached(status) ? status.color || '#7c8596' : 'transparent' }"></span>
<span>{{ status.name }}</span>
</div>
</div>
<div class="mt-3 d-flex gap-2">
<button class="btn btn-outline-secondary" :disabled="!localTask.id" @click="emit('transition', { taskId: localTask.id, direction: 'backward' })">Revert</button>
<button class="btn btn-outline-primary" :disabled="!localTask.id" @click="emit('transition', { taskId: localTask.id, direction: 'forward' })">Advance</button>
</div>
<div class="mt-4">
<h6>Subtasks</h6>
<div v-if="!subtasks.length" class="text-muted small">No subtasks yet.</div>
<button v-for="subtask in subtasks" :key="subtask.id" class="subtask-row" @click="emit('open-task', subtask)">
<span>{{ subtask.title }}</span>
<small>L{{ subtask.depth + 1 }}</small>
</button>
<button v-if="canAddSubtask" class="btn btn-sm btn-outline-primary mt-2" @click="emit('create-subtask', localTask)">Add Subtask</button>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @click="emit('close')">Close</button>
<button v-if="localTask.id" type="button" class="btn btn-danger" @click="emit('delete-task', localTask)">Delete</button>
<button type="button" class="btn btn-primary" @click="saveTask">Save</button>
</div>
</div>
</div>
</div>
<div class="modal-backdrop fade show"></div>
</teleport>
</template>
<script setup>
import { computed, ref, watch } from "vue";
const props = defineProps({
task: {
type: Object,
default: () => ({}),
},
statuses: {
type: Array,
default: () => [],
},
categoryOptions: {
type: Array,
default: () => [],
},
parentTaskOptions: {
type: Array,
default: () => [],
},
subtasks: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(["close", "save-task", "delete-task", "transition", "create-subtask", "open-task"]);
const localTask = ref({});
watch(
() => props.task,
(value) => {
localTask.value = {
title: "",
description: "",
category_id: "",
status_id: props.statuses[0]?.id || "",
parent_task_id: "",
note_links: [],
...value,
category_id: value?.category_id || "",
parent_task_id: value?.parent_task_id || "",
note_links: value?.note_links || [],
};
},
{ immediate: true },
);
const canAddSubtask = computed(() => !!localTask.value.id && (localTask.value.depth ?? 0) < 2);
const isReached = (status) => {
const current = props.statuses.find((item) => item.id === localTask.value.status_id)?.order ?? 0;
return status.order <= current;
};
const stepClass = (status) => {
const current = props.statuses.find((item) => item.id === localTask.value.status_id)?.order ?? 0;
return {
current: status.order === current,
done: status.order < current,
};
};
const saveTask = () => {
emit("save-task", {
...localTask.value,
category_id: localTask.value.category_id || null,
parent_task_id: localTask.value.parent_task_id || null,
});
};
</script>
<style scoped>
.status-progress {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.progress-step {
display: flex;
align-items: center;
gap: 0.45rem;
color: #627086;
}
.progress-step.current {
color: #0f172a;
font-weight: 700;
}
.progress-step.done {
color: #1f7a4d;
}
.dot {
width: 12px;
height: 12px;
border-radius: 999px;
border: 2px solid;
}
.subtask-row {
width: 100%;
margin-top: 0.35rem;
border: 1px solid #dbe4f0;
border-radius: 8px;
background: #f8fbff;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.35rem 0.5rem;
}
/* ── Dark mode ── */
:root[data-bs-theme="dark"] .progress-step {
color: #7a8fa8;
}
:root[data-bs-theme="dark"] .progress-step.current {
color: #e2e8f0;
}
:root[data-bs-theme="dark"] .progress-step.done {
color: #4ade80;
}
:root[data-bs-theme="dark"] .subtask-row {
background: #252b38;
border-color: #3a3f4b;
color: #c8d3e6;
}
</style>

View File

@@ -13,9 +13,12 @@ export const useSpaceStore = defineStore("space", () => {
const notesLoading = ref(false);
const categories = ref([]);
const categoryTree = ref([]);
const tasks = ref([]);
const taskStatuses = ref([]);
const noteLinkedTasks = ref([]);
const refreshSpaceData = async (spaceId) => {
await Promise.all([fetchCategories(spaceId), fetchNotes(spaceId)]);
await Promise.all([fetchCategories(spaceId), fetchNotes(spaceId), fetchTaskStatuses(spaceId), fetchTasks(spaceId)]);
};
const fetchSpaces = async () => {
@@ -208,6 +211,130 @@ export const useSpaceStore = defineStore("space", () => {
searchResults.value = [];
};
const fetchTaskStatuses = async (spaceId) => {
if (!spaceId) {
taskStatuses.value = [];
return [];
}
try {
const response = await apiClient.get(`/api/v1/spaces/${spaceId}/task-statuses`);
taskStatuses.value = response.data || [];
return taskStatuses.value;
} catch (error) {
console.error("Error fetching task statuses:", error);
taskStatuses.value = [];
return [];
}
};
const createTaskStatus = async (spaceId, payload) => {
const response = await apiClient.post(`/api/v1/spaces/${spaceId}/task-statuses`, payload);
await fetchTaskStatuses(spaceId);
return response.data;
};
const updateTaskStatus = async (spaceId, statusId, payload) => {
const response = await apiClient.put(`/api/v1/spaces/${spaceId}/task-statuses/${statusId}`, payload);
await fetchTaskStatuses(spaceId);
return response.data;
};
const deleteTaskStatus = async (spaceId, statusId) => {
await apiClient.delete(`/api/v1/spaces/${spaceId}/task-statuses/${statusId}`);
await fetchTaskStatuses(spaceId);
};
const reorderTaskStatuses = async (spaceId, orderedStatusIds) => {
const response = await apiClient.put(`/api/v1/spaces/${spaceId}/task-statuses/reorder`, {
ordered_status_ids: orderedStatusIds,
});
taskStatuses.value = response.data || [];
return taskStatuses.value;
};
const fetchTasks = async (spaceId, filters = {}) => {
if (!spaceId) {
tasks.value = [];
return [];
}
const params = {};
if (filters.categoryId) {
params.categoryId = filters.categoryId;
}
if (filters.statusId) {
params.statusId = filters.statusId;
}
if (typeof filters.parentTaskId === "string") {
params.parentTaskId = filters.parentTaskId;
}
try {
const response = await apiClient.get(`/api/v1/spaces/${spaceId}/tasks`, { params });
tasks.value = response.data || [];
return tasks.value;
} catch (error) {
console.error("Error fetching tasks:", error);
tasks.value = [];
return [];
}
};
const searchTasks = async (spaceId, query) => {
if (!spaceId || !query?.trim()) {
return [];
}
const response = await apiClient.get(`/api/v1/spaces/${spaceId}/tasks/search`, { params: { q: query } });
return response.data || [];
};
const getTask = async (spaceId, taskId) => {
const response = await apiClient.get(`/api/v1/spaces/${spaceId}/tasks/${taskId}`);
return response.data;
};
const createTask = async (spaceId, payload) => {
const response = await apiClient.post(`/api/v1/spaces/${spaceId}/tasks`, payload);
await fetchTasks(spaceId);
return response.data;
};
const updateTask = async (spaceId, taskId, payload) => {
const response = await apiClient.put(`/api/v1/spaces/${spaceId}/tasks/${taskId}`, payload);
await fetchTasks(spaceId);
return response.data;
};
const deleteTask = async (spaceId, taskId) => {
await apiClient.delete(`/api/v1/spaces/${spaceId}/tasks/${taskId}`);
await fetchTasks(spaceId);
};
const transitionTask = async (spaceId, taskId, direction) => {
const response = await apiClient.post(`/api/v1/spaces/${spaceId}/tasks/${taskId}/transition`, { direction });
await fetchTasks(spaceId);
return response.data;
};
const fetchTasksForNote = async (spaceId, noteId) => {
if (!spaceId || !noteId) {
noteLinkedTasks.value = [];
return [];
}
const response = await apiClient.get(`/api/v1/spaces/${spaceId}/notes/${noteId}/tasks`);
noteLinkedTasks.value = response.data || [];
return noteLinkedTasks.value;
};
const linkTaskToNote = async (spaceId, taskId, noteId) => {
const response = await apiClient.post(`/api/v1/spaces/${spaceId}/tasks/${taskId}/notes`, { note_id: noteId });
return response.data;
};
const unlinkTaskFromNote = async (spaceId, taskId, noteId) => {
const response = await apiClient.delete(`/api/v1/spaces/${spaceId}/tasks/${taskId}/notes/${noteId}`);
return response.data;
};
return {
spaces,
currentSpace,
@@ -217,6 +344,9 @@ export const useSpaceStore = defineStore("space", () => {
notesLoading,
categories,
categoryTree,
tasks,
taskStatuses,
noteLinkedTasks,
fetchSpaces,
selectSpace,
fetchNotes,
@@ -232,5 +362,20 @@ export const useSpaceStore = defineStore("space", () => {
deleteNote,
searchNotes,
clearSearchResults,
fetchTaskStatuses,
createTaskStatus,
updateTaskStatus,
deleteTaskStatus,
reorderTaskStatuses,
fetchTasks,
searchTasks,
getTask,
createTask,
updateTask,
deleteTask,
transitionTask,
fetchTasksForNote,
linkTaskToNote,
unlinkTaskFromNote,
};
});