Labels: Add context menu
Signed-off-by: Michael Mayer <michael@liquidbytes.net>
This commit is contained in:
parent
afbbfbdc31
commit
e02cbe1b10
20 changed files with 445 additions and 54 deletions
|
@ -2,11 +2,12 @@ import PNotify from "./p-notify.vue";
|
|||
import PNavigation from "./p-navigation.vue";
|
||||
import PLoadingBar from "./p-loading-bar.vue";
|
||||
import PPhotoSearch from "./p-photo-search.vue";
|
||||
import PPhotoClipboard from "./p-photo-clipboard.vue";
|
||||
import PPhotoDetails from "./p-photo-details.vue";
|
||||
import PPhotoTiles from "./p-photo-tiles.vue";
|
||||
import PPhotoMosaic from "./p-photo-mosaic.vue";
|
||||
import PPhotoList from "./p-photo-list.vue";
|
||||
import PPhotoClipboard from "./p-photo-clipboard.vue";
|
||||
import PLabelClipboard from "./p-label-clipboard.vue";
|
||||
import PAlbumClipboard from "./p-album-clipboard.vue";
|
||||
import PAlbumToolbar from "./p-album-toolbar.vue";
|
||||
import PPhotoViewer from "./p-photo-viewer.vue";
|
||||
|
@ -25,6 +26,7 @@ components.install = (Vue) => {
|
|||
Vue.component("p-photo-list", PPhotoList);
|
||||
Vue.component("p-photo-search", PPhotoSearch);
|
||||
Vue.component("p-photo-clipboard", PPhotoClipboard);
|
||||
Vue.component("p-label-clipboard", PLabelClipboard);
|
||||
Vue.component("p-album-clipboard", PAlbumClipboard);
|
||||
Vue.component("p-album-toolbar", PAlbumToolbar);
|
||||
Vue.component("p-scroll-top", PScrollTop);
|
||||
|
|
145
frontend/src/component/p-label-clipboard.vue
Normal file
145
frontend/src/component/p-label-clipboard.vue
Normal file
|
@ -0,0 +1,145 @@
|
|||
<template>
|
||||
<div>
|
||||
<v-container fluid class="pa-0" v-if="selection.length > 0">
|
||||
<v-speed-dial
|
||||
fixed
|
||||
bottom
|
||||
right
|
||||
direction="top"
|
||||
v-model="expanded"
|
||||
transition="slide-y-reverse-transition"
|
||||
class="p-clipboard p-label-clipboard"
|
||||
id="t-clipboard"
|
||||
>
|
||||
<v-btn
|
||||
slot="activator"
|
||||
color="accent darken-2"
|
||||
dark
|
||||
fab
|
||||
class="p-label-clipboard-menu"
|
||||
>
|
||||
<v-icon v-if="selection.length === 0">menu</v-icon>
|
||||
<span v-else class="t-clipboard-count">{{ selection.length }}</span>
|
||||
</v-btn>
|
||||
|
||||
<!-- v-btn
|
||||
fab
|
||||
dark
|
||||
small
|
||||
:title="labels.download"
|
||||
color="download"
|
||||
@click.stop="download()"
|
||||
class="p-label-clipboard-download"
|
||||
:disabled="selection.length !== 1"
|
||||
>
|
||||
<v-icon>cloud_download</v-icon>
|
||||
</v-btn -->
|
||||
<v-btn
|
||||
fab
|
||||
dark
|
||||
small
|
||||
:title="labels.addToAlbum"
|
||||
color="album"
|
||||
:disabled="selection.length === 0"
|
||||
@click.stop="dialog.album = true"
|
||||
class="p-photo-clipboard-album"
|
||||
>
|
||||
<v-icon>folder</v-icon>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
fab
|
||||
dark
|
||||
small
|
||||
color="remove"
|
||||
:title="labels.delete"
|
||||
@click.stop="dialog.delete = true"
|
||||
:disabled="selection.length === 0"
|
||||
class="p-label-clipboard-delete"
|
||||
>
|
||||
<v-icon>delete</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
fab
|
||||
dark
|
||||
small
|
||||
color="accent"
|
||||
@click.stop="clearClipboard()"
|
||||
class="p-label-clipboard-clear"
|
||||
>
|
||||
<v-icon>clear</v-icon>
|
||||
</v-btn>
|
||||
</v-speed-dial>
|
||||
</v-container>
|
||||
<p-photo-album-dialog :show="dialog.album" @cancel="dialog.album = false"
|
||||
@confirm="addToAlbum"></p-photo-album-dialog>
|
||||
<p-label-delete-dialog :show="dialog.delete" @cancel="dialog.delete = false"
|
||||
@confirm="batchDelete"></p-label-delete-dialog>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import Api from "common/api";
|
||||
import Notify from "common/notify";
|
||||
|
||||
export default {
|
||||
name: 'p-label-clipboard',
|
||||
props: {
|
||||
selection: Array,
|
||||
refresh: Function,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
expanded: false,
|
||||
dialog: {
|
||||
delete: false,
|
||||
album: false,
|
||||
edit: false,
|
||||
},
|
||||
labels: {
|
||||
download: this.$gettext("Download"),
|
||||
delete: this.$gettext("Delete"),
|
||||
addToAlbum: this.$gettext("Add to album"),
|
||||
removeFromAlbum: this.$gettext("Remove"),
|
||||
},
|
||||
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
clearClipboard() {
|
||||
this.selection.splice(0, this.selection.length);
|
||||
this.expanded = false;
|
||||
},
|
||||
addToAlbum(albumUUID) {
|
||||
this.dialog.album = false;
|
||||
|
||||
Api.post(`albums/${albumUUID}/photos`, {"labels": this.selection}).then(() => this.onAdded());
|
||||
},
|
||||
onAdded() {
|
||||
this.clearClipboard();
|
||||
},
|
||||
batchDelete() {
|
||||
this.dialog.delete = false;
|
||||
|
||||
Api.post("batch/labels/delete", {"labels": this.selection}).then(this.onDeleted.bind(this));
|
||||
},
|
||||
onDeleted() {
|
||||
Notify.success(this.$gettext("Labels deleted"));
|
||||
this.clearClipboard();
|
||||
},
|
||||
download() {
|
||||
if(this.selection.length !== 1) {
|
||||
Notify.error(this.$gettext("You can only download one label"));
|
||||
return;
|
||||
}
|
||||
|
||||
this.onDownload(`/api/v1/labels/${this.selection[0]}/download`);
|
||||
|
||||
this.expanded = false;
|
||||
},
|
||||
onDownload(path) {
|
||||
Notify.success(this.$gettext("Downloading..."));
|
||||
window.open(path, "_blank");
|
||||
},
|
||||
}
|
||||
};
|
||||
</script>
|
|
@ -74,7 +74,6 @@ main {
|
|||
|
||||
#photoprism main .p-inline-edit a,
|
||||
#photoprism main .p-inline-edit a span {
|
||||
color: #333333;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
|
|
|
@ -20,8 +20,12 @@
|
|||
|
||||
#photoprism .p-photo-tiles .p-photo-select,
|
||||
#photoprism .p-photo-details .p-photo-select,
|
||||
#photoprism .p-photo-mosaic .p-photo-select,
|
||||
#photoprism .p-albums-details .p-album-select {
|
||||
#photoprism .p-photo-mosaic .p-photo-select {
|
||||
right: 4px; bottom: 4px;
|
||||
}
|
||||
|
||||
#photoprism .p-albums-details .p-album-select,
|
||||
#photoprism .p-labels-details .p-label-select {
|
||||
right: 4px; bottom: 4px;
|
||||
}
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@ import PPhotoAlbumDialog from "./p-photo-album-dialog.vue";
|
|||
import PPhotoEditDialog from "./p-photo-edit-dialog.vue";
|
||||
import PPhotoShareDialog from "./p-photo-share-dialog.vue";
|
||||
import PAlbumDeleteDialog from "./p-album-delete-dialog.vue";
|
||||
import PLabelDeleteDialog from "./p-label-delete-dialog.vue";
|
||||
import PUploadDialog from "./p-upload-dialog.vue";
|
||||
|
||||
const dialogs = {};
|
||||
|
@ -13,6 +14,7 @@ dialogs.install = (Vue) => {
|
|||
Vue.component("p-photo-edit-dialog", PPhotoEditDialog);
|
||||
Vue.component("p-photo-share-dialog", PPhotoShareDialog);
|
||||
Vue.component("p-album-delete-dialog", PAlbumDeleteDialog);
|
||||
Vue.component("p-label-delete-dialog", PLabelDeleteDialog);
|
||||
Vue.component("p-upload-dialog", PUploadDialog);
|
||||
};
|
||||
|
||||
|
|
43
frontend/src/dialog/p-label-delete-dialog.vue
Normal file
43
frontend/src/dialog/p-label-delete-dialog.vue
Normal file
|
@ -0,0 +1,43 @@
|
|||
<template>
|
||||
<v-dialog lazy v-model="show" persistent max-width="350" class="p-label-delete-dialog" @keydown.esc="cancel">
|
||||
<v-card raised elevation="24">
|
||||
<v-container fluid class="pb-2 pr-2 pl-2">
|
||||
<v-layout row wrap>
|
||||
<v-flex xs3 text-xs-center>
|
||||
<v-icon size="54" color="grey lighten-1">delete_outline</v-icon>
|
||||
</v-flex>
|
||||
<v-flex xs9 text-xs-left align-self-center>
|
||||
<div class="subheading pr-1"><translate>Are you sure you want to delete these labels?</translate></div>
|
||||
</v-flex>
|
||||
<v-flex xs12 text-xs-right class="pt-3">
|
||||
<v-btn @click.stop="cancel" depressed color="grey lighten-3" class="p-photo-dialog-cancel">
|
||||
<translate>Cancel</translate>
|
||||
</v-btn>
|
||||
<v-btn color="blue-grey lighten-2" depressed dark @click.stop="confirm"
|
||||
class="p-photo-dialog-confirm"><translate>Delete</translate>
|
||||
</v-btn>
|
||||
</v-flex>
|
||||
</v-layout>
|
||||
</v-container>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
name: 'p-label-delete-dialog',
|
||||
props: {
|
||||
show: Boolean,
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
methods: {
|
||||
cancel() {
|
||||
this.$emit('cancel');
|
||||
},
|
||||
confirm() {
|
||||
this.$emit('confirm');
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -50,7 +50,7 @@
|
|||
</div>
|
||||
</v-card-title>
|
||||
</v-card>
|
||||
<v-layout row wrap class="p-results">
|
||||
<v-layout row wrap class="p-album-results">
|
||||
<v-flex
|
||||
v-for="(album, index) in results"
|
||||
:key="index"
|
||||
|
@ -60,7 +60,8 @@
|
|||
<v-hover>
|
||||
<v-card tile class="accent lighten-3"
|
||||
slot-scope="{ hover }"
|
||||
:class="selection.includes(album.AlbumUUID) ? 'elevation-10 ma-0' : 'elevation-0 ma-1'"
|
||||
:dark="selection.includes(album.AlbumUUID)"
|
||||
:class="selection.includes(album.AlbumUUID) ? 'elevation-10 ma-0 accent darken-1 white--text' : 'elevation-0 ma-1 accent lighten-3'"
|
||||
:to="{name: 'album', params: {uuid: album.AlbumUUID, slug: album.AlbumSlug}}"
|
||||
>
|
||||
<v-img
|
||||
|
@ -80,7 +81,7 @@
|
|||
</v-layout>
|
||||
|
||||
<v-btn v-if="hover || selection.length > 0" :flat="!hover" :ripple="false"
|
||||
icon small absolute
|
||||
icon large absolute
|
||||
class="p-album-select"
|
||||
@click.stop.prevent="toggleSelection(album.AlbumUUID)">
|
||||
<v-icon v-if="selection.includes(album.AlbumUUID)" color="white">check_circle
|
||||
|
@ -328,6 +329,13 @@
|
|||
this.selection.push(uuid)
|
||||
}
|
||||
},
|
||||
removeSelection(uuid) {
|
||||
const pos = this.selection.indexOf(uuid);
|
||||
|
||||
if (pos !== -1) {
|
||||
this.selection.splice(pos, 1);
|
||||
}
|
||||
},
|
||||
onUpdate(ev, data) {
|
||||
if (!this.listen) return;
|
||||
|
||||
|
@ -356,9 +364,12 @@
|
|||
for (let i = 0; i < data.entities.length; i++) {
|
||||
const uuid = data.entities[i];
|
||||
const index = this.results.findIndex((m) => m.AlbumUUID === uuid);
|
||||
|
||||
if (index >= 0) {
|
||||
this.results.splice(index, 1);
|
||||
}
|
||||
|
||||
this.removeSelection(uuid)
|
||||
}
|
||||
|
||||
break;
|
||||
|
|
|
@ -36,10 +36,12 @@
|
|||
<v-progress-linear color="secondary-dark" :indeterminate="true"></v-progress-linear>
|
||||
</v-container>
|
||||
<v-container fluid class="pa-0" v-else>
|
||||
<p-label-clipboard :refresh="refresh" :selection="selection"></p-label-clipboard>
|
||||
|
||||
<p-scroll-top></p-scroll-top>
|
||||
|
||||
<v-container grid-list-xs fluid class="pa-2 p-labels p-labels-details">
|
||||
<v-card v-if="results.length === 0" class="p-labels-empty secondary-light lighten-1" flat>
|
||||
<v-card v-if="results.length === 0" class="p-labels-empty secondary-light lighten-1 ma-1" flat>
|
||||
<v-card-title primary-title>
|
||||
<div>
|
||||
<h3 class="title mb-3">
|
||||
|
@ -51,7 +53,7 @@
|
|||
</div>
|
||||
</v-card-title>
|
||||
</v-card>
|
||||
<v-layout row wrap>
|
||||
<v-layout row wrap class="p-label-results">
|
||||
<v-flex
|
||||
v-for="(label, index) in results"
|
||||
:key="index"
|
||||
|
@ -59,7 +61,10 @@
|
|||
xs6 sm4 md3 lg2 d-flex
|
||||
>
|
||||
<v-hover>
|
||||
<v-card tile class="elevation-0 ma-1 accent lighten-3"
|
||||
<v-card tile class="accent lighten-3"
|
||||
slot-scope="{ hover }"
|
||||
:dark="selection.includes(label.LabelUUID)"
|
||||
:class="selection.includes(label.LabelUUID) ? 'elevation-10 ma-0 accent darken-1 white--text' : 'elevation-0 ma-1 accent lighten-3'"
|
||||
:to="{name: 'photos', query: {q: 'label:' + label.LabelSlug}}">
|
||||
<v-img
|
||||
:src="label.getThumbnailUrl('tile_500')"
|
||||
|
@ -76,6 +81,15 @@
|
|||
<v-progress-circular indeterminate
|
||||
color="accent lighten-5"></v-progress-circular>
|
||||
</v-layout>
|
||||
|
||||
<v-btn v-if="hover || selection.length > 0" :flat="!hover" :ripple="false"
|
||||
icon large absolute
|
||||
class="p-label-select"
|
||||
@click.stop.prevent="toggleSelection(label.LabelUUID)">
|
||||
<v-icon v-if="selection.includes(label.LabelUUID)" color="white">check_circle
|
||||
</v-icon>
|
||||
<v-icon v-else color="accent lighten-3">radio_button_off</v-icon>
|
||||
</v-btn>
|
||||
</v-img>
|
||||
|
||||
<v-card-actions @click.stop.prevent="">
|
||||
|
@ -120,6 +134,7 @@
|
|||
|
||||
<script>
|
||||
import Label from "model/label";
|
||||
import Event from "pubsub-js";
|
||||
|
||||
export default {
|
||||
name: 'p-page-labels',
|
||||
|
@ -146,12 +161,16 @@
|
|||
const settings = {};
|
||||
|
||||
return {
|
||||
subscriptions: [],
|
||||
listen: false,
|
||||
dirty: false,
|
||||
results: [],
|
||||
scrollDisabled: true,
|
||||
loading: true,
|
||||
pageSize: 24,
|
||||
offset: 0,
|
||||
selection: this.$clipboard.selection,
|
||||
page: 0,
|
||||
selection: [],
|
||||
settings: settings,
|
||||
filter: filter,
|
||||
lastFilter: {},
|
||||
|
@ -179,28 +198,55 @@
|
|||
this.filter.q = '';
|
||||
this.updateQuery();
|
||||
},
|
||||
toggleSelection(uuid) {
|
||||
const pos = this.selection.indexOf(uuid);
|
||||
|
||||
if (pos !== -1) {
|
||||
this.selection.splice(pos, 1);
|
||||
} else {
|
||||
this.selection.push(uuid)
|
||||
}
|
||||
},
|
||||
removeSelection(uuid) {
|
||||
const pos = this.selection.indexOf(uuid);
|
||||
|
||||
if (pos !== -1) {
|
||||
this.selection.splice(pos, 1);
|
||||
}
|
||||
},
|
||||
loadMore() {
|
||||
if (this.scrollDisabled) return;
|
||||
|
||||
this.scrollDisabled = true;
|
||||
this.listen = false;
|
||||
|
||||
this.offset += this.pageSize;
|
||||
const count = this.dirty ? (this.page + 2) * this.pageSize : this.pageSize;
|
||||
const offset = this.dirty ? 0 : this.offset;
|
||||
|
||||
const params = {
|
||||
count: this.pageSize,
|
||||
offset: this.offset,
|
||||
count: count,
|
||||
offset: offset,
|
||||
};
|
||||
|
||||
Object.assign(params, this.lastFilter);
|
||||
|
||||
Label.search(params).then(response => {
|
||||
this.results = this.results.concat(response.models);
|
||||
this.page++;
|
||||
this.offset += this.pageSize;
|
||||
|
||||
this.scrollDisabled = (response.models.length < this.pageSize);
|
||||
this.results = this.dirty ? response.models : this.results.concat(response.models);
|
||||
|
||||
this.scrollDisabled = (response.models.length < count);
|
||||
|
||||
if (this.scrollDisabled) {
|
||||
this.$notify.info(this.$gettext('All ') + this.results.length + this.$gettext(' labels loaded'));
|
||||
}
|
||||
}).catch(() => {
|
||||
this.scrollDisabled = false;
|
||||
}).finally(() => {
|
||||
this.dirty = false;
|
||||
this.loading = false;
|
||||
this.listen = true;
|
||||
});
|
||||
},
|
||||
updateQuery() {
|
||||
|
@ -256,12 +302,14 @@
|
|||
Object.assign(this.lastFilter, this.filter);
|
||||
|
||||
this.offset = 0;
|
||||
this.page = 0;
|
||||
this.loading = true;
|
||||
this.listen = false;
|
||||
|
||||
const params = this.searchParams();
|
||||
|
||||
Label.search(params).then(response => {
|
||||
this.loading = false;
|
||||
this.offset = this.pageSize;
|
||||
|
||||
this.results = response.models;
|
||||
|
||||
|
@ -274,11 +322,66 @@
|
|||
|
||||
this.$nextTick(() => this.$emit("scrollRefresh"));
|
||||
}
|
||||
}).catch(() => this.loading = false);
|
||||
}).finally(() => {
|
||||
this.dirty = false;
|
||||
this.loading = false;
|
||||
this.listen = true;
|
||||
});
|
||||
},
|
||||
onUpdate(ev, data) {
|
||||
if (!this.listen) return;
|
||||
|
||||
if (!data || !data.entities) {
|
||||
return
|
||||
}
|
||||
|
||||
const type = ev.split('.')[1];
|
||||
|
||||
switch (type) {
|
||||
case 'updated':
|
||||
for (let i = 0; i < data.entities.length; i++) {
|
||||
const values = data.entities[i];
|
||||
const model = this.results.find((m) => m.LabelUUID === values.LabelUUID);
|
||||
|
||||
for (let key in values) {
|
||||
if (values.hasOwnProperty(key)) {
|
||||
model[key] = values[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'deleted':
|
||||
this.dirty = true;
|
||||
|
||||
for (let i = 0; i < data.entities.length; i++) {
|
||||
const uuid = data.entities[i];
|
||||
const index = this.results.findIndex((m) => m.LabelUUID === uuid);
|
||||
|
||||
if (index >= 0) {
|
||||
this.results.splice(index, 1);
|
||||
}
|
||||
|
||||
this.removeSelection(uuid)
|
||||
}
|
||||
|
||||
break;
|
||||
case 'created':
|
||||
this.dirty = true;
|
||||
break;
|
||||
default:
|
||||
console.warn("unexpected event type", ev);
|
||||
}
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.search();
|
||||
|
||||
this.subscriptions.push(Event.subscribe("labels", (ev, data) => this.onUpdate(ev, data)));
|
||||
},
|
||||
destroyed() {
|
||||
for(let i = 0; i < this.subscriptions.length; i++) {
|
||||
Event.unsubscribe(this.subscriptions[i]);
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
|
@ -244,37 +244,35 @@ func AddPhotosToAlbum(router *gin.RouterGroup, conf *config.Config) {
|
|||
return
|
||||
}
|
||||
|
||||
var f form.PhotoUUIDs
|
||||
var f form.Selection
|
||||
|
||||
if err := c.BindJSON(&f); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": txt.UcFirst(err.Error())})
|
||||
return
|
||||
}
|
||||
|
||||
if len(f.Photos) == 0 {
|
||||
log.Error("no photos selected")
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": txt.UcFirst("no photos selected")})
|
||||
return
|
||||
}
|
||||
|
||||
uuid := c.Param("uuid")
|
||||
q := query.New(conf.OriginalsPath(), conf.Db())
|
||||
a, err := q.FindAlbumByUUID(c.Param("uuid"))
|
||||
a, err := q.FindAlbumByUUID(uuid)
|
||||
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, ErrAlbumNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
photos, err := q.PhotoSelection(f)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf("album: %s", err)
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": txt.UcFirst(err.Error())})
|
||||
return
|
||||
}
|
||||
|
||||
db := conf.Db()
|
||||
var added []*entity.PhotoAlbum
|
||||
var failed []string
|
||||
|
||||
for _, photoUUID := range f.Photos {
|
||||
if p, err := q.FindPhotoByUUID(photoUUID); err != nil {
|
||||
failed = append(failed, photoUUID)
|
||||
} else {
|
||||
added = append(added, entity.NewPhotoAlbum(p.PhotoUUID, a.AlbumUUID).FirstOrCreate(db))
|
||||
}
|
||||
for _, p := range photos {
|
||||
added = append(added, entity.NewPhotoAlbum(p.PhotoUUID, a.AlbumUUID).FirstOrCreate(db))
|
||||
}
|
||||
|
||||
if len(added) == 1 {
|
||||
|
@ -283,7 +281,7 @@ func AddPhotosToAlbum(router *gin.RouterGroup, conf *config.Config) {
|
|||
event.Success(fmt.Sprintf("%d photos added to %s", len(added), a.AlbumName))
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "photos added to album", "album": a, "added": added, "failed": failed})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "photos added to album", "album": a, "added": added})
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -295,7 +293,7 @@ func RemovePhotosFromAlbum(router *gin.RouterGroup, conf *config.Config) {
|
|||
return
|
||||
}
|
||||
|
||||
var f form.PhotoUUIDs
|
||||
var f form.Selection
|
||||
|
||||
if err := c.BindJSON(&f); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": txt.UcFirst(err.Error())})
|
||||
|
|
|
@ -97,9 +97,10 @@ func LikeLabel(router *gin.RouterGroup, conf *config.Config) {
|
|||
return
|
||||
}
|
||||
|
||||
id := c.Param("uuid")
|
||||
q := query.New(conf.OriginalsPath(), conf.Db())
|
||||
|
||||
label, err := q.FindLabelByUUID(c.Param("uuid"))
|
||||
label, err := q.FindLabelByUUID(id)
|
||||
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(404, gin.H{"error": txt.UcFirst(err.Error())})
|
||||
|
@ -115,6 +116,8 @@ func LikeLabel(router *gin.RouterGroup, conf *config.Config) {
|
|||
})
|
||||
}
|
||||
|
||||
PublishLabelEvent(EntityUpdated, id, c, q)
|
||||
|
||||
c.JSON(http.StatusOK, http.Response{})
|
||||
})
|
||||
}
|
||||
|
@ -130,9 +133,10 @@ func DislikeLabel(router *gin.RouterGroup, conf *config.Config) {
|
|||
return
|
||||
}
|
||||
|
||||
id := c.Param("uuid")
|
||||
q := query.New(conf.OriginalsPath(), conf.Db())
|
||||
|
||||
label, err := q.FindLabelByUUID(c.Param("uuid"))
|
||||
label, err := q.FindLabelByUUID(id)
|
||||
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(404, gin.H{"error": txt.UcFirst(err.Error())})
|
||||
|
@ -148,6 +152,8 @@ func DislikeLabel(router *gin.RouterGroup, conf *config.Config) {
|
|||
})
|
||||
}
|
||||
|
||||
PublishLabelEvent(EntityUpdated, id, c, q)
|
||||
|
||||
c.JSON(http.StatusOK, http.Response{})
|
||||
})
|
||||
}
|
||||
|
|
|
@ -25,7 +25,7 @@ func BatchPhotosArchive(router *gin.RouterGroup, conf *config.Config) {
|
|||
|
||||
start := time.Now()
|
||||
|
||||
var f form.PhotoUUIDs
|
||||
var f form.Selection
|
||||
|
||||
if err := c.BindJSON(&f); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": txt.UcFirst(err.Error())})
|
||||
|
@ -66,7 +66,7 @@ func BatchPhotosRestore(router *gin.RouterGroup, conf *config.Config) {
|
|||
|
||||
start := time.Now()
|
||||
|
||||
var f form.PhotoUUIDs
|
||||
var f form.Selection
|
||||
|
||||
if err := c.BindJSON(&f); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": txt.UcFirst(err.Error())})
|
||||
|
@ -106,7 +106,7 @@ func BatchAlbumsDelete(router *gin.RouterGroup, conf *config.Config) {
|
|||
return
|
||||
}
|
||||
|
||||
var f form.AlbumUUIDs
|
||||
var f form.Selection
|
||||
|
||||
if err := c.BindJSON(&f); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": txt.UcFirst(err.Error())})
|
||||
|
@ -146,7 +146,7 @@ func BatchPhotosPrivate(router *gin.RouterGroup, conf *config.Config) {
|
|||
|
||||
start := time.Now()
|
||||
|
||||
var f form.PhotoUUIDs
|
||||
var f form.Selection
|
||||
|
||||
if err := c.BindJSON(&f); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": txt.UcFirst(err.Error())})
|
||||
|
@ -181,7 +181,7 @@ func BatchPhotosStory(router *gin.RouterGroup, conf *config.Config) {
|
|||
|
||||
start := time.Now()
|
||||
|
||||
var f form.PhotoUUIDs
|
||||
var f form.Selection
|
||||
|
||||
if err := c.BindJSON(&f); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": txt.UcFirst(err.Error())})
|
||||
|
@ -207,3 +207,40 @@ func BatchPhotosStory(router *gin.RouterGroup, conf *config.Config) {
|
|||
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("photos marked as story in %s", elapsed)})
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/v1/batch/labels/delete
|
||||
func BatchLabelsDelete(router *gin.RouterGroup, conf *config.Config) {
|
||||
router.POST("/batch/labels/delete", func(c *gin.Context) {
|
||||
if Unauthorized(c, conf) {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, ErrUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var f form.Selection
|
||||
|
||||
if err := c.BindJSON(&f); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": txt.UcFirst(err.Error())})
|
||||
return
|
||||
}
|
||||
|
||||
if len(f.Labels) == 0 {
|
||||
log.Error("no labels selected")
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": txt.UcFirst("no labels selected")})
|
||||
return
|
||||
}
|
||||
|
||||
log.Infof("deleting labels: %#v", f.Labels)
|
||||
|
||||
db := conf.Db()
|
||||
|
||||
db.Where("label_uuid IN (?)", f.Labels).Delete(&entity.Label{})
|
||||
|
||||
event.Publish("config.updated", event.Data(conf.ClientConfig()))
|
||||
|
||||
event.Publish("labels.deleted", event.Data{
|
||||
"entities": f.Labels,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("labels deleted")})
|
||||
})
|
||||
}
|
|
@ -28,7 +28,7 @@ func CreateZip(router *gin.RouterGroup, conf *config.Config) {
|
|||
return
|
||||
}
|
||||
|
||||
var f form.PhotoUUIDs
|
||||
var f form.Selection
|
||||
start := time.Now()
|
||||
|
||||
if err := c.BindJSON(&f); err != nil {
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
package form
|
||||
|
||||
type AlbumUUIDs struct {
|
||||
Albums []string `json:"albums"`
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
package form
|
||||
|
||||
type PhotoUUIDs struct {
|
||||
Photos []string `json:"photos"`
|
||||
}
|
15
internal/form/selection.go
Normal file
15
internal/form/selection.go
Normal file
|
@ -0,0 +1,15 @@
|
|||
package form
|
||||
|
||||
type Selection struct {
|
||||
Photos []string `json:"photos"`
|
||||
Albums []string `json:"albums"`
|
||||
Labels []string `json:"labels"`
|
||||
}
|
||||
|
||||
func (f Selection) Empty() bool {
|
||||
if len(f.Photos) > 0 || len(f.Albums) > 0 || len(f.Labels) > 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
|
@ -33,5 +33,7 @@ func (l Location) Name() (result string) {
|
|||
result = result[:i]
|
||||
}
|
||||
|
||||
result = strings.SplitN(result, "/", 2)[0]
|
||||
|
||||
return txt.Title(strings.TrimSpace(result))
|
||||
}
|
|
@ -4,6 +4,7 @@ import (
|
|||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
gc "github.com/patrickmn/go-cache"
|
||||
"github.com/photoprism/photoprism/pkg/s2"
|
||||
|
@ -89,7 +90,7 @@ func (l Location) CellID() (result string) {
|
|||
}
|
||||
|
||||
func (l Location) Name() (result string) {
|
||||
return l.LocName
|
||||
return strings.SplitN(l.LocName, "/", 2)[0]
|
||||
}
|
||||
|
||||
func (l Location) Category() (result string) {
|
||||
|
|
32
internal/query/selection.go
Normal file
32
internal/query/selection.go
Normal file
|
@ -0,0 +1,32 @@
|
|||
package query
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/internal/form"
|
||||
)
|
||||
|
||||
// PhotoSelection returns all selected photos.
|
||||
func (s *Repo) PhotoSelection(f form.Selection) (results []entity.Photo, err error) {
|
||||
if f.Empty() {
|
||||
return results, errors.New("no photos selected")
|
||||
}
|
||||
|
||||
q := s.db.NewScope(nil).DB()
|
||||
|
||||
q = q.Table("photos").
|
||||
Select("photos.*").
|
||||
Joins("LEFT JOIN photos_labels ON photos_labels.photo_id = photos.id").
|
||||
Joins("LEFT JOIN labels ON photos_labels.label_id = labels.id AND labels.deleted_at IS NULL").
|
||||
Where("photos.deleted_at IS NULL").
|
||||
Group("photos.id")
|
||||
|
||||
q = q.Where("photos.photo_uuid IN (?) OR labels.label_uuid IN (?)", f.Photos, f.Labels)
|
||||
|
||||
if result := q.Scan(&results); result.Error != nil {
|
||||
return results, result.Error
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
|
@ -55,6 +55,7 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
|
|||
api.BatchPhotosPrivate(v1, conf)
|
||||
api.BatchPhotosStory(v1, conf)
|
||||
api.BatchAlbumsDelete(v1, conf)
|
||||
api.BatchLabelsDelete(v1, conf)
|
||||
|
||||
api.GetAlbum(v1, conf)
|
||||
api.CreateAlbum(v1, conf)
|
||||
|
|
Loading…
Reference in a new issue