diff options
author | John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com> | 2020-08-21 09:46:20 +0200 |
---|---|---|
committer | John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com> | 2020-08-21 17:25:14 +0200 |
commit | 417448cac5e1ebac691a8fc417a578f45e9bf08e (patch) | |
tree | 56c46b30a9a42dd721f8426b3faee4fd9a54b56d /src | |
parent | 68c5b1ce9c2739e2909dc32dbf8f302f52b7aacb (diff) |
Implement masonry
Signed-off-by: John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
Diffstat (limited to 'src')
-rw-r--r-- | src/components/ContactDetails.vue | 225 | ||||
-rw-r--r-- | src/components/ContactDetails/ContactDetailsAddNewProp.vue | 2 | ||||
-rw-r--r-- | src/components/ContactDetails/ContactDetailsProperty.vue | 45 | ||||
-rw-r--r-- | src/components/ContactsList.vue | 3 | ||||
-rw-r--r-- | src/components/Properties/PropertyDateTime.vue | 9 | ||||
-rw-r--r-- | src/components/Properties/PropertyGroups.vue | 2 | ||||
-rw-r--r-- | src/components/Properties/PropertyMultipleText.vue | 10 | ||||
-rw-r--r-- | src/components/Properties/PropertySelect.vue | 8 | ||||
-rw-r--r-- | src/components/Properties/PropertyText.vue | 11 |
9 files changed, 232 insertions, 83 deletions
diff --git a/src/components/ContactDetails.vue b/src/components/ContactDetails.vue index 58d68ff6..e806904a 100644 --- a/src/components/ContactDetails.vue +++ b/src/components/ContactDetails.vue @@ -183,60 +183,74 @@ <section v-if="loadingData" class="icon-loading contact-details" /> <!-- contact details --> - <section v-else class="contact-details"> + <section v-else + v-masonry="contactDetailsSelector" + class="contact-details" + :fit-width="true" + item-selector=".property-masonry" + :transition-duration="0"> <!-- properties iteration --> <!-- using contact.key in the key and index as key to avoid conflicts between similar data and exact key --> <!-- passing the debounceUpdateContact so that the contact-property component contains the function and allow us to use it on the rfcProps since the scope is forwarded to the actions --> - <ContactProperty v-for="(property, index) in sortedProperties" - :key="`${index}-${contact.key}-${property.name}`" - :index="index" - :sorted-properties="sortedProperties" - :property="property" - :contact="contact" - :local-contact="localContact" - :update-contact="debounceUpdateContact" /> + <div v-for="(properties, name) in groupedProperties" + :key="name" + v-masonry-tile + class="property-masonry"> + <ContactProperty v-for="(property, index) in properties" + :key="`${index}-${contact.key}-${property.name}`" + :is-first-property="index===0" + :is-last-property="index === properties.length - 1" + :property="property" + :contact="contact" + :local-contact="localContact" + :update-contact="debounceUpdateContact" /> + </div> <!-- addressbook change select - no last property because class is not applied here, empty property because this is a required prop on regular property-select. But since we are hijacking this... (this is supposed to be used with a ICAL.property, but to avoid code duplication, we created a fake propModel and property with our own options here) --> - <PropertySelect v-if="addressbooksOptions.length > 1" + <PropertySelect v-masonry-tile :prop-model="addressbookModel" :value.sync="addressbook" :is-first-property="true" :is-last-property="true" :property="{}" - class="property--addressbooks property--last property--without-actions" /> + class="property-masonry property--addressbooks property--last property--without-actions" /> <!-- Groups always visible --> - <PropertyGroups :prop-model="groupsModel" + <PropertyGroups v-masonry-tile + :prop-model="groupsModel" :value.sync="groups" :contact="contact" :is-read-only="isReadOnly" - class="property--groups property--last" /> + class="property-masonry property--groups property--last" /> + + <!-- new property select --> + <AddNewProp v-masonry-tile :contact="contact" class="property-masonry" /> <!-- Last modified--> <PropertyRev v-if="contact.rev" :value="contact.rev" /> - - <!-- new property select --> - <AddNewProp v-if="!isReadOnly" :contact="contact" /> </section> </template> </div> </template> <script> +import { showError } from '@nextcloud/dialogs' +import { stringify } from 'ical.js' import debounce from 'debounce' import PQueue from 'p-queue' import qr from 'qr-image' -import { stringify } from 'ical.js' -import Actions from '@nextcloud/vue/dist/Components/Actions' -import ActionLink from '@nextcloud/vue/dist/Components/ActionLink' +import Vue from 'vue' +import { VueMasonryPlugin } from 'vue-masonry' + import ActionButton from '@nextcloud/vue/dist/Components/ActionButton' -import Multiselect from '@nextcloud/vue/dist/Components/Multiselect' +import ActionLink from '@nextcloud/vue/dist/Components/ActionLink' +import Actions from '@nextcloud/vue/dist/Components/Actions' import Modal from '@nextcloud/vue/dist/Components/Modal' -import { showError } from '@nextcloud/dialogs' +import Multiselect from '@nextcloud/vue/dist/Components/Multiselect' import rfcProps from '../models/rfcProps' import validate from '../services/validate' @@ -249,6 +263,7 @@ import PropertyGroups from './Properties/PropertyGroups' import PropertyRev from './Properties/PropertyRev' import PropertySelect from './Properties/PropertySelect' +Vue.use(VueMasonryPlugin) const updateQueue = new PQueue({ concurrency: 1 }) export default { @@ -297,6 +312,8 @@ export default { qrcode: '', showPickAddressbookModal: false, pickedAddressbook: null, + + contactDetailsSelector: '.contact-details', } }, @@ -356,6 +373,29 @@ export default { }, /** + * Contact properties filtered and grouped by rfcProps.fieldOrder + * + * @returns {Object} + */ + groupedProperties() { + return this.sortedProperties + .reduce((list, property) => { + // If there is no component to display this prop, ignore it + if (!this.canDisplay(property)) { + return list + } + + // Init if needed + if (!list[property.name]) { + list[property.name] = [] + } + + list[property.name].push(property) + return list + }, {}) + }, + + /** * Fake model to use the propertySelect component * * @returns {Object} @@ -442,9 +482,16 @@ export default { if (this.contactKey && newContact !== oldContact) { this.selectContact(this.contactKey) } + + // Reflow grid + this.$redrawVueMasonry(this.contactDetailsSelector) }, }, + updated() { + this.$redrawVueMasonry(this.contactDetailsSelector) + }, + beforeMount() { // load the desired data if we already selected a contact if (this.contactKey) { @@ -708,6 +755,142 @@ export default { }) } }, + + /** + * Should display the property + * + * @param {Property} property the property to check + * @returns {boolean} + */ + canDisplay(property) { + const propModel = rfcProps.properties[property.name] + const propType = propModel && propModel.force + ? propModel.force + : property.getDefaultType() + + return propModel && propType !== 'unknown' + }, }, } </script> + +<style lang="scss"> +#contact-details { + flex: 1 1 100%; + min-width: 0; + + // Header with avatar, name, position, actions... + header { + display: flex; + align-items: center; + padding: 50px 0 20px; + font-weight: bold; + + // ORG-TITLE-NAME + #contact-header-infos { + display: flex; + flex: 1 1 auto; // shrink avatar before this one + flex-direction: column; + h2, + #details-org-container { + display: flex; + flex-wrap: wrap; + margin: 0; + } + input { + overflow: hidden; + flex: 1 1; + min-width: 100px; + max-width: 100%; + margin: 0; + padding: 4px 5px; + white-space: nowrap; + text-overflow: ellipsis; + border: none; + background: transparent; + font-size: inherit; + &#contact-fullname { + font-weight: bold; + } + } + #contact-org:placeholder-shown { + max-width: 20%; + } + } + + // ACTIONS + #contact-header-actions { + position: relative; + display: flex; + .header-menu { + margin-right: 10px; + } + .header-icon { + width: 44px; + height: 44px; + padding: 14px; + cursor: pointer; + opacity: .7; + border-radius: 22px; + background-size: 16px; + &:hover, + &:focus { + opacity: 1; + } + &.header-icon--pulse { + width: 16px; + height: 16px; + margin: 8px; + } + } + } + } + + // List of all properties + section.contact-details { + margin: 0 auto; + + .property-masonry { + width: 350px; + } + + .property--rev { + position: absolute; + right: 22px; + bottom: 0; + height: 44px; + opacity: .5; + color: var(--color-text-lighter); + line-height: 44px; + } + } + + #qrcode-modal { + .modal-container { + display: flex; + padding: 10px; + background-color: #fff; + .qrcode { + max-width: 100%; + } + } + } + + #pick-addressbook-modal { + .modal-container { + display: flex; + overflow: visible; + flex-wrap: wrap; + justify-content: space-evenly; + margin-bottom: 20px; + padding: 10px; + background-color: #fff; + .multiselect { + flex: 1 1 100%; + width: 100%; + margin-bottom: 20px; + } + } + } +} +</style> diff --git a/src/components/ContactDetails/ContactDetailsAddNewProp.vue b/src/components/ContactDetails/ContactDetailsAddNewProp.vue index 7dc86f00..a4fe6b37 100644 --- a/src/components/ContactDetails/ContactDetailsAddNewProp.vue +++ b/src/components/ContactDetails/ContactDetailsAddNewProp.vue @@ -21,7 +21,7 @@ --> <template> - <div class="grid-span-3 property property--without-actions property--last"> + <div class="property property--without-actions property--last"> <!-- title --> <PropertyTitle :icon="'icon-add'" :readable-name="t('contacts', 'Add new property')" /> diff --git a/src/components/ContactDetails/ContactDetailsProperty.vue b/src/components/ContactDetails/ContactDetailsProperty.vue index bcbfbe4e..ecc641cb 100644 --- a/src/components/ContactDetails/ContactDetailsProperty.vue +++ b/src/components/ContactDetails/ContactDetailsProperty.vue @@ -23,7 +23,6 @@ <template> <!-- If not in the rfcProps then we don't want to display it --> <component :is="componentInstance" - v-if="propModel && propType !== 'unknown'" ref="component" :select-type.sync="selectType" :prop-model="propModel" @@ -31,7 +30,10 @@ :is-first-property="isFirstProperty" :property="property" :is-last-property="isLastProperty" - :class="{'property--last': isLastProperty}" + :class="{ + 'property--last': isLastProperty, + [`property-${propName}`]: true + }" :local-contact="localContact" :prop-name="propName" :prop-type="propType" @@ -59,16 +61,22 @@ export default { type: Property, default: true, }, - sortedProperties: { - type: Array, - default() { - return [] - }, + + /** + * Is it the first property of its kind + */ + isFirstProperty: { + type: Boolean, + default: false, }, - index: { - type: Number, - default: 0, + /** + * Is it the last property of its kind + */ + isLastProperty: { + type: Boolean, + default: false, }, + contact: { type: Contact, default: null, @@ -110,22 +118,6 @@ export default { fieldOrder() { return rfcProps.fieldOrder }, - - // is this the first property of its kind - isFirstProperty() { - if (this.index > 0) { - return this.sortedProperties[this.index - 1].name.split('.').pop() !== this.propName - } - return true - }, - // is this the last property of its kind - isLastProperty() { - // array starts at 0, length starts at 1 - if (this.index < this.sortedProperties.length - 1) { - return this.sortedProperties[this.index + 1].name.split('.').pop() !== this.propName - } - return true - }, isReadOnly() { if (this.contact.addressbook) { return this.contact.addressbook.readOnly @@ -146,6 +138,7 @@ export default { return this.property.name }, + /** * Return the type or property * diff --git a/src/components/ContactsList.vue b/src/components/ContactsList.vue index d89d493c..6548faf5 100644 --- a/src/components/ContactsList.vue +++ b/src/components/ContactsList.vue @@ -189,6 +189,9 @@ export default { // Virtual scroller overrides .vue-recycle-scroller { position: sticky !important; + min-width: 0; + // try to take some width + flex: 1 1 300px; } .vue-recycle-scroller__item-view { diff --git a/src/components/Properties/PropertyDateTime.vue b/src/components/Properties/PropertyDateTime.vue index 7acc7233..3b2430f6 100644 --- a/src/components/Properties/PropertyDateTime.vue +++ b/src/components/Properties/PropertyDateTime.vue @@ -21,7 +21,7 @@ --> <template> - <div v-if="propModel" :class="`grid-span-${gridLength}`" class="property"> + <div v-if="propModel" class="property"> <!-- title if first element --> <PropertyTitle v-if="isFirstProperty && propModel.icon" :icon="propModel.icon" @@ -140,13 +140,6 @@ export default { }, computed: { - gridLength() { - const hasTitle = this.isFirstProperty && this.propModel.icon ? 1 : 0 - const isLast = this.isLastProperty ? 1 : 0 - // length is always one & add one space at the end - return hasTitle + 1 + isLast - }, - // make sure the property is valid vcardTimeLocalValue() { if (typeof this.localValue === 'string') { diff --git a/src/components/Properties/PropertyGroups.vue b/src/components/Properties/PropertyGroups.vue index bb1b03f2..170565e1 100644 --- a/src/components/Properties/PropertyGroups.vue +++ b/src/components/Properties/PropertyGroups.vue @@ -21,7 +21,7 @@ --> <template> - <div v-if="propModel" class="grid-span-2 property property--without-actions"> + <div v-if="propModel" class="property property--without-actions"> <PropertyTitle icon="icon-contacts" :readable-name="t('contacts', 'Groups')" /> diff --git a/src/components/Properties/PropertyMultipleText.vue b/src/components/Properties/PropertyMultipleText.vue index d23d9434..3b4be4c1 100644 --- a/src/components/Properties/PropertyMultipleText.vue +++ b/src/components/Properties/PropertyMultipleText.vue @@ -21,7 +21,7 @@ --> <template> - <div v-if="propModel" :class="`grid-span-${gridLength}`" class="property"> + <div v-if="propModel" class="property"> <!-- title if first element --> <PropertyTitle v-if="isFirstProperty && propModel.icon" :icon="propModel.icon" @@ -125,14 +125,6 @@ export default { }, computed: { - gridLength() { - const hasTitle = this.isFirstProperty && this.propModel.icon ? 1 : 0 - const isLast = this.isLastProperty - const hasValueNames = this.propModel.options || this.selectType || !this.property.isStructuredValue ? 1 : 0 - const length = this.propModel.displayOrder ? this.propModel.displayOrder.length : this.value.length - return hasValueNames + hasTitle + length + isLast - }, - filteredValue() { return this.localValue.filter((value, index) => index > 0) }, diff --git a/src/components/Properties/PropertySelect.vue b/src/components/Properties/PropertySelect.vue index 67b7a35c..8e4ef727 100644 --- a/src/components/Properties/PropertySelect.vue +++ b/src/components/Properties/PropertySelect.vue @@ -21,7 +21,7 @@ --> <template> - <div v-if="propModel" :class="`grid-span-${gridLength}`" class="property"> + <div v-if="propModel" class="property"> <!-- title if first element --> <PropertyTitle v-if="isFirstProperty && propModel.icon" :icon="propModel.icon" @@ -83,12 +83,6 @@ export default { }, computed: { - gridLength() { - const hasTitle = this.isFirstProperty && this.propModel.icon ? 1 : 0 - const isLast = this.isLastProperty ? 1 : 0 - // length is one & add one space at the end - return hasTitle + 1 + isLast - }, // is there only one option available isSingleOption() { return this.propModel.options.length <= 1 diff --git a/src/components/Properties/PropertyText.vue b/src/components/Properties/PropertyText.vue index 39554ef3..129bd811 100644 --- a/src/components/Properties/PropertyText.vue +++ b/src/components/Properties/PropertyText.vue @@ -21,7 +21,7 @@ --> <template> - <div v-if="propModel" :class="`grid-span-${gridLength} property-${propName}`" class="property"> + <div v-if="propModel" class="property"> <!-- title if first element --> <PropertyTitle v-if="isFirstProperty && propModel.icon" :icon="propModel.icon" @@ -131,15 +131,6 @@ export default { }, computed: { - gridLength() { - const hasTitle = this.isFirstProperty && this.propModel.icon ? 1 : 0 - const isLast = this.isLastProperty ? 1 : 0 - const noteHeight = this.propName === 'note' - ? this.noteHeight - : 0 - // length is one & add one space at the end - return hasTitle + 1 + isLast + noteHeight - }, inputmode() { if (this.propName === 'tel') { return 'tel' |