feat: Created task lists that work in categories
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 1m20s
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 1m20s
This commit is contained in:
@@ -44,7 +44,7 @@
|
||||
|
||||
<!-- Search -->
|
||||
<div class="search-box nav-search" v-if="!isAdminRoute">
|
||||
<input type="text" class="form-control" placeholder="Search notes..." v-model="searchQuery" @keyup.enter="performSearch" />
|
||||
<input type="text" class="form-control" placeholder="Search notes & task lists..." v-model="searchQuery" @keyup.enter="performSearch" />
|
||||
</div>
|
||||
|
||||
<!-- Theme Toggle -->
|
||||
@@ -98,6 +98,7 @@
|
||||
: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"
|
||||
@@ -128,9 +129,36 @@
|
||||
</h5>
|
||||
</div>
|
||||
<div class="col-auto d-flex align-items-center">
|
||||
<div class="btn-group me-2" role="group" aria-label="Workspace mode">
|
||||
<button type="button" class="btn action-button" :class="activeView === 'notes' ? 'btn-secondary' : 'btn-outline-secondary'" @click="activeView = 'notes'">Notes</button>
|
||||
<button type="button" class="btn action-button" :class="activeView === 'tasks' ? 'btn-secondary' : 'btn-outline-secondary'" @click="activeView = 'tasks'">Tasks</button>
|
||||
<div
|
||||
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
|
||||
@@ -174,14 +202,6 @@
|
||||
<i class="mdi mdi-share-variant-outline me-1" aria-hidden="true"></i>
|
||||
<span class="action-label">{{ shareCopied ? "Copied" : "Share" }}</span>
|
||||
</button>
|
||||
<button v-if="activeView === 'notes' && canCreateNotes" class="btn btn-primary action-button" aria-label="New note" title="New note" @click="showCreateNoteModal = true">
|
||||
<i class="mdi mdi-note-plus-outline me-1" aria-hidden="true"></i>
|
||||
<span class="action-label">New Note</span>
|
||||
</button>
|
||||
<button v-if="activeView === 'tasks' && canCreateTasks" class="btn btn-primary action-button" aria-label="New task" title="New task" @click="openTaskCreateModal">
|
||||
<i class="mdi mdi-checkbox-marked-circle-plus-outline me-1" aria-hidden="true"></i>
|
||||
<span class="action-label">New Task</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -192,8 +212,6 @@
|
||||
v-if="activeView === 'tasks'"
|
||||
:tasks="tasks"
|
||||
:statuses="taskStatuses"
|
||||
:category-options="categoryOptions"
|
||||
@create-task="openTaskCreateModal"
|
||||
@select-task="openTaskDetail"
|
||||
@filter-change="applyTaskFilters"
|
||||
@reorder-status="reorderTaskStatuses"
|
||||
@@ -204,12 +222,13 @@
|
||||
/>
|
||||
<SearchResultsPage
|
||||
v-else-if="isSearchRoute"
|
||||
:notes="searchResults"
|
||||
:items="searchItems"
|
||||
:query="searchQuery"
|
||||
:current-page="searchPage"
|
||||
:page-size="searchPageSize"
|
||||
:view-mode="noteViewMode"
|
||||
@select-note="selectSearchResultNote"
|
||||
@select-task-list="selectTaskList"
|
||||
@page-change="setSearchPage"
|
||||
/>
|
||||
<NoteEditor
|
||||
@@ -231,13 +250,14 @@
|
||||
:linked-tasks="linkedTasksForSelectedNote"
|
||||
@open-linked-task="openLinkedTaskFromNote"
|
||||
/>
|
||||
<NoteList
|
||||
<WorkspaceList
|
||||
v-else
|
||||
:notes="displayedNotes"
|
||||
: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>
|
||||
@@ -279,6 +299,13 @@
|
||||
@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"
|
||||
@@ -290,7 +317,6 @@
|
||||
v-if="showTaskModal"
|
||||
:task="taskModalDraft || {}"
|
||||
:statuses="taskStatuses"
|
||||
:category-options="categoryOptions"
|
||||
:parent-task-options="taskParentOptions"
|
||||
:subtasks="taskDetail?.subtasks || []"
|
||||
@close="showTaskModal = false"
|
||||
@@ -342,11 +368,12 @@ import { useSpaceStore } from "./stores/spaceStore";
|
||||
import CategoryTree from "./components/CategoryTree.vue";
|
||||
import NoteEditor from "./components/NoteEditor.vue";
|
||||
import NoteViewer from "./components/NoteViewer.vue";
|
||||
import NoteList from "./components/NoteList.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";
|
||||
@@ -363,12 +390,14 @@ 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);
|
||||
@@ -393,14 +422,16 @@ const unlockError = ref("");
|
||||
const unlockingNote = ref(false);
|
||||
const activeView = ref("notes");
|
||||
const taskFilters = ref({
|
||||
categoryId: null,
|
||||
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");
|
||||
@@ -410,6 +441,19 @@ const isAuthRoute = computed(() => route.path === "/login" || route.path === "/r
|
||||
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);
|
||||
@@ -469,13 +513,46 @@ const collectNotesFromCategory = (category, 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) {
|
||||
@@ -518,6 +595,13 @@ const openSpaceHome = () => {
|
||||
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("/");
|
||||
@@ -580,6 +664,27 @@ const breadcrumbItems = computed(() => {
|
||||
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++) {
|
||||
@@ -710,6 +815,14 @@ const toggleUserMenu = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const toggleCreateMenu = () => {
|
||||
showCreateMenu.value = !showCreateMenu.value;
|
||||
if (showCreateMenu.value) {
|
||||
showSpaceDropdown.value = false;
|
||||
showUserMenu.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDocumentClick = (event) => {
|
||||
const target = event.target;
|
||||
|
||||
@@ -720,6 +833,10 @@ const handleDocumentClick = (event) => {
|
||||
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) => {
|
||||
@@ -728,6 +845,7 @@ const handleEscapeKey = (event) => {
|
||||
}
|
||||
showSpaceDropdown.value = false;
|
||||
showUserMenu.value = false;
|
||||
showCreateMenu.value = false;
|
||||
showSidebar.value = false;
|
||||
};
|
||||
|
||||
@@ -737,7 +855,9 @@ const selectSpace = async (space) => {
|
||||
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);
|
||||
};
|
||||
@@ -782,8 +902,13 @@ const selectNote = async (note) => {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -795,8 +920,13 @@ const selectNote = async (note) => {
|
||||
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.");
|
||||
}
|
||||
@@ -828,7 +958,9 @@ const unlockProtectedNote = async () => {
|
||||
});
|
||||
selectedNote.value = response.data;
|
||||
selectedCategory.value = null;
|
||||
selectedTaskList.value = null;
|
||||
isEditingNote.value = false;
|
||||
activeView.value = "notes";
|
||||
showSidebar.value = false;
|
||||
closeUnlockModal();
|
||||
} catch (error) {
|
||||
@@ -840,11 +972,31 @@ const unlockProtectedNote = async () => {
|
||||
|
||||
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) {
|
||||
@@ -903,22 +1055,30 @@ const loadMoreMainNotes = async () => {
|
||||
};
|
||||
|
||||
const applyTaskFilters = async (filters) => {
|
||||
taskFilters.value = 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, filters);
|
||||
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: "",
|
||||
category_id: selectedCategory.value?.id || null,
|
||||
task_list_id: selectedTaskList.value?.id || null,
|
||||
status_id: initialTaskStatusId.value,
|
||||
parent_task_id: null,
|
||||
note_links: selectedNote.value?.id ? [selectedNote.value.id] : [],
|
||||
@@ -1010,7 +1170,7 @@ const createSubtask = (parentTask) => {
|
||||
taskModalDraft.value = {
|
||||
title: "",
|
||||
description: "",
|
||||
category_id: parentTask.category_id || null,
|
||||
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] : [],
|
||||
@@ -1102,10 +1262,38 @@ const updateTaskStatusFromBoard = async ({ taskId, currentStatusId, targetStatus
|
||||
};
|
||||
|
||||
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 createSpace = async (spaceData) => {
|
||||
showCreateSpaceModal.value = false;
|
||||
await spaceStore.createSpace(spaceData);
|
||||
@@ -1259,6 +1447,3 @@ const logout = () => {
|
||||
</script>
|
||||
|
||||
<style scoped src="./assets/styles/scoped/App.css"></style>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -90,6 +90,27 @@
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.task-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 0.25rem;
|
||||
color: #2f3d52;
|
||||
background: #f7f9ff;
|
||||
border: 1px solid #d9e3ff;
|
||||
}
|
||||
|
||||
.task-list-item:hover {
|
||||
background: #e9efff;
|
||||
}
|
||||
|
||||
.task-list-item span {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.note-item:hover {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
@@ -120,12 +141,12 @@
|
||||
}
|
||||
|
||||
.note-item.is-featured {
|
||||
background: var(--color-surface)9db;
|
||||
background: #fff9db;
|
||||
border: 1px solid #ffd8a8;
|
||||
}
|
||||
|
||||
.note-item.is-featured:hover {
|
||||
background: var(--color-surface)6c5;
|
||||
background: #fff3c5;
|
||||
}
|
||||
|
||||
.subcategories {
|
||||
@@ -163,6 +184,16 @@
|
||||
background-color: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .task-list-item {
|
||||
background: #1f2a44;
|
||||
border-color: #334b7d;
|
||||
color: #bfceef;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .task-list-item:hover {
|
||||
background: #26365b;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .note-item.is-pinned {
|
||||
background: #1a3a5c;
|
||||
border-color: #2d6a9f;
|
||||
@@ -180,5 +211,3 @@
|
||||
:root[data-bs-theme="dark"] .note-item.is-featured:hover {
|
||||
background: #453710;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
.task-filters {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -292,7 +292,7 @@
|
||||
.danger-zone {
|
||||
border: 1px solid #f3b5b5;
|
||||
border-radius: 0.75rem;
|
||||
background: var(--color-surface)5f5;
|
||||
background: var(--color-surface) 5f5;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -399,5 +399,3 @@
|
||||
:root[data-bs-theme="dark"] .empty-state {
|
||||
color: #7a8fa8;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
.note-list {
|
||||
.workspace-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.empty-notes-state {
|
||||
.empty-workspace-state {
|
||||
grid-column: 1 / -1;
|
||||
min-height: 48vh;
|
||||
display: flex;
|
||||
@@ -18,41 +18,42 @@
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.empty-notes-icon {
|
||||
.empty-workspace-icon {
|
||||
font-size: 5.25rem;
|
||||
line-height: 1;
|
||||
color: #60789a;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.empty-notes-title {
|
||||
.empty-workspace-title {
|
||||
margin: 0;
|
||||
color: #23364f;
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.empty-notes-message {
|
||||
.empty-workspace-message {
|
||||
margin: 0.75rem 0 0;
|
||||
max-width: 460px;
|
||||
color: #4f637d;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.note-card {
|
||||
.content-card {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.note-card:hover {
|
||||
.content-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.note-title {
|
||||
.content-title {
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--color-text);
|
||||
display: flex;
|
||||
@@ -60,6 +61,14 @@
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.content-preview {
|
||||
color: #666;
|
||||
margin-bottom: 0.5rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pin-icon {
|
||||
color: #408aca;
|
||||
font-size: 0.9em;
|
||||
@@ -72,22 +81,25 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.note-card.is-pinned {
|
||||
.list-icon {
|
||||
color: #5568a8;
|
||||
font-size: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.content-card.is-pinned {
|
||||
background: #dbf5ff;
|
||||
border-color: #a8d1ff;
|
||||
}
|
||||
|
||||
.note-card.is-featured {
|
||||
.content-card.is-featured {
|
||||
border-color: #ffd8a8;
|
||||
background: var(--color-surface)9db;
|
||||
background: #fff9db;
|
||||
}
|
||||
|
||||
.note-preview {
|
||||
color: #666;
|
||||
margin-bottom: 0.5rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
.content-card.is-task-list {
|
||||
border-color: #d9e3ff;
|
||||
background: #f7f9ff;
|
||||
}
|
||||
|
||||
.list-footer {
|
||||
@@ -97,14 +109,13 @@
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* List view overrides */
|
||||
.note-list--list {
|
||||
.workspace-list--list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.note-list--list .note-card {
|
||||
.workspace-list--list .content-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
@@ -112,7 +123,7 @@
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.note-list--list .note-card:hover {
|
||||
.workspace-list--list .content-card:hover {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
background-color: #eef2ff;
|
||||
@@ -120,7 +131,7 @@
|
||||
border-left: 3px solid var(--color-primary);
|
||||
}
|
||||
|
||||
.note-list--list .note-title {
|
||||
.workspace-list--list .content-title {
|
||||
flex: 0 0 220px;
|
||||
margin-bottom: 0;
|
||||
white-space: nowrap;
|
||||
@@ -128,80 +139,82 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.note-list--list .note-preview {
|
||||
.workspace-list--list .content-preview {
|
||||
flex: 1;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.note-list--list .note-card > small {
|
||||
.workspace-list--list .content-card > small {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.note-list--list .list-footer {
|
||||
grid-column: unset;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.empty-notes-state {
|
||||
.empty-workspace-state {
|
||||
min-height: 40vh;
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
|
||||
.empty-notes-icon {
|
||||
.empty-workspace-icon {
|
||||
font-size: 4.3rem;
|
||||
}
|
||||
|
||||
.empty-notes-title {
|
||||
.empty-workspace-title {
|
||||
font-size: 1.45rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode overrides */
|
||||
:root[data-bs-theme="dark"] .empty-notes-state {
|
||||
:root[data-bs-theme="dark"] .empty-workspace-state {
|
||||
border-color: var(--color-border);
|
||||
background: linear-gradient(180deg, #1e2430 0%, var(--color-surface) 100%);
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .empty-notes-title {
|
||||
:root[data-bs-theme="dark"] .empty-workspace-title {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .empty-notes-message {
|
||||
:root[data-bs-theme="dark"] .empty-workspace-message {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .note-card {
|
||||
:root[data-bs-theme="dark"] .content-card {
|
||||
border-color: var(--color-border);
|
||||
background-color: var(--color-surface);
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .note-card:hover {
|
||||
:root[data-bs-theme="dark"] .content-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .note-list--list .note-card:hover {
|
||||
:root[data-bs-theme="dark"] .workspace-list--list .content-card:hover {
|
||||
background-color: #2a2f3a;
|
||||
border-color: #7aa2f7;
|
||||
border-left-color: #7aa2f7;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .note-title {
|
||||
:root[data-bs-theme="dark"] .content-title {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .note-preview {
|
||||
:root[data-bs-theme="dark"] .content-preview {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .note-card.is-pinned {
|
||||
:root[data-bs-theme="dark"] .content-card.is-pinned {
|
||||
background: #1a3a5c;
|
||||
border-color: #2d6a9f;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .note-card.is-featured {
|
||||
:root[data-bs-theme="dark"] .content-card.is-featured {
|
||||
background: #3a2e0a;
|
||||
border-color: #7a5a0a;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .content-card.is-task-list {
|
||||
background: #1f2a44;
|
||||
border-color: #334b7d;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .list-icon {
|
||||
color: #bfceef;
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="category-tree">
|
||||
<div v-for="category in categories" :key="category.id" class="category-item">
|
||||
<div class="category-header" @click="handleCategoryClick(category)">
|
||||
<span class="expand-icon" v-if="category.subcategories?.length || category.notes?.length">
|
||||
<span class="expand-icon" v-if="category.subcategories?.length || category.notes?.length || category.task_lists?.length">
|
||||
<i :class="expandedCategories[category.id] ? 'mdi mdi-chevron-down' : 'mdi mdi-chevron-right'" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span v-else class="expand-icon"> </span>
|
||||
@@ -20,6 +20,11 @@
|
||||
</div>
|
||||
|
||||
<div v-if="expandedCategories[category.id]" class="category-content">
|
||||
<div v-for="taskList in category.task_lists || []" :key="taskList.id" class="task-list-item" @click.stop="onSelectTaskList(taskList)">
|
||||
<i class="mdi mdi-format-list-checkbox me-1" aria-hidden="true"></i>
|
||||
<span>{{ taskList.name }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="note in sortedNotes(category.notes)"
|
||||
:key="note.id"
|
||||
@@ -41,6 +46,7 @@
|
||||
:on-add-subcategory="onAddSubcategory"
|
||||
:on-edit-category="onEditCategory"
|
||||
:on-delete-category="onDeleteCategory"
|
||||
:on-select-task-list="onSelectTaskList"
|
||||
:can-create-categories="canCreateCategories"
|
||||
:can-edit-categories="canEditCategories"
|
||||
:can-delete-categories="canDeleteCategories"
|
||||
@@ -80,6 +86,10 @@ const props = defineProps({
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
onSelectTaskList: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
canCreateCategories: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -142,6 +152,3 @@ const handleDeleteCategory = (category) => {
|
||||
</script>
|
||||
|
||||
<style scoped src="../assets/styles/scoped/components/CategoryTree.css"></style>
|
||||
|
||||
|
||||
|
||||
|
||||
84
frontend/src/components/CreateTaskListModal.vue
Normal file
84
frontend/src/components/CreateTaskListModal.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<div class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="closeModal">
|
||||
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Create New Task List</h5>
|
||||
<button type="button" class="btn-close" aria-label="Close" @click="closeModal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form @submit.prevent="handleCreate">
|
||||
<div class="mb-3">
|
||||
<label for="taskListName" class="form-label">Task List Name</label>
|
||||
<input id="taskListName" v-model="form.name" type="text" class="form-control" maxlength="120" required />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="taskListCategory" class="form-label">Category</label>
|
||||
<select id="taskListCategory" v-model="form.category_id" class="form-select">
|
||||
<option :value="null">No category</option>
|
||||
<option v-for="category in categoryOptions" :key="category.id" :value="category.id">
|
||||
{{ category.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Create</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop fade show"></div>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
categoryOptions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
defaultCategoryId: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close", "create"]);
|
||||
|
||||
const form = ref({
|
||||
name: "",
|
||||
category_id: null,
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.defaultCategoryId,
|
||||
(defaultCategoryId) => {
|
||||
form.value.category_id = defaultCategoryId || null;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const closeModal = () => {
|
||||
emit("close");
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
const name = form.value.name.trim();
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit("create", {
|
||||
name,
|
||||
category_id: form.value.category_id || null,
|
||||
});
|
||||
|
||||
form.value = {
|
||||
name: "",
|
||||
category_id: props.defaultCategoryId || null,
|
||||
};
|
||||
};
|
||||
</script>
|
||||
@@ -1,69 +0,0 @@
|
||||
<template>
|
||||
<div class="note-list" :class="{ 'note-list--list': viewMode === 'list' }">
|
||||
<div v-if="notes.length === 0" class="empty-notes-state" role="status" aria-live="polite">
|
||||
<i class="mdi mdi-file-document-outline empty-notes-icon" aria-hidden="true"></i>
|
||||
<h3 class="empty-notes-title">No Notes Yet</h3>
|
||||
<p class="empty-notes-message">This space is empty for now. Create your first note to get started.</p>
|
||||
</div>
|
||||
|
||||
<div v-for="note in notes" :key="note.id" class="note-card" :class="{ 'is-pinned': note.is_pinned, 'is-featured': note.is_favorite || note.is_featured }" @click="selectNote(note)">
|
||||
<h5 class="note-title">
|
||||
<i v-if="note.is_pinned" class="mdi mdi-pin pin-icon" aria-hidden="true"></i>
|
||||
<i v-else-if="note.is_favorite || note.is_featured" class="mdi mdi-star featured-icon" aria-hidden="true"></i>
|
||||
{{ note.title }}
|
||||
</h5>
|
||||
<p class="note-preview">{{ getDescription(note) }}</p>
|
||||
<small class="text-muted">Updated: {{ formatDate(note.updated_at) }}</small>
|
||||
</div>
|
||||
|
||||
<div v-if="canLoadMore" class="list-footer">
|
||||
<button class="btn btn-outline-secondary" :disabled="isLoadingMore" @click="emit('loadMore')">
|
||||
{{ isLoadingMore ? "Loading..." : "Load more" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
notes: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
canLoadMore: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isLoadingMore: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
viewMode: {
|
||||
type: String,
|
||||
default: "grid",
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["selectNote", "loadMore"]);
|
||||
|
||||
const selectNote = (note) => {
|
||||
emit("selectNote", note);
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
return new Date(dateString).toLocaleDateString();
|
||||
};
|
||||
|
||||
const getDescription = (note) => {
|
||||
const description = (note?.description || "").trim();
|
||||
if (!description) {
|
||||
return "No description";
|
||||
}
|
||||
return description;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped src="../assets/styles/scoped/components/NoteList.css"></style>
|
||||
|
||||
|
||||
|
||||
@@ -3,23 +3,23 @@
|
||||
<header class="search-results-header">
|
||||
<h2>Search Results</h2>
|
||||
<p v-if="query" class="search-meta">{{ totalResults }} matches for "{{ query }}"</p>
|
||||
<p v-else class="search-meta">Type in the top bar and press Enter to search notes.</p>
|
||||
<p v-else class="search-meta">Type in the top bar and press Enter to search notes and task lists.</p>
|
||||
</header>
|
||||
|
||||
<div v-if="!query" class="empty-state">
|
||||
<i class="mdi mdi-magnify empty-state-icon" aria-hidden="true"></i>
|
||||
<h3>Start your search</h3>
|
||||
<p>Use a title, content keyword, or tag to find matching notes in the selected space.</p>
|
||||
<p>Use a title, content keyword, or tag to find matching notes and task lists in the selected space.</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="totalResults === 0" class="empty-state">
|
||||
<i class="mdi mdi-file-search-outline empty-state-icon" aria-hidden="true"></i>
|
||||
<h3>No matching notes</h3>
|
||||
<h3>No matching results</h3>
|
||||
<p>Try different keywords or a shorter phrase.</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<NoteList :notes="paginatedNotes" :view-mode="viewMode" @select-note="emit('select-note', $event)" />
|
||||
<WorkspaceList :items="paginatedItems" :view-mode="viewMode" @select-note="emit('select-note', $event)" @select-task-list="emit('select-task-list', $event)" />
|
||||
|
||||
<nav v-if="totalPages > 1" class="pagination-bar" aria-label="Search result pages">
|
||||
<button class="btn btn-outline-secondary" :disabled="currentPage <= 1" @click="goToPage(currentPage - 1)">Previous</button>
|
||||
@@ -32,14 +32,14 @@
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import NoteList from "./NoteList.vue";
|
||||
import WorkspaceList from "./WorkspaceList.vue";
|
||||
|
||||
const props = defineProps({
|
||||
query: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
notes: {
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
@@ -57,9 +57,9 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["select-note", "page-change"]);
|
||||
const emit = defineEmits(["select-note", "select-task-list", "page-change"]);
|
||||
|
||||
const totalResults = computed(() => props.notes.length);
|
||||
const totalResults = computed(() => props.items.length);
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(totalResults.value / props.pageSize)));
|
||||
|
||||
const normalizedPage = computed(() => {
|
||||
@@ -69,9 +69,9 @@ const normalizedPage = computed(() => {
|
||||
return Math.min(props.currentPage, totalPages.value);
|
||||
});
|
||||
|
||||
const paginatedNotes = computed(() => {
|
||||
const paginatedItems = computed(() => {
|
||||
const start = (normalizedPage.value - 1) * props.pageSize;
|
||||
return props.notes.slice(start, start + props.pageSize);
|
||||
return props.items.slice(start, start + props.pageSize);
|
||||
});
|
||||
|
||||
const goToPage = (page) => {
|
||||
@@ -83,6 +83,3 @@ const goToPage = (page) => {
|
||||
</script>
|
||||
|
||||
<style scoped src="../assets/styles/scoped/components/SearchResultsPage.css"></style>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -5,19 +5,9 @@
|
||||
<h4 class="mb-0">Tasks</h4>
|
||||
<p class="text-muted small mb-0">Track work with ordered statuses.</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" @click="emit('create-task')">
|
||||
<i class="mdi mdi-checkbox-marked-circle-plus-outline me-1" aria-hidden="true"></i>
|
||||
New Task
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="task-filters">
|
||||
<select v-model="filterCategory" class="form-select" @change="emitFilters">
|
||||
<option value="">All categories</option>
|
||||
<option v-for="category in categoryOptions" :key="category.id" :value="category.id">
|
||||
{{ category.label }}
|
||||
</option>
|
||||
</select>
|
||||
<select v-model="filterStatus" class="form-select" @change="emitFilters">
|
||||
<option value="">All statuses</option>
|
||||
<option v-for="status in statuses" :key="status.id" :value="status.id">
|
||||
@@ -245,15 +235,10 @@ const props = defineProps({
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
categoryOptions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["create-task", "select-task", "filter-change", "reorder-status", "create-status", "rename-status", "delete-status", "update-task-status"]);
|
||||
|
||||
const filterCategory = ref("");
|
||||
const filterStatus = ref("");
|
||||
const filterParent = ref("");
|
||||
const showStatusModal = ref(false);
|
||||
@@ -307,7 +292,6 @@ const statusSections = computed(() =>
|
||||
|
||||
const emitFilters = () => {
|
||||
emit("filter-change", {
|
||||
categoryId: filterCategory.value || null,
|
||||
statusId: filterStatus.value || null,
|
||||
parentTaskId: filterParent.value || null,
|
||||
});
|
||||
@@ -489,6 +473,3 @@ const deleteStatusFromModal = () => {
|
||||
</script>
|
||||
|
||||
<style scoped src="../assets/styles/scoped/components/TaskBoard.css"></style>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -16,12 +16,6 @@
|
||||
<label class="form-label mt-3">Description</label>
|
||||
<textarea v-model="localTask.description" class="form-control" rows="5" maxlength="2000"></textarea>
|
||||
|
||||
<label class="form-label mt-3">Category</label>
|
||||
<select v-model="localTask.category_id" class="form-select">
|
||||
<option value="">Uncategorized</option>
|
||||
<option v-for="category in categoryOptions" :key="category.id" :value="category.id">{{ category.label }}</option>
|
||||
</select>
|
||||
|
||||
<label class="form-label mt-3">Parent Task</label>
|
||||
<select v-model="localTask.parent_task_id" class="form-select">
|
||||
<option value="">No parent (top level)</option>
|
||||
@@ -83,10 +77,6 @@ const props = defineProps({
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
categoryOptions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
parentTaskOptions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
@@ -107,12 +97,12 @@ watch(
|
||||
localTask.value = {
|
||||
title: "",
|
||||
description: "",
|
||||
category_id: "",
|
||||
task_list_id: "",
|
||||
status_id: props.statuses[0]?.id || "",
|
||||
parent_task_id: "",
|
||||
note_links: [],
|
||||
...value,
|
||||
category_id: value?.category_id || "",
|
||||
task_list_id: value?.task_list_id || "",
|
||||
parent_task_id: value?.parent_task_id || "",
|
||||
note_links: value?.note_links || [],
|
||||
};
|
||||
@@ -138,13 +128,10 @@ const stepClass = (status) => {
|
||||
const saveTask = () => {
|
||||
emit("save-task", {
|
||||
...localTask.value,
|
||||
category_id: localTask.value.category_id || null,
|
||||
task_list_id: localTask.value.task_list_id || null,
|
||||
parent_task_id: localTask.value.parent_task_id || null,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped src="../assets/styles/scoped/components/TaskDetailModal.css"></style>
|
||||
|
||||
|
||||
|
||||
|
||||
80
frontend/src/components/WorkspaceList.vue
Normal file
80
frontend/src/components/WorkspaceList.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<div class="workspace-list" :class="{ 'workspace-list--list': viewMode === 'list' }">
|
||||
<div v-if="items.length === 0" class="empty-workspace-state" role="status" aria-live="polite">
|
||||
<i class="mdi mdi-view-grid-outline empty-workspace-icon" aria-hidden="true"></i>
|
||||
<h3 class="empty-workspace-title">Nothing Here Yet</h3>
|
||||
<p class="empty-workspace-message">This view has no notes or task lists yet.</p>
|
||||
</div>
|
||||
|
||||
<div v-for="item in items" :key="`${item.kind}-${item.id}`" class="content-card" :class="contentCardClass(item)" @click="openItem(item)">
|
||||
<h5 class="content-title">
|
||||
<template v-if="item.kind === 'note'">
|
||||
<i v-if="item.is_pinned" class="mdi mdi-pin pin-icon" aria-hidden="true"></i>
|
||||
<i v-else-if="item.is_favorite || item.is_featured" class="mdi mdi-star featured-icon" aria-hidden="true"></i>
|
||||
{{ item.title }}
|
||||
</template>
|
||||
<template v-else>
|
||||
<i class="mdi mdi-format-list-checkbox list-icon" aria-hidden="true"></i>
|
||||
{{ item.name }}
|
||||
</template>
|
||||
</h5>
|
||||
<p class="content-preview">{{ getDescription(item) }}</p>
|
||||
<small class="text-muted">Updated: {{ formatDate(item.updated_at) }}</small>
|
||||
</div>
|
||||
|
||||
<div v-if="canLoadMore" class="list-footer">
|
||||
<button class="btn btn-outline-secondary" :disabled="isLoadingMore" @click.stop="emit('loadMore')">
|
||||
{{ isLoadingMore ? "Loading..." : "Load more" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
canLoadMore: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isLoadingMore: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
viewMode: {
|
||||
type: String,
|
||||
default: "grid",
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["selectNote", "selectTaskList", "loadMore"]);
|
||||
|
||||
const openItem = (item) => {
|
||||
if (item.kind === "task-list") {
|
||||
emit("selectTaskList", item);
|
||||
return;
|
||||
}
|
||||
emit("selectNote", item);
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => new Date(dateString).toLocaleDateString();
|
||||
|
||||
const getDescription = (item) => {
|
||||
const description = (item?.description || "").trim();
|
||||
if (description) {
|
||||
return description;
|
||||
}
|
||||
return item.kind === "task-list" ? "Open this task list to manage tasks." : "No description";
|
||||
};
|
||||
|
||||
const contentCardClass = (item) => ({
|
||||
"is-pinned": item.kind === "note" && item.is_pinned,
|
||||
"is-featured": item.kind === "note" && (item.is_favorite || item.is_featured),
|
||||
"is-task-list": item.kind === "task-list",
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped src="../assets/styles/scoped/components/WorkspaceList.css"></style>
|
||||
@@ -13,12 +13,13 @@ export const useSpaceStore = defineStore("space", () => {
|
||||
const notesLoading = ref(false);
|
||||
const categories = ref([]);
|
||||
const categoryTree = ref([]);
|
||||
const taskLists = ref([]);
|
||||
const tasks = ref([]);
|
||||
const taskStatuses = ref([]);
|
||||
const noteLinkedTasks = ref([]);
|
||||
|
||||
const refreshSpaceData = async (spaceId) => {
|
||||
await Promise.all([fetchCategories(spaceId), fetchNotes(spaceId), fetchTaskStatuses(spaceId), fetchTasks(spaceId)]);
|
||||
await Promise.all([fetchCategories(spaceId), fetchNotes(spaceId), fetchTaskLists(spaceId), fetchTaskStatuses(spaceId), fetchTasks(spaceId)]);
|
||||
};
|
||||
|
||||
const fetchSpaces = async () => {
|
||||
@@ -227,6 +228,39 @@ export const useSpaceStore = defineStore("space", () => {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchTaskLists = async (spaceId) => {
|
||||
if (!spaceId) {
|
||||
taskLists.value = [];
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const response = await apiClient.get(`/api/v1/spaces/${spaceId}/task-lists`);
|
||||
taskLists.value = response.data || [];
|
||||
return taskLists.value;
|
||||
} catch (error) {
|
||||
console.error("Error fetching task lists:", error);
|
||||
taskLists.value = [];
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const createTaskList = async (spaceId, payload) => {
|
||||
const response = await apiClient.post(`/api/v1/spaces/${spaceId}/task-lists`, payload);
|
||||
await fetchTaskLists(spaceId);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const updateTaskList = async (spaceId, taskListId, payload) => {
|
||||
const response = await apiClient.put(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}`, payload);
|
||||
await fetchTaskLists(spaceId);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const deleteTaskList = async (spaceId, taskListId) => {
|
||||
await apiClient.delete(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}`);
|
||||
await fetchTaskLists(spaceId);
|
||||
};
|
||||
|
||||
const createTaskStatus = async (spaceId, payload) => {
|
||||
const response = await apiClient.post(`/api/v1/spaces/${spaceId}/task-statuses`, payload);
|
||||
await fetchTaskStatuses(spaceId);
|
||||
@@ -258,8 +292,8 @@ export const useSpaceStore = defineStore("space", () => {
|
||||
return [];
|
||||
}
|
||||
const params = {};
|
||||
if (filters.categoryId) {
|
||||
params.categoryId = filters.categoryId;
|
||||
if (filters.taskListId) {
|
||||
params.taskListId = filters.taskListId;
|
||||
}
|
||||
if (filters.statusId) {
|
||||
params.statusId = filters.statusId;
|
||||
@@ -344,6 +378,7 @@ export const useSpaceStore = defineStore("space", () => {
|
||||
notesLoading,
|
||||
categories,
|
||||
categoryTree,
|
||||
taskLists,
|
||||
tasks,
|
||||
taskStatuses,
|
||||
noteLinkedTasks,
|
||||
@@ -363,6 +398,10 @@ export const useSpaceStore = defineStore("space", () => {
|
||||
searchNotes,
|
||||
clearSearchResults,
|
||||
fetchTaskStatuses,
|
||||
fetchTaskLists,
|
||||
createTaskList,
|
||||
updateTaskList,
|
||||
deleteTaskList,
|
||||
createTaskStatus,
|
||||
updateTaskStatus,
|
||||
deleteTaskStatus,
|
||||
|
||||
Reference in New Issue
Block a user