All checks were successful
Build and Push App Image / build-and-push (push) Successful in 1m48s
1483 lines
55 KiB
Vue
1483 lines
55 KiB
Vue
<template>
|
|
<div id="app" class="app-container">
|
|
<nav v-if="!isPublicRoute && !isAuthRoute" 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 && !isAdminRoute"
|
|
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 && !isAdminRoute" @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" v-if="!isAdminRoute">
|
|
<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 v-else class="nav-user-menu">
|
|
<router-link to="/login" class="btn btn-primary">Login</router-link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
<div class="app-main d-flex" v-if="currentUser && currentSpace && !isAdminRoute && !isPublicRoute">
|
|
<!-- 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="removeCategory"
|
|
: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>
|
|
|
|
<!-- Note Editor or Note List -->
|
|
<div class="content p-4">
|
|
<TaskBoard
|
|
v-if="activeView === 'tasks'"
|
|
:tasks="tasks"
|
|
:statuses="taskStatuses"
|
|
:selected-task-list="selectedTaskList"
|
|
:can-delete-task-list="canDeleteTasks"
|
|
@select-task="openTaskDetail"
|
|
@filter-change="applyTaskFilters"
|
|
@reorder-status="reorderTaskStatuses"
|
|
@create-status="createTaskStatus"
|
|
@rename-status="renameTaskStatus"
|
|
@delete-status="deleteTaskStatus"
|
|
@update-task-status="updateTaskStatusFromBoard"
|
|
@delete-task-list="removeTaskList"
|
|
/>
|
|
<SearchResultsPage
|
|
v-else-if="isSearchRoute"
|
|
:items="searchItems"
|
|
:query="searchQuery"
|
|
:current-page="searchPage"
|
|
:page-size="searchPageSize"
|
|
:view-mode="noteViewMode"
|
|
@select-note="selectSearchResultNote"
|
|
@select-task-list="selectTaskList"
|
|
@page-change="setSearchPage"
|
|
/>
|
|
<NoteEditor
|
|
v-else-if="selectedNote && isEditingNote"
|
|
:note="selectedNote"
|
|
:category-options="categoryOptions"
|
|
:can-delete="canDeleteNotes"
|
|
:space-id="currentSpace?.id"
|
|
@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"
|
|
/>
|
|
<WorkspaceList
|
|
v-else
|
|
:items="displayedItems"
|
|
:can-load-more="canLoadMoreMainNotes"
|
|
:is-loading-more="spaceStore.notesLoading"
|
|
:view-mode="noteViewMode"
|
|
@select-note="selectNote"
|
|
@select-task-list="selectTaskList"
|
|
@load-more="loadMoreMainNotes"
|
|
/>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
|
|
<div v-else-if="currentUser && !isAdminRoute && !isPublicRoute && 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>
|
|
|
|
<div v-else-if="currentUser && isAdminRoute" class="admin-route-view">
|
|
<router-view />
|
|
</div>
|
|
|
|
<div v-else-if="isPublicRoute">
|
|
<router-view />
|
|
</div>
|
|
|
|
<div v-else>
|
|
<router-view />
|
|
</div>
|
|
|
|
<!-- Modals -->
|
|
<CreateSpaceModal v-if="showCreateSpaceModal" @close="showCreateSpaceModal = false" @create="createSpace" />
|
|
<CreateCategoryModal
|
|
v-if="showCreateCategoryModal"
|
|
:category="editingCategory"
|
|
:parent-options="categoryParentOptions"
|
|
:parent-id="categoryModalParentId"
|
|
@close="closeCategoryModal"
|
|
@submit="submitCategory"
|
|
/>
|
|
<CreateNoteModal v-if="showCreateNoteModal" :category-options="categoryOptions" :default-category-id="selectedCategory?.id || null" @close="showCreateNoteModal = false" @create="createNote" />
|
|
<CreateTaskListModal
|
|
v-if="showCreateTaskListModal"
|
|
:category-options="categoryOptions"
|
|
:default-category-id="selectedCategory?.id || null"
|
|
@close="showCreateTaskListModal = false"
|
|
@create="createTaskList"
|
|
/>
|
|
<SpaceSettingsModal
|
|
v-if="showSpaceSettingsModal && currentSpace && canManageSpaceSettings"
|
|
:space="currentSpace"
|
|
@close="showSpaceSettingsModal = false"
|
|
@saved="applyUpdatedSpace"
|
|
@deleted="handleSpaceDeleted"
|
|
/>
|
|
<TaskDetailModal
|
|
v-if="showTaskModal"
|
|
:task="taskModalDraft || {}"
|
|
:statuses="taskStatuses"
|
|
: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">
|
|
<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>
|
|
</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 NoteEditor from "./components/NoteEditor.vue";
|
|
import NoteViewer from "./components/NoteViewer.vue";
|
|
import WorkspaceList from "./components/WorkspaceList.vue";
|
|
import SearchResultsPage from "./components/SearchResultsPage.vue";
|
|
import CreateSpaceModal from "./components/CreateSpaceModal.vue";
|
|
import CreateCategoryModal from "./components/CreateCategoryModal.vue";
|
|
import CreateNoteModal from "./components/CreateNoteModal.vue";
|
|
import CreateTaskListModal from "./components/CreateTaskListModal.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";
|
|
|
|
const router = useRouter();
|
|
const route = useRoute();
|
|
const authStore = useAuthStore();
|
|
const spaceStore = useSpaceStore();
|
|
|
|
const showSpaceDropdown = ref(false);
|
|
const showUserMenu = ref(false);
|
|
const showCreateSpaceModal = ref(false);
|
|
const showCreateCategoryModal = ref(false);
|
|
const showCreateNoteModal = ref(false);
|
|
const showCreateTaskListModal = ref(false);
|
|
const showSpaceSettingsModal = 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 activeView = ref("notes");
|
|
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 currentUser = computed(() => authStore.user);
|
|
const isAdminRoute = computed(() => route.path === "/admin");
|
|
const isSearchRoute = computed(() => route.path === "/search");
|
|
const isPublicRoute = computed(() => route.path.startsWith("/s/"));
|
|
const isAuthRoute = computed(() => route.path === "/login" || route.path === "/register");
|
|
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 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) {
|
|
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;
|
|
activeView.value = "notes";
|
|
taskFilters.value = {
|
|
taskListId: null,
|
|
statusId: null,
|
|
parentTaskId: null,
|
|
};
|
|
spaceStore.clearSearchResults();
|
|
if (route.path !== "/") {
|
|
router.push("/");
|
|
}
|
|
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 (activeView.value === "tasks" && 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 storedSpaceId = localStorage.getItem("currentSpaceId");
|
|
const targetSpace = spaceStore.spaces.find((space) => space.id === storedSpaceId) || spaceStore.spaces[0];
|
|
if (targetSpace) {
|
|
await spaceStore.selectSpace(targetSpace.id);
|
|
}
|
|
};
|
|
|
|
const updateNavbarHeight = () => {
|
|
navbarHeight.value = navbarRef.value?.offsetHeight || 56;
|
|
};
|
|
|
|
onMounted(async () => {
|
|
document.addEventListener("click", handleDocumentClick);
|
|
document.addEventListener("keydown", handleEscapeKey);
|
|
window.addEventListener("resize", updateNavbarHeight);
|
|
await nextTick();
|
|
updateNavbarHeight();
|
|
|
|
if (authStore.isAuthenticated && !isPublicRoute.value) {
|
|
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(
|
|
() => 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 toggleSpaceDropdown = () => {
|
|
showSpaceDropdown.value = !showSpaceDropdown.value;
|
|
if (showSpaceDropdown.value) {
|
|
showUserMenu.value = false;
|
|
}
|
|
};
|
|
|
|
const toggleUserMenu = () => {
|
|
showUserMenu.value = !showUserMenu.value;
|
|
if (showUserMenu.value) {
|
|
showSpaceDropdown.value = false;
|
|
}
|
|
};
|
|
|
|
const toggleCreateMenu = () => {
|
|
showCreateMenu.value = !showCreateMenu.value;
|
|
if (showCreateMenu.value) {
|
|
showSpaceDropdown.value = false;
|
|
showUserMenu.value = false;
|
|
}
|
|
};
|
|
|
|
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 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;
|
|
activeView.value = "notes";
|
|
linkedTasksForSelectedNote.value = [];
|
|
await applyTaskFilters(taskFilters.value);
|
|
};
|
|
|
|
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;
|
|
activeView.value = "notes";
|
|
showSidebar.value = false;
|
|
if (route.path === "/search") {
|
|
router.push("/");
|
|
}
|
|
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;
|
|
activeView.value = "notes";
|
|
showSidebar.value = false;
|
|
if (route.path === "/search") {
|
|
router.push("/");
|
|
}
|
|
} 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;
|
|
activeView.value = "notes";
|
|
showSidebar.value = false;
|
|
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;
|
|
activeView.value = "notes";
|
|
showSidebar.value = false;
|
|
};
|
|
|
|
const selectTaskList = async (taskList) => {
|
|
selectedTaskList.value = taskList;
|
|
selectedCategory.value = null;
|
|
selectedNote.value = null;
|
|
isEditingNote.value = false;
|
|
activeView.value = "tasks";
|
|
showSidebar.value = false;
|
|
|
|
if (route.path === "/search") {
|
|
router.push("/");
|
|
}
|
|
|
|
await applyTaskFilters({
|
|
...taskFilters.value,
|
|
taskListId: taskList?.id || null,
|
|
});
|
|
};
|
|
|
|
const performSearch = async () => {
|
|
const q = searchQuery.value.trim();
|
|
if (!q) {
|
|
spaceStore.clearSearchResults();
|
|
if (route.path !== "/") {
|
|
await router.push("/");
|
|
}
|
|
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);
|
|
if (route.path === "/search") {
|
|
router.push("/");
|
|
}
|
|
};
|
|
|
|
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,
|
|
};
|
|
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: "",
|
|
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) {
|
|
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) => {
|
|
selectedTaskList.value = taskLists.value.find((list) => list.id === task.task_list_id) || null;
|
|
selectedNote.value = null;
|
|
await applyTaskFilters({ taskListId: selectedTaskList.value?.id || null });
|
|
activeView.value = "tasks";
|
|
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 removeTaskList = async (taskList) => {
|
|
if (!currentSpace.value?.id || !taskList?.id || !canDeleteTasks.value) {
|
|
return;
|
|
}
|
|
|
|
if (!confirm(`Delete task list "${taskList.name}" and all associated tasks?`)) {
|
|
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);
|
|
activeView.value = "notes";
|
|
}
|
|
} catch (error) {
|
|
alert(error?.response?.data || "Unable to delete task list.");
|
|
}
|
|
};
|
|
|
|
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 removeCategory = async (category) => {
|
|
if (!canDeleteCategories.value) {
|
|
return;
|
|
}
|
|
if (!currentSpace.value) {
|
|
return;
|
|
}
|
|
|
|
if (!confirm(`Delete category "${category.name}"? Notes will be moved to uncategorized.`)) {
|
|
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 openAdminPanel = () => {
|
|
showUserMenu.value = false;
|
|
router.push("/admin");
|
|
};
|
|
|
|
const logout = () => {
|
|
authStore.logout();
|
|
localStorage.removeItem("currentSpaceId");
|
|
router.push("/login");
|
|
};
|
|
</script>
|
|
|
|
<style scoped src="./assets/styles/scoped/App.css"></style>
|