diff options
author | John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com> | 2020-06-19 07:36:52 +0200 |
---|---|---|
committer | John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com> | 2020-08-21 09:56:07 +0200 |
commit | 63b2aff43903d51fc382a7c6cb0019845363c183 (patch) | |
tree | a88f3787e8bf2f28fe05388b66bdb9f198b83512 | |
parent | d8ede82510afe55a06c754710463a45eab6e5d7c (diff) |
Temp
Signed-off-by: John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
-rw-r--r-- | src/components/ContactDetails.vue | 8 | ||||
-rw-r--r-- | src/components/ContactsList.vue | 4 | ||||
-rw-r--r-- | src/components/EntityPicker/EntityBubble.vue | 109 | ||||
-rw-r--r-- | src/components/EntityPicker/EntityPicker.vue | 334 | ||||
-rw-r--r-- | src/components/EntityPicker/EntitySearchResult.vue | 122 | ||||
-rw-r--r-- | src/components/Properties/PropertyGroups.vue | 6 | ||||
-rw-r--r-- | src/views/Contacts.vue | 177 | ||||
-rw-r--r-- | webpack.common.js | 3 |
8 files changed, 689 insertions, 74 deletions
diff --git a/src/components/ContactDetails.vue b/src/components/ContactDetails.vue index da1b7e87..0735a3d0 100644 --- a/src/components/ContactDetails.vue +++ b/src/components/ContactDetails.vue @@ -105,7 +105,7 @@ show: true, trigger: 'manual', }" - class="header-icon header-icon--pulse icon-history-force-white" + class="header-icon header-icon--pulse icon-history" @click="refreshContact" /> <!-- repaired contact message --> @@ -115,7 +115,7 @@ show: true, trigger: 'manual', }" - class="header-icon header-icon--pulse icon-up-force-white" + class="header-icon header-icon--pulse icon-up" @click="updateContact" /> <!-- menu actions --> @@ -316,12 +316,12 @@ export default { warning() { if (!this.contact.dav) { return { - icon: 'icon-error-white header-icon--pulse', + icon: 'icon-error header-icon--pulse', msg: t('contacts', 'This contact is not yet synced. Edit it to save it to the server.'), } } else if (this.isReadOnly) { return { - icon: 'icon-eye-white', + icon: 'icon-eye', msg: t('contacts', 'This contact is in read-only mode. You do not have permission to edit this contact.'), } } diff --git a/src/components/ContactsList.vue b/src/components/ContactsList.vue index 222b4f1e..1a5f0745 100644 --- a/src/components/ContactsList.vue +++ b/src/components/ContactsList.vue @@ -180,7 +180,7 @@ export default { onAddContactsToGroup() { // TODO: add popup - } + }, }, } </script> @@ -199,4 +199,4 @@ export default { // same as app-content-list-item height: 68px; } -</style>
\ No newline at end of file +</style> diff --git a/src/components/EntityPicker/EntityBubble.vue b/src/components/EntityPicker/EntityBubble.vue new file mode 100644 index 00000000..0394d9c2 --- /dev/null +++ b/src/components/EntityPicker/EntityBubble.vue @@ -0,0 +1,109 @@ +<!-- + - @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com> + - + - @author John Molakvoæ <skjnldsv@protonmail.com> + - @author Team Popcorn <teampopcornberlin@gmail.com> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + - + --> +<template> + <UserBubble + class="entity-picker__bubble" + :margin="0" + :size="22" + :display-name="label"> + <template #title> + <a href="#" + :title="t('contacts', 'Remove {type}', { type })" + class="entity-picker__bubble-delete icon-close" + @click="onDelete" /> + </template> + </UserBubble> +</template> + +<script> +import UserBubble from '@nextcloud/vue/dist/Components/UserBubble' + +export default { + name: 'EntityBubble', + + components: { + UserBubble, + }, + + props: { + /** + * Unique id of the entity + */ + id: { + type: String, + required: true, + }, + + /** + * Label of the entity + */ + label: { + type: String, + required: true, + }, + + /** + * Type of the entity. e.g user, circle, group... + */ + type: { + type: String, + required: true, + }, + }, + + methods: { + onDelete() { + // Emit delete. Be aware it might be unique + // amongst their types, but not unique amongst + // the whole selection all. Make sure to + // properly compare all necessary data. + this.$emit('delete', { + id: this.id, + type: this.type, + }) + }, + }, +} +</script> + +<style lang="scss" scoped> +// better visual with light default tint +::v-deep .user-bubble__content { + background-color: var(--color-primary-light); +} + +.entity-picker__bubble-delete { + display: block; + height: 100%; + // squeeze in the border radius + margin-right: -4px; + opacity: .7; + + &:hover, + &:active, + &:focus { + opacity: 1; + } +} + +</style> diff --git a/src/components/EntityPicker/EntityPicker.vue b/src/components/EntityPicker/EntityPicker.vue new file mode 100644 index 00000000..d0421af0 --- /dev/null +++ b/src/components/EntityPicker/EntityPicker.vue @@ -0,0 +1,334 @@ +<!-- + - @copyright Copyright (c) 2019 Marco Ambrosini <marcoambrosini@pm.me> + - + - @author Marco Ambrosini <marcoambrosini@pm.me> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. +--> + +<template> + <Modal + size="full" + @close="onCancel"> + <!-- Wrapper for content & navigation --> + <div + class="entity-picker"> + <!-- Search --> + <div class="entity-picker__search"> + <div class="entity-picker__search-icon icon-search" /> + <input + v-model="searchQuery" + class="entity-picker__search-input" + type="search" + :placeholder="t('contacts', 'Search contacts')"> + </div> + + <!-- Content --> + <div class="entity-picker__content"> + <!-- Picked entities --> + <transition-group + v-if="selection.length > 0" + name="zoom" + tag="ul" + class="entity-picker__selection"> + <EntityBubble + v-for="entity in selection" + :key="entity.key || `entity-${entity.type}-${entity.id}`" + v-bind="entity" + @delete="onDelete(entity)" /> + </transition-group> + + <!-- Searched & picked entities --> + <template v-if="searchSet.length > 0 && availableEntities.length > 0"> + <section v-for="type in availableEntities" :key="type.id" class="entity-picker__options"> + <h4 v-if="isSingleType" class="entity-picker__options-caption"> + {{ t('contacts', 'Add {type}', {type: type.label}) }} + </h4> + <EntitySearchResult v-for="entity in type.dataSet" + :key="entity.key || `entity-${entity.type}-${entity.id}`" + v-bind="entity" /> + </section> + </template> + <EmptyContent v-else-if="searchQuery" icon="icon-search"> + {{ t('contacts', 'No results') }} + </EmptyContent> + <!-- TODO: find better wording/icon --> + <EmptyContent v-else icon=""> + {{ t('contacts', 'Loading …') }} + </EmptyContent> + </div> + + <div class="entity-picker__navigation"> + <button + class="navigation__button-left" + @click="onCancel"> + {{ t('contacts', 'Cancel') }} + </button> + <button + class="navigation__button-right primary" + @click="onSubmit"> + {{ t('contacts', 'Add to group') }} + </button> + </div> + </div> + </modal> +</template> + +<script> +import Modal from '@nextcloud/vue/dist/Components/Modal' +import EmptyContent from '@nextcloud/vue/dist/Components/EmptyContent' +import EntityBubble from './EntityBubble' +import EntitySearchResult from './EntitySearchResult' + +export default { + name: 'EntityPicker', + + components: { + EmptyContent, + EntityBubble, + EntitySearchResult, + Modal, + }, + + props: { + dataSet: { + type: Array, + required: true, + }, + dataTypes: { + type: Array, + required: true, + }, + sort: { + type: String, + default: 'label', + }, + }, + + data() { + return { + searchQuery: '', + selection: [ + { type: 'user', label: 'Test 4 Lorem ipsum is a very long user name', id: 'test4' }, + { type: 'user', label: 'Test 2', id: 'test2' }, + { type: 'user', label: 'Test 1 (AVHJ)', id: 'tes2' }, + { type: 'user', label: 'Test 278 975 869', id: 'tebst2' }, + { type: 'user', label: 'Administrator', id: 'tespot2' }, + ], + } + }, + + computed: { + /** + * Are we handling a single entity type ? + * @returns {boolean} + */ + isSingleType() { + return !(this.dataTypes.length > 1) + }, + + /** + * Available data based on current search if query + * is valid, returns default full data et otherwise + * @returns {Object[]} + */ + searchSet() { + if (this.searchQuery && this.searchQuery.trim !== '') { + return this.dataSet.filter(entity => { + return entity.label.indexOf(this.searchQuery) > -1 + }) + } + return this.dataSet + }, + + /** + * Returns available entities grouped by type(s) if any + * @returns {Array[]} + */ + availableEntities() { + // If only one type, return the full set directly + if (this.isSingleType) { + return [{ + id: this.dataTypes[0].id, + label: this.dataTypes[0].label, + dataSet: this.searchSet, + }] + } + + // Else group by types + return this.dataTypes.map(type => ({ + id: type.id, + label: type.label, + dataSet: this.searchSet.filter(entity => entity.type === type), + })) + }, + }, + + methods: { + onCancel() { + this.$emit('close') + }, + onSubmit() { + this.$emit('submit', this.selection) + }, + /** + * Remove entity from selection + * @param {Object} entity the entity to remove + */ + onDelete(entity) { + const index = this.selection.findIndex(search => search === entity) + this.selection.splice(index, 1) + console.debug('Removing entity from selection', entity) + }, + }, + +} + +</script> + +<style lang="scss" scoped> + +// Dialog variables +$dialog-margin: 20px; +$dialog-width: 300px; +$dialog-height: 480px; + +// https://uxplanet.org/7-rules-for-mobile-ui-button-design-e9cf2ea54556 +// recommended is 48px +// 44px is what we choose and have very good visual-to-usability ratio +$clickable-area: 44px; + +// background icon size +// also used for the scss icon font +$icon-size: 16px; + +// icon padding for a $clickable-area width and a $icon-size icon +// ( 44px - 16px ) / 2 +$icon-margin: ($clickable-area - $icon-size) / 2; + +.entity-picker { + position: relative; + display: flex; + flex-direction: column; + justify-content: space-between; + /** This next 2 rules are pretty hacky, with the modal component somehow + the margin applied to the content is added to the total modal width, + so here we subtract it to the width and height of the content. + */ + width: $dialog-width - $dialog-margin * 2; + height: $dialog-height - $dialog-margin * 2; + margin: $dialog-margin; + + &__search { + position: relative; + display: flex; + align-items: center; + &-input { + width: 100%; + height: $clickable-area !important; + padding-left: $clickable-area; + font-size: 16px; + line-height: $clickable-area; + } + &-icon { + position: absolute; + width: $clickable-area; + height: $clickable-area; + } + } + + &__content { + height: 100%; + } + + &__selection { + display: flex; + overflow-y: auto; + align-content: flex-start; + flex: 1 0 auto; + flex-wrap: wrap; + // half a line height to know there is more lines + max-height: 6.5em; + padding: 4px 0; + border-bottom: 1px solid var(--color-background-darker); + } + + &__navigation { + z-index: 1; + display: flex; + // define our base width, no shrinkage + flex: 0 0; + justify-content: space-between; + // Same as above + width: $dialog-width - $dialog-margin * 2; + box-shadow: 0 -10px 5px var(--color-main-background); + &__button-right { + margin-left: auto; + } + } +} + +.entity-picker__options { + margin: 4px 0; + display: flex; + flex-direction: column; + justify-content: center; + &-caption { + color: var(--color-primary); + line-height: $clickable-area; + list-style-type: none; + white-space: nowrap; + text-overflow: ellipsis; + box-shadow: none !important; + user-select: none; + pointer-events: none; + padding-left: 10px; + + &:not(:first-child) { + margin-top: $clickable-area / 2; + } + } +} + +/** Size full in the modal component doesn't have border radius, this adds +it back */ +::v-deep .modal-container { + border-radius: var(--border-radius-large) !important; +} + +</style> + +<style lang="scss" scoped> +.zoom-enter-active { + animation: zoom-in var(--animation-quick); +} + +.zoom-leave-active { + animation: zoom-in var(--animation-quick) reverse; + + will-change: transform; +} + +@keyframes zoom-in { + 0% { + transform: scale(0); + } + 100% { + transform: scale(1); + } +} + +</style> diff --git a/src/components/EntityPicker/EntitySearchResult.vue b/src/components/EntityPicker/EntitySearchResult.vue new file mode 100644 index 00000000..e0a25109 --- /dev/null +++ b/src/components/EntityPicker/EntitySearchResult.vue @@ -0,0 +1,122 @@ +<!-- + - @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com> + - + - @author John Molakvoæ <skjnldsv@protonmail.com> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. +--> + +<template> + <UserBubble + class="entity-picker__bubble" + :class="{'entity-picker__bubble--selected': selected}" + :display-name="label" + :margin="6" + :size="44" + url="#" + @click="onClick"> + <template #title> + <span class="entity-picker__bubble-checkmark icon-checkmark" /> + </template> + </UserBubble> +</template> + +<script> +import UserBubble from '@nextcloud/vue/dist/Components/UserBubble' + +export default { + name: 'EntitySearchResult', + + components: { + UserBubble, + }, + + props: { + /** + * Unique id of the entity + */ + id: { + type: String, + required: true, + }, + + /** + * Label of the entity + */ + label: { + type: String, + required: true, + }, + + /** + * Label of the entity + */ + selected: { + type: Boolean, + default: false, + }, + }, + + methods: { + onClick(event) { + console.info(event) + this.$emit('click', event) + }, + }, +} + +</script> + +<style lang="scss" scoped> +.entity-picker__bubble { + display: flex; + margin-bottom: 4px; + .entity-picker__bubble-checkmark { + display: block; + opacity: 0; + } + + &--selected, + &:hover, + &:focus { + .entity-picker__bubble-checkmark { + opacity: 1; + } + ::v-deep .user-bubble__content { + // better visual with light default tint + background-color: var(--color-primary-light); + } + } +} + +::v-deep .user-bubble__content { + // Take full width + width: 100%; + // Override default styling + background: none; + .user-bubble__secondary { + // Force show checkmark + display: inline-flex; + margin-right: 4px; + margin-left: auto; + } + &, * { + // the whole row is clickable,let's force the proper cursor + cursor: pointer; + } +} + +</style> diff --git a/src/components/Properties/PropertyGroups.vue b/src/components/Properties/PropertyGroups.vue index 03026992..63990e08 100644 --- a/src/components/Properties/PropertyGroups.vue +++ b/src/components/Properties/PropertyGroups.vue @@ -22,7 +22,9 @@ <template> <div v-if="propModel" class="grid-span-2 property property--without-actions"> - <!-- NO title if first element for groups --> + <PropertyTitle + icon="icon-contacts-dark" + :readable-name="t('contacts', 'Groups')" /> <div class="property__row"> <div class="property__label"> @@ -60,11 +62,13 @@ import debounce from 'debounce' import Multiselect from '@nextcloud/vue/dist/Components/Multiselect' import Contact from '../../models/contact' +import PropertyTitle from './PropertyTitle' export default { name: 'PropertyGroups', components: { + PropertyTitle, Multiselect, }, diff --git a/src/views/Contacts.vue b/src/views/Contacts.vue index 60784a97..0f4364a8 100644 --- a/src/views/Contacts.vue +++ b/src/views/Contacts.vue @@ -35,21 +35,57 @@ <!-- groups list --> <ul v-if="!loading" id="groups-list"> - <AppNavigationItem v-for="item in menu" - :key="item.key" - :to="item.router" - :title="item.text" - :icon="item.icon"> + <!-- All contacts group --> + <AppNavigationItem id="everyone" + :title="GROUP_ALL_CONTACTS" + :to="{ + name: 'group', + params: { selectedGroup: GROUP_ALL_CONTACTS }, + }" + icon="icon-contacts-dark"> + <AppNavigationCounter slot="counter"> + {{ sortedContacts.length }} + </AppNavigationCounter> + </AppNavigationItem> + + <!-- Not grouped group --> + <AppNavigationItem + v-if="ungroupedContacts.length > 0" + id="notgrouped" + :title="GROUP_NO_GROUP_CONTACTS" + :to="{ + name: 'group', + params: { selectedGroup: GROUP_NO_GROUP_CONTACTS }, + }" + icon="icon-user"> + <AppNavigationCounter slot="counter"> + {{ ungroupedContacts.length }} + </AppNavigationCounter> + </AppNavigationItem> + + <AppNavigationSpacer /> + + <!-- Custom groups --> + <AppNavigationItem v-for="group in groupsMenu" + :key="group.key" + :to="group.router" + :title="group.name" + :icon="group.icon"> <template slot="actions"> - <ActionButton v-for="action in item.utils.actions" - :key="action.text" - :icon="action.icon" - @click="action.action"> - {{ action.text }} + <ActionButton + icon="icon-add" + @click="addContactsToGroup(group)"> + {{ t('contacts', 'Add contacts to this group') }} + </ActionButton> + <ActionButton + icon="icon-download" + @click="downloadGroup(group)"> + {{ t('contacts', 'Download') }} </ActionButton> </template> + <AppNavigationCounter slot="counter"> - {{ item.utils.counter }} + {{ group.contacts.length }} </AppNavigationCounter> </AppNavigationItem> @@ -95,12 +131,21 @@ <ContactDetails :loading="loading" :contact-key="selectedContact" /> </div> </AppContent> + + <!-- Import modal --> <Modal v-if="isImporting" :clear-view-delay="-1" :can-close="isImportDone" @close="closeImport"> <ImportScreen /> </Modal> + + <!-- Select contacts group modal --> + <EntityPicker v-if="showContactPicker" + :data-types="pickerTypes" + :data-set="pickerData" + @close="onContactPickerClose" + @submit="onContactPickerPick" /> </Content> </template> @@ -108,6 +153,7 @@ import AppContent from '@nextcloud/vue/dist/Components/AppContent' import AppNavigation from '@nextcloud/vue/dist/Components/AppNavigation' import AppNavigationItem from '@nextcloud/vue/dist/Components/AppNavigationItem' +import AppNavigationSpacer from '@nextcloud/vue/dist/Components/AppNavigationSpacer' import AppNavigationCounter from '@nextcloud/vue/dist/Components/AppNavigationCounter' import AppNavigationNew from '@nextcloud/vue/dist/Components/AppNavigationNew' import AppNavigationSettings from '@nextcloud/vue/dist/Components/AppNavigationSettings' @@ -125,6 +171,7 @@ import SettingsSection from '../components/SettingsSection' import ContactsList from '../components/ContactsList' import ContactDetails from '../components/ContactDetails' import ImportScreen from '../components/ImportScreen' +import EntityPicker from '../components/EntityPicker/EntityPicker' import Contact from '../models/contact' import rfcProps from '../models/rfcProps' @@ -144,12 +191,14 @@ export default { AppNavigationCounter, AppNavigationNew, AppNavigationSettings, + AppNavigationSpacer, ActionButton, ActionInput, ContactDetails, ContactsList, Content, ImportScreen, + EntityPicker, Modal, SettingsSection, }, @@ -173,10 +222,18 @@ export default { data() { return { - isNewGroupMenuOpen: false, + GROUP_ALL_CONTACTS, + GROUP_NO_GROUP_CONTACTS, isCreatingGroup: false, + isNewGroupMenuOpen: false, loading: true, searchQuery: '', + showContactPicker: true, + contactPickerforGroup: null, + pickerTypes: [{ + id: 'contacts', + label: t('contacts', 'contacts'), + }], } }, @@ -242,70 +299,32 @@ export default { // generate groups menu from groups store groupsMenu() { return this.groups.map(group => { - return { + return Object.assign(group, { id: group.name.replace(' ', '_'), key: group.name.replace(' ', '_'), router: { name: 'group', params: { selectedGroup: group.name }, }, - text: group.name, - utils: { - counter: group.contacts.length, - actions: [ - { - icon: 'icon-download', - text: 'Download', - action: () => this.downloadGroup(group), - }, - ], - }, - } + icon: group.name === t('contactsinteraction', 'Recently contacted') + ? 'icon-recent-actors' + : '', + }) }).sort(function(a, b) { - return parseInt(b.utils.counter) - parseInt(a.utils.counter) + return parseInt(b.contacts.length) - parseInt(a.contacts.length) }) }, - // building the main menu - menu() { - return this.groupAllGroup.concat(this.groupNotGrouped.concat(this.groupsMenu)) - }, - - // default group for every contacts - groupAllGroup() { - return [{ - id: 'everyone', - key: 'everyone', - icon: 'icon-contacts-dark', - router: { - name: 'group', - params: { selectedGroup: GROUP_ALL_CONTACTS }, - }, - text: GROUP_ALL_CONTACTS, - utils: { - counter: this.sortedContacts.length, - }, - }] - }, - - // default group for every contacts - groupNotGrouped() { - if (this.ungroupedContacts.length === 0) { - return [] - } - return [{ - id: 'notgrouped', - key: 'notgrouped', - icon: 'icon-user', - router: { - name: 'group', - params: { selectedGroup: GROUP_NO_GROUP_CONTACTS }, - }, - text: GROUP_NO_GROUP_CONTACTS, - utils: { - counter: this.ungroupedContacts.length, - }, - }] + /** + * Contacts formatted for the EntityPicker + * @returns {Array} + */ + pickerData() { + return Object.values(this.contacts).map(contact => ({ + id: contact.key, + label: contact.displayName, + type: 'contact', + })) }, }, @@ -539,7 +558,33 @@ export default { const groupName = input.value.trim() this.$store.dispatch('addGroup', groupName) this.isNewGroupMenuOpen = false + + // Select group + this.$router.push({ + name: 'contact', + params: { + selectedGroup: groupName, + selectedContact: undefined, + }, + }) + }, + + // Bulk contacts group management handlers + addContactsToGroup(group) { + this.showContactPicker = true + this.contactPickerforGroup = group }, + + onContactPickerClose() { + this.showContactPicker = false + }, + + onContactPickerPick(selection) { + const group = this.contactPickerforGroup + console.info('Adding', selection, 'to group', group) + this.contactPickerforGroup = null + }, + }, } |