diff options
author | Julius Härtl <jus@bitgrid.net> | 2024-02-26 17:23:05 +0100 |
---|---|---|
committer | Julius Härtl <jus@bitgrid.net> | 2024-03-06 08:45:47 +0100 |
commit | e947bccda653851ca999ba980143c1290e162f43 (patch) | |
tree | 9445188d81026c1f24333e8b5f843cde4795acfa | |
parent | 995f51f546037f874d86b6e98b81bbdc45da7f15 (diff) |
feat: Implement team overview page updates
Signed-off-by: Julius Härtl <jus@bitgrid.net>
-rw-r--r-- | package-lock.json | 1 | ||||
-rw-r--r-- | package.json | 1 | ||||
-rw-r--r-- | src/components/AppContent/CircleContent.vue | 17 | ||||
-rw-r--r-- | src/components/CircleDetails.vue | 340 | ||||
-rw-r--r-- | src/components/Icons/IconCircles.vue | 38 | ||||
-rw-r--r-- | src/components/MembersList/MembersListItem.vue | 3 |
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 { |