diff options
Diffstat (limited to 'src/site/components')
| -rw-r--r-- | src/site/components/grid/Grid.vue | 166 | ||||
| -rw-r--r-- | src/site/components/grid/waterfall/Waterfall.vue | 181 | ||||
| -rw-r--r-- | src/site/components/grid/waterfall/WaterfallItem.vue | 60 | ||||
| -rw-r--r-- | src/site/components/grid/waterfall/old/waterfall-slot.vue | 76 | ||||
| -rw-r--r-- | src/site/components/grid/waterfall/old/waterfall.vue | 442 | ||||
| -rw-r--r-- | src/site/components/home/links/Links.vue | 100 | ||||
| -rw-r--r-- | src/site/components/loading/CubeShadow.vue | 48 | ||||
| -rw-r--r-- | src/site/components/loading/Origami.vue | 121 | ||||
| -rw-r--r-- | src/site/components/loading/PingPong.vue | 98 | ||||
| -rw-r--r-- | src/site/components/loading/RotateSquare.vue | 87 | ||||
| -rw-r--r-- | src/site/components/logo/Logo.vue | 59 | ||||
| -rw-r--r-- | src/site/components/navbar/Navbar.vue | 119 | ||||
| -rw-r--r-- | src/site/components/sidebar/Sidebar.vue | 43 | ||||
| -rw-r--r-- | src/site/components/uploader/Uploader.vue | 251 |
14 files changed, 1851 insertions, 0 deletions
diff --git a/src/site/components/grid/Grid.vue b/src/site/components/grid/Grid.vue new file mode 100644 index 0000000..09922c9 --- /dev/null +++ b/src/site/components/grid/Grid.vue @@ -0,0 +1,166 @@ +<style lang="scss" scoped> + @import '../../styles/_colors.scss'; + .item-move { + transition: all .25s cubic-bezier(.55,0,.1,1); + -webkit-transition: all .25s cubic-bezier(.55,0,.1,1); + } + div.actions { + opacity: 0; + -webkit-transition: opacity 0.1s linear; + -moz-transition: opacity 0.1s linear; + -ms-transition: opacity 0.1s linear; + -o-transition: opacity 0.1s linear; + transition: opacity 0.1s linear; + position: absolute; + top: 0px; + left: 0px; + width: 100%; + height: calc(100% - 6px); + background: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + + span { + padding: 3px; + + &:nth-child(1), &:nth-child(2) { + align-items: flex-end; + } + + &:nth-child(1), &:nth-child(3) { + justify-content: flex-end; + } + a { + width: 30px; + height: 30px; + color: white; + justify-content: center; + align-items: center; + display: flex; + &:before { + content: ''; + width: 30px; + height: 30px; + border: 1px solid white; + border-radius: 50%; + position: absolute; + } + } + } + + &.fixed { + position: relative; + opacity: 1; + background: none; + + a { + width: auto; + height: auto; + color: $defaultTextColor; + &:before { + display: none; + } + } + + } + } +</style> + +<style lang="scss"> + .waterfall-item:hover { + div.actions { + opacity: 1 + } + } +</style> + +<template> + <Waterfall + :gutterWidth="10" + :gutterHeight="4"> + <WaterfallItem v-for="(item, index) in files" + v-if="showWaterfall && item.thumb" + :key="index" + move-class="item-move"> + <img :src="`${item.thumb}`"> + <div :class="{ fixed }" + class="actions"> + <b-tooltip label="Link" + position="is-top"> + <a :href="`${item.url}`" + target="_blank"> + <i class="icon-web-code"/> + </a> + </b-tooltip> + <b-tooltip label="Albums" + position="is-top"> + <a @click="manageAlbums(item)"> + <i class="icon-interface-window"/> + </a> + </b-tooltip> + <b-tooltip label="Tags" + position="is-top"> + <a @click="manageTags(item)"> + <i class="icon-ecommerce-tag-c"/> + </a> + </b-tooltip> + <b-tooltip label="Delete" + position="is-top"> + <a @click="deleteFile(item, index)"> + <i class="icon-editorial-trash-a-l"/> + </a> + </b-tooltip> + </div> + </WaterfallItem> + </Waterfall> +</template> +<script> +import Waterfall from './waterfall/Waterfall.vue'; +import WaterfallItem from './waterfall/WaterfallItem.vue'; + +export default { + components: { + Waterfall, + WaterfallItem + }, + props: { + files: { + type: Array, + default: null + }, + fixed: { + type: Boolean, + default: false + } + }, + data() { + return { showWaterfall: true }; + }, + mounted() {}, + methods: { + deleteFile(file, index) { + this.$dialog.confirm({ + title: 'Deleting file', + message: 'Are you sure you want to <b>delete</b> this file?', + confirmText: 'Delete File', + type: 'is-danger', + hasIcon: true, + onConfirm: async () => { + try { + const response = await this.axios.delete(`${this.$config.baseURL}/file/${file.id}`); + this.showWaterfall = false; + this.files.splice(index, 1); + this.$nextTick(() => { + this.showWaterfall = true; + }); + return this.$toast.open(response.data.message); + } catch (error) { + return this.$onPromiseError(error); + } + } + }); + } + } +}; +</script> diff --git a/src/site/components/grid/waterfall/Waterfall.vue b/src/site/components/grid/waterfall/Waterfall.vue new file mode 100644 index 0000000..9827075 --- /dev/null +++ b/src/site/components/grid/waterfall/Waterfall.vue @@ -0,0 +1,181 @@ +<style> + .waterfall { + position: relative; + } +</style> +<template> + <div class="waterfall"> + <slot/> + </div> +</template> +<script> +// import {quickSort, getMinIndex, _, sum} from './util' + +const quickSort = (arr, type) => { + let left = []; + let right = []; + let povis; + if (arr.length <= 1) { + return arr; + } + povis = arr[0]; + for (let i = 1; i < arr.length; i++) { + if (arr[i][type] < povis[type]) { + left.push(arr[i]); + } else { + right.push(arr[i]); + } + } + return quickSort(left, type).concat(povis, quickSort(right, type)) +}; + +const getMinIndex = arr => { + let pos = 0; + for (let i = 0; i < arr.length; i++) { + if (arr[pos] > arr[i]) { + pos = i; + } + } + return pos; +}; + +const _ = { + on(el, type, func, capture = false) { + el.addEventListener(type, func, capture); + }, + off(el, type, func, capture = false) { + el.removeEventListener(type, func, capture); + } +}; + +const sum = arr => arr.reduce((sum, val) => sum + val); +export default { + name: 'Waterfall', + props: { + gutterWidth: { + type: Number, + default: 0 + }, + gutterHeight: { + type: Number, + default: 0 + }, + resizable: { + type: Boolean, + default: true + }, + align: { + type: String, + default: 'center' + }, + fixWidth: { + type: Number + }, + minCol: { + type: Number, + default: 1 + }, + maxCol: { + type: Number + }, + percent: { + type: Array + } + }, + data() { + return { + timer: null, + colNum: 0, + lastWidth: 0, + percentWidthArr: [] + }; + }, + created() { + this.$on('itemRender', () => { + if (this.timer) { + clearTimeout(this.timer); + } + this.timer = setTimeout(() => { + this.render(); + }, 0); + }); + }, + mounted() { + this.resizeHandle(); + this.$watch('resizable', this.resizeHandle); + }, + methods: { + calulate(arr) { + let pageWidth = this.fixWidth ? this.fixWidth : this.$el.offsetWidth; + // 百分比布局计算 + if (this.percent) { + this.colNum = this.percent.length; + const total = sum(this.percent); + this.percentWidthArr = this.percent.map(value => (value / total) * pageWidth); + this.lastWidth = 0; + // 正常布局计算 + } else { + this.colNum = parseInt(pageWidth / (arr.width + this.gutterWidth)); + if (this.minCol && this.colNum < this.minCol) { + this.colNum = this.minCol; + this.lastWidth = 0; + } else if (this.maxCol && this.colNum > this.maxCol) { + this.colNum = this.maxCol; + this.lastWidth = pageWidth - (arr.width + this.gutterWidth) * this.colNum + this.gutterWidth; + } else { + this.lastWidth = pageWidth - (arr.width + this.gutterWidth) * this.colNum + this.gutterWidth; + } + } + }, + resizeHandle() { + if (this.resizable) { + _.on(window, 'resize', this.render, false); + } else { + _.off(window, 'resize', this.render, false); + } + }, + render() { + // 重新排序 + let childArr = []; + childArr = this.$children.map(child => child.getMeta()); + childArr = quickSort(childArr, 'order'); + // 计算列数 + this.calulate(childArr[0]) + let offsetArr = Array(this.colNum).fill(0); + // 渲染 + childArr.forEach(child => { + let position = getMinIndex(offsetArr); + // 百分比布局渲染 + if (this.percent) { + let left = 0; + child.el.style.width = `${this.percentWidthArr[position]}px`; + if (position === 0) { + left = 0; + } else { + for (let i = 0; i < position; i++) { + left += this.percentWidthArr[i]; + } + } + child.el.style.left = `${left}px`; + // 正常布局渲染 + } else { + if (this.align === 'left') { // eslint-disable-line no-lonely-if + child.el.style.left = `${position * (child.width + this.gutterWidth)}px`; + } else if (this.align === 'right') { + child.el.style.left = `${position * (child.width + this.gutterWidth) + this.lastWidth}px`; + } else { + child.el.style.left = `${position * (child.width + this.gutterWidth) + this.lastWidth / 2}px`; + } + } + if (child.height === 0) { + return; + } + child.el.style.top = `${offsetArr[position]}px`; + offsetArr[position] += (child.height + this.gutterHeight); + this.$el.style.height = `${Math.max.apply(Math, offsetArr)}px`; + }); + this.$emit('rendered', this); + } + } +}; +</script> diff --git a/src/site/components/grid/waterfall/WaterfallItem.vue b/src/site/components/grid/waterfall/WaterfallItem.vue new file mode 100644 index 0000000..597cca6 --- /dev/null +++ b/src/site/components/grid/waterfall/WaterfallItem.vue @@ -0,0 +1,60 @@ +<style> + .waterfall-item { + position: absolute; + } +</style> +<template> + <div class="waterfall-item"> + <slot/> + </div> +</template> +<script> +import imagesLoaded from 'imagesloaded'; +export default { + name: 'WaterfallItem', + props: { + order: { + type: Number, + default: 0 + }, + width: { + type: Number, + default: 150 + } + }, + data() { + return { + itemWidth: 0, + height: 0 + }; + }, + created() { + this.$watch(() => this.height, this.emit); + }, + mounted() { + this.$el.style.display = 'none'; + this.$el.style.width = `${this.width}px`; + this.emit(); + imagesLoaded(this.$el, () => { + this.$el.style.left = '-9999px'; + this.$el.style.top = '-9999px'; + this.$el.style.display = 'block'; + this.height = this.$el.offsetHeight; + this.itemWidth = this.$el.offsetWidth; + }); + }, + methods: { + emit() { + this.$parent.$emit('itemRender'); + }, + getMeta() { + return { + el: this.$el, + height: this.height, + width: this.itemWidth, + order: this.order + }; + } + } +} +</script> diff --git a/src/site/components/grid/waterfall/old/waterfall-slot.vue b/src/site/components/grid/waterfall/old/waterfall-slot.vue new file mode 100644 index 0000000..07ca268 --- /dev/null +++ b/src/site/components/grid/waterfall/old/waterfall-slot.vue @@ -0,0 +1,76 @@ +<template> + <div class="vue-waterfall-slot" v-show="isShow"> + <slot></slot> + </div> +</template> + +<style> +.vue-waterfall-slot { + position: absolute; + margin: 0; + padding: 0; + box-sizing: border-box; +} +</style> + +<script> + +export default { + data: () => ({ + isShow: false + }), + props: { + width: { + required: true, + validator: (val) => val >= 0 + }, + height: { + required: true, + validator: (val) => val >= 0 + }, + order: { + default: 0 + }, + moveClass: { + default: '' + } + }, + methods: { + notify () { + this.$parent.$emit('reflow', this) + }, + getMeta () { + return { + vm: this, + node: this.$el, + order: this.order, + width: this.width, + height: this.height, + moveClass: this.moveClass + } + } + }, + created () { + this.rect = { + top: 0, + left: 0, + width: 0, + height: 0 + } + this.$watch(() => ( + this.width, + this.height + ), this.notify) + }, + mounted () { + this.$parent.$once('reflowed', () => { + this.isShow = true + }) + this.notify() + }, + destroyed () { + this.notify() + } +} + +</script> diff --git a/src/site/components/grid/waterfall/old/waterfall.vue b/src/site/components/grid/waterfall/old/waterfall.vue new file mode 100644 index 0000000..84e3c98 --- /dev/null +++ b/src/site/components/grid/waterfall/old/waterfall.vue @@ -0,0 +1,442 @@ +<template> + <div class="vue-waterfall" :style="style"> + <slot></slot> + </div> +</template> + +<style> +.vue-waterfall { + position: relative; + /*overflow: hidden; cause clientWidth = 0 in IE if height not bigger than 0 */ +} +</style> + +<script> + +const MOVE_CLASS_PROP = '_wfMoveClass' + +export default { + props: { + autoResize: { + default: true + }, + interval: { + default: 200, + validator: (val) => val >= 0 + }, + align: { + default: 'left', + validator: (val) => ~['left', 'right', 'center'].indexOf(val) + }, + line: { + default: 'v', + validator: (val) => ~['v', 'h'].indexOf(val) + }, + lineGap: { + required: true, + validator: (val) => val >= 0 + }, + minLineGap: { + validator: (val) => val >= 0 + }, + maxLineGap: { + validator: (val) => val >= 0 + }, + singleMaxWidth: { + validator: (val) => val >= 0 + }, + fixedHeight: { + default: false + }, + grow: { + validator: (val) => val instanceof Array + }, + watch: { + default: () => ({}) + } + }, + data: () => ({ + style: { + height: '', + overflow: '' + }, + token: null + }), + methods: { + reflowHandler, + autoResizeHandler, + reflow + }, + created () { + this.virtualRects = [] + this.$on('reflow', () => { + this.reflowHandler() + }) + this.$watch(() => ( + this.align, + this.line, + this.lineGap, + this.minLineGap, + this.maxLineGap, + this.singleMaxWidth, + this.fixedHeight, + this.watch + ), this.reflowHandler) + this.$watch('grow', this.reflowHandler) + }, + mounted () { + this.$watch('autoResize', this.autoResizeHandler) + on(this.$el, getTransitionEndEvent(), tidyUpAnimations, true) + this.autoResizeHandler(this.autoResize) + }, + beforeDestroy () { + this.autoResizeHandler(false) + off(this.$el, getTransitionEndEvent(), tidyUpAnimations, true) + } +} + +function autoResizeHandler (autoResize) { + if (autoResize === false || !this.autoResize) { + off(window, 'resize', this.reflowHandler, false) + } else { + on(window, 'resize', this.reflowHandler, false) + } +} + +function tidyUpAnimations (event) { + let node = event.target + let moveClass = node[MOVE_CLASS_PROP] + if (moveClass) { + removeClass(node, moveClass) + } +} + +function reflowHandler () { + clearTimeout(this.token) + this.token = setTimeout(this.reflow, this.interval) +} + +function reflow () { + if (!this.$el) { return } + let width = this.$el.clientWidth + let metas = this.$children.map((slot) => slot.getMeta()) + metas.sort((a, b) => a.order - b.order) + this.virtualRects = metas.map(() => ({})) + calculate(this, metas, this.virtualRects) + setTimeout(() => { + if (isScrollBarVisibilityChange(this.$el, width)) { + calculate(this, metas, this.virtualRects) + } + this.style.overflow = 'hidden' + render(this.virtualRects, metas) + this.$emit('reflowed', this) + }, 0) +} + +function isScrollBarVisibilityChange (el, lastClientWidth) { + return lastClientWidth !== el.clientWidth +} + +function calculate (vm, metas, styles) { + let options = getOptions(vm) + let processor = vm.line === 'h' ? horizontalLineProcessor : verticalLineProcessor + processor.calculate(vm, options, metas, styles) +} + +function getOptions (vm) { + const maxLineGap = vm.maxLineGap ? +vm.maxLineGap : vm.lineGap + return { + align: ~['left', 'right', 'center'].indexOf(vm.align) ? vm.align : 'left', + line: ~['v', 'h'].indexOf(vm.line) ? vm.line : 'v', + lineGap: +vm.lineGap, + minLineGap: vm.minLineGap ? +vm.minLineGap : vm.lineGap, + maxLineGap: maxLineGap, + singleMaxWidth: Math.max(vm.singleMaxWidth || 0, maxLineGap), + fixedHeight: !!vm.fixedHeight, + grow: vm.grow && vm.grow.map(val => +val) + } +} + +var verticalLineProcessor = (() => { + + function calculate (vm, options, metas, rects) { + let width = vm.$el.clientWidth + let grow = options.grow + let strategy = grow + ? getRowStrategyWithGrow(width, grow) + : getRowStrategy(width, options) + let tops = getArrayFillWith(0, strategy.count) + metas.forEach((meta, index) => { + let offset = tops.reduce((last, top, i) => top < tops[last] ? i : last, 0) + let width = strategy.width[offset % strategy.count] + let rect = rects[index] + rect.top = tops[offset] + rect.left = strategy.left + (offset ? sum(strategy.width.slice(0, offset)) : 0) + rect.width = width + rect.height = meta.height * (options.fixedHeight ? 1 : width / meta.width) + tops[offset] = tops[offset] + rect.height + }) + vm.style.height = Math.max.apply(Math, tops) + 'px' + } + + function getRowStrategy (width, options) { + let count = width / options.lineGap + let slotWidth + if (options.singleMaxWidth >= width) { + count = 1 + slotWidth = Math.max(width, options.minLineGap) + } else { + let maxContentWidth = options.maxLineGap * ~~count + let minGreedyContentWidth = options.minLineGap * ~~(count + 1) + let canFit = maxContentWidth >= width + let canFitGreedy = minGreedyContentWidth <= width + if (canFit && canFitGreedy) { + count = Math.round(count) + slotWidth = width / count + } else if (canFit) { + count = ~~count + slotWidth = width / count + } else if (canFitGreedy) { + count = ~~(count + 1) + slotWidth = width / count + } else { + count = ~~count + slotWidth = options.maxLineGap + } + if (count === 1) { + slotWidth = Math.min(width, options.singleMaxWidth) + slotWidth = Math.max(slotWidth, options.minLineGap) + } + } + return { + width: getArrayFillWith(slotWidth, count), + count: count, + left: getLeft(width, slotWidth * count, options.align) + } + } + + function getRowStrategyWithGrow (width, grow) { + let total = sum(grow) + return { + width: grow.map(val => width * val / total), + count: grow.length, + left: 0 + } + } + + return { + calculate + } + +})() + +var horizontalLineProcessor = (() => { + + function calculate (vm, options, metas, rects) { + let width = vm.$el.clientWidth + let total = metas.length + let top = 0 + let offset = 0 + while (offset < total) { + let strategy = getRowStrategy(width, options, metas, offset) + for (let i = 0, left = 0, meta, rect; i < strategy.count; i++) { + meta = metas[offset + i] + rect = rects[offset + i] + rect.top = top + rect.left = strategy.left + left + rect.width = meta.width * strategy.height / meta.height + rect.height = strategy.height + left += rect.width + } + offset += strategy.count + top += strategy.height + } + vm.style.height = top + 'px' + } + + function getRowStrategy (width, options, metas, offset) { + let greedyCount = getGreedyCount(width, options.lineGap, metas, offset) + let lazyCount = Math.max(greedyCount - 1, 1) + let greedySize = getContentSize(width, options, metas, offset, greedyCount) + let lazySize = getContentSize(width, options, metas, offset, lazyCount) + let finalSize = chooseFinalSize(lazySize, greedySize, width) + let height = finalSize.height + let fitContentWidth = finalSize.width + if (finalSize.count === 1) { + fitContentWidth = Math.min(options.singleMaxWidth, width) + height = metas[offset].height * fitContentWidth / metas[offset].width + } + return { + left: getLeft(width, fitContentWidth, options.align), + count: finalSize.count, + height: height + } + } + + function getGreedyCount (rowWidth, rowHeight, metas, offset) { + let count = 0 + for (let i = offset, width = 0; i < metas.length && width <= rowWidth; i++) { + width += metas[i].width * rowHeight / metas[i].height + count++ + } + return count + } + + function getContentSize (rowWidth, options, metas, offset, count) { + let originWidth = 0 + for (let i = count - 1; i >= 0; i--) { + let meta = metas[offset + i] + originWidth += meta.width * options.lineGap / meta.height + } + let fitHeight = options.lineGap * rowWidth / originWidth + let canFit = (fitHeight <= options.maxLineGap && fitHeight >= options.minLineGap) + if (canFit) { + return { + cost: Math.abs(options.lineGap - fitHeight), + count: count, + width: rowWidth, + height: fitHeight + } + } else { + let height = originWidth > rowWidth ? options.minLineGap : options.maxLineGap + return { + cost: Infinity, + count: count, + width: originWidth * height / options.lineGap, + height: height + } + } + } + + function chooseFinalSize (lazySize, greedySize, rowWidth) { + if (lazySize.cost === Infinity && greedySize.cost === Infinity) { + return greedySize.width < rowWidth ? greedySize : lazySize + } else { + return greedySize.cost >= lazySize.cost ? lazySize : greedySize + } + } + + return { + calculate + } + +})() + +function getLeft (width, contentWidth, align) { + switch (align) { + case 'right': + return width - contentWidth + case 'center': + return (width - contentWidth) / 2 + default: + return 0 + } +} + +function sum (arr) { + return arr.reduce((sum, val) => sum + val) +} + +function render (rects, metas) { + let metasNeedToMoveByTransform = metas.filter((meta) => meta.moveClass) + let firstRects = getRects(metasNeedToMoveByTransform) + applyRects(rects, metas) + let lastRects = getRects(metasNeedToMoveByTransform) + metasNeedToMoveByTransform.forEach((meta, i) => { + meta.node[MOVE_CLASS_PROP] = meta.moveClass + setTransform(meta.node, firstRects[i], lastRects[i]) + }) + document.body.clientWidth // forced reflow + metasNeedToMoveByTransform.forEach((meta) => { + addClass(meta.node, meta.moveClass) + clearTransform(meta.node) + }) +} + +function getRects (metas) { + return metas.map((meta) => meta.vm.rect) +} + +function applyRects (rects, metas) { + rects.forEach((rect, i) => { + let style = metas[i].node.style + metas[i].vm.rect = rect + for (let prop in rect) { + style[prop] = rect[prop] + 'px' + } + }) +} + +function setTransform (node, firstRect, lastRect) { + let dx = firstRect.left - lastRect.left + let dy = firstRect.top - lastRect.top + let sw = firstRect.width / lastRect.width + let sh = firstRect.height / lastRect.height + node.style.transform = + node.style.WebkitTransform = `translate(${dx}px,${dy}px) scale(${sw},${sh})` + node.style.transitionDuration = '0s' +} + +function clearTransform (node) { + node.style.transform = node.style.WebkitTransform = '' + node.style.transitionDuration = '' +} + +function getTransitionEndEvent () { + let isWebkitTrans = + window.ontransitionend === undefined && + window.onwebkittransitionend !== undefined + let transitionEndEvent = isWebkitTrans + ? 'webkitTransitionEnd' + : 'transitionend' + return transitionEndEvent +} + +/** + * util + */ + +function getArrayFillWith (item, count) { + let getter = (typeof item === 'function') ? () => item() : () => item + let arr = [] + for (let i = 0; i < count; i++) { + arr[i] = getter() + } + return arr +} + +function addClass (elem, name) { + if (!hasClass(elem, name)) { + let cur = attr(elem, 'class').trim() + let res = (cur + ' ' + name).trim() + attr(elem, 'class', res) + } +} + +function removeClass (elem, name) { + let reg = new RegExp('\\s*\\b' + name + '\\b\\s*', 'g') + let res = attr(elem, 'class').replace(reg, ' ').trim() + attr(elem, 'class', res) +} + +function hasClass (elem, name) { + return (new RegExp('\\b' + name + '\\b')).test(attr(elem, 'class')) +} + +function attr (elem, name, value) { + if (typeof value !== 'undefined') { + elem.setAttribute(name, value) + } else { + return elem.getAttribute(name) || '' + } +} + +function on (elem, type, listener, useCapture = false) { + elem.addEventListener(type, listener, useCapture) +} + +function off (elem, type, listener, useCapture = false) { + elem.removeEventListener(type, listener, useCapture) +} + +</script> diff --git a/src/site/components/home/links/Links.vue b/src/site/components/home/links/Links.vue new file mode 100644 index 0000000..ba1e493 --- /dev/null +++ b/src/site/components/home/links/Links.vue @@ -0,0 +1,100 @@ +<style lang="scss" scoped> + @import '../../../styles/_colors.scss'; + .links { + margin-bottom: 3em; + align-items: stretch; + display: flex; + justify-content: space-between; + + div.link { cursor: pointer; } + .link { + background: $backgroundAccent; + display: block; + width: calc(25% - 2rem); + border-radius: 6px; + box-shadow: 0 1.5rem 1.5rem -1.25rem rgba(10,10,10,.05); + transition-duration: 86ms; + transition-property: box-shadow,-webkit-transform; + transition-property: box-shadow,transform; + transition-property: box-shadow,transform,-webkit-transform; + will-change: box-shadow,transform; + + header.bd-footer-star-header { + padding: 1.5rem; + + &:hover .bd-footer-subtitle { color: $textColorHighlight; } + + h4.bd-footer-title { + color: $textColorHighlight; + font-size: 1.5rem; + line-height: 1.25; + margin-bottom: .5rem; + transition-duration: 86ms; + transition-property: color; + font-weight: 700; + } + + p.bd-footer-subtitle { + color: $textColor; + margin-top: -.5rem; + transition-duration: 86ms; + transition-property: color; + font-weight: 400; + } + } + + &:hover { + box-shadow: 0 3rem 3rem -1.25rem rgba(10,10,10,.1); + -webkit-transform: translateY(-.5rem); + transform: translateY(-.5rem); + } + } + } + + @media screen and (max-width: 768px) { + .links { + display: block; + padding: 0px 2em; + .link { + width: 100%; + margin-bottom: 1.5em; + } + } + } +</style> +<template> + <div class="links"> + <a href="https://github.com/WeebDev/lolisafe" + target="_blank" + class="link"> + <header class="bd-footer-star-header"> + <h4 class="bd-footer-title">GitHub</h4> + <p class="bd-footer-subtitle">Deploy your own lolisafe</p> + </header> + </a> + <div class="link"> + <header class="bd-footer-star-header"> + <h4 class="bd-footer-title">ShareX</h4> + <p class="bd-footer-subtitle">Upload from your Desktop</p> + </header> + </div> + <a href="https://chrome.google.com/webstore/detail/lolisafe-uploader/enkkmplljfjppcdaancckgilmgoiofnj" + target="_blank" + class="link"> + <header class="bd-footer-star-header"> + <h4 class="bd-footer-title">Extension</h4> + <p class="bd-footer-subtitle">Upload from any website</p> + </header> + </a> + <router-link to="faq" + class="link"> + <header class="bd-footer-star-header"> + <h4 class="bd-footer-title">FAQ</h4> + <p class="bd-footer-subtitle">dunno</p> + </header> + </router-link> + </div> +</template> +<script> +export default {} +</script> diff --git a/src/site/components/loading/CubeShadow.vue b/src/site/components/loading/CubeShadow.vue new file mode 100644 index 0000000..af31dac --- /dev/null +++ b/src/site/components/loading/CubeShadow.vue @@ -0,0 +1,48 @@ +<template> + <div :style="styles" + class="spinner spinner--cube-shadow" /> +</template> + +<script> +export default { + props: { + size: { + type: String, + default: '60px' + }, + background: { + type: String, + default: '#9C27B0' + }, + duration: { + type: String, + default: '1.8s' + } + }, + computed: { + styles() { + return { + width: this.size, + height: this.size, + backgroundColor: this.background, + animationDuration: this.duration + }; + } + } +}; +</script> + +<style lang="scss" scoped> + .spinner{ + animation: cube-shadow-spinner 1.8s cubic-bezier(0.75, 0, 0.5, 1) infinite; + } + @keyframes cube-shadow-spinner { + 50% { + border-radius: 50%; + transform: scale(0.5) rotate(360deg); + } + 100% { + transform: scale(1) rotate(720deg); + } + } +</style> diff --git a/src/site/components/loading/Origami.vue b/src/site/components/loading/Origami.vue new file mode 100644 index 0000000..d1b523d --- /dev/null +++ b/src/site/components/loading/Origami.vue @@ -0,0 +1,121 @@ +<template> + <div :style="styles" + class="spinner spinner-origami"> + <div :style="innerStyles" + class="spinner-inner loading"> + <span class="slice" /> + <span class="slice" /> + <span class="slice" /> + <span class="slice" /> + <span class="slice" /> + <span class="slice" /> + </div> + </div> +</template> + +<script> +export default { + props: { + size: { + type: String, + default: '40px' + } + }, + computed: { + innerStyles() { + let size = parseInt(this.size); + return { transform: `scale(${(size / 60)})` }; + }, + styles() { + return { + width: this.size, + height: this.size + }; + } + } +}; +</script> + +<style lang="scss" scoped> +@import '../../styles/colors.scss'; + +@for $i from 1 through 6 { + @keyframes origami-show-#{$i}{ + from{ + transform: rotateZ(60* $i + deg) rotateY(-90deg) rotateX(0deg); + border-left-color: #31855e; + } + } + @keyframes origami-hide-#{$i}{ + to{ + transform: rotateZ(60* $i + deg) rotateY(-90deg) rotateX(0deg); + border-left-color: #31855e; + } + } + + @keyframes origami-cycle-#{$i} { + + $startIndex: $i*5; + $reverseIndex: (80 - $i*5); + + #{$startIndex * 1%} { + transform: rotateZ(60* $i + deg) rotateY(90deg) rotateX(0deg); + border-left-color: #31855e; + } + #{$startIndex + 5%}, + #{$reverseIndex * 1%} { + transform: rotateZ(60* $i + deg) rotateY(0) rotateX(0deg); + border-left-color: #41b883; + } + + #{$reverseIndex + 5%}, + 100%{ + transform: rotateZ(60* $i + deg) rotateY(90deg) rotateX(0deg); + border-left-color: #31855e; + } + } +} + +.spinner{ + display: flex; + justify-content: center; + align-items: center; + * { + line-height: 0; + box-sizing: border-box; + } +} +.spinner-inner{ + display: block; + width: 60px; + height: 68px; + .slice { + border-top: 18px solid transparent; + border-right: none; + border-bottom: 16px solid transparent; + border-left: 30px solid #f7484e; + position: absolute; + top: 0px; + left: 50%; + transform-origin: left bottom; + border-radius: 3px 3px 0 0; + } + + @for $i from 1 through 6 { + .slice:nth-child(#{$i}) { + transform: rotateZ(60* $i + deg) rotateY(0deg) rotateX(0); + animation: .15s linear .9 - $i*.08s origami-hide-#{$i} both 1; + } + } + + &.loading{ + @for $i from 1 through 6 { + .slice:nth-child(#{$i}) { + transform: rotateZ(60* $i + deg) rotateY(90deg) rotateX(0); + animation: 2s origami-cycle-#{$i} linear infinite both; + } + } + } + +} +</style> diff --git a/src/site/components/loading/PingPong.vue b/src/site/components/loading/PingPong.vue new file mode 100644 index 0000000..ac33e28 --- /dev/null +++ b/src/site/components/loading/PingPong.vue @@ -0,0 +1,98 @@ +<template> + <div :style="styles" + class="spinner spinner--ping-pong"> + <div :style="innerStyles" + class="spinner-inner"> + <div class="board"> + <div class="left"/> + <div class="right"/> + <div class="ball"/> + </div> + </div> + </div> +</template> + +<script> +export default { + props: { + size: { + type: String, + default: '60px' + } + }, + computed: { + innerStyles() { + let size = parseInt(this.size); + return { transform: `scale(${size / 250})` }; + }, + styles() { + return { + width: this.size, + height: this.size + }; + } + } +} +</script> + +<style lang="scss" scoped> + .spinner{ + overflow: hidden; + display: flex; + justify-content: center; + align-items: center; + * { + line-height: 0; + box-sizing: border-box; + } + } + .board { + width:250px; + position: relative; + } + .left, + .right { + height:50px; + width:15px; + background:#41b883; + display: inline-block; + position:absolute; + } + .left { + left:0; + animation: pingpong-position1 2s linear infinite; + } + .right { + right:0; + animation: pingpong-position2 2s linear infinite; + } + .ball{ + width:15px; + height:15px; + border-radius:50%; + background:#f7484e; + position:absolute; + animation: pingpong-bounce 2s linear infinite; + } + @keyframes pingpong-position1 { + 0% {top:-60px;} + 25% {top:0;} + 50% {top:60px;} + 75% {top:-60px;} + 100% {top:-60px;} + } + @keyframes pingpong-position2 { + 0% {top:60px;} + 25% {top:0;} + 50% {top:-60px;} + 75% {top:-60px;} + 100% {top:60px;} + } + @keyframes pingpong-bounce { + 0% {top:-35px;left:10px;} + 25% {top:25px;left:225px;} + 50% {top:75px;left:10px;} + 75% {top:-35px;left:225px;} + 100% {top:-35px;left:10px;} + } +</style> diff --git a/src/site/components/loading/RotateSquare.vue b/src/site/components/loading/RotateSquare.vue new file mode 100644 index 0000000..4da8300 --- /dev/null +++ b/src/site/components/loading/RotateSquare.vue @@ -0,0 +1,87 @@ +<template> + <div :style="styles" + class="spinner spinner--rotate-square-2" /> +</template> + +<script> +export default { + props: { + size: { + type: String, + default: '40px' + } + }, + computed: { + styles() { + return { + width: this.size, + height: this.size, + display: 'inline-block' + }; + } + } +}; +</script> + +<style lang="scss" scoped> +@import '../../styles/colors.scss'; + +.spinner { + position: relative; + * { + line-height: 0; + box-sizing: border-box; + } + &:before { + content: ''; + width: 100%; + height: 20%; + min-width: 5px; + background: #000; + opacity: 0.1; + position: absolute; + bottom: 0%; + left: 0; + border-radius: 50%; + animation: rotate-square-2-shadow .5s linear infinite; + } + &:after { + content: ''; + width: 100%; + height: 100%; + background: $basePink; + animation: rotate-square-2-animate .5s linear infinite; + position: absolute; + bottom:40%; + left: 0; + border-radius: 3px; + } +} + +@keyframes rotate-square-2-animate { + 17% { + border-bottom-right-radius: 3px; + } + 25% { + transform: translateY(20%) rotate(22.5deg); + } + 50% { + transform: translateY(40%) scale(1, .9) rotate(45deg); + border-bottom-right-radius: 50%; + } + 75% { + transform: translateY(20%) rotate(67.5deg); + } + 100% { + transform: translateY(0) rotate(90deg); + } +} +@keyframes rotate-square-2-shadow { + 0%, 100% { + transform: scale(1, 1); + } + 50% { + transform: scale(1.2, 1); + } +} +</style> diff --git a/src/site/components/logo/Logo.vue b/src/site/components/logo/Logo.vue new file mode 100644 index 0000000..d594c7e --- /dev/null +++ b/src/site/components/logo/Logo.vue @@ -0,0 +1,59 @@ +<style lang="scss" scoped> + @import '../../styles/_colors.scss'; + #logo { + -webkit-animation-delay: 0.5s; + animation-delay: 0.5s; + -webkit-animation-duration: 1.5s; + animation-duration: 1.5s; + -webkit-animation-fill-mode: both; + animation-fill-mode: both; + -webkit-animation-name: floatUp; + animation-name: floatUp; + -webkit-animation-timing-function: cubic-bezier(0, 0.71, 0.29, 1); + animation-timing-function: cubic-bezier(0, 0.71, 0.29, 1); + border-radius: 24px; + display: inline-block; + height: 240px; + position: relative; + vertical-align: top; + width: 240px; + box-shadow: 0 20px 60px rgba(10, 10, 10, 0.05), 0 5px 10px rgba(10, 10, 10, 0.1), 0 1px 1px rgba(10, 10, 10, 0.2); + background: $backgroundAccent; + pointer-events: none; + } + + #logo img { + height: 200px; + margin-top: 20px; + } + + @keyframes floatUp { + 0% { + opacity: 0; + box-shadow: 0 0 0 rgba(10, 10, 10, 0), 0 0 0 rgba(10, 10, 10, 0), 0 0 0 rgba(10, 10, 10, 0); + -webkit-transform: scale(0.86); + transform: scale(0.86); + } + 25% { opacity: 100; } + 67% { + box-shadow: 0 0 0 rgba(10, 10, 10, 0), 0 5px 10px rgba(10, 10, 10, 0.1), 0 1px 1px rgba(10, 10, 10, 0.2); + -webkit-transform: scale(1); + transform: scale(1); + } + 100% { + box-shadow: 0 20px 60px rgba(10, 10, 10, 0.05), 0 5px 10px rgba(10, 10, 10, 0.1), 0 1px 1px rgba(10, 10, 10, 0.2); + -webkit-transform: scale(1); + transform: scale(1); + } + } +</style> + +<template> + <p id="logo"> + <img src="../../../../public/images/logo.png"> + </p> +</template> + +<script> +export default {}; +</script> diff --git a/src/site/components/navbar/Navbar.vue b/src/site/components/navbar/Navbar.vue new file mode 100644 index 0000000..108c150 --- /dev/null +++ b/src/site/components/navbar/Navbar.vue @@ -0,0 +1,119 @@ +<style lang="scss" scoped> + @import '../../styles/colors.scss'; + nav.navbar { + background: transparent; + box-shadow: none; + + .navbar-brand { + width: 100%; + align-items: flex-start; + padding: 1em; + + div.spacer { flex: 1 0 10px; } + a.navbar-item { + color: $defaultTextColor; + font-size: 16px; + font-weight: 700; + text-decoration-style: solid; + } + a.navbar-item:hover, a.navbar-item.is-active, a.navbar-link:hover, a.navbar-link.is-active { + text-decoration: underline; + background: transparent; + } + + i { + font-size: 2em; + &.hidden { + width: 0px; + height: 1.5em; + pointer-events: none; + } + } + } + + &.isWhite { + .navbar-brand { + a.navbar-item { + color: white; + } + } + } + } +</style> + +<template> + <nav :class="{ isWhite }" + class="navbar is-transparent"> + <div class="navbar-brand"> + <router-link to="/" + class="navbar-item no-active"> + <i class="icon-ecommerce-safebox"/> {{ config.serviceName }} + </router-link> + + <!-- + <template v-if="loggedIn"> + <router-link + to="/dashboard/uploads" + class="navbar-item no-active" + exact><i class="hidden"/>Uploads</router-link> + + <router-link + to="/dashboard/albums" + class="navbar-item no-active" + exact><i class="hidden"/>Albums</router-link> + + <router-link + to="/dashboard/tags" + class="navbar-item no-active" + exact><i class="hidden"/>Tags</router-link> + + <router-link + to="/dashboard/settings" + class="navbar-item no-active" + exact><i class="hidden"/>Settings</router-link> + </template> + --> + + <div class="spacer" /> + + <router-link v-if="!loggedIn" + class="navbar-item" + to="/login"><i class="hidden"/>Login</router-link> + + <router-link v-else + to="/dashboard" + class="navbar-item no-active" + exact><i class="hidden"/>Dashboard</router-link> + </div> + </nav> +</template> + +<script> +export default { + props: { + isWhite: { + type: Boolean, + default: false + } + }, + data() { + return { hamburger: false }; + }, + computed: { + loggedIn() { + return this.$store.state.loggedIn; + }, + user() { + return this.$store.state.user; + }, + config() { + return this.$store.state.config; + } + }, + methods: { + logOut() { + this.$emit('logout'); + } + } +}; +</script> diff --git a/src/site/components/sidebar/Sidebar.vue b/src/site/components/sidebar/Sidebar.vue new file mode 100644 index 0000000..861ebea --- /dev/null +++ b/src/site/components/sidebar/Sidebar.vue @@ -0,0 +1,43 @@ +<style lang="scss" scoped> + @import '../../styles/colors.scss'; + .dashboard-menu { + a { + display: block; + font-weight: 700; + color: #868686; + position: relative; + padding-left: 40px; + height: 35px; + &:hover, &:first-child { + color: $defaultTextColor; + } + + i { + position: absolute; + font-size: 1.5em; + top: -4px; + left: 5px; + } + } + + hr { margin-top: 0.6em; } + } +</style> +<template> + <div class="dashboard-menu"> + <router-link to="/"><i class="icon-ecommerce-safebox"/>lolisafe</router-link> + <hr> + <a><i class="icon-interface-cloud-upload"/>Upload files</a> + <hr> + <router-link to="/dashboard"><i class="icon-com-pictures"/>Files</router-link> + <router-link to="/dashboard/albums"><i class="icon-interface-window"/>Albums</router-link> + <router-link to="/dashboard/tags"><i class="icon-ecommerce-tag-c"/>Tags</router-link> + <hr> + <router-link to="/dashboard/settings"><i class="icon-setting-gear-a"/>Settings</router-link> + </div> +</template> +<script> +export default { + +} +</script> diff --git a/src/site/components/uploader/Uploader.vue b/src/site/components/uploader/Uploader.vue new file mode 100644 index 0000000..fcb79cb --- /dev/null +++ b/src/site/components/uploader/Uploader.vue @@ -0,0 +1,251 @@ +<template> + <div :class="{ hasFiles: files.length > 0 }" + class="uploader-wrapper"> + <b-select v-if="loggedIn" + v-model="selectedAlbum" + placeholder="Upload to album" + size="is-medium" + expanded> + <option + v-for="album in albums" + :value="album.id" + :key="album.id"> + {{ album.name }} + </option> + </b-select> + <dropzone v-if="showDropzone" + id="dropzone" + ref="el" + :options="dropzoneOptions" + :include-styling="false" + @vdropzone-success="dropzoneSuccess" + @vdropzone-error="dropzoneError" + @vdropzone-files-added="dropzoneFilesAdded" /> + + <div id="template" + ref="template"> + <div class="dz-preview dz-file-preview"> + <div class="dz-details"> + <div class="dz-filename"><span data-dz-name/></div> + <div class="dz-size"><span data-dz-size/></div> + </div> + <div class="result"> + <div class="copyLink"> + <b-tooltip label="Copy link"> + <i class="icon-web-code"/> + </b-tooltip> + </div> + <div class="openLink"> + <b-tooltip label="Open file"> + <a class="link" + target="_blank"> + <i class="icon-web-url"/> + </a> + </b-tooltip> + </div> + </div> + <div class="error"> + <div> + <span> + <span class="error-message" + data-dz-errormessage/> + <i class="icon-web-warning"/> + </span> + </div> + </div> + <div class="dz-progress"> + <span class="dz-upload" + data-dz-uploadprogress/> + </div> + <!-- + <div class="dz-error-message"><span data-dz-errormessage/></div> + <div class="dz-success-mark"><i class="fa fa-check"/></div> + <div class="dz-error-mark"><i class="fa fa-close"/></div> + --> + </div> + </div> + </div> +</template> + +<script> +import Dropzone from 'nuxt-dropzone'; +import '../../styles/dropzone.scss'; + +export default { + components: { Dropzone }, + data() { + return { + files: [], + dropzoneOptions: {}, + showDropzone: false, + albums: [], + selectedAlbum: null + }; + }, + computed: { + config() { + return this.$store.state.config; + }, + token() { + return this.$store.state.token; + }, + loggedIn() { + return this.$store.state.loggedIn; + } + }, + watch: { + loggedIn() { + this.getAlbums(); + }, + selectedAlbum() { + this.updateDropzoneConfig(); + } + }, + mounted() { + this.dropzoneOptions = { + url: `${this.$config.baseURL}/upload`, + autoProcessQueue: true, + addRemoveLinks: false, + parallelUploads: 5, + uploadMultiple: false, + maxFiles: 1000, + createImageThumbnails: false, + paramName: 'file', + chunking: true, + retryChunks: true, + retryChunksLimit: 3, + parallelChunkUploads: false, + chunkSize: this.config.chunkSize * 1000000, + chunksUploaded: this.dropzoneChunksUploaded, + maxFilesize: this.config.maxFileSize, + previewTemplate: this.$refs.template.innerHTML, + dictDefaultMessage: 'Drag & Drop your files or click to browse', + headers: { Accept: 'application/vnd.lolisafe.json' } + }; + this.showDropzone = true; + if (this.loggedIn) this.getAlbums(); + }, + methods: { + /* + Get all available albums so the user can upload directly to one (or several soon™) of them. + */ + async getAlbums() { + try { + const response = await this.axios.get(`${this.$config.baseURL}/albums/dropdown`); + this.albums = response.data.albums; + this.updateDropzoneConfig(); + } catch (error) { + this.$onPromiseError(error); + } + }, + + /* + This method needs to be called after the token or selectedAlbum changes + since dropzone doesn't seem to update the config values unless you force it. + Tch. + */ + updateDropzoneConfig() { + this.$refs.el.setOption('headers', { + Accept: 'application/vnd.lolisafe.json', + Authorization: this.token ? `Bearer ${this.token}` : '', + albumId: this.selectedAlbum ? this.selectedAlbum : null + }); + }, + + /* + Dropzone stuff + */ + dropzoneFilesAdded(files) { + // console.log(files); + }, + dropzoneSuccess(file, response) { + this.processResult(file, response); + }, + dropzoneError(file, message, xhr) { + this.$showToast('There was an error uploading this file. Check the console.', true, 5000); + console.error(file, message, xhr); + }, + dropzoneChunksUploaded(file, done) { + const response = JSON.parse(file.xhr.response); + if (!response.url) { + console.error('There was a problem uploading the file?'); + return done(); + } + + this.processResult(file, response); + this.$forceUpdate(); + return done(); + }, + + /* + If upload/s was/were successfull we modify the template so that the buttons for + copying the returned url or opening it in a new window appear. + */ + processResult(file, response) { + if (!response.url) return; + file.previewTemplate.querySelector('.link').setAttribute('href', response.url); + file.previewTemplate.querySelector('.copyLink').addEventListener('click', () => { + this.$showToast('Link copied!', false, 1000); + this.$clipboard(response.url); + }); + } + } +}; +</script> +<style lang="scss" scoped> + #template { display: none; } + .uploader-wrapper { + display: block; + width: 400px; + margin: 0 auto; + max-width: 100%; + transition-duration: 86ms; + transition-property: box-shadow,-webkit-transform; + transition-property: box-shadow,transform; + transition-property: box-shadow,transform,-webkit-transform; + will-change: box-shadow,transform; + + &:hover, &.hasFiles { + box-shadow: 0 1rem 3rem 0rem rgba(10, 10, 10, 0.25); + -webkit-transform: translateY(-.5rem); + transform: translateY(-.5rem); + } + } +</style> +<style lang="scss"> + @import '../../styles/colors.scss'; + .filepond--panel-root { + background: transparent; + border: 2px solid #2c3340; + } + .filepond--drop-label { + color: #c7ccd8; + pointer-events: none; + } + + .filepond--item-panel { + background-color: #767b8b; + } + + .filepond--root .filepond--drip-blob { + background-color: #7f8a9a + } + + .filepond--drip { + background: black; + } + + div.uploader-wrapper { + div.control { + margin-bottom: 5px; + span.select { + select { + border: 2px solid #2c3340; + background: rgba(0, 0, 0, 0.15); + border-radius: .3em; + color: $uploaderDropdownColor; + } + } + } + } +</style> |