Files
luminic-front/src/components/spaces/SpacesList.vue

343 lines
12 KiB
Vue

<script setup lang="ts">
import {computed, onMounted, ref, watch, reactive, onUnmounted} from 'vue';
import {Space, SpaceInvite} from "@/models/Space";
import {
createSpaceInvite,
deleteSpaceRequest,
getSpaces,
kickMemberFromSpaceRequest,
leaveSpaceRequest
} from "@/services/spaceService";
import LoadingView from "@/components/LoadingView.vue";
import {formatDate, formatDateTime} from "../../utils/utils";
import Button from "primevue/button";
import Toast from "primevue/toast";
import Dialog from "primevue/dialog";
import InputText from "primevue/inputtext";
import SpaceCreationDialog from "@/components/spaces/SpaceCreationDialog.vue";
import {useToast} from "primevue/usetoast";
import {deleteBudgetRequest, getBudgetInfos} from "@/services/budgetsService";
import ConfirmDialog from "primevue/confirmdialog";
import {useConfirm} from "primevue/useconfirm";
import {useSpaceStore} from "@/stores/spaceStore";
import {EventBus} from "@/utils/EventBus";
import {useUserStore} from "@/stores/userStore";
import {User} from "@/models/User";
const toast = useToast()
const confirm = useConfirm();
const loading = ref(true);
const creationOpened = ref(false);
const spaceCreated = async () => {
creationOpened.value = false;
await spaceStore.fetchSpaces();
}
const invite = ref<SpaceInvite | null>(null);
const inviteUrl = computed(() => {
if (invite.value) {
return 'https://luminic.space/spaces/invite/' + invite.value.code;
}
})
const inviteCreatedDialog = ref<boolean | null>(null);
const createInvite = async (space: Space) => {
confirm.require({
message: `Вы действительно хотите создать приглашение в пространство ${space.name}?`,
header: 'Создание приграшения',
icon: 'pi pi-info-circle',
rejectLabel: 'Отмена',
rejectProps: {
label: 'Отмена',
severity: 'secondary',
outlined: true
},
acceptProps: {
label: 'Создать',
severity: 'success'
},
accept: async () => {
try {
await createSpaceInvite().then(async (res) => {
await spaceStore.fetchSpaces();
invite.value = res;
inviteCreatedDialog.value = true;
})
} catch (error) {
toast.add({
severity: 'error',
summary: 'Ошибка!',
detail: error.response?.data?.message || 'Ошибка при создании приглашения',
life: 3000
});
}
},
reject: () => {
toast.add({severity: 'info', summary: 'Отменено', detail: 'Вы отменили создание приглашения', life: 3000});
}
});
}
const leaveSpace = async (space: Space) => {
confirm.require({
message: `Вы действительно хотите выйти из пространства ${space.name}?`,
header: 'Выход из пространства',
icon: 'pi pi-info-circle',
rejectLabel: 'Отмена',
rejectProps: {
label: 'Отмена',
severity: 'secondary',
outlined: true
},
acceptProps: {
label: 'Выйти',
severity: 'danger'
},
accept: async () => {
try {
await leaveSpaceRequest(space.id).then((res) => {
spaceStore.fetchSpaces();
})
} catch (error) {
toast.add({
severity: 'error',
summary: 'Ошибка!',
detail: error.response?.data?.message || 'Ошибка при выходе из пространства',
life: 3000
});
}
},
reject: () => {
toast.add({severity: 'info', summary: 'Отменено', detail: 'Вы отменили выход', life: 3000});
}
});
}
const kickMember = async (space: Space, user: User) => {
confirm.require({
message: `Вы действительно хотите исключить пользователя ${user.firstName} из пространства ${space.name}?`,
header: 'Исключить из пространства',
icon: 'pi pi-info-circle',
rejectLabel: 'Отмена',
rejectProps: {
label: 'Отмена',
severity: 'secondary',
outlined: true
},
acceptProps: {
label: 'Исключить',
severity: 'danger'
},
accept: async () => {
try {
await kickMemberFromSpaceRequest(space.id, user.username).then((res) => {
spaceStore.fetchSpaces();
})
} catch (error) {
toast.add({
severity: 'error',
summary: 'Ошибка!',
detail: error.response?.data?.message || 'Ошибка при исключении из пространства',
life: 3000
});
}
},
reject: () => {
toast.add({severity: 'info', summary: 'Отменено', detail: 'Вы отменили исключение', life: 3000});
}
});
}
const deleteSpace = async (space: Space) => {
confirm.require({
message: `Вы действительно хотите удалить пространство ${space.name}? Будут удалены все бюджеты!`,
header: 'Удаление пространства',
icon: 'pi pi-info-circle',
rejectLabel: 'Отмена',
rejectProps: {
label: 'Отмена',
severity: 'secondary',
outlined: true
},
acceptProps: {
label: 'Удалить',
severity: 'danger'
},
accept: async () => {
try {
await deleteSpaceRequest(space.id).then((res) => {
spaceStore.fetchSpaces();
})
} catch (error: Error) {
toast.add({
severity: 'error',
summary: 'Ошибка!',
detail: error.response?.data?.message || 'Ошибка при удалении пространства',
life: 3000
});
}
},
reject: () => {
toast.add({severity: 'info', summary: 'Отменено', detail: 'Вы отменили удаление', life: 3000});
}
});
}
const copied = ref(false);
const copyToClipboard = async (text) => {
try {
await navigator.clipboard.writeText(text);
copied.value = true;
// setTimeout(() => copied.value = false, 1500); // Убираем сообщение через 1.5 сек
} catch (err) {
console.error('Ошибка копирования:', err);
}
};
const spaces = computed(() => spaceStore.spaces)
const spaceStore = useSpaceStore()
const selectedSpace = computed(() => spaceStore.space)
const selectSpace = (space: Space) => {
spaceStore.setSpace(space);
localStorage.setItem("spaceId", space.id);
}
const loadingValue = computed(() => {
return !spaces.value
})
onMounted(async () => {
})
</script>
<template>
<LoadingView v-if="loadingValue"/>
<div v-else class="p-4 bg-gray-100 grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-4 ">
<Toast/>
<ConfirmDialog/>
<Dialog :visible="inviteCreatedDialog" header="Приглашение" @hide="inviteCreatedDialog=false"
@update:visible="inviteCreatedDialog=false">
<div class="flex flex-col gap-4 justify-start">
<div class="flex flex-row gap-2 items-center"> Ссылка приглашения:
<input class="p-2 border w-5/6" v-model="inviteUrl" disabled></input>
<button @click="copyToClipboard('https://luminic.space/spaces/invite/' + invite.code)">
{{ !copied ? 'Копировать' : 'Скопировано!' }}
</button>
</div>
<p>Действует до {{ formatDateTime(invite.activeTill) }} и только на 1 использование.</p>
</div>
</Dialog>
<div v-for="space in spaces" class="w-full ">
<div
class="bg-white p-4 shadow-lg rounded-lg flex flex-col gap-2 items-start justify-between h-[200px] ">
<div class="flex flex-col w-full h-full justify-between">
<div class="flex flex-row justify-between"><p class="text-xl font-bold line-clamp-1">{{ space.name }}</p>
<button @click="selectSpace(space)">
<div class="flex p-1 rounded border"
:class="selectedSpace? selectedSpace.id === space.id ? 'bg-green-100' : 'bg-gray-100':'bg-gray-100'"
@click="spaceStore.setSpace(space)">
<span v-if="selectedSpace? space.id === selectedSpace.id : false"
class="font-bold text-gray-500 items-center"><i
class="pi pi-check"/> Выбрано</span>
<span v-else class="">Выбрать</span>
</div>
</button>
</div>
<p class="line-clamp-2 items-start flex ">{{ space.description }}</p>
<div class="flex flex-row gap-2">
<div v-for="user in space.users"
:key="user.id"
@mouseover="user.isHovered = true"
@mouseleave="user.isHovered = false">
<div
class="relative flex bg-emerald-300 rounded-full w-10 h-10 items-center justify-center"
>
<!-- Первая буква имени -->
<span class="text-white text-center">
{{ user.firstName.substring(0, 1) }}
</span>
<!-- Иконка короны для владельца -->
<i
v-if="space.owner.id === user.id"
class="pi pi-crown absolute -top-1 -right-1 text-yellow-400 z-10 bg-white rounded-full p-[0.2rem] border"
style="font-size: 0.8rem"
></i>
<!-- Всплывающее окно -->
<div
v-if="user.isHovered"
class="absolute top-10 left-20 transform -translate-x-1/2 bg-white shadow-lg rounded-lg p-2 w-40 z-50 border"
>
<p class="text-sm font-semibold text-gray-800">{{ user.firstName }} {{ user.lastName }}</p>
<p class="text-xs text-gray-500">Роль: {{ user.role || 'Пользователь' }}</p>
<!-- Кнопка удаления (только если это не текущий пользователь) -->
<button
v-if="user.id !== useUserStore().user.id"
@click="kickMember(space, user)"
class="mt-2 bg-red-500 text-white text-xs rounded p-1 w-full hover:bg-red-600 transition"
>
Удалить
</button>
</div>
</div>
</div>
<div v-if="space.owner.id == useUserStore().user.id"
class="flex bg-gray-300 hover:bg-emerald-300 rounded-full w-10 h-10 items-center justify-center relative"
>
<i class="text-white text-center pi pi-plus" @click="createInvite(space)"></i>
</div>
</div>
<div class="flex flex-row justify-between items-center w-full">
<p class="text-sm text-gray-600">Создано: {{ formatDate(space.createdAt) }}</p>
<button v-if="space.owner.id == useUserStore().user.id" @click="deleteSpace(space)"
class="flex justify-end"><i class="pi pi-trash"
style="color:red; font-size: 1.2rem"/>
</button>
<button v-else class="flex items-center gap-2 text-sm" @click="leaveSpace(space)">Выйти<i
class="pi pi-sign-out" style="font-size: 0.7rem"/></button>
</div>
</div>
</div>
</div>
<div class="w-full h-full">
<div class="bg-white p-4 shadow-lg rounded-lg flex flex-col gap-2 justify-center items-center h-[200px]">
<button class="flex-col" @click="creationOpened = !creationOpened">
<i class="pi pi-plus-circle text-emerald-300" style="font-size: 2.5rem"></i>
<p class="text-emerald-600">Создать пространство</p>
</button>
<SpaceCreationDialog :opened="creationOpened" @spaceCreated="spaceCreated"
@close-modal="creationOpened = false"/>
</div>
</div>
</div>
</template>
<style scoped>
</style>