diff options
| -rw-r--r-- | src/site/components/search-input/SearchInput.vue | 516 | ||||
| -rw-r--r-- | src/site/components/search/Search.vue | 119 | ||||
| -rw-r--r-- | src/site/pages/dashboard/index.vue | 21 | ||||
| -rw-r--r-- | src/site/store/images.js | 1 |
4 files changed, 646 insertions, 11 deletions
diff --git a/src/site/components/search-input/SearchInput.vue b/src/site/components/search-input/SearchInput.vue new file mode 100644 index 0000000..abc433a --- /dev/null +++ b/src/site/components/search-input/SearchInput.vue @@ -0,0 +1,516 @@ +<template> + <div + class="autocomplete control" + :class="{'is-expanded': expanded}"> + <b-input + ref="input" + v-model="newValue" + type="text" + :size="size" + :loading="loading" + :rounded="rounded" + :icon="icon" + :icon-right="newIconRight" + :icon-right-clickable="newIconRightClickable" + :icon-pack="iconPack" + :maxlength="maxlength" + :autocomplete="newAutocomplete" + :use-html5-validation="false" + v-bind="$attrs" + @input="onInput" + @focus="focused" + @blur="onBlur" + @keyup.native.esc.prevent="isActive = false" + @keydown.native.tab="tabPressed" + @keydown.native.enter.prevent="enterPressed" + @keydown.native.up.prevent="keyArrows('up')" + @keydown.native.down.prevent="keyArrows('down')" + @icon-right-click="rightIconClick" + @icon-click="(event) => $emit('icon-click', event)" /> + + <transition name="fade"> + <div + v-show="isActive && (data.length > 0 || hasEmptySlot || hasHeaderSlot)" + ref="dropdown" + class="dropdown-menu" + :class="{ 'is-opened-top': isOpenedTop && !appendToBody }" + :style="style"> + <div + v-show="isActive" + class="dropdown-content" + :style="contentStyle"> + <div + v-if="hasHeaderSlot" + class="dropdown-item"> + <slot name="header" /> + </div> + <a + v-for="(option, index) in data" + :key="index" + class="dropdown-item" + :class="{ 'is-hovered': option === hovered }" + @click="setSelected(option, undefined, $event)"> + + <slot + v-if="hasDefaultSlot" + :option="option" + :index="index" /> + <span v-else> + {{ getValue(option, true) }} + </span> + </a> + <div + v-if="data.length === 0 && hasEmptySlot" + class="dropdown-item is-disabled"> + <slot name="empty" /> + </div> + <div + v-if="hasFooterSlot" + class="dropdown-item"> + <slot name="footer" /> + </div> + </div> + </div> + </transition> + </div> +</template> + +<script> +/* eslint-disable no-underscore-dangle */ +/* eslint-disable vue/require-default-prop */ +/* eslint-disable vue/no-reserved-keys */ +import { getValueByPath, removeElement, createAbsoluteElement } from '../../../../node_modules/buefy/src/utils/helpers'; +import FormElementMixin from '../../../../node_modules/buefy/src/utils/FormElementMixin'; + +export default { + name: 'SearchInput', + mixins: [FormElementMixin], + inheritAttrs: false, + props: { + value: [Number, String], + data: { + type: Array, + default: () => [], + }, + field: { + type: String, + default: 'value', + }, + keepFirst: Boolean, + clearOnSelect: Boolean, + openOnFocus: Boolean, + customFormatter: Function, + checkInfiniteScroll: Boolean, + keepOpen: Boolean, + clearable: Boolean, + maxHeight: [String, Number], + dropdownPosition: { + type: String, + default: 'auto', + }, + iconRight: String, + iconRightClickable: Boolean, + appendToBody: Boolean, + customSelector: Function, + }, + data() { + return { + selected: null, + hovered: null, + isActive: false, + newValue: this.value, + newAutocomplete: this.autocomplete || 'off', + isListInViewportVertically: true, + hasFocus: false, + style: {}, + _isAutocomplete: true, + _elementRef: 'input', + _bodyEl: undefined, // Used to append to body + }; + }, + computed: { + /** + * White-listed items to not close when clicked. + * Add input, dropdown and all children. + */ + whiteList() { + const whiteList = []; + whiteList.push(this.$refs.input.$el.querySelector('input')); + whiteList.push(this.$refs.dropdown); + // Add all chidren from dropdown + if (this.$refs.dropdown !== undefined) { + const children = this.$refs.dropdown.querySelectorAll('*'); + for (const child of children) { + whiteList.push(child); + } + } + if (this.$parent.$data._isTaginput) { + // Add taginput container + whiteList.push(this.$parent.$el); + // Add .tag and .delete + const tagInputChildren = this.$parent.$el.querySelectorAll('*'); + for (const tagInputChild of tagInputChildren) { + whiteList.push(tagInputChild); + } + } + return whiteList; + }, + /** + * Check if exists default slot + */ + hasDefaultSlot() { + return !!this.$scopedSlots.default; + }, + /** + * Check if exists "empty" slot + */ + hasEmptySlot() { + return !!this.$slots.empty; + }, + /** + * Check if exists "header" slot + */ + hasHeaderSlot() { + return !!this.$slots.header; + }, + /** + * Check if exists "footer" slot + */ + hasFooterSlot() { + return !!this.$slots.footer; + }, + /** + * Apply dropdownPosition property + */ + isOpenedTop() { + return this.dropdownPosition === 'top' || (this.dropdownPosition === 'auto' && !this.isListInViewportVertically); + }, + newIconRight() { + if (this.clearable && this.newValue) { + return 'close-circle'; + } + return this.iconRight; + }, + newIconRightClickable() { + if (this.clearable) { + return true; + } + return this.iconRightClickable; + }, + contentStyle() { + return { + // eslint-disable-next-line no-nested-ternary + maxHeight: this.maxHeight === undefined + // eslint-disable-next-line no-restricted-globals + ? null : (isNaN(this.maxHeight) ? this.maxHeight : `${this.maxHeight}px`), + }; + }, + }, + watch: { + /** + * When dropdown is toggled, check the visibility to know when + * to open upwards. + */ + isActive(active) { + if (this.dropdownPosition === 'auto') { + if (active) { + this.calcDropdownInViewportVertical(); + } else { + // Timeout to wait for the animation to finish before recalculating + setTimeout(() => { + this.calcDropdownInViewportVertical(); + }, 100); + } + } + if (active) this.$nextTick(() => this.setHovered(null)); + }, + /** + * When updating input's value + * 1. Emit changes + * 2. If value isn't the same as selected, set null + * 3. Close dropdown if value is clear or else open it + */ + newValue(value) { + this.$emit('input', value); + // Check if selected is invalid + const currentValue = this.getValue(this.selected); + if (currentValue && currentValue !== value) { + this.setSelected(null, false); + } + // Close dropdown if input is clear or else open it + if (this.hasFocus && (!this.openOnFocus || value)) { + this.isActive = !!value; + } + }, + /** + * When v-model is changed: + * 1. Update internal value. + * 2. If it's invalid, validate again. + */ + value(value) { + this.newValue = value; + }, + /** + * Select first option if "keep-first + */ + data(value) { + // Keep first option always pre-selected + if (this.keepFirst) { + this.selectFirstOption(value); + } + }, + }, + created() { + if (typeof window !== 'undefined') { + document.addEventListener('click', this.clickedOutside); + if (this.dropdownPosition === 'auto') window.addEventListener('resize', this.calcDropdownInViewportVertical); + } + }, + mounted() { + if (this.checkInfiniteScroll && this.$refs.dropdown && this.$refs.dropdown.querySelector('.dropdown-content')) { + const list = this.$refs.dropdown.querySelector('.dropdown-content'); + list.addEventListener('scroll', () => this.checkIfReachedTheEndOfScroll(list)); + } + if (this.appendToBody) { + this.$data._bodyEl = createAbsoluteElement(this.$refs.dropdown); + this.updateAppendToBody(); + } + }, + beforeDestroy() { + if (typeof window !== 'undefined') { + document.removeEventListener('click', this.clickedOutside); + if (this.dropdownPosition === 'auto') window.removeEventListener('resize', this.calcDropdownInViewportVertical); + } + if (this.checkInfiniteScroll && this.$refs.dropdown && this.$refs.dropdown.querySelector('.dropdown-content')) { + const list = this.$refs.dropdown.querySelector('.dropdown-content'); + list.removeEventListener('scroll', this.checkIfReachedTheEndOfScroll); + } + if (this.appendToBody) { + removeElement(this.$data._bodyEl); + } + }, + methods: { + /** + * Set which option is currently hovered. + */ + setHovered(option) { + if (option === undefined) return; + this.hovered = option; + }, + /** + * Set which option is currently selected, update v-model, + * update input value and close dropdown. + */ + setSelected(option, closeDropdown = true, event = undefined) { + if (option === undefined) return; + this.selected = option; + this.$emit('select', this.selected, event); + if (this.selected !== null) { + if (this.customSelector) { + this.newValue = this.clearOnSelect ? '' : this.customSelector(this.selected, this.newValue); + } else { + this.newValue = this.clearOnSelect ? '' : this.getValue(this.selected); + } + this.setHovered(null); + } + // eslint-disable-next-line no-unused-expressions + closeDropdown && this.$nextTick(() => { this.isActive = false; }); + this.checkValidity(); + }, + /** + * Select first option + */ + selectFirstOption(options) { + this.$nextTick(() => { + if (options.length) { + // If has visible data or open on focus, keep updating the hovered + if (this.openOnFocus || (this.newValue !== '' && this.hovered !== options[0])) { + this.setHovered(options[0]); + } + } else { + this.setHovered(null); + } + }); + }, + /** + * Enter key listener. + * Select the hovered option. + */ + enterPressed(event) { + if (this.hovered === null) return; + this.setSelected(this.hovered, !this.keepOpen, event); + }, + /** + * Tab key listener. + * Select hovered option if it exists, close dropdown, then allow + * native handling to move to next tabbable element. + */ + tabPressed(event) { + if (this.hovered === null) { + this.isActive = false; + return; + } + this.setSelected(this.hovered, !this.keepOpen, event); + }, + /** + * Close dropdown if clicked outside. + */ + clickedOutside(event) { + if (this.whiteList.indexOf(event.target) < 0) this.isActive = false; + }, + /** + * Return display text for the input. + * If object, get value from path, or else just the value. + */ + getValue(option) { + if (option === null) return; + if (typeof this.customFormatter !== 'undefined') { + // eslint-disable-next-line consistent-return + return this.customFormatter(option); + } + // eslint-disable-next-line consistent-return + return typeof option === 'object' + ? getValueByPath(option, this.field) + : option; + }, + /** + * Check if the scroll list inside the dropdown + * reached it's end. + */ + checkIfReachedTheEndOfScroll(list) { + if (list.clientHeight !== list.scrollHeight + && list.scrollTop + list.clientHeight >= list.scrollHeight) { + this.$emit('infinite-scroll'); + } + }, + /** + * Calculate if the dropdown is vertically visible when activated, + * otherwise it is openened upwards. + */ + calcDropdownInViewportVertical() { + this.$nextTick(() => { + /** + * this.$refs.dropdown may be undefined + * when Autocomplete is conditional rendered + */ + if (this.$refs.dropdown === undefined) return; + const rect = this.$refs.dropdown.getBoundingClientRect(); + this.isListInViewportVertically = ( + rect.top >= 0 + && rect.bottom <= (window.innerHeight + || document.documentElement.clientHeight) + ); + if (this.appendToBody) { + this.updateAppendToBody(); + } + }); + }, + /** + * Arrows keys listener. + * If dropdown is active, set hovered option, or else just open. + */ + keyArrows(direction) { + const sum = direction === 'down' ? 1 : -1; + if (this.isActive) { + let index = this.data.indexOf(this.hovered) + sum; + index = index > this.data.length - 1 ? this.data.length : index; + index = index < 0 ? 0 : index; + this.setHovered(this.data[index]); + const list = this.$refs.dropdown.querySelector('.dropdown-content'); + const element = list.querySelectorAll('a.dropdown-item:not(.is-disabled)')[index]; + if (!element) return; + const visMin = list.scrollTop; + const visMax = list.scrollTop + list.clientHeight - element.clientHeight; + if (element.offsetTop < visMin) { + list.scrollTop = element.offsetTop; + } else if (element.offsetTop >= visMax) { + list.scrollTop = ( + element.offsetTop + - list.clientHeight + + element.clientHeight + ); + } + } else { + this.isActive = true; + } + }, + /** + * Focus listener. + * If value is the same as selected, select all text. + */ + focused(event) { + if (this.getValue(this.selected) === this.newValue) { + this.$el.querySelector('input').select(); + } + if (this.openOnFocus) { + this.isActive = true; + if (this.keepFirst) { + this.selectFirstOption(this.data); + } + } + this.hasFocus = true; + this.$emit('focus', event); + }, + /** + * Blur listener. + */ + onBlur(event) { + this.hasFocus = false; + this.$emit('blur', event); + }, + onInput() { + const currentValue = this.getValue(this.selected); + if (currentValue && currentValue === this.newValue) return; + this.$emit('typing', this.newValue); + this.checkValidity(); + }, + rightIconClick(event) { + if (this.clearable) { + this.newValue = ''; + if (this.openOnFocus) { + this.$el.focus(); + } + } else { + this.$emit('icon-right-click', event); + } + }, + checkValidity() { + if (this.useHtml5Validation) { + this.$nextTick(() => { + this.checkHtml5Validity(); + }); + } + }, + updateAppendToBody() { + const dropdownMenu = this.$refs.dropdown; + const trigger = this.$refs.input.$el; + if (dropdownMenu && trigger) { + // update wrapper dropdown + const root = this.$data._bodyEl; + root.classList.forEach((item) => root.classList.remove(item)); + root.classList.add('autocomplete'); + root.classList.add('control'); + if (this.expandend) { + root.classList.add('is-expandend'); + } + const rect = trigger.getBoundingClientRect(); + let top = rect.top + window.scrollY; + const left = rect.left + window.scrollX; + if (!this.isOpenedTop) { + top += trigger.clientHeight; + } else { + top -= dropdownMenu.clientHeight; + } + this.style = { + position: 'absolute', + top: `${top}px`, + left: `${left}px`, + width: `${trigger.clientWidth}px`, + maxWidth: `${trigger.clientWidth}px`, + zIndex: '99', + }; + } + }, + }, +}; +</script> diff --git a/src/site/components/search/Search.vue b/src/site/components/search/Search.vue new file mode 100644 index 0000000..57226a9 --- /dev/null +++ b/src/site/components/search/Search.vue @@ -0,0 +1,119 @@ +<template> + <div class="level-right"> + <div class="level-item"> + <b-field> + <SearchInput + ref="autocomplete" + v-model="query" + :data="filteredHints" + :customSelector="handleSelect" + field="name" + class="lolisafe-input search" + placeholder="Search" + type="search" + open-on-focus + @typing="handleTyping"> + <template slot-scope="props"> + <b>{{ props.option.name }}:</b> + <small> + {{ props.option.valueFormat }} + </small> + </template> + </SearchInput> + <p class="control"> + <b-button type="is-lolisafe" @click="onSubmit"> + Search + </b-button> + </p> + </b-field> + </div> + </div> +</template> + +<script> +import SearchInput from '~/components/search-input/SearchInput.vue'; + +export default { + components: { + SearchInput, + }, + data() { + return { + query: '', + hints: [ + { + 'name': 'tag', + 'valueFormat': 'name', + 'hint': '', + }, + { + 'name': 'album', + 'valueFormat': 'name', + 'hint': '', + }, + { + 'name': 'before', + 'valueFormat': 'specific date', + 'hint': '', + }, + { + 'name': 'during', + 'valueFormat': 'specific date', + 'hint': '', + }, + { + 'name': 'after', + 'valueFormat': 'specific date', + 'hint': '', + }, + ], + filteredHints: [], + }; + }, + created() { + this.filteredHints = this.hints; // fixes the issue where on pageload, suggestions wont load + }, + methods: { + handleSelect(selected, currentValue) { + this.$refs.autocomplete.focus(); + if (!currentValue) { return `${selected.name}:`; } + if (/[^:][\s|;|,]+$/gi.test(currentValue)) return `${currentValue}${selected.name}:`; + return currentValue.replace(/\w+$/gi, `${selected.name}:`); + }, + handleTyping(qry) { + qry = qry || ''; + // get the last word or group of words + const lastWord = (qry.match(/("[^"]*")|[^;, ]+/g) || ['']).pop().toLowerCase(); + // if there's an open/unbalanced quote, don't autosuggest + if (/^[^"]*("[^"]*"[^"]*)*(")[^"]*$/.test(qry)) { this.filteredHints = []; return; } + // don't autosuggest if we have an open query but no text yet + if (/:[\s|;|,]?$/gi.test(qry)) { this.filteredHints = []; return; } + // if the above query didn't match (all quotes are balanced + // and the previous tag has value + // check if we're about to start a new tag + if (/[\s|;|,]+$/gi.test(qry)) { this.filteredHints = this.hints; return; } + + // if we got here, then we handled all special cases + // now take last word, and check if we can autosuggest a tag + this.filteredHints = this.hints.filter((hint) => hint.name + .toString() + .toLowerCase() + .indexOf(lastWord) === 0); + }, + sanitizeQuery(qry) { + // \w+:\s+? to transform 'tag: 123' into 'tag:123' + }, + onSubmit() { + this.$emit('search', this.query); + }, + }, +}; +</script> + +<style lang="scss" scoped> + .search { + ::v-deep .dropdown-content { + background-color: #323846; + } + } +</style> diff --git a/src/site/pages/dashboard/index.vue b/src/site/pages/dashboard/index.vue index d623b5c..cfd68ba 100644 --- a/src/site/pages/dashboard/index.vue +++ b/src/site/pages/dashboard/index.vue @@ -16,17 +16,7 @@ </div> <div class="level-right"> <div class="level-item"> - <b-field> - <b-input - class="lolisafe-input" - placeholder="Search" - type="search" /> - <p class="control"> - <b-button type="is-lolisafe"> - Search - </b-button> - </p> - </b-field> + <Search @search="onSearch" /> </div> </div> </nav> @@ -68,11 +58,13 @@ import { mapState, mapGetters, mapActions } from 'vuex'; import Sidebar from '~/components/sidebar/Sidebar.vue'; import Grid from '~/components/grid/Grid.vue'; +import Search from '~/components/search/Search.vue'; export default { components: { Sidebar, Grid, + Search, }, middleware: ['auth', ({ store }) => { store.commit('images/resetState'); @@ -82,6 +74,7 @@ export default { return { current: 1, isLoading: false, + search: '', }; }, computed: { @@ -98,6 +91,9 @@ export default { watch: { current: 'fetchPaginate', }, + created() { + this.filteredHints = this.hints; // fixes the issue where on pageload, suggestions wont load + }, methods: { ...mapActions({ fetch: 'images/fetch', @@ -107,6 +103,9 @@ export default { await this.fetch(this.current); this.isLoading = false; }, + onSearch(query) { + this.searc = query; + }, }, }; </script> diff --git a/src/site/store/images.js b/src/site/store/images.js index be04c8a..4737c26 100644 --- a/src/site/store/images.js +++ b/src/site/store/images.js @@ -8,6 +8,7 @@ export const getDefaultState = () => ({ limit: 30, totalFiles: 0, }, + search: '', albumName: null, albumDownloadEnabled: false, fileExtraInfoMap: {}, // information about the selected file |