aboutsummaryrefslogtreecommitdiff
path: root/src/site/components
diff options
context:
space:
mode:
authorPitu <[email protected]>2021-01-04 01:04:20 +0900
committerPitu <[email protected]>2021-01-04 01:04:20 +0900
commitfcd39dc550dec8dbcb8325e07e938c5024cbc33d (patch)
treef41acb4e0d5fd3c3b1236fe4324b3fef9ec6eafe /src/site/components
parentCreate FUNDING.yml (diff)
parentchore: update todo (diff)
downloadhost.fuwn.me-fcd39dc550dec8dbcb8325e07e938c5024cbc33d.tar.xz
host.fuwn.me-fcd39dc550dec8dbcb8325e07e938c5024cbc33d.zip
Merge branch 'dev'
Diffstat (limited to 'src/site/components')
-rw-r--r--src/site/components/album/AlbumDetails.vue294
-rw-r--r--src/site/components/album/AlbumEntry.vue182
-rw-r--r--src/site/components/footer/Footer.test.js25
-rw-r--r--src/site/components/footer/Footer.vue110
-rw-r--r--src/site/components/grid/Grid.vue498
-rw-r--r--src/site/components/grid/waterfall/Waterfall.vue129
-rw-r--r--src/site/components/grid/waterfall/WaterfallItem.vue10
-rw-r--r--src/site/components/home/links/Links.vue142
-rw-r--r--src/site/components/image-modal/AlbumInfo.vue90
-rw-r--r--src/site/components/image-modal/ImageInfo.vue211
-rw-r--r--src/site/components/image-modal/TagInfo.vue95
-rw-r--r--src/site/components/loading/BulmaLoading.vue33
-rw-r--r--src/site/components/loading/CubeShadow.vue49
-rw-r--r--src/site/components/loading/Origami.vue123
-rw-r--r--src/site/components/loading/PingPong.vue100
-rw-r--r--src/site/components/loading/RotateSquare.vue88
-rw-r--r--src/site/components/logo/Logo.vue27
-rw-r--r--src/site/components/navbar/Navbar.vue137
-rw-r--r--src/site/components/search-input/SearchInput.vue517
-rw-r--r--src/site/components/search/Search.vue145
-rw-r--r--src/site/components/sidebar/Sidebar.vue82
-rw-r--r--src/site/components/uploader/Uploader.vue258
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>