aboutsummaryrefslogtreecommitdiff
path: root/src/site/components
diff options
context:
space:
mode:
authorZephyrrus <[email protected]>2020-07-20 23:01:45 +0300
committerZephyrrus <[email protected]>2020-07-20 23:01:45 +0300
commit18bb451f793677a5bbfdc2c14128bae33c66dfde (patch)
tree12e5e02d793b11bfac3dafd5078a1f0b2464d6ce /src/site/components
parentfix: return the edited/changed/delete entity from API (diff)
downloadhost.fuwn.me-18bb451f793677a5bbfdc2c14128bae33c66dfde.tar.xz
host.fuwn.me-18bb451f793677a5bbfdc2c14128bae33c66dfde.zip
feat: implement all-in-one file detail viewer, tag editor and album selection modal
Diffstat (limited to 'src/site/components')
-rw-r--r--src/site/components/grid/Grid.vue71
-rw-r--r--src/site/components/grid/waterfall/Waterfall.vue3
-rw-r--r--src/site/components/image-modal/AlbumInfo.vue73
-rw-r--r--src/site/components/image-modal/ImageInfo.vue61
-rw-r--r--src/site/components/image-modal/TagInfo.vue103
5 files changed, 252 insertions, 59 deletions
diff --git a/src/site/components/grid/Grid.vue b/src/site/components/grid/Grid.vue
index 1e427fc..d29160f 100644
--- a/src/site/components/grid/Grid.vue
+++ b/src/site/components/grid/Grid.vue
@@ -6,6 +6,7 @@
<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">
@@ -66,27 +67,22 @@
@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="icon-web-code" />
+ <i class="mdi mdi-open-in-new" />
</a>
</b-tooltip>
- <b-tooltip label="Tags" position="is-top">
- <a class="btn" @click="false && manageTags(item)">
- <i class="icon-ecommerce-tag-c" />
- </a>
- </b-tooltip>
- <b-tooltip label="Albums" position="is-top">
+ <b-tooltip label="Edit" position="is-top">
<a class="btn" @click="handleFileModal(item)">
- <i class="icon-interface-window" />
+ <i class="mdi mdi-pencil" />
</a>
</b-tooltip>
<b-tooltip label="Delete" position="is-top">
<a class="btn" @click="deleteFile(item)">
- <i class="icon-editorial-trash-a-l" />
+ <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="icon-interface-more" />
+ <i class="mdi mdi-dots-horizontal" />
</nuxt-link>
</b-tooltip>
</div>
@@ -120,19 +116,19 @@
</b-table-column>
<b-table-column field="purge" centered>
- <b-tooltip label="Albums" position="is-top">
+ <b-tooltip label="Edit" position="is-top">
<a class="btn" @click="handleFileModal(props.row)">
- <i class="icon-interface-window" />
+ <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="icon-editorial-trash-a-l" />
+ <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="icon-interface-more" />
+ <i class="mdi mdi-dots-horizontal" />
</nuxt-link>
</b-tooltip>
</b-table-column>
@@ -159,33 +155,10 @@
Load more
</button>
</div>
- <b-modal :active.sync="isAlbumsModalActive" scroll="keep">
- <ImageInfo :file="modalData.file" />
+
+ <b-modal class="imageinfo-modal" :active.sync="isAlbumsModalActive">
+ <ImageInfo :file="modalData.file" :albums="modalData.albums" :tags="modalData.tags" />
</b-modal>
- <!-- <b-modal :active.sync="isAlbumsModalActive" :width="640" scroll="keep">
- <div class="card albumsModal">
- <div class="card-content">
- <div class="content">
- <h3 class="subtitle">
- Select the albums this file should be a part of
- </h3>
- <hr>
-
- <div class="albums-container">
- <div v-for="album in albums" :key="album.id" class="album">
- <div class="field">
- <b-checkbox
- :value="isAlbumSelected(album.id)"
- @input="albumCheckboxClicked($event, album.id)">
- {{ album.name }}
- </b-checkbox>
- </div>
- </div>
- </div>
- </div>
- </div>
- </div>
- </b-modal> -->
</div>
</template>
@@ -263,7 +236,9 @@ export default {
},
},
created() {
+ // TODO: Create a middleware for this
this.getAlbums();
+ this.getTags();
},
methods: {
async search() {
@@ -348,6 +323,13 @@ export default {
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;
@@ -504,4 +486,13 @@ 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;
+}
</style>
diff --git a/src/site/components/grid/waterfall/Waterfall.vue b/src/site/components/grid/waterfall/Waterfall.vue
index 79a330a..762cbbd 100644
--- a/src/site/components/grid/waterfall/Waterfall.vue
+++ b/src/site/components/grid/waterfall/Waterfall.vue
@@ -1,7 +1,7 @@
<template>
<div ref="waterfall" class="waterfall">
<WaterfallItem
- v-for="(item, index) in items"
+ v-for="item in items"
:key="item.id"
:style="{ width: `${itemWidth}px`, marginBottom: `${gutterHeight}px` }"
:width="itemWidth">
@@ -13,6 +13,7 @@
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;
diff --git a/src/site/components/image-modal/AlbumInfo.vue b/src/site/components/image-modal/AlbumInfo.vue
new file mode 100644
index 0000000..9d57e55
--- /dev/null
+++ b/src/site/components/image-modal/AlbumInfo.vue
@@ -0,0 +1,73 @@
+<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 albums"
+ :key="album.id"
+ :value="album.id"
+ aria-role="listitem"
+ @click="handleClick(album.id)">
+ <span>{{ album. name }}</span>
+ </b-dropdown-item>
+ </b-dropdown>
+</template>
+
+<script>
+import { mapState } from 'vuex';
+
+export default {
+ name: 'Albuminfo',
+ props: {
+ imageId: {
+ type: Number,
+ default: 0,
+ },
+ imageAlbums: {
+ type: Array,
+ default: () => [],
+ },
+ },
+ data() {
+ return {
+ selectedOptions: this.imageAlbums.map((e) => e.id),
+ };
+ },
+ computed: {
+ ...mapState({
+ albums: (state) => state.albums.tinyDetails,
+ }),
+ },
+ methods: {
+ isAlbumSelected(id) {
+ if (!this.showingModalForFile) return false;
+ const found = this.showingModalForFile.albums.find((el) => el.id === id);
+ return !!(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
index c9dba1a..c3f0041 100644
--- a/src/site/components/image-modal/ImageInfo.vue
+++ b/src/site/components/image-modal/ImageInfo.vue
@@ -1,10 +1,13 @@
<template>
<div class="container has-background-lolisafe">
<div class="columns is-marginless">
- <div class="column fucking-opl-shut-up">
- <img src="https://placehold.it/1024x10024">
+ <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 is-one-third">
+ <div class="column data-col is-one-third">
<div class="sticky">
<div class="divider is-lolisafe has-text-light">
File information
@@ -90,21 +93,16 @@
<span class="fake-input"><timeago :since="file.createdAt" /></span>
</div>
</b-field>
+
<div class="divider is-lolisafe has-text-light">
- Albums
+ Tags
</div>
+ <Taginfo :imageId="file.id" :imageTags="tags" />
<div class="divider is-lolisafe has-text-light">
- Tags
+ Albums
</div>
- <b-field label="Add some tags">
- <b-taginput
- v-model="tags"
- class="lolisafe"
- ellipsis
- icon="label"
- placeholder="Add a tag" />
- </b-field>
+ <Albuminfo :imageId="file.id" :imageAlbums="albums" />
</div>
</div>
</div>
@@ -114,17 +112,27 @@
<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: () => ({}),
},
- },
- data() {
- return {
- tags: [],
- };
+ albums: {
+ type: Array,
+ default: () => ([]),
+ },
+ tags: {
+ type: Array,
+ default: () => ([]),
+ },
},
computed: mapState(['images']),
methods: {
@@ -139,6 +147,9 @@ export default {
return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`;
},
+ isVideo(type) {
+ return type.startsWith('video');
+ },
},
};
</script>
@@ -176,4 +187,18 @@ export default {
.divider:first-child {
margin: 10px 0 25px;
}
+
+.col-vid {
+ width: 100%;
+}
+
+.image-col {
+ align-items: start;
+}
+
+.data-col {
+ @media screen and (min-width: 769px) {
+ padding-right: 1.5rem;
+ }
+}
</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..2054e6a
--- /dev/null
+++ b/src/site/components/image-modal/TagInfo.vue
@@ -0,0 +1,103 @@
+<template>
+ <b-field label="Add some tags">
+ <b-taginput
+ :value="selectedTags"
+ :data="filteredTags"
+ class="lolisafe 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: hsl(0, 0%, 100%);
+
+ .dropdown-item {
+ color: hsl(0, 0%, 29%);
+
+ &:hover {
+ color: hsl(0, 0%, 4%);
+ background-color: hsl(0, 0%, 90%);
+ }
+ }
+ }
+}
+</style>