All checks were successful
Build and Push App Image / build-and-push (push) Successful in 1m52s
1911 lines
64 KiB
Vue
1911 lines
64 KiB
Vue
<template>
|
|
<div class="dashboard-container">
|
|
<nav ref="navbarRef" class="navbar navbar-dark bg-dark sticky-top">
|
|
<div class="container-fluid app-navbar">
|
|
<div class="navbar-left d-flex align-items-center gap-2">
|
|
<button v-if="currentUser && currentSpace" class="btn btn-outline-light d-md-none nav-menu-toggle" type="button" aria-label="Toggle sidebar" @click="showSidebar = !showSidebar">
|
|
<i class="mdi mdi-menu" aria-hidden="true"></i>
|
|
</button>
|
|
|
|
<span class="navbar-brand mb-0 h1 d-flex align-items-center gap-2 app-brand">
|
|
<i class="mdi mdi-notebook-outline" aria-hidden="true"></i>
|
|
<span>Notely</span>
|
|
</span>
|
|
</div>
|
|
|
|
<div class="navbar-controls d-flex align-items-center gap-3">
|
|
<!-- Space Selector Dropdown -->
|
|
<div ref="spaceDropdownRef" class="dropdown nav-space-selector" v-if="currentUser" @mouseleave="showSpaceDropdown = false">
|
|
<button class="btn btn-outline-light dropdown-toggle" type="button" @click="toggleSpaceDropdown">
|
|
<span v-if="currentSpace">{{ currentSpace.name }}</span>
|
|
<span v-else>Select Space</span>
|
|
</button>
|
|
<ul class="dropdown-menu" :class="{ show: showSpaceDropdown }">
|
|
<li v-for="space in spaces" :key="space.id">
|
|
<a class="dropdown-item" @click="selectSpace(space)">
|
|
{{ space.name }}
|
|
</a>
|
|
</li>
|
|
<li><hr class="dropdown-divider" /></li>
|
|
<li>
|
|
<button v-if="canCreateSpaces" class="dropdown-item" @click="showCreateSpaceModal = true">
|
|
<i class="mdi mdi-plus-circle-outline me-1" aria-hidden="true"></i>
|
|
New Space
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- Search -->
|
|
<div class="search-box nav-search">
|
|
<input type="text" class="form-control" placeholder="Search notes & task lists..." v-model="searchQuery" @keyup.enter="performSearch" />
|
|
</div>
|
|
|
|
<!-- Theme Toggle -->
|
|
<button
|
|
class="btn btn-outline-light theme-toggle"
|
|
type="button"
|
|
:aria-label="isDarkMode ? 'Switch to light mode' : 'Switch to dark mode'"
|
|
:title="isDarkMode ? 'Switch to light mode' : 'Switch to dark mode'"
|
|
@click="isDarkMode = !isDarkMode"
|
|
>
|
|
<i :class="isDarkMode ? 'mdi mdi-weather-sunny' : 'mdi mdi-weather-night'" aria-hidden="true"></i>
|
|
</button>
|
|
|
|
<!-- User Menu -->
|
|
<div ref="userDropdownRef" class="dropdown nav-user-menu" v-if="currentUser" @mouseleave="showUserMenu = false">
|
|
<button class="btn btn-outline-light dropdown-toggle" type="button" @click="toggleUserMenu">
|
|
{{ currentUser.username }}
|
|
</button>
|
|
<ul class="dropdown-menu dropdown-menu-end" :class="{ show: showUserMenu }">
|
|
<li v-if="authStore.isAdmin"><button class="dropdown-item" @click="openAdminPanel">Admin Panel</button></li>
|
|
<li v-if="authStore.isAdmin"><hr class="dropdown-divider" /></li>
|
|
<li><a class="dropdown-item" @click="logout">Logout</a></li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
<div class="app-main d-flex" v-if="currentSpace">
|
|
<!-- Mobile backdrop -->
|
|
<div v-if="showSidebar" class="sidebar-backdrop" :style="offcanvasOffsetStyle" @click="showSidebar = false"></div>
|
|
|
|
<!-- Sidebar -->
|
|
<aside class="sidebar bg-light border-end" :class="{ open: showSidebar }" :style="offcanvasOffsetStyle">
|
|
<div class="sidebar-header p-3">
|
|
<h6 class="mb-0">Categories</h6>
|
|
<button v-if="canCreateCategories" class="btn btn-sm btn-outline-primary mt-2 w-100" @click="openCreateCategoryModal">
|
|
<i class="mdi mdi-folder-plus-outline me-1" aria-hidden="true"></i>
|
|
New Category
|
|
</button>
|
|
</div>
|
|
|
|
<div class="sidebar-content p-2">
|
|
<CategoryTree
|
|
:categories="categoryTree"
|
|
:on-select-note="selectNote"
|
|
:on-select-category="selectCategory"
|
|
:on-add-subcategory="openCreateSubcategoryModal"
|
|
:on-edit-category="openEditCategoryModal"
|
|
:on-delete-category="requestRemoveCategory"
|
|
:on-select-task-list="selectTaskList"
|
|
:can-create-categories="canCreateCategories"
|
|
:can-edit-categories="canEditCategories"
|
|
:can-delete-categories="canDeleteCategories"
|
|
/>
|
|
</div>
|
|
|
|
<div v-if="canManageSpaceSettings" class="sidebar-footer p-2 border-top">
|
|
<button class="btn btn-sm btn-outline-secondary w-100" @click="showSpaceSettingsModal = true">
|
|
<i class="mdi mdi-cog-outline me-1" aria-hidden="true"></i>
|
|
Space Settings
|
|
</button>
|
|
</div>
|
|
</aside>
|
|
|
|
<!-- Main Content -->
|
|
<main class="main-content flex-grow-1">
|
|
<div class="toolbar p-3 border-bottom">
|
|
<div class="row align-items-center">
|
|
<div class="col">
|
|
<h5 class="mb-0 breadcrumb-title">
|
|
<template v-for="(crumb, index) in breadcrumbItems" :key="`${crumb.label}-${index}`">
|
|
<button v-if="crumb.clickable" type="button" class="breadcrumb-link" @click="crumb.onClick">
|
|
{{ crumb.label }}
|
|
</button>
|
|
<span v-else>{{ crumb.label }}</span>
|
|
<span v-if="index < breadcrumbItems.length - 1" class="breadcrumb-separator"> / </span>
|
|
</template>
|
|
</h5>
|
|
</div>
|
|
<div class="col-auto d-flex align-items-center">
|
|
<div
|
|
v-if="(activeView === 'notes' || activeView === 'tasks') && (canCreateNotes || canCreateTasks)"
|
|
ref="createDropdownRef"
|
|
class="dropdown me-2"
|
|
@mouseleave="showCreateMenu = false"
|
|
>
|
|
<button class="btn btn-primary dropdown-toggle action-button" type="button" aria-label="Create" title="Create" @click="toggleCreateMenu">
|
|
<i class="mdi mdi-plus-circle-outline me-1" aria-hidden="true"></i>
|
|
<span class="action-label">Create</span>
|
|
</button>
|
|
<ul class="dropdown-menu dropdown-menu-end" :class="{ show: showCreateMenu }">
|
|
<li v-if="activeView === 'notes' && canCreateNotes">
|
|
<button class="dropdown-item" type="button" @click="openCreateNoteModalFromMenu">
|
|
<i class="mdi mdi-note-plus-outline me-1" aria-hidden="true"></i>
|
|
New Note
|
|
</button>
|
|
</li>
|
|
<li v-if="activeView === 'notes' && canCreateTasks">
|
|
<button class="dropdown-item" type="button" @click="openCreateTaskListModalFromMenu">
|
|
<i class="mdi mdi-format-list-checkbox me-1" aria-hidden="true"></i>
|
|
New Task List
|
|
</button>
|
|
</li>
|
|
<li v-if="activeView === 'tasks' && canCreateTasks">
|
|
<button class="dropdown-item" type="button" @click="openTaskCreateModal">
|
|
<i class="mdi mdi-checkbox-marked-circle-plus-outline me-1" aria-hidden="true"></i>
|
|
New Task
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
</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"
|
|
:class="noteViewMode === 'grid' ? 'btn-secondary' : 'btn-outline-secondary'"
|
|
aria-label="Grid view"
|
|
title="Grid view"
|
|
@click="noteViewMode = 'grid'"
|
|
>
|
|
<i class="mdi mdi-view-grid-outline" aria-hidden="true"></i>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="btn action-button"
|
|
:class="noteViewMode === 'list' ? 'btn-secondary' : 'btn-outline-secondary'"
|
|
aria-label="List view"
|
|
title="List view"
|
|
@click="noteViewMode = 'list'"
|
|
>
|
|
<i class="mdi mdi-view-list-outline" aria-hidden="true"></i>
|
|
</button>
|
|
</div>
|
|
<button
|
|
v-if="activeView === 'notes' && canEditNotes && selectedNote && !isEditingNote && !isSearchRoute"
|
|
class="btn btn-outline-secondary me-2 action-button"
|
|
aria-label="Edit note"
|
|
title="Edit note"
|
|
@click="startEditingNote"
|
|
>
|
|
<i class="mdi mdi-pencil-outline me-1" aria-hidden="true"></i>
|
|
<span class="action-label">Edit Note</span>
|
|
</button>
|
|
<button
|
|
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'"
|
|
@click="copyShareLink"
|
|
>
|
|
<i class="mdi mdi-share-variant-outline me-1" aria-hidden="true"></i>
|
|
<span class="action-label">{{ shareCopied ? "Copied" : "Share" }}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<AppWorkspaceContent
|
|
:active-view="activeView"
|
|
:tasks="tasks"
|
|
:task-statuses="taskStatuses"
|
|
:selected-task-list="selectedTaskList"
|
|
:is-search-route="isSearchRoute"
|
|
:search-items="searchItems"
|
|
:search-query="searchQuery"
|
|
:search-page="searchPage"
|
|
:search-page-size="searchPageSize"
|
|
:note-view-mode="noteViewMode"
|
|
:selected-note="selectedNote"
|
|
:is-editing-note="isEditingNote"
|
|
:category-options="categoryOptions"
|
|
:can-delete-notes="canDeleteNotes"
|
|
:current-space-id="currentSpace?.id"
|
|
:linked-tasks-for-selected-note="linkedTasksForSelectedNote"
|
|
:displayed-items="displayedItems"
|
|
:can-load-more-main-notes="canLoadMoreMainNotes"
|
|
:is-loading-more-main-notes="spaceStore.notesLoading"
|
|
@select-task="openTaskDetail"
|
|
@filter-change="applyTaskFilters"
|
|
@update-task-status="updateTaskStatusFromBoard"
|
|
@edit-task-list="showEditTaskListModal = true"
|
|
@select-note="selectSearchResultNote"
|
|
@select-task-list="selectTaskList"
|
|
@page-change="setSearchPage"
|
|
@save-note="updateNote"
|
|
@delete-note="deleteNote"
|
|
@cancel-edit-note="cancelEditingNote"
|
|
@open-linked-task="openLinkedTaskFromNote"
|
|
@load-more="loadMoreMainNotes"
|
|
/>
|
|
</main>
|
|
</div>
|
|
|
|
<div v-else-if="spaces.length === 0" class="d-flex align-items-center justify-content-center min-vh-100">
|
|
<div class="text-center">
|
|
<i class="mdi mdi-folder-outline" style="font-size: 5rem; color: #6c757d; margin-bottom: 1rem; display: block"></i>
|
|
<h2 class="text-muted mb-3">No Spaces Yet</h2>
|
|
<p class="text-muted mb-4" style="font-size: 1.1rem">Create a space to start organizing your notes</p>
|
|
<button v-if="canCreateSpaces" class="btn btn-primary" @click="showCreateSpaceModal = true">
|
|
<i class="mdi mdi-plus-circle-outline me-2" aria-hidden="true"></i>
|
|
Create Your First Space
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<AppModalHost
|
|
:show-create-space-modal="showCreateSpaceModal"
|
|
:show-create-category-modal="showCreateCategoryModal"
|
|
:editing-category="editingCategory"
|
|
:category-parent-options="categoryParentOptions"
|
|
:category-modal-parent-id="categoryModalParentId"
|
|
:show-create-note-modal="showCreateNoteModal"
|
|
:category-options="categoryOptions"
|
|
:selected-category-id="selectedCategory?.id || null"
|
|
:show-create-task-list-modal="showCreateTaskListModal"
|
|
:show-space-settings-modal="showSpaceSettingsModal"
|
|
:current-space="currentSpace"
|
|
:can-manage-space-settings="canManageSpaceSettings"
|
|
:show-task-modal="showTaskModal"
|
|
:task-modal-draft="taskModalDraft"
|
|
:task-statuses="taskStatuses"
|
|
:task-parent-options="taskParentOptions"
|
|
:task-detail-subtasks="taskDetail?.subtasks || []"
|
|
@close-create-space="showCreateSpaceModal = false"
|
|
@create-space="createSpace"
|
|
@close-create-category="closeCategoryModal"
|
|
@submit-category="submitCategory"
|
|
@close-create-note="showCreateNoteModal = false"
|
|
@create-note="createNote"
|
|
@close-create-task-list="showCreateTaskListModal = false"
|
|
@create-task-list="createTaskList"
|
|
@close-space-settings="showSpaceSettingsModal = false"
|
|
@saved-space="applyUpdatedSpace"
|
|
@deleted-space="handleSpaceDeleted"
|
|
@close-task-modal="showTaskModal = false"
|
|
@save-task="saveTask"
|
|
@delete-task="requestRemoveTask"
|
|
@transition-task="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">
|
|
<div class="modal-dialog modal-dialog-centered" role="document">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title d-flex align-items-center gap-2 mb-0">
|
|
<i class="mdi mdi-lock-outline" aria-hidden="true"></i>
|
|
<span>Unlock Note</span>
|
|
</h5>
|
|
<button type="button" class="btn-close" aria-label="Close" @click="closeUnlockModal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<p class="text-muted mb-3">
|
|
Enter the password to view <strong>{{ unlockTargetNote?.title }}</strong
|
|
>.
|
|
</p>
|
|
<label class="form-label" for="unlockNotePassword">Password</label>
|
|
<input id="unlockNotePassword" v-model="unlockPassword" type="password" class="form-control" maxlength="128" @keyup.enter="unlockProtectedNote" />
|
|
<div v-if="unlockError" class="text-danger small mt-2">{{ unlockError }}</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-outline-secondary" @click="closeUnlockModal">Cancel</button>
|
|
<button type="button" class="btn btn-primary" :disabled="unlockingNote" @click="unlockProtectedNote">
|
|
{{ unlockingNote ? "Unlocking..." : "Unlock" }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-if="showUnlockModal" class="modal-backdrop fade show"></div>
|
|
</teleport>
|
|
|
|
<EditTaskListModal
|
|
v-if="showEditTaskListModal && selectedTaskList"
|
|
:task-list="selectedTaskList"
|
|
:statuses="taskStatuses"
|
|
:category-options="categoryOptions"
|
|
:can-delete-task-list="canDeleteTasks"
|
|
@close="showEditTaskListModal = false"
|
|
@update-task-list="updateTaskListFromModal"
|
|
@reorder-status="reorderTaskStatuses"
|
|
@create-status="createTaskStatus"
|
|
@rename-status="renameTaskStatus"
|
|
@delete-status="requestDeleteTaskStatus"
|
|
@delete-task-list="requestRemoveTaskList"
|
|
/>
|
|
|
|
<ConfirmActionModal
|
|
:visible="showTaskDeleteConfirmModal"
|
|
:title="taskDeleteConfirmTitle"
|
|
:message="taskDeleteConfirmMessage"
|
|
:busy="taskDeleteConfirmBusy"
|
|
@close="closeTaskDeleteConfirmModal"
|
|
@confirm="confirmTaskDeleteAction"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, nextTick, onBeforeUnmount, onMounted, watch } from "vue";
|
|
import { useRouter, useRoute } from "vue-router";
|
|
import { useAuthStore } from "../stores/authStore";
|
|
import { useSpaceStore } from "../stores/spaceStore";
|
|
import CategoryTree from "../components/CategoryTree.vue";
|
|
import AppWorkspaceContent from "../components/app/AppWorkspaceContent.vue";
|
|
import AppModalHost from "../components/app/AppModalHost.vue";
|
|
import ConfirmActionModal from "../components/ConfirmActionModal.vue";
|
|
import EditTaskListModal from "../components/EditTaskListModal.vue";
|
|
import { sortNotesByPriority } from "../utils/noteSort";
|
|
import apiClient from "../services/apiClient";
|
|
|
|
const router = useRouter();
|
|
const route = useRoute();
|
|
const authStore = useAuthStore();
|
|
const spaceStore = useSpaceStore();
|
|
|
|
const showSpaceDropdown = ref(false);
|
|
const showUserMenu = ref(false);
|
|
const showSpaceSettingsModal = ref(false);
|
|
const showCreateSpaceModal = ref(false);
|
|
const showCreateCategoryModal = ref(false);
|
|
const showCreateNoteModal = ref(false);
|
|
const showCreateTaskListModal = ref(false);
|
|
const showEditTaskListModal = ref(false);
|
|
const showSidebar = ref(false);
|
|
const navbarRef = ref(null);
|
|
const navbarHeight = ref(56);
|
|
const spaceDropdownRef = ref(null);
|
|
const userDropdownRef = ref(null);
|
|
const createDropdownRef = ref(null);
|
|
const searchQuery = ref("");
|
|
const selectedNote = ref(null);
|
|
const selectedCategory = ref(null);
|
|
const isEditingNote = ref(false);
|
|
const editingCategory = ref(null);
|
|
const categoryModalParentId = ref(null);
|
|
const shareCopied = ref(false);
|
|
const shareCopyTimeout = ref(null);
|
|
const noteViewMode = ref(localStorage.getItem("noteViewMode") || "grid");
|
|
watch(noteViewMode, (val) => localStorage.setItem("noteViewMode", val));
|
|
const isDarkMode = ref(localStorage.getItem("theme") === "dark");
|
|
const applyTheme = (dark) => {
|
|
document.documentElement.setAttribute("data-bs-theme", dark ? "dark" : "light");
|
|
localStorage.setItem("theme", dark ? "dark" : "light");
|
|
};
|
|
watch(isDarkMode, applyTheme);
|
|
applyTheme(isDarkMode.value);
|
|
const showUnlockModal = ref(false);
|
|
const unlockTargetNote = ref(null);
|
|
const unlockPassword = ref("");
|
|
const unlockError = ref("");
|
|
const unlockingNote = ref(false);
|
|
const taskFilters = ref({
|
|
taskListId: null,
|
|
statusId: null,
|
|
parentTaskId: null,
|
|
});
|
|
const selectedTaskList = ref(null);
|
|
const showTaskModal = ref(false);
|
|
const taskDetail = ref(null);
|
|
const taskModalDraft = ref(null);
|
|
const linkedTasksForSelectedNote = ref([]);
|
|
const showCreateMenu = ref(false);
|
|
const showTaskDeleteConfirmModal = ref(false);
|
|
const taskDeleteConfirmBusy = ref(false);
|
|
const taskDeleteIntent = ref({
|
|
type: "",
|
|
payload: null,
|
|
});
|
|
|
|
const currentUser = computed(() => authStore.user);
|
|
const isSearchRoute = computed(() => route.name === "Search");
|
|
const isDashboardTaskRoute = computed(() => route.name === "DashboardTaskList");
|
|
const activeView = computed(() => (isDashboardTaskRoute.value ? "tasks" : "notes"));
|
|
const spaces = computed(() => spaceStore.spaces);
|
|
const currentSpace = computed(() => spaceStore.currentSpace);
|
|
const searchResults = computed(() => sortNotesByPriority(spaceStore.searchResults));
|
|
const searchItems = computed(() => {
|
|
const query = searchQuery.value.toLowerCase();
|
|
const noteItems = searchResults.value.map((note) => ({
|
|
...note,
|
|
kind: "note",
|
|
}));
|
|
const matchingTaskLists = (spaceStore.taskLists || []).filter((tl) => tl.name.toLowerCase().includes(query));
|
|
const taskListItems = matchingTaskLists.map((tl) => ({
|
|
...tl,
|
|
kind: "task-list",
|
|
}));
|
|
return [...taskListItems, ...noteItems];
|
|
});
|
|
const searchPageSize = 12;
|
|
const searchPage = computed(() => {
|
|
const pageValue = Number.parseInt(route.query.page || "1", 10);
|
|
if (Number.isNaN(pageValue) || pageValue < 1) {
|
|
return 1;
|
|
}
|
|
return pageValue;
|
|
});
|
|
const categoryTree = computed(() => spaceStore.categoryTree);
|
|
const canCreateSpaces = computed(() => authStore.hasPermission("space.create"));
|
|
const canCreateCategories = computed(() => authStore.hasSpacePermission(currentSpace.value, "category.create"));
|
|
const canEditCategories = computed(() => authStore.hasSpacePermission(currentSpace.value, "category.edit"));
|
|
const canDeleteCategories = computed(() => authStore.hasSpacePermission(currentSpace.value, "category.delete"));
|
|
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`,
|
|
}));
|
|
const canManageSpaceSettings = computed(
|
|
() =>
|
|
authStore.hasPermission("space.edit") ||
|
|
authStore.hasSpacePermission(currentSpace.value, "settings.edit") ||
|
|
authStore.hasSpacePermission(currentSpace.value, "settings.member.manage") ||
|
|
authStore.hasSpacePermission(currentSpace.value, "settings.member.view"),
|
|
);
|
|
|
|
const flattenCategories = (items, trail = []) =>
|
|
items.flatMap((category) => {
|
|
const nextTrail = [...trail, category.name];
|
|
const label = nextTrail.join("/");
|
|
return [{ id: category.id, name: category.name, label }, ...(category.subcategories?.length ? flattenCategories(category.subcategories, nextTrail) : [])];
|
|
});
|
|
|
|
const categoryOptions = computed(() => flattenCategories(categoryTree.value));
|
|
const categoryParentOptions = computed(() => {
|
|
if (!editingCategory.value) {
|
|
return categoryOptions.value;
|
|
}
|
|
return categoryOptions.value.filter((category) => category.id !== editingCategory.value.id);
|
|
});
|
|
|
|
const collectNotesFromCategory = (category, bucket = []) => {
|
|
if (!category) {
|
|
return bucket;
|
|
}
|
|
if (Array.isArray(category.notes) && category.notes.length) {
|
|
bucket.push(...category.notes);
|
|
}
|
|
for (const subcategory of category.subcategories || []) {
|
|
collectNotesFromCategory(subcategory, bucket);
|
|
}
|
|
return bucket;
|
|
};
|
|
|
|
const collectTaskListsFromCategory = (category, bucket = []) => {
|
|
if (!category) {
|
|
return bucket;
|
|
}
|
|
if (Array.isArray(category.task_lists) && category.task_lists.length) {
|
|
bucket.push(...category.task_lists);
|
|
}
|
|
for (const subcategory of category.subcategories || []) {
|
|
collectTaskListsFromCategory(subcategory, bucket);
|
|
}
|
|
return bucket;
|
|
};
|
|
|
|
const displayedNotes = computed(() => {
|
|
if (!selectedCategory.value) {
|
|
return sortNotesByPriority(spaceStore.notes);
|
|
}
|
|
return sortNotesByPriority(collectNotesFromCategory(selectedCategory.value, []));
|
|
});
|
|
|
|
const displayedTaskLists = computed(() => {
|
|
if (!selectedCategory.value) {
|
|
return [...(spaceStore.taskLists || [])].sort((left, right) => left.name.localeCompare(right.name));
|
|
}
|
|
return [...collectTaskListsFromCategory(selectedCategory.value, [])].sort((left, right) => left.name.localeCompare(right.name));
|
|
});
|
|
|
|
const displayedItems = computed(() => {
|
|
const taskListItems = displayedTaskLists.value.map((taskList) => ({
|
|
...taskList,
|
|
kind: "task-list",
|
|
}));
|
|
const noteItems = displayedNotes.value.map((note) => ({
|
|
...note,
|
|
kind: "note",
|
|
}));
|
|
return [...taskListItems, ...noteItems];
|
|
});
|
|
const tasks = computed(() => spaceStore.tasks || []);
|
|
const taskLists = computed(() => spaceStore.taskLists || []);
|
|
const taskStatuses = computed(() => spaceStore.taskStatuses || []);
|
|
const taskDeleteConfirmTitle = computed(() => {
|
|
if (taskDeleteIntent.value.type === "task-status") {
|
|
return "Delete Task Status";
|
|
}
|
|
if (taskDeleteIntent.value.type === "task-list") {
|
|
return "Delete Task List";
|
|
}
|
|
if (taskDeleteIntent.value.type === "task") {
|
|
return "Delete Task";
|
|
}
|
|
if (taskDeleteIntent.value.type === "category") {
|
|
return "Delete Category";
|
|
}
|
|
return "Confirm Deletion";
|
|
});
|
|
const taskDeleteConfirmMessage = computed(() => {
|
|
if (taskDeleteIntent.value.type === "task-status") {
|
|
const name = taskDeleteIntent.value.payload?.name || "this status";
|
|
return `Delete status "${name}"? This action cannot be undone.`;
|
|
}
|
|
if (taskDeleteIntent.value.type === "task-list") {
|
|
const name = taskDeleteIntent.value.payload?.name || "this task list";
|
|
return `Delete task list "${name}" and all associated tasks? This action cannot be undone.`;
|
|
}
|
|
if (taskDeleteIntent.value.type === "task") {
|
|
const title = taskDeleteIntent.value.payload?.title || "this task";
|
|
return `Delete task "${title}"? This action cannot be undone.`;
|
|
}
|
|
if (taskDeleteIntent.value.type === "category") {
|
|
const name = taskDeleteIntent.value.payload?.name || "this category";
|
|
return `Delete category "${name}"? Notes will be moved to uncategorized.`;
|
|
}
|
|
return "Are you sure you want to continue?";
|
|
});
|
|
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 dashboardNoteRoute = (spaceId, noteId = undefined) => ({
|
|
name: "DashboardNote",
|
|
params: {
|
|
spaceId,
|
|
...(noteId ? { noteId } : {}),
|
|
},
|
|
});
|
|
|
|
const dashboardTaskRoute = (spaceId, taskListId) => ({
|
|
name: "DashboardTaskList",
|
|
params: { spaceId, taskListId },
|
|
});
|
|
|
|
const canLoadMoreMainNotes = computed(() => {
|
|
if (selectedCategory.value || selectedNote.value) {
|
|
return false;
|
|
}
|
|
if (isSearchRoute.value) {
|
|
return false;
|
|
}
|
|
return spaceStore.notesHasMore;
|
|
});
|
|
|
|
const findCategoryPath = (categories, categoryId, trail = []) => {
|
|
for (const category of categories || []) {
|
|
const nextTrail = [...trail, category];
|
|
if (category.id === categoryId) {
|
|
return nextTrail;
|
|
}
|
|
const nested = findCategoryPath(category.subcategories || [], categoryId, nextTrail);
|
|
if (nested) {
|
|
return nested;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const openSpaceHome = () => {
|
|
selectedNote.value = null;
|
|
selectedCategory.value = null;
|
|
isEditingNote.value = false;
|
|
showUnlockModal.value = false;
|
|
unlockTargetNote.value = null;
|
|
unlockPassword.value = "";
|
|
unlockError.value = "";
|
|
searchQuery.value = "";
|
|
selectedTaskList.value = null;
|
|
taskFilters.value = {
|
|
taskListId: null,
|
|
statusId: null,
|
|
parentTaskId: null,
|
|
};
|
|
spaceStore.clearSearchResults();
|
|
if (currentSpace.value?.id) {
|
|
router.push(dashboardNoteRoute(currentSpace.value.id));
|
|
}
|
|
if (currentSpace.value?.id) {
|
|
spaceStore.fetchNotes(currentSpace.value.id, { reset: true });
|
|
}
|
|
};
|
|
|
|
const openCategoryFromBreadcrumb = (category) => {
|
|
selectCategory(category);
|
|
};
|
|
|
|
const breadcrumbItems = computed(() => {
|
|
if (!currentSpace.value) {
|
|
return [];
|
|
}
|
|
|
|
if (isSearchRoute.value) {
|
|
return [
|
|
{
|
|
label: currentSpace.value.name,
|
|
clickable: true,
|
|
onClick: openSpaceHome,
|
|
},
|
|
{
|
|
label: searchQuery.value.trim() ? `Search: ${searchQuery.value.trim()}` : "Search",
|
|
clickable: false,
|
|
onClick: null,
|
|
},
|
|
];
|
|
}
|
|
|
|
const items = [
|
|
{
|
|
label: currentSpace.value.name,
|
|
clickable: !!(selectedNote.value || selectedCategory.value),
|
|
onClick: openSpaceHome,
|
|
},
|
|
];
|
|
|
|
if (selectedNote.value) {
|
|
const noteCategoryId = selectedNote.value.category_id;
|
|
if (noteCategoryId) {
|
|
const categoryTrail = findCategoryPath(categoryTree.value, noteCategoryId) || [];
|
|
for (const category of categoryTrail) {
|
|
items.push({
|
|
label: category.name,
|
|
clickable: true,
|
|
onClick: () => openCategoryFromBreadcrumb(category),
|
|
});
|
|
}
|
|
}
|
|
|
|
items.push({
|
|
label: selectedNote.value.title,
|
|
clickable: false,
|
|
onClick: null,
|
|
});
|
|
return items;
|
|
}
|
|
|
|
if (isDashboardTaskRoute.value && selectedTaskList.value) {
|
|
const taskListCategoryId = selectedTaskList.value.category_id;
|
|
if (taskListCategoryId) {
|
|
const categoryTrail = findCategoryPath(categoryTree.value, taskListCategoryId) || [];
|
|
for (const category of categoryTrail) {
|
|
items.push({
|
|
label: category.name,
|
|
clickable: true,
|
|
onClick: () => openCategoryFromBreadcrumb(category),
|
|
});
|
|
}
|
|
}
|
|
|
|
items.push({
|
|
label: selectedTaskList.value.name,
|
|
clickable: false,
|
|
onClick: null,
|
|
});
|
|
return items;
|
|
}
|
|
|
|
if (selectedCategory.value) {
|
|
const categoryTrail = findCategoryPath(categoryTree.value, selectedCategory.value.id) || [selectedCategory.value];
|
|
for (let i = 0; i < categoryTrail.length; i++) {
|
|
const category = categoryTrail[i];
|
|
items.push({
|
|
label: category.name,
|
|
clickable: i < categoryTrail.length - 1,
|
|
onClick: () => openCategoryFromBreadcrumb(category),
|
|
});
|
|
}
|
|
}
|
|
|
|
return items;
|
|
});
|
|
|
|
const bootstrapSpaces = async () => {
|
|
await spaceStore.fetchSpaces();
|
|
if (spaceStore.spaces.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const routeSpaceId = typeof route.params.spaceId === "string" ? route.params.spaceId : "";
|
|
const storedSpaceId = localStorage.getItem("currentSpaceId");
|
|
const targetSpace = spaceStore.spaces.find((space) => space.id === routeSpaceId) || spaceStore.spaces.find((space) => space.id === storedSpaceId) || spaceStore.spaces[0];
|
|
if (targetSpace) {
|
|
await spaceStore.selectSpace(targetSpace.id);
|
|
}
|
|
};
|
|
|
|
onMounted(async () => {
|
|
document.addEventListener("click", handleDocumentClick);
|
|
document.addEventListener("keydown", handleEscapeKey);
|
|
window.addEventListener("resize", updateNavbarHeight);
|
|
await nextTick();
|
|
updateNavbarHeight();
|
|
|
|
if (authStore.isAuthenticated) {
|
|
await bootstrapSpaces();
|
|
await nextTick();
|
|
updateNavbarHeight();
|
|
}
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
document.removeEventListener("click", handleDocumentClick);
|
|
document.removeEventListener("keydown", handleEscapeKey);
|
|
window.removeEventListener("resize", updateNavbarHeight);
|
|
clearTimeout(shareCopyTimeout.value);
|
|
});
|
|
|
|
watch(
|
|
() => authStore.isAuthenticated,
|
|
async (isAuthenticated) => {
|
|
if (!isAuthenticated) {
|
|
return;
|
|
}
|
|
await bootstrapSpaces();
|
|
await nextTick();
|
|
updateNavbarHeight();
|
|
},
|
|
);
|
|
|
|
watch(
|
|
() => route.fullPath,
|
|
async () => {
|
|
await nextTick();
|
|
updateNavbarHeight();
|
|
},
|
|
);
|
|
|
|
watch(
|
|
[() => route.path, () => route.query.q, () => currentSpace.value?.id],
|
|
async ([path, routeQuery, spaceId]) => {
|
|
if (path !== "/search") {
|
|
return;
|
|
}
|
|
|
|
selectedNote.value = null;
|
|
selectedCategory.value = null;
|
|
isEditingNote.value = false;
|
|
|
|
const q = typeof routeQuery === "string" ? routeQuery.trim() : "";
|
|
searchQuery.value = q;
|
|
|
|
if (!spaceId || !q) {
|
|
spaceStore.clearSearchResults();
|
|
return;
|
|
}
|
|
|
|
await spaceStore.searchNotes(q);
|
|
},
|
|
{ immediate: true },
|
|
);
|
|
|
|
watch(
|
|
[() => route.name, () => route.params.spaceId, () => route.params.noteId, () => route.params.taskListId, () => currentSpace.value?.id],
|
|
async ([routeName, routeSpaceId, routeNoteId, routeTaskListId, currentSpaceId]) => {
|
|
if (routeName !== "DashboardNote" && routeName !== "DashboardTaskList") {
|
|
return;
|
|
}
|
|
|
|
const spaceId = typeof routeSpaceId === "string" ? routeSpaceId : "";
|
|
if (!spaceId) {
|
|
return;
|
|
}
|
|
|
|
if (spaceId !== currentSpaceId) {
|
|
await spaceStore.selectSpace(spaceId);
|
|
localStorage.setItem("currentSpaceId", spaceId);
|
|
return;
|
|
}
|
|
|
|
if (routeName === "DashboardTaskList") {
|
|
const taskListId = typeof routeTaskListId === "string" ? routeTaskListId : "";
|
|
if (!taskListId) {
|
|
return;
|
|
}
|
|
|
|
selectedNote.value = null;
|
|
isEditingNote.value = false;
|
|
selectedCategory.value = null;
|
|
selectedTaskList.value = taskLists.value.find((list) => list.id === taskListId) || null;
|
|
await spaceStore.fetchTaskStatuses(currentSpace.value?.id, taskListId);
|
|
await applyTaskFilters({ taskListId });
|
|
return;
|
|
}
|
|
|
|
const noteId = typeof routeNoteId === "string" ? routeNoteId : "";
|
|
selectedTaskList.value = null;
|
|
isEditingNote.value = false;
|
|
showTaskModal.value = false;
|
|
|
|
if (!noteId) {
|
|
selectedNote.value = null;
|
|
return;
|
|
}
|
|
|
|
if (selectedNote.value?.id === noteId) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await apiClient.get(`/api/v1/spaces/${spaceId}/notes/${noteId}`);
|
|
if (response.data?.is_password_protected) {
|
|
unlockTargetNote.value = response.data;
|
|
unlockPassword.value = "";
|
|
unlockError.value = "";
|
|
showUnlockModal.value = true;
|
|
selectedNote.value = null;
|
|
selectedCategory.value = null;
|
|
router.push(dashboardNoteRoute(spaceId));
|
|
return;
|
|
}
|
|
selectedNote.value = response.data;
|
|
selectedCategory.value = null;
|
|
} catch {
|
|
selectedNote.value = null;
|
|
}
|
|
},
|
|
{ immediate: true },
|
|
);
|
|
|
|
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 = [];
|
|
}
|
|
},
|
|
);
|
|
|
|
const toggleCreateMenu = () => {
|
|
showCreateMenu.value = !showCreateMenu.value;
|
|
if (showCreateMenu.value) {
|
|
showSpaceDropdown.value = false;
|
|
showUserMenu.value = false;
|
|
}
|
|
};
|
|
|
|
const toggleSpaceDropdown = () => {
|
|
showSpaceDropdown.value = !showSpaceDropdown.value;
|
|
if (showSpaceDropdown.value) {
|
|
showUserMenu.value = false;
|
|
}
|
|
};
|
|
|
|
const toggleUserMenu = () => {
|
|
showUserMenu.value = !showUserMenu.value;
|
|
if (showUserMenu.value) {
|
|
showSpaceDropdown.value = false;
|
|
}
|
|
};
|
|
|
|
const updateNavbarHeight = () => {
|
|
navbarHeight.value = navbarRef.value?.offsetHeight || 56;
|
|
};
|
|
|
|
const handleDocumentClick = (event) => {
|
|
const target = event.target;
|
|
|
|
if (showSpaceDropdown.value && spaceDropdownRef.value && !spaceDropdownRef.value.contains(target)) {
|
|
showSpaceDropdown.value = false;
|
|
}
|
|
|
|
if (showUserMenu.value && userDropdownRef.value && !userDropdownRef.value.contains(target)) {
|
|
showUserMenu.value = false;
|
|
}
|
|
|
|
if (showCreateMenu.value && createDropdownRef.value && !createDropdownRef.value.contains(target)) {
|
|
showCreateMenu.value = false;
|
|
}
|
|
};
|
|
|
|
const handleEscapeKey = (event) => {
|
|
if (event.key !== "Escape") {
|
|
return;
|
|
}
|
|
showSpaceDropdown.value = false;
|
|
showUserMenu.value = false;
|
|
showCreateMenu.value = false;
|
|
showSidebar.value = false;
|
|
};
|
|
|
|
const copyShareLink = async () => {
|
|
if (!canShareSelectedNote.value) {
|
|
return;
|
|
}
|
|
|
|
const shareUrl = `${window.location.origin}/s/${currentSpace.value.id}/n/${selectedNote.value.id}`;
|
|
|
|
try {
|
|
if (navigator.clipboard?.writeText) {
|
|
await navigator.clipboard.writeText(shareUrl);
|
|
} else {
|
|
const textArea = document.createElement("textarea");
|
|
textArea.value = shareUrl;
|
|
textArea.setAttribute("readonly", "");
|
|
textArea.style.position = "fixed";
|
|
textArea.style.opacity = "0";
|
|
document.body.appendChild(textArea);
|
|
textArea.select();
|
|
document.execCommand("copy");
|
|
document.body.removeChild(textArea);
|
|
}
|
|
|
|
shareCopied.value = true;
|
|
clearTimeout(shareCopyTimeout.value);
|
|
shareCopyTimeout.value = setTimeout(() => {
|
|
shareCopied.value = false;
|
|
}, 1800);
|
|
} catch {
|
|
alert("Unable to copy share link.");
|
|
}
|
|
};
|
|
|
|
const selectNote = async (note) => {
|
|
if (note?.is_password_protected) {
|
|
unlockTargetNote.value = note;
|
|
unlockPassword.value = "";
|
|
unlockError.value = "";
|
|
showUnlockModal.value = true;
|
|
selectedNote.value = null;
|
|
selectedCategory.value = null;
|
|
selectedTaskList.value = null;
|
|
isEditingNote.value = false;
|
|
showSidebar.value = false;
|
|
if (currentSpace.value?.id) {
|
|
router.push(dashboardNoteRoute(currentSpace.value.id));
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!note?.id || !currentSpace.value?.id) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await apiClient.get(`/api/v1/spaces/${currentSpace.value.id}/notes/${note.id}`);
|
|
selectedNote.value = response.data;
|
|
selectedCategory.value = null;
|
|
selectedTaskList.value = null;
|
|
isEditingNote.value = false;
|
|
showSidebar.value = false;
|
|
if (currentSpace.value?.id) {
|
|
router.push(dashboardNoteRoute(currentSpace.value.id, response.data.id));
|
|
}
|
|
} catch {
|
|
alert("Unable to load note content.");
|
|
}
|
|
};
|
|
|
|
const closeUnlockModal = () => {
|
|
showUnlockModal.value = false;
|
|
unlockTargetNote.value = null;
|
|
unlockPassword.value = "";
|
|
unlockError.value = "";
|
|
unlockingNote.value = false;
|
|
};
|
|
|
|
const unlockProtectedNote = async () => {
|
|
if (!unlockTargetNote.value?.id || !currentSpace.value?.id) {
|
|
closeUnlockModal();
|
|
return;
|
|
}
|
|
if (!unlockPassword.value.trim()) {
|
|
unlockError.value = "Password is required.";
|
|
return;
|
|
}
|
|
|
|
unlockingNote.value = true;
|
|
unlockError.value = "";
|
|
try {
|
|
const response = await apiClient.post(`/api/v1/spaces/${currentSpace.value.id}/notes/${unlockTargetNote.value.id}/unlock`, {
|
|
password: unlockPassword.value,
|
|
});
|
|
selectedNote.value = response.data;
|
|
selectedCategory.value = null;
|
|
selectedTaskList.value = null;
|
|
isEditingNote.value = false;
|
|
showSidebar.value = false;
|
|
if (currentSpace.value?.id) {
|
|
router.push(dashboardNoteRoute(currentSpace.value.id, response.data.id));
|
|
}
|
|
closeUnlockModal();
|
|
} catch (error) {
|
|
unlockError.value = error?.response?.data || "Invalid password.";
|
|
} finally {
|
|
unlockingNote.value = false;
|
|
}
|
|
};
|
|
|
|
const selectCategory = (category) => {
|
|
selectedCategory.value = category;
|
|
selectedTaskList.value = null;
|
|
selectedNote.value = null;
|
|
isEditingNote.value = false;
|
|
showSidebar.value = false;
|
|
if (currentSpace.value?.id) {
|
|
router.push(dashboardNoteRoute(currentSpace.value.id));
|
|
}
|
|
};
|
|
|
|
const selectTaskList = async (taskList) => {
|
|
selectedTaskList.value = taskList;
|
|
selectedCategory.value = null;
|
|
selectedNote.value = null;
|
|
isEditingNote.value = false;
|
|
showSidebar.value = false;
|
|
if (currentSpace.value?.id && taskList?.id) {
|
|
await router.push(dashboardTaskRoute(currentSpace.value.id, taskList.id));
|
|
await spaceStore.fetchTaskStatuses(currentSpace.value.id, taskList.id);
|
|
}
|
|
|
|
await applyTaskFilters({
|
|
...taskFilters.value,
|
|
taskListId: taskList?.id || null,
|
|
});
|
|
};
|
|
|
|
const performSearch = async () => {
|
|
const q = searchQuery.value.trim();
|
|
if (!q) {
|
|
spaceStore.clearSearchResults();
|
|
if (currentSpace.value?.id) {
|
|
await router.push(dashboardNoteRoute(currentSpace.value.id));
|
|
}
|
|
if (currentSpace.value?.id) {
|
|
await spaceStore.fetchNotes(currentSpace.value.id, { reset: true });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (route.path !== "/search" || route.query.q !== q || route.query.page !== "1") {
|
|
await router.push({
|
|
path: "/search",
|
|
query: {
|
|
q,
|
|
page: "1",
|
|
},
|
|
});
|
|
} else {
|
|
await spaceStore.searchNotes(q);
|
|
}
|
|
};
|
|
|
|
const setSearchPage = async (page) => {
|
|
const q = typeof route.query.q === "string" ? route.query.q : "";
|
|
if (!q) {
|
|
return;
|
|
}
|
|
await router.push({
|
|
path: "/search",
|
|
query: {
|
|
q,
|
|
page: String(page),
|
|
},
|
|
});
|
|
};
|
|
|
|
const selectSearchResultNote = async (note) => {
|
|
if (!note) {
|
|
return;
|
|
}
|
|
await selectNote(note);
|
|
};
|
|
|
|
const loadMoreMainNotes = async () => {
|
|
if (!currentSpace.value?.id || !canLoadMoreMainNotes.value) {
|
|
return;
|
|
}
|
|
await spaceStore.loadMoreNotes(currentSpace.value.id);
|
|
};
|
|
|
|
const applyTaskFilters = async (filters) => {
|
|
taskFilters.value = {
|
|
...taskFilters.value,
|
|
...filters,
|
|
taskListId: selectedTaskList.value?.id || taskFilters.value.taskListId || null,
|
|
};
|
|
if (!currentSpace.value?.id) {
|
|
return;
|
|
}
|
|
await spaceStore.fetchTasks(currentSpace.value.id, taskFilters.value);
|
|
};
|
|
|
|
const openTaskCreateModal = () => {
|
|
if (!canCreateTasks.value) {
|
|
return;
|
|
}
|
|
if (!selectedTaskList.value?.id) {
|
|
alert("Select a task list first.");
|
|
return;
|
|
}
|
|
taskDetail.value = null;
|
|
taskModalDraft.value = {
|
|
title: "",
|
|
description: "",
|
|
task_list_id: selectedTaskList.value?.id || null,
|
|
status_id: initialTaskStatusId.value,
|
|
parent_task_id: null,
|
|
note_links: selectedNote.value?.id ? [selectedNote.value.id] : [],
|
|
depth: 0,
|
|
};
|
|
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 requestRemoveTask = (task) => {
|
|
if (!currentSpace.value?.id || !task?.id || !canDeleteTasks.value) {
|
|
return;
|
|
}
|
|
showTaskModal.value = false;
|
|
taskDeleteIntent.value = {
|
|
type: "task",
|
|
payload: task,
|
|
};
|
|
showTaskDeleteConfirmModal.value = true;
|
|
};
|
|
|
|
const removeTask = async (task) => {
|
|
if (!currentSpace.value?.id || !task?.id || !canDeleteTasks.value) {
|
|
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.");
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
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: "",
|
|
task_list_id: parentTask.task_list_id || selectedTaskList.value?.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 || !selectedTaskList.value?.id) {
|
|
return;
|
|
}
|
|
try {
|
|
await spaceStore.createTaskStatus(currentSpace.value.id, selectedTaskList.value.id, payload);
|
|
} catch (error) {
|
|
alert(error?.response?.data || "Unable to create status.");
|
|
}
|
|
};
|
|
|
|
const renameTaskStatus = async (status) => {
|
|
if (!currentSpace.value?.id || !selectedTaskList.value?.id || !status?.id) {
|
|
return;
|
|
}
|
|
try {
|
|
await spaceStore.updateTaskStatus(currentSpace.value.id, selectedTaskList.value.id, status.id, {
|
|
name: status.name,
|
|
color: status.color,
|
|
});
|
|
} catch (error) {
|
|
alert(error?.response?.data || "Unable to update status.");
|
|
}
|
|
};
|
|
|
|
const closeTaskDeleteConfirmModal = () => {
|
|
if (taskDeleteConfirmBusy.value) {
|
|
return;
|
|
}
|
|
showTaskDeleteConfirmModal.value = false;
|
|
taskDeleteIntent.value = {
|
|
type: "",
|
|
payload: null,
|
|
};
|
|
};
|
|
|
|
const requestDeleteTaskStatus = (status) => {
|
|
if (!currentSpace.value?.id || !status?.id) {
|
|
return;
|
|
}
|
|
taskDeleteIntent.value = {
|
|
type: "task-status",
|
|
payload: status,
|
|
};
|
|
showTaskDeleteConfirmModal.value = true;
|
|
};
|
|
|
|
const deleteTaskStatus = async (status) => {
|
|
if (!currentSpace.value?.id || !selectedTaskList.value?.id || !status?.id) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await spaceStore.deleteTaskStatus(currentSpace.value.id, selectedTaskList.value.id, status.id);
|
|
} catch (error) {
|
|
alert(error?.response?.data || "Unable to delete status.");
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
const reorderTaskStatuses = async (orderedIds) => {
|
|
if (!currentSpace.value?.id || !selectedTaskList.value?.id) {
|
|
return;
|
|
}
|
|
try {
|
|
await spaceStore.reorderTaskStatuses(currentSpace.value.id, selectedTaskList.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) => {
|
|
selectedTaskList.value = taskLists.value.find((list) => list.id === task.task_list_id) || null;
|
|
selectedNote.value = null;
|
|
if (currentSpace.value?.id && selectedTaskList.value?.id) {
|
|
await router.push(dashboardTaskRoute(currentSpace.value.id, selectedTaskList.value.id));
|
|
}
|
|
await applyTaskFilters({ taskListId: selectedTaskList.value?.id || null });
|
|
await openTaskDetail(task);
|
|
};
|
|
|
|
const openCreateNoteModalFromMenu = () => {
|
|
showCreateMenu.value = false;
|
|
showCreateNoteModal.value = true;
|
|
};
|
|
|
|
const openCreateTaskListModalFromMenu = () => {
|
|
showCreateMenu.value = false;
|
|
showCreateTaskListModal.value = true;
|
|
};
|
|
|
|
const createTaskList = async (taskListData) => {
|
|
if (!currentSpace.value?.id || !canCreateTasks.value) {
|
|
showCreateTaskListModal.value = false;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const created = await spaceStore.createTaskList(currentSpace.value.id, taskListData);
|
|
showCreateTaskListModal.value = false;
|
|
await selectTaskList(created);
|
|
} catch (error) {
|
|
alert(error?.response?.data || "Unable to create task list.");
|
|
}
|
|
};
|
|
|
|
const updateTaskListFromModal = async (payload) => {
|
|
if (!currentSpace.value?.id || !selectedTaskList.value?.id) return;
|
|
try {
|
|
await spaceStore.updateTaskList(currentSpace.value.id, selectedTaskList.value.id, {
|
|
name: payload.name,
|
|
description: payload.description,
|
|
category_id: payload.category_id,
|
|
});
|
|
selectedTaskList.value = { ...selectedTaskList.value, name: payload.name, category_id: payload.category_id };
|
|
} catch (error) {
|
|
alert(error?.response?.data || "Unable to update task list.");
|
|
}
|
|
};
|
|
|
|
const requestRemoveTaskList = (taskList) => {
|
|
if (!currentSpace.value?.id || !taskList?.id || !canDeleteTasks.value) {
|
|
return;
|
|
}
|
|
showEditTaskListModal.value = false;
|
|
taskDeleteIntent.value = {
|
|
type: "task-list",
|
|
payload: taskList,
|
|
};
|
|
showTaskDeleteConfirmModal.value = true;
|
|
};
|
|
|
|
const removeTaskList = async (taskList) => {
|
|
if (!currentSpace.value?.id || !taskList?.id || !canDeleteTasks.value) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await spaceStore.deleteTaskList(currentSpace.value.id, taskList.id);
|
|
|
|
if (selectedTaskList.value?.id === taskList.id) {
|
|
selectedTaskList.value = null;
|
|
taskDetail.value = null;
|
|
taskModalDraft.value = null;
|
|
showTaskModal.value = false;
|
|
taskFilters.value = {
|
|
taskListId: null,
|
|
statusId: null,
|
|
parentTaskId: null,
|
|
};
|
|
await spaceStore.fetchTasks(currentSpace.value.id, taskFilters.value);
|
|
await router.push(dashboardNoteRoute(currentSpace.value.id));
|
|
}
|
|
} catch (error) {
|
|
alert(error?.response?.data || "Unable to delete task list.");
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
const confirmTaskDeleteAction = async () => {
|
|
if (taskDeleteConfirmBusy.value) {
|
|
return;
|
|
}
|
|
|
|
const { type, payload } = taskDeleteIntent.value;
|
|
if (!type || !payload) {
|
|
return;
|
|
}
|
|
|
|
taskDeleteConfirmBusy.value = true;
|
|
try {
|
|
if (type === "task-status") {
|
|
await deleteTaskStatus(payload);
|
|
} else if (type === "task-list") {
|
|
await removeTaskList(payload);
|
|
} else if (type === "task") {
|
|
await removeTask(payload);
|
|
} else if (type === "category") {
|
|
await removeCategory(payload);
|
|
}
|
|
|
|
showTaskDeleteConfirmModal.value = false;
|
|
taskDeleteIntent.value = {
|
|
type: "",
|
|
payload: null,
|
|
};
|
|
} finally {
|
|
taskDeleteConfirmBusy.value = false;
|
|
}
|
|
};
|
|
|
|
const createSpace = async (spaceData) => {
|
|
showCreateSpaceModal.value = false;
|
|
await spaceStore.createSpace(spaceData);
|
|
};
|
|
|
|
const openCreateCategoryModal = () => {
|
|
if (!canCreateCategories.value) {
|
|
return;
|
|
}
|
|
editingCategory.value = null;
|
|
categoryModalParentId.value = null;
|
|
showCreateCategoryModal.value = true;
|
|
};
|
|
|
|
const submitCategory = async (categoryData) => {
|
|
const canSubmit = editingCategory.value ? canEditCategories.value : canCreateCategories.value;
|
|
if (!canSubmit) {
|
|
closeCategoryModal();
|
|
return;
|
|
}
|
|
if (!currentSpace.value) {
|
|
return;
|
|
}
|
|
|
|
if (editingCategory.value) {
|
|
await spaceStore.updateCategory(currentSpace.value.id, editingCategory.value.id, categoryData);
|
|
} else {
|
|
await spaceStore.createCategory(currentSpace.value.id, categoryData);
|
|
}
|
|
|
|
closeCategoryModal();
|
|
};
|
|
|
|
const createNote = async (noteData) => {
|
|
if (!canCreateNotes.value) {
|
|
showCreateNoteModal.value = false;
|
|
return;
|
|
}
|
|
showCreateNoteModal.value = false;
|
|
const createdNote = await spaceStore.createNote(currentSpace.value.id, noteData);
|
|
selectedNote.value = createdNote;
|
|
isEditingNote.value = false;
|
|
};
|
|
|
|
const updateNote = async (noteData) => {
|
|
if (!canEditNotes.value) {
|
|
isEditingNote.value = false;
|
|
return;
|
|
}
|
|
const updatedNote = await spaceStore.updateNote(currentSpace.value.id, noteData);
|
|
selectedNote.value = updatedNote;
|
|
};
|
|
|
|
const deleteNote = async (noteId) => {
|
|
if (!canDeleteNotes.value) {
|
|
return;
|
|
}
|
|
await spaceStore.deleteNote(currentSpace.value.id, noteId);
|
|
selectedNote.value = null;
|
|
isEditingNote.value = false;
|
|
await spaceStore.fetchNotes(currentSpace.value.id, { reset: true });
|
|
};
|
|
|
|
const startEditingNote = () => {
|
|
if (!canEditNotes.value) {
|
|
return;
|
|
}
|
|
isEditingNote.value = true;
|
|
};
|
|
|
|
const cancelEditingNote = () => {
|
|
isEditingNote.value = false;
|
|
};
|
|
|
|
const openCreateSubcategoryModal = (category) => {
|
|
if (!canCreateCategories.value) {
|
|
return;
|
|
}
|
|
editingCategory.value = null;
|
|
categoryModalParentId.value = category.id;
|
|
showCreateCategoryModal.value = true;
|
|
};
|
|
|
|
const openEditCategoryModal = (category) => {
|
|
if (!canEditCategories.value) {
|
|
return;
|
|
}
|
|
editingCategory.value = category;
|
|
categoryModalParentId.value = category.parent_id || null;
|
|
showCreateCategoryModal.value = true;
|
|
};
|
|
|
|
const requestRemoveCategory = (category) => {
|
|
if (!canDeleteCategories.value) {
|
|
return;
|
|
}
|
|
if (!currentSpace.value) {
|
|
return;
|
|
}
|
|
if (!category?.id) {
|
|
return;
|
|
}
|
|
|
|
taskDeleteIntent.value = {
|
|
type: "category",
|
|
payload: category,
|
|
};
|
|
showTaskDeleteConfirmModal.value = true;
|
|
};
|
|
|
|
const removeCategory = async (category) => {
|
|
if (!canDeleteCategories.value || !currentSpace.value || !category?.id) {
|
|
return;
|
|
}
|
|
|
|
await spaceStore.deleteCategory(currentSpace.value.id, category.id);
|
|
|
|
if (selectedCategory.value?.id === category.id) {
|
|
selectedCategory.value = null;
|
|
}
|
|
};
|
|
|
|
const closeCategoryModal = () => {
|
|
showCreateCategoryModal.value = false;
|
|
editingCategory.value = null;
|
|
categoryModalParentId.value = null;
|
|
};
|
|
|
|
const applyUpdatedSpace = (updatedSpace) => {
|
|
if (!updatedSpace?.id) {
|
|
return;
|
|
}
|
|
const index = spaceStore.spaces.findIndex((space) => space.id === updatedSpace.id);
|
|
if (index !== -1) {
|
|
spaceStore.spaces[index] = { ...spaceStore.spaces[index], ...updatedSpace };
|
|
}
|
|
if (spaceStore.currentSpace?.id === updatedSpace.id) {
|
|
spaceStore.currentSpace = { ...spaceStore.currentSpace, ...updatedSpace };
|
|
}
|
|
};
|
|
|
|
const handleSpaceDeleted = (deletedSpace) => {
|
|
showSpaceSettingsModal.value = false;
|
|
spaceStore.spaces = spaceStore.spaces.filter((space) => space.id !== deletedSpace.id);
|
|
if (spaceStore.currentSpace?.id === deletedSpace.id) {
|
|
spaceStore.currentSpace = null;
|
|
selectedNote.value = null;
|
|
selectedCategory.value = null;
|
|
}
|
|
};
|
|
|
|
const selectSpace = async (space) => {
|
|
showSpaceDropdown.value = false;
|
|
await spaceStore.selectSpace(space.id);
|
|
localStorage.setItem("currentSpaceId", space.id);
|
|
selectedNote.value = null;
|
|
selectedCategory.value = null;
|
|
selectedTaskList.value = null;
|
|
isEditingNote.value = false;
|
|
linkedTasksForSelectedNote.value = [];
|
|
await applyTaskFilters(taskFilters.value);
|
|
await router.push(dashboardNoteRoute(space.id));
|
|
};
|
|
|
|
const openAdminPanel = () => {
|
|
showUserMenu.value = false;
|
|
router.push("/admin");
|
|
};
|
|
|
|
const logout = () => {
|
|
authStore.logout();
|
|
localStorage.removeItem("currentSpaceId");
|
|
router.push("/login");
|
|
};
|
|
</script>
|
|
|
|
<style scoped>
|
|
.dashboard-container {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100vh;
|
|
overflow-x: hidden;
|
|
overflow-y: visible;
|
|
}
|
|
|
|
.navbar {
|
|
z-index: 1100;
|
|
overflow: visible;
|
|
}
|
|
|
|
.app-navbar {
|
|
align-items: center;
|
|
}
|
|
|
|
.navbar-left {
|
|
min-width: 0;
|
|
}
|
|
|
|
.navbar-controls {
|
|
min-width: 0;
|
|
}
|
|
|
|
.app-brand {
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.app-main {
|
|
flex: 1;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.sidebar {
|
|
width: 280px;
|
|
overflow-y: auto;
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.sidebar-content {
|
|
flex: 1;
|
|
min-height: 0;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.sidebar-header {
|
|
border-bottom: 1px solid var(--color-border);
|
|
}
|
|
|
|
.main-content {
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.content {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.breadcrumb-title {
|
|
font-size: 1rem;
|
|
color: #495057;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.breadcrumb-link {
|
|
border: 0;
|
|
background: transparent;
|
|
padding: 0;
|
|
color: var(--color-primary);
|
|
text-decoration: none;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.breadcrumb-link:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.breadcrumb-separator {
|
|
color: var(--color-text-muted);
|
|
}
|
|
|
|
.search-box {
|
|
width: 300px;
|
|
}
|
|
|
|
.dropdown {
|
|
position: relative;
|
|
}
|
|
|
|
.dropdown-menu {
|
|
z-index: 1200;
|
|
max-width: min(92vw, 320px);
|
|
}
|
|
|
|
.dropdown-menu-end {
|
|
right: 0;
|
|
left: auto;
|
|
}
|
|
|
|
.dropdown-menu.show {
|
|
display: block;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.app-navbar {
|
|
display: grid;
|
|
grid-template-columns: 1fr auto;
|
|
grid-template-areas:
|
|
"left user"
|
|
"space space"
|
|
"search search";
|
|
row-gap: 0.5rem;
|
|
column-gap: 0.5rem;
|
|
align-items: center;
|
|
}
|
|
|
|
.navbar-left {
|
|
grid-area: left;
|
|
}
|
|
|
|
.navbar-controls {
|
|
width: 100%;
|
|
}
|
|
|
|
.nav-user-menu {
|
|
grid-area: user;
|
|
justify-self: end;
|
|
}
|
|
|
|
.nav-space-selector {
|
|
grid-area: space;
|
|
}
|
|
|
|
.nav-space-selector > .btn {
|
|
width: 100%;
|
|
text-align: left;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.nav-search {
|
|
grid-area: search;
|
|
width: 100%;
|
|
}
|
|
|
|
.nav-search .form-control {
|
|
width: 100%;
|
|
}
|
|
|
|
.search-box {
|
|
width: 100%;
|
|
}
|
|
|
|
.app-brand {
|
|
font-size: 1.5rem;
|
|
}
|
|
|
|
.nav-menu-toggle {
|
|
padding: 0.35rem 0.55rem;
|
|
}
|
|
|
|
.sidebar-backdrop {
|
|
position: fixed;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background-color: rgba(0, 0, 0, 0.5);
|
|
z-index: 1090;
|
|
}
|
|
|
|
.sidebar {
|
|
position: fixed;
|
|
left: 0;
|
|
bottom: 0;
|
|
width: 280px;
|
|
z-index: 1095;
|
|
transform: translateX(-100%);
|
|
transition: transform 0.3s ease-in-out;
|
|
box-shadow: 2px 0 5px rgba(0, 0, 0, 0.1);
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.sidebar.open {
|
|
transform: translateX(0);
|
|
}
|
|
|
|
.toolbar {
|
|
position: relative;
|
|
z-index: 0;
|
|
}
|
|
|
|
.action-button {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.action-label {
|
|
display: none;
|
|
}
|
|
|
|
.action-button .mdi {
|
|
margin-right: 0 !important;
|
|
}
|
|
|
|
.col-auto .action-button {
|
|
min-width: 2.75rem;
|
|
}
|
|
|
|
.app-main {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.main-content {
|
|
width: 100%;
|
|
}
|
|
}
|
|
|
|
:root[data-bs-theme="dark"] .sidebar-header {
|
|
border-bottom-color: var(--color-border);
|
|
}
|
|
|
|
:root[data-bs-theme="dark"] .breadcrumb-title {
|
|
color: #94a3b8;
|
|
}
|
|
|
|
:root[data-bs-theme="dark"] .breadcrumb-link {
|
|
color: #7aa2f7;
|
|
}
|
|
|
|
:root[data-bs-theme="dark"] .breadcrumb-separator {
|
|
color: #4a5568;
|
|
}
|
|
</style>
|