summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorJulius Härtl <jus@bitgrid.net>2024-03-06 10:41:16 +0100
committerGitHub <noreply@github.com>2024-03-06 10:41:16 +0100
commit199a8758f630c8873494cee7582a2cd0cf13d97f (patch)
treea5d3780d5edac372819ed1f7bb5085b8ac3d2745
parentdb3cbb532d1a4dc9ed6bfedb9308ab27c3183510 (diff)
parente947bccda653851ca999ba980143c1290e162f43 (diff)
Merge pull request #3831 from nextcloud/enh/team-overview
feat: Implement team overview page updates
-rw-r--r--package-lock.json1
-rw-r--r--package.json1
-rw-r--r--src/components/AppContent/CircleContent.vue17
-rw-r--r--src/components/CircleDetails.vue340
-rw-r--r--src/components/Icons/IconCircles.vue38
-rw-r--r--src/components/MembersList/MembersListItem.vue3
6 files changed, 269 insertions, 131 deletions
diff --git a/package-lock.json b/package-lock.json
index 9cc8fa8f..62a70452 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -26,6 +26,7 @@
"@nextcloud/router": "^2.2.0",
"@nextcloud/sharing": "^0.1.0",
"@nextcloud/vue": "^8.8.1",
+ "@vueuse/core": "^10.9.0",
"b64-to-blob": "^1.2.19",
"camelcase": "^8.0.0",
"d3": "^7.8.5",
diff --git a/package.json b/package.json
index a7905aa7..c3ed199e 100644
--- a/package.json
+++ b/package.json
@@ -53,6 +53,7 @@
"@nextcloud/router": "^2.2.0",
"@nextcloud/sharing": "^0.1.0",
"@nextcloud/vue": "^8.8.1",
+ "@vueuse/core": "^10.9.0",
"b64-to-blob": "^1.2.19",
"camelcase": "^8.0.0",
"d3": "^7.8.5",
diff --git a/src/components/AppContent/CircleContent.vue b/src/components/AppContent/CircleContent.vue
index bfb5c5c8..f6a17273 100644
--- a/src/components/AppContent/CircleContent.vue
+++ b/src/components/AppContent/CircleContent.vue
@@ -24,7 +24,7 @@
<AppContent v-if="!circle">
<EmptyContent :name="t('contacts', 'Please select a team')">
<template #icon>
- <IconCircles :size="20" />
+ <AccountGroup :size="20" />
</template>
</EmptyContent>
</AppContent>
@@ -38,13 +38,6 @@
</AppContent>
<AppContent v-else :show-details.sync="showDetails">
- <!-- member list -->
- <template #list>
- <MemberList :list="members"
- :loading="loadingList"
- :show-details.sync="showDetails" />
- </template>
-
<!-- main contacts details -->
<CircleDetails :circle="circle">
<!-- not a member -->
@@ -58,7 +51,7 @@
<EmptyContent v-else :name="t('contacts', 'You are not a member of {circle}', { circle: circle.displayName})">
<template #icon>
- <IconCircles :size="20" />
+ <AccountGroup :size="20" />
</template>
</EmptyContent>
</template>
@@ -73,9 +66,8 @@ import {
NcLoadingIcon as IconLoading,
isMobile,
} from '@nextcloud/vue'
-import IconCircles from '../Icons/IconCircles.vue'
+import AccountGroup from 'vue-material-design-icons/AccountGroup.vue'
import CircleDetails from '../CircleDetails.vue'
-import MemberList from '../MemberList.vue'
import RouterMixin from '../../mixins/RouterMixin.js'
export default {
@@ -85,8 +77,7 @@ export default {
AppContent,
CircleDetails,
EmptyContent,
- MemberList,
- IconCircles,
+ AccountGroup,
IconLoading,
},
diff --git a/src/components/CircleDetails.vue b/src/components/CircleDetails.vue
index c4a76211..74135908 100644
--- a/src/components/CircleDetails.vue
+++ b/src/components/CircleDetails.vue
@@ -34,44 +34,45 @@
<!-- display name -->
<template #title>
- <input v-model="circle.displayName"
- :readonly="!circle.isOwner"
- :placeholder="t('contacts', 'Team name')"
- type="text"
- autocomplete="off"
- autocorrect="off"
- spellcheck="false"
- name="displayname"
- @input="onNameChangeDebounce">
<div v-if="loadingName" class="circle-name__loader icon-loading-small" />
+ <h2>
+ {{ circle.displayName }}
+ </h2>
</template>
<!-- org, title -->
<template v-if="!circle.isOwner" #subtitle>
{{ t('contacts', 'Team owned by {owner}', { owner: circle.owner.displayName}) }}
</template>
- </DetailsHeader>
- <section class="circle-details-section circle-details-section__actions">
- <!-- copy circle link -->
- <a class="circle-details__action-copy-link button"
- :href="circleUrl"
- :class="copyLinkIcon"
- @click.stop.prevent="copyToClipboard(circleUrl)">
- {{ copyButtonText }}
- </a>
-
- <!-- Only show the join button if the circle is accepting requests -->
- <Button v-if="!circle.isPendingMember && !circle.isMember && circle.canJoin"
- :disabled="loadingJoin"
- class="primary"
- @click="joinCircle">
- <Login slot="icon"
- :size="16"
- decorative />
- {{ t('contacts', 'Request to join') }}
- </Button>
- </section>
+ <template #actions>
+ <!-- copy circle link -->
+ <Button type="tertiary"
+ :href="circleUrl"
+ :title="copyButtonText"
+ :class="copyLinkIcon"
+ @click.stop.prevent="copyToClipboard(circleUrl)" />
+
+ <!-- Team settings modal -->
+ <Button v-if="(circle.isOwner || circle.isAdmin) && !circle.isPersonal" @click="showSettingsModal = true">
+ <template #icon>
+ <Cog :size="20" />
+ </template>
+ {{ t('contacts', 'Team settings') }}
+ </Button>
+
+ <!-- Only show the join button if the circle is accepting requests -->
+ <Button v-if="!circle.isPendingMember && !circle.isMember && circle.canJoin"
+ :disabled="loadingJoin"
+ class="primary"
+ @click="joinCircle">
+ <Login slot="icon"
+ :size="16"
+ decorative />
+ {{ t('contacts', 'Request to join') }}
+ </Button>
+ </template>
+ </DetailsHeader>
<section v-if="showDescription" class="circle-details-section">
<ContentHeading :loading="loadingDescription">
@@ -82,42 +83,123 @@
:auto-complete="onAutocomplete"
:maxlength="1024"
:multiline="true"
- :contenteditable="circle.isOwner"
+ :contenteditable="false"
:placeholder="descriptionPlaceholder"
class="circle-details-section__description"
@update:value="onDescriptionChangeDebounce" />
</section>
- <section v-if="(circle.isOwner || circle.isAdmin) && !circle.isPersonal" class="circle-details-section">
- <CircleConfigs class="circle-details-section__configs" :circle="circle" />
- <CirclePasswordSettings class="circle-details-section__configs" :circle="circle" />
+ <section v-if="circle.isMember" class="circle-details-section">
+ <ContentHeading>
+ {{ t('contacts', 'Members') }}
+ </ContentHeading>
+ <div ref="avatarList" class="avatar-list">
+ <Avatar v-for="member in membersLimited"
+ :key="member.singleId"
+ :user="member.userId"
+ :display-name="member.displayName"
+ :is-no-user="!member.isUser"
+ :size="44" />
+ <Button @click="showMembersModal = true">
+ <template #icon>
+ <AccountMultiplePlus :size="20" />
+ </template>
+ {{ t('contacts', 'Add members') }}
+ </Button>
+ </div>
+ <Modal v-if="showMembersModal" @close="showMembersModal=false">
+ <div class="members-modal">
+ <h2>{{ t('contacts', 'Team members') }}</h2>
+ <MemberList :list="members" />
+ </div>
+ </Modal>
</section>
- <section v-else>
- <slot />
+ <section>
+ <ContentHeading>
+ {{ t('contacts', 'Team resources') }}
+ </ContentHeading>
+ <p>{{ t('contacts', 'Anything shared with this team will show up here') }}</p>
+ <div v-for="provider in resourceProviders" :key="provider.id">
+ <ContentHeading>
+ <span v-show="false" class="provider__icon" v-html="provider.icon" /> {{ provider.name }}
+ </ContentHeading>
+
+ <ul>
+ <ListItem v-for="resource in resourcesForProvider(provider.id)"
+ :key="resource.url"
+ class="resource"
+ :name="resource.label"
+ :href="resource.url">
+ <template #icon>
+ <span v-if="resource.iconEmoji" class="resource__icon">
+ {{ resource.iconEmoji }}
+ </span>
+ <span v-else-if="resource.iconSvg" class="resource__icon" v-html="resource.iconSvg" />
+ <span v-if="resource.iconUrl" class="resource__icon">
+ <img :src="resource.iconURL" alt="">
+ </span>
+ </template>
+ </ListItem>
+ </ul>
+ </div>
</section>
- <section class="circle-details-section">
- <!-- leave circle -->
- <Button v-if="circle.canLeave"
- class="circle-details__action-copy-link"
- @click="confirmLeaveCircle">
- <Logout slot="icon"
- :size="16"
- decorative />
- {{ t('contacts', 'Leave team') }}
- </Button>
-
- <!-- delete circle -->
- <Button v-if="circle.canDelete"
- class="circle-details__action-delete"
- href="#"
- @click.prevent.stop="confirmDeleteCircle">
- <template #icon>
- <IconDelete :size="20" />
- </template>
- {{ t('contacts', 'Delete team') }}
- </Button>
+ <Modal v-if="(circle.isOwner || circle.isAdmin) && !circle.isPersonal && showSettingsModal" @close="showSettingsModal=false">
+ <div class="circle-settings">
+ <h2>{{ t('contacts', 'Team settings') }}</h2>
+
+ <h3>{{ t('contacts', 'Team name') }}</h3>
+ <input v-model="circle.displayName"
+ :readonly="!circle.isOwner"
+ :placeholder="t('contacts', 'Team name')"
+ type="text"
+ autocomplete="off"
+ autocorrect="off"
+ spellcheck="false"
+ name="displayname"
+ @input="onNameChangeDebounce">
+
+ <h3>{{ t('contacts', 'Description') }}</h3>
+ <RichContenteditable :value.sync="circle.description"
+ :auto-complete="onAutocomplete"
+ :maxlength="1024"
+ :multiline="true"
+ :contenteditable="circle.isOwner"
+ :placeholder="descriptionPlaceholder"
+ class="circle-details-section__description"
+ @update:value="onDescriptionChangeDebounce" />
+
+ <h3>{{ t('contacts', 'Settings') }}</h3>
+ <CircleConfigs class="circle-details-section__configs" :circle="circle" />
+ <CirclePasswordSettings class="circle-details-section__configs" :circle="circle" />
+
+ <h3>{{ t('contacts', 'Actions') }}</h3>
+ <!-- leave circle -->
+ <Button v-if="circle.canLeave"
+ type="warning"
+ @click="confirmLeaveCircle">
+ <Logout slot="icon"
+ :size="16"
+ decorative />
+ {{ t('contacts', 'Leave team') }}
+ </Button>
+
+ <!-- delete circle -->
+ <Button v-if="circle.canDelete"
+ type="error"
+ href="#"
+ @click.prevent.stop="confirmDeleteCircle">
+ <template #icon>
+ <IconDelete :size="20" />
+ </template>
+ {{ t('contacts', 'Delete team') }}
+ </Button>
+ </div>
+ </Modal>
+
+ <section v-else>
+ <slot />
</section>
</AppContentDetails>
</template>
@@ -125,24 +207,32 @@
<script>
import { showError } from '@nextcloud/dialogs'
import debounce from 'debounce'
+import { useResizeObserver } from '@vueuse/core'
import {
NcAppContentDetails as AppContentDetails,
NcAvatar as Avatar,
NcButton as Button,
+ NcListItem as ListItem,
+ NcModal as Modal,
NcRichContenteditable as RichContenteditable,
} from '@nextcloud/vue'
+import Cog from 'vue-material-design-icons/Cog.vue'
import Login from 'vue-material-design-icons/Login.vue'
import Logout from 'vue-material-design-icons/Logout.vue'
import IconDelete from 'vue-material-design-icons/Delete.vue'
+import AccountMultiplePlus from 'vue-material-design-icons/AccountMultiplePlus.vue'
import { CircleEdit, editCircle } from '../services/circles.ts'
import CircleActionsMixin from '../mixins/CircleActionsMixin.js'
import DetailsHeader from './DetailsHeader.vue'
import CircleConfigs from './CircleDetails/CircleConfigs.vue'
+import MemberList from './MemberList.vue'
import ContentHeading from './CircleDetails/ContentHeading.vue'
import CirclePasswordSettings from './CircleDetails/CirclePasswordSettings.vue'
+import axios from '@nextcloud/axios'
+import { generateOcsUrl } from '@nextcloud/router'
export default {
name: 'CircleDetails',
@@ -150,14 +240,19 @@ export default {
components: {
AppContentDetails,
Avatar,
+ MemberList,
Button,
CircleConfigs,
CirclePasswordSettings,
ContentHeading,
DetailsHeader,
+ ListItem,
+ Cog,
Login,
Logout,
+ Modal,
IconDelete,
+ AccountMultiplePlus,
RichContenteditable,
},
@@ -167,6 +262,10 @@ export default {
return {
loadingDescription: false,
loadingName: false,
+ showSettingsModal: false,
+ showMembersModal: false,
+ resources: null,
+ memberLimit: 1,
}
},
@@ -188,9 +287,52 @@ export default {
}
return !this.isEmptyDescription
},
+
+ members() {
+ return Object.values(this.$store.getters.getCircle(this.circle.id)?.members || [])
+ },
+
+ membersLimited() {
+ return this.members.slice(0, this.memberLimit)
+ },
+
+ resourceProviders() {
+ return this.resources?.reduce((acc, res) => {
+ if (!acc.find(p => p.id === res.provider.id)) {
+ acc.push(res.provider)
+ }
+ return acc
+ }, []) ?? []
+ },
+
+ resourcesForProvider() {
+ return (providerId) => {
+ return this.resources?.filter(res => res.provider.id === providerId) ?? []
+ }
+ },
+ },
+
+ watch: {
+ 'circle.id': {
+ handler() {
+ this.fetchTeamResources()
+ },
+ immediate: true,
+ },
+ },
+
+ mounted() {
+ this.updateMemberLimit()
+ useResizeObserver(this.$refs.avatarList, () => {
+ this.updateMemberLimit()
+ })
},
methods: {
+ async fetchTeamResources() {
+ const response = await axios.get(generateOcsUrl(`/teams/${this.circle.id}/resources`))
+ this.resources = response.data.ocs.data.resources
+ },
/**
* Autocomplete @mentions on the description
*
@@ -232,11 +374,34 @@ export default {
this.loadingName = false
}
},
+ updateMemberLimit() {
+ this.memberLimit = Math.floor((this.$refs.avatarList.clientWidth - 44) / 44)
+ console.error(this.memberLimit)
+ },
},
}
</script>
<style lang="scss" scoped>
+.app-content-details header,
+.app-content-details section {
+ max-width: 800px;
+ margin: auto;
+ margin-bottom: 36px;
+
+ &:deep(.contact-header__avatar) {
+ width: 75px;
+ }
+
+ &:deep(.contact-header__no-wrap) {
+ flex-grow: 1;
+ }
+
+ &:deep(.contact-header__actions) {
+ flex-grow: 0;
+ }
+}
+
.circle-name__loader {
margin-left: 8px;
}
@@ -258,35 +423,50 @@ export default {
}
}
-// TODO: replace by button component when available
-button,
-.circle-details__action-copy-link {
- height: 44px;
- display: inline-flex;
- justify-content: center;
- align-items: center;
- text-align: left;
- span {
- margin-right: 10px;
- }
+.avatar-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
- &[class*='icon-'] {
- padding-left: 44px;
- background-position: 16px center;
+.members-modal {
+ padding: 12px;
+ h2 {
+ margin-bottom: 16px;
}
+
+ :deep(.app-content-list) {
+ max-width: 100%;
+ border: 0;
+ }
+}
+
+.circle-settings {
+ margin: 12px;
}
-.circle-details__action-delete {
- background-color: var(--color-error);
- color: white;
- border-width: 2px;
- border-color: var(--color-error) !important;
+.provider__icon {
+ display: inline-block;
+ width: 24px;
+ height: 24px;
+}
+.resource {
+ &__icon {
+ width: 44px;
+ height: 44px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ svg {
+ width: 20px;
+ height: 20px;
+ }
+ }
- &:hover,
- &:focus {
- background-color: var(--color-main-background);
- color: var(--color-error);
+ &:deep(.line-one__name) {
+ font-weight: normal;
}
}
</style>
diff --git a/src/components/Icons/IconCircles.vue b/src/components/Icons/IconCircles.vue
deleted file mode 100644
index 5b349c3c..00000000
--- a/src/components/Icons/IconCircles.vue
+++ /dev/null
@@ -1,38 +0,0 @@
-<template functional>
- <span :aria-hidden="!props.title"
- :aria-label="props.title"
- :class="[data.class, data.staticClass]"
- class="material-design-icon icon-circle"
- role="img"
- v-bind="data.attrs"
- v-on="listeners">
- <svg :fill="props.fillColor"
- class="material-design-icon__svg"
- :width="props.size"
- :height="props.size"
- viewBox="0 0 21.33 21.33">
- <path d="M0 0h24v24H0V0z" fill="none" />
- <path d="M10.67 1.33a9.34 9.34 0 100 18.68 9.34 9.34 0 000-18.68zM6.93 15.8a2.33 2.33 0 110-4.67 2.33 2.33 0 010 4.67zm1.4-8.87a2.33 2.33 0 114.67 0 2.33 2.33 0 01-4.67 0zm6.07 8.87a2.33 2.33 0 110-4.67 2.33 2.33 0 010 4.67z" />
- </svg>
- </span>
-</template>
-
-<script>
-export default {
- name: 'IconCircles',
- props: {
- title: {
- type: String,
- default: '',
- },
- size: {
- type: Number,
- default: 20,
- },
- fillColor: {
- type: String,
- default: 'currentColor',
- },
- },
-}
-</script>
diff --git a/src/components/MembersList/MembersListItem.vue b/src/components/MembersList/MembersListItem.vue
index fe1a186e..ea5a50c1 100644
--- a/src/components/MembersList/MembersListItem.vue
+++ b/src/components/MembersList/MembersListItem.vue
@@ -374,6 +374,9 @@ export default {
.members-list__item {
padding: 8px;
user-select: none;
+ box-sizing: border-box;
+ margin-bottom: 8px;
+ border-radius: var(--border-radius-rounded);
&:focus,
&:hover {