feat: implement all-in-one file detail viewer, tag editor and album selection modal

This commit is contained in:
Zephyrrus 2020-07-20 23:01:45 +03:00
parent fe314a742f
commit 18bb451f79
9 changed files with 337 additions and 61 deletions

View File

@ -69,3 +69,15 @@ $sidebar-box-shadow: none;
$menu-item-color: $textColor;
$menu-item-hover-color: $textColorHighlight;
$menu-item-active-background-color: $backgroundAccent;
// dropdown
$dropdown-content-background-color: $background;
$dropdown-item-hover-background-color: $backgroundAccentLighter;
$dropdown-item-color: $textColor;
$dropdown-item-hover-color: $textColorHighlight;
$dropdown-item-active-color: $textColorHighlight;
$dropdown-item-active-background-color: hsl(171, 100%, 41%); // $primary
// tags
$tag-background-color: $base-2;
$tag-color: $textColor;

View File

@ -360,7 +360,7 @@ table.table {
margin: 0 !important;
}
.fucking-opl-shut-up {
.has-centered-items {
display: flex;
justify-content: center;
align-items: center;
@ -399,3 +399,7 @@ table.table {
}
}
}
.dropdown-content a {
text-decoration: none;
}

View File

@ -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>
<!-- <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> -->
<b-modal class="imageinfo-modal" :active.sync="isAlbumsModalActive">
<ImageInfo :file="modalData.file" :albums="modalData.albums" :tags="modalData.tags" />
</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>

View File

@ -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;

View File

@ -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>

View File

@ -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
</div>
<div class="divider is-lolisafe has-text-light">
Tags
</div>
<b-field label="Add some tags">
<b-taginput
v-model="tags"
class="lolisafe"
ellipsis
icon="label"
placeholder="Add a tag" />
</b-field>
<Taginfo :imageId="file.id" :imageTags="tags" />
<div class="divider is-lolisafe has-text-light">
Albums
</div>
<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>

View File

@ -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>

View File

@ -91,6 +91,20 @@ export const actions = {
commit('removeFile', fileId);
return response;
},
async addTag({ commit }, { fileId, tagName }) {
const response = await this.$axios.$post('file/tag/add', { fileId, tagName });
commit('addTagToFile', response.data);
return response;
},
async removeTag({ commit }, { fileId, tagName }) {
const response = await this.$axios.$post('file/tag/del', { fileId, tagName });
commit('removeTagFromFile', response.data);
return response;
},
};
@ -138,6 +152,19 @@ export const mutations = {
state.fileAlbumsMap[fileId].splice(foundIndex, 1);
}
},
addTagToFile(state, { fileId, tag }) {
if (!state.fileTagsMap[fileId]) return;
state.fileTagsMap[fileId].push(tag);
},
removeTagFromFile(state, { fileId, tag }) {
if (!state.fileTagsMap[fileId]) return;
const foundIndex = state.fileTagsMap[fileId].findIndex(({ id }) => id === tag.id);
if (foundIndex > -1) {
state.fileTagsMap[fileId].splice(foundIndex, 1);
}
},
resetState(state) {
Object.assign(state, getDefaultState());
},

40
src/site/store/tags.js Normal file
View File

@ -0,0 +1,40 @@
export const state = () => ({
tagsList: [],
});
export const actions = {
async fetch({ commit }) {
const response = await this.$axios.$get('tags');
commit('setTags', response.tags);
return response;
},
async createTag({ commit }, name) {
const response = await this.$axios.$post('tag/new', { name });
commit('addTag', response.data);
return response;
},
async deleteTag({ commit }, tagId) {
const response = await this.$axios.$delete(`tag/${tagId}`);
commit('deleteTag', response.data);
return response;
},
};
export const mutations = {
setTags(state, tags) {
state.tagsList = tags;
},
addTag(state, tag) {
state.tagsList.unshift(tag);
},
deleteTag(state, { id: tagId }) {
const foundIndex = state.tagsList.findIndex(({ id }) => id === tagId);
state.tagsList.splice(foundIndex, 1);
},
};