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

@@ -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>