Merge pull request #5156 from christianbeeznest/ofaj-21101-6

Social: Add social group UI enhancements and API integration - refs BT#21101
pull/5168/head
christianbeeznest 2 years ago committed by GitHub
commit 3905a6da0f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 511
      assets/css/scss/_social.scss
  2. 18
      assets/vue/components/message/MessageLayout.vue
  3. 18
      assets/vue/components/personalfile/Layout.vue
  4. 38
      assets/vue/components/social/GroupInfoCard.vue
  5. 8
      assets/vue/components/social/MyGroupsCard.vue
  6. 60
      assets/vue/components/social/SocialGroupMenu.vue
  7. 4
      assets/vue/components/social/SocialSideMenu.vue
  8. 7
      assets/vue/components/social/SocialWallComment.vue
  9. 69
      assets/vue/components/social/SocialWallPost.vue
  10. 18
      assets/vue/components/user/Layout.vue
  11. 55
      assets/vue/components/usergroup/GroupDiscussions.vue
  12. 63
      assets/vue/components/usergroup/GroupMembers.vue
  13. 35
      assets/vue/components/usergroup/Layout.vue
  14. 27
      assets/vue/components/userreluser/Layout.vue
  15. 80
      assets/vue/composables/useSocialInfo.js
  16. 18
      assets/vue/router/usergroup.js
  17. 19
      assets/vue/views/account/Home.vue
  18. 27
      assets/vue/views/social/SocialLayout.vue
  19. 112
      assets/vue/views/usergroup/Invite.vue
  20. 805
      assets/vue/views/usergroup/List.vue
  21. 69
      assets/vue/views/usergroup/Search.vue
  22. 286
      assets/vue/views/usergroup/Show.vue
  23. 9
      config/services.yaml
  24. 2
      public/main/inc/lib/message.lib.php
  25. 10
      public/main/inc/lib/notification.lib.php
  26. 9
      public/main/social/group_topics.php
  27. 2
      public/main/social/message_for_group_form.inc.php
  28. 62
      src/CoreBundle/Controller/SocialController.php
  29. 39
      src/CoreBundle/DataProvider/GroupMembersDataProvider.php
  30. 36
      src/CoreBundle/DataProvider/MessageByGroupDataProvider.php
  31. 117
      src/CoreBundle/DataProvider/UsergroupDataProvider.php
  32. 14
      src/CoreBundle/Entity/Message.php
  33. 110
      src/CoreBundle/Entity/Usergroup.php
  34. 12
      src/CoreBundle/Repository/MessageRepository.php
  35. 31
      src/CoreBundle/Repository/Node/UserRepository.php
  36. 147
      src/CoreBundle/Repository/Node/UsergroupRepository.php
  37. 59
      src/CoreBundle/State/UsergroupPostProcessor.php

@ -234,3 +234,514 @@
box-shadow: 0 2px 5px 0 rgba(0,0,0,0.24), 0 2px 10px 0 rgba(0,0,0,0.19);
}
}
.social-groups {
.search-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.search-term-input {
flex: 1;
}
.large-icon {
font-size: 3rem;
}
.search-results {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
}
.group-card {
border: 1px solid #dee2e6;
border-radius: 0.25rem;
overflow: hidden;
transition: box-shadow 0.3s;
}
.group-card:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.group-image {
background-color: #f8f9fa;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.group-details {
padding: 1rem;
}
a.group-title {
font-size: 1rem !important;
margin-bottom: 0.5rem !important;
color: #0a66c2 !important;
}
.group-description {
font-size: 1rem;
color: #6c757d;
}
.p-button-text {
display: block;
margin-top: 1rem;
text-align: center;
color: var(--primary-color, #007bff);
font-weight: bold;
}
.social-groups-container .header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.social-groups-container .create-group-button {
background-color: #5c6bc0;
color: white;
padding: 0.5rem 1rem;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-size: 1rem;
transition: background-color 0.3s;
}
.social-groups-container .create-group-button:hover {
background-color: #3949ab;
}
.social-group-tabs .p-tabview-nav {
margin-top: 1rem;
border: none;
}
.social-group-tabs .p-tabview-nav .p-tabview-selected {
background: white;
border-color: #e0e0e0;
border-bottom: 2px solid #3949ab;
}
.social-group-tabs .p-tabview-nav .p-tabview-selected a {
font-weight: bold;
}
.group-list .group-item {
display: flex;
align-items: center;
padding: 1rem;
border-bottom: 1px solid #e0e0e0;
}
.group-list .group-item .mdi {
font-size: 32px;
margin-right: 1rem;
}
.group-list .group-item .group-details {
display: flex;
flex-direction: column;
}
.group-list .group-item .group-details .group-title {
font-size: 1.25rem;
font-weight: bold;
}
.group-list .group-item .group-details .group-info {
display: flex;
align-items: center;
font-size: 0.875rem;
color: #666;
}
.group-list .group-item .group-details .group-info .group-member-count {
margin-right: 1rem;
}
.social-group-tabs .p-tabview .p-tabview-nav {
border: none;
margin-bottom: 0;
}
.social-group-tabs .p-tabview-nav .p-tabview-selected .tab-header {
font-weight: bold;
border-bottom: 3px solid #1976D2;
}
.social-group-tabs .p-tabview-nav .p-tabview-selected {
background: none;
}
.tab-header {
cursor: pointer;
padding: 0.75rem 1rem;
border-bottom: 3px solid transparent;
transition: border-color 0.3s;
}
.tab-header:hover {
border-bottom: 3px solid #64B5F6;
}
.active-tab:hover {
border-bottom: 3px solid #1976D2;
}
}
.social-group-show {
.group-header {
text-align: center;
margin-bottom: 20px;
}
.group-title {
font-size: 2em;
margin: 0;
}
.group-description {
color: #666;
}
.discussions-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.discussion-list {
list-style-type: none;
padding: 0;
}
.discussion-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid #ccc;
}
.discussion-content {
display: flex;
flex-direction: column;
}
.discussion-title {
font-weight: bold;
margin-bottom: 5px;
}
.discussion-details {
display: flex;
align-items: center;
font-size: 0.8rem;
}
.discussion-details .icon {
margin-right: 5px;
}
.discussion-author {
display: flex;
align-items: center;
}
.author-avatar-icon {
font-size: 50px;
margin-right: 10px;
}
.author-name {
font-size: 0.9rem;
}
.author-avatar {
width: 30px;
height: 30px;
border-radius: 50%;
margin-right: 10px;
}
.author-avatar-icon {
font-size: 30px;
margin-right: 10px;
}
.author-name {
font-size: 0.9em;
}
.discussion-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
}
.discussion-author {
display: flex;
align-items: center;
}
.author-name {
margin-right: 10px;
}
.author-avatar {
width: 30px;
height: 30px;
border-radius: 50%;
}
.author-avatar-icon {
font-size: 30px;
}
.discussions-container {
border-top: 1px solid #ccc;
}
.discussion-info {
font-size: 0.9em;
color: #666;
}
.mdi {
font-size: 18px;
vertical-align: middle;
margin-right: 5px;
}
.members-container {
border-top: 1px solid #ccc;
}
.group-members {
margin: 20px;
}
.edit-members {
text-align: right;
margin-bottom: 20px;
}
.edit-members-btn {
padding: 10px 20px;
cursor: pointer;
}
.members-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 20px;
}
.member-card {
border: 1px solid #ccc;
padding: 10px;
text-align: center;
}
.member-avatar {
margin-bottom: 10px;
}
.member-avatar img {
width: 100px;
height: 100px;
border-radius: 50%;
}
.member-avatar i {
font-size: 100px;
}
.member-name {
font-weight: bold;
}
.member-role {
color: #666;
}
.member-item {
padding: 16px;
border-bottom: 1px solid #ccc;
display: flex;
align-items: center;
}
.member-avatar {
width: 50px;
height: 50px;
border-radius: 50%;
margin-right: 16px;
}
.member-name {
font-size: 1.2em;
color: #333;
}
.member-role {
font-size: 0.9em;
color: #666;
}
.tabs {
list-style-type: none;
padding: 0;
display: flex;
border-bottom: 1px solid #ccc;
}
.tabs li {
padding: 10px 20px;
cursor: pointer;
border-top: 3px solid transparent;
}
.tabs li.active {
border-top-color: #007bff;
background-color: #f8f9fa;
}
.tab-content {
padding: 20px;
border: 1px solid #ccc;
border-top: none;
}
}
.invite-friends {
.invite-friends-container {
max-width: 600px;
margin: auto;
}
.invite-friends-header {
text-align: center;
margin-bottom: 20px;
}
.invite-friends-body {
display: flex;
justify-content: space-between;
margin-bottom: 20px;
}
.friends-list, .selected-friends-list {
width: 48%;
}
.list-header {
background-color: #f5f5f5;
padding: 10px;
border-radius: 5px;
}
.list-content {
border: 1px solid #ddd;
border-radius: 5px;
padding: 10px;
height: 300px;
overflow-y: auto;
}
.friend-entry {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.friend-avatar {
border-radius: 50%;
width: 30px;
height: 30px;
margin-right: 10px;
}
.friend-info {
display: flex;
align-items: center;
}
.invite-btn, .remove-btn {
border: none;
background-color: #5cb85c;
color: white;
padding: 5px 10px;
border-radius: 5px;
cursor: pointer;
}
.remove-btn {
background-color: #d9534f;
}
.send-invites-btn {
width: 100%;
padding: 10px 20px;
background-color: #0275d8;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
}
.send-invites-btn:hover {
background-color: #025aa5;
}
.invited-users-container {
margin-top: 20px;
}
.invited-users-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 10px;
}
.user-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 10px;
border: 1px solid #ddd;
border-radius: 5px;
background-color: #f9f9f9;
}
.user-avatar {
border-radius: 50%;
width: 50px;
height: 50px;
margin-bottom: 10px;
}
.user-name {
text-align: center;
}
}
.admin-icon {
color: gold;
margin-left: 5px;
vertical-align: middle;
}

@ -15,23 +15,17 @@ import SocialSideMenu from "../social/SocialSideMenu.vue"
import { useStore } from "vuex"
import { useRoute } from "vue-router"
import { onMounted, provide, readonly, ref, watch } from "vue"
import { useSocialInfo } from "../../composables/useSocialInfo"
const store = useStore()
const route = useRoute()
const user = ref({})
const { user, isCurrentUser, groupInfo, isGroup, loadUser } = useSocialInfo()
provide("social-user", readonly(user))
async function loadUser() {
try {
user.value = route.query.id ? await store.dispatch("user/load", '/api/users/' + route.query.id) : store.getters["security/getUser"]
} catch (e) {
user.value = {}
}
}
provide("social-user", user)
provide("is-current-user", isCurrentUser)
provide("group-info", groupInfo)
provide("is-group", isGroup)
onMounted(loadUser)
watch(() => route.query, loadUser)
</script>

@ -17,23 +17,17 @@ import SocialSideMenu from "../social/SocialSideMenu.vue"
import { useStore } from "vuex"
import { useRoute } from "vue-router"
import { onMounted, provide, readonly, ref, watch } from "vue"
import { useSocialInfo } from "../../composables/useSocialInfo"
const store = useStore()
const route = useRoute()
const user = ref({})
const { user, isCurrentUser, groupInfo, isGroup, loadUser } = useSocialInfo()
provide("social-user", readonly(user))
async function loadUser() {
try {
user.value = route.query.id ? await store.dispatch("user/load", '/api/users/' + route.query.id) : store.getters["security/getUser"]
} catch (e) {
user.value = {}
}
}
provide("social-user", user)
provide("is-current-user", isCurrentUser)
provide("group-info", groupInfo)
provide("is-group", isGroup)
onMounted(loadUser)
watch(() => route.query, loadUser)
</script>

@ -0,0 +1,38 @@
<template>
<BaseCard plain>
<div class="p-4 text-center">
<img
:src="groupInfo.image"
class="mb-4 w-24 h-24 mx-auto rounded-full"
alt="Group picture"
/>
<hr />
<BaseButton
:label="t('Edit this group')"
type="primary"
class="mt-4"
@click="editGroup"
icon="edit"
/>
</div>
</BaseCard>
</template>
<script setup>
import { computed, inject, onMounted, ref, watch } from "vue"
import { useStore } from 'vuex'
import BaseCard from "../basecomponents/BaseCard.vue"
import BaseButton from "../basecomponents/BaseButton.vue"
import { useI18n } from "vue-i18n"
import { useRoute } from "vue-router"
const { t } = useI18n()
const store = useStore()
const route = useRoute()
const groupInfo = inject('group-info')
const isGroup = inject('is-group')
const editGroup = () => {
window.location = "/account/edit"
}
</script>

@ -20,8 +20,8 @@
<div v-if="isValidGlobalForumsCourse" class="text-center mb-3">
<a :href="goToUrl" class="btn btn-primary">{{ t('See all communities') }}</a>
</div>
<div v-else class="input-group mb-3">
<div v-if="isCurrentUser">
<div v-else >
<div v-if="isCurrentUser" class="input-group mb-3">
<input
type="search"
class="form-control"
@ -47,6 +47,7 @@ import { useI18n } from "vue-i18n"
import { ref, inject, watchEffect, computed } from "vue"
import axios from 'axios'
import { usePlatformConfig } from "../../store/platformConfig"
import { useRouter } from "vue-router"
const { t } = useI18n()
const searchQuery = ref('')
@ -61,8 +62,9 @@ const isValidGlobalForumsCourse = computed(() => {
return courseId !== null && courseId !== undefined && courseId > 0
})
const router = useRouter()
function search() {
window.location.href = `/search?query=${searchQuery.value}`
router.push({ name: 'UserGroupSearch', query: { q: searchQuery.value } })
}
async function fetchGroups(userId) {

@ -0,0 +1,60 @@
<template>
<BaseCard class="social-side-menu mt-4">
<template #header>
<div class="px-4 py-2 -mb-2 bg-gray-15">
<h2 class="text-h5">{{ t('Social Group') }}</h2>
</div>
</template>
<hr class="-mt-2 mb-4 -mx-4">
<ul class="menu-list">
<li class="menu-item">
<router-link to="/social">
<i class="mdi mdi-home" aria-hidden="true"></i>
{{ t("Home") }}
</router-link>
</li>
<li class="menu-item">
<router-link :to="{ name: '', params: { group_id: groupInfo.id } }">
<i class="mdi mdi-account-multiple-outline" aria-hidden="true"></i>
{{ t("Waiting list") }}
</router-link>
</li>
<li class="menu-item">
<router-link :to="{ name: 'UserGroupInvite', params: { group_id: groupInfo.id } }">
<i class="mdi mdi-account-plus" aria-hidden="true"></i>
{{ t("Invite friends") }}
</router-link>
</li>
<li class="menu-item">
<router-link :to="{ name: '', params: { group_id: groupInfo.id } }">
<i class="mdi mdi-exit-to-app" aria-hidden="true"></i>
{{ t("Leave group") }}
</router-link>
</li>
</ul>
</BaseCard>
</template>
<script setup>
import BaseCard from "../basecomponents/BaseCard.vue"
import { useRoute } from 'vue-router'
import { useI18n } from "vue-i18n"
import { onMounted, computed, ref, inject, watchEffect } from "vue"
import { useStore } from "vuex"
import { useSecurityStore } from "../../store/securityStore"
const { t } = useI18n()
const route = useRoute()
const store = useStore()
const securityStore = useSecurityStore()
const groupInfo = inject('group-info')
const isGroup = inject('is-group')
const isActive = (path, filterType = null) => {
const pathMatch = route.path.startsWith(path)
const hasQueryParams = Object.keys(route.query).length > 0
const filterMatch = filterType ? (route.query.filterType === filterType && hasQueryParams) : !hasQueryParams
return pathMatch && filterMatch
}
</script>

@ -113,11 +113,11 @@ const getGroupLink = async () => {
if (isValidGlobalForumsCourse.value) {
groupLink.value = response.data.go_to
} else {
groupLink.value = { name: 'UserGroupShow' }
groupLink.value = { name: 'UserGroupList' }
}
} catch (error) {
console.error('Error fetching forum link:', error)
groupLink.value = { name: 'UserGroupShow' }
groupLink.value = { name: 'UserGroupList' }
}
}

@ -46,12 +46,11 @@ const props = defineProps({
const emit = defineEmits(['comment-deleted'])
const store = useStore();
const currentUser = store.getters['security/getUser'];
const store = useStore()
const currentUser = store.getters['security/getUser']
const isOwner = computed(() => currentUser['@id'] === props.comment.sender['@id'])
function onCommentDeleted(event) {
emit('comment-deleted', event);
emit('comment-deleted', event)
}
</script>

@ -79,15 +79,15 @@
</template>
<script setup>
import WallCommentForm from "./SocialWallCommentForm.vue";
import WallCommentForm from "./SocialWallCommentForm.vue"
import { ref, computed, onMounted, reactive, inject } from "vue"
import WallComment from "./SocialWallComment.vue";
import WallActions from "./Actions";
import axios from "axios";
import {ENTRYPOINT} from "../../config/entrypoint";
import {useStore} from "vuex";
import BaseCard from "../basecomponents/BaseCard.vue";
import {SOCIAL_TYPE_PROMOTED_MESSAGE} from "./constants";
import WallComment from "./SocialWallComment.vue"
import WallActions from "./Actions"
import axios from "axios"
import {ENTRYPOINT} from "../../config/entrypoint"
import {useStore} from "vuex"
import BaseCard from "../basecomponents/BaseCard.vue"
import {SOCIAL_TYPE_PROMOTED_MESSAGE} from "./constants"
import { useFormatDate } from "../../composables/formatDate"
const props = defineProps({
@ -95,16 +95,14 @@ const props = defineProps({
type: Object,
required: true
}
});
const emit = defineEmits(["post-deleted"]);
const store = useStore();
})
const emit = defineEmits(["post-deleted"])
const store = useStore()
import { useSecurityStore } from "../../store/securityStore"
const { relativeDatetime } = useFormatDate()
let comments = reactive([]);
const attachments = ref([]);
let comments = reactive([])
const attachments = ref([])
const securityStore = useSecurityStore()
const currentUser = inject('social-user')
@ -112,15 +110,12 @@ const isCurrentUser = inject('is-current-user')
const isOwner = computed(() => currentUser['@id'] === props.post.sender['@id'])
onMounted(async () => {
loadComments();
await loadAttachments();
});
loadComments()
await loadAttachments()
})
const computedAttachments = computed(() => {
return attachments.value;
});
return attachments.value
})
async function loadAttachments() {
try {
const postIri = props.post["@id"]
@ -142,40 +137,38 @@ function loadComments() {
}
})
.then(response => comments.push(...response.data['hydra:member']))
;
}
function onCommentDeleted(event) {
const index = comments.findIndex(comment => comment['@id'] === event.comment['@id']);
const index = comments.findIndex(comment => comment['@id'] === event.comment['@id'])
if (-1 !== index) {
comments.splice(index, 1);
comments.splice(index, 1)
}
}
function onCommentPosted(newComment) {
comments.unshift(newComment);
comments.unshift(newComment)
}
function onPostDeleted(post) {
emit('post-deleted', post);
emit('post-deleted', post)
}
const isImageAttachment = (attachment) => {
if (attachment.filename) {
const fileExtension = attachment.filename.split('.').pop().toLowerCase();
return ['jpg', 'jpeg', 'png', 'gif'].includes(fileExtension);
const fileExtension = attachment.filename.split('.').pop().toLowerCase()
return ['jpg', 'jpeg', 'png', 'gif'].includes(fileExtension)
}
return false;
};
return false
}
const isVideoAttachment = (attachment) => {
if (attachment.filename) {
const fileExtension = attachment.filename.split('.').pop().toLowerCase();
return ['mp4', 'webm', 'ogg'].includes(fileExtension);
const fileExtension = attachment.filename.split('.').pop().toLowerCase()
return ['mp4', 'webm', 'ogg'].includes(fileExtension)
}
return false;
};
return false
}
</script>

@ -17,23 +17,17 @@ import SocialSideMenu from "../social/SocialSideMenu.vue"
import { useStore } from "vuex"
import { useRoute } from "vue-router"
import { onMounted, provide, readonly, ref, watch } from "vue"
import { useSocialInfo } from "../../composables/useSocialInfo"
const store = useStore()
const route = useRoute()
const user = ref({})
const { user, isCurrentUser, groupInfo, isGroup, loadUser } = useSocialInfo()
provide("social-user", readonly(user))
async function loadUser() {
try {
user.value = route.query.id ? await store.dispatch("user/load", '/api/users/' + route.query.id) : store.getters["security/getUser"]
} catch (e) {
user.value = {}
}
}
provide("social-user", user)
provide("is-current-user", isCurrentUser)
provide("group-info", groupInfo)
provide("is-group", isGroup)
onMounted(loadUser)
watch(() => route.query, loadUser)
</script>

@ -0,0 +1,55 @@
<template>
<div>
<div class="discussions-header">
<h2>Discussions</h2>
<a :href="threadCreationUrl" class="btn btn-primary create-thread-btn ajax">
<i class="pi pi-plus"></i> {{ t("Create thread") }}
</a>
</div>
<div class="discussion-item" v-for="discussion in discussions" :key="discussion.id">
<div class="discussion-content">
<div class="discussion-title" v-html="discussion.title"></div>
<div class="discussion-details">
<i class="mdi mdi-message-reply-text icon"></i>
<span>{{ discussion.repliesCount }} {{ t("Replies") }}</span>
<i class="mdi mdi-clock-outline icon"></i>
<span>Created {{ new Date(discussion.sendDate).toLocaleDateString() }}</span>
</div>
</div>
<div class="discussion-author">
<img v-if="discussion.sender.illustrationUrl" :src="discussion.sender.illustrationUrl" class="author-avatar-icon">
<i v-else class="mdi mdi-account-circle-outline author-avatar-icon"></i>
<span class="author-name">{{ discussion.sender.name }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import axios from 'axios'
import { useI18n } from "vue-i18n"
const route = useRoute()
const discussions = ref([])
const groupId = ref(route.params.group_id)
const { t } = useI18n()
onMounted(async () => {
if (groupId.value) {
try {
const response = await axios.get(`/api/messages/by-group/list?groupId=${groupId.value}`)
discussions.value = response.data['hydra:member'].map(discussion => ({
...discussion,
repliesCount: discussion.receiversTo.length + discussion.receiversCc.length
}))
} catch (error) {
console.error('Error fetching discussions:', error)
discussions.value = []
}
}
})
const threadCreationUrl = computed(() => {
return `/main/social/message_for_group_form.inc.php?view_panel=1&user_friend=1&group_id=${groupId.value}&action=add_message_group`
})
</script>

@ -0,0 +1,63 @@
<template>
<div class="group-members">
<div class="edit-members">
<BaseButton
label="Edit members list"
type="primary"
class="edit-members-btn"
icon="pi pi-plus"
@click="editMembers"
/>
</div>
<div class="members-grid">
<div class="member-card" v-for="member in members" :key="member.id">
<div class="member-avatar">
<img v-if="member.avatar" :src="member.avatar" alt="Member avatar">
<i v-else class="mdi mdi-account-circle-outline"></i>
</div>
<div class="member-name">
{{ member.name }}
<i v-if="member.isAdmin" class="mdi mdi-star-outline admin-icon"></i>
</div>
<div class="member-role" v-if="member.role">{{ member.role }}</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import BaseButton from "../basecomponents/BaseButton.vue"
import axios from "axios"
const route = useRoute()
const members = ref([])
const groupId = ref(route.params.group_id)
const fetchMembers = async (groupId) => {
if (groupId.value) {
try {
const response = await axios.get(`/api/usergroups/${groupId.value}/members`)
members.value = response.data['hydra:member'].map(member => ({
id: member.id,
name: member.username,
role: member.relationType === 1 ? 'Admin' : 'Member',
avatar: null,
isAdmin: member.relationType === 1
}))
} catch (error) {
console.error('Error fetching group members:', error)
members.value = []
}
}
}
const editMembers = () => {
}
onMounted(() => {
if (groupId) {
fetchMembers(groupId)
}
})
</script>

@ -1,9 +1,34 @@
<template>
<router-view></router-view>
<div class="flex flex-col md:flex-row gap-4">
<div class="md:basis-1/3 lg:basis-1/4 2xl:basis-1/6 flex flex-col">
<UserProfileCard v-if="!isLoading && !isGroup" />
<GroupInfoCard v-if="!isLoading && isGroup" />
<SocialSideMenu v-if="!isLoading && !isGroup" />
<SocialGroupMenu v-if="!isLoading && isGroup" />
</div>
<div class="md:basis-2/3 lg:basis-3/4 2xl:basis-5/6">
<router-view></router-view>
</div>
</div>
</template>
<script setup>
import UserProfileCard from "../social/UserProfileCard.vue"
import SocialSideMenu from "../social/SocialSideMenu.vue"
import { useStore } from "vuex"
import { useRoute } from "vue-router"
import { onMounted, provide, readonly, ref, watch } from "vue"
import { useSocialInfo } from "../../composables/useSocialInfo"
import SocialGroupMenu from "../social/SocialGroupMenu.vue"
import GroupInfoCard from "../social/GroupInfoCard.vue"
const store = useStore()
const route = useRoute()
const { user, isCurrentUser, groupInfo, isGroup, loadGroup, loadUser, isLoading } = useSocialInfo()
provide("social-user", user)
provide("is-current-user", isCurrentUser)
provide("group-info", groupInfo)
provide("is-group", isGroup)
<script>
export default {
name: 'UserGroupLayout'
}
</script>

@ -15,32 +15,17 @@ import SocialSideMenu from "../social/SocialSideMenu.vue"
import { useStore } from "vuex"
import { useRoute } from "vue-router"
import { onMounted, provide, readonly, ref, watch } from "vue"
import { useSocialInfo } from "../../composables/useSocialInfo"
const store = useStore()
const route = useRoute()
const user = ref({})
const isCurrentUser = ref(true)
const { user, isCurrentUser, groupInfo, isGroup, loadUser } = useSocialInfo()
provide("social-user", readonly(user))
provide("is-current-user", readonly(isCurrentUser))
async function loadUser() {
try {
if (route.query.id) {
user.value = await store.dispatch("user/load", '/api/users/' + route.query.id)
isCurrentUser.value = false
} else {
user.value = store.getters["security/getUser"]
isCurrentUser.value = true
}
} catch (e) {
user.value = {}
isCurrentUser.value = true
}
}
provide("social-user", user)
provide("is-current-user", isCurrentUser)
provide("group-info", groupInfo)
provide("is-group", isGroup)
onMounted(loadUser)
watch(() => route.query, loadUser)
</script>

@ -0,0 +1,80 @@
import { ref, readonly, onMounted } from "vue";
import { useStore } from "vuex";
import { useRoute } from "vue-router";
import axios from "axios";
export function useSocialInfo() {
const store = useStore();
const route = useRoute();
const user = ref({});
const isCurrentUser = ref(true);
const groupInfo = ref({});
const isGroup = ref(false);
const isLoading = ref(true);
const loadGroup = async (groupId) => {
isLoading.value = true;
if (groupId) {
try {
const response = await axios.get(`/api/usergroup/${groupId}`);
const groupData = response.data;
const extractedId = groupData['@id'].split('/').pop();
groupInfo.value = {
...groupData,
id: extractedId
};
isGroup.value = true;
} catch (error) {
console.error("Error loading group:", error);
groupInfo.value = {};
isGroup.value = false;
}
isLoading.value = false;
} else {
isGroup.value = false;
groupInfo.value = {};
}
};
const loadUser = async () => {
try {
if (route.query.id) {
user.value = await store.dispatch("user/load", '/api/users/' + route.query.id)
isCurrentUser.value = false
} else {
user.value = store.getters["security/getUser"]
isCurrentUser.value = true
}
} catch (e) {
user.value = {}
isCurrentUser.value = true
}
};
onMounted(async () => {
try {
//if (!route.params.group_id) {
await loadUser();
//}
if (route.params.group_id) {
await loadGroup(route.params.group_id);
}
} finally {
isLoading.value = false;
}
});
return {
user: readonly(user),
isCurrentUser: readonly(isCurrentUser),
groupInfo: readonly(groupInfo),
isGroup: readonly(isGroup),
loadGroup,
loadUser,
isLoading,
};
}

@ -12,9 +12,21 @@ export default {
},
{
name: 'UserGroupShow',
//path: ':id',
path: 'show',
component: () => import('../views/usergroup/Show.vue')
path: 'show/:group_id?',
component: () => import('../views/usergroup/Show.vue'),
props: true
},
{
name: 'UserGroupSearch',
path: 'search',
component: () => import('../views/usergroup/Search.vue'),
props: (route) => ({ q: route.query.q })
},
{
name: 'UserGroupInvite',
path: 'invite/:group_id?',
component: () => import('../views/usergroup/Invite.vue'),
props: true
}
]
};

@ -43,25 +43,20 @@ import { useI18n } from "vue-i18n"
import SocialSideMenu from "../../components/social/SocialSideMenu.vue";
import UserProfileCard from "../../components/social/UserProfileCard.vue"
import { useRoute } from "vue-router"
import { useSocialInfo } from "../../composables/useSocialInfo"
const store = useStore()
const route = useRoute()
const { t } = useI18n()
const user = ref({})
provide("social-user", readonly(user))
const { user, isCurrentUser, groupInfo, isGroup, loadUser } = useSocialInfo();
async function loadUser() {
try {
user.value = route.query.id ? await store.dispatch("user/load", '/api/users/' + route.query.id) : store.getters["security/getUser"]
} catch (e) {
user.value = {}
}
}
onMounted(loadUser)
provide("social-user", user);
provide("is-current-user", isCurrentUser);
provide("group-info", groupInfo);
provide("is-group", isGroup);
watch(() => route.query, loadUser)
onMounted(loadUser);
function btnEditProfileOnClick() {
window.location = "/account/edit"

@ -27,32 +27,17 @@ import UserProfileCard from "../../components/social/UserProfileCard.vue"
import MyGroupsCard from "../../components/social/MyGroupsCard.vue"
import MyFriendsCard from "../../components/social/MyFriendsCard.vue"
import MySkillsCard from "../../components/social/MySkillsCard.vue"
import { useSocialInfo } from "../../composables/useSocialInfo"
const store = useStore()
const route = useRoute()
const user = ref({})
const isCurrentUser = ref(true)
const { user, isCurrentUser, groupInfo, isGroup, loadUser } = useSocialInfo()
provide("social-user", readonly(user))
provide("is-current-user", readonly(isCurrentUser))
async function loadUser() {
try {
if (route.query.id) {
user.value = await store.dispatch("user/load", '/api/users/' + route.query.id)
isCurrentUser.value = false
} else {
user.value = store.getters["security/getUser"]
isCurrentUser.value = true
}
} catch (e) {
user.value = {}
isCurrentUser.value = true
}
}
provide("social-user", user)
provide("is-current-user", isCurrentUser)
provide("group-info", groupInfo)
provide("is-group", isGroup)
onMounted(loadUser)
watch(() => route.query, loadUser)
</script>

@ -0,0 +1,112 @@
<template>
<div class="invite-friends-container invite-friends">
<div class="invite-friends-header">
<h2>{{ t('Invite Friends to Group') }}</h2>
</div>
<div class="invite-friends-body">
<div class="friends-list">
<div class="list-header">
<h3>{{ t('Available Friends') }}</h3>
</div>
<div class="list-content">
<div class="friend-entry" v-for="friend in availableFriends" :key="friend.id">
<div class="friend-info">
<img :src="friend.avatar" alt="avatar" class="friend-avatar" />
<span class="friend-name">{{ friend.name }}</span>
</div>
<button @click="selectFriend(friend)" class="invite-btn">+</button>
</div>
</div>
</div>
<div class="selected-friends-list">
<div class="list-header">
<h3>{{ t('Selected Friends') }}</h3>
</div>
<div class="list-content">
<div class="friend-entry" v-for="friend in selectedFriends" :key="friend.id">
<div class="friend-info">
<img :src="friend.avatar" alt="avatar" class="friend-avatar" />
<span class="friend-name">{{ friend.name }}</span>
</div>
<button @click="removeFriend(friend)" class="remove-btn">-</button>
</div>
</div>
</div>
</div>
<div class="invite-friends-footer">
<button @click="sendInvitations" class="send-invites-btn">{{ t('Send Invitations') }}</button>
</div>
<div class="invited-friends-list mt-4">
<div class="list-header">
<h3>{{ t('Users Already Invited') }}</h3>
</div>
<div class="invited-users-grid mt-4">
<div class="user-card" v-for="user in invitedFriends" :key="user.id">
<img :src="user.avatar" alt="avatar" class="user-avatar" />
<span class="user-name">{{ user.name }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { inject, onMounted, ref, computed} from "vue"
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { useStore } from "vuex"
import axios from "axios"
const { t } = useI18n()
const route = useRoute()
const store = useStore()
const currentUser = computed(() => store.getters["security/getUser"])
const availableFriends = ref([])
const selectedFriends = ref([])
const invitedFriends = ref([])
const selectFriend = (friend) => {
availableFriends.value = availableFriends.value.filter((f) => f.id !== friend.id)
selectedFriends.value.push(friend)
}
const removeFriend = (friend) => {
selectedFriends.value = selectedFriends.value.filter((f) => f.id !== friend.id)
availableFriends.value.push(friend)
}
const loadAvailableFriends = async () => {
const groupId = route.params.group_id
const userId = currentUser.value.id
try {
const response = await axios.get(`/social-network/invite-friends/${userId}/${groupId}`)
availableFriends.value = response.data.friends
} catch (error) {
console.error('Error loading available friends:', error)
}
}
onMounted(() => {
loadAvailableFriends()
loadInvitedFriends()
})
const sendInvitations = async () => {
const groupId = route.params.group_id
const userIds = selectedFriends.value.map(friend => friend.id)
try {
await axios.post(`/social-network/add-users-to-group/${groupId}`, {
userIds,
})
console.log('Users added to group successfully!')
selectedFriends.value = []
loadInvitedFriends()
} catch (error) {
console.error('Error adding users to group:', error)
}
}
const loadInvitedFriends = async () => {
const groupId = route.params.group_id
try {
const response = await axios.get(`/social-network/group/${groupId}/invited-users`)
invitedFriends.value = response.data.invitedUsers
} catch (error) {
console.error('Error loading invited friends:', error)
}
}
</script>

@ -1,642 +1,205 @@
<template>
<Toolbar>
<template v-slot:right>
<v-btn
icon
tile
@click="composeHandler"
>
<v-icon icon="mdi-email-plus-outline" />
</v-btn>
<v-btn
:loading="isLoading"
icon
tile
@click="reloadHandler"
>
<v-icon icon="mdi-refresh" />
</v-btn>
<v-btn
:class="[!selectedItems || !selectedItems.length ? 'hidden' : '']"
icon
tile
@click="confirmDeleteMultiple"
>
<v-icon icon="mdi-delete" />
</v-btn>
<!-- :disabled="!selectedItems || !selectedItems.length"-->
<v-btn
:class="[!selectedItems || !selectedItems.length ? 'hidden' : '']"
icon
tile
@click="markAsUnReadMultiple"
>
<v-icon icon="mdi-email" />
</v-btn>
<v-btn
:class="[!selectedItems || !selectedItems.length ? 'hidden' : '']"
icon
tile
>
<v-icon icon="mdi-email-open" />
</v-btn>
</template>
</Toolbar>
<div class="flex flex-row pt-2">
<div class="w-1/5">
<v-card
max-width="300"
tile
>
<v-list dense>
<!-- v-model="selectedItem"-->
<v-list-item-group color="primary">
<v-list-item @click="goToInbox">
<v-list-item-icon>
<v-icon icon="mdi-inbox"></v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Inbox</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item @click="goToSent">
<v-list-item-icon>
<v-icon icon="mdi-send-outline"></v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Sent</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item @click="goToUnread">
<v-list-item-icon>
<v-icon icon="mdi-email-outline"></v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Unread</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item
v-for="(tag, i) in tags"
:key="i"
@click="goToTag(tag)"
>
<v-list-item-icon>
<v-icon icon="mdi-label-outline"></v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title v-text="tag.tag"></v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list-item-group>
</v-list>
</v-card>
</div>
<div class="w-4/5 pl-4">
<div class="text-h4 q-mb-md">{{ title }}</div>
<DataTable
v-model:filters="filters"
v-model:selection="selectedItems"
:globalFilterFields="['title', 'sendDate']"
:lazy="true"
:loading="isLoading"
:paginator="true"
:rows="10"
:rowsPerPageOptions="[5, 10, 20, 50]"
:totalRecords="totalItems"
:value="items"
class="p-datatable-sm"
currentPageReportTemplate="Showing {first} to {last} of {totalRecords}"
dataKey="id"
filterDisplay="menu"
paginatorTemplate="CurrentPageReport FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown"
responsiveLayout="scroll"
sortBy="sendDate"
sortOrder="asc"
@page="onPage($event)"
@sort="sortingChanged($event)"
>
<Column
:exportable="false"
selectionMode="multiple"
style="width: 3rem"
></Column>
<Column
:header="$t('From')"
:sortable="false"
field="sender"
>
<template #body="slotProps">
<q-avatar size="40px">
<img :src="slotProps.data.sender.illustrationUrl + '?w=80&h=80&fit=crop'" />
</q-avatar>
<a
v-if="slotProps.data"
:class="[true === slotProps.data.read ? 'font-normal' : 'font-semibold']"
class="cursor-pointer"
@click="showHandler(slotProps.data)"
>
{{ slotProps.data.sender.username }}
</a>
</template>
</Column>
<Column
:header="$t('Title')"
:sortable="false"
field="title"
>
<template #body="slotProps">
<a
v-if="slotProps.data"
class="cursor-pointer"
v-bind:class="{ 'font-semibold': !slotProps.data.read }"
@click="showHandler(slotProps.data)"
>
{{ slotProps.data.title }}
</a>
<div class="flex flex-row">
<v-chip v-for="tag in slotProps.data.tags">
{{ tag.tag }}
</v-chip>
<div class="p-grid p-nogutter social-groups">
<div class="p-col-12">
<div class="p-d-flex p-jc-between p-ai-center p-mb-4">
<h1>Social groups</h1>
<Button label="Create a social group" icon="pi pi-plus" class="create-group-button" @click="showCreateGroupDialog = true" />
</div>
<TabView class="social-group-tabs">
<TabPanel header="Newest" headerClass="tab-header" :class="{ 'active-tab': activeTab === 'Newest' }">
<div class="group-list">
<div class="group-item" v-for="group in newestGroups" :key="group['@id']">
<i class="mdi mdi-account-group-outline group-icon"></i>
<div class="group-details">
<a :href="`/resources/usergroups/show/${extractGroupId(group)}`" class="group-title">{{ group.title }}</a>
<div class="group-info">
<span class="group-member-count">{{ group.memberCount }} Member</span>
<span class="group-description">{{ group.description }}</span>
</div>
</div>
</div>
</template>
<!-- <template #filter="{filterModel}">-->
<!-- <InputText type="text" v-model="filterModel.value" class="p-column-filter" placeholder="Search by name"/>-->
<!-- </template>-->
<!-- -->
<!-- <template #filter="{filterModel}">-->
<!-- <InputText type="text" v-model="filterModel.value" class="p-column-filter" placeholder="Search by title"/>-->
<!-- </template>-->
<!-- <template #filterclear="{filterCallback}">-->
<!-- <Button type="button" icon="pi pi-times" @click="filterCallback()" class="p-button-secondary"></Button>-->
<!-- </template>-->
<!-- <template #filterapply="{filterCallback}">-->
<!-- <Button type="button" icon="pi pi-check" @click="filterCallback()" class="p-button-success"></Button>-->
<!-- </template>-->
</Column>
<Column
:header="$t('Send date')"
:sortable="true"
field="sendDate"
>
<template #body="slotProps">
{{ relativeDatetime(slotProps.data.sendDate) }}
</template>
</Column>
<Column :exportable="false">
<template #body="slotProps">
<div class="flex flex-row gap-2">
<v-btn
icon
tile
@click="confirmDeleteItem(slotProps.data)"
>
<v-icon icon="mdi-delete" />
</v-btn>
</div>
</TabPanel>
<TabPanel header="Popular" headerClass="tab-header" :class="{ 'active-tab': activeTab === 'Popular' }">
<div class="group-list">
<div class="group-item" v-for="group in popularGroups" :key="group['@id']">
<i class="mdi mdi-account-group-outline group-icon"></i>
<div class="group-details">
<a :href="`/resources/usergroups/show/${extractGroupId(group)}`" class="group-title">{{ group.title }}</a>
<div class="group-info">
<span class="group-member-count">{{ group.memberCount }} Member</span>
<span class="group-description">{{ group.description }}</span>
</div>
</div>
</div>
</template>
</Column>
</DataTable>
</div> </TabPanel>
<TabPanel header="My groups" headerClass="tab-header" :class="{ 'active-tab': activeTab === 'My groups' }">
<div class="group-list">
<div class="group-item" v-for="group in myGroups" :key="group['@id']">
<i class="mdi mdi-account-group-outline group-icon"></i>
<div class="group-details">
<a :href="`/resources/usergroups/show/${extractGroupId(group)}`" class="group-title">{{ group.title }}</a>
<div class="group-info">
<span class="group-member-count">{{ group.memberCount }} Member</span>
<span class="group-description">{{ group.description }}</span>
</div>
</div>
</div>
</div>
</TabPanel>
</TabView>
</div>
</div>
<!-- Dialogs-->
<Dialog
v-model:visible="itemDialog"
:header="$t('New folder')"
:modal="true"
:style="{ width: '450px' }"
class="p-fluid"
>
<div class="p-field">
<label for="title">{{ $t("Name") }}</label>
<InputText
id="title"
v-model.trim="item.title"
:class="{ 'p-invalid': submitted && !item.title }"
autocomplete="off"
autofocus
required="true"
/>
<small
v-if="submitted && !item.title"
class="p-error"
>$t('Title is required')</small
>
</div>
<template #footer>
<Button
class="p-button-text"
icon="pi pi-times"
label="Cancel"
@click="hideDialog"
/>
<Button
class="p-button-text"
icon="pi pi-check"
label="Save"
@click="saveItem"
/>
</template>
</Dialog>
<Dialog
v-model:visible="deleteItemDialog"
:modal="true"
:style="{ width: '450px' }"
header="Confirm"
>
<div class="confirmation-content">
<i
class="pi pi-exclamation-triangle p-mr-3"
style="font-size: 2rem"
/>
<span v-if="item"
>Are you sure you want to delete <b>{{ item.title }}</b
>?</span
>
</div>
<template #footer>
<Button
class="p-button-text"
icon="pi pi-times"
label="No"
@click="deleteItemDialog = false"
/>
<Button
class="p-button-text"
icon="pi pi-check"
label="Yes"
@click="deleteItemButton"
/>
</template>
</Dialog>
<Dialog
v-model:visible="deleteMultipleDialog"
:modal="true"
:style="{ width: '450px' }"
header="Confirm"
>
<div class="confirmation-content">
<i
class="pi pi-exclamation-triangle p-mr-3"
style="font-size: 2rem"
/>
<span v-if="item">Are you sure you want to delete the selected items?</span>
</div>
<template #footer>
<Button
class="p-button-text"
icon="pi pi-times"
label="No"
@click="deleteMultipleDialog = false"
/>
<Button
class="p-button-text"
icon="pi pi-check"
label="Yes"
@click="deleteMultipleItems"
/>
</template>
<Dialog header="Add" :visible="showCreateGroupDialog" :modal="true" :closable="true" @hide="showCreateGroupDialog = false">
<form @submit.prevent="createGroup">
<div class="p-fluid">
<BaseInputTextWithVuelidate
v-model="groupForm.name"
label="Name*"
:vuelidate-property="v$.groupForm.name"
/>
<BaseInputTextWithVuelidate
v-model="groupForm.description"
label="Description"
:vuelidate-property="v$.groupForm.description"
as="textarea"
rows="3"
/>
<BaseInputTextWithVuelidate
v-model="groupForm.url"
label="URL"
:vuelidate-property="v$.groupForm.url"
/>
<BaseFileUpload
:label="t('Add a picture')"
accept="image"
size="small"
@file-selected="selectedFile = $event"
/>
<div class="p-field mt-2">
<label for="groupPermissions">Group Permissions</label>
<Dropdown id="groupPermissions" v-model="groupForm.permissions" :options="permissionsOptions" optionLabel="label" placeholder="Select Permission" />
</div>
<div class="p-field-checkbox mt-2">
<BaseCheckbox
id="leaveGroup"
v-model="groupForm.allowLeave"
:label="$t('Allow members to leave group')"
name="leaveGroup"
/>
</div>
</div>
<Button label="Add" icon="pi pi-check" class="p-button-rounded p-button-text" @click="createGroup" />
</form>
</Dialog>
</template>
<script>
import { mapActions, mapGetters, useStore } from "vuex"
import { mapFields } from "vuex-map-fields"
import ListMixin from "../../mixins/ListMixin"
import ActionCell from "../../components/ActionCell.vue"
import Toolbar from "../../components/Toolbar.vue"
import ResourceIcon from "../../components/documents/ResourceIcon.vue"
import ResourceFileLink from "../../components/documents/ResourceFileLink.vue"
import DataFilter from "../../components/DataFilter"
import DocumentsFilterForm from "../../components/documents/Filter"
import { ref } from "vue"
<script setup>
import Button from 'primevue/button'
import TabView from 'primevue/tabview'
import TabPanel from 'primevue/tabpanel'
import { ref, onMounted } from 'vue'
import useVuelidate from '@vuelidate/core'
import { required } from '@vuelidate/validators'
import BaseInputTextWithVuelidate from "../../components/basecomponents/BaseInputTextWithVuelidate.vue"
import BaseFileUpload from "../../components/basecomponents/BaseFileUpload.vue"
import BaseCheckbox from "../../components/basecomponents/BaseCheckbox.vue"
import { useI18n } from "vue-i18n"
import axios from "axios"
import { ENTRYPOINT } from "../../config/entrypoint"
import { RESOURCE_LINK_PUBLISHED } from "../../components/resource_links/visibility"
import { MESSAGE_TYPE_INBOX, MESSAGE_TYPE_OUTBOX } from "../../components/message/constants"
import { useFormatDate } from "../../composables/formatDate"
import { useI18n } from "vue-i18n"
export default {
name: "UserGroupList",
servicePrefix: "usergroups",
components: {
Toolbar,
ActionCell,
ResourceIcon,
ResourceFileLink,
DocumentsFilterForm,
DataFilter,
},
mixins: [ListMixin],
setup() {
const { t } = useI18n()
const { relativeDatetime } = useFormatDate()
const store = useStore()
const filters = ref([])
const filtersSent = ref([])
const user = store.getters["security/getUser"]
const tags = ref([])
const title = ref("Inbox")
filtersSent.value = {
msgType: MESSAGE_TYPE_OUTBOX,
sender: user.id,
}
// inbox
filters.value = {
msgType: MESSAGE_TYPE_INBOX,
userReceiver: user.id,
const {t} = useI18n()
const newestGroups = ref([])
const popularGroups = ref([])
const myGroups = ref([])
const activeTab = ref('Newest')
const showCreateGroupDialog = ref(false)
const selectedFile = ref(null)
const groupForm = ref({
name: '',
description: '',
url: '',
picture: null,
})
const v$ = useVuelidate({
groupForm: {
name: { required },
description: {},
url: {},
}
}, { groupForm })
const permissionsOptions = [
{ label: 'Open', value: '1' },
{ label: 'Closed', value: '2' },
]
const createGroup = async () => {
v$.value.$touch()
if (!v$.value.$invalid) {
const groupData = {
title: groupForm.value.name,
description: groupForm.value.description,
url: groupForm.value.url,
visibility: groupForm.value.permissions.value,
allowMembersToLeaveGroup: groupForm.value.allowLeave ? 1 : 0,
groupType: 1,
}
// Get user tags.
axios
.get(ENTRYPOINT + "message_tags", {
params: {
user: user["@id"],
try {
const response = await axios.post(ENTRYPOINT + 'usergroups', groupData, {
headers: {
'Content-Type': 'application/json',
},
})
.then((response) => {
let data = response.data
tags.value = data["hydra:member"]
})
function goToInbox() {
title.value = "Inbox"
filters.value = {
msgType: MESSAGE_TYPE_INBOX,
userReceiver: user.id,
}
store.dispatch("message/resetList")
store.dispatch("message/fetchAll", filters.value)
}
function goToUnread() {
title.value = "Unread"
filters.value = {
msgType: MESSAGE_TYPE_INBOX,
userReceiver: user.id,
read: false,
}
store.dispatch("message/resetList")
store.dispatch("message/fetchAll", filters.value)
}
function goToSent() {
title.value = "Sent"
filters.value = {
msgType: MESSAGE_TYPE_OUTBOX,
sender: user.id,
}
store.dispatch("message/resetList")
store.dispatch("message/fetchAll", filters.value)
}
function goToTag(tag) {
title.value = tag.tag
filters.value = {
msgType: MESSAGE_TYPE_INBOX,
userReceiver: user.id,
tags: [tag],
}
store.dispatch("message/resetList")
store.dispatch("message/fetchAll", filters.value)
}
/*if (selectedFile.value && response.data && response.data.id) {
const formData = new FormData()
formData.append('picture', selectedFile.value)
await axios.post(`/social-network/upload-group-picture/${response.data.id}`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
}*/
return {
goToInbox,
goToSent,
goToTag,
goToUnread,
tags,
filters,
title,
relativeDatetime,
showCreateGroupDialog.value = false
await updateGroupsList()
} catch (error) {
console.error('Failed to create group or upload picture:', error.response.data)
}
},
data() {
const {t} = useI18n()
return {
columns: [
{ label: t("Title"), field: "title", name: "title", sortable: true },
{ label: t("Sender"), field: "sender", name: "userSender", sortable: true },
{ label: t("Modified"), field: "sendDate", name: "updatedAt", sortable: true },
{ label: t("Actions"), name: "action", sortable: false },
],
pageOptions: [10, 20, 50, t("All")],
selected: [],
isBusy: false,
options: {
sortBy: "sendDate",
sortDesc: "asc",
},
selectedItems: [],
// prime vue
itemDialog: false,
deleteItemDialog: false,
deleteMultipleDialog: false,
item: {},
submitted: false,
}
}
const fetchGroups = async (endpoint) => {
try {
const response = await fetch(ENTRYPOINT + `${endpoint}`)
if (!response.ok) {
throw new Error('Failed to fetch groups')
}
},
mounted() {
this.onUpdateOptions(this.options)
},
computed: {
// From crud.js list function
...mapGetters("resourcenode", {
resourceNode: "getResourceNode",
}),
...mapGetters({
isAuthenticated: "security/isAuthenticated",
isAdmin: "security/isAdmin",
currentUser: "security/getUser",
}),
...mapGetters("message", {
items: "list",
}),
//...getters
// From ListMixin
...mapFields("message", {
deletedItem: "deleted",
error: "error",
isLoading: "isLoading",
resetList: "resetList",
totalItems: "totalItems",
view: "view",
}),
},
methods: {
composeHandler() {
let folderParams = this.$route.query
this.$router.push({ name: `${this.$options.servicePrefix}Create`, query: folderParams })
},
// prime
onPage(event) {
this.options.itemsPerPage = event.rows
this.options.page = event.page + 1
this.options.sortBy = event.sortField
this.options.sortDesc = event.sortOrder === -1
this.onUpdateOptions(this.options)
},
sortingChanged(event) {
console.log("sortingChanged")
console.log(event)
this.options.sortBy = event.sortField
this.options.sortDesc = event.sortOrder === -1
this.onUpdateOptions(this.options)
// ctx.sortBy ==> Field key for sorting by (or null for no sorting)
// ctx.sortDesc ==> true if sorting descending, false otherwise
},
openNew() {
this.item = {}
this.submitted = false
this.itemDialog = true
},
hideDialog() {
this.itemDialog = false
this.submitted = false
},
saveItem() {
this.submitted = true
if (this.item.title.trim()) {
if (this.item.id) {
} else {
//this.products.push(this.product);
this.item.filetype = "folder"
this.item.parentResourceNodeId = this.$route.params.node
this.item.resourceLinkList = JSON.stringify([
{
gid: this.$route.query.gid,
sid: this.$route.query.sid,
cid: this.$route.query.cid,
visibility: RESOURCE_LINK_PUBLISHED, // visible by default
},
])
this.create(this.item)
this.showMessage("Saved")
}
this.itemDialog = false
this.item = {}
}
},
editItem(item) {
this.item = { ...item }
this.itemDialog = true
},
confirmDeleteItem(item) {
this.item = item
this.deleteItemDialog = true
},
confirmDeleteMultiple() {
this.deleteMultipleDialog = true
},
markAsReadMultiple() {
console.log("markAsReadMultiple")
this.selectedItems.forEach((message) => {
message.read = true
this.update(message)
})
this.selectedItems = null
this.resetList = true
},
reloadHandler() {
this.onUpdateOptions(this.options)
},
markAsUnReadMultiple() {
console.log("markAsUnReadMultiple")
this.selectedItems.forEach((message) => {
message.read = false
this.update(message)
})
this.selectedItems = null
this.resetList = true
//this.onUpdateOptions(this.options);
},
deleteMultipleItems() {
console.log("deleteMultipleItems")
console.log(this.selectedItems)
this.deleteMultipleAction(this.selectedItems)
this.onRequest({
pagination: this.pagination,
})
this.deleteMultipleDialog = false
this.selectedItems = null
//this.onUpdateOptions(this.options);
},
deleteItemButton() {
console.log("deleteItem")
this.deleteItem(this.item)
//this.items = this.items.filter(val => val.iid !== this.item.iid);
this.deleteItemDialog = false
this.item = {}
this.onUpdateOptions(this.options)
},
onRowSelected(items) {
this.selected = items
},
selectAllRows() {
this.$refs.selectableTable.selectAllRows()
},
clearSelected() {
this.$refs.selectableTable.clearSelected()
},
async deleteSelected() {
console.log("deleteSelected")
/*for (let i = 0; i < this.selected.length; i++) {
let item = this.selected[i];
//this.deleteHandler(item);
this.deleteItem(item);
}*/
const data = await response.json()
console.log('hidra menber ::: ', data['hydra:member'])
return data['hydra:member']
} catch (error) {
console.error(error)
return []
}
}
const updateGroupsList = async () => {
newestGroups.value = await fetchGroups('usergroup/list/newest')
popularGroups.value = await fetchGroups('usergroup/list/popular')
myGroups.value = await fetchGroups('usergroup/list/my')
}
this.deleteMultipleAction(this.selected)
this.onRequest({
pagination: this.pagination,
})
},
//...actions,
// From ListMixin
...mapActions("message", {
getPage: "fetchAll",
create: "create",
update: "update",
deleteItem: "del",
deleteMultipleAction: "delMultiple",
}),
...mapActions("resourcenode", {
findResourceNode: "findResourceNode",
}),
},
const extractGroupId = (group) => {
const match = group['@id'].match(/\/api\/usergroup\/(\d+)/)
return match ? match[1] : null
}
const redirectToGroupDetails = (groupId) => {
router.push({ name: 'UserGroupShow', params: { group_id: groupId } })
}
onMounted(async () => {
await updateGroupsList()
})
</script>

@ -0,0 +1,69 @@
<template>
<div class="search-container social-groups">
<div class="search-header">
<h2>{{ t('Results and feedback') }} {{ searchTerm }}</h2>
<div class="p-inputgroup">
<BaseInputText
v-model="searchTerm"
placeholder="Search term..."
class="search-term-input"
label="Search term ..."/>
<BaseButton
label="Search"
icon="pi pi-search"
@click="performSearch"
type="button"/>
</div>
</div>
<div class="p-grid search-results">
<div class="p-col-12 p-md-4" v-for="group in searchResults" :key="group.id">
<div class="group-card">
<div class="group-image">
<i v-if="!group.pictureUrl" class="pi pi-users large-icon"></i>
<img v-else :src="group.pictureUrl" alt="Group" />
</div>
<div class="group-details">
<h4 class="group-title">{{ group.title }}</h4>
<p class="group-description">{{ group.description }}</p>
<a :href="`/resources/usergroups/show/${extractGroupId(group)}`" class="group-title">{{ t('See more') }}</a>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, ref } from "vue"
import { useI18n } from 'vue-i18n'
import BaseInputText from "../../components/basecomponents/BaseInputText.vue"
import BaseButton from "../../components/basecomponents/BaseButton.vue"
import axios from 'axios'
import { useRoute } from "vue-router"
const { t } = useI18n()
const route = useRoute()
const searchTerm = ref('')
const searchResults = ref([])
onMounted(() => {
if (route.query.q) {
searchTerm.value = route.query.q
performSearch()
}
})
const performSearch = async () => {
try {
const response = await axios.get('/api/usergroups/search', {
params: { search: searchTerm.value },
})
searchResults.value = response.data['hydra:member']
} catch (error) {
console.error('Error performing search:', error)
searchResults.value = []
}
}
const extractGroupId = (group) => {
const match = group['@id'].match(/\/api\/usergroup\/(\d+)/)
return match ? match[1] : null
}
</script>

@ -1,262 +1,40 @@
<template>
<div v-if="item">
<Toolbar :handle-delete="del">
<template v-slot:right>
<!-- <v-toolbar-title v-if="item">-->
<!-- {{-->
<!-- `${$options.servicePrefix} ${item['@id']}`-->
<!-- }}-->
<!-- </v-toolbar-title>-->
<v-btn
:loading="isLoading"
icon
tile
@click="reply"
>
<v-icon icon="mdi-reply" />
</v-btn>
</template>
</Toolbar>
<VueMultiselect
v-model="item.tags"
:internal-search="false"
:loading="isLoadingSelect"
:multiple="true"
:options="tags"
:searchable="true"
:taggable="true"
label="tag"
placeholder="Tags"
tag-placeholder="Add this as new tag"
track-by="id"
@remove="removeTagFromMessage"
@select="addTagToMessage"
@tag="addTag"
@search-change="asyncFind"
/>
<p class="text-lg">
From:
<q-avatar size="32px">
<img :src="item['sender']['illustrationUrl'] + '?w=80&h=80&fit=crop'" />
</q-avatar>
{{ item["sender"]["username"] }}
</p>
<p class="text-lg">
{{ relativeDatetime(item["sendDate"]) }}
</p>
<div class="social-group-show">
<div class="group-header">
<h1 class="group-title">mi grupo 0002</h1>
<p class="group-description">test</p>
</div>
<h3 class="text-lg">{{ item.title }}</h3>
<ul class="tabs">
<li :class="{ active: activeTab === 'discussions' }" @click="activeTab = 'discussions'">Discussions</li>
<li :class="{ active: activeTab === 'members' }" @click="activeTab = 'members'">Members</li>
</ul>
<div class="flex flex-row">
<div class="w-full">
<p v-html="item.content" />
</div>
<div class="tab-content">
<GroupDiscussions v-if="activeTab === 'discussions'" :group-id="groupId" />
<GroupMembers v-if="activeTab === 'members'" :group-id="groupId" />
</div>
<Loading :visible="isLoading" />
</div>
</template>
<style src="vue-multiselect/dist/vue-multiselect.css"></style>
<script>
import { mapActions, mapGetters, useStore } from "vuex"
import { mapFields } from "vuex-map-fields"
import Loading from "../../components/Loading.vue"
import ShowMixin from "../../mixins/ShowMixin"
import Toolbar from "../../components/Toolbar.vue"
import VueMultiselect from "vue-multiselect"
import { ref } from "vue"
import isEmpty from "lodash/isEmpty"
import axios from "axios"
import { ENTRYPOINT } from "../../config/entrypoint"
import useVuelidate from "@vuelidate/core"
import { useRoute, useRouter } from "vue-router"
import NotificationMixin from "../../mixins/NotificationMixin"
import { useFormatDate } from "../../composables/formatDate"
const servicePrefix = "usergroups"
export default {
name: "UserGroupShow",
components: {
Loading,
Toolbar,
VueMultiselect,
},
setup() {
const tags = ref([])
const isLoadingSelect = ref(false)
const store = useStore()
const user = store.getters["security/getUser"]
const find = store.getters["message/find"]
const route = useRoute()
const router = useRouter()
const { relativeDatetime } = useFormatDate()
let id = route.params.id
if (isEmpty(id)) {
id = route.query.id
}
console.log(id)
console.log(decodeURIComponent(id))
let item = find(decodeURIComponent(id))
// Change to read
if (false === item.read) {
axios
.put(ENTRYPOINT + "messages/" + item.id, {
read: true,
})
.then((response) => {
console.log(response)
})
.catch(function (error) {
console.log(error)
})
}
function addTag(newTag) {
axios
.post(ENTRYPOINT + "message_tags", {
user: user["@id"],
tag: newTag,
})
.then((response) => {
addTagToMessage(response.data)
//this.showMessage('Added');
item.tags.push(response.data)
console.log(response)
isLoadingSelect.value = false
})
.catch(function (error) {
isLoadingSelect.value = false
console.log(error)
})
}
function addTagToMessage(newTag) {
console.log("addTagToMessage")
let tagsToUpdate = []
item.tags.forEach((tagItem) => {
tagsToUpdate.push(tagItem["@id"])
})
tagsToUpdate.push(newTag["@id"])
console.log(tagsToUpdate)
axios
.put(ENTRYPOINT + "messages/" + item.id, {
tags: tagsToUpdate,
})
.then((response) => {
//this.showMessage('Added');
console.log(response)
isLoadingSelect.value = false
})
.catch(function (error) {
isLoadingSelect.value = false
console.log(error)
})
}
function removeTagFromMessage() {
let tagsToUpdate = []
item.tags.forEach((tagItem) => {
tagsToUpdate.push(tagItem["@id"])
})
axios
.put(ENTRYPOINT + "messages/" + item.id, {
tags: tagsToUpdate,
})
.then((response) => {
console.log(response)
isLoadingSelect.value = false
})
.catch(function (error) {
isLoadingSelect.value = false
console.log(error)
})
}
axios
.get(ENTRYPOINT + "message_tags", {
params: {
user: user["@id"],
},
})
.then((response) => {
isLoadingSelect.value = false
let data = response.data
tags.value = data["hydra:member"]
})
function reply() {
let params = route.query
router.push({ name: `${servicePrefix}Reply`, query: params })
}
function asyncFind(query) {
if (query.toString().length < 3) {
return
}
isLoadingSelect.value = true
axios
.get(ENTRYPOINT + "message_tags", {
params: {
user: user["@id"],
},
})
.then((response) => {
isLoadingSelect.value = false
let data = response.data
tags.value = data["hydra:member"]
})
.catch(function (error) {
isLoadingSelect.value = false
console.log(error)
})
}
return {
v$: useVuelidate(),
tags,
isLoadingSelect,
item,
addTag,
addTagToMessage,
removeTagFromMessage,
asyncFind,
reply,
relativeDatetime,
}
},
mixins: [ShowMixin, NotificationMixin],
computed: {
...mapFields("message", {
isLoading: "isLoading",
}),
...mapGetters("message", ["find"]),
...mapGetters({
isAuthenticated: "security/isAuthenticated",
isAdmin: "security/isAdmin",
currentUser: "security/getUser",
}),
},
methods: {
...mapActions("message", {
deleteItem: "del",
reset: "resetShow",
retrieve: "loadWithQuery",
}),
},
servicePrefix,
}
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import axios from 'axios'
import GroupDiscussions from "../../components/usergroup/GroupDiscussions.vue"
import GroupMembers from "../../components/usergroup/GroupMembers.vue"
const route = useRoute()
const activeTab = ref('discussions')
const groupId = ref(route.params.group_id)
const group = ref(null)
onMounted(async () => {
if (groupId.value) {
try {
const response = await axios.get(`/api/usergroup/${groupId.value}`)
group.value = response.data
} catch (error) {
console.error('Error fetching group details:', error)
}
}
})
</script>

@ -67,6 +67,15 @@ services:
bind:
$persistProcessor: '@api_platform.doctrine.orm.state.persist_processor'
Chamilo\CoreBundle\State\UsergroupPostProcessor:
arguments:
$processor: '@api_platform.doctrine.orm.state.persist_processor'
$entityManager: '@doctrine.orm.entity_manager'
$security: '@security.helper'
$requestStack: '@request_stack'
tags:
- { name: 'api_platform.state_processor' }
Chamilo\CoreBundle\EventSubscriber\AnonymousUserSubscriber:
tags:
- name: kernel.event_subscriber

@ -366,7 +366,7 @@ class MessageManager
);
// Adding more sense to the message group
$subject = sprintf(get_lang('There is a new message in group %s'), $group_info['name']);
$subject = sprintf(get_lang('There is a new message in group %s'), $group_info['title']);
$new_user_list = [];
foreach ($user_list as $user_data) {
$new_user_list[] = $user_data['id'];

@ -171,7 +171,7 @@ class Notification extends Model
break;
case self::NOTIFICATION_TYPE_GROUP:
if (!empty($senderInfo)) {
$senderName = $senderInfo['group_info']['name'];
$senderName = $senderInfo['group_info']['title'];
$newTitle .= sprintf(get_lang('You have received a new message in group %s'), $senderName);
$senderName = api_get_person_name(
$senderInfo['user_info']['firstname'],
@ -397,7 +397,7 @@ class Notification extends Model
case self::NOTIFICATION_TYPE_GROUP:
$topicPage = isset($_REQUEST['topics_page_nr']) ? (int) $_REQUEST['topics_page_nr'] : 0;
if (!empty($senderInfo)) {
$senderName = $senderInfo['group_info']['name'];
$senderName = $senderInfo['group_info']['title'];
$newMessageText = sprintf(get_lang('You have received a new message in group %s'), $senderName);
$senderName = Display::url(
$senderInfoName,
@ -405,7 +405,7 @@ class Notification extends Model
);
$newMessageText .= '<br />'.get_lang('User').': '.$senderName;
}
$groupUrl = api_get_path(WEB_CODE_PATH).'social/group_topics.php?id='.$senderInfo['group_info']['id'].'&topic_id='.$senderInfo['group_info']['topic_id'].'&msg_id='.$senderInfo['group_info']['msg_id'].'&topics_page_nr='.$topicPage;
$groupUrl = api_get_path(WEB_PATH).'resources/usergroups/show/'.$senderInfo['group_info']['id'];
$linkToNewMessage = Display::url(get_lang('See message'), $groupUrl);
break;
}
@ -519,6 +519,8 @@ class Notification extends Model
/** @var array $decodedResult */
$decodedResult = json_decode($result, true);
return intval($decodedResult['success']);
$return = isset($decodedResult['success']) ? (int) $decodedResult['success'] : 0;
return $return;
}
}

@ -68,7 +68,7 @@ if (isset($_POST['action'])) {
if ('edit_message_group' === $_POST['action']) {
$edit_message_id = intval($_POST['message_id']);
$res = MessageManager::send_message(
0,
api_get_user_id(),
$title,
$content,
$_FILES,
@ -83,7 +83,7 @@ if (isset($_POST['action'])) {
api_not_allowed(true);
}
$res = MessageManager::send_message(
0,
api_get_user_id(),
$title,
$content,
$_FILES,
@ -99,6 +99,9 @@ if (isset($_POST['action'])) {
if (!$res) {
Display::addFlash(Display::return_message(get_lang('Error'), 'error'));
}
header('Location: ' . api_get_path(WEB_PATH).'resources/usergroups/show/'.$group_id);
exit;
$topic_id = isset($_GET['topic_id']) ? intval($_GET['topic_id']) : null;
if ('add_message_group' === $_POST['action']) {
$topic_id = $res;
@ -178,7 +181,7 @@ $(function() {
$this_section = SECTION_SOCIAL;
$interbreadcrumb[] = ['url' => 'groups.php', 'name' => get_lang('Groups')];
$interbreadcrumb[] = ['url' => 'group_view.php?id='.$group_id, 'name' => Security::remove_XSS($group_info['name'])];
$interbreadcrumb[] = ['url' => 'group_view.php?id='.$group_id, 'name' => Security::remove_XSS($group_info['title'])];
$interbreadcrumb[] = ['url' => '#', 'name' => get_lang('Discussions')];
$social_left_content = null; //SocialManager::show_social_menu('member_list', $group_id);

@ -36,7 +36,7 @@ if (!empty($group_id) && $allowed_action) {
api_not_allowed(true);
}
$to_group = $group_info['name'];
$to_group = $group_info['title'];
if (!empty($message_id)) {
/*$message_info = MessageManager::get_message_by_id($message_id);
if ('reply_message_group' === $allowed_action) {

@ -7,8 +7,11 @@ declare(strict_types=1);
namespace Chamilo\CoreBundle\Controller;
use Chamilo\CoreBundle\Entity\User;
use Chamilo\CoreBundle\Entity\Usergroup;
use Chamilo\CoreBundle\Repository\LanguageRepository;
use Chamilo\CoreBundle\Repository\LegalRepository;
use Chamilo\CoreBundle\Repository\Node\CourseRepository;
use Chamilo\CoreBundle\Repository\Node\IllustrationRepository;
use Chamilo\CoreBundle\Repository\Node\UsergroupRepository;
use Chamilo\CoreBundle\Repository\Node\UserRepository;
use Chamilo\CoreBundle\Serializer\UserToJsonNormalizer;
@ -20,6 +23,7 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
use Symfony\Component\Routing\Annotation\Route;
@ -240,7 +244,7 @@ class SocialController extends AbstractController
'id' => $group->getId(),
'name' => $group->getTitle(),
'description' => $group->getDescription(),
'url' => '#',
'url' => $baseUrl.'/resources/usergroups/show/'.$group->getId(),
];
}
}
@ -267,4 +271,60 @@ class SocialController extends AbstractController
return $this->json(['go_to' => $goToLink]);
}
#[Route('/invite-friends/{userId}/{groupId}', name: 'chamilo_core_social_invite_friends')]
public function inviteFriends(int $userId, int $groupId, UserRepository $userRepository, UsergroupRepository $usergroupRepository, IllustrationRepository $illustrationRepository): JsonResponse
{
$user = $userRepository->find($userId);
if (!$user) {
return $this->json(['error' => 'User not found'], Response::HTTP_NOT_FOUND);
}
$group = $usergroupRepository->find($groupId);
if (!$group) {
return $this->json(['error' => 'Group not found'], Response::HTTP_NOT_FOUND);
}
$friends = $userRepository->getFriendsNotInGroup($userId, $groupId);
$friendsList = array_map(function ($friend) use ($illustrationRepository) {
return [
'id' => $friend->getId(),
'name' => $friend->getFirstName() . ' ' . $friend->getLastName(),
'avatar' => $illustrationRepository->getIllustrationUrl($friend),
];
}, $friends);
return $this->json(['friends' => $friendsList]);
}
#[Route('/add-users-to-group/{groupId}', name: 'chamilo_core_social_add_users_to_group')]
public function addUsersToGroup(Request $request, int $groupId, UsergroupRepository $usergroupRepository): JsonResponse
{
$data = json_decode($request->getContent(), true);
$userIds = $data['userIds'] ?? [];
try {
$usergroupRepository->addUserToGroup($userIds, $groupId);
return $this->json(['success' => true, 'message' => 'Users added to group successfully.']);
} catch (\Exception $e) {
return $this->json(['success' => false, 'message' => 'An error occurred: ' . $e->getMessage()], Response::HTTP_BAD_REQUEST);
}
}
#[Route('/group/{groupId}/invited-users', name: 'chamilo_core_social_group_invited_users')]
public function groupInvitedUsers(int $groupId, UsergroupRepository $usergroupRepository, IllustrationRepository $illustrationRepository): JsonResponse
{
$invitedUsers = $usergroupRepository->getInvitedUsersByGroup($groupId);
$invitedUsersList = array_map(function ($user) use ($illustrationRepository) {
return [
'id' => $user['id'],
'name' => $user['username'],
// 'avatar' => $illustrationRepository->getIllustrationUrl($user),
];
}, $invitedUsers);
return $this->json(['invitedUsers' => $invitedUsersList]);
}
}

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Chamilo\CoreBundle\DataProvider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use Chamilo\CoreBundle\Entity\Usergroup;
use Doctrine\ORM\EntityManagerInterface;
final class GroupMembersDataProvider implements ProviderInterface
{
private EntityManagerInterface $entityManager;
public function __construct(EntityManagerInterface $entityManager)
{
$this->entityManager = $entityManager;
}
public function supports(Operation $operation, array $uriVariables = [], array $context = []): bool
{
return Usergroup::class === $operation->getClass() && 'get_group_members' === $operation->getName();
}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable
{
$groupId = $uriVariables['id'] ?? null;
if (null === $groupId) {
return [];
}
$usergroupRepository = $this->entityManager->getRepository(Usergroup::class);
$users = $usergroupRepository->getUsersByGroup((int)$groupId);
return $users;
}
}

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Chamilo\CoreBundle\DataProvider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use Chamilo\CoreBundle\Entity\Message;
use Chamilo\CoreBundle\Repository\MessageRepository;
final class MessageByGroupDataProvider implements ProviderInterface
{
private MessageRepository $messageRepository;
public function __construct(MessageRepository $messageRepository)
{
$this->messageRepository = $messageRepository;
}
public function supports(Operation $operation, array $uriVariables = [], array $context = []): bool
{
return Message::class === $operation->getClass() && 'get_messages_by_group' === $operation->getName();
}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable
{
$groupId = $context['filters']['groupId'] ?? null;
if (null === $groupId) {
return [];
}
return $this->messageRepository->findByGroupId((int) $groupId);
}
}

@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
/* For licensing terms, see /license.txt */
namespace Chamilo\CoreBundle\DataProvider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use Chamilo\CoreBundle\Entity\Usergroup;
use Chamilo\CoreBundle\Repository\Node\IllustrationRepository;
use Chamilo\CoreBundle\Repository\Node\UsergroupRepository;
use Symfony\Component\Security\Core\Security;
final class UsergroupDataProvider implements ProviderInterface
{
private $security;
private $usergroupRepository;
private $illustrationRepository;
public function __construct(Security $security, UsergroupRepository $usergroupRepository, IllustrationRepository $illustrationRepository)
{
$this->security = $security;
$this->usergroupRepository = $usergroupRepository;
$this->illustrationRepository = $illustrationRepository;
}
/**
* @param Operation $operation
* @param array $uriVariables
* @param array $context
* @return iterable
*/
public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable
{
$operationName = $operation->getName();
if ($operationName === 'get_usergroup') {
$groupId = $uriVariables['id'] ?? null;
if (!$groupId) {
throw new \Exception("Group ID is required for 'get_usergroup' operation");
}
$group = $this->usergroupRepository->findGroupById($groupId);
if (!$group) {
throw new \Exception("Group not found");
}
$this->setGroupDetails($group);
return [$group];
}
if ($operationName === 'search_usergroups') {
$searchTerm = $context['filters']['search'] ?? '';
$groups = $this->usergroupRepository->searchGroups($searchTerm);
foreach ($groups as $group) {
$this->setGroupDetails($group);
}
return $groups;
}
switch ($operationName) {
case 'get_my_usergroups':
$userId = $context['request_attributes']['_api_filters']['userId'] ?? null;
if (!$userId) {
$user = $this->security->getUser();
$userId = $user ? $user->getId() : null;
}
if (!$userId) {
throw new \Exception("User ID is required");
}
$groups = $this->usergroupRepository->getGroupsByUser($userId, 0);
break;
case 'get_newest_usergroups':
$groups = $this->usergroupRepository->getNewestGroups();
break;
case 'get_popular_usergroups':
$groups = $this->usergroupRepository->getPopularGroups();
break;
default:
$groups = [];
break;
}
if (in_array($operationName, ['get_my_usergroups', 'get_newest_usergroups', 'get_popular_usergroups'])) {
/* @var Usergroup $group */
foreach ($groups as $group) {
$this->setGroupDetails($group);
}
}
return $groups;
}
public function supports(Operation $operation, array $uriVariables = [], array $context = []): bool
{
return Usergroup::class === $operation->getClass();
}
private function setGroupDetails(Usergroup $group): void
{
$memberCount = $this->usergroupRepository->countMembers($group->getId());
$group->setMemberCount($memberCount);
if ($this->illustrationRepository->hasIllustration($group)) {
$picture = $this->illustrationRepository->getIllustrationUrl($group);
$group->setPictureUrl($picture);
}
}
}

@ -16,6 +16,7 @@ use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use Chamilo\CoreBundle\DataProvider\MessageByGroupDataProvider;
use Chamilo\CoreBundle\Entity\Listener\MessageListener;
use Chamilo\CoreBundle\Repository\MessageRepository;
use DateTime;
@ -38,7 +39,17 @@ use Symfony\Component\Validator\Constraints as Assert;
new Get(security: "is_granted('VIEW', object)"),
new Put(security: "is_granted('EDIT', object)"),
new Delete(security: "is_granted('DELETE', object)"),
new GetCollection(security: "is_granted('ROLE_USER')"),
new GetCollection(
uriTemplate: '/messages',
security: "is_granted('ROLE_USER')",
name: 'get_all_messages'
),
new GetCollection(
uriTemplate: '/messages/by-group/list',
security: "is_granted('ROLE_USER')",
name: 'get_messages_by_group',
provider: MessageByGroupDataProvider::class
),
new Post(securityPostDenormalize: "is_granted('CREATE', object)"),
],
normalizationContext: [
@ -121,6 +132,7 @@ class Message
#[ORM\Column(name: 'content', type: 'text', nullable: false)]
protected string $content;
#[Groups(['message:read', 'message:write'])]
#[ORM\ManyToOne(targetEntity: Usergroup::class)]
#[ORM\JoinColumn(name: 'group_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
protected ?Usergroup $group = null;

@ -7,19 +7,82 @@ declare(strict_types=1);
namespace Chamilo\CoreBundle\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use Chamilo\CoreBundle\DataProvider\GroupMembersDataProvider;
use Chamilo\CoreBundle\DataProvider\UsergroupDataProvider;
use Chamilo\CoreBundle\Repository\Node\UsergroupRepository;
use Chamilo\CoreBundle\State\UsergroupPostProcessor;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Gedmo\Timestampable\Traits\TimestampableEntity;
use Stringable;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Classes and social groups.
*/
#[ApiResource(security: 'is_granted(\'ROLE_ADMIN\')', normalizationContext: ['groups' => ['usergroup:read']])]
#[ApiResource(
operations: [
new Get(
uriTemplate: '/usergroup/{id}',
normalizationContext: ['groups' => ['usergroup:read']],
security: "is_granted('ROLE_USER')",
name: 'get_usergroup'
),
new Put(security: "is_granted('EDIT', object)"),
new Delete(security: "is_granted('DELETE', object)"),
new GetCollection(
uriTemplate: '/usergroup/list/my',
normalizationContext: ['groups' => ['usergroup:read']],
security: "is_granted('ROLE_USER')",
name: 'get_my_usergroups'
),
new GetCollection(
uriTemplate: '/usergroup/list/newest',
normalizationContext: ['groups' => ['usergroup:read']],
security: "is_granted('ROLE_USER')",
name: 'get_newest_usergroups'
),
new GetCollection(
uriTemplate: '/usergroup/list/popular',
normalizationContext: ['groups' => ['usergroup:read']],
security: "is_granted('ROLE_USER')",
name: 'get_popular_usergroups'
),
new GetCollection(
uriTemplate: '/usergroups/search',
normalizationContext: ['groups' => ['usergroup:read']],
security: "is_granted('ROLE_USER')",
name: 'search_usergroups'
),
new GetCollection(
uriTemplate: '/usergroups/{id}/members',
normalizationContext: ['groups' => ['usergroup:read']],
security: "is_granted('ROLE_USER')",
name: 'get_group_members',
provider: GroupMembersDataProvider::class
),
new Post(
securityPostDenormalize: "is_granted('CREATE', object)",
processor: UsergroupPostProcessor::class
),
],
normalizationContext: [
'groups' => ['usergroup:read'],
],
denormalizationContext: [
'groups' => ['usergroup:write'],
],
security: "is_granted('ROLE_USER')",
provider: UsergroupDataProvider::class
)]
#[ORM\Table(name: 'usergroup')]
#[ORM\Entity(repositoryClass: UsergroupRepository::class)]
class Usergroup extends AbstractResource implements ResourceInterface, ResourceIllustrationInterface, ResourceWithAccessUrlInterface, Stringable
@ -41,23 +104,29 @@ class Usergroup extends AbstractResource implements ResourceInterface, ResourceI
#[ORM\GeneratedValue]
protected ?int $id = null;
#[Assert\NotBlank]
#[Groups(['usergroup:read', 'usergroup:write'])]
#[ORM\Column(name: 'title', type: 'string', length: 255)]
protected string $title;
#[Groups(['usergroup:read', 'usergroup:write'])]
#[ORM\Column(name: 'description', type: 'text', nullable: true)]
protected ?string $description = null;
#[Assert\NotBlank]
#[Groups(['usergroup:read', 'usergroup:write'])]
#[ORM\Column(name: 'group_type', type: 'integer', nullable: false)]
protected int $groupType;
#[ORM\Column(name: 'picture', type: 'string', length: 255, nullable: true)]
protected ?string $picture = null;
#[Groups(['usergroup:read', 'usergroup:write'])]
#[ORM\Column(name: 'url', type: 'string', length: 255, nullable: true)]
protected ?string $url = null;
#[Assert\NotBlank]
#[Groups(['usergroup:read', 'usergroup:write'])]
#[ORM\Column(name: 'visibility', type: 'string', length: 255, nullable: false)]
protected string $visibility;
#[ORM\Column(name: 'author_id', type: 'integer', nullable: true)]
protected ?int $authorId = null;
#[Assert\NotBlank]
#[Groups(['usergroup:read', 'usergroup:write'])]
#[ORM\Column(name: 'allow_members_leave_group', type: 'integer')]
protected int $allowMembersToLeaveGroup;
@ -90,6 +159,13 @@ class Usergroup extends AbstractResource implements ResourceInterface, ResourceI
*/
#[ORM\OneToMany(mappedBy: 'userGroup', targetEntity: AccessUrlRelUserGroup::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
protected Collection $urls;
#[Groups(['usergroup:read'])]
private ?int $memberCount = null;
#[Groups(['usergroup:read'])]
private ?string $pictureUrl = '';
public function __construct()
{
$this->groupType = self::NORMAL_CLASS;
@ -283,6 +359,38 @@ class Usergroup extends AbstractResource implements ResourceInterface, ResourceI
{
return $this->picture;
}
public function setPicture(?string $picture): self
{
$this->picture = $picture;
return $this;
}
public function getPictureUrl(): ?string
{
return $this->picture;
}
public function setPictureUrl(?string $pictureUrl): self
{
$this->pictureUrl = $pictureUrl;
return $this;
}
public function getMemberCount(): ?int
{
return $this->memberCount;
}
public function setMemberCount(int $memberCount): self
{
$this->memberCount = $memberCount;
return $this;
}
public function getDefaultIllustration(int $size): string
{
$size = empty($size) ? 32 : $size;

@ -69,4 +69,16 @@ class MessageRepository extends ServiceEntityRepository
return $qb;
}
public function findByGroupId(int $groupId)
{
$qb = $this->createQueryBuilder('m');
$qb->where('m.group = :groupId')
->andWhere('m.status NOT IN (:excludedStatuses)')
->setParameter('groupId', $groupId)
->setParameter('excludedStatuses', [Message::MESSAGE_STATUS_DRAFT, Message::MESSAGE_STATUS_DELETED])
->orderBy('m.id', 'ASC');
return $qb->getQuery()->getResult();
}
}

@ -14,6 +14,8 @@ use Chamilo\CoreBundle\Entity\Session;
use Chamilo\CoreBundle\Entity\TrackELogin;
use Chamilo\CoreBundle\Entity\TrackEOnline;
use Chamilo\CoreBundle\Entity\User;
use Chamilo\CoreBundle\Entity\Usergroup;
use Chamilo\CoreBundle\Entity\UsergroupRelUser;
use Chamilo\CoreBundle\Entity\UserRelUser;
use Chamilo\CoreBundle\Repository\ResourceRepository;
use Datetime;
@ -694,4 +696,33 @@ class UserRepository extends ResourceRepository implements PasswordUpgraderInter
return $qb;
}
public function getFriendsNotInGroup(int $userId, int $groupId)
{
$entityManager = $this->getEntityManager();
$subQueryBuilder = $entityManager->createQueryBuilder();
$subQuery = $subQueryBuilder
->select('IDENTITY(ugr.user)')
->from(UsergroupRelUser::class, 'ugr')
->where('ugr.usergroup = :subGroupId')
->andWhere('ugr.relationType IN (:subRelationTypes)')
->getDQL();
$queryBuilder = $entityManager->createQueryBuilder();
$query = $queryBuilder
->select('u')
->from(User::class, 'u')
->leftJoin('u.friendsWithMe', 'uruf')
->leftJoin('u.friends', 'urut')
->where('uruf.friend = :userId OR urut.user = :userId')
->andWhere($queryBuilder->expr()->notIn('u.id', $subQuery))
->setParameter('userId', $userId)
->setParameter('subGroupId', $groupId)
->setParameter('subRelationTypes', [Usergroup::GROUP_USER_PERMISSION_PENDING_INVITATION])
->getQuery();
return $query->getResult();
}
}

@ -6,7 +6,9 @@ declare(strict_types=1);
namespace Chamilo\CoreBundle\Repository\Node;
use Chamilo\CoreBundle\Entity\User;
use Chamilo\CoreBundle\Entity\Usergroup;
use Chamilo\CoreBundle\Entity\UsergroupRelUser;
use Chamilo\CoreBundle\Repository\ResourceRepository;
use Doctrine\Persistence\ManagerRegistry;
@ -20,7 +22,7 @@ class UsergroupRepository extends ResourceRepository
/**
* @param int|array $relationType
*/
public function getGroupsByUser(int $userId, int $relationType = Usergroup::GROUP_USER_PERMISSION_READER, bool $withImage = false): array
public function getGroupsByUser(int $userId, int $relationType = 0, bool $withImage = false): array
{
$qb = $this->createQueryBuilder('g')
->innerJoin('g.users', 'gu')
@ -51,22 +53,66 @@ class UsergroupRepository extends ResourceRepository
}
$qb->orderBy('g.createdAt', 'DESC');
$query = $qb->getQuery();
return $query->getResult();
}
public function searchGroupsByQuery(string $query): array
public function countMembers(int $usergroupId): int
{
$qb = $this->createQueryBuilder('g')
->select('count(gu.id)')
->innerJoin('g.users', 'gu')
->where('g.id = :usergroupId')
->setParameter('usergroupId', $usergroupId);
return (int) $qb->getQuery()->getSingleScalarResult();
}
public function getNewestGroups(int $limit = 6, string $query = ''): array
{
$qb = $this->createQueryBuilder('g');
$qb = $this->createQueryBuilder('g')
->select('g, COUNT(gu) AS HIDDEN memberCount')
->innerJoin('g.users', 'gu')
->where('g.groupType = :socialClass')
->setParameter('socialClass', Usergroup::SOCIAL_CLASS)
->groupBy('g')
->orderBy('g.createdAt', 'DESC')
->setMaxResults($limit);
if ($this->getUseMultipleUrl()) {
$urlId = $this->getCurrentAccessUrlId();
$qb->innerJoin('g.urls', 'u')
->andWhere('u.accessUrl = :urlId')
->setParameter('urlId', $urlId);
}
if (!empty($query)) {
$qb->where('g.title LIKE :query OR g.description LIKE :query')
->setParameter('query', '%'.$query.'%')
;
$qb->andWhere('g.title LIKE :query OR g.description LIKE :query')
->setParameter('query', '%'.$query.'%');
}
return $qb->getQuery()->getResult();
}
public function getPopularGroups(int $limit = 6): array
{
$qb = $this->createQueryBuilder('g')
->select('g, COUNT(gu) as HIDDEN memberCount')
->innerJoin('g.users', 'gu')
->where('g.groupType = :socialClass')
->setParameter('socialClass', Usergroup::SOCIAL_CLASS)
->andWhere('gu.relationType IN (:relationTypes)')
->setParameter('relationTypes', [
Usergroup::GROUP_USER_PERMISSION_ADMIN,
Usergroup::GROUP_USER_PERMISSION_READER,
Usergroup::GROUP_USER_PERMISSION_HRM
])
->groupBy('g')
->orderBy('memberCount', 'DESC')
->setMaxResults($limit);
if ($this->getUseMultipleUrl()) {
$urlId = $this->getCurrentAccessUrlId();
$qb->innerJoin('g.urls', 'u')
@ -78,6 +124,93 @@ class UsergroupRepository extends ResourceRepository
return $qb->getQuery()->getResult();
}
public function findGroupById($id)
{
return $this->createQueryBuilder('ug')
->where('ug.id = :id')
->setParameter('id', $id)
->getQuery()
->getOneOrNullResult();
}
public function searchGroups(string $searchTerm): array
{
$queryBuilder = $this->createQueryBuilder('g');
$queryBuilder->where('g.title LIKE :searchTerm')
->setParameter('searchTerm', '%' . $searchTerm . '%');
return $queryBuilder->getQuery()->getResult();
}
public function getUsersByGroup(int $groupID)
{
$qb = $this->createQueryBuilder('g')
->innerJoin('g.users', 'gu')
->innerJoin('gu.user', 'u')
->where('g.id = :groupID')
->setParameter('groupID', $groupID)
->andWhere('gu.relationType IN (:relationTypes)')
->setParameter('relationTypes', [
Usergroup::GROUP_USER_PERMISSION_ADMIN,
Usergroup::GROUP_USER_PERMISSION_READER,
Usergroup::GROUP_USER_PERMISSION_PENDING_INVITATION
])
->select('u.id, u.username, u.email, gu.relationType');
return $qb->getQuery()->getResult();
}
public function addUserToGroup(array $userIds, int $groupId): void
{
$group = $this->find($groupId);
if (!$group) {
throw new \Exception("Group not found");
}
foreach ($userIds as $userId) {
$user = $this->_em->getRepository(User::class)->find($userId);
if ($user) {
$groupRelUser = new UsergroupRelUser();
$groupRelUser->setUsergroup($group);
$groupRelUser->setUser($user);
$groupRelUser->setRelationType(Usergroup::GROUP_USER_PERMISSION_PENDING_INVITATION);
$this->_em->persist($groupRelUser);
}
}
$this->_em->flush();
}
public function getInvitedUsersByGroup(int $groupID)
{
$qb = $this->createQueryBuilder('g')
->innerJoin('g.users', 'gu')
->innerJoin('gu.user', 'u')
->where('g.id = :groupID')
->setParameter('groupID', $groupID)
->andWhere('gu.relationType = :relationType')
->setParameter('relationType', Usergroup::GROUP_USER_PERMISSION_PENDING_INVITATION)
->select('u.id, u.username, u.email, gu.relationType');
return $qb->getQuery()->getResult();
}
public function getInvitedUsers(int $groupId): array
{
$qb = $this->createQueryBuilder('g')
->innerJoin('g.users', 'rel')
->innerJoin('rel.user', 'u')
->where('g.id = :groupId')
->andWhere('rel.relationType = :relationType')
->setParameter('groupId', $groupId)
->setParameter('relationType', Usergroup::GROUP_USER_PERMISSION_PENDING_INVITATION)
->select('u');
return $qb->getQuery()->getResult();
}
/**
* Determines whether to use the multi-URL feature.
*

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Chamilo\CoreBundle\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use Chamilo\CoreBundle\Entity\Usergroup;
use Chamilo\CoreBundle\Entity\UsergroupRelUser;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Security\Core\Security;
class UsergroupPostProcessor implements ProcessorInterface
{
private ProcessorInterface $processor;
private EntityManagerInterface $entityManager;
private Security $security;
private RequestStack $requestStack;
public function __construct(
ProcessorInterface $processor,
EntityManagerInterface $entityManager,
Security $security,
RequestStack $requestStack
) {
$this->processor = $processor;
$this->entityManager = $entityManager;
$this->security = $security;
$this->requestStack = $requestStack;
}
public function process($data, Operation $operation, array $uriVariables = [], array $context = [])
{
/** @var Usergroup $usergroup */
$usergroup = $this->processor->process($data, $operation, $uriVariables, $context);
if ($usergroup instanceof Usergroup) {
$this->associateCurrentUser($usergroup);
$this->entityManager->flush();
}
return $usergroup;
}
private function associateCurrentUser(Usergroup $usergroup)
{
$currentUser = $this->security->getUser();
if ($currentUser) {
$usergroupRelUser = new UsergroupRelUser();
$usergroupRelUser->setUsergroup($usergroup);
$usergroupRelUser->setUser($currentUser);
$usergroupRelUser->setRelationType(Usergroup::GROUP_USER_PERMISSION_ADMIN);
$this->entityManager->persist($usergroupRelUser);
}
}
}
Loading…
Cancel
Save