feat: implement all-in-one file detail viewer, tag editor and album selection modal
This commit is contained in:
parent
fe314a742f
commit
18bb451f79
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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());
|
||||
},
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
};
|
Loading…
Reference in New Issue