diff --git a/.eslintrc.js b/.eslintrc.js index 7c652642b..8aabc2d3b 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -27,6 +27,7 @@ module.exports = { 'plugin:node/recommended', 'plugin:vue/essential', 'plugin:vue/recommended', + 'plugin:nextcloud/recommended', 'standard' ], settings: { @@ -73,8 +74,14 @@ module.exports = { // es6 import/export and require 'node/no-unpublished-require': ['off'], 'node/no-unsupported-features/es-syntax': ['off'], - // kebab case components for vuejs + // PascalCase components names for vuejs + // https://vuejs.org/v2/style-guide/#Single-file-component-filename-casing-strongly-recommended 'vue/component-name-in-template-casing': ['error', 'PascalCase'], + // force name + 'vue/match-component-file-name': ['error', { + 'extensions': ['jsx', 'vue', 'js'], + 'shouldMatchCase': true + }], // space before self-closing elements 'vue/html-closing-bracket-spacing': 'error', // no ending html tag on a new line diff --git a/css/ContactDetails.scss b/css/ContactDetails.scss index 6c4e53f7d..85796c73a 100644 --- a/css/ContactDetails.scss +++ b/css/ContactDetails.scss @@ -21,19 +21,20 @@ */ #contact-details { - + $grid-column-gap: 20px; + $grid-column-width: 350px; // header header { - height: 100px; display: flex; - font-weight: bold; align-items: center; + height: 100px; + font-weight: bold; // ORG-TITLE-NAME #contact-header-infos { display: flex; - flex-direction: column; flex: 1 1 auto; // shrink avatar before this one + flex-direction: column; h2, #details-org-container { display: flex; @@ -41,22 +42,22 @@ margin: 0; } input { - font-size: inherit; - color: #fff !important; - text-shadow: 0 0 2px var(--color-box-shadow); - background: transparent; - text-overflow: ellipsis; overflow: hidden; - white-space: nowrap; - border: none; - margin: 0; - padding: 4px 5px; flex: 1 1; min-width: 100px; max-width: 100%; + margin: 0; + padding: 4px 5px; + white-space: nowrap; + text-overflow: ellipsis; + color: #fff !important; + border: none; + background: transparent; + text-shadow: 0 0 2px var(--color-box-shadow); + font-size: inherit; &::placeholder { - color: #fff !important; opacity: .8; + color: #fff !important; } } #contact-org { @@ -76,45 +77,43 @@ } } .header-icon { - height: 44px; width: 44px; + height: 44px; padding: 14px; - border-radius: 22px; cursor: pointer; - background-size: 16px; opacity: .7; + border-radius: 22px; + background-size: 16px; &:hover, &:focus { opacity: 1; } &.header-icon--pulse { - margin: 8px; width: 16px; height: 16px; + margin: 8px; } } } } - $grid-column-gap: 20px; - $grid-column-width: 350px; - // contact details section.contact-details { display: grid; + min-height: 200px; + padding: 20px $grid-column-gap; /* unquote is a strange hack to avoid removal of the comma by the scss compiler */ grid-template-columns: repeat(auto-fit, minmax(unquote('#{$grid-column-width}'), 1fr)); grid-column-gap: $grid-column-gap; - padding: 20px $grid-column-gap; - min-height: 200px; } // single column fix, better visual @media only screen and (max-width: $navigation-width + $list-min-width + 2 * $grid-column-gap +$grid-column-width) { section.contact-details { + padding: 10px; + grid-template-columns: 1fr; grid-column-gap: 10px; - padding: 10px; } } } @@ -124,9 +123,9 @@ right: 22px; bottom: 0; height: 44px; - line-height: 44px; - color: var(--color-text-lighter); opacity: .5; + color: var(--color-text-lighter); + line-height: 44px; } #qrcode-modal { diff --git a/css/ContactDetailsAvatar.scss b/css/ContactDetailsAvatar.scss index 6ec6cfe81..d2fa69bc8 100644 --- a/css/ContactDetailsAvatar.scss +++ b/css/ContactDetailsAvatar.scss @@ -37,37 +37,37 @@ margin-left: auto; } &__background { - opacity: .2; z-index: 0; - left: 0; top: 50px; + left: 0; + opacity: .2; } &__photo, &__options { + overflow: hidden; width: 100%; height: 100%; border-radius: 50%; - overflow: hidden; } &__photo { z-index: 10; - background-size: cover; + cursor: pointer; background-repeat: no-repeat; background-position: center; - cursor: pointer; + background-size: cover; } &__options { - top: 0; - z-index: 2; position: absolute; - background-color: rgba(0, 0, 0, 0.2); + z-index: 2; + top: 0; + background-color: rgba(0, 0, 0, .2); } .contact-avatar-options { + display: block; width: 100%; height: 100%; - display: block; opacity: .5; - background-color: rgba(0, 0, 0, 0.2); + background-color: rgba(0, 0, 0, .2); &:hover, &:active, &:focus { @@ -103,4 +103,3 @@ } } } - diff --git a/css/ContactsList.scss b/css/ContactsList.scss index 0851c4479..670c1231a 100644 --- a/css/ContactsList.scss +++ b/css/ContactsList.scss @@ -22,16 +22,16 @@ #app-details-toggle { position: fixed; - display: inline-block; + z-index: 149; left: 0; + display: inline-block; width: 44px; height: 44px; - z-index: 149; - background-color: var(--color-background-darker); + margin-top: 44px; // under the show navigation button cursor: pointer; - opacity: 0.6; transform: rotate(180deg); - margin-top: 44px; // under the show navigation button + opacity: .6; + background-color: var(--color-background-darker); } @@ -41,10 +41,10 @@ } .vue-recycle-scroller__item-view { - // same as app-content-list-item - height: 68px; // TODO: find better solution? // https://github.com/Akryum/vue-virtual-scroller/issues/70 // hack to not show the transition overflow: hidden; -} \ No newline at end of file + // same as app-content-list-item + height: 68px; +} diff --git a/css/ContactsListItem.scss b/css/ContactsListItem.scss index 4fcad0df4..87e1cb066 100644 --- a/css/ContactsListItem.scss +++ b/css/ContactsListItem.scss @@ -45,7 +45,7 @@ left: 0; width: 100%; } - + &.delete-slide-left-enter, &.delete-slide-left-leave-to { left: 100%; @@ -58,9 +58,9 @@ position: absolute; top: 0; left: 0; - height: inherit; width: inherit; - background-size: cover; + height: inherit; cursor: pointer; + background-size: cover; } } diff --git a/css/ImportScreen.scss b/css/ImportScreen.scss index 4899060f5..0eb9cd258 100644 --- a/css/ImportScreen.scss +++ b/css/ImportScreen.scss @@ -21,9 +21,9 @@ */ .import-screen { - margin: 50px; width: auto; min-width: 30vw; + margin: 50px; &__header { padding-top: 20px; } diff --git a/css/Properties/Properties.scss b/css/Properties/Properties.scss index 9ba2f0d32..318a74513 100644 --- a/css/Properties/Properties.scss +++ b/css/Properties/Properties.scss @@ -24,15 +24,15 @@ $property-label-max-width: 2 * $property-label-min-width; $property-value-max-width: 250px; .property { - @include generate-grid-span(1); position: relative; - padding-right: 44px; // actions menu / button + width: 100%; // we need this to keep the alignment of the ext and delete/action button // The flex grow will never go over those values. Therefore we can set // the max width and keep the right alignment max-width: $property-label-max-width + $property-value-max-width + 44px; + + @include generate-grid-span(1); justify-self: center; - width: 100%; &--last { margin-bottom: $grid-height-unit; @@ -42,25 +42,30 @@ $property-value-max-width: 250px; display: none !important; } + &--without-actions { + padding-right: 44px; // actions menu / button + } + // property row &__row { + position: relative; display: flex; align-items: center; - position: relative; } // property label or multiselect within row &__label, &__label.multiselect { - margin: $grid-input-margin 5px $grid-input-margin 0 !important; // override multiselect flex: 1 0; // min width is 60px, let's grow until 120px - height: $grid-input-height-with-margin; width: $property-label-min-width; min-width: $property-label-min-width !important; // override multiselect max-width: $property-label-max-width; - + height: $grid-input-height-with-margin; + margin: $grid-input-margin 5px $grid-input-margin 0 !important; // override multiselect user-select: none; + text-align: right; background-size: 16px; + line-height: $grid-input-height-with-margin + 1px; &, .multiselect__input { @@ -68,17 +73,18 @@ $property-value-max-width: 250px; text-align: right; } + .multiselect__single { + overflow: hidden; white-space: nowrap; text-overflow: ellipsis; - overflow: hidden; } } &:not(.multiselect) { - text-overflow: ellipsis; - white-space: nowrap; overflow: hidden; overflow-x: hidden; + white-space: nowrap; + text-overflow: ellipsis; + opacity: .7; } // mouse feedback @@ -89,8 +95,8 @@ $property-value-max-width: 250px; &:focus, &:active { .multiselect__tags { - border-color: var(--color-border-dark); opacity: 1; + border-color: var(--color-border-dark); } } @@ -98,8 +104,8 @@ $property-value-max-width: 250px; &.multiselect--disabled { &, .multiselect__single { &, &:hover, &:focus &:active { - background-color: var(--color-main-background) !important; border-color: transparent !important; + background-color: var(--color-main-background) !important; } } } @@ -109,29 +115,28 @@ $property-value-max-width: 250px; .multiselect__tags { border: none !important; // override multiselect .multiselect__single { + padding-right: 24px; background-repeat: no-repeat; background-position: center right 4px; - padding-right: 24px; } } .multiselect__content-wrapper { - min-width: $property-label-max-width; // improve readability on narrow screens - width: auto !important; // grow bigger if content is bigger than the original 100% right: 0; // align right + width: auto !important; // grow bigger if content is bigger than the original 100% + min-width: $property-label-max-width; // improve readability on narrow screens } @media only screen and (max-width: 768px) { // align left of screen on narrow views .multiselect__content-wrapper { - left: 0; right: auto; + left: 0; } } } // Property value within row, after label &__value { - flex: 1 1 $property-value-max-width; - max-width: $property-value-max-width; + flex: 1 1; textarea& { align-self: flex-start; @@ -157,7 +162,7 @@ $property-value-max-width: 250px; } // show ext button on full row hover - &:hover &__ext{ + &:hover &__ext { opacity: .5; } @@ -176,13 +181,14 @@ $property-value-max-width: 250px; // Delete property button + actions &__actions { - position: absolute !important; - top: 0; - left: 100%; - margin: -2px 2px; // align with line because of the 44x44px size - border: 0; - background-color: transparent; z-index: 10; + margin-left: auto !important; + // floating actions next to the title + &--floating { + position: absolute !important; + right: 0; + bottom: 0; + } } .property__value { margin-right: 0; diff --git a/css/Properties/PropertyTitle.scss b/css/Properties/PropertyTitle.scss index aaa923041..eed164965 100644 --- a/css/Properties/PropertyTitle.scss +++ b/css/Properties/PropertyTitle.scss @@ -24,8 +24,8 @@ display: flex; align-items: center; margin: 0; - opacity: 0.6; user-select: none; + opacity: .6; .property__title--right { display: flex; diff --git a/css/SettingsSection.scss b/css/SettingsSection.scss index 09953f398..0f01dff3e 100644 --- a/css/SettingsSection.scss +++ b/css/SettingsSection.scss @@ -45,9 +45,10 @@ margin: 0; .multiselect__single { padding-right: 24px !important; - @include icon-color('triangle-s', 'actions', $color-black, 1, true); background-repeat: no-repeat; background-position: right 4px center; + + @include icon-color('triangle-s', 'actions', $color-black, 1, true); } } } @@ -57,13 +58,13 @@ display: flex; flex-direction: column; &__multiselect-label { + z-index: 2; width: 100%; + margin: 0; padding: 6px 12px; padding-left: 34px; - margin: 0; border-radius: var(--border-radius) var(--border-radius) 0 0; background-position: left 9px center; - z-index: 2; &--no-select { border-radius: var(--border-radius); } diff --git a/css/contacts.scss b/css/contacts.scss index 0b458fd48..55602537c 100644 --- a/css/contacts.scss +++ b/css/contacts.scss @@ -25,7 +25,7 @@ $grid-height-unit: 40px; $grid-input-padding: 7px; $grid-input-margin: 3px; $grid-column-width: 380px; -$grid-input-height-with-margin: #{$grid-height-unit - $grid-input-margin * 2}; +$grid-input-height-with-margin: $grid-height-unit - $grid-input-margin * 2; @mixin generate-grid-span($default-unit) { // we only supports 10 props of the same type diff --git a/css/icons.scss b/css/icons.scss index a543346ff..4f74e7d4a 100644 --- a/css/icons.scss +++ b/css/icons.scss @@ -20,29 +20,14 @@ * */ -.icon-social { - @include icon-color('social', 'contacts', $color-black, 1); -} - -.icon-qrcode { - @include icon-color('qrcode', 'contacts', $color-black, 2); -} - -.icon-address-book { - @include icon-color('address-book', 'contacts', $color-black, 1); -} -.icon-phone { - @include icon-color('phone', 'contacts', $color-black, 1); -} - -.icon-eye-white { - @include icon-color('eye', 'contacts', $color-white, 1); -} - -.icon-up { - @include icon-color('up', 'contacts', $color-black, 1); -} +@include icon-black-white('social', 'contacts', 1); +@include icon-black-white('qrcode', 'contacts', 1); +@include icon-black-white('address-book', 'contacts', 1); +@include icon-black-white('phone', 'contacts', 1); +@include icon-black-white('eye', 'contacts', 1); +@include icon-black-white('up', 'contacts', 1); +@include icon-black-white('no-calendar', 'contacts', 1); .icon-up-force-white { // using #fffffe to trick the accessibility dark theme icon invert diff --git a/img/no-calendar.svg b/img/no-calendar.svg new file mode 100644 index 000000000..6a328ca16 --- /dev/null +++ b/img/no-calendar.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16"><path d="M4 1c-.5 0-1 .5-1 1v2c0 .5.5 1 1 1s1-.5 1-1V2c0-.5-.5-1-1-1zm8 0c-.5 0-1 .5-1 1v2c0 .223.11.439.264.617L13 2.881V2c0-.5-.5-1-1-1zM5.5 3v1c0 .831-.5 1.5-1.5 1.5S2.5 5 2.5 4v-.938A1.998 1.998 0 0 0 1 5v8c0 .524.202.996.53 1.352L3 12.88V8h4.88l2.942-2.941C10.61 4.81 10.5 4.46 10.5 4V3h-5zM15 5.123L12.123 8H13v5H7.123l-2 2H13c1.108 0 2-.892 2-2V5.123z"/><path d="M13.773 2.756L1.903 14.627 3.274 16 15.146 4.129z" paint-order="fill markers stroke"/></svg> \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 6b1fa06db..fc4a86322 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4053,6 +4053,15 @@ } } }, + "eslint-plugin-nextcloud": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-nextcloud/-/eslint-plugin-nextcloud-0.3.0.tgz", + "integrity": "sha512-LUD2qdirGL0BRt4uaMDGxen17mWVq9JwuGDt7P7Celz7bzdu0X48RrS8mhXn9e0w78+nYN5kPoULG2Bw04r4HA==", + "dev": true, + "requires": { + "requireindex": "~1.2.0" + } + }, "eslint-plugin-node": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/eslint-plugin-node/-/eslint-plugin-node-9.2.0.tgz", @@ -5763,7 +5772,7 @@ "dependencies": { "readable-stream": { "version": "3.0.6", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.0.6.tgz", + "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-3.0.6.tgz", "integrity": "sha512-9E1oLoOWfhSXHGv6QlwXJim7uNzd9EVlWK+21tCU9Ju/kR0/p2AZYPz4qSchgO8PlLIH4FpZYfzwS+rEksZjIg==", "dev": true, "requires": { @@ -8810,7 +8819,7 @@ }, "readable-stream": { "version": "2.3.6", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", "dev": true, "requires": { @@ -9127,6 +9136,12 @@ "integrity": "sha1-l/cXtp1IeE9fUmpsWqj/3aBVpNE=", "dev": true }, + "requireindex": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/requireindex/-/requireindex-1.2.0.tgz", + "integrity": "sha512-L9jEkOi3ASd9PYit2cwRfyppc9NoABujTP8/5gFcbERmo5jUoAKovIC3fsF17pkTnGsrByysqX+Kxd2OTNI1ww==", + "dev": true + }, "resolve": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.8.1.tgz", @@ -9946,7 +9961,7 @@ }, "stream-browserify": { "version": "2.0.1", - "resolved": "http://registry.npmjs.org/stream-browserify/-/stream-browserify-2.0.1.tgz", + "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-2.0.1.tgz", "integrity": "sha1-ZiZu5fm9uZQKTkUUyvtDu3Hlyds=", "dev": true, "requires": { @@ -10012,7 +10027,7 @@ }, "string_decoder": { "version": "1.1.1", - "resolved": "http://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, "requires": { @@ -10752,7 +10767,7 @@ }, "tty-browserify": { "version": "0.0.0", - "resolved": "http://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.0.tgz", + "resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.0.tgz", "integrity": "sha1-oVe6QC2iTpv5V/mqadUk7tQpAaY=", "dev": true }, diff --git a/package.json b/package.json index 8467372db..3e2358e22 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "nextcloud-dialogs": "0.0.3", "nextcloud-l10n": "0.1.0", "nextcloud-router": "0.0.8", - "nextcloud-vue": "^0.12.1", + "nextcloud-vue": "^0.12.2", "p-limit": "^2.2.1", "p-queue": "^6.1.1", "qr-image": "^3.2.0", @@ -81,6 +81,7 @@ "eslint-import-resolver-webpack": "^0.11.1", "eslint-loader": "^3.0.0", "eslint-plugin-import": "^2.18.2", + "eslint-plugin-nextcloud": "^0.3.0", "eslint-plugin-node": "^9.2.0", "eslint-plugin-promise": "^4.2.1", "eslint-plugin-standard": "^4.0.1", diff --git a/src/App.vue b/src/ContactsRoot.vue similarity index 100% rename from src/App.vue rename to src/ContactsRoot.vue diff --git a/src/components/Actions/ActionCopyNtoFN.vue b/src/components/Actions/ActionCopyNtoFN.vue new file mode 100644 index 000000000..ccce1fb84 --- /dev/null +++ b/src/components/Actions/ActionCopyNtoFN.vue @@ -0,0 +1,51 @@ +<!-- + - @copyright Copyright (c) 2018 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> + <ActionButton icon="icon-up" @click="copyNtoFN"> + {{ t('contacts', 'Copy to full name') }} + </ActionButton> +</template> +<script> +import { ActionButton } from 'nextcloud-vue' +import ActionsMixin from 'Mixins/ActionsMixin' + +export default { + name: 'ActionCopyNtoFN', + components: { + ActionButton + }, + mixins: [ActionsMixin], + methods: { + copyNToFN() { + console.info(this.component) + if (this.component.contact.vCard.hasProperty('n')) { + // Stevenson;John;Philip,Paul;Dr.;Jr.,M.D.,A.C.P. + // -> John Stevenson + const n = this.component.contact.vCard.getFirstPropertyValue('n') + this.component.contact.fullName = n.slice(0, 2).reverse().join(' ') + this.component.updateContact() + } + } + } +} +</script> diff --git a/src/components/Actions/ActionToggleYear.vue b/src/components/Actions/ActionToggleYear.vue new file mode 100644 index 000000000..9c204fbd4 --- /dev/null +++ b/src/components/Actions/ActionToggleYear.vue @@ -0,0 +1,79 @@ +<!-- + - @copyright Copyright (c) 2018 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> + <ActionCheckbox :checked="omitYear" + @check="removeYear" @uncheck="addYear"> + {{ t('contacts', 'Omit year') }} + </ActionCheckbox> +</template> +<script> +import { ActionCheckbox } from 'nextcloud-vue' +import ActionsMixin from 'Mixins/ActionsMixin' + +export default { + name: 'ActionToggleYear', + components: { + ActionCheckbox + }, + mixins: [ActionsMixin], + data() { + return { + omitYear: false + } + }, + + beforeMount() { + this.omitYear = !!this.component.property.getFirstParameter('x-apple-omit-year') + || !this.component.value.year // if null + }, + + methods: { + removeYear() { + const dateObject = this.component.localValue.toJSON() + + // year was already displayed: removing it + // and use --0124 format + if (this.component.localContact.version === '4.0') { + dateObject.year = null + this.component.updateValue(dateObject) + } else { + // --0124 format is only for vcards 4.0 + // using x-apple-omit-year custom parameter + const year = this.component.value.year + if (this.component.value.year) { + this.component.property.setParameter('x-apple-omit-year', parseInt(year).toString()) + this.$nextTick(() => { + this.component.updateValue(dateObject) + }) + } + } + this.omitYear = !this.omitYear + }, + addYear() { + const dateObject = this.component.localValue.toJSON() + this.component.updateValue(dateObject, true) + this.omitYear = !this.omitYear + } + } +} +</script> diff --git a/src/components/ContactDetails.vue b/src/components/ContactDetails.vue index 9c8b63175..86b0333d4 100644 --- a/src/components/ContactDetails.vue +++ b/src/components/ContactDetails.vue @@ -128,7 +128,7 @@ :prop-model="addressbookModel" :value.sync="addressbook" :is-first-property="true" :is-last-property="true" :property="{}" - class="property--addressbooks property--last" /> + class="property--addressbooks property--last property--without-actions" /> <!-- Groups always visible --> <PropertyGroups :prop-model="groupsModel" :value.sync="groups" :contact="contact" diff --git a/src/components/ContactDetails/ContactDetailsAddNewProp.vue b/src/components/ContactDetails/ContactDetailsAddNewProp.vue index 8cbe98417..1400d624b 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--last"> + <div class="grid-span-3 property property--without-actions property--last"> <!-- title --> <PropertyTitle :icon="'icon-add'" :readable-name="t('contacts', 'Add new property')" /> @@ -57,11 +57,11 @@ export default { computed: { /** - * Rfc props scoped + * Rfc props * @returns {Object} */ properties() { - return rfcProps.properties(this) + return rfcProps.properties }, /** diff --git a/src/components/ContactDetails/ContactDetailsAvatar.vue b/src/components/ContactDetails/ContactDetailsAvatar.vue index 2f0f587aa..fbfea2505 100644 --- a/src/components/ContactDetails/ContactDetailsAvatar.vue +++ b/src/components/ContactDetails/ContactDetailsAvatar.vue @@ -82,7 +82,7 @@ import { generateRemoteUrl } from 'nextcloud-router' const axios = () => import('axios') export default { - name: 'ContactAvatar', + name: 'ContactDetailsAvatar', components: { ActionLink, diff --git a/src/components/ContactDetails/ContactDetailsProperty.vue b/src/components/ContactDetails/ContactDetailsProperty.vue index 0a4aaf382..ac2452f52 100644 --- a/src/components/ContactDetails/ContactDetailsProperty.vue +++ b/src/components/ContactDetails/ContactDetailsProperty.vue @@ -22,11 +22,11 @@ <template> <!-- If not in the rfcProps then we don't want to display it --> - <component :is="componentInstance" v-if="propModel && propType !== 'unknown'" :select-type.sync="selectType" - :prop-model="propModel" :value.sync="value" :is-first-property="isFirstProperty" - :property="property" :is-last-property="isLastProperty" :class="{'property--last': isLastProperty}" - :local-contact="localContact" :prop-name="propName" :prop-type="propType" - :options="sortedModelOptions" :is-read-only="isReadOnly" + <component :is="componentInstance" v-if="propModel && propType !== 'unknown'" ref="component" + :select-type.sync="selectType" :prop-model="propModel" :value.sync="value" + :is-first-property="isFirstProperty" :property="property" :is-last-property="isLastProperty" + :class="{'property--last': isLastProperty}" :local-contact="localContact" :prop-name="propName" + :prop-type="propType" :options="sortedModelOptions" :is-read-only="isReadOnly" @delete="deleteProp" @update="updateContact" /> </template> @@ -93,10 +93,8 @@ export default { }, // rfc properties list - // passing this to properties to allow us to scope the properties object - // this make possible defining actions there properties() { - return rfcProps.properties(this) + return rfcProps.properties }, fieldOrder() { return rfcProps.fieldOrder diff --git a/src/components/Properties/PropertyActions.vue b/src/components/Properties/PropertyActions.vue index 8a611d377..bbf908ed1 100644 --- a/src/components/Properties/PropertyActions.vue +++ b/src/components/Properties/PropertyActions.vue @@ -25,10 +25,8 @@ <ActionButton icon="icon-delete" @click="deleteProperty"> {{ t('contacts', 'Delete') }} </ActionButton> - <ActionButton v-for="(action, index) in actions" :key="index" - :icon="action.icon" @click="action.action"> - {{ action.text }} - </ActionButton> + <actions :is="action" v-for="(action, index) in actions" :key="index" + :component="propertyComponent" /> </Actions> </template> @@ -46,6 +44,10 @@ export default { actions: { type: Array, default: () => [] + }, + propertyComponent: { + type: Object, + required: true } }, diff --git a/src/components/Properties/PropertyDateTime.vue b/src/components/Properties/PropertyDateTime.vue index 71561c425..83b1a314b 100644 --- a/src/components/Properties/PropertyDateTime.vue +++ b/src/components/Properties/PropertyDateTime.vue @@ -43,14 +43,14 @@ {{ propModel.readableName }} </div> - <!-- props actions --> - <PropertyActions :actions="actions" @delete="deleteProperty" /> - <!-- Real input where the picker shows --> <DatetimePicker :value="vcardTimeLocalValue.toJSDate()" :minute-step="10" :lang="lang" :clearable="false" :first-day-of-week="firstDay" :type="inputType" :readonly="isReadOnly" :format="dateFormat" class="property__value" - confirm @confirm="updateValue" /> + confirm @confirm="debounceUpdateValue" /> + + <!-- props actions --> + <PropertyActions :actions="actions" :property-component="this" @delete="deleteProperty" /> </div> </div> </template> @@ -159,42 +159,67 @@ export default { /** * Debounce and send update event to parent */ - updateValue: debounce(function(e) { + debounceUpdateValue: debounce(function(date) { const objMap = ['year', 'month', 'day', 'hour', 'minute', 'second'] - let rawArray = moment(e).toArray() + const rawArray = moment(date).toArray() - const rawObject = rawArray.reduce((acc, cur, index) => { + let dateObject = rawArray.reduce((acc, cur, index) => { acc[objMap[index]] = cur return acc }, {}) + /** + * VCardTime starts months at 1 + * but moment and js starts at 0 + * ! since we use moment to generate our time array + * ! we need to make sure the conversion to VCardTime is done well + */ + dateObject.month++ + + this.updateValue(dateObject) + }, 500), + + updateValue(dateObject, forceYear) { + const ignoreYear = this.property.getParameter('x-apple-omit-year') + + /** + * If forceYear, we add back the year! + * taken from x-apple-omit-year parameter + * of from the current year if we don't have + * any other appropriate year data + */ + if (forceYear) { + this.property.removeParameter('x-apple-omit-year') + dateObject.year = parseInt(ignoreYear) ? ignoreYear : moment().year() + } else + /** * Use the current year to ensure we do not lose * the year data on v4.0 since we currently have * no options to remove the year selection. * ! using this.value since this.localValue reflect the current change * ! so we need to make sure we do not use the updated data - * TODO: add option to omit year and not use already existing data + * If we force the removal of the year (vcard 4.0 only) + * year is still valid on the apple format x-apple-omit-year */ - if (this.value.year === null) { - rawObject.year = null + if (!this.value.year) { + dateObject.year = null + } else + + // Apple style omit year parameter + // if year changed and we were already + // ignoring the year, we update the parameter + if (ignoreYear && dateObject.year) { + this.property.setParameter('x-apple-omit-year', parseInt(dateObject.year).toString()) } - /** - * VCardTime starts months at 1 - * but moment and js starts at 0 - * ! since we use moment to generate our time array - * ! we need to make sure the conversion to VCardTime is done well - */ - rawObject.month++ - // reset the VCardTime component to the selected date/time - this.localValue = new VCardTime(rawObject, null, this.propType) + this.localValue = new VCardTime(dateObject, null, this.propType) // https://vuejs.org/v2/guide/components-custom-events.html#sync-Modifier // Use moment to convert the JsDate to Object this.$emit('update:value', this.localValue) - }, 500), + }, /** * Format time with locale to display only @@ -211,6 +236,11 @@ export default { let datetimeData = this.vcardTimeLocalValue.toJSON() let datetime = '' + const ignoreYear = this.property.getParameter('x-apple-omit-year') + if (ignoreYear) { + datetimeData.year = null + } + // FUN FACT: JS date starts month at zero! datetimeData.month-- diff --git a/src/components/Properties/PropertyGroups.vue b/src/components/Properties/PropertyGroups.vue index 4cdf296b3..c6e6c419c 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"> + <div v-if="propModel" class="grid-span-2 property property--without-actions"> <!-- NO title if first element for groups --> <div class="property__row"> diff --git a/src/components/Properties/PropertyMultipleText.vue b/src/components/Properties/PropertyMultipleText.vue index d14e83e47..84753871e 100644 --- a/src/components/Properties/PropertyMultipleText.vue +++ b/src/components/Properties/PropertyMultipleText.vue @@ -45,12 +45,14 @@ {{ isFirstProperty ? '' : propModel.readableName }} </div> - <!-- show the first input if not --> + <!-- show the first input if not a structured value --> <input v-if="!property.isStructuredValue" v-model.trim="localValue[0]" :readonly="isReadOnly" class="property__value" type="text" @input="updateValue"> <!-- props actions --> - <PropertyActions :actions="actions" @delete="deleteProperty" /> + <PropertyActions class="property__actions--floating" + :actions="actions" :property-component="this" + @delete="deleteProperty" /> </div> <!-- force order based on model --> @@ -82,7 +84,7 @@ import PropertyTitle from './PropertyTitle' import PropertyActions from './PropertyActions' export default { - name: 'PropertyText', + name: 'PropertyMultipleText', components: { PropertyTitle, diff --git a/src/components/Properties/PropertySelect.vue b/src/components/Properties/PropertySelect.vue index 559aebc84..4b5893d34 100644 --- a/src/components/Properties/PropertySelect.vue +++ b/src/components/Properties/PropertySelect.vue @@ -37,12 +37,12 @@ {{ propModel.readableName }} </div> - <!-- props actions --> - <PropertyActions :actions="actions" @delete="deleteProperty" /> - <multiselect v-model="matchedOptions" :options="propModel.options" :placeholder="t('contacts', 'Select option')" :disabled="isSingleOption || isReadOnly" class="property__value" track-by="id" label="name" @input="updateValue" /> + + <!-- props actions --> + <PropertyActions :actions="actions" :property-component="this" @delete="deleteProperty" /> </div> </div> </template> diff --git a/src/components/Properties/PropertyText.vue b/src/components/Properties/PropertyText.vue index 8ccaf9691..05ec9861b 100644 --- a/src/components/Properties/PropertyText.vue +++ b/src/components/Properties/PropertyText.vue @@ -60,7 +60,7 @@ target="_blank" /> <!-- props actions --> - <PropertyActions :actions="actions" @delete="deleteProperty" /> + <PropertyActions :actions="actions" :property-component="this" @delete="deleteProperty" /> </div> </div> </template> diff --git a/src/components/Settings/SettingsAddressbookShare.vue b/src/components/Settings/SettingsAddressbookShare.vue index a54e8d1f7..eb3c29ca0 100644 --- a/src/components/Settings/SettingsAddressbookShare.vue +++ b/src/components/Settings/SettingsAddressbookShare.vue @@ -52,7 +52,7 @@ import addressBookSharee from './SettingsAddressbookSharee' import debounce from 'debounce' export default { - name: 'SettingsShareAddressbook', + name: 'SettingsAddressbookShare', components: { addressBookSharee }, diff --git a/src/components/Settings/SettingsAddressbookSharee.vue b/src/components/Settings/SettingsAddressbookSharee.vue index 93a3ee71c..d7bea0244 100644 --- a/src/components/Settings/SettingsAddressbookSharee.vue +++ b/src/components/Settings/SettingsAddressbookSharee.vue @@ -53,7 +53,7 @@ <script> export default { - name: 'SettingsShareSharee', + name: 'SettingsAddressbookSharee', props: { addressbook: { diff --git a/src/main.js b/src/main.js index 021ef9663..9f5addf8e 100644 --- a/src/main.js +++ b/src/main.js @@ -1,3 +1,4 @@ +/* eslint-disable vue/match-component-file-name */ /** * @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com> * @@ -24,7 +25,7 @@ import 'core-js/stable' import 'regenerator-runtime/runtime' import Vue from 'vue' -import App from './App' +import App from './ContactsRoot' import router from './router' import store from './store' import { sync } from 'vuex-router-sync' diff --git a/src/mixins/ActionsMixin.js b/src/mixins/ActionsMixin.js new file mode 100644 index 000000000..829efc248 --- /dev/null +++ b/src/mixins/ActionsMixin.js @@ -0,0 +1,32 @@ +/** + * @copyright Copyright (c) 2018 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/>. + * + */ + +export default { + props: { + // The current component root + component: { + type: Object, + default: () => {}, + required: true + } + } +} diff --git a/src/mixins/PropertyMixin.js b/src/mixins/PropertyMixin.js index 9fa3ed0a0..36c57bf5e 100644 --- a/src/mixins/PropertyMixin.js +++ b/src/mixins/PropertyMixin.js @@ -89,6 +89,9 @@ export default { computed: { actions() { return this.propModel.actions ? this.propModel.actions : [] + }, + haveAction() { + return this.actions && this.actions.length > 0 } }, diff --git a/src/models/rfcProps.js b/src/models/rfcProps.js index ef8f4baca..16c766a22 100644 --- a/src/models/rfcProps.js +++ b/src/models/rfcProps.js @@ -20,18 +20,10 @@ * */ import { VCardTime } from 'ical.js' +import ActionCopyNtoFN from '../components/Actions/ActionCopyNtoFN' +import ActionToggleYear from '../components/Actions/ActionToggleYear' -const copyNtoFN = ({ contact, updateContact }) => () => { - if (contact.vCard.hasProperty('n')) { - // Stevenson;John;Philip,Paul;Dr.;Jr.,M.D.,A.C.P. - // -> John Stevenson - const n = contact.vCard.getFirstPropertyValue('n') - contact.fullName = n.slice(0, 2).reverse().join(' ') - updateContact() - } -} - -const properties = component => ({ +const properties = { nickname: { readableName: t('contacts', 'Nickname'), icon: 'icon-user' @@ -51,11 +43,7 @@ const properties = component => ({ }, icon: 'icon-user', actions: [ - { - text: t('contacts', 'Copy to full name'), - icon: 'icon-up', - action: copyNtoFN(component) - } + ActionCopyNtoFN ] }, note: { @@ -113,7 +101,10 @@ const properties = component => ({ force: 'date', // most ppl prefer date for birthdays, time is usually irrelevant defaultValue: { value: new VCardTime(null, null, 'date').fromJSDate(new Date()) - } + }, + actions: [ + ActionToggleYear + ] }, anniversary: { readableName: t('contacts', 'Anniversary'), @@ -289,7 +280,7 @@ const properties = component => ({ { id: 'U', name: t('contacts', 'Unknown') } ] } -}) +} const fieldOrder = [ 'org', diff --git a/src/services/checks/badGenderType.js b/src/services/checks/badGenderType.js index a113ad8e4..6b6584eb0 100644 --- a/src/services/checks/badGenderType.js +++ b/src/services/checks/badGenderType.js @@ -33,7 +33,7 @@ export default { fix: contact => { const gender = contact.vCard.getFirstProperty('gender') const type = gender.getFirstParameter('type') - const option = Object.values(rfcProps.properties({}).gender.options).find(opt => opt.id === type) + const option = Object.values(rfcProps.properties.gender.options).find(opt => opt.id === type) if (option) { gender.removeParameter('type') gender.setValue(option.id) diff --git a/src/store/addressbooks.js b/src/store/addressbooks.js index 85e23379f..2576311bc 100644 --- a/src/store/addressbooks.js +++ b/src/store/addressbooks.js @@ -355,6 +355,7 @@ const actions = { contacts.push(contact) } catch (error) { // PARSING FAILED + console.error('Error reading contact', item.url, item.data) console.error(error) failed++ } diff --git a/src/views/Contacts.vue b/src/views/Contacts.vue index d5c0deb2c..a6fc224fd 100644 --- a/src/views/Contacts.vue +++ b/src/views/Contacts.vue @@ -324,7 +324,7 @@ export default { contact.rev = rev // itterate over all properties (filter is not usable on objects and we need the key of the property) - const properties = rfcProps.properties(this) + const properties = rfcProps.properties for (let name in properties) { if (properties[name].default) { let defaultData = properties[name].defaultValue