Files
notely/frontend/src/components/AdminSpaceModal.vue
T
domrichardson 74d8899eec
Build and Push App Image / build-and-push (push) Successful in 34s
feat: Updates to dashboard and delete confirmations
2026-04-01 13:40:18 +01:00

343 lines
12 KiB
Vue

<template>
<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-dialog modal-xl modal-dialog-centered modal-dialog-scrollable" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Edit Space</h5>
<button type="button" class="btn-close" aria-label="Close" @click="emit('close')"></button>
</div>
<div class="modal-body">
<div class="row g-3 mb-4">
<div class="col-md-5">
<label class="form-label">Name</label>
<input v-model="form.name" type="text" class="form-control" />
</div>
<div class="col-md-5">
<label class="form-label">Description</label>
<input v-model="form.description" type="text" class="form-control" />
</div>
<div class="col-md-2">
<label class="form-label">Icon</label>
<input v-model="form.icon" type="text" class="form-control" maxlength="20" />
</div>
<div class="col-12 d-flex justify-content-between align-items-center">
<div class="form-check form-switch">
<input id="admin-space-public" v-model="form.is_public" class="form-check-input" type="checkbox" />
<label for="admin-space-public" class="form-check-label">Public space</label>
</div>
<button class="btn btn-primary" :disabled="savingSpace" @click="saveSpace">
{{ savingSpace ? "Saving..." : "Save Space" }}
</button>
</div>
</div>
<hr />
<div class="d-flex justify-content-between align-items-center mt-3 mb-2">
<h6 class="mb-0">Members</h6>
<button class="btn btn-sm btn-outline-secondary" :disabled="loadingMembers" @click="loadMembers">Refresh</button>
</div>
<form class="row g-2 align-items-end mb-3" @submit.prevent="addMember">
<div class="col-md-10">
<label class="form-label form-label-sm mb-1">Username</label>
<select v-model="newMember.user_id" class="form-select form-select-sm" required>
<option disabled value="">Select user</option>
<option v-for="u in selectableUsers" :key="u.id" :value="u.id">{{ u.username }}</option>
</select>
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-primary btn-sm w-100" :disabled="addingMember">
{{ addingMember ? "..." : "Add" }}
</button>
</div>
</form>
<div v-if="loadingMembers" class="text-muted small">Loading members...</div>
<div v-else-if="members.length === 0" class="text-muted small">No members found.</div>
<div v-else class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead>
<tr>
<th>Username</th>
<th>Joined</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="m in members" :key="m.user_id">
<td>{{ m.username || m.user_id }}</td>
<td class="small text-muted">{{ formatDate(m.joined_at) }}</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-danger" :disabled="removingMemberId === m.user_id" @click="removeMember(m)">
{{ removingMemberId === m.user_id ? "Removing..." : "Remove" }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="error" class="alert alert-danger mt-3 mb-0">{{ error }}</div>
<div v-if="success" class="alert alert-success mt-3 mb-0">{{ success }}</div>
<hr />
<DangerZonePanel
class="mt-4"
title-id="danger-zone-title"
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" }}
</button>
</DangerZonePanel>
</div>
</div>
</div>
</div>
<div class="modal-backdrop fade show admin-modal-backdrop"></div>
</teleport>
<ConfirmActionModal
:visible="showDeleteConfirmModal"
:title="deleteConfirmTitle"
:message="deleteConfirmMessage"
:busy="deleteConfirmBusy"
@close="closeDeleteConfirmModal"
@confirm="confirmDeleteAction"
/>
</template>
<script setup>
import { computed, ref, watch } from "vue";
import apiClient from "../services/apiClient";
import ConfirmActionModal from "./ConfirmActionModal.vue";
import DangerZonePanel from "./DangerZonePanel.vue";
const props = defineProps({
space: {
type: Object,
required: true,
},
users: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(["close", "saved", "deleted"]);
const form = ref({
name: "",
description: "",
icon: "",
is_public: false,
});
const members = ref([]);
const loadingMembers = ref(false);
const savingSpace = ref(false);
const addingMember = ref(false);
const removingMemberId = ref("");
const error = ref("");
const success = ref("");
const newMember = ref({ user_id: "" });
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 clearMessages = () => {
error.value = "";
success.value = "";
};
const resetFormFromSpace = () => {
form.value = {
name: props.space?.name || "",
description: props.space?.description || "",
icon: props.space?.icon || "",
is_public: !!props.space?.is_public,
};
};
const selectableUsers = computed(() => {
const memberIds = new Set(members.value.map((m) => m.user_id));
return (props.users || []).filter((u) => !memberIds.has(u.id));
});
const loadMembers = async () => {
loadingMembers.value = true;
clearMessages();
try {
const response = await apiClient.get(`/api/v1/admin/spaces/${props.space.id}/members`);
members.value = response.data.members || [];
} catch (e) {
error.value = e.response?.data || "Failed to load members.";
} finally {
loadingMembers.value = false;
}
};
const saveSpace = async () => {
savingSpace.value = true;
clearMessages();
try {
const response = await apiClient.put(`/api/v1/admin/spaces/${props.space.id}`, {
name: form.value.name,
description: form.value.description,
icon: form.value.icon,
is_public: form.value.is_public,
});
success.value = "Space updated.";
emit("saved", response.data);
} catch (e) {
error.value = e.response?.data || "Failed to update space.";
} finally {
savingSpace.value = false;
}
};
const addMember = async () => {
if (!newMember.value.user_id) {
return;
}
addingMember.value = true;
clearMessages();
try {
await apiClient.post(`/api/v1/admin/spaces/${props.space.id}/members`, {
user_id: newMember.value.user_id,
});
success.value = "Member added.";
newMember.value = { user_id: "" };
await loadMembers();
} catch (e) {
error.value = e.response?.data || "Failed to add member.";
} finally {
addingMember.value = false;
}
};
const removeMember = (member) => {
if (!member?.user_id) {
return;
}
deleteConfirmIntent.value = {
type: "member",
payload: member,
};
showDeleteConfirmModal.value = true;
};
const removeMemberConfirmed = async (member) => {
if (!member?.user_id) {
return;
}
removingMemberId.value = member.user_id;
clearMessages();
try {
await apiClient.delete(`/api/v1/admin/spaces/${props.space.id}/members/${member.user_id}`);
success.value = "Member removed.";
await loadMembers();
} catch (e) {
error.value = e.response?.data || "Failed to remove member.";
} finally {
removingMemberId.value = "";
}
};
watch(
() => props.space,
async () => {
resetFormFromSpace();
await loadMembers();
},
{ immediate: true },
);
const requestDeleteSpace = () => {
deleteConfirmIntent.value = {
type: "space",
payload: props.space,
};
showDeleteConfirmModal.value = true;
};
const deleteSpaceConfirmed = async () => {
deleting.value = true;
clearMessages();
try {
await apiClient.delete(`/api/v1/admin/spaces/${props.space.id}`);
emit("deleted", props.space);
} catch (e) {
error.value = e.response?.data || "Failed to delete space.";
throw e;
} finally {
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>
<style scoped src="../assets/styles/scoped/components/AdminSpaceModal.css"></style>