aboutsummaryrefslogtreecommitdiffstats
path: root/apps/profile/src
diff options
context:
space:
mode:
Diffstat (limited to 'apps/profile/src')
-rw-r--r--apps/profile/src/main.ts27
-rw-r--r--apps/profile/src/services/ProfileSections.ts25
-rw-r--r--apps/profile/src/views/Profile.vue490
3 files changed, 542 insertions, 0 deletions
diff --git a/apps/profile/src/main.ts b/apps/profile/src/main.ts
new file mode 100644
index 00000000000..b48c6d5dc74
--- /dev/null
+++ b/apps/profile/src/main.ts
@@ -0,0 +1,27 @@
+/**
+ * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { getCSPNonce } from '@nextcloud/auth'
+import Vue from 'vue'
+
+import Profile from './views/Profile.vue'
+import ProfileSections from './services/ProfileSections.js'
+
+__webpack_nonce__ = getCSPNonce()
+
+if (!window.OCA) {
+ window.OCA = {}
+}
+
+if (!window.OCA.Core) {
+ window.OCA.Core = {}
+}
+Object.assign(window.OCA.Core, { ProfileSections: new ProfileSections() })
+
+const View = Vue.extend(Profile)
+
+window.addEventListener('DOMContentLoaded', () => {
+ new View().$mount('#content')
+})
diff --git a/apps/profile/src/services/ProfileSections.ts b/apps/profile/src/services/ProfileSections.ts
new file mode 100644
index 00000000000..9c6ca08e33f
--- /dev/null
+++ b/apps/profile/src/services/ProfileSections.ts
@@ -0,0 +1,25 @@
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+export default class ProfileSections {
+
+ _sections
+
+ constructor() {
+ this._sections = []
+ }
+
+ /**
+ * @param {registerSectionCallback} section To be called to mount the section to the profile page
+ */
+ registerSection(section) {
+ this._sections.push(section)
+ }
+
+ getSections() {
+ return this._sections
+ }
+
+}
diff --git a/apps/profile/src/views/Profile.vue b/apps/profile/src/views/Profile.vue
new file mode 100644
index 00000000000..046a731cb93
--- /dev/null
+++ b/apps/profile/src/views/Profile.vue
@@ -0,0 +1,490 @@
+<!--
+ - SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <NcContent app-name="profile">
+ <NcAppContent>
+ <div class="profile__header">
+ <div class="profile__header__container">
+ <div class="profile__header__container__placeholder" />
+ <div class="profile__header__container__displayname">
+ <h2>{{ displayname || userId }}</h2>
+ <span v-if="pronouns">·</span>
+ <span v-if="pronouns" class="profile__header__container__pronouns">{{ pronouns }}</span>
+ <NcButton v-if="isCurrentUser"
+ type="primary"
+ :href="settingsUrl">
+ <template #icon>
+ <PencilIcon :size="20" />
+ </template>
+ {{ t('profile', 'Edit Profile') }}
+ </NcButton>
+ </div>
+ <NcButton v-if="status.icon || status.message"
+ :disabled="!isCurrentUser"
+ :type="isCurrentUser ? 'tertiary' : 'tertiary-no-background'"
+ @click="openStatusModal">
+ {{ status.icon }} {{ status.message }}
+ </NcButton>
+ </div>
+ </div>
+
+ <div class="profile__wrapper">
+ <div class="profile__content">
+ <div class="profile__sidebar">
+ <NcAvatar class="avatar"
+ :class="{ interactive: isCurrentUser }"
+ :user="userId"
+ :size="180"
+ :show-user-status="true"
+ :show-user-status-compact="false"
+ :disable-menu="true"
+ :disable-tooltip="true"
+ :is-no-user="!isUserAvatarVisible"
+ @click.native.prevent.stop="openStatusModal" />
+
+ <div class="user-actions">
+ <!-- When a tel: URL is opened with target="_blank", a blank new tab is opened which is inconsistent with the handling of other URLs so we set target="_self" for the phone action -->
+ <NcButton v-if="primaryAction"
+ type="primary"
+ class="user-actions__primary"
+ :href="primaryAction.target"
+ :icon="primaryAction.icon"
+ :target="primaryAction.id === 'phone' ? '_self' :'_blank'">
+ <template #icon>
+ <!-- Fix for https://github.com/nextcloud-libraries/nextcloud-vue/issues/2315 -->
+ <img :src="primaryAction.icon" alt="" class="user-actions__primary__icon">
+ </template>
+ {{ primaryAction.title }}
+ </NcButton>
+ <NcActions class="user-actions__other" :inline="4">
+ <NcActionLink v-for="action in otherActions"
+ :key="action.id"
+ :close-after-click="true"
+ :href="action.target"
+ :target="action.id === 'phone' ? '_self' :'_blank'">
+ <template #icon>
+ <!-- Fix for https://github.com/nextcloud-libraries/nextcloud-vue/issues/2315 -->
+ <img :src="action.icon" alt="" class="user-actions__other__icon">
+ </template>
+ {{ action.title }}
+ </NcActionLink>
+ </NcActions>
+ </div>
+ </div>
+
+ <div class="profile__blocks">
+ <div v-if="organisation || role || address" class="profile__blocks-details">
+ <div v-if="organisation || role" class="detail">
+ <p>{{ organisation }} <span v-if="organisation && role">•</span> {{ role }}</p>
+ </div>
+ <div v-if="address" class="detail">
+ <p>
+ <MapMarkerIcon class="map-icon"
+ :size="16" />
+ {{ address }}
+ </p>
+ </div>
+ </div>
+ <template v-if="headline || biography || sections.length > 0">
+ <h3 v-if="headline" class="profile__blocks-headline">
+ {{ headline }}
+ </h3>
+ <NcRichText v-if="biography" :text="biography" use-extended-markdown />
+
+ <!-- additional entries, use it with cautious -->
+ <div v-for="(section, index) in sections"
+ :ref="'section-' + index"
+ :key="index"
+ class="profile__additionalContent">
+ <component :is="section($refs['section-'+index], userId)" :user-id="userId" />
+ </div>
+ </template>
+ <NcEmptyContent v-else
+ class="profile__blocks-empty-info"
+ :name="emptyProfileMessage"
+ :description="t('profile', 'The headline and about sections will show up here')">
+ <template #icon>
+ <AccountIcon :size="60" />
+ </template>
+ </NcEmptyContent>
+ </div>
+ </div>
+ </div>
+ </NcAppContent>
+ </NcContent>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue'
+import { generateUrl } from '@nextcloud/router'
+import { getCurrentUser } from '@nextcloud/auth'
+import { loadState } from '@nextcloud/initial-state'
+import { showError } from '@nextcloud/dialogs'
+import { subscribe, unsubscribe } from '@nextcloud/event-bus'
+import { translate as t } from '@nextcloud/l10n'
+
+import NcActions from '@nextcloud/vue/components/NcActions'
+import NcActionLink from '@nextcloud/vue/components/NcActionLink'
+import NcAppContent from '@nextcloud/vue/components/NcAppContent'
+import NcAvatar from '@nextcloud/vue/components/NcAvatar'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcContent from '@nextcloud/vue/components/NcContent'
+import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
+import NcRichText from '@nextcloud/vue/components/NcRichText'
+import AccountIcon from 'vue-material-design-icons/AccountOutline.vue'
+import MapMarkerIcon from 'vue-material-design-icons/MapMarker.vue'
+import PencilIcon from 'vue-material-design-icons/PencilOutline.vue'
+
+interface IProfileAction {
+ target: string
+ icon: string
+ id: string
+ title: string
+}
+
+interface IStatus {
+ icon: string,
+ message: string,
+ userId: string,
+}
+
+export default defineComponent({
+ name: 'Profile',
+
+ components: {
+ AccountIcon,
+ MapMarkerIcon,
+ NcActionLink,
+ NcActions,
+ NcAppContent,
+ NcAvatar,
+ NcButton,
+ NcContent,
+ NcEmptyContent,
+ NcRichText,
+ PencilIcon,
+ },
+
+ setup() {
+ return {
+ t,
+ }
+ },
+
+ data() {
+ const profileParameters = loadState('profile', 'profileParameters', {
+ userId: null as string|null,
+ displayname: null as string|null,
+ address: null as string|null,
+ organisation: null as string|null,
+ role: null as string|null,
+ headline: null as string|null,
+ biography: null as string|null,
+ actions: [] as IProfileAction[],
+ isUserAvatarVisible: false,
+ pronouns: null as string|null,
+ })
+
+ return {
+ ...profileParameters,
+ status: loadState<Partial<IStatus>>('profile', 'status', {}),
+ sections: window.OCA.Core.ProfileSections.getSections(),
+ }
+ },
+
+ computed: {
+ isCurrentUser() {
+ return getCurrentUser()?.uid === this.userId
+ },
+
+ allActions() {
+ return this.actions
+ },
+
+ primaryAction() {
+ if (this.allActions.length) {
+ return this.allActions[0]
+ }
+ return null
+ },
+
+ otherActions() {
+ if (this.allActions.length > 1) {
+ return this.allActions.slice(1)
+ }
+ return []
+ },
+
+ settingsUrl() {
+ return generateUrl('/settings/user')
+ },
+
+ emptyProfileMessage() {
+ return this.isCurrentUser
+ ? t('profile', 'You have not added any info yet')
+ : t('profile', '{user} has not added any info yet', { user: (this.displayname || this.userId || '') })
+ },
+ },
+
+ mounted() {
+ // Set the user's displayname or userId in the page title and preserve the default title of "Nextcloud" at the end
+ document.title = `${this.displayname || this.userId} - ${document.title}`
+ subscribe('user_status:status.updated', this.handleStatusUpdate)
+ },
+
+ beforeDestroy() {
+ unsubscribe('user_status:status.updated', this.handleStatusUpdate)
+ },
+
+ methods: {
+ handleStatusUpdate(status: IStatus) {
+ if (this.isCurrentUser && status.userId === this.userId) {
+ this.status = status
+ }
+ },
+
+ openStatusModal() {
+ const statusMenuItem = document.querySelector<HTMLButtonElement>('.user-status-menu-item')
+ // Changing the user status is only enabled if you are the current user
+ if (this.isCurrentUser) {
+ if (statusMenuItem) {
+ statusMenuItem.click()
+ } else {
+ showError(t('profile', 'Error opening the user status modal, try hard refreshing the page'))
+ }
+ }
+ },
+ },
+})
+</script>
+
+<style lang="scss" scoped>
+$profile-max-width: 1024px;
+$content-max-width: 640px;
+
+:deep(#app-content-vue) {
+ background-color: unset;
+}
+
+.profile {
+ width: 100%;
+ overflow-y: auto;
+
+ &__header {
+ display: flex;
+ position: sticky;
+ height: 190px;
+ top: -40px;
+ background-color: var(--color-main-background-blur);
+ backdrop-filter: var(--filter-background-blur);
+ -webkit-backdrop-filter: var(--filter-background-blur);
+
+ &__container {
+ align-self: flex-end;
+ width: 100%;
+ max-width: $profile-max-width;
+ margin: 8px auto;
+ row-gap: 8px;
+ display: grid;
+ grid-template-rows: max-content max-content;
+ grid-template-columns: 240px 1fr;
+ justify-content: center;
+
+ &__placeholder {
+ grid-row: 1 / 3;
+ }
+
+ &__displayname {
+ padding-inline: 16px; // same as the status text button, see NcButton
+ width: $content-max-width;
+ height: 45px;
+ margin-block: 125px 0;
+ display: flex;
+ align-items: center;
+ gap: 18px;
+
+ h2 {
+ font-size: 30px;
+ margin: 0;
+ }
+
+ span {
+ font-size: 20px;
+ }
+ }
+ }
+ }
+
+ &__sidebar {
+ position: sticky;
+ top: 0;
+ align-self: flex-start;
+ padding-top: 20px;
+ min-width: 220px;
+ margin-block: -150px 0;
+ margin-inline: 0 20px;
+
+ // Specificity hack is needed to override Avatar component styles
+ :deep(.avatar.avatardiv) {
+ text-align: center;
+ margin: auto;
+ display: block;
+ padding: 8px;
+
+ &.interactive {
+ .avatardiv__user-status {
+ // Show that the status is interactive
+ cursor: pointer;
+ }
+ }
+
+ .avatardiv__user-status {
+ inset-inline-end: 14px;
+ bottom: 14px;
+ width: 34px;
+ height: 34px;
+ background-size: 28px;
+ border: none;
+ // Styles when custom status icon and status text are set
+ background-color: var(--color-main-background);
+ line-height: 34px;
+ font-size: 20px;
+ }
+ }
+ }
+
+ &__wrapper {
+ background-color: var(--color-main-background);
+ min-height: 100%;
+ }
+
+ &__content {
+ max-width: $profile-max-width;
+ margin: 0 auto;
+ display: flex;
+ width: 100%;
+ }
+
+ &__blocks {
+ margin: 18px 0 80px 0;
+ display: grid;
+ gap: 16px 0;
+ width: $content-max-width;
+
+ p, h3 {
+ cursor: text;
+ overflow-wrap: anywhere;
+ }
+
+ &-details {
+ display: flex;
+ flex-direction: column;
+ gap: 2px 0;
+
+ .detail {
+ display: inline-block;
+ color: var(--color-text-maxcontrast);
+
+ p .map-icon {
+ display: inline-block;
+ vertical-align: middle;
+ }
+ }
+ }
+
+ &-headline {
+ margin-inline: 0;
+ margin-block: 10px 0;
+ font-weight: bold;
+ font-size: 20px;
+ }
+ }
+}
+
+@media only screen and (max-width: 1024px) {
+ .profile {
+ &__header {
+ height: 250px;
+ position: unset;
+
+ &__container {
+ grid-template-columns: unset;
+ margin-bottom: 110px;
+
+ &__displayname {
+ margin: 80px 20px 0px 0px!important;
+ width: unset;
+ text-align: center;
+ padding-inline: 12px;
+ }
+
+ &__edit-button {
+ width: fit-content;
+ display: block;
+ margin: 60px auto;
+ }
+
+ &__status-text {
+ margin: 4px auto;
+ }
+ }
+ }
+
+ &__content {
+ display: block;
+
+ .avatar {
+ // Overlap avatar to top header
+ margin-top: -110px !important;
+ }
+ }
+
+ &__blocks {
+ width: unset;
+ max-width: 600px;
+ margin: 0 auto;
+ padding: 20px 50px 50px 50px;
+ }
+
+ &__sidebar {
+ margin: unset;
+ position: unset;
+ }
+ }
+}
+
+.user-actions {
+ display: flex;
+ flex-direction: column;
+ gap: 8px 0;
+ margin-top: 20px;
+ max-width: 300px;
+
+ &__primary {
+ margin: 0 auto;
+ max-width: 100%;
+
+ &__icon {
+ filter: var(--primary-invert-if-dark);
+ }
+ }
+
+ &__other {
+ display: flex;
+ justify-content: center;
+ gap: 0 4px;
+
+ &__icon {
+ height: 20px;
+ width: 20px;
+ object-fit: contain;
+ filter: var(--background-invert-if-dark);
+ align-self: center;
+ margin: 12px; // so we get 44px x 44px
+ }
+ }
+}
+</style>