diff options
| author | Pitu <[email protected]> | 2021-01-04 01:04:20 +0900 |
|---|---|---|
| committer | Pitu <[email protected]> | 2021-01-04 01:04:20 +0900 |
| commit | fcd39dc550dec8dbcb8325e07e938c5024cbc33d (patch) | |
| tree | f41acb4e0d5fd3c3b1236fe4324b3fef9ec6eafe /src/site/components | |
| parent | Create FUNDING.yml (diff) | |
| parent | chore: update todo (diff) | |
| download | host.fuwn.me-fcd39dc550dec8dbcb8325e07e938c5024cbc33d.tar.xz host.fuwn.me-fcd39dc550dec8dbcb8325e07e938c5024cbc33d.zip | |
Merge branch 'dev'
Diffstat (limited to 'src/site/components')
22 files changed, 3345 insertions, 0 deletions
diff --git a/src/site/components/album/AlbumDetails.vue b/src/site/components/album/AlbumDetails.vue new file mode 100644 index 0000000..81819b2 --- /dev/null +++ b/src/site/components/album/AlbumDetails.vue @@ -0,0 +1,294 @@ +<template> + <div class="details"> + <h2>Public links for this album:</h2> + + <b-table + :data="details.links || []" + :mobile-cards="true"> + <b-table-column + v-slot="props" + field="identifier" + label="Link" + centered> + <a + :href="`${config.URL}/a/${props.row.identifier}`" + target="_blank"> + {{ props.row.identifier }} + </a> + </b-table-column> + + <b-table-column + v-slot="props" + field="views" + label="Views" + centered> + {{ props.row.views }} + </b-table-column> + + <b-table-column + v-slot="props" + field="enableDownload" + label="Allow download" + centered> + <b-switch + v-model="props.row.enableDownload" + @input="updateLinkOptions(albumId, props.row)" /> + </b-table-column> + + <b-table-column + v-slot="props" + field="enabled" + numeric> + <button + :class="{ 'is-loading': isDeleting(props.row.identifier) }" + class="button is-danger" + :disabled="isDeleting(props.row.identifier)" + @click="promptDeleteAlbumLink(albumId, props.row.identifier)"> + Delete link + </button> + </b-table-column> + + <template slot="empty"> + <div class="has-text-centered"> + <i class="icon-misc-mood-sad" /> + </div> + <div class="has-text-centered"> + Nothing here + </div> + </template> + + <template slot="footer"> + <div class="level is-paddingless"> + <div class="level-left"> + <div class="level-item"> + <b-field v-if="auth.user.isAdmin"> + <p class="control"> + <button + :class="{ 'is-loading': isCreatingLink }" + class="button is-primary reset-font-size-button" + style="float: left" + @click="createLink(albumId)"> + Create new link + </button> + </p> + <p class="control"> + <b-dropdown> + <button slot="trigger" class="button is-primary reset-font-size-button"> + <b-icon icon="menu-down" /> + </button> + + <b-dropdown-item @click="createCustomLink(albumId)"> + Custom link + </b-dropdown-item> + </b-dropdown> + </p> + </b-field> + <button + v-else + :class="{ 'is-loading': isCreatingLink }" + class="button is-primary" + style="float: left" + @click="createLink(albumId)"> + Create new link + </button> + </div> + <div class="level-item"> + <span class="has-text-default">{{ details.links.length }} / {{ config.maxLinksPerAlbum }} links created</span> + </div> + </div> + + <div class="level-right"> + <div class="level-item"> + <b-switch + :value="nsfw" + @input="toggleNsfw()" /> + </div> + <div class="level-item"> + <button + class="button is-danger" + style="float: right" + @click="promptDeleteAlbum(albumId)"> + Delete album + </button> + </div> + </div> + </div> + </template> + </b-table> + </div> +</template> + +<script> +import { mapState, mapActions } from 'vuex'; + +export default { + props: { + albumId: { + 'type': Number, + 'default': 0 + }, + details: { + 'type': Object, + 'default': () => ({}) + }, + nsfw: { + 'type': Boolean, + 'default': false + } + }, + data() { + return { + isCreatingLink: false, + isDeletingLinks: [] + }; + }, + computed: { + ...mapState(['config', 'auth']) + }, + mounted() { + console.log(this.isNsfw); + }, + methods: { + ...mapActions({ + deleteAlbumAction: 'albums/deleteAlbum', + deleteAlbumLinkAction: 'albums/deleteLink', + updateLinkOptionsAction: 'albums/updateLinkOptions', + createLinkAction: 'albums/createLink', + createCustomLinkAction: 'albums/createCustomLink', + toggleNsfwAction: 'albums/toggleNsfw', + alert: 'alert/set' + }), + promptDeleteAlbum(id) { + this.$buefy.dialog.confirm({ + type: 'is-danger', + message: 'Are you sure you want to delete this album?', + onConfirm: () => this.deleteAlbum(id) + }); + }, + promptDeleteAlbumLink(albumId, identifier) { + this.$buefy.dialog.confirm({ + type: 'is-danger', + message: 'Are you sure you want to delete this album link?', + onConfirm: () => this.deleteAlbumLink(albumId, identifier) + }); + }, + async deleteAlbum(id) { + try { + const response = await this.deleteAlbumAction(id); + + this.alert({ text: response.message, error: false }); + } catch (e) { + this.alert({ text: e.message, error: true }); + } + }, + async deleteAlbumLink(albumId, identifier) { + this.isDeletingLinks.push(identifier); + try { + const response = await this.deleteAlbumLinkAction({ albumId, identifier }); + + this.alert({ text: response.message, error: false }); + } catch (e) { + this.alert({ text: e.message, error: true }); + } finally { + this.isDeletingLinks = this.isDeletingLinks.filter(e => e !== identifier); + } + }, + async createLink(albumId) { + this.isCreatingLink = true; + try { + const response = await this.createLinkAction(albumId); + + this.alert({ text: response.message, error: false }); + } catch (e) { + this.alert({ text: e.message, error: true }); + } finally { + this.isCreatingLink = false; + } + }, + async updateLinkOptions(albumId, linkOpts) { + try { + const response = await this.updateLinkOptionsAction({ albumId, linkOpts }); + + this.alert({ text: response.message, error: false }); + } catch (e) { + this.alert({ text: e.message, error: true }); + } + }, + async toggleNsfw() { + try { + const response = await this.toggleNsfwAction({ + albumId: this.albumId, + nsfw: !this.nsfw + }); + this.alert({ text: response.message, error: false }); + } catch (e) { + this.alert({ text: e.message, error: true }); + } + }, + async createCustomLink(albumId) { + this.$buefy.dialog.prompt({ + message: 'Custom link identifier', + inputAttrs: { + placeholder: '', + maxlength: 10 + }, + trapFocus: true, + onConfirm: value => this.$handler.executeAction('albums/createCustomLink', { albumId, value }) + }); + }, + isDeleting(identifier) { + return this.isDeletingLinks.indexOf(identifier) > -1; + } + } +}; +</script> + +<style lang="scss" scoped> + @import '~/assets/styles/_colors.scss'; + + .reset-font-size-button { + font-size: 1rem; + height: 2.25em; + } + + div.details { + flex: 0 1 100%; + padding-left: 2em; + padding-top: 1em; + min-height: 50px; + + .b-table { + padding: 2em 0em; + + .table-wrapper { + box-shadow: $boxShadowLight; + } + } + } +</style> + +<style lang="scss"> + @import '~/assets/styles/_colors.scss'; + + .b-table { + .table-wrapper { + box-shadow: $boxShadowLight; + } + } + + .dialog.modal .modal-card-body input { + border: 2px solid #21252d; + border-radius: 0.3em !important; + background: rgba(0, 0, 0, 0.15); + padding: 1rem; + color: $textColor; + height: 3rem; + &:focus, + &:hover { + border: 2px solid #21252d; + } + &::placeholder { + color: $textColor; + } + } +</style> diff --git a/src/site/components/album/AlbumEntry.vue b/src/site/components/album/AlbumEntry.vue new file mode 100644 index 0000000..8947fa5 --- /dev/null +++ b/src/site/components/album/AlbumEntry.vue @@ -0,0 +1,182 @@ +<template> + <div class="album"> + <div + class="arrow-container" + @click="toggleDetails(album)"> + <i + :class="{ active: isExpanded }" + class="icon-arrow" /> + </div> + <div class="thumb"> + <figure class="image is-64x64 thumb"> + <img src="~/assets/images/blank_darker.png"> + </figure> + </div> + <div class="info"> + <h4> + <router-link :to="`/dashboard/albums/${album.id}`"> + {{ album.name }} + </router-link> + </h4> + <span> + Created <span class="is-inline has-text-weight-semibold"><timeago :since="album.createdAt" /></span> + </span> + <span>{{ album.fileCount || 0 }} files</span> + </div> + <div class="latest is-hidden-mobile"> + <template v-if="album.fileCount > 0"> + <div + v-for="file of album.files" + :key="file.id" + class="thumb"> + <figure class="image is-64x64"> + <a + :href="file.url" + target="_blank"> + <img :src="file.thumbSquare"> + </a> + </figure> + </div> + <div + v-if="album.fileCount > 5" + class="thumb more no-background"> + <router-link :to="`/dashboard/albums/${album.id}`"> + {{ album.fileCount - 5 }}+ more + </router-link> + </div> + </template> + <template v-else> + <span class="no-files">Nothing to show here</span> + </template> + </div> + + <AlbumDetails + v-if="isExpanded" + :details="getDetails(album.id)" + :album-id="album.id" + :nsfw="album.nsfw" /> + </div> +</template> + +<script> +import { mapGetters } from 'vuex'; +import AlbumDetails from '~/components/album/AlbumDetails.vue'; + +export default { + components: { + AlbumDetails + }, + props: { + album: { + 'type': Object, + 'default': () => ({}) + } + }, + computed: { + ...mapGetters({ + isExpandedGetter: 'albums/isExpanded', + getDetails: 'albums/getDetails' + }), + isExpanded() { + return this.isExpandedGetter(this.album.id); + } + }, + methods: { + async toggleDetails(album) { + if (!this.isExpanded) { + await this.$store.dispatch('albums/fetchDetails', album.id); + } + this.$store.commit('albums/toggleExpandedState', album.id); + } + } +}; +</script> + +<style lang="scss" scoped> + @import '~/assets/styles/_colors.scss'; + + div.album { + display: flex; + flex-wrap: wrap; + margin-bottom: 10px; + + div.arrow-container { + width: 2em; + height: 64px; + position: relative; + cursor: pointer; + + i { + border: 2px solid $defaultTextColor; + border-right: 0; + border-top: 0; + display: block; + height: 1em; + position: absolute; + transform: rotate(-135deg); + transform-origin: center; + width: 1em; + z-index: 4; + top: 22px; + transition: transform 0.1s linear; + + &.active { + transform: rotate(-45deg); + } + } + } + + div.thumb { + width: 64px; + height: 64px; + box-shadow: $boxShadowLight; + } + + div.info { + margin-left: 15px; + text-align: left; + h4 { + font-size: 1.5rem; + a { + color: $defaultTextColor; + font-weight: 400; + &:hover { text-decoration: underline; } + } + } + span { display: block; } + span:nth-child(3) { + font-size: 0.9rem; + } + } + + div.latest { + flex-grow: 1; + justify-content: flex-end; + display: flex; + margin-left: 15px; + + span.no-files { + font-size: 1.5em; + color: #b1b1b1; + padding-top: 17px; + } + + div.more { + width: 64px; + height: 64px; + background: white; + display: flex; + align-items: center; + padding: 10px; + text-align: center; + a { + line-height: 1rem; + color: $defaultTextColor; + &:hover { text-decoration: underline; } + } + } + } + } + + div.no-background { background: none !important; } +</style> diff --git a/src/site/components/footer/Footer.test.js b/src/site/components/footer/Footer.test.js new file mode 100644 index 0000000..379f939 --- /dev/null +++ b/src/site/components/footer/Footer.test.js @@ -0,0 +1,25 @@ +/* eslint-disable no-undef */ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Component from './Footer.vue'; +import Vuex from 'vuex'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('Footer.vue', () => { + const store = new Vuex.Store({ + getters: { + 'auth/isLoggedIn': () => false + }, + state: { + config: {} + } + }); + + it('Should render chibisafe as the instance title', () => { + const wrapper = shallowMount(Component, { store, localVue }); + + const title = wrapper.find('h4'); + expect(title.text()).toBe('chibisafe'); + }); +}); diff --git a/src/site/components/footer/Footer.vue b/src/site/components/footer/Footer.vue new file mode 100644 index 0000000..38e3f07 --- /dev/null +++ b/src/site/components/footer/Footer.vue @@ -0,0 +1,110 @@ +<template> + <footer> + <div class="container"> + <div class="columns"> + <div class="column is-narrow"> + <h4>chibisafe</h4> + <span>© 2017-{{ getYear }} + <a + href="https://github.com/pitu" + class="no-block">Pitu</a> + </span><br> + <span>v{{ version }}</span> + </div> + <div class="column is-narrow bottom-up"> + <a href="https://github.com/weebdev/chibisafe">GitHub</a> + <a href="https://patreon.com/pitu">Patreon</a> + <a href="https://discord.gg/5g6vgwn">Discord</a> + </div> + <div class="column is-narrow bottom-up"> + <a + v-if="loggedIn" + @click="createShareXThing">ShareX Config</a> + <a href="https://chrome.google.com/webstore/detail/lolisafe-uploader/enkkmplljfjppcdaancckgilmgoiofnj">Chrome Extension</a> + </div> + </div> + </div> + </footer> +</template> + +<script> +/* eslint-disable no-restricted-globals */ + +import { mapState, mapGetters } from 'vuex'; +import { saveAs } from 'file-saver'; + +export default { + computed: { + ...mapGetters({ loggedIn: 'auth/isLoggedIn' }), + ...mapState({ + version: state => state.config.version, + serviceName: state => state.config.serviceName, + token: state => state.auth.token + }), + getYear() { + return new Date().getFullYear(); + } + }, + methods: { + createShareXThing() { + const sharexFile = `{ + "Name": "${this.serviceName}", + "DestinationType": "ImageUploader, FileUploader", + "RequestType": "POST", + "RequestURL": "${location.origin}/api/upload", + "FileFormName": "files[]", + "Headers": { + "authorization": "Bearer ${this.token}", + "accept": "application/vnd.chibisafe.json" + }, + "ResponseType": "Text", + "URL": "$json:url$", + "ThumbnailURL": "$json:url$" + }`; + const sharexBlob = new Blob([sharexFile], { type: 'application/octet-binary' }); + saveAs(sharexBlob, `${location.hostname}.sxcu`); + } + } +}; +</script> + +<style lang="scss" scoped> + @import '~/assets/styles/_colors.scss'; + footer { + @media screen and (min-width: 1025px) { + position: fixed; + bottom: 0; + width: 100%; + > div { + padding: 1rem 1rem !important; + max-width: unset !important; + } + } + + .container { + .column { + text-align: center; + @media screen and (min-width: 1025px) { + margin-right: 2rem; + &.bottom-up { + display: flex; + flex-direction: column; + justify-content: flex-end; + margin-right: 0; + } + } + + a { + display: block; + color: $textColor; + &:hover { + color: white + } + &.no-block { + display: inherit; + } + } + } + } + } +</style> diff --git a/src/site/components/grid/Grid.vue b/src/site/components/grid/Grid.vue new file mode 100644 index 0000000..9e1ce6f --- /dev/null +++ b/src/site/components/grid/Grid.vue @@ -0,0 +1,498 @@ +<template> + <div> + <nav class="level"> + <div class="level-left"> + <div class="level-item"> + <slot name="pagination" /> + </div> + </div> + <!-- TODO: Externalize this so it can be saved as an user config (and between re-renders) --> + <div v-if="enableToolbar" class="level-right toolbar"> + <div class="level-item"> + <div class="block"> + <b-radio v-model="showList" name="name" :native-value="true"> + List + </b-radio> + <b-radio v-model="showList" name="name" :native-value="false"> + Grid + </b-radio> + </div> + </div> + </div> + </nav> + + <template v-if="!images.showList"> + <Waterfall + :gutter-width="10" + :gutter-height="4" + :options="{fitWidth: true}" + :item-width="width" + :items="gridFiles"> + <template v-slot="{item}"> + <template v-if="isPublic"> + <a + :href="`${item.url}`" + class="preview-container" + target="_blank" + @mouseenter.self.stop.prevent="item.preview && mouseOver(item.id)" + @mouseleave.self.stop.prevent="item.preview && mouseOut(item.id)"> + + <img :src="item.thumb ? item.thumb : blank"> + <div v-if="item.preview && isHovered(item.id)" class="preview"> + <video ref="video" class="preview" autoplay loop muted> + <source :src="item.preview" type="video/mp4"> + </video> + </div> + + <span v-if="!item.thumb && item.name" class="extension">{{ + item.name.split('.').pop() + }}</span> + </a> + </template> + <template v-else> + <img :src="item.thumb ? item.thumb : blank"> + <div v-if="item.preview && isHovered(item.id)" class="preview"> + <video ref="video" class="preview" autoplay loop muted> + <source :src="item.preview" type="video/mp4"> + </video> + </div> + + <span v-if="!item.thumb && item.name" class="extension">{{ item.name.split('.').pop() }}</span> + <div + v-if="!isPublic" + :class="{ fixed }" + class="actions" + @mouseenter.self.stop.prevent="item.preview && mouseOver(item.id)" + @mouseleave.self.stop.prevent="item.preview && mouseOut(item.id)"> + <b-tooltip label="Link" position="is-top"> + <a :href="`${item.url}`" target="_blank" class="btn"> + <i class="mdi mdi-open-in-new" /> + </a> + </b-tooltip> + <b-tooltip label="Edit" position="is-top"> + <a class="btn" @click="handleFileModal(item)"> + <i class="mdi mdi-pencil" /> + </a> + </b-tooltip> + <b-tooltip label="Delete" position="is-top"> + <a class="btn" @click="deleteFile(item)"> + <i class="mdi mdi-delete" /> + </a> + </b-tooltip> + <b-tooltip v-if="user && user.isAdmin" label="More info" position="is-top" class="more"> + <nuxt-link :to="`/dashboard/admin/file/${item.id}`"> + <i class="mdi mdi-dots-horizontal" /> + </nuxt-link> + </b-tooltip> + </div> + </template> + </template> + </Waterfall> + </template> + <div v-else> + <b-table :data="gridFiles || []" :mobile-cards="true"> + <b-table-column v-slot="props" field="url" label="URL"> + <a :href="props.row.url" target="_blank">{{ props.row.url }}</a> + </b-table-column> + + <b-table-column v-slot="props" field="albums" label="Albums" centered> + <template v-for="(album, index) in props.row.albums"> + <nuxt-link :key="index" :to="`/dashboard/albums/${album.id}`"> + {{ album.name }} + </nuxt-link> + <template v-if="index < props.row.albums.length - 1"> + , + </template> + </template> + + {{ props.row.username }} + </b-table-column> + + <b-table-column v-slot="props" field="uploaded" label="Uploaded" centered> + <span><timeago :since="props.row.createdAt" /></span> + </b-table-column> + + <b-table-column v-slot="props" field="purge" centered> + <b-tooltip label="Edit" position="is-top"> + <a class="btn" @click="handleFileModal(props.row)"> + <i class="mdi mdi-pencil" /> + </a> + </b-tooltip> + <b-tooltip label="Delete" position="is-top" class="is-danger"> + <a class="is-danger" @click="deleteFile(props.row)"> + <i class="mdi mdi-delete" /> + </a> + </b-tooltip> + <b-tooltip v-if="user && user.isAdmin" label="More info" position="is-top" class="more"> + <nuxt-link :to="`/dashboard/admin/file/${props.row.id}`"> + <i class="mdi mdi-dots-horizontal" /> + </nuxt-link> + </b-tooltip> + </b-table-column> + + <template slot="empty"> + <div class="has-text-centered"> + <i class="icon-misc-mood-sad" /> + </div> + <div class="has-text-centered"> + Nothing here + </div> + </template> + <template slot="footer"> + <div class="has-text-right has-text-default"> + Showing {{ files.length }} files ({{ total }} total) + </div> + </template> + </b-table> + </div> + + <b-modal class="imageinfo-modal" :active.sync="isAlbumsModalActive"> + <ImageInfo :file="modalData.file" :albums="modalData.albums" :tags="modalData.tags" /> + </b-modal> + </div> +</template> + +<script> +import { mapState } from 'vuex'; + +import Waterfall from './waterfall/Waterfall.vue'; +import ImageInfo from '~/components/image-modal/ImageInfo.vue'; + +export default { + components: { + Waterfall, + ImageInfo + }, + props: { + files: { + 'type': Array, + 'default': () => [] + }, + total: { + 'type': Number, + 'default': 0 + }, + fixed: { + 'type': Boolean, + 'default': false + }, + isPublic: { + 'type': Boolean, + 'default': false + }, + width: { + 'type': Number, + 'default': 150 + }, + enableSearch: { + 'type': Boolean, + 'default': true + }, + enableToolbar: { + 'type': Boolean, + 'default': true + } + }, + data() { + return { + searchTerm: null, + showList: false, + hoveredItems: [], + isAlbumsModalActive: false, + showingModalForFile: null, + filesOffsetWaterfall: 0, + filesOffsetEndWaterfall: 50, + filesPerPageWaterfall: 50, + modalData: { + file: null, + tags: null, + albums: null + } + }; + }, + computed: { + ...mapState({ + user: state => state.auth.user, + albums: state => state.albums.tinyDetails, + images: state => state.images + }), + blank() { + return require('@/assets/images/blank.png'); + }, + gridFiles() { + return (this.files || []).filter(v => !v.hideFromList); + } + }, + watch: { + showList: 'displayTypeChange' + }, + created() { + // TODO: Create a middleware for this + this.getAlbums(); + this.getTags(); + + this.showList = this.images.showList; + }, + methods: { + async search() { + const data = await this.$search.do(this.searchTerm, ['name', 'original', 'type', 'albums:name']); + console.log('> Search result data', data); // eslint-disable-line no-console + }, + deleteFile(file) { + // this.$emit('delete', file); + this.$buefy.dialog.confirm({ + title: 'Deleting file', + message: 'Are you sure you want to <b>delete</b> this file?', + confirmText: 'Delete File', + type: 'is-danger', + onConfirm: async () => { + try { + const response = await this.$store.dispatch('images/deleteFile', file.id); + + this.$buefy.toast.open(response.message); + } catch (e) { + this.$store.dispatch('alert/set', { text: e.message, error: true }, { root: true }); + } + } + }); + }, + isAlbumSelected(id) { + if (!this.showingModalForFile) return false; + const found = this.showingModalForFile.albums.find(el => el.id === id); + return Boolean(found && found.id); + }, + async openAlbumModal(file) { + const { id } = file; + this.showingModalForFile = file; + this.showingModalForFile.albums = []; + + try { + await this.$store.dispatch('images/getFileAlbums', id); + } catch (e) { + this.$store.dispatch('alert/set', { text: e.message, error: true }, { root: true }); + } + this.showingModalForFile.albums = this.images.fileAlbumsMap[id]; + + this.isAlbumsModalActive = true; + }, + async albumCheckboxClicked(add, id) { + try { + let response; + if (add) { + response = await this.$store.dispatch('images/addToAlbum', { + albumId: id, + fileId: this.showingModalForFile.id + }); + } else { + response = await this.$store.dispatch('images/removeFromAlbum', { + albumId: id, + fileId: this.showingModalForFile.id + }); + } + + this.$buefy.toast.open(response.message); + } catch (e) { + this.$store.dispatch('alert/set', { text: e.message, error: true }, { root: true }); + } + }, + async getAlbums() { + try { + await this.$store.dispatch('albums/getTinyDetails'); + } catch (e) { + this.$store.dispatch('alert/set', { text: e.message, error: true }, { root: true }); + } + }, + async handleFileModal(file) { + const { id } = file; + + try { + await this.$store.dispatch('images/fetchFileMeta', id); + this.modalData.file = this.images.fileExtraInfoMap[id]; + this.modalData.albums = this.images.fileAlbumsMap[id]; + this.modalData.tags = this.images.fileTagsMap[id]; + } catch (e) { + this.$store.dispatch('alert/set', { text: e.message, error: true }, { root: true }); + } + + this.isAlbumsModalActive = true; + }, + async getTags() { + try { + await this.$store.dispatch('tags/fetch'); + } catch (e) { + this.$store.dispatch('alert/set', { text: e.message, error: true }, { root: true }); + } + }, + mouseOver(id) { + const foundIndex = this.hoveredItems.indexOf(id); + if (foundIndex > -1) return; + this.hoveredItems.push(id); + }, + mouseOut(id) { + const foundIndex = this.hoveredItems.indexOf(id); + if (foundIndex > -1) this.hoveredItems.splice(foundIndex, 1); + }, + isHovered(id) { + return this.hoveredItems.includes(id); + }, + displayTypeChange(showList) { + this.$store.commit('images/setShowList', showList); + } + } +}; +</script> + +<style lang="scss" scoped> +@import '~/assets/styles/_colors.scss'; +.item-move { + transition: all 0.25s cubic-bezier(0.55, 0, 0.1, 1); +} + +div.toolbar { + padding: 1rem; + + .block { + text-align: right; + } +} + +span.extension { + position: absolute; + width: 100%; + height: 100%; + z-index: 0; + top: 0; + left: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 2rem; + pointer-events: none; + opacity: 0.75; + max-width: 150px; +} + +div.preview { + position: absolute; + top: 0px; + left: 0px; + width: 100%; + height: calc(100% - 6px); + overflow: hidden; +} + +.preview-container { + display: inline-block; +} + +div.actions { + opacity: 0; + transition: opacity 0.1s linear; + position: absolute; + top: 0px; + left: 0px; + width: 100%; + height: calc(100% - 6px); + // background: rgba(0, 0, 0, 0.5); + background: linear-gradient(to top, rgba(0, 0, 0, 0.5) 0px, rgba(0, 0, 0, 0) 60px), + linear-gradient(to bottom, rgba(0, 0, 0, 0.5) 0px, rgba(0, 0, 0, 0) 45px); + display: flex; + justify-content: center; + align-items: flex-end; + + span { + padding: 3px; + + &.more { + position: absolute; + top: 0; + right: 0; + } + + &:nth-child(1), + &:nth-child(2) { + align-items: flex-end; + padding-bottom: 10px; + } + + &:nth-child(3), + &:nth-child(4) { + justify-content: flex-end; + padding-bottom: 10px; + } + + a { + width: 30px; + height: 30px; + color: white; + justify-content: center; + align-items: center; + display: flex; + &.btn: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; + } + } + } +} + +.albums-container { + display: flex; + flex-direction: row; + flex-wrap: wrap; + .album { + flex-basis: 33%; + text-align: left; + } +} + +.hidden { + display: none; +} + +.waterfall { + margin: 0 auto; +} + +.waterfall-item:hover { + div.actions { + opacity: 1; + } +} + +.imageinfo-modal::-webkit-scrollbar { + width: 0px; /* Remove scrollbar space */ + background: transparent; /* Optional: just make scrollbar invisible */ +} + +i.mdi { + font-size: 16px; +} + +.imageinfo-modal{ + ::v-deep .modal-content { + @media screen and (max-width: 768px) { + min-height: 100vh; + } + } +} +</style> diff --git a/src/site/components/grid/waterfall/Waterfall.vue b/src/site/components/grid/waterfall/Waterfall.vue new file mode 100644 index 0000000..5a4c569 --- /dev/null +++ b/src/site/components/grid/waterfall/Waterfall.vue @@ -0,0 +1,129 @@ +<template> + <div ref="waterfall" class="waterfall"> + <WaterfallItem + v-for="item in items" + :key="item.id" + :style="{ width: `${itemWidth}px`, marginBottom: `${gutterHeight}px` }" + :width="itemWidth"> + <slot :item="item" /> + </WaterfallItem> + </div> +</template> +<script> +import WaterfallItem from './WaterfallItem.vue'; + +const isBrowser = typeof window !== 'undefined'; +// eslint-disable-next-line global-require +const Masonry = isBrowser ? window.Masonry || require('masonry-layout') : null; +const imagesloaded = isBrowser ? require('imagesloaded') : null; + +export default { + name: 'Waterfall', + components: { + WaterfallItem + }, + props: { + options: { + 'type': Object, + 'default': () => {} + }, + items: { + 'type': Array, + 'default': () => [] + }, + itemWidth: { + 'type': Number, + 'default': 150 + }, + gutterWidth: { + 'type': Number, + 'default': 10 + }, + gutterHeight: { + 'type': Number, + 'default': 4 + } + }, + mounted() { + this.initializeMasonry(); + this.imagesLoaded(); + }, + updated() { + this.performLayout(); + this.imagesLoaded(); + }, + unmounted() { + this.masonry.destroy(); + }, + methods: { + imagesLoaded() { + const node = this.$refs.waterfall; + imagesloaded( + node, + () => { + this.masonry.layout(); + } + ); + }, + performLayout() { + const diff = this.diffDomChildren(); + if (diff.removed.length > 0) { + this.masonry.remove(diff.removed); + this.masonry.reloadItems(); + } + if (diff.appended.length > 0) { + this.masonry.appended(diff.appended); + this.masonry.reloadItems(); + } + if (diff.prepended.length > 0) { + this.masonry.prepended(diff.prepended); + } + if (diff.moved.length > 0) { + this.masonry.reloadItems(); + } + this.masonry.layout(); + }, + diffDomChildren() { + const oldChildren = this.domChildren.filter(element => Boolean(element.parentNode)); + const newChildren = this.getNewDomChildren(); + const removed = oldChildren.filter(oldChild => !newChildren.includes(oldChild)); + const domDiff = newChildren.filter(newChild => !oldChildren.includes(newChild)); + const prepended = domDiff.filter((newChild, index) => newChildren[index] === newChild); + const appended = domDiff.filter(el => !prepended.includes(el)); + let moved = []; + if (removed.length === 0) { + moved = oldChildren.filter((child, index) => index !== newChildren.indexOf(child)); + } + this.domChildren = newChildren; + return { + 'old': oldChildren, + 'new': newChildren, + removed, + appended, + prepended, + moved + }; + }, + initializeMasonry() { + if (!this.masonry) { + this.masonry = new Masonry( + this.$refs.waterfall, + { + columnWidth: this.itemWidth, + gutter: this.gutterWidth, + ...this.options + } + ); + this.domChildren = this.getNewDomChildren(); + } + }, + getNewDomChildren() { + const node = this.$refs.waterfall; + const children = this.options && this.options.itemSelector + ? node.querySelectorAll(this.options.itemSelector) + : node.children; + return Array.prototype.slice.call(children); + } + } +}; +</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..2a18606 --- /dev/null +++ b/src/site/components/grid/waterfall/WaterfallItem.vue @@ -0,0 +1,10 @@ +<template> + <div class="waterfall-item"> + <slot /> + </div> +</template> +<script> +export default { + name: 'WaterfallItem' +}; +</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..05915b9 --- /dev/null +++ b/src/site/components/home/links/Links.vue @@ -0,0 +1,142 @@ +<template> + <div class="links"> + <a + href="https://github.com/WeebDev/chibisafe" + 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 chibisafe</p> + </header> + </a> + <div + v-if="loggedIn" + class="link" + @click="createShareXThing"> + <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"> + We got you covered + </p> + </header> + </router-link> + </div> +</template> +<script> +import { saveAs } from 'file-saver'; + +export default { + computed: { + loggedIn() { + return this.$store.state.auth.loggedIn; + } + }, + methods: { + createShareXThing() { + const sharexFile = `{ + "Name": "${this.$store.state.config.serviceName}", + "DestinationType": "ImageUploader, FileUploader", + "RequestType": "POST", + "RequestURL": "${location.origin}/api/upload", + "FileFormName": "files[]", + "Headers": { + "authorization": "Bearer ${this.$store.state.token}", + "accept": "application/vnd.chibisafe.json" + }, + "ResponseType": "Text", + "URL": "$json:url$", + "ThumbnailURL": "$json:url$" + }`; + const sharexBlob = new Blob([sharexFile], { type: 'application/octet-binary' }); + saveAs(sharexBlob, `${location.hostname}.sxcu`); + } + } +}; +</script> +<style lang="scss" scoped> + @import '~/assets/styles/_colors.scss'; + .links { + margin: 7rem 0 3rem 0; + align-items: stretch; + display: flex; + justify-content: space-between; + + div.link { cursor: pointer; } + .link { + background: #0000002e; + border: 1px solid #00000061; + 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); + transform: translateY(-.5rem); + } + } + } + + @media screen and (max-width: 768px) { + .links { + display: block; + padding: 0px 2em; + .link { + width: 100%; + margin-bottom: 1.5em; + } + } + } +</style> diff --git a/src/site/components/image-modal/AlbumInfo.vue b/src/site/components/image-modal/AlbumInfo.vue new file mode 100644 index 0000000..8aeb02e --- /dev/null +++ b/src/site/components/image-modal/AlbumInfo.vue @@ -0,0 +1,90 @@ +<template> + <b-dropdown + v-model="selectedOptions" + multiple + expanded + scrollable + inline + aria-role="list" + max-height="500px"> + <button slot="trigger" class="button is-primary" type="button"> + <span>Albums ({{ selectedOptions.length }})</span> + <b-icon icon="menu-down" /> + </button> + + <b-dropdown-item + v-for="album in orderedAlbums" + :key="album.id" + :value="album.id" + aria-role="listitem" + @click="handleClick(album.id)"> + <span>{{ album.name }}</span> + </b-dropdown-item> + </b-dropdown> +</template> + +<script> +export default { + name: 'Albuminfo', + props: { + imageId: { + 'type': Number, + 'default': 0 + }, + imageAlbums: { + 'type': Array, + 'default': () => [] + }, + albums: { + 'type': Array, + 'default': () => [] + } + }, + data() { + return { + selectedOptions: [], + orderedAlbums: [] + }; + }, + created() { + this.orderedAlbums = this.getOrderedAlbums(); + // we're sorting here instead of computed because we want sort on creation + // then the array's values should be frozen + this.selectedOptions = this.imageAlbums.map(e => e.id); + }, + methods: { + getOrderedAlbums() { + return [...this.albums].sort( + (a, b) => { + const selectedA = this.imageAlbums.findIndex(({ name }) => name === a.name) !== -1; + const selectedB = this.imageAlbums.findIndex(({ name }) => name === b.name) !== -1; + + if (selectedA !== selectedB) { + return selectedA ? -1 : 1; + } + return a.name.localeCompare(b.name); + } + ); + }, + isAlbumSelected(id) { + if (!this.showingModalForFile) return false; + const found = this.showingModalForFile.albums.find(el => el.id === id); + return Boolean(found && found.id); + }, + async handleClick(id) { + // here the album should be already removed from the selected list + if (this.selectedOptions.indexOf(id) > -1) { + this.$handler.executeAction('images/addToAlbum', { + albumId: id, + fileId: this.imageId + }); + } else { + this.$handler.executeAction('images/removeFromAlbum', { + albumId: id, + fileId: this.imageId + }); + } + } + } +}; +</script> diff --git a/src/site/components/image-modal/ImageInfo.vue b/src/site/components/image-modal/ImageInfo.vue new file mode 100644 index 0000000..fdea285 --- /dev/null +++ b/src/site/components/image-modal/ImageInfo.vue @@ -0,0 +1,211 @@ +<template> + <div class="container has-background-chibisafe"> + <div class="columns is-marginless"> + <div class="column image-col has-centered-items"> + <img v-if="!isVideo(file.type)" class="col-img" :src="file.url"> + <video v-else class="col-vid" controls> + <source :src="file.url" :type="file.type"> + </video> + </div> + <div class="column data-col is-one-third"> + <div class="sticky"> + <div class="divider is-chibisafe has-text-light"> + File information + </div> + <b-field + label="ID" + label-position="on-border" + type="is-chibisafe" + class="chibisafe-on-border"> + <div class="control"> + <span class="fake-input">{{ file.id }}</span> + </div> + </b-field> + <b-field + label="Name" + label-position="on-border" + type="is-chibisafe" + class="chibisafe-on-border"> + <div class="control"> + <span class="fake-input">{{ file.name }}</span> + </div> + </b-field> + + <b-field + label="Original Name" + label-position="on-border" + type="is-chibisafe" + class="chibisafe-on-border"> + <div class="control"> + <span class="fake-input">{{ file.original }}</span> + </div> + </b-field> + + <b-field + label="IP" + label-position="on-border" + type="is-chibisafe" + class="chibisafe-on-border"> + <div class="control"> + <span class="fake-input">{{ file.ip }}</span> + </div> + </b-field> + + <b-field + label="Link" + label-position="on-border" + type="is-chibisafe" + class="chibisafe-on-border"> + <div class="control"> + <a + class="fake-input" + :href="file.url" + target="_blank">{{ file.url }}</a> + </div> + </b-field> + + <b-field + label="Size" + label-position="on-border" + type="is-chibisafe" + class="chibisafe-on-border"> + <div class="control"> + <span class="fake-input">{{ formatBytes(file.size) }}</span> + </div> + </b-field> + + <b-field + label="Hash" + label-position="on-border" + type="is-chibisafe" + class="chibisafe-on-border"> + <div class="control"> + <span class="fake-input">{{ file.hash }}</span> + </div> + </b-field> + + <b-field + label="Uploaded" + label-position="on-border" + type="is-chibisafe" + class="chibisafe-on-border"> + <div class="control"> + <span class="fake-input"><timeago :since="file.createdAt" /></span> + </div> + </b-field> + + <div class="divider is-chibisafe has-text-light"> + Tags + </div> + <Taginfo :image-id="file.id" :image-tags="tags" /> + + <div class="divider is-chibisafe has-text-light"> + Albums + </div> + <Albuminfo :image-id="file.id" :image-albums="albums" :albums="tinyDetails" /> + </div> + </div> + </div> + </div> +</template> + +<script> +import { mapState } from 'vuex'; + +import Albuminfo from './AlbumInfo.vue'; +import Taginfo from './TagInfo.vue'; + +export default { + components: { + Taginfo, + Albuminfo + }, + props: { + file: { + 'type': Object, + 'default': () => ({}) + }, + albums: { + 'type': Array, + 'default': () => ([]) + }, + tags: { + 'type': Array, + 'default': () => ([]) + } + }, + computed: mapState({ + images: state => state.images, + tinyDetails: state => state.albums.tinyDetails + }), + methods: { + formatBytes(bytes, decimals = 2) { + if (bytes === 0) return '0 Bytes'; + + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + // eslint-disable-next-line no-mixed-operators + return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`; + }, + isVideo(type) { + return type.startsWith('video'); + } + } +}; +</script> + +<style lang="scss" scoped> +@import '~/assets/styles/_colors.scss'; +.modal-content, .modal-card { + max-height: 100%; +} + +.fake-input { + font-size: 1rem !important; + height: 2.5rem; + border-color: #323846; /* $chibisafe */ + max-width: 100%; + width: 100%; + border-radius: 4px; + display: inline-block; + font-size: 1rem; + justify-content: flex-start; + line-height: 1.5; + padding-bottom: calc(0.375em - 1px); + padding-left: calc(0.625em - 1px); + padding-right: calc(0.625em - 1px); + padding-top: calc(0.375em - 1px); + background-color: #21252d; + border: 2px solid #21252d; + border-radius: 0.3em !important; + + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} + +.divider:first-child { + margin: 10px 0 25px; +} + +.col-vid { + width: 100%; +} + +.image-col { + align-items: center; +} + +.data-col { + @media screen and (min-width: 769px) { + padding-right: 1.5rem; + } + @media screen and (max-width: 769px) { + padding-bottom: 3rem; + } +} +</style> diff --git a/src/site/components/image-modal/TagInfo.vue b/src/site/components/image-modal/TagInfo.vue new file mode 100644 index 0000000..fb65343 --- /dev/null +++ b/src/site/components/image-modal/TagInfo.vue @@ -0,0 +1,95 @@ +<template> + <b-field label="Add some tags"> + <b-taginput + :value="selectedTags" + :data="filteredTags" + class="chibisafe taginp" + ellipsis + icon="label" + placeholder="Add a tag" + autocomplete + allow-new + @typing="getFilteredTags" + @add="tagAdded" + @remove="tagRemoved" /> + </b-field> +</template> + +<script> +import { mapState } from 'vuex'; + +export default { + name: 'Taginfo', + props: { + imageId: { + 'type': Number, + 'default': 0 + }, + imageTags: { + 'type': Array, + 'default': () => [] + } + }, + data() { + return { + filteredTags: [] + }; + }, + computed: { + ...mapState({ + tags: state => state.tags.tagsList + }), + selectedTags() { return this.imageTags.map(e => e.name); }, + lowercaseTags() { return this.imageTags.map(e => e.name.toLowerCase()); } + }, + methods: { + getFilteredTags(str) { + this.filteredTags = this.tags.map(e => e.name).filter(e => { + // check if the search string matches any of the tags + const sanitezedTag = e.toString().toLowerCase(); + const matches = sanitezedTag.indexOf(str.toLowerCase()) >= 0; + + // check if this tag is already added to our image, to avoid duplicates + if (matches) { + const foundIndex = this.lowercaseTags.indexOf(sanitezedTag); + if (foundIndex === -1) { + return true; + } + } + + return false; + }); + }, + async tagAdded(tag) { + if (!tag) return; + + // normalize into NFC form (diactirics and moonrunes) + // replace all whitespace with _ + // replace multiple __ with a single one + tag = tag.normalize('NFC').replace(/\s/g, '_').replace(/_+/g, '_'); + + const foundIndex = this.tags.findIndex(({ name }) => name === tag); + + if (foundIndex === -1) { + await this.$handler.executeAction('tags/createTag', tag); + } + + await this.$handler.executeAction('images/addTag', { fileId: this.imageId, tagName: tag }); + }, + tagRemoved(tag) { + this.$handler.executeAction('images/removeTag', { fileId: this.imageId, tagName: tag }); + } + } +}; +</script> + +<style lang="scss" scoped> +@import '~/assets/styles/_colors.scss'; + +.taginp { + ::v-deep .dropdown-content { + background-color: #323846; + box-shadow: 0 14px 28px rgba(0,0,0,0.25), 0 10px 10px rgba(0,0,0,0.22); + } +} +</style> diff --git a/src/site/components/loading/BulmaLoading.vue b/src/site/components/loading/BulmaLoading.vue new file mode 100644 index 0000000..37cc5a5 --- /dev/null +++ b/src/site/components/loading/BulmaLoading.vue @@ -0,0 +1,33 @@ +<template> + <div class="loader-wrapper"> + <div class="loader is-loading" /> + </div> +</template> + +<style lang="scss" scoped> + .loader-wrapper { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 100%; + background: #fff; + opacity: 0; + z-index: -1; + transition: opacity .3s; + display: flex; + justify-content: center; + align-items: center; + border-radius: 6px; + + .loader { + height: 80px; + width: 80px; + } + + &.is-active { + opacity: 1; + z-index: 1; + } + } +</style> diff --git a/src/site/components/loading/CubeShadow.vue b/src/site/components/loading/CubeShadow.vue new file mode 100644 index 0000000..7aeed65 --- /dev/null +++ b/src/site/components/loading/CubeShadow.vue @@ -0,0 +1,49 @@ +<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..1c7a4c3 --- /dev/null +++ b/src/site/components/loading/Origami.vue @@ -0,0 +1,123 @@ +<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() { + const size = parseInt(this.size, 10); + 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..bab33d5 --- /dev/null +++ b/src/site/components/loading/PingPong.vue @@ -0,0 +1,100 @@ +<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() { + const size = parseInt(this.size, 10); + 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..b7967ec --- /dev/null +++ b/src/site/components/loading/RotateSquare.vue @@ -0,0 +1,88 @@ +<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..5c2546b --- /dev/null +++ b/src/site/components/logo/Logo.vue @@ -0,0 +1,27 @@ +<template> + <img src="~/assets/images/logo.png"> +</template> +<style lang="scss" scoped> + img { + height: 376px; + animation-delay: 0.5s; + animation-duration: 1.5s; + animation-fill-mode: both; + animation-name: floatUp; + animation-timing-function: cubic-bezier(0, 0.71, 0.29, 1); + } + + @keyframes floatUp { + 0% { + opacity: 0; + transform: scale(0.86); + } + 25% { opacity: 100; } + 67% { + transform: scale(1); + } + 100% { + transform: scale(1); + } + } +</style> diff --git a/src/site/components/navbar/Navbar.vue b/src/site/components/navbar/Navbar.vue new file mode 100644 index 0000000..65db69f --- /dev/null +++ b/src/site/components/navbar/Navbar.vue @@ -0,0 +1,137 @@ +<template> + <b-navbar + :class="{ isWhite }" + transparent> + <template slot="end"> + <b-navbar-item tag="div"> + <router-link + to="/" + class="navbar-item no-active" + exact> + Home + </router-link> + </b-navbar-item> + <template v-if="loggedIn"> + <b-navbar-item tag="div"> + <router-link + to="/dashboard" + class="navbar-item no-active" + exact> + Uploads + </router-link> + </b-navbar-item> + <b-navbar-item tag="div"> + <router-link + to="/dashboard/albums" + class="navbar-item no-active" + exact> + Albums + </router-link> + </b-navbar-item> + <b-navbar-item tag="div"> + <router-link + to="/dashboard/account" + class="navbar-item no-active" + exact> + Account + </router-link> + </b-navbar-item> + <b-navbar-item tag="div"> + <router-link + to="/" + class="navbar-item no-active" + @click.native="logOut"> + Logout + </router-link> + </b-navbar-item> + </template> + <template v-else> + <b-navbar-item tag="div"> + <router-link + class="navbar-item" + to="/login"> + Login + </router-link> + </b-navbar-item> + </template> + </template> + </b-navbar> +</template> + +<script> +import { mapState, mapGetters } from 'vuex'; + +export default { + props: { + isWhite: { + 'type': Boolean, + 'default': false + } + }, + data() { + return { hamburger: false }; + }, + computed: { + ...mapGetters({ loggedIn: 'auth/isLoggedIn' }), + ...mapState(['config']) + }, + methods: { + async logOut() { + await this.$store.dispatch('auth/logout'); + this.$router.replace('/login'); + } + } +}; +</script> + +<style lang="scss" scoped> + @import '~/assets/styles/_colors.scss'; + nav.navbar { + background: transparent; + box-shadow: none; + padding-right: 2rem; + .navbar-brand { + a.burger { + color: $defaultTextColor; + } + } + .navbar-menu { + height: 5rem; + + .navbar-end { + padding-right: 2rem; + } + 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; + } + } + } + } + + .no-active { + text-decoration: none !important; + } +</style> diff --git a/src/site/components/search-input/SearchInput.vue b/src/site/components/search-input/SearchInput.vue new file mode 100644 index 0000000..10728a0 --- /dev/null +++ b/src/site/components/search-input/SearchInput.vue @@ -0,0 +1,517 @@ +<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 Boolean(this.$scopedSlots.default); + }, + /** + * Check if exists "empty" slot + */ + hasEmptySlot() { + return Boolean(this.$slots.empty); + }, + /** + * Check if exists "header" slot + */ + hasHeaderSlot() { + return Boolean(this.$slots.header); + }, + /** + * Check if exists "footer" slot + */ + hasFooterSlot() { + return Boolean(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 = Boolean(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 -= dropdownMenu.clientHeight; + } else { + top += trigger.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..1f55691 --- /dev/null +++ b/src/site/components/search/Search.vue @@ -0,0 +1,145 @@ +<template> + <div class="level-right"> + <div class="level-item"> + <b-field> + <SearchInput + ref="autocomplete" + v-model="query" + :data="filteredHints" + :custom-selector="handleSelect" + field="name" + class="chibisafe-input search" + placeholder="Search" + type="search" + open-on-focus + @typing="handleTyping" + @keydown.native.enter="onSubmit"> + <template slot-scope="props"> + <b>{{ props.option.name }}:</b> + <small> + {{ props.option.valueFormat }} + </small> + </template> + </SearchInput> + <p class="control"> + <b-button type="is-chibisafe" @click="onSubmit"> + Search + </b-button> + </p> + </b-field> + </div> + </div> +</template> + +<script> +import SearchInput from '~/components/search-input/SearchInput.vue'; + +export default { + components: { + SearchInput + }, + props: { + hiddenHints: { + 'type': Array, + 'default': () => [] + } + }, + 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: '' + }, + { + name: 'file', + valueFormat: 'generated name', + hint: '' + } + ], + filteredHints: [] + }; + }, + created() { + this.hints = this.hints.filter(({ name }) => this.hiddenHints.indexOf(name) === -1); + 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 + let lastWord = (qry.match(/("[^"]*")|[^\s]+/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; + } + + // ignore starting `-` from lastword, because - is used to + // exclude something, so -alb should autosuggest album + lastWord = lastWord.replace(/^-/, ''); + + // 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); + }, + onSubmit(event) { + if (event.key === 'Enter') { + if (/:$/gi.test(this.query)) { return; } + } + this.$emit('search', this.query, event); + } + } +}; +</script> + +<style lang="scss" scoped> + .search { + ::v-deep .dropdown-content { + background-color: #323846; + } + } +</style> diff --git a/src/site/components/sidebar/Sidebar.vue b/src/site/components/sidebar/Sidebar.vue new file mode 100644 index 0000000..8d96712 --- /dev/null +++ b/src/site/components/sidebar/Sidebar.vue @@ -0,0 +1,82 @@ +<template> + <b-menu class="dashboard-menu"> + <b-menu-list label="Menu"> + <b-menu-item + class="item" + icon="information-outline" + label="Dashboard" + tag="nuxt-link" + to="/dashboard" + exact /> + <b-menu-item + class="item" + icon="image-multiple-outline" + label="Albums" + tag="nuxt-link" + to="/dashboard/albums" + exact /> + <b-menu-item + class="item" + icon="tag-outline" + label="Tags" + tag="nuxt-link" + to="/dashboard/tags" + exact /> + <b-menu-item icon="menu" expanded> + <template slot="label" slot-scope="props"> + Administration + <b-icon class="is-pulled-right" :icon="props.expanded ? 'menu-down' : 'menu-up'" /> + </template> + <b-menu-item icon="account" label="Users" tag="nuxt-link" to="/dashboard/admin/users" exact /> + <b-menu-item icon="cog-outline" label="Settings" tag="nuxt-link" to="/dashboard/admin/settings" exact /> + </b-menu-item> + <b-menu-item + class="item" + icon="account-cog-outline" + label="My account" + tag="nuxt-link" + to="/dashboard/account" + exact /> + </b-menu-list> + <b-menu-list label="Actions"> + <b-menu-item icon="exit-to-app" label="Logout" tag="nuxt-link" to="/logout" exact /> + </b-menu-list> + </b-menu> +</template> +<script> +import { mapState } from 'vuex'; + +export default { + computed: mapState({ + user: state => state.auth.user + }), + methods: { + isRouteActive(id) { + if (this.$route.path.includes(id)) { + return true; + } + return false; + } + } +}; + +</script> +<style lang="scss" scoped> + @import '~/assets/styles/_colors.scss'; + .dashboard-menu { + ::v-deep a:hover { + cursor: pointer; + text-decoration: none; + } + + ::v-deep .icon { + margin-right: 0.5rem; + } + + ::v-deep .icon.is-pulled-right { + margin-right: 0; + } + + hr { margin-top: 0.6em; } + } +</style> diff --git a/src/site/components/uploader/Uploader.vue b/src/site/components/uploader/Uploader.vue new file mode 100644 index 0000000..f180546 --- /dev/null +++ b/src/site/components/uploader/Uploader.vue @@ -0,0 +1,258 @@ +<template> + <div + :class="{ 'has-files': alreadyAddedFiles }" + 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" + :key="album.id" + :value="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" /> + <label class="add-more"> + Add or drop more files + </label> + + <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="openLink"> + <a + class="link" + target="_blank"> + Link + </a> + </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 { mapState, mapGetters } from 'vuex'; + +import Dropzone from 'nuxt-dropzone'; +import '~/assets/styles/dropzone.scss'; + +export default { + components: { Dropzone }, + data() { + return { + alreadyAddedFiles: false, + files: [], + dropzoneOptions: {}, + showDropzone: false, + selectedAlbum: null + }; + }, + computed: { + ...mapState({ + config: state => state.config, + albums: state => state.albums.tinyDetails + }), + ...mapGetters({ loggedIn: 'auth/isLoggedIn', token: 'auth/getToken' }) + }, + watch: { + loggedIn() { + this.getAlbums(); + }, + selectedAlbum() { + this.updateDropzoneConfig(); + } + }, + mounted() { + this.dropzoneOptions = { + url: `${this.config.baseURL}/upload`, + timeout: 600000, // 10 minutes + autoProcessQueue: true, + addRemoveLinks: false, + parallelUploads: 5, + uploadMultiple: false, + maxFiles: 1000, + createImageThumbnails: false, + paramName: 'files[]', + forceChunking: false, + chunking: true, + retryChunks: true, + retryChunksLimit: 3, + parallelChunkUploads: true, + 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.chibisafe.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 { + await this.$store.dispatch('albums/getTinyDetails'); + } catch (e) { + this.$store.dispatch('alert/set', { text: e.message, error: true }, { root: true }); + } + this.updateDropzoneConfig(); + }, + + /* + 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.chibisafe.json', + Authorization: this.token ? `Bearer ${this.token}` : '', + albumId: this.selectedAlbum ? this.selectedAlbum : null + }); + }, + + /* + Dropzone stuff + */ + dropzoneFilesAdded() { + this.alreadyAddedFiles = true; + }, + dropzoneSuccess(file, response) { + this.processResult(file, response); + }, + dropzoneError(file, message, xhr) { + this.$store.dispatch('alert', { + text: 'There was an error uploading this file. Check the console.', + error: true + }); + // eslint-disable-next-line no-console + console.error(file, message, xhr); + }, + async dropzoneChunksUploaded(file, done) { + const { data } = await this.$axios.post(`${this.config.baseURL}/upload/chunks`, { + files: [{ + uuid: file.upload.uuid, + original: file.name, + size: file.size, + type: file.type, + count: file.upload.totalChunkCount + }] + }, { + headers: { + albumId: this.selectedAlbum ? this.selectedAlbum : null + } + }); + + this.processResult(file, data); + 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.$store.dispatch('alert', { + text: 'Link copied!' + }); + this.$clipboard(response.url); + }); + */ + } + } +}; +</script> +<style lang="scss" scoped> + #template { display: none; } + .uploader-wrapper { + display: block; + width: 400px; + margin: 0 auto; + max-width: 100%; + position: relative; + } +</style> +<style lang="scss"> + @import '~/assets/styles/_colors.scss'; + + div.uploader-wrapper { + &.has-files { + #dropzone { + padding-bottom: 50px; + } + label.add-more { + position: absolute; + bottom: 23px; + width: 100%; + text-align: center; + color: #797979; + display: block; + pointer-events: none; + } + } + div.control { + margin-bottom: 5px; + span.select { + select { + border: 1px solid #00000061; + background: rgba(0, 0, 0, 0.15); + border-radius: .3em; + color: $uploaderDropdownColor; + padding: 0 0 0 1rem; + } + } + } + label.add-more { display: none; } + } +</style> |