Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74d8899eec |
1480
frontend/src/App.vue
1480
frontend/src/App.vue
File diff suppressed because it is too large
Load Diff
@@ -66,24 +66,6 @@
|
|||||||
max-height: 600px;
|
max-height: 600px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.danger-zone {
|
|
||||||
padding: 1rem;
|
|
||||||
border: 1px solid #f3b5b5;
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
background: var(--color-surface)5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.danger-zone-title {
|
|
||||||
color: #9f1c1c;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.danger-zone-copy {
|
|
||||||
color: #7a2727;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-mention-panel {
|
.task-mention-panel {
|
||||||
margin-top: 0.45rem;
|
margin-top: 0.45rem;
|
||||||
border: 1px solid #dbe4f0;
|
border: 1px solid #dbe4f0;
|
||||||
@@ -225,19 +207,6 @@
|
|||||||
background-color: var(--color-surface);
|
background-color: var(--color-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-bs-theme="dark"] .danger-zone {
|
|
||||||
background: #2d1a1a;
|
|
||||||
border-color: #7a3030;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-bs-theme="dark"] .danger-zone-title {
|
|
||||||
color: #fc8181;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-bs-theme="dark"] .danger-zone-copy {
|
|
||||||
color: #fca5a5;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-bs-theme="dark"] .task-mention-panel {
|
:root[data-bs-theme="dark"] .task-mention-panel {
|
||||||
border-color: #3a4558;
|
border-color: #3a4558;
|
||||||
background: #1f2733;
|
background: #1f2733;
|
||||||
@@ -296,5 +265,3 @@
|
|||||||
background: color-mix(in srgb, var(--task-status-color, #7aa2f7) 26%, #111827 74%);
|
background: color-mix(in srgb, var(--task-status-color, #7aa2f7) 26%, #111827 74%);
|
||||||
color: color-mix(in srgb, var(--task-status-color, #7aa2f7) 70%, #dbeafe 30%);
|
color: color-mix(in srgb, var(--task-status-color, #7aa2f7) 70%, #dbeafe 30%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -289,24 +289,6 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.danger-zone {
|
|
||||||
border: 1px solid #f3b5b5;
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
background: var(--color-surface) 5f5;
|
|
||||||
padding: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.danger-zone-title {
|
|
||||||
color: #9f1c1c;
|
|
||||||
margin: 0;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.danger-zone-copy {
|
|
||||||
color: #7a2727;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.task-filters {
|
.task-filters {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
32
frontend/src/assets/styles/shared/danger-zone.css
Normal file
32
frontend/src/assets/styles/shared/danger-zone.css
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
.danger-zone {
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid #f3b5b5;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: #fff5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-zone-title {
|
||||||
|
color: #9f1c1c;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-zone-copy {
|
||||||
|
color: #7a2727;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .danger-zone {
|
||||||
|
background: #2d1a1a;
|
||||||
|
border-color: #7a3030;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .danger-zone-title {
|
||||||
|
color: #fc8181;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .danger-zone-copy {
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
@@ -62,17 +62,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="mode === 'edit'" class="col-12">
|
<div v-if="mode === 'edit'" class="col-12">
|
||||||
<div class="danger-zone border border-danger-subtle rounded p-3 mt-2">
|
<DangerZonePanel
|
||||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-2">
|
class="mt-4"
|
||||||
<div>
|
title-id="danger-zone-title"
|
||||||
<div class="fw-semibold text-danger">Danger Zone</div>
|
title="Danger Zone"
|
||||||
<div class="small text-muted">Permanently delete this provider configuration.</div>
|
description="Permanently delete this provider configuration. This action cannot be undone."
|
||||||
</div>
|
>
|
||||||
<button type="button" class="btn btn-sm btn-outline-danger" :disabled="submitting || deleting" @click="emit('delete', props.provider)">
|
<button class="btn btn-danger" type="button" :disabled="submitting || deleting" @click="emit('delete', props.provider)">
|
||||||
<i class="mdi mdi-trash-can-outline me-1" aria-hidden="true"></i>Delete Provider
|
<i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i>
|
||||||
|
Delete Provider
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</DangerZonePanel>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,6 +92,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from "vue";
|
import { ref, watch } from "vue";
|
||||||
|
import DangerZonePanel from "./DangerZonePanel.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
mode: {
|
mode: {
|
||||||
@@ -179,6 +180,3 @@ const handleSubmit = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped src="../assets/styles/scoped/components/AdminProviderModal.css"></style>
|
<style scoped src="../assets/styles/scoped/components/AdminProviderModal.css"></style>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<teleport to="body">
|
<teleport v-if="!showDeleteConfirmModal" to="body">
|
||||||
<div class="modal fade show d-block admin-modal" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')">
|
<div class="modal fade show d-block admin-modal" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')">
|
||||||
<div class="modal-dialog modal-xl modal-dialog-centered modal-dialog-scrollable" role="document">
|
<div class="modal-dialog modal-xl modal-dialog-centered modal-dialog-scrollable" role="document">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
@@ -85,24 +85,39 @@
|
|||||||
<div v-if="success" class="alert alert-success mt-3 mb-0">{{ success }}</div>
|
<div v-if="success" class="alert alert-success mt-3 mb-0">{{ success }}</div>
|
||||||
|
|
||||||
<hr />
|
<hr />
|
||||||
<div class="border border-danger rounded p-3 mt-3">
|
<DangerZonePanel
|
||||||
<h6 class="text-danger mb-1">Danger Zone</h6>
|
class="mt-4"
|
||||||
<p class="text-muted small mb-3">Permanently delete this space and all its notes, categories, and members. This cannot be undone.</p>
|
title-id="danger-zone-title"
|
||||||
<button class="btn btn-danger btn-sm" :disabled="deleting" @click="deleteSpace">
|
title="Danger Zone"
|
||||||
|
description="Permanently delete this space and all its notes, categories, and members. This cannot be undone."
|
||||||
|
>
|
||||||
|
<button class="btn btn-danger" type="button" :disabled="deleting" @click="requestDeleteSpace">
|
||||||
|
<i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i>
|
||||||
{{ deleting ? "Deleting..." : "Delete Space" }}
|
{{ deleting ? "Deleting..." : "Delete Space" }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</DangerZonePanel>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-backdrop fade show admin-modal-backdrop"></div>
|
<div class="modal-backdrop fade show admin-modal-backdrop"></div>
|
||||||
</teleport>
|
</teleport>
|
||||||
|
|
||||||
|
<ConfirmActionModal
|
||||||
|
:visible="showDeleteConfirmModal"
|
||||||
|
:title="deleteConfirmTitle"
|
||||||
|
:message="deleteConfirmMessage"
|
||||||
|
:busy="deleteConfirmBusy"
|
||||||
|
@close="closeDeleteConfirmModal"
|
||||||
|
@confirm="confirmDeleteAction"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref, watch } from "vue";
|
import { computed, ref, watch } from "vue";
|
||||||
import apiClient from "../services/apiClient";
|
import apiClient from "../services/apiClient";
|
||||||
|
import ConfirmActionModal from "./ConfirmActionModal.vue";
|
||||||
|
import DangerZonePanel from "./DangerZonePanel.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
space: {
|
space: {
|
||||||
@@ -133,6 +148,20 @@ const error = ref("");
|
|||||||
const success = ref("");
|
const success = ref("");
|
||||||
const newMember = ref({ user_id: "" });
|
const newMember = ref({ user_id: "" });
|
||||||
const deleting = ref(false);
|
const deleting = ref(false);
|
||||||
|
const showDeleteConfirmModal = ref(false);
|
||||||
|
const deleteConfirmBusy = ref(false);
|
||||||
|
const deleteConfirmIntent = ref({
|
||||||
|
type: "",
|
||||||
|
payload: null,
|
||||||
|
});
|
||||||
|
const deleteConfirmTitle = computed(() => (deleteConfirmIntent.value.type === "member" ? "Remove Member" : "Delete Space"));
|
||||||
|
const deleteConfirmMessage = computed(() => {
|
||||||
|
if (deleteConfirmIntent.value.type === "member") {
|
||||||
|
const memberName = deleteConfirmIntent.value.payload?.username || deleteConfirmIntent.value.payload?.user_id || "this member";
|
||||||
|
return `Remove member "${memberName}" from this space?`;
|
||||||
|
}
|
||||||
|
return `Permanently delete space "${props.space.name}"? All notes, categories, and members will be removed. This cannot be undone.`;
|
||||||
|
});
|
||||||
|
|
||||||
const formatDate = (iso) => (iso ? new Date(iso).toLocaleDateString() : "-");
|
const formatDate = (iso) => (iso ? new Date(iso).toLocaleDateString() : "-");
|
||||||
|
|
||||||
@@ -208,9 +237,20 @@ const addMember = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeMember = async (member) => {
|
const removeMember = (member) => {
|
||||||
const memberName = member?.username || member?.user_id;
|
if (!member?.user_id) {
|
||||||
if (!member?.user_id || !confirm(`Remove member "${memberName}" from this space?`)) {
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteConfirmIntent.value = {
|
||||||
|
type: "member",
|
||||||
|
payload: member,
|
||||||
|
};
|
||||||
|
showDeleteConfirmModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeMemberConfirmed = async (member) => {
|
||||||
|
if (!member?.user_id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,10 +276,15 @@ watch(
|
|||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteSpace = async () => {
|
const requestDeleteSpace = () => {
|
||||||
if (!confirm(`Permanently delete space "${props.space.name}"? All notes, categories, and members will be removed. This cannot be undone.`)) {
|
deleteConfirmIntent.value = {
|
||||||
return;
|
type: "space",
|
||||||
}
|
payload: props.space,
|
||||||
|
};
|
||||||
|
showDeleteConfirmModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteSpaceConfirmed = async () => {
|
||||||
deleting.value = true;
|
deleting.value = true;
|
||||||
clearMessages();
|
clearMessages();
|
||||||
try {
|
try {
|
||||||
@@ -247,13 +292,51 @@ const deleteSpace = async () => {
|
|||||||
emit("deleted", props.space);
|
emit("deleted", props.space);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e.response?.data || "Failed to delete space.";
|
error.value = e.response?.data || "Failed to delete space.";
|
||||||
|
throw e;
|
||||||
} finally {
|
} finally {
|
||||||
deleting.value = false;
|
deleting.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const closeDeleteConfirmModal = () => {
|
||||||
|
if (deleteConfirmBusy.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showDeleteConfirmModal.value = false;
|
||||||
|
deleteConfirmIntent.value = {
|
||||||
|
type: "",
|
||||||
|
payload: null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDeleteAction = async () => {
|
||||||
|
if (deleteConfirmBusy.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { type, payload } = deleteConfirmIntent.value;
|
||||||
|
if (!type) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteConfirmBusy.value = true;
|
||||||
|
try {
|
||||||
|
if (type === "member") {
|
||||||
|
await removeMemberConfirmed(payload);
|
||||||
|
} else if (type === "space") {
|
||||||
|
await deleteSpaceConfirmed();
|
||||||
|
}
|
||||||
|
|
||||||
|
showDeleteConfirmModal.value = false;
|
||||||
|
deleteConfirmIntent.value = {
|
||||||
|
type: "",
|
||||||
|
payload: null,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
deleteConfirmBusy.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped src="../assets/styles/scoped/components/AdminSpaceModal.css"></style>
|
<style scoped src="../assets/styles/scoped/components/AdminSpaceModal.css"></style>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
62
frontend/src/components/ConfirmActionModal.vue
Normal file
62
frontend/src/components/ConfirmActionModal.vue
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<template>
|
||||||
|
<teleport to="body">
|
||||||
|
<div v-if="visible" class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')">
|
||||||
|
<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-alert-outline text-danger" aria-hidden="true"></i>
|
||||||
|
<span>{{ title }}</span>
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" aria-label="Close" :disabled="busy" @click="emit('close')"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="text-muted mb-0">{{ message }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" :disabled="busy" @click="emit('close')">{{ cancelLabel }}</button>
|
||||||
|
<button type="button" class="btn btn-danger" :disabled="busy" @click="emit('confirm')">
|
||||||
|
{{ busy ? busyLabel : confirmLabel }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="visible" class="modal-backdrop fade show"></div>
|
||||||
|
</teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
visible: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: "Confirm Deletion",
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
type: String,
|
||||||
|
default: "Are you sure you want to continue?",
|
||||||
|
},
|
||||||
|
confirmLabel: {
|
||||||
|
type: String,
|
||||||
|
default: "Delete",
|
||||||
|
},
|
||||||
|
cancelLabel: {
|
||||||
|
type: String,
|
||||||
|
default: "Cancel",
|
||||||
|
},
|
||||||
|
busyLabel: {
|
||||||
|
type: String,
|
||||||
|
default: "Deleting...",
|
||||||
|
},
|
||||||
|
busy: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["close", "confirm"]);
|
||||||
|
</script>
|
||||||
24
frontend/src/components/DangerZonePanel.vue
Normal file
24
frontend/src/components/DangerZonePanel.vue
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<template>
|
||||||
|
<section class="danger-zone" :aria-labelledby="titleId">
|
||||||
|
<h3 :id="titleId" class="danger-zone-title mb-2">{{ title }}</h3>
|
||||||
|
<p class="danger-zone-copy mb-3">{{ description }}</p>
|
||||||
|
<slot></slot>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
titleId: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: "Danger Zone",
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
default: "This action is permanent and cannot be undone.",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
<i :class="fileIcon(obj)" style="font-size: 1rem; width: 1.1rem; flex-shrink: 0" aria-hidden="true"></i>
|
<i :class="fileIcon(obj)" style="font-size: 1rem; width: 1.1rem; flex-shrink: 0" aria-hidden="true"></i>
|
||||||
<span class="flex-grow-1 text-truncate" style="font-size: 0.82rem">{{ displayName(obj) }}</span>
|
<span class="flex-grow-1 text-truncate" style="font-size: 0.82rem">{{ displayName(obj) }}</span>
|
||||||
<span v-if="!obj.is_folder && obj.size > 0" class="text-muted flex-shrink-0" style="font-size: 0.68rem">{{ formatSize(obj.size) }}</span>
|
<span v-if="!obj.is_folder && obj.size > 0" class="text-muted flex-shrink-0" style="font-size: 0.68rem">{{ formatSize(obj.size) }}</span>
|
||||||
<button class="btn-delete btn btn-sm btn-link p-0 text-danger ms-1" :title="obj.is_folder ? 'Delete folder' : 'Delete file'" @click.stop="deleteItem(obj)">
|
<button class="btn-delete btn btn-sm btn-link p-0 text-danger ms-1" :title="obj.is_folder ? 'Delete folder' : 'Delete file'" @click.stop="requestDeleteItem(obj)">
|
||||||
<i class="mdi mdi-trash-can-outline" style="font-size: 0.85rem" aria-hidden="true"></i>
|
<i class="mdi mdi-trash-can-outline" style="font-size: 0.85rem" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -87,11 +87,14 @@
|
|||||||
<!-- Hidden file input -->
|
<!-- Hidden file input -->
|
||||||
<input ref="fileInputRef" type="file" multiple class="d-none" @change="handleFilePick" />
|
<input ref="fileInputRef" type="file" multiple class="d-none" @change="handleFilePick" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ConfirmActionModal :visible="showDeleteConfirmModal" title="Delete Item" :message="deleteConfirmMessage" :busy="deletingItem" @close="closeDeleteConfirmModal" @confirm="confirmDeleteItem" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, nextTick } from "vue";
|
import { ref, computed, watch, nextTick } from "vue";
|
||||||
import apiClient from "../services/apiClient";
|
import apiClient from "../services/apiClient";
|
||||||
|
import ConfirmActionModal from "./ConfirmActionModal.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
spaceId: {
|
spaceId: {
|
||||||
@@ -117,6 +120,9 @@ const showNewFolderInput = ref(false);
|
|||||||
const newFolderName = ref("");
|
const newFolderName = ref("");
|
||||||
const fileInputRef = ref(null);
|
const fileInputRef = ref(null);
|
||||||
const newFolderInputRef = ref(null);
|
const newFolderInputRef = ref(null);
|
||||||
|
const showDeleteConfirmModal = ref(false);
|
||||||
|
const pendingDeleteItem = ref(null);
|
||||||
|
const deletingItem = ref(false);
|
||||||
|
|
||||||
const breadcrumbs = computed(() => {
|
const breadcrumbs = computed(() => {
|
||||||
if (!currentPrefix.value) return [];
|
if (!currentPrefix.value) return [];
|
||||||
@@ -215,9 +221,37 @@ const createFolder = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteItem = async (obj) => {
|
const requestDeleteItem = (obj) => {
|
||||||
const label = displayName(obj);
|
if (!obj) {
|
||||||
if (!confirm(`Delete "${label}"?${obj.is_folder ? "./nThis will delete all files inside the folder." : ""}`)) return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingDeleteItem.value = obj;
|
||||||
|
showDeleteConfirmModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDeleteConfirmModal = () => {
|
||||||
|
if (deletingItem.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showDeleteConfirmModal.value = false;
|
||||||
|
pendingDeleteItem.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteConfirmMessage = computed(() => {
|
||||||
|
const obj = pendingDeleteItem.value;
|
||||||
|
const label = obj ? displayName(obj) : "this item";
|
||||||
|
return obj?.is_folder ? `Delete "${label}"? This will delete all files inside the folder.` : `Delete "${label}"?`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmDeleteItem = async () => {
|
||||||
|
const obj = pendingDeleteItem.value;
|
||||||
|
if (!obj) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deletingItem.value = true;
|
||||||
error.value = "";
|
error.value = "";
|
||||||
try {
|
try {
|
||||||
if (obj.is_folder) {
|
if (obj.is_folder) {
|
||||||
@@ -227,8 +261,12 @@ const deleteItem = async (obj) => {
|
|||||||
await apiClient.delete(`/api/v1/spaces/${props.spaceId}/files/object`, { params: { key: obj.key } });
|
await apiClient.delete(`/api/v1/spaces/${props.spaceId}/files/object`, { params: { key: obj.key } });
|
||||||
}
|
}
|
||||||
await loadFiles();
|
await loadFiles();
|
||||||
|
showDeleteConfirmModal.value = false;
|
||||||
|
pendingDeleteItem.value = null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e.response?.data || "Delete failed";
|
error.value = e.response?.data || "Delete failed";
|
||||||
|
} finally {
|
||||||
|
deletingItem.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -125,16 +125,22 @@
|
|||||||
<input v-if="passwordAction === 'set'" v-model="notePassword" type="password" class="form-control mt-2" minlength="4" maxlength="128" placeholder="Enter a note password" />
|
<input v-if="passwordAction === 'set'" v-model="notePassword" type="password" class="form-control mt-2" minlength="4" maxlength="128" placeholder="Enter a note password" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section v-if="canDelete && editingNote.id" class="danger-zone mt-4" aria-labelledby="danger-zone-title">
|
<DangerZonePanel v-if="canDelete && editingNote.id" class="mt-4" title-id="danger-zone-title" title="Danger Zone" description="Deleting this note is permanent and cannot be undone.">
|
||||||
<h3 id="danger-zone-title" class="danger-zone-title mb-2">Danger Zone</h3>
|
<button class="btn btn-danger" type="button" @click="requestDelete">
|
||||||
<p class="danger-zone-copy mb-3">Deleting this note is permanent and cannot be undone.</p>
|
|
||||||
<button class="btn btn-danger" type="button" @click="confirmDelete">
|
|
||||||
<i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i>
|
<i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i>
|
||||||
Delete Note
|
Delete Note
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</DangerZonePanel>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ConfirmActionModal
|
||||||
|
:visible="showDeleteConfirmModal"
|
||||||
|
title="Delete Note"
|
||||||
|
message="Are you sure you want to delete this note? This action cannot be undone."
|
||||||
|
@close="showDeleteConfirmModal = false"
|
||||||
|
@confirm="confirmDelete"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -144,6 +150,8 @@ import { useSettingsStore } from "../stores/settingsStore";
|
|||||||
import { useSpaceStore } from "../stores/spaceStore";
|
import { useSpaceStore } from "../stores/spaceStore";
|
||||||
import { renderMarkdown } from "../utils/markdown.js";
|
import { renderMarkdown } from "../utils/markdown.js";
|
||||||
import FileExplorer from "./FileExplorer.vue";
|
import FileExplorer from "./FileExplorer.vue";
|
||||||
|
import DangerZonePanel from "./DangerZonePanel.vue";
|
||||||
|
import ConfirmActionModal from "./ConfirmActionModal.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
note: {
|
note: {
|
||||||
@@ -187,6 +195,7 @@ const linkedTasks = ref([]);
|
|||||||
const showTaskPicker = ref(false);
|
const showTaskPicker = ref(false);
|
||||||
const taskPickerQuery = ref("");
|
const taskPickerQuery = ref("");
|
||||||
const taskPickerLoading = ref(false);
|
const taskPickerLoading = ref(false);
|
||||||
|
const showDeleteConfirmModal = ref(false);
|
||||||
|
|
||||||
const hasAuxPanels = computed(() => showFileExplorer.value || showTaskPicker.value);
|
const hasAuxPanels = computed(() => showFileExplorer.value || showTaskPicker.value);
|
||||||
const hasTwoAuxPanels = computed(() => showFileExplorer.value && showTaskPicker.value);
|
const hasTwoAuxPanels = computed(() => showFileExplorer.value && showTaskPicker.value);
|
||||||
@@ -374,13 +383,22 @@ const autoSave = () => {
|
|||||||
detectTaskMention();
|
detectTaskMention();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const requestDelete = () => {
|
||||||
|
if (!props.canDelete) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showDeleteConfirmModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
const confirmDelete = () => {
|
const confirmDelete = () => {
|
||||||
if (!props.canDelete) {
|
if (!props.canDelete) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (confirm("Are you sure you want to delete this note?")) {
|
if (!editingNote.value?.id) {
|
||||||
emit("delete", editingNote.value.id);
|
return;
|
||||||
}
|
}
|
||||||
|
showDeleteConfirmModal.value = false;
|
||||||
|
emit("delete", editingNote.value.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Insert markdown snippet at the textarea cursor position. */
|
/** Insert markdown snippet at the textarea cursor position. */
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<teleport to="body">
|
<teleport v-if="!showDeleteConfirmModal" to="body">
|
||||||
<div class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')">
|
<div class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')">
|
||||||
<div class="modal-dialog modal-lg modal-dialog-centered" role="document">
|
<div class="modal-dialog modal-lg modal-dialog-centered" role="document">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
@@ -76,13 +76,17 @@
|
|||||||
|
|
||||||
<template v-if="canDeleteSpace">
|
<template v-if="canDeleteSpace">
|
||||||
<hr />
|
<hr />
|
||||||
<div class="border border-danger rounded p-3 mt-3">
|
<DangerZonePanel
|
||||||
<h6 class="text-danger mb-1">Danger Zone</h6>
|
class="mt-4"
|
||||||
<p class="text-muted small mb-3">Permanently delete this space and all its notes, categories, and members. This cannot be undone.</p>
|
title-id="danger-zone-title"
|
||||||
<button class="btn btn-danger btn-sm" :disabled="deleting" @click="deleteSpace">
|
title="Danger Zone"
|
||||||
|
description="Permanently delete this space and all its notes, categories, and members. This cannot be undone."
|
||||||
|
>
|
||||||
|
<button class="btn btn-danger" type="button" :disabled="deleting" @click="requestDeleteSpace">
|
||||||
|
<i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i>
|
||||||
{{ deleting ? "Deleting..." : "Delete Space" }}
|
{{ deleting ? "Deleting..." : "Delete Space" }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</DangerZonePanel>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div v-if="error" class="alert alert-danger mt-3 mb-0">{{ error }}</div>
|
<div v-if="error" class="alert alert-danger mt-3 mb-0">{{ error }}</div>
|
||||||
@@ -93,12 +97,23 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-backdrop fade show"></div>
|
<div class="modal-backdrop fade show"></div>
|
||||||
</teleport>
|
</teleport>
|
||||||
|
|
||||||
|
<ConfirmActionModal
|
||||||
|
:visible="showDeleteConfirmModal"
|
||||||
|
:title="deleteConfirmTitle"
|
||||||
|
:message="deleteConfirmMessage"
|
||||||
|
:busy="deleteConfirmBusy"
|
||||||
|
@close="closeDeleteConfirmModal"
|
||||||
|
@confirm="confirmDeleteAction"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref, watch } from "vue";
|
import { computed, ref, watch } from "vue";
|
||||||
import apiClient from "../services/apiClient";
|
import apiClient from "../services/apiClient";
|
||||||
import { useAuthStore } from "../stores/authStore";
|
import { useAuthStore } from "../stores/authStore";
|
||||||
|
import ConfirmActionModal from "./ConfirmActionModal.vue";
|
||||||
|
import DangerZonePanel from "./DangerZonePanel.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
space: {
|
space: {
|
||||||
@@ -130,6 +145,20 @@ const success = ref("");
|
|||||||
const memberForm = ref({ user_id: "" });
|
const memberForm = ref({ user_id: "" });
|
||||||
const canViewMembers = computed(() => authStore.hasSpacePermission(props.space, "settings.member.view"));
|
const canViewMembers = computed(() => authStore.hasSpacePermission(props.space, "settings.member.view"));
|
||||||
const canManageMembers = computed(() => authStore.hasSpacePermission(props.space, "settings.member.manage"));
|
const canManageMembers = computed(() => authStore.hasSpacePermission(props.space, "settings.member.manage"));
|
||||||
|
const showDeleteConfirmModal = ref(false);
|
||||||
|
const deleteConfirmBusy = ref(false);
|
||||||
|
const deleteConfirmIntent = ref({
|
||||||
|
type: "",
|
||||||
|
payload: null,
|
||||||
|
});
|
||||||
|
const deleteConfirmTitle = computed(() => (deleteConfirmIntent.value.type === "member" ? "Remove Member" : "Delete Space"));
|
||||||
|
const deleteConfirmMessage = computed(() => {
|
||||||
|
if (deleteConfirmIntent.value.type === "member") {
|
||||||
|
const memberName = deleteConfirmIntent.value.payload?.username || deleteConfirmIntent.value.payload?.user_id || "this member";
|
||||||
|
return `Remove member "${memberName}" from this space?`;
|
||||||
|
}
|
||||||
|
return `Permanently delete space "${props.space.name}"? All notes, categories, and members will be removed. This cannot be undone.`;
|
||||||
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.space,
|
() => props.space,
|
||||||
@@ -224,13 +253,24 @@ const addMember = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeMember = async (member) => {
|
const removeMember = (member) => {
|
||||||
if (!canManageMembers.value) {
|
if (!canManageMembers.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const memberName = member?.username || member?.user_id;
|
if (!member?.user_id) {
|
||||||
if (!member?.user_id || !confirm(`Remove member "${memberName}" from this space?`)) {
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteConfirmIntent.value = {
|
||||||
|
type: "member",
|
||||||
|
payload: member,
|
||||||
|
};
|
||||||
|
showDeleteConfirmModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeMemberConfirmed = async (member) => {
|
||||||
|
if (!member?.user_id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,10 +291,15 @@ if (canViewMembers.value) {
|
|||||||
Promise.all([loadMembers(), loadUserOptions()]);
|
Promise.all([loadMembers(), loadUserOptions()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteSpace = async () => {
|
const requestDeleteSpace = () => {
|
||||||
if (!confirm(`Permanently delete space "${props.space.name}"? All notes, categories, and members will be removed. This cannot be undone.`)) {
|
deleteConfirmIntent.value = {
|
||||||
return;
|
type: "space",
|
||||||
}
|
payload: props.space,
|
||||||
|
};
|
||||||
|
showDeleteConfirmModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteSpaceConfirmed = async () => {
|
||||||
deleting.value = true;
|
deleting.value = true;
|
||||||
clearMessages();
|
clearMessages();
|
||||||
try {
|
try {
|
||||||
@@ -262,8 +307,49 @@ const deleteSpace = async () => {
|
|||||||
emit("deleted", props.space);
|
emit("deleted", props.space);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e.response?.data || "Failed to delete space.";
|
error.value = e.response?.data || "Failed to delete space.";
|
||||||
|
throw e;
|
||||||
} finally {
|
} finally {
|
||||||
deleting.value = false;
|
deleting.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const closeDeleteConfirmModal = () => {
|
||||||
|
if (deleteConfirmBusy.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showDeleteConfirmModal.value = false;
|
||||||
|
deleteConfirmIntent.value = {
|
||||||
|
type: "",
|
||||||
|
payload: null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDeleteAction = async () => {
|
||||||
|
if (deleteConfirmBusy.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { type, payload } = deleteConfirmIntent.value;
|
||||||
|
if (!type) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteConfirmBusy.value = true;
|
||||||
|
try {
|
||||||
|
if (type === "member") {
|
||||||
|
await removeMemberConfirmed(payload);
|
||||||
|
} else if (type === "space") {
|
||||||
|
await deleteSpaceConfirmed();
|
||||||
|
}
|
||||||
|
|
||||||
|
showDeleteConfirmModal.value = false;
|
||||||
|
deleteConfirmIntent.value = {
|
||||||
|
type: "",
|
||||||
|
payload: null,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
deleteConfirmBusy.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -185,11 +185,17 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section v-if="selectedTaskList && canDeleteTaskList" class="danger-zone" aria-labelledby="task-list-danger-zone-title">
|
<DangerZonePanel
|
||||||
<h6 id="task-list-danger-zone-title" class="danger-zone-title">Danger Zone</h6>
|
v-if="selectedTaskList && canDeleteTaskList"
|
||||||
<p class="danger-zone-copy mb-2">Delete this task list and all associated tasks permanently.</p>
|
title-id="task-list-danger-zone-title"
|
||||||
<button type="button" class="btn btn-outline-danger" @click="emitDeleteTaskList">Delete Task List</button>
|
title="Danger Zone"
|
||||||
</section>
|
description="Delete this task list and all associated tasks permanently."
|
||||||
|
>
|
||||||
|
<button type="button" class="btn btn-danger" @click="emitDeleteTaskList">
|
||||||
|
<i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i>
|
||||||
|
Delete Task List
|
||||||
|
</button>
|
||||||
|
</DangerZonePanel>
|
||||||
|
|
||||||
<teleport to="body">
|
<teleport to="body">
|
||||||
<div v-if="showStatusModal" class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="closeStatusModal">
|
<div v-if="showStatusModal" class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="closeStatusModal">
|
||||||
@@ -209,11 +215,16 @@
|
|||||||
<input v-model="statusForm.color" type="text" class="form-control" placeholder="#7c8596" maxlength="20" />
|
<input v-model="statusForm.color" type="text" class="form-control" placeholder="#7c8596" maxlength="20" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section v-if="statusMode === 'edit'" class="danger-zone mt-4" aria-labelledby="status-danger-zone-title">
|
<DangerZonePanel
|
||||||
<h6 id="status-danger-zone-title" class="danger-zone-title">Danger Zone</h6>
|
v-if="statusMode === 'edit'"
|
||||||
<p class="danger-zone-copy mb-2">Deleting this status is permanent and cannot be undone.</p>
|
class="mt-4"
|
||||||
|
title-id="status-danger-zone-title"
|
||||||
|
title="Danger Zone"
|
||||||
|
description="Deleting this status is permanent and cannot be undone."
|
||||||
|
copy-class="mb-2"
|
||||||
|
>
|
||||||
<button type="button" class="btn btn-outline-danger" @click="deleteStatusFromModal">Delete Status</button>
|
<button type="button" class="btn btn-outline-danger" @click="deleteStatusFromModal">Delete Status</button>
|
||||||
</section>
|
</DangerZonePanel>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-outline-secondary" @click="closeStatusModal">Cancel</button>
|
<button type="button" class="btn btn-outline-secondary" @click="closeStatusModal">Cancel</button>
|
||||||
@@ -231,6 +242,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||||
|
import DangerZonePanel from "./DangerZonePanel.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
tasks: {
|
tasks: {
|
||||||
|
|||||||
145
frontend/src/components/app/AppModalHost.vue
Normal file
145
frontend/src/components/app/AppModalHost.vue
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
<template>
|
||||||
|
<CreateSpaceModal v-if="showCreateSpaceModal" @close="emit('close-create-space')" @create="emit('create-space', $event)" />
|
||||||
|
<CreateCategoryModal
|
||||||
|
v-if="showCreateCategoryModal"
|
||||||
|
:category="editingCategory"
|
||||||
|
:parent-options="categoryParentOptions"
|
||||||
|
:parent-id="categoryModalParentId"
|
||||||
|
@close="emit('close-create-category')"
|
||||||
|
@submit="emit('submit-category', $event)"
|
||||||
|
/>
|
||||||
|
<CreateNoteModal
|
||||||
|
v-if="showCreateNoteModal"
|
||||||
|
:category-options="categoryOptions"
|
||||||
|
:default-category-id="selectedCategoryId"
|
||||||
|
@close="emit('close-create-note')"
|
||||||
|
@create="emit('create-note', $event)"
|
||||||
|
/>
|
||||||
|
<CreateTaskListModal
|
||||||
|
v-if="showCreateTaskListModal"
|
||||||
|
:category-options="categoryOptions"
|
||||||
|
:default-category-id="selectedCategoryId"
|
||||||
|
@close="emit('close-create-task-list')"
|
||||||
|
@create="emit('create-task-list', $event)"
|
||||||
|
/>
|
||||||
|
<SpaceSettingsModal
|
||||||
|
v-if="showSpaceSettingsModal && currentSpace && canManageSpaceSettings"
|
||||||
|
:space="currentSpace"
|
||||||
|
@close="emit('close-space-settings')"
|
||||||
|
@saved="emit('saved-space', $event)"
|
||||||
|
@deleted="emit('deleted-space', $event)"
|
||||||
|
/>
|
||||||
|
<TaskDetailModal
|
||||||
|
v-if="showTaskModal"
|
||||||
|
:task="taskModalDraft || {}"
|
||||||
|
:statuses="taskStatuses"
|
||||||
|
:parent-task-options="taskParentOptions"
|
||||||
|
:subtasks="taskDetailSubtasks"
|
||||||
|
@close="emit('close-task-modal')"
|
||||||
|
@save-task="emit('save-task', $event)"
|
||||||
|
@delete-task="emit('delete-task', $event)"
|
||||||
|
@transition="emit('transition-task', $event)"
|
||||||
|
@create-subtask="emit('create-subtask', $event)"
|
||||||
|
@open-task="emit('open-task', $event)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import CreateSpaceModal from "../CreateSpaceModal.vue";
|
||||||
|
import CreateCategoryModal from "../CreateCategoryModal.vue";
|
||||||
|
import CreateNoteModal from "../CreateNoteModal.vue";
|
||||||
|
import CreateTaskListModal from "../CreateTaskListModal.vue";
|
||||||
|
import SpaceSettingsModal from "../SpaceSettingsModal.vue";
|
||||||
|
import TaskDetailModal from "../TaskDetailModal.vue";
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
showCreateSpaceModal: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
showCreateCategoryModal: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
editingCategory: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
categoryParentOptions: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
categoryModalParentId: {
|
||||||
|
type: [String, Number, null],
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
showCreateNoteModal: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
categoryOptions: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
selectedCategoryId: {
|
||||||
|
type: [String, Number, null],
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
showCreateTaskListModal: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
showSpaceSettingsModal: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
currentSpace: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
canManageSpaceSettings: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
showTaskModal: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
taskModalDraft: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
taskStatuses: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
taskParentOptions: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
taskDetailSubtasks: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
"close-create-space",
|
||||||
|
"create-space",
|
||||||
|
"close-create-category",
|
||||||
|
"submit-category",
|
||||||
|
"close-create-note",
|
||||||
|
"create-note",
|
||||||
|
"close-create-task-list",
|
||||||
|
"create-task-list",
|
||||||
|
"close-space-settings",
|
||||||
|
"saved-space",
|
||||||
|
"deleted-space",
|
||||||
|
"close-task-modal",
|
||||||
|
"save-task",
|
||||||
|
"delete-task",
|
||||||
|
"transition-task",
|
||||||
|
"create-subtask",
|
||||||
|
"open-task",
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
169
frontend/src/components/app/AppWorkspaceContent.vue
Normal file
169
frontend/src/components/app/AppWorkspaceContent.vue
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
<template>
|
||||||
|
<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="emit('select-task', $event)"
|
||||||
|
@filter-change="emit('filter-change', $event)"
|
||||||
|
@reorder-status="emit('reorder-status', $event)"
|
||||||
|
@create-status="emit('create-status', $event)"
|
||||||
|
@rename-status="emit('rename-status', $event)"
|
||||||
|
@delete-status="emit('delete-status', $event)"
|
||||||
|
@update-task-status="emit('update-task-status', $event)"
|
||||||
|
@delete-task-list="emit('delete-task-list', $event)"
|
||||||
|
/>
|
||||||
|
<SearchResultsPage
|
||||||
|
v-else-if="isSearchRoute"
|
||||||
|
:items="searchItems"
|
||||||
|
:query="searchQuery"
|
||||||
|
:current-page="searchPage"
|
||||||
|
:page-size="searchPageSize"
|
||||||
|
:view-mode="noteViewMode"
|
||||||
|
@select-note="emit('select-note', $event)"
|
||||||
|
@select-task-list="emit('select-task-list', $event)"
|
||||||
|
@page-change="emit('page-change', $event)"
|
||||||
|
/>
|
||||||
|
<NoteEditor
|
||||||
|
v-else-if="selectedNote && isEditingNote"
|
||||||
|
:note="selectedNote"
|
||||||
|
:category-options="categoryOptions"
|
||||||
|
:can-delete="canDeleteNotes"
|
||||||
|
:space-id="currentSpaceId"
|
||||||
|
@save="emit('save-note', $event)"
|
||||||
|
@delete="emit('delete-note', $event)"
|
||||||
|
@cancel="emit('cancel-edit-note')"
|
||||||
|
@open-linked-task="emit('open-linked-task', $event)"
|
||||||
|
/>
|
||||||
|
<NoteViewer
|
||||||
|
v-else-if="selectedNote"
|
||||||
|
:note="selectedNote"
|
||||||
|
:category-options="categoryOptions"
|
||||||
|
:space-id="currentSpaceId"
|
||||||
|
:linked-tasks="linkedTasksForSelectedNote"
|
||||||
|
@open-linked-task="emit('open-linked-task', $event)"
|
||||||
|
/>
|
||||||
|
<WorkspaceList
|
||||||
|
v-else
|
||||||
|
:items="displayedItems"
|
||||||
|
:can-load-more="canLoadMoreMainNotes"
|
||||||
|
:is-loading-more="isLoadingMoreMainNotes"
|
||||||
|
:view-mode="noteViewMode"
|
||||||
|
@select-note="emit('select-note', $event)"
|
||||||
|
@select-task-list="emit('select-task-list', $event)"
|
||||||
|
@load-more="emit('load-more')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import TaskBoard from "../TaskBoard.vue";
|
||||||
|
import SearchResultsPage from "../SearchResultsPage.vue";
|
||||||
|
import NoteEditor from "../NoteEditor.vue";
|
||||||
|
import NoteViewer from "../NoteViewer.vue";
|
||||||
|
import WorkspaceList from "../WorkspaceList.vue";
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
activeView: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
tasks: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
taskStatuses: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
selectedTaskList: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
canDeleteTasks: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
isSearchRoute: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
searchItems: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
searchQuery: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
searchPage: {
|
||||||
|
type: Number,
|
||||||
|
default: 1,
|
||||||
|
},
|
||||||
|
searchPageSize: {
|
||||||
|
type: Number,
|
||||||
|
default: 12,
|
||||||
|
},
|
||||||
|
noteViewMode: {
|
||||||
|
type: String,
|
||||||
|
default: "grid",
|
||||||
|
},
|
||||||
|
selectedNote: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
isEditingNote: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
categoryOptions: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
canDeleteNotes: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
currentSpaceId: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
linkedTasksForSelectedNote: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
displayedItems: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
canLoadMoreMainNotes: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
isLoadingMoreMainNotes: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
"select-task",
|
||||||
|
"filter-change",
|
||||||
|
"reorder-status",
|
||||||
|
"create-status",
|
||||||
|
"rename-status",
|
||||||
|
"delete-status",
|
||||||
|
"update-task-status",
|
||||||
|
"delete-task-list",
|
||||||
|
"select-note",
|
||||||
|
"select-task-list",
|
||||||
|
"page-change",
|
||||||
|
"save-note",
|
||||||
|
"delete-note",
|
||||||
|
"cancel-edit-note",
|
||||||
|
"open-linked-task",
|
||||||
|
"load-more",
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
@@ -6,6 +6,7 @@ import "bootstrap/dist/css/bootstrap.min.css";
|
|||||||
import "@mdi/font/css/materialdesignicons.min.css";
|
import "@mdi/font/css/materialdesignicons.min.css";
|
||||||
import "highlight.js/styles/github-dark.min.css";
|
import "highlight.js/styles/github-dark.min.css";
|
||||||
import "./assets/styles/main.css";
|
import "./assets/styles/main.css";
|
||||||
|
import "./assets/styles/shared/danger-zone.css";
|
||||||
|
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
<div class="user-row-actions">
|
<div class="user-row-actions">
|
||||||
<div class="d-flex gap-2 user-actions-stack">
|
<div class="d-flex gap-2 user-actions-stack">
|
||||||
<button class="btn btn-sm btn-outline-primary" @click="openEditUserModal(u)">Edit</button>
|
<button class="btn btn-sm btn-outline-primary" @click="openEditUserModal(u)">Edit</button>
|
||||||
<button class="btn btn-sm btn-outline-danger" @click="deleteUser(u)">Delete</button>
|
<button class="btn btn-sm btn-outline-danger" @click="requestDeleteUser(u)">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -105,7 +105,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<button class="btn btn-sm btn-outline-primary" @click="openEditGroupModal(group)">Edit</button>
|
<button class="btn btn-sm btn-outline-primary" @click="openEditGroupModal(group)">Edit</button>
|
||||||
<button class="btn btn-sm btn-outline-danger" :disabled="group.is_system" @click="deleteGroup(group)">Delete</button>
|
<button class="btn btn-sm btn-outline-danger" :disabled="group.is_system" @click="requestDeleteGroup(group)">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -286,7 +286,16 @@
|
|||||||
:deleting="deletingProviderModal"
|
:deleting="deletingProviderModal"
|
||||||
@close="closeProviderModal"
|
@close="closeProviderModal"
|
||||||
@submit="submitProviderModal"
|
@submit="submitProviderModal"
|
||||||
@delete="deleteProviderFromModal"
|
@delete="requestDeleteProvider"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmActionModal
|
||||||
|
:visible="showDeleteConfirmModal"
|
||||||
|
:title="deleteConfirmTitle"
|
||||||
|
:message="deleteConfirmMessage"
|
||||||
|
:busy="deleteConfirmBusy"
|
||||||
|
@close="closeDeleteConfirmModal"
|
||||||
|
@confirm="confirmDeleteAction"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -298,6 +307,7 @@ import AdminSpaceModal from "../components/AdminSpaceModal.vue";
|
|||||||
import AdminGroupModal from "../components/AdminGroupModal.vue";
|
import AdminGroupModal from "../components/AdminGroupModal.vue";
|
||||||
import AdminUserModal from "../components/AdminUserModal.vue";
|
import AdminUserModal from "../components/AdminUserModal.vue";
|
||||||
import AdminProviderModal from "../components/AdminProviderModal.vue";
|
import AdminProviderModal from "../components/AdminProviderModal.vue";
|
||||||
|
import ConfirmActionModal from "../components/ConfirmActionModal.vue";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const activeTab = ref("users");
|
const activeTab = ref("users");
|
||||||
@@ -344,6 +354,12 @@ const providerModalMode = ref("create");
|
|||||||
const selectedProvider = ref(null);
|
const selectedProvider = ref(null);
|
||||||
const submittingProviderModal = ref(false);
|
const submittingProviderModal = ref(false);
|
||||||
const deletingProviderModal = ref(false);
|
const deletingProviderModal = ref(false);
|
||||||
|
const showDeleteConfirmModal = ref(false);
|
||||||
|
const deleteConfirmBusy = ref(false);
|
||||||
|
const deleteConfirmIntent = ref({
|
||||||
|
type: "",
|
||||||
|
payload: null,
|
||||||
|
});
|
||||||
|
|
||||||
const loadingFeatureFlags = ref(false);
|
const loadingFeatureFlags = ref(false);
|
||||||
const savingFeatureFlags = ref(false);
|
const savingFeatureFlags = ref(false);
|
||||||
@@ -365,6 +381,47 @@ const clearMessages = () => {
|
|||||||
successMessage.value = "";
|
successMessage.value = "";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const deleteConfirmTitle = computed(() => {
|
||||||
|
if (deleteConfirmIntent.value.type === "user") {
|
||||||
|
return "Delete User";
|
||||||
|
}
|
||||||
|
if (deleteConfirmIntent.value.type === "group") {
|
||||||
|
return "Delete Group";
|
||||||
|
}
|
||||||
|
if (deleteConfirmIntent.value.type === "provider") {
|
||||||
|
return "Delete Identity Provider";
|
||||||
|
}
|
||||||
|
return "Confirm Deletion";
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteConfirmMessage = computed(() => {
|
||||||
|
if (deleteConfirmIntent.value.type === "user") {
|
||||||
|
const username = deleteConfirmIntent.value.payload?.username || "this user";
|
||||||
|
return `Delete user "${username}"? This action cannot be undone.`;
|
||||||
|
}
|
||||||
|
if (deleteConfirmIntent.value.type === "group") {
|
||||||
|
const name = deleteConfirmIntent.value.payload?.name || "this group";
|
||||||
|
return `Delete group "${name}"? This action cannot be undone.`;
|
||||||
|
}
|
||||||
|
if (deleteConfirmIntent.value.type === "provider") {
|
||||||
|
const name = deleteConfirmIntent.value.payload?.name || "this identity provider";
|
||||||
|
return `Delete identity provider "${name}"? This action cannot be undone.`;
|
||||||
|
}
|
||||||
|
return "Are you sure you want to continue?";
|
||||||
|
});
|
||||||
|
|
||||||
|
const closeDeleteConfirmModal = () => {
|
||||||
|
if (deleteConfirmBusy.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showDeleteConfirmModal.value = false;
|
||||||
|
deleteConfirmIntent.value = {
|
||||||
|
type: "",
|
||||||
|
payload: null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const formatDate = (iso) => {
|
const formatDate = (iso) => {
|
||||||
if (!iso) return "";
|
if (!iso) return "";
|
||||||
return new Date(iso).toLocaleDateString();
|
return new Date(iso).toLocaleDateString();
|
||||||
@@ -438,8 +495,20 @@ const submitUserModal = async ({ group_ids }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const requestDeleteUser = (user) => {
|
||||||
|
if (!user?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteConfirmIntent.value = {
|
||||||
|
type: "user",
|
||||||
|
payload: user,
|
||||||
|
};
|
||||||
|
showDeleteConfirmModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
const deleteUser = async (user) => {
|
const deleteUser = async (user) => {
|
||||||
if (!confirm(`Delete user "${user.username}"? This action cannot be undone.`)) {
|
if (!user?.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -530,7 +599,7 @@ const deleteGroup = async (group) => {
|
|||||||
if (group.is_system) {
|
if (group.is_system) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!confirm(`Delete group "${group.name}"? This action cannot be undone.`)) {
|
if (!group?.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -541,9 +610,22 @@ const deleteGroup = async (group) => {
|
|||||||
await Promise.all([loadGroups(), loadUsers()]);
|
await Promise.all([loadGroups(), loadUsers()]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e.response?.data || "Failed to delete group.";
|
error.value = e.response?.data || "Failed to delete group.";
|
||||||
|
throw e;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const requestDeleteGroup = (group) => {
|
||||||
|
if (!group?.id || group.is_system) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteConfirmIntent.value = {
|
||||||
|
type: "group",
|
||||||
|
payload: group,
|
||||||
|
};
|
||||||
|
showDeleteConfirmModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
const loadSpaces = async () => {
|
const loadSpaces = async () => {
|
||||||
loadingSpaces.value = true;
|
loadingSpaces.value = true;
|
||||||
clearMessages();
|
clearMessages();
|
||||||
@@ -631,12 +713,21 @@ const loadProviders = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteProviderFromModal = async (provider) => {
|
const requestDeleteProvider = (provider) => {
|
||||||
if (!provider?.id) {
|
if (!provider?.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!confirm(`Delete identity provider "${provider.name}"? This action cannot be undone.`)) {
|
closeProviderModal();
|
||||||
|
deleteConfirmIntent.value = {
|
||||||
|
type: "provider",
|
||||||
|
payload: { ...provider },
|
||||||
|
};
|
||||||
|
showDeleteConfirmModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteProviderFromModal = async (provider) => {
|
||||||
|
if (!provider?.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -649,11 +740,42 @@ const deleteProviderFromModal = async (provider) => {
|
|||||||
closeProviderModal();
|
closeProviderModal();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e.response?.data || "Failed to delete provider.";
|
error.value = e.response?.data || "Failed to delete provider.";
|
||||||
|
throw e;
|
||||||
} finally {
|
} finally {
|
||||||
deletingProviderModal.value = false;
|
deletingProviderModal.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const confirmDeleteAction = async () => {
|
||||||
|
if (deleteConfirmBusy.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { type, payload } = deleteConfirmIntent.value;
|
||||||
|
if (!type || !payload) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteConfirmBusy.value = true;
|
||||||
|
try {
|
||||||
|
if (type === "user") {
|
||||||
|
await deleteUser(payload);
|
||||||
|
} else if (type === "group") {
|
||||||
|
await deleteGroup(payload);
|
||||||
|
} else if (type === "provider") {
|
||||||
|
await deleteProviderFromModal(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
showDeleteConfirmModal.value = false;
|
||||||
|
deleteConfirmIntent.value = {
|
||||||
|
type: "",
|
||||||
|
payload: null,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
deleteConfirmBusy.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const loadFeatureFlags = async () => {
|
const loadFeatureFlags = async () => {
|
||||||
loadingFeatureFlags.value = true;
|
loadingFeatureFlags.value = true;
|
||||||
clearMessages();
|
clearMessages();
|
||||||
|
|||||||
1881
frontend/src/pages/Dashboard.vue
Normal file
1881
frontend/src/pages/Dashboard.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,7 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="home-page">
|
|
||||||
<router-view />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup></script>
|
|
||||||
@@ -18,13 +18,25 @@ const routes = [
|
|||||||
{
|
{
|
||||||
path: "/",
|
path: "/",
|
||||||
name: "Home",
|
name: "Home",
|
||||||
component: () => import("../pages/Home.vue"),
|
component: () => import("../pages/Dashboard.vue"),
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/dashboard/s/:spaceId/n/:noteId?",
|
||||||
|
name: "DashboardNote",
|
||||||
|
component: () => import("../pages/Dashboard.vue"),
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/dashboard/s/:spaceId/t/:taskListId",
|
||||||
|
name: "DashboardTaskList",
|
||||||
|
component: () => import("../pages/Dashboard.vue"),
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/search",
|
path: "/search",
|
||||||
name: "Search",
|
name: "Search",
|
||||||
component: () => import("../pages/Home.vue"),
|
component: () => import("../pages/Dashboard.vue"),
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user