Files
notely/frontend/src/App.vue
domrichardson b09137eca5
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 1m48s
feat: Added the ability to delete task lists
2026-03-30 10:14:07 +01:00

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>